|
|
@@ -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;
|