浏览代码

1.文生文通用非流式兼容流式请求和返回2.动漫对话调整为任何提示词都生成新版本

lh 3 周之前
父节点
当前提交
be7f51ddb1
共有 2 个文件被更改,包括 411 次插入1 次删除
  1. 43 0
      app/Http/Controllers/DeepSeek/DeepSeekController.php
  2. 368 1
      app/Services/DeepSeek/DeepSeekService.php

+ 43 - 0
app/Http/Controllers/DeepSeek/DeepSeekController.php

@@ -546,6 +546,49 @@ class DeepSeekController extends BaseController
         ini_set('max_execution_time', '0');
 
         $data = $request->all();
+
+        // stream=1:SSE 流式输出(格式与 generateText 一致:yield type=content/reasoning/done)
+        if ((int)getProp($data, 'stream', 0) === 1) {
+            return response()->stream(function () use ($data) {
+                if (ob_get_level()) {
+                    ob_end_clean();
+                }
+                ini_set('output_buffering', 'off');
+                ini_set('zlib.output_compression', 'off');
+                if (function_exists('apache_setenv')) {
+                    apache_setenv('no-gzip', '1');
+                }
+
+                try {
+                    $result = $this->deepseekService->newGenerateText($data);
+                    foreach ($result as $chunk) {
+                        echo "data: " . json_encode($chunk, JSON_UNESCAPED_UNICODE) . "\n\n";
+                        if (ob_get_level() > 0) {
+                            ob_flush();
+                        }
+                        flush();
+                        if (connection_aborted()) {
+                            break;
+                        }
+                    }
+                } catch (\Throwable $e) {
+                    echo "data: " . json_encode([
+                        'type' => 'error',
+                        'message' => $e->getMessage(),
+                    ], JSON_UNESCAPED_UNICODE) . "\n\n";
+                    if (ob_get_level() > 0) {
+                        ob_flush();
+                    }
+                    flush();
+                }
+            }, 200, [
+                'Content-Type' => 'text/event-stream',
+                'Cache-Control' => 'no-cache',
+                'Connection' => 'keep-alive',
+                'X-Accel-Buffering' => 'no',
+            ]);
+        }
+
         $result = $this->deepseekService->newGenerateText($data);
         return $this->success($result);
     }

+ 368 - 1
app/Services/DeepSeek/DeepSeekService.php

