Kaynağa Gözat

1.文生图通用接口新增传入图片数量,更改返回格式2.新增gemini大模型的公共方法

lh 1 ay önce
ebeveyn
işleme
455911257b

+ 7 - 0
app/Consts/BaseConst.php

@@ -86,6 +86,13 @@ class BaseConst
     const GPT_IMAGE2_MODELS = ['gpt-image-2'];
     const NANO_BANANA_MODELS = ['NanoBanana2', 'NanoBananaPro', 'GptImage2'];
     const GPT_TEXT_MODELS = ['gpt-5.4', 'gpt-5.6-terra'];
+    const GEMINI_TEXT_MODELS = [
+        'gemini-3-pro-preview',
+        'gemini-3-flash-preview',
+        'gemini-3.1-pro-preview',
+        'gemini-3.1-flash-image-preview-t',
+        'gemini-3.6-flash'
+    ];
     // 火山写作奖励计划接入点(使用该接入点可在第二天根据前一天使用量获取免费额度)
     const MODELS_MAP = [
         'doubao-seed-2-0-pro-260215'        => 'ep-20260422093628-pv5vj',

+ 11 - 0
app/Http/Controllers/Anime/AnimeController.php

@@ -1223,6 +1223,17 @@ class AnimeController extends BaseController
         $data = $request->all();
 
         $result = $this->AnimeService->generateImg($data);
+
+        // 多张图片:data为首图URL,image_urls为全部图片URL数组
+        if (is_array($result) && isset($result['img_url']) && isset($result['image_urls'])) {
+            return $this->respond([
+                'msg'        => '',
+                'code'       => 0,
+                'data'       => $result['img_url'],
+                'image_urls' => $result['image_urls']
+            ]);
+        }
+
         return $this->success($result);
     }
 

+ 25 - 2
app/Services/Anime/AnimeService.php

@@ -5871,6 +5871,7 @@ class AnimeService
             $start_time = time();
             $timeout = 300; // 5分钟
             $img_url = '';
+            $img_urls = [];
             
             while (time() - $start_time < $timeout) {
                 sleep(3);
@@ -5881,7 +5882,14 @@ class AnimeService
                     $task = MpGeneratePicTask::where('id', $task_id)->first();
                     
                     if ($task->status === 'success' && $task->result_url) {
-                        $img_url = self::extractResultUrls($task->result_url, $inner_prompt_type);
+                        $resultUrls = is_array($task->result_url) ? $task->result_url : json_decode((string)$task->result_url, true);
+                        $resultUrls = is_array($resultUrls) ? array_values(array_filter($resultUrls)) : [];
+                        if (!empty($resultUrls)) {
+                            $img_urls = $resultUrls;
+                            $img_url = $resultUrls[0];
+                        } else {
+                            $img_url = (string)$task->result_url;
+                        }
                         break;
                     } elseif ($task->status === 'failed') {
                         $errorMsg = mapErrorMessage($task->error_message ?? '');
@@ -5900,7 +5908,14 @@ class AnimeService
                             'result_json' => $statusInfo['result_json'] ?? []
                         ]);
                         