@@ -772,12 +772,377 @@ class DeepSeekService
     }
     
     /**
+     * 通用文生文(流式版本)- newGenerateText 的 stream=1 分支
+     * 支持与 newGenerateText 相同的参数(模板/剧本/JSON输出/图片输入),
+     * 输出格式与 generateText 一致:yield type=content / reasoning / done
+     *
+     * @param array $data 请求参数
+     * @return \Generator
+     */
+    public function newGenerateTextStream($data) {
+        $model = getProp($data, 'model', 'deepseek-v4-pro');
+        $messages = getProp($data, 'messages', []);
+        $systemPrompt = getProp($data, 'system_prompt', '');
+        $prompt = getProp($data, 'prompt', '');
+        $images = getProp($data, 'images', []);
+        $imageUrls = getProp($data, 'image_urls', []);
+        $responseFormat = getProp($data, 'response_format', 'text');
+        $templateId = getProp($data, 'template_id', 0);
+        $script_id = getProp($data, 'script_id', 0);
+        $sequence = getProp($data, 'sequence', 0);
+
+        $generateTaskId = 0;
+        $uid = Site::getUid();
+        if ($script_id > 0) {
+            $existingTask = DB::table('mp_script_generate_tasks')
+                ->where('uid', $uid)
+                ->where('script_id', $script_id)
+                ->orderByDesc('id')
+                ->first();
+            if ($existingTask && in_array(getProp($existingTask, 'status'), ['pending', 'processing'])) {
+                Utils::throwError('20003:该剧本资产正在生成中,请稍后重试');
+            }
+        }
+
+        // 如果提供了script_id,从数据库获取剧本内容作为原文
+        $originalContent = '';
+        $hasValidScript = false;
+        if ($script_id > 0) {
+            $script = DB::table('mp_scripts')
+                ->where('id', $script_id)
+                ->where('is_deleted', 0)
+                ->first();
+            if (!$script) {
+                Utils::throwError('20003:剧本不存在或已删除');
+            }
+            $originalContent = $script->content ?? '';
+            if (empty($originalContent)) {
+                Utils::throwError('20003:剧本内容为空');
+            }
+            $hasValidScript = true;
+            if (!empty($prompt)) {
+                $prompt = "原文内容:\n" . $originalContent . "\n\n用户要求:\n" . $prompt;
+            } else {
+                $prompt = "原文内容:\n" . $originalContent;
+            }
+        }
+
+        // 如果提供了template_id,从数据库获取template_prompt
+        if ($templateId > 0) {
+            $template = DB::table('mp_prompt_templates')
+                ->where('id', $templateId)
+                ->where('is_deleted', 0)
+                ->first();
+            if (!$template) {
+                Utils::throwError('20003:模板不存在或已删除');
+            }
+            $templatePrompt = $template->template_prompt ?? '';
+            if (!empty($templatePrompt)) {
+                if (!empty($prompt)) {
+                    $prompt = $templatePrompt . "\n\n" . $prompt;
+                } else {
+                    $prompt = $templatePrompt;
+                }
+            }
+        }
+
+        // 如果没有提供messages,则根据system_prompt、prompt和images构建
+        if (empty($messages)) {
+            $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) {
+                        $imageBase64 = $this->processImageToBase64($image);
+                        if ($imageBase64) {
+                            $userMessage['content'][] = [
+                                'type' => 'image_url',
+                                'image_url' => [
+                                    'url' => "data:image/jpeg;base64,{$imageBase64}"
+                                ]
+                            ];
+                        }
+                    }
+                }
+                if (!empty($imageUrls)) {
+                    if (!is_array($imageUrls)) {
+                        $imageUrls = [$imageUrls];
+                    }
+                    foreach ($imageUrls as $url) {
+                        if (!empty($url)) {
+                            $userMessage['content'][] = [
+                                'type' => 'image_url',
+                                'image_url' => [
+                                    'url' => $url
+                                ]
+                            ];
+                        }
+                    }
+                }
+                if (count($userMessage['content']) === 1 && $userMessage['content'][0]['type'] === 'text') {
+                    $userMessage['content'] = $userMessage['content'][0]['text'];
+                }
+                $messages[] = $userMessage;
+            }
+        }
+
+        if (empty($messages)) {
+            Utils::throwError('20003:请提供有效的对话内容');
+        }
+
+        // 模型兼容性处理
+        $thinkingMode = getProp($data, 'thinking', 'disabled');
+        $reasoningEffort = getProp($data, 'reasoning_effort', 'high');
+        if ($model === 'deepseek-chat') {
+            $model = 'deepseek-v4-flash';
+            $thinkingMode = 'disabled';
+        } elseif ($model === 'deepseek-reasoner') {
+            $model = 'deepseek-v4-flash';
+            $thinkingMode = 'enabled';
+        }
+
+        // 构建请求参数(流式)
+        $post_data = [
+            'model' => $model,
+            'messages' => $messages,
+            'temperature' => getProp($data, 'temperature', 1),
+            'frequency_penalty' => getProp($data, 'frequency_penalty', 0),
+            'presence_penalty' => getProp($data, 'presence_penalty', 0),
+            'thinking' => ['type' => $thinkingMode],
+            'stream' => true
+        ];
+        if ($thinkingMode == 'enabled') $post_data['reasoning_effort'] = $reasoningEffort;
+        if (isset($data['top_p'])) {
+            $post_data['top_p'] = $data['top_p'];
+        }
+
+        // response_format=json 时附加 JSON 说明与响应格式(与 newGenerateText 一致)
+        if ($responseFormat === 'json') {
+            $hasJsonKeyword = false;
+            foreach ($post_data['messages'] as $message) {
+                if (isset($message['content'])) {
+                    $content = is_array($message['content']) ? json_encode($message['content']) : $message['content'];
+                    if (stripos($content, 'json') !== false) {
+                        $hasJsonKeyword = true;
+                        break;
+                    }
+                }
+            }
+            if (!$hasJsonKeyword) {
+                $lastMessageIndex = count($post_data['messages']) - 1;
+                if ($lastMessageIndex >= 0 && $post_data['messages'][$lastMessageIndex]['role'] === 'user') {
+                    $lastContent = $post_data['messages'][$lastMessageIndex]['content'];
+                    if (is_string($lastContent)) {
+                        $post_data['messages'][$lastMessageIndex]['content'] .= "\n\n请以JSON格式返回结果。";
+                    } elseif (is_array($lastContent)) {
+                        foreach ($post_data['messages'][$lastMessageIndex]['content'] as &$contentPart) {
+                            if ($contentPart['type'] === 'text') {
+                                $contentPart['text'] .= "\n\n请以JSON格式返回结果。";
+                                break;
+                            }
+                        }
+                    }
+                }
+            }
+            $post_data['response_format'] = ['type' => 'json_object'];
+        }
+
+        // 如果提供了script_id,创建剧本资产生成任务
+        if ($script_id > 0) {
+            $uid = Site::getUid();
+            $generateTaskId = DB::table('mp_script_generate_tasks')->insertGetId([
+                'uid'        => $uid,
+                'script_id'  => $script_id,
+                'sequence'   => $sequence,
+                'status'     => 'processing',
+                'prompt'     => getProp($data, 'prompt', ''),
+                'started_at' => date('Y-m-d H:i:s'),
+                'created_at' => date('Y-m-d H:i:s'),
+                'updated_at' => date('Y-m-d H:i:s')
+            ]);
+        }
+
+        // 积分余额预检(仅存在有效剧本时按 chat 类型计费,不足直接报错)
+        if ($hasValidScript) {
+            $this->pointsService->checkUserPointsEnough($this->pointsService->getChatChargePoints((string)$model), $uid);
+        }
+
+        // 根据模型类型选择流式调用方法
+        if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
+            $streamGenerator = $this->deepSeekStreamResponse($post_data);
+        } elseif (in_array($model, $this->gpt_text_models)) {
+            $streamGenerator = $this->gpt54StreamResponse($post_data);
+        } elseif (in_array($model, $this->gemini_text_models)) {
+            $streamGenerator = $this->geminiStreamResponse($post_data, $model);
+        } elseif (in_array($model, $this->valid_text_models)) {
+            $streamGenerator = $this->volcEngineChatCompletion($post_data);
+        } else {
+            Utils::throwError('20003:不支持的模型: ' . $model);
+        }
+
+        // 流式输出包装:done 时更新任务状态、扣费并保存对话记录
+        return $this->wrapNewGenerateTextStream($streamGenerator, [
+            'uid' => $uid,
+            'model' => $model,
+            'generate_task_id' => $generateTaskId,
+            'has_valid_script' => $hasValidScript,
+            'script_id' => $script_id,
+            'sequence' => $sequence,
+            'original_prompt' => getProp($data, 'prompt', ''),
+            'response_format' => $responseFormat,
+        ]);
+    }
+
+    /**
+     * 流式生成收尾包装:done 时更新剧本资产生成任务、扣除积分并保存对话记录
+     *
+     * @param \Generator $generator
+     * @param array $ctx
+     * @return \Generator
+     */
+    private function wrapNewGenerateTextStream(\Generator $generator, array $ctx): \Generator
+    {
+        $fullContent = '';
+        $usage = [];
+        $finished = false;
+        try {
+            foreach ($generator as $chunk) {
+                if (isset($chunk['type']) && $chunk['type'] === 'content') {
+                    $fullContent .= (string)getProp($chunk, 'content', '');
+                }
+                if (isset($chunk['type']) && $chunk['type'] === 'done') {
+                    $fullContent = (string)getProp($chunk, 'full_content', $fullContent);
+                    $usage = getProp($chunk, 'usage', []);
+                    // 流式场景下部分模型/中转站不强制 JSON 输出,兜底提取标准 JSON
+                    if (($ctx['response_format'] ?? 'text') === 'json') {
+                        $fullContent = $this->extractJsonContent($fullContent);
+                    }
+                    $finished = true;
+                }
+                yield $chunk;
+            }
+
+            if ($finished) {
+                if (!empty($ctx['generate_task_id'])) {
+                    DB::table('mp_script_generate_tasks')->where('id', $ctx['generate_task_id'])->update([
+                        'status'       => 'success',
+                        'result'       => $fullContent,
+                        'completed_at' => date('Y-m-d H:i:s'),
+                        'updated_at'   => date('Y-m-d H:i:s'),
+                    ]);
+                }
+                if ($ctx['has_valid_script'] && $fullContent !== '') {
+                    $this->chargeChatSuccess($ctx['uid'], $this->pointsService->getTokensFromUsage($usage), [
+                        'model' => $ctx['model'],
+                        'script_id' => $ctx['script_id'],
+                        'sequence' => $ctx['sequence'],
+                        'source' => 'newGenerateText',
+                    ]);
+                }
+                if (!empty($ctx['script_id'])) {
+                    $now = now();
+                    DB::table('mp_script_records')->insert([
+                        [
+                            'uid' => $ctx['uid'],
+                            'script_id' => $ctx['script_id'],
+                            'sequence' => $ctx['sequence'],
+                            'role' => 'user',
+                            'content' => $ctx['original_prompt'],
+                            'created_at' => $now,
+                            'updated_at' => $now,
+                        ],
+                        [
+                            'uid' => $ctx['uid'],
+                            'script_id' => $ctx['script_id'],
+                            'sequence' => $ctx['sequence'],
+                            'role' => 'assistant',
+                            'content' => $fullContent,
+                            'created_at' => $now,
+                            'updated_at' => $now,
+                        ],
+                    ]);
+                }
+            }
+        } catch (\Throwable $e) {
+            if (!empty($ctx['generate_task_id'])) {
+                DB::table('mp_script_generate_tasks')->where('id', $ctx['generate_task_id'])->update([
+                    'status'        => 'failed',
+                    'error_message' => $e->getMessage(),
+                    'completed_at'  => date('Y-m-d H:i:s'),
+                    'updated_at'    => date('Y-m-d H:i:s'),
+                ]);
+            }
+            yield [
+                'type' => 'error',
+                'message' => $e->getMessage(),
+            ];
+        }
+    }
+
+    /**
+     * 从模型输出中提取标准 JSON(剥离 markdown 代码块及前后说明文字)
+     *
+     * @param string $content
+     * @return string
+     */
+    private function extractJsonContent(string $content): string
+    {
+        $content = trim($content);
+        if ($content === '') {
+            return $content;
+        }
+
+        // 剥离 ```json ... ``` 代码块
+        if (preg_match('/```(?:json)?\s*(.*?)```/s', $content, $m)) {
+            $content = trim($m[1]);
+        }
+
+        // 提取第一个 { 到最后一个 } 之间的内容(去除前后说明文字)
+        $first = strpos($content, '{');
+        $last = strrpos($content, '}');
+        if ($first !== false && $last !== false && $last > $first) {
+            $content = substr($content, $first, $last - $first + 1);
+        }
+
+        // 校验是否为合法 JSON,非法则原样返回(不强行截断)
+        json_decode($content, true);
+        if (json_last_error() !== JSON_ERROR_NONE) {
+            return trim($content);
+        }
+
+        return $content;
+    }
+
+    /**
      * 通用文生文方法(非流式版本)- 支持多模型、图片输入、JSON输出和模板提示词
      * 
      * @param array $data 请求参数
      * @return array 返回结果数组
      */
     public function newGenerateText($data) {
+        // stream=1 时改为流式请求大模型(输出格式参考 generateText:yield type=content/reasoning/done)
+        if ((int)getProp($data, 'stream', 0) === 1) {
+            return $this->newGenerateTextStream($data);
+        }
+
         // 获取请求参数
         $model = getProp($data, 'model', 'deepseek-v4-pro'); // 默认使用deepseek-v4
         $messages = getProp($data, 'messages', []);
@@ -10321,7 +10686,9 @@ Q版卡通风格,头大身小,造型圆润可爱,线条简单,色彩明
                 \n\n";
 
         $origin_prompt = trim((string)getProp($data, 'prompt', '确认分镜大纲'));
-        $is_regenerate_version = !in_array($origin_prompt, ['确认分镜大纲', '继续策划下一集'], true);
+        // 所有提示词统一走版本化:确认分镜大纲/继续策划下一集也创建新分集,不覆盖本分集
+        // $is_regenerate_version = !in_array($origin_prompt, ['确认分镜大纲', '继续策划下一集'], true);
+        $is_regenerate_version = true;
         $is_global_generate_pics = $origin_prompt == '确认分镜大纲' ? 1 : 0;
         $prompt = $origin_prompt;
         $is_single = (int)getProp($anime, 'is_multi') !== 1;