-                        $img_url = self::extractResultUrls($statusInfo['result_url'], $inner_prompt_type);
+                        $resultUrls = is_array($statusInfo['result_url']) ? $statusInfo['result_url'] : json_decode((string)$statusInfo['result_url'], true);
+                        $resultUrls = is_array($resultUrls) ? array_values(array_filter($resultUrls)) : [];
+                        if (!empty($resultUrls)) {
+                            $img_urls = $resultUrls;
+                            $img_url = $resultUrls[0];
+                        } else {
+                            $img_url = (string)$statusInfo['result_url'];
+                        }
                         break;
                     } elseif ($statusInfo['status'] === 'failed') {
                         $task->updateStatus('failed', [
@@ -5920,6 +5935,14 @@ class AnimeService
                 Utils::throwError('20003:图片生成超时,请稍后重试');
             }
             
+            // 多张图片返回全部URL数组(配合控制器输出image_urls字段)
+            if (count($img_urls) > 1) {
+                return [
+                    'img_url'    => $img_url,
+                    'image_urls' => $img_urls
+                ];
+            }
+            
             return $img_url;
             
         } catch (\Exception $e) {

+ 858 - 0
app/Services/DeepSeek/DeepSeekService.php

@@ -28,6 +28,7 @@ class DeepSeekService
     private $headers;
     private $valid_text_models;
     private $gpt_text_models;
+    private $gemini_text_models;
     protected $aiImageGenerationService;
 
     public function __construct(AIImageGenerationService $aiImageGenerationService) {
@@ -42,6 +43,8 @@ class DeepSeekService
         $this->valid_text_models = DB::table('mp_text_models')->where('is_enabled', 1)->pluck('model')->toArray();
         // GPT文本模型列表
         $this->gpt_text_models = BaseConst::GPT_TEXT_MODELS;
+        // Gemini文本模型列表
+        $this->gemini_text_models = BaseConst::GEMINI_TEXT_MODELS;
         $this->sys_message = [
                 'role'    => 'system',
                 'content' => "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容完成示例格式的内容输出(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分集剧本>,每个板块之间必须使用###分隔,#不能多也不能少;同时板块之间需满足以下要求:\n
@@ -254,6 +257,9 @@ class DeepSeekService
         } else if (in_array($model, $this->gpt_text_models)) {
             // GPT 模型使用 TokenRouter API
             return $this->gpt54StreamResponse($post_data);
+        } else if (in_array($model, $this->gemini_text_models)) {
+            // Gemini 模型使用 Gemini API
+            return $this->geminiStreamResponse($post_data, $model);
         } else if (in_array($model, $this->valid_text_models)) {
             // 火山引擎支持的模型使用火山引擎 API
             return $this->volcEngineChatCompletion($post_data);
@@ -606,6 +612,19 @@ class DeepSeekService
                     'model' => $model,
                     'finish_reason' => 'stop'
                 ];
+            } else if (in_array($model, $this->gemini_text_models)) {
+                // Gemini 模型使用 Gemini API(非流式)
+                $result = $this->geminiChatOnly($post_data, $model);
+
+                dLog('deepseek')->info('newGenerateText使用Gemini模型', ['requested_model' => $model]);
+
+                $aiResult = [
+                    'content' => $result['fullContent'],
+                    'reasoning_content' => $result['fullReasoningContent'] ?? '',
+                    'usage' => $result['usage'],
+                    'model' => $model,
+                    'finish_reason' => $result['finish_reason'] ?? 'stop'
+                ];
             } else if (in_array($model, $this->valid_text_models)) {
                 // 火山引擎支持的模型使用火山引擎 API(非流式)
                 $post_data['stream'] = false;
@@ -721,6 +740,818 @@ class DeepSeekService
     }
 
     /**
+     * 通用 Gemini 文生文方法(支持流式和非流式)
+     *
+     * 与 chatWithFileStream / newGenerateText 相同的入参格式:
+     *   model            模型ID(gemini-3-flash-preview 等,默认 gemini-3-flash-preview)
+     *   messages         OpenAI 风格消息数组(可选,与 system_prompt/prompt 二选一)
+     *   system_prompt    系统提示词(可选)
+     *   prompt           用户提示词(可选)
+     *   images           上传的图片/视频文件列表(可选,自动转 inlineData,超限自动用 ffmpeg 压缩)
+     *   image_urls       图片/视频URL列表(可选,同上)
+     *   temperature      温度(可选,默认 1)
+     *   top_p            核采样(可选)
+     *   max_tokens       最大输出 token(可选,默认 8192)
+     *   response_format  text/json(可选)
+     *   thinking         enabled/disabled(可选,pro 系列默认 enabled)
+     *   thinking_budget  思考预算(可选,默认 20)
+     *   stream           是否流式(可选,默认 false)
+     *
+     * @param array $data 请求参数
+     * @return array|\Generator 非流式返回数组;流式返回生成器(yield type=content/reasoning/done)
+     */
+    public function geminiGenerateText($data) {
+        $model = getProp($data, 'model', 'gemini-3-flash-preview');
+        $isStream = (bool)getProp($data, 'stream', false);
+
+        if (!in_array($model, $this->gemini_text_models)) {
+            Utils::throwError('20003:不支持的Gemini模型: ' . $model);
+        }
+
+        $requestData = $this->buildGeminiRequestBody($data, $model);
+
+        dLog('deepseek')->info('Gemini请求参数', ['model' => $model, 'stream' => $isStream]);
+
+        if ($isStream) {
+            // 流式返回生成器
+            return $this->geminiStreamResponse($requestData, $model);
+        }
+
+        // 非流式
+        return $this->geminiChatOnly($requestData, $model);
+    }
+
+    /**
+     * Gemini 多媒体文件大小上限(原始字节,base64 后约 19MB,留出 20MB 请求体余量)
+     */
+    private function geminiMediaMaxBytes() {
+        return 14 * 1024 * 1024;
+    }
+
+    /**
+     * 构建 Gemini 请求体(将 OpenAI 风格消息转换为 Gemini contents 格式)
+     */
+    private function buildGeminiRequestBody($data, $model) {
+        $messages = getProp($data, 'messages', []);
+
+        // 如果没有提供 messages,则根据 system_prompt、prompt 和 images 构建
+        if (empty($messages)) {
+            $systemPrompt = getProp($data, 'system_prompt', '');
+            $prompt = getProp($data, 'prompt', '');
+            $images = getProp($data, 'images', []);
+            $imageUrls = getProp($data, 'image_urls', []);
+
+            $messages = [];
+            if (!empty($systemPrompt)) {
+                $messages[] = [
+                    'role'    => 'system',
+                    'content' => $systemPrompt
+                ];
+            }
+            if (!empty($prompt) || !empty($images) || !empty($imageUrls)) {
+                $userMessage = ['role' => 'user', 'content' => []];
+                if (!empty($prompt)) {
+                    $userMessage['content'][] = ['type' => 'text', 'text' => $prompt];
+                }
+                if (!empty($images)) {
+                    if (!is_array($images)) {
+                        $images = [$images];
+                    }
+                    foreach ($images as $image) {
+                        $inlineData = $this->processMediaToInlineData($image);
+                        if ($inlineData) {
+                            $userMessage['content'][] = [
+                                'type'        => 'image',
+                                'inline_data' => $inlineData
+                            ];
+                        }
+                    }
+                }
+                if (!empty($imageUrls)) {
+                    if (!is_array($imageUrls)) {
+                        $imageUrls = [$imageUrls];
+                    }
+                    foreach ($imageUrls as $url) {
+                        if (!empty($url)) {
+                            $inlineData = $this->processMediaToInlineData($url);
+                            if ($inlineData) {
+                                $userMessage['content'][] = [
+                                    'type'        => 'image',
+                                    'inline_data' => $inlineData
+                                ];
+                            }
+                        }
+                    }
+                }
+                if (count($userMessage['content']) === 1 && isset($userMessage['content'][0]['type']) && $userMessage['content'][0]['type'] === 'text') {
+                    $userMessage['content'] = $userMessage['content'][0]['text'];
+                }
+                $messages[] = $userMessage;
+            }
+        }
+
+        if (empty($messages)) {
+            Utils::throwError('20003:请提供有效的对话内容');
+        }
+
+        // 转换为 Gemini contents 格式
+        $systemInstruction = '';
+        $contents = [];
+        foreach ($messages as $message) {
+            $role = getProp($message, 'role', 'user');
+            $content = getProp($message, 'content', '');
+
+            if ($role === 'system') {
+                if (is_array($content)) {
+                    $systemInstruction = $this->extractGeminiTextFromParts($content);
+                } else {
+                    $systemInstruction = (string)$content;
+                }
+                continue;
+            }
+
+            $geminiRole = $role === 'assistant' ? 'model' : 'user';
+            $parts = [];
+            if (is_array($content)) {
+                foreach ($content as $part) {
+                    $partType = getProp($part, 'type', 'text');
+                    if ($partType === 'text') {
+                        $text = (string)getProp($part, 'text', '');
+                        if ($text !== '') {
+                            $parts[] = ['text' => $text];
+                        }
+                    } elseif (in_array($partType, ['image_url', 'image', 'video_url', 'video'])) {
+                        if (in_array($partType, ['image_url', 'video_url'])) {
+                            $mediaPart = getProp($part, $partType === 'image_url' ? 'image_url' : 'video_url', []);
+                            $url = is_array($mediaPart) ? (string)getProp($mediaPart, 'url', '') : (string)$mediaPart;
+                        } elseif (isset($part['inline_data'])) {
+                            // 已处理过的 inline_data 直接使用
+                            $parts[] = ['inline_data' => $part['inline_data']];
+                            continue;
+                        } else {
+                            $url = '';
+                        }
+                        $inlineData = $this->processMediaToInlineData($url);
+                        if ($inlineData) {
+                            $parts[] = ['inline_data' => $inlineData];
+                        }
+                    }
+                }
+            } else {
+                $text = (string)$content;
+                if ($text !== '') {
+                    $parts[] = ['text' => $text];
+                }
+            }
+
+            if (!empty($parts)) {
+                $contents[] = ['role' => $geminiRole, 'parts' => $parts];
+            }
+        }
+
+        if (empty($contents)) {
+            Utils::throwError('20003:请提供有效的对话内容');
+        }
+
+        // 构建生成配置
+        $generationConfig = [];
+        if (getProp($data, 'temperature', null) !== null) {
+            $generationConfig['temperature'] = (float)getProp($data, 'temperature');
+        }
+        if (getProp($data, 'top_p', null) !== null) {
+            $generationConfig['topP'] = (float)getProp($data, 'top_p');
+        }
+        if (getProp($data, 'frequency_penalty', null) !== null) {
+            $generationConfig['frequencyPenalty'] = (float)getProp($data, 'frequency_penalty');
+        }
+        if (getProp($data, 'presence_penalty', null) !== null) {
+            $generationConfig['presencePenalty'] = (float)getProp($data, 'presence_penalty');
+        }
+        $generationConfig['maxOutputTokens'] = (int)getProp($data, 'max_tokens', 8192);
+
+        // response_format 映射:text -> text/plain,json -> application/json
+        $responseFormat = getProp($data, 'response_format', 'text');
+        if (is_array($responseFormat)) {
+            $responseFormat = getProp($responseFormat, 'type', 'text');
+        }
+        if (in_array($responseFormat, ['json', 'json_object'])) {
+            $generationConfig['responseMimeType'] = 'application/json';
+        } else {
+            $generationConfig['responseMimeType'] = 'text/plain';
+        }
+
+        $requestData = [
+            'contents'          => $contents,
+            'generationConfig'  => $generationConfig,
+        ];
+
+        if ($systemInstruction !== '') {
+            $requestData['systemInstruction'] = ['parts' => [['text' => $systemInstruction]]];
+        }
+
+        // thinking 配置(Gemini 通过 thinkingConfig.thinkingBudget 控制,0 表示关闭)
+        $thinkingMode = getProp($data, 'thinking', null);
+        if (is_array($thinkingMode)) {
+            $thinkingMode = getProp($thinkingMode, 'type', 'disabled');
+        }
+        $defaultThinking = in_array($model, ['gemini-3-pro-preview', 'gemini-3.1-pro-preview']);
+        if ($thinkingMode === null) {
+            $thinkingMode = $defaultThinking ? 'enabled' : 'disabled';
+        }
+        if ($thinkingMode === 'enabled') {
+            $requestData['generationConfig']['thinkingConfig'] = [
+                'thinkingBudget' => (int)getProp($data, 'thinking_budget', 20)
+            ];
+        } else {
+            $requestData['generationConfig']['thinkingConfig'] = ['thinkingBudget' => 0];
+        }
+
+        return $requestData;
+    }
+
+    /**
+     * 获取指定 Gemini 模型对应的 API Key(env 配置,优先取模型专属 Key,缺失时回退 GEMINI_API_KEY)
+     *
+     * 模型名 -> env 变量名映射:
+     *   gemini-3-pro-preview            => GEMINI_API_KEY_GEMINI_3_PRO_PREVIEW
+     *   gemini-3-flash-preview          => GEMINI_API_KEY_GEMINI_3_FLASH_PREVIEW
+     *   gemini-3.1-pro-preview          => GEMINI_API_KEY_GEMINI_3_1_PRO_PREVIEW
+     *   gemini-3.1-flash-image-preview-t => GEMINI_API_KEY_GEMINI_3_1_FLASH_IMAGE_PREVIEW_T
+     *   gemini-3.6-flash                => GEMINI_API_KEY_GEMINI_3_6_FLASH
+     */
+    private function getGeminiApiKey($model) {
+        $map = [
+            'gemini-3-pro-preview'               => 'GEMINI_API_KEY_GEMINI_3_PRO_PREVIEW',
+            'gemini-3-flash-preview'             => 'GEMINI_API_KEY_GEMINI_3_FLASH_PREVIEW',
+            'gemini-3.1-pro-preview'             => 'GEMINI_API_KEY_GEMINI_3_1_PRO_PREVIEW',
+            'gemini-3.1-flash-image-preview-t'   => 'GEMINI_API_KEY_GEMINI_3_1_FLASH_IMAGE_PREVIEW_T',
+            'gemini-3.6-flash'                   => 'GEMINI_API_KEY_GEMINI_3_6_FLASH',
+        ];
+
+        if (isset($map[$model])) {
+            $key = env($map[$model]);
+            if (!empty($key)) {
+                return $key;
+            }
+        }
+
+        return env('GEMINI_API_KEY', '');
+    }
+
+    /**
+     * Gemini 非流式对话(返回与 chatOnly 一致的结构)
+     */
+    private function geminiChatOnly($requestData, $model) {
+        $apiKey = $this->getGeminiApiKey($model);
+        if (empty($apiKey)) {
+            Utils::throwError('20003:Gemini API Key未配置: ' . $model);
+        }
+
+        $url = rtrim(env('GEMINI_API_BASE_URL', 'https://ai-api.kkidc.com/v1beta'), '/')
+            . '/models/' . $model . ':generateContent';
+
+        $client = new Client(['timeout' => 1800, 'verify' => false]);
+        $headers = [
+            'Authorization' => 'Bearer ' . $apiKey,
+            'Content-Type'  => 'application/json'
+        ];
+
+        $maxRetries = $this->gptRetryTimes();
+        $interval = $this->gptRetryInterval();
+        $attempt = 0;
+
+        while (true) {
+            try {
+                $response = $client->post($url, [
+                    'json'    => $requestData,
+                    'headers' => $headers
+                ]);
+                $responseArr = json_decode($response->getBody()->getContents(), true);
+                dLog('deepseek')->info('Gemini请求完成', ['model' => $model, 'response' => $responseArr]);
+
+                $fullContent = $this->extractGeminiContent($responseArr);
+                $fullReasoningContent = $this->extractGeminiReasoningContent($responseArr);
+                $usage = isset($responseArr['usageMetadata']) ? $responseArr['usageMetadata'] : [];
+                $finishReason = isset($responseArr['candidates'][0]['finishReason'])
+                    ? $responseArr['candidates'][0]['finishReason']
+                    : '';
+
+                return [
+                    'fullContent'           => $fullContent,
+                    'fullReasoningContent'  => $fullReasoningContent,
+                    'usage'                 => $usage,
+                    'finish_reason'         => $finishReason
+                ];
+            } catch (\Exception $e) {
+                $retryable = true;
+                if ($e instanceof \GuzzleHttp\Exception\BadResponseException && $e->getResponse()) {
+                    $status = $e->getResponse()->getStatusCode();
+                    $retryable = $status >= 500 || $status == 429;
+                }
+
+                if (!$retryable || $attempt >= $maxRetries) {
+                    dLog('deepseek')->error('Gemini请求失败: ' . $e->getMessage());
+                    logDB('deepseek', 'error', 'Gemini请求失败', ['error' => $e->getMessage(), 'model' => $model]);
+                    Utils::throwError('20003:Gemini请求失败: ' . $e->getMessage());
+                }
+
+                dLog('deepseek')->warning('Gemini请求失败,准备重试', [
+                    'retry'       => $attempt + 1,
+                    'max_retries' => $maxRetries,
+                    'error'       => $e->getMessage()
+                ]);
+                sleep($interval);
+                $attempt++;
+            }
+        }
+    }
+
+    /**
+     * Gemini 流式对话(SSE 输出,格式与 deepSeekStreamResponse 一致)
+     */
+    private function geminiStreamResponse($requestData, $model) {
+        $apiKey = $this->getGeminiApiKey($model);
+        if (empty($apiKey)) {
+            Utils::throwError('20003:Gemini API Key未配置: ' . $model);
+        }
+
+        // 兼容 OpenAI 风格 post_data(包含 messages 字段)
+        if (isset($requestData['messages'])) {
+            $requestData = $this->buildGeminiRequestBody($requestData, $model);
+        }
+
+        $url = rtrim(env('GEMINI_API_BASE_URL', 'https://ai-api.kkidc.com/v1beta'), '/')
+            . '/models/' . $model . ':generateContent?alt=sse';
+
+        $client = new Client(['timeout' => 1800, 'verify' => false]);
+        $headers = [
+            'Authorization' => 'Bearer ' . $apiKey,
+            'Content-Type'  => 'application/json'
+        ];
+
+        try {
+            $response = $client->post($url, [
+                'json'    => $requestData,
+                'headers' => $headers,
+                'stream'  => true
+            ]);
+
+            $body = $response->getBody();
+            $fullContent = '';
+            $fullReasoningContent = '';
+            $usage = [];
+            $buffer = '';
+
+            dLog('deepseek')->info('开始读取 Gemini 流式响应');
+
+            while (!$body->eof()) {
+                $chunk = $body->read(1024);
+                $buffer .= $chunk;
+
+                $lines = explode("\n", $buffer);
+                $buffer = array_pop($lines);
+
+                foreach ($lines as $line) {
+                    $line = trim($line);
+                    if (empty($line) || strpos($line, 'data: ') !== 0) {
+                        continue;
+                    }
+
+                    $data = substr($line, 6);
+                    if ($data === '[DONE]') {
+                        break 2;
+                    }
+
+                    try {
+                        $json = json_decode($data, true);
+                        if (json_last_error() !== JSON_ERROR_NONE) {
+                            dLog('deepseek')->warning('Gemini JSON解析错误: ' . json_last_error_msg(), ['data' => $data]);
+                            continue;
+                        }
+
+                        // 思考内容(thoughts)
+                        $thoughts = $this->extractGeminiThoughts($json);
+                        if ($thoughts !== '') {
+                            $fullReasoningContent .= $thoughts;
+                            yield [
+                                'type'            => 'reasoning',
+                                'content'         => $thoughts,
+                                'full_reasoning'  => $fullReasoningContent
+                            ];
+                        }
+
+                        // 回答内容
+                        $partText = $this->extractGeminiContent($json);
+                        if ($partText !== '') {
+                            $fullContent .= $partText;
+                            yield [
+                                'type'    => 'content',
+                                'content' => $partText,
+                            ];
+                        }
+
+                        // 使用统计
+                        if (isset($json['usageMetadata'])) {
+                            $usage = $json['usageMetadata'];
+                            dLog('deepseek')->info('Gemini收到使用统计', ['usage' => $usage]);
+                        }
+                    } catch (\Exception $e) {
+                        dLog('deepseek')->error('Gemini解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
+                        continue;
+                    }
+                }
+            }
+
+            dLog('deepseek')->info('Gemini流式读取完成', [
+                'content_length'    => strlen($fullContent),
+                'reasoning_length'  => strlen($fullReasoningContent)
+            ]);
+
+            yield [
+                'type'            => 'done',
+                'full_content'    => $fullContent,
+                'full_reasoning'  => $fullReasoningContent,
+                'usage'           => $usage
+            ];
+        } catch (\Exception $e) {
+            dLog('deepseek')->error('Gemini流式请求失败: ' . $e->getMessage());
+            yield [
+                'type'    => 'error',
+                'msg'     => $e->getMessage(),
+                'code'    => $e->getCode()
+            ];
+        }
+    }
+
+    /**
+     * 从 Gemini 响应中提取回答文本
+     */
+    private function extractGeminiContent($responseArr) {
+        $text = '';
+        if (isset($responseArr['candidates'][0]['content']['parts']) && is_array($responseArr['candidates'][0]['content']['parts'])) {
+            foreach ($responseArr['candidates'][0]['content']['parts'] as $part) {
+                if (isset($part['text'])) {
+                    $text .= $part['text'];
+                }
+            }
+        }
+        return $text;
+    }
+
+    /**
+     * 从 Gemini 响应中提取思考内容
+     */
+    private function extractGeminiReasoningContent($responseArr) {
+        $reasoning = '';
+        if (isset($responseArr['candidates'][0]['thoughts']) && is_array($responseArr['candidates'][0]['thoughts'])) {
+            foreach ($responseArr['candidates'][0]['thoughts'] as $thought) {
+                if (isset($thought['text'])) {
+                    $reasoning .= $thought['text'];
+                }
+            }
+        }
+        return $reasoning;
+    }
+
+    /**
+     * 从 Gemini 流式响应中提取本次增量思考内容
+     */
+    private function extractGeminiThoughts($responseArr) {
+        $thoughts = '';
+        if (isset($responseArr['candidates'][0]['thoughts']) && is_array($responseArr['candidates'][0]['thoughts'])) {
+            foreach ($responseArr['candidates'][0]['thoughts'] as $thought) {
+                if (isset($thought['text'])) {
+                    $thoughts .= $thought['text'];
+                }
+            }
+        }
+        return $thoughts;
+    }
+
+    /**
+     * 提取消息数组中的纯文本
+     */
+    private function extractGeminiTextFromParts($parts) {
+        $text = '';
+        if (is_array($parts)) {
+            foreach ($parts as $part) {
+                if (is_array($part) && getProp($part, 'type', 'text') === 'text') {
+                    $text .= (string)getProp($part, 'text', '');
+                }
+            }
+        }
+        return $text;
+    }
+
+    /**
+     * 将多媒体文件(上传文件路径或 http(s) URL)转换为 Gemini inline_data 格式
+     *
+     * 限制:中转站请求体最大 20MB,base64 体积膨胀约 1.37 倍,
+     * 因此压缩目标为原始文件 <= 14MB(base64 后约 19.2MB,留有余量);
+     * 视频时长超过 5 分钟时通过加速压缩到 5 分钟以内。
+     *
+     * @param mixed $media 文件路径 / UploadedFile / http(s) URL / data URI
+     * @return array|null ['mime_type' => string, 'data' => string]
+     */
+    private function processMediaToInlineData($media) {
+        if (empty($media)) {
+            return null;
+        }
+
+        $localPath = null;
+        $isTempFile = false;
+
+        try {
+            // 1. data URI:直接解析使用(无法压缩,体积本身已受限)
+            if (is_string($media) && strpos($media, 'data:') === 0) {
+                if (preg_match('/^data:([^;,]+);base64,(.+)$/s', $media, $matches)) {
+                    return [
+                        'mime_type' => $matches[1],
+                        'data'      => $matches[2]
+                    ];
+                }
+                return null;
+            }
+
+            // 2. 获取本地文件路径
+            if (is_string($media) && preg_match('#^https?://#i', $media)) {
+                $localPath = $this->downloadMediaToTemp($media);
+                $isTempFile = true;
+            } elseif (is_string($media)) {
+                $localPath = $media;
+            } else {
+                // Laravel UploadedFile 对象
+                $localPath = $media->getRealPath();
+            }
+
+            if (!$localPath || !file_exists($localPath)) {
+                dLog('deepseek')->warning('Gemini多媒体文件不存在', ['path' => $localPath]);
+                return null;
+            }
+
+            $fileSize = filesize($localPath);
+            $maxBytes = $this->geminiMediaMaxBytes();
+
+            // 3. 判断是否需要压缩(超过大小上限或视频时长超过5分钟)
+            $mimeType = $this->detectMediaMime($localPath);
+            $isVideo = strpos($mimeType, 'video/') === 0;
+            $needCompress = $fileSize > $maxBytes;
+
+            $duration = 0;
+            if ($isVideo) {
+                $duration = $this->getMediaDuration($localPath);
+                if ($duration > 300) {
+                    $needCompress = true;
+                }
+            }
+
+            $finalPath = $localPath;
+            $compressedPath = null;
+            if ($needCompress && $this->ffmpegAvailable()) {
+                $compressed = $this->compressMediaFile($localPath, $isVideo, $duration);
+                if ($compressed && file_exists($compressed)) {
+                    $compressedPath = $compressed;
+                    if (filesize($compressed) < $fileSize || $isVideo) {
+                        $finalPath = $compressed;
+                        $mimeType = $isVideo ? 'video/mp4' : $this->detectMediaMime($compressed);
+                    } else {
+                        @unlink($compressed);
+                        $compressedPath = null;
+                    }
+                }
+            }
+
+            // 兜底校验:压缩后(或未压缩)仍超过大小上限,直接报错,不静默发送
+            $finalSize = filesize($finalPath);
+            if ($finalSize > $maxBytes) {
+                dLog('deepseek')->error('Gemini多媒体文件压缩后仍超过大小上限', [
+                    'path' => $localPath,
+                    'size_mb' => round($finalSize / 1024 / 1024, 2),
+                    'limit_mb' => 14
+                ]);
+                Utils::throwError('20003:多媒体文件过大(' . round($finalSize / 1024 / 1024, 2) . 'MB),压缩后仍超过14MB限制,请先自行压缩后重试');
+            }
+
+            // 兜底校验:视频时长超过5分钟且未能压缩成功
+            if ($isVideo && $duration > 300 && $finalPath === $localPath && !$this->ffmpegAvailable()) {
+                Utils::throwError('20003:视频时长超过5分钟(' . round($duration, 1) . '秒),且服务器ffmpeg不可用,无法压缩,请先自行处理视频后重试');
+            }
+
+            $data = base64_encode(file_get_contents($finalPath));
+            return [
+                'mime_type' => $mimeType,
+                'data'      => $data
+            ];
+        } catch (\App\Exceptions\ApiException $e) {
+            // 业务错误(如文件过大)原样抛出,让前端看到明确提示
+            throw $e;
+        } catch (\Exception $e) {
+            dLog('deepseek')->error('Gemini多媒体处理失败: ' . $e->getMessage());
+            return null;
+        } finally {
+            if (!empty($compressedPath) && $compressedPath !== $localPath && file_exists($compressedPath)) {
+                @unlink($compressedPath);
+            }
+            if ($isTempFile && $localPath && file_exists($localPath)) {
+                @unlink($localPath);
+            }
+        }
+    }
+
+    /**
+     * 下载远程多媒体到临时目录
+     *
+     * @return string|null 本地临时文件路径
+     */
+    private function downloadMediaToTemp($url) {
+        try {
+            $tempDir = storage_path('app/temp/gemini');
+            if (!is_dir($tempDir)) {
+                mkdir($tempDir, 0775, true);
+            }
+
+            $extension = 'tmp';
+            $pathInfo = parse_url($url, PHP_URL_PATH);
+            if ($pathInfo) {
+                $ext = pathinfo($pathInfo, PATHINFO_EXTENSION);
+                if ($ext && strlen($ext) <= 10) {
+                    $extension = strtolower($ext);
+                }
+            }
+
+            $tmpFile = $tempDir . '/' . uniqid('gemini_media_') . '.' . $extension;
+            $client = new Client(['timeout' => 300, 'verify' => false]);
+            $response = $client->get($url, ['sink' => $tmpFile]);
+
+            if (!file_exists($tmpFile) || filesize($tmpFile) <= 0) {
+                @unlink($tmpFile);
+                return null;
+            }
+            return $tmpFile;
+        } catch (\Exception $e) {
+            dLog('deepseek')->warning('Gemini多媒体下载失败: ' . $e->getMessage(), ['url' => $url]);
+            return null;
+        }
+    }
+
+    /**
+     * 检测多媒体文件 MIME 类型
+     */
+    private function detectMediaMime($filePath) {
+        $imageInfo = @getimagesize($filePath);
+        if ($imageInfo !== false && isset($imageInfo['mime'])) {
+            return $imageInfo['mime'];
+        }
+
+        // 视频按扩展名粗略判断
+        $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
+        $videoExts = ['mp4' => 'video/mp4', 'mov' => 'video/quicktime', 'avi' => 'video/x-msvideo',
+            'mkv' => 'video/x-matroska', 'webm' => 'video/webm', 'm4v' => 'video/x-m4v',
+            'flv' => 'video/x-flv', 'wmv' => 'video/x-ms-wmv', 'ts' => 'video/mp2t'];
+        if (isset($videoExts[$ext])) {
+            return $videoExts[$ext];
+        }
+
+        $imageExts = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
+            'gif' => 'image/gif', 'webp' => 'image/webp', 'bmp' => 'image/bmp'];
+        if (isset($imageExts[$ext])) {
+            return $imageExts[$ext];
+        }
+
+        return 'application/octet-stream';
+    }
+
+    /**
+     * 获取多媒体时长(秒),非视频或失败返回 0
+     */
+    private function getMediaDuration($filePath) {
+        $ffprobePath = env('FFPROBE_PATH', 'ffprobe');
+        $cmd = '"' . $ffprobePath . '" -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "' . $filePath . '" 2>&1';
+        $output = trim((string)shell_exec($cmd));
+        $duration = (float)$output;
+        return $duration > 0 ? $duration : 0;
+    }
+
+    /**
+     * 检查 ffmpeg 是否可用
+     */
+    private function ffmpegAvailable() {
+        static $available = null;
+        if ($available !== null) {
+            return $available;
+        }
+
+        $ffmpegPath = env('FFMPEG_PATH', 'ffmpeg');
+        if (strtoupper((string)env('APP_ENV', 'production')) === 'LOCAL') {
+            // 本地环境不执行 ffmpeg 压缩(与现有图片超分逻辑保持一致)
+            $available = false;
+            return $available;
+        }
+
+        $cmd = '"' . $ffmpegPath . '" -version 2>&1';
+        $output = shell_exec($cmd);
+        $available = is_string($output) && stripos($output, 'ffmpeg') !== false;
+        return $available;
+    }
+
+    /**
+     * 使用 ffmpeg 压缩多媒体文件
+     *
+     * @param string $inputFile 输入文件路径
+     * @param bool   $isVideo   是否为视频
+     * @param float  $duration  视频时长(秒),非视频传 0
+     * @return string|null 压缩后的文件路径,失败返回 null
+     */
+    private function compressMediaFile($inputFile, $isVideo, $duration = 0) {
+        try {
+            $tempDir = storage_path('app/temp/gemini');
+            if (!is_dir($tempDir)) {
+                mkdir($tempDir, 0775, true);
+            }
+
+            $ffmpegPath = env('FFMPEG_PATH', 'ffmpeg');
+            $uniqueId = uniqid('gemini_cmp_') . bin2hex(random_bytes(4));
+            $outputFile = $tempDir . '/' . $uniqueId . ($isVideo ? '.mp4' : '.jpg');
+
+            if ($isVideo) {
+                // 视频:时长超过5分钟则加速压缩到5分钟以内
+                $speedFactor = 1.0;
+                if ($duration > 300) {
+                    $speedFactor = $duration / 300;
+                }
+
+                // 目标文件大小:14MB,按加速后时长估算码率(不高于 6000kbps,不低于 300kbps)
+                $finalDuration = $duration / max(1.0, $speedFactor);
+                $targetBitrate = (int)((14 * 1024 * 8) / max(1, $finalDuration));
+                $targetBitrate = max(300, min(6000, $targetBitrate));
+
+                $filters = [];
+                $filters[] = 'scale=min(1280,iw):-2';
+                $filters[] = 'fps=24';
+
+                if ($speedFactor > 1.0) {
+                    $filters[] = 'setpts=' . number_format(1 / $speedFactor, 6, '.', '') . '*PTS';
+                    // 用多个 atempo 级联实现加速:前 n-1 个为 2.0,最后一个补足剩余倍数
+                    $fullCount = (int)ceil($speedFactor / 2.0);
+                    $lastTempo = $speedFactor / pow(2.0, $fullCount - 1);
+                    if ($lastTempo < 0.5) {
+                        $fullCount++;
+                        $lastTempo = $speedFactor / pow(2.0, $fullCount - 1);
+                    }
+                    for ($i = 0; $i < $fullCount; $i++) {
+                        $tempoValue = $i === $fullCount - 1 ? $lastTempo : 2.0;
+                        $filters[] = 'atempo=' . number_format($tempoValue, 6, '.', '');
+                    }
+                }
+
+                $filterStr = implode(',', $filters);
+                $cmd = '"' . $ffmpegPath . '" -i "' . $inputFile . '" -vf "' . $filterStr
+                    . '" -c:v libx264 -b:v ' . $targetBitrate . 'k -preset medium'
+                    . ' -c:a aac -b:a 96k -movflags +faststart -y "' . $outputFile . '" 2>&1';
+            } else {
+                // 图片:缩放至最长边2048px(保持纵横比),JPEG质量压缩
+                $imageInfo = @getimagesize($inputFile);
+                $scaleW = 2048;
+                $scaleH = 2048;
+                if ($imageInfo !== false && $imageInfo[0] > 0 && $imageInfo[1] > 0) {
+                    if ($imageInfo[0] >= $imageInfo[1]) {
+                        $scaleH = (int)round($imageInfo[1] * 2048 / $imageInfo[0]);
+                        $scaleW = 2048;
+                    } else {
+                        $scaleW = (int)round($imageInfo[0] * 2048 / $imageInfo[1]);
+                        $scaleH = 2048;
+                    }
+                }
+                $cmd = '"' . $ffmpegPath . '" -i "' . $inputFile
+                    . '" -vf "scale=' . $scaleW . ':' . $scaleH . '"'
+                    . ' -q:v 3 -y "' . $outputFile . '" 2>&1';
+            }
+
+            dLog('deepseek')->info('Gemini开始压缩多媒体', ['command' => $cmd]);
+            $output = shell_exec($cmd);
+
+            if (!file_exists($outputFile) || filesize($outputFile) <= 0) {
+                dLog('deepseek')->error('Gemini多媒体压缩失败', [
+                    'input'  => $inputFile,
+                    'output' => $output
+                ]);
+                @unlink($outputFile);
+                return null;
+            }
+
+            dLog('deepseek')->info('Gemini多媒体压缩成功', [
+                'input_size'  => round(filesize($inputFile) / 1024 / 1024, 2) . 'MB',
+                'output_size' => round(filesize($outputFile) / 1024 / 1024, 2) . 'MB'
+            ]);
+
+            return $outputFile;
+        } catch (\Exception $e) {
+            dLog('deepseek')->error('Gemini多媒体压缩异常: ' . $e->getMessage());
+            return null;
+        }
+    }
+
+    /**
      * 获取剧本资产生成任务列表(按用户分页,每页15条)
      *
      * @param array $data
@@ -2701,6 +3532,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         } else if (in_array($model, $this->gpt_text_models)) {
             // GPT-5.4 模型使用 TokenRouter API
             $streamGenerator = $this->gpt54StreamResponse($post_data);
+        } else if (in_array($model, $this->gemini_text_models)) {
+            // Gemini 模型使用 Gemini API
+            $streamGenerator = $this->geminiStreamResponse($post_data, $model);
         } else {
             // 其他模型使用火山引擎API
             $streamGenerator = $this->volcEngineChatCompletion($post_data);
@@ -2985,6 +3819,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
         } else if (in_array($model, $this->gpt_text_models)) {
             // GPT-5.4 模型使用 TokenRouter API
             $streamGenerator = $this->gpt54StreamResponse($post_data);
+        } else if (in_array($model, $this->gemini_text_models)) {
+            // Gemini 模型使用 Gemini API
+            $streamGenerator = $this->geminiStreamResponse($post_data, $model);
         } else {
             // 其他模型使用火山引擎API
             $streamGenerator = $this->volcEngineChatCompletion($post_data);
@@ -4112,6 +4949,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             } else if (in_array($model, $this->gpt_text_models)) {
                 // GPT-5.4 模型使用 TokenRouter API
                 $streamGenerator = $this->gpt54StreamResponse($post_data);
+            } else if (in_array($model, $this->gemini_text_models)) {
+                // Gemini 模型使用 Gemini API
+                $streamGenerator = $this->geminiStreamResponse($post_data, $model);
             } else {
                 // 其他模型使用火山引擎API
                 $streamGenerator = $this->volcEngineChatCompletion($post_data);
@@ -4961,6 +5801,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             } else if (in_array($model, $this->gpt_text_models)) {
                 // GPT-5.4 模型使用 TokenRouter API
                 $streamGenerator = $this->gpt54StreamResponse($post_data);
+            } else if (in_array($model, $this->gemini_text_models)) {
+                // Gemini 模型使用 Gemini API
+                $streamGenerator = $this->geminiStreamResponse($post_data, $model);
             } else {
                 $streamGenerator = $this->volcEngineChatCompletion($post_data);
             }
@@ -5826,6 +6669,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             } else if (in_array($model, $this->gpt_text_models)) {
                 // GPT-5.4 模型使用 TokenRouter API
                 $streamGenerator = $this->gpt54StreamResponse($post_data);
+            } else if (in_array($model, $this->gemini_text_models)) {
+                // Gemini 模型使用 Gemini API
+                $streamGenerator = $this->geminiStreamResponse($post_data, $model);
             } else {
                 // 其他模型使用火山引擎API
                 $streamGenerator = $this->volcEngineChatCompletion($post_data);
@@ -6575,6 +7421,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             } else if (in_array($model, $this->gpt_text_models)) {
                 // GPT-5.4 模型使用 TokenRouter API
                 $streamGenerator = $this->gpt54StreamResponse($post_data);
+            } else if (in_array($model, $this->gemini_text_models)) {
+                // Gemini 模型使用 Gemini API
+                $streamGenerator = $this->geminiStreamResponse($post_data, $model);
             } else {
                 // 其他模型使用火山引擎API
                 $streamGenerator = $this->volcEngineChatCompletion($post_data);
@@ -7598,6 +8447,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             } else if (in_array($model, $this->gpt_text_models)) {
                 // GPT-5.4 模型使用 TokenRouter API
                 $streamGenerator = $this->gpt54StreamResponse($post_data);
+            } else if (in_array($model, $this->gemini_text_models)) {
+                // Gemini 模型使用 Gemini API
+                $streamGenerator = $this->geminiStreamResponse($post_data, $model);
             } else {
                 $streamGenerator = $this->volcEngineChatCompletion($post_data);
             }
@@ -8699,6 +9551,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
             } else if (in_array($model, $this->gpt_text_models)) {
                 // GPT-5.4 模型使用 TokenRouter API
                 $streamGenerator = $this->gpt54StreamResponse($post_data);
+            } else if (in_array($model, $this->gemini_text_models)) {
+                // Gemini 模型使用 Gemini API
+                $streamGenerator = $this->geminiStreamResponse($post_data, $model);
             } else {
                 // 其他模型使用火山引擎API
                 $streamGenerator = $this->volcEngineChatCompletion($post_data);
@@ -8876,6 +9731,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                     $streamGenerator = $this->deepSeekStreamResponse($post_data);
                 } else if (in_array($model, $this->gpt_text_models)) {
                     $streamGenerator = $this->gpt54StreamResponse($post_data);
+                } else if (in_array($model, $this->gemini_text_models)) {
+                    // Gemini 模型使用 Gemini API
+                    $streamGenerator = $this->geminiStreamResponse($post_data, $model);
                 } else {
                     $streamGenerator = $this->volcEngineChatCompletion($post_data);
                 }