url = 'https://api.deepseek.com/chat/completions';
$this->api_key = env('DEEPSEEK_API_KEY');
$this->headers = [
'Authorization' => 'Bearer '.$this->api_key,
'Content-Type' => 'application/json; charset=UTF-8'
];
$this->sys_message = [
'role' => 'system',
'content' => "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容完成示例格式的内容输出(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分集剧本>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n
1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n
2.<分集剧本>要求包含“分集序号”、“分集标题”以及分镜信息,分镜信息需包含“场景描述”、“运镜”、“出场角色”和“台词内容”这几个部分,其中“出场角色”和“场景描述”必须在前面的板块<主体列表>和<场景列表>中选取,不得自定义。\n
3.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答\n
示例如下:\n\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。
阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
阴阳球:天地异宝,能化生精气,救人于危难。
问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。
###分集剧本
##分集01
分集名: 第一集
分镜01-1
场景描述:地点、时间、场景
运镜:固定近距离
出场角色:男主、女主
台词内容:
女主: 女主对话
男主:男主对话
女主: 女主对话
男主: 男主对话
分镜01-2
场景描述:地点、时间、场景
运镜:从近到远
出场角色:男主、女主
台词内容:
女主: 女主对话
男主:男主对话
女主: 女主对话
男主: 男主对话
分镜01-3
场景描述:地点、时间、场景
运镜:快速拉近
出场角色:男主、女主
台词内容:
女主: 女主对话
男主:男主对话
女主: 女主对话
男主: 男主对话
##分集02
分集名: 第二集
分镜02-1
场景描述:地点、时间、场景
运镜:从近到远
出场角色:男主、女主
台词内容:
女主: 女主对话
男主:男主对话
女主: 女主对话
男主: 男主对话
分镜02-2
场景描述:地点、时间、场景
运镜:从近到远
出场角色:男主、女主
台词内容:
女主: 女主对话
男主:男主对话
女主: 女主对话
男主: 男主对话"
];
}
private function getArtStylePromptMappings(): array
{
return [
'厚涂古风' => [
'aliases' => ['厚涂古风', '古风厚涂', '国风厚涂', '古风'],
'prompt' => "基础画风风格词:厚涂古风\n整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,可辅以温暖的金色、琥珀色光晕强化画面情绪。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。"
],
'现代都市情感' => [
'aliases' => ['现代都市', '都市情感', '现代情感', '都市写实', '现代写实'],
'prompt' => "基础画风风格词:写实细腻的现代都市情感风格,带有轻微悬疑色彩。\n整体采用电影质感的写实风格,色彩随情绪和剧情变化。日常场景偏自然光感与生活化细节,压抑或冲突场景可适度加强蓝灰、暗绿色调,营造情感张力与轻悬疑氛围。人物造型追求真实可信,环境道具需体现都市生活气息。"
],
'现实主义超现实' => [
'aliases' => ['现实主义', '超现实', '现实主义超现实', '现实主义奇幻', '现代奇幻写实'],
'prompt' => "基础画风风格词:现实主义的细腻光影与超现实元素结合\n整体以现实主义基调塑造人物与场景,色彩饱和度适中偏低,通过细腻光影与局部超现实视觉元素制造情绪冲击。画面在真实生活质感之上融入象征性表达,让现实压迫感与奇异氛围并存。"
],
'韩漫二次元' => [
'aliases' => ['韩漫', '韩漫二次元', '二次元', '漫画风', '韩系漫画'],
'prompt' => "基础画风风格词:韩漫二次元\n整体采用精致清晰的韩漫二次元风格,人物五官立体、轮廓干净,画面注重高颜值塑造与情绪表达。色彩通透,光影偏柔和,兼顾浪漫氛围与剧情张力,适合强化角色关系和戏剧冲突。"
],
];
}
private function getArtStylePromptByInput($inputArtStyle): string
{
$normalizedInput = $this->normalizeArtStyleInput($inputArtStyle);
if ($normalizedInput === '') {
return '';
}
foreach ($this->getArtStylePromptMappings() as $mapping) {
foreach ($mapping['aliases'] as $alias) {
$normalizedAlias = $this->normalizeArtStyleInput($alias);
if ($normalizedAlias === '') {
continue;
}
if (strpos($normalizedInput, $normalizedAlias) !== false || strpos($normalizedAlias, $normalizedInput) !== false) {
return $mapping['prompt'];
}
}
}
return '';
}
private function normalizeArtStyleInput($value): string
{
$value = trim((string)$value);
if ($value === '') {
return '';
}
$value = str_replace(["\r", "\n", "\t", ' ', ' ', '-', '_', ':', ':', ',', ',', '。', '.'], '', $value);
return mb_strtolower($value, 'UTF-8');
}
private function replaceArtStyleSection(string $content, string $artStyle): string
{
if ($content === '' || $artStyle === '') {
return $content;
}
$pattern = '/###美术风格\s*\R(.*?)(?=\R###|$)/su';
if (!preg_match($pattern, $content)) {
return $content;
}
return preg_replace($pattern, "###美术风格\n" . trim($artStyle), $content, 1);
}
// 与推理模型对话
public function chatWithReasoner($data) {
$content = getProp($data, 'content');
$model = getProp($data, 'model', 'r1');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
// 获取可选情感(根据音色可支持情感选)
$timbre_emotion = DB::table('mp_timbres')->where('is_enabled', 1)->pluck('emotion')->toArray();
$emotion_list = [];
foreach ($timbre_emotion as $emotion) {
$tmp = explode(',', $emotion);
$emotion_list = array_merge($emotion_list, $tmp);
}
$emotion_list = array_unique($emotion_list);
$emotion_str = implode('、', $emotion_list);
// // 获取可选情感
// $emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->select('emotion_name')->orderBy('id')->get()->pluck('emotion_name')->toArray();
// $emotion_str = implode('、', $emotion_list);
// 是否启用情感
$enable_emotion = getProp($data, 'enable_emotion', 0);
if ($enable_emotion) {
$sys_content = "下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男、女、中性):台词{情感}”输出,需要注意以下几点要求:
1.角色名后不要加入任何其他词语,只能加性别,在男、女或中性中选
2.非对话部分请全部用旁白角色代替,旁白的情感统一选择中性
3.情感必须在【{$emotion_str}】中选一个,不得使用其他词语";
}else {
$sys_content = "下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男、女、中性):台词”输出,需要注意以下几点要求:
1.角色名后不要加入任何其他词语,只能加性别,在男、女或中性中选
2.非对话部分请全部用旁白角色代替,旁白的情感统一选择中性";
}
$messages = [
[
'role' => 'system',
'content' => $sys_content
],
[
'role' => 'user',
'content' => $content
]
];
$post_data = [
'model' => $model, // R1模型: deepseek-reasoner V3模型: deepseek-chat
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 1, // 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。
// 'top_p' => 1, // 作为调节采样温度的替代方案(<=1),模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。
'frequency_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。
'presence_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。
'response_format' => [
'type' => 'text' // 默认值text,回答的结果输出文字(非接口返回值是text,接口返回值还是json字串),还可选:json_object,输出json格式
],
'stream' => false // 是否流式输出,如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾。
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]);
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
$update_data = [];
$content = '';
if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
$content = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
$update_data = [
'role' => 'assistant',
'content' => $response_arr['choices'][0]['message']['content'],
'usage' => isset($response_arr['usage']) ? $response_arr['usage'] : []
];
}
// 处理获取到的剧本数据
$script_content = handleScriptWords($content, $enable_emotion);
$result = [
'origin_content' => $content,
'roles' => getProp($script_content, 'roles'),
'words' => getProp($script_content, 'words'),
];
return $result;
}
public function resetParagraphAudio($data) {
$bid = getProp($data, 'bid');
$cid = getProp($data, 'cid');
$version_id = getProp($data, 'version_id');
if (!DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->value('id')) return true;
return DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->update([
'generate_status' => '待制作',
'updated_at' => date('Y-m-d H:i:s')
]);
}
public function saveParagraphAudio($data) {
$bid = getProp($data, 'bid');
$cid = getProp($data, 'cid');
$version_id = getProp($data, 'version_id');
$sequence = getProp($data, 'sequence');
$id = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->where('sequence', $sequence)->value('id');
// 获取所有情感
$emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->pluck('emotion_name', 'emotion_code')->toArray();
$emotion_list = array_flip($emotion_list);
// 获取音色支持情感
$timbre_emotion = DB::table('mp_timbres')->where('timbre_type', getProp($data, 'voice_type'))->value('emotion');
$timbre_emotion = explode(',', $timbre_emotion);
$emotion = getProp($data, 'emotion');
if (!in_array($emotion, $timbre_emotion)) $emotion = '中性';
$emotion_type = isset($emotion_list[$emotion]) ? $emotion_list[$emotion] : 'neutral';
$list = [
'bid' => $bid,
'cid' => $cid,
'version_id' => $version_id,
'sequence' => $sequence,
'role' => getProp($data, 'role'),
'gender' => getProp($data, 'gender'),
'text' => trim(getProp($data, 'text')),
'emotion' => $emotion,
'emotion_type' => $emotion_type,
'voice_type' => getProp($data, 'voice_type'),
'voice_name' => getProp($data, 'voice_name'),
'speed_ratio' => getProp($data, 'speed_ratio', 0),
'loudness_ratio'=> getProp($data, 'loudness_ratio', 0),
'emotion_scale' => getProp($data, 'emotion_scale', 4),
'pitch' => getProp($data, 'pitch', 0),
// 'paragraph_audio_url' => '',
'generate_status' => '制作中',
'error_msg' => '',
'updated_at' => date('Y-m-d H:i:s')
];
$continue = false;
// 判断是否含有中文、数字或英文,如果没有则跳过合成
if (!preg_match('/[\x{4e00}-\x{9fa5}]+/u', $list['text']) && !preg_match('/[0-9]+/', $list['text']) && !preg_match('/[a-zA-Z]+/', $list['text'])) {
$list['generate_status'] = '制作成功';
$list['paragraph_audio_url'] = 'https://zw-audiobook.tos-cn-beijing.volces.com/effects/ellipses_2s.mp3';
$list['subtitle_info'] = '{"duration": 2.0, "words": []}';
$continue = true;
}
// if (getProp($data, 'paragraph_audio_url')) $list['paragraph_audio_url'] = getProp($data, 'paragraph_audio_url');
if ($id) {
$boolen = DB::table('mp_chapter_paragraph_audios')->where('id', $id)->update($list);
}else {
$list['created_at'] = date('Y-m-d H:i:s');
$id = DB::table('mp_chapter_paragraph_audios')->insertGetId($list);
$boolen = $id ? true : false;
}
// 如果更新成功则加入查询队列
if ($boolen) {
$redis_key = "select-{$bid}-{$version_id}-{$cid}";
Redis::sadd($redis_key, $id);
}
if ($boolen && !$continue) {
$boolen = false;
$client = new Client(['timeout' => 300, 'verify' => false]);
// 根据ID通过API通知合成音频
// $result = $client->get("http://47.240.171.155:5000/api/previewTask?taskId={$id}");
$result = $client->get("http://122.9.129.83:5000/api/previewTask?taskId={$id}");
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
$error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
Log::info('通知火山生成段落音频失败: '.$error_msg);
Utils::throwError('20003:通知火山生成段落音频失败');
}
$boolen = true;
}
return $boolen;
}
public function insertAudioEffect($data) {
$audio_id = getProp($data, 'audio_id');
$chapter_audio = DB::table('mp_chapter_audios')->where('id', $audio_id)->first();
$bid = getProp($chapter_audio, 'bid');
$version_id = getProp($chapter_audio, 'version_id');
$cid = getProp($chapter_audio, 'cid');
if (getProp($chapter_audio, 'generate_status') != '制作成功') Utils::throwError('20003:请先完成有声制作');
$audio_effect_json = getProp($data, 'audio_effect_json');
if (!is_string($audio_effect_json)) $audio_effect_json = json_encode($audio_effect_json, 256);
try {
DB::beginTransaction();
$count = DB::table('mp_audio_tasks')->where('audio_id', $audio_id)->count('id');
if (!$count) {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】';
}else {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')';
}
$boolen1 = DB::table('mp_chapter_audios')->where('id', $audio_id)->update([
'audio_effect_status' => '添加中',
'audio_effect_json' => $audio_effect_json,
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$boolen1) {
DB::rollBack();
Utils::throwError('20003:更新生成数据失败');
}
$id = DB::table('mp_audio_tasks')->insertGetId([
'audio_id' => $audio_id,
'generate_status' => '执行中',
'generate_json' => json_encode([], 256),
'bid' => $bid,
'book_name' => getProp($chapter_audio, 'book_name'),
'version_id' => $version_id,
'version_name' => getProp($chapter_audio, 'version_name'),
'cid' => $cid,
'chapter_name' => getProp($chapter_audio, 'chapter_name'),
'task_name' => $task_name,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$id) {
DB::rollBack();
Utils::throwError('20003:创建任务失败');
}
}catch (\Exception $e) {
DB::rollBack();
Utils::throwError('20003:'.$e->getMessage());
}
DB::commit();
$client = new Client(['timeout' => 300, 'verify' => false]);
// 根据ID通过API通知合成音频
// $result = $client->get("http://47.240.171.155:5000/api/postTask?taskId={$id}");
$result = $client->get("http://122.9.129.83:5000/api/postTask?taskId={$id}");
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
$error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
Log::info('通知火山插入音效失败: '.$error_msg);
Utils::throwError('20003:通知火山插入音效失败');
}
return true;
}
public function insertBgm($data) {
$audio_id = getProp($data, 'audio_id');
$chapter_audio = DB::table('mp_chapter_audios')->where('id', $audio_id)->first();
$bid = getProp($chapter_audio, 'bid');
$version_id = getProp($chapter_audio, 'version_id');
$cid = getProp($chapter_audio, 'cid');
if (getProp($chapter_audio, 'generate_status') != '制作成功') Utils::throwError('20003:请先完成有声制作');
$bgm_json = getProp($data, 'bgm_json');
if (!is_string($bgm_json)) $bgm_json = json_encode($bgm_json, 256);
if (!$bgm_json) {
$bgm_json = getProp($data, 'audio_effect_json');
if (!is_string($bgm_json)) $bgm_json = json_encode($bgm_json, 256);
}
try {
DB::beginTransaction();
$count = DB::table('mp_audio_tasks')->where('audio_id', $audio_id)->count('id');
if (!$count) {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】';
}else {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')';
}
$boolen1 = DB::table('mp_chapter_audios')->where('id', $audio_id)->update([
'bgm_status' => '添加中',
'bgm_json' => $bgm_json,
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$boolen1) {
DB::rollBack();
Utils::throwError('20003:更新生成数据失败');
}
$id = DB::table('mp_audio_tasks')->insertGetId([
'audio_id' => $audio_id,
'generate_status' => '执行中',
'generate_json' => json_encode([], 256),
'bid' => $bid,
'book_name' => getProp($chapter_audio, 'book_name'),
'version_id' => $version_id,
'version_name' => getProp($chapter_audio, 'version_name'),
'cid' => $cid,
'chapter_name' => getProp($chapter_audio, 'chapter_name'),
'task_name' => $task_name,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$id) {
DB::rollBack();
Utils::throwError('20003:创建任务失败');
}
}catch (\Exception $e) {
DB::rollBack();
Utils::throwError('20003:'.$e->getMessage());
}
DB::commit();
$client = new Client(['timeout' => 300, 'verify' => false]);
// 根据ID通过API通知合成音频
// $result = $client->get("http://47.240.171.155:5000/api/postTask?taskId={$id}");
$result = $client->get("http://122.9.129.83:5000/api/postTask?taskId={$id}");
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
$error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
Log::info('通知火山插入bgm失败: '.$error_msg);
Utils::throwError('20003:通知火山插入bgm失败');
}
return true;
}
public function getParagraphAudios($data) {
$bid = getProp($data, 'bid');
$cid = getProp($data, 'cid');
$version_id = getProp($data, 'version_id');
$sequence = getProp($data, 'sequence');
// 获取已生成的音频
$query = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id);
if ($sequence) $query->where('sequence', $sequence);
$paragraph_audios = $query->orderBy('sequence')->get();
$result = [];
foreach($paragraph_audios as $item) {
$result[] = [
'sequence' => getProp($item, 'sequence'),
'role' => getProp($item, 'role'),
'gender' => getProp($item, 'gender'),
'text' => trim(getProp($item, 'text')),
'emotion' => getProp($item, 'emotion'),
'emotion_type' => getProp($item, 'emotion_type'),
'voice_type' => getProp($item, 'voice_type'),
'voice_name' => getProp($item, 'voice_name'),
'speed_ratio' => getProp($item, 'speed_ratio'),
'loudness_ratio' => getProp($item, 'loudness_ratio'),
'emotion_scale' => getProp($item, 'emotion_scale'),
'paragraph_audio_url' => getProp($item, 'paragraph_audio_url'),
'subtitle_info' => json_decode(getProp($item, 'subtitle_info'), true),
];
}
return $result;
}
// 新增合成任务
public function addGenerateTask($data) {
$bid = getProp($data, 'bid');
$cid = getProp($data, 'cid');
$version_id = getProp($data, 'version_id');
$generate_json = getProp($data, 'generate_json');
// 获取已生成的音频
$paragraph_audios = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->get();
$paragraph_list = [];
foreach($paragraph_audios as $item) {
$paragraph_list[getProp($item, 'sequence')] = [
'role' => getProp($item, 'role'),
'gender' => getProp($item, 'gender'),
'text' => trim(getProp($item, 'text')),
'emotion' => getProp($item, 'emotion'),
'emotion_type' => getProp($item, 'emotion_type'),
'voice_type' => getProp($item, 'voice_type'),
'voice_name' => getProp($item, 'voice_name'),
'speed_ratio' => getProp($item, 'speed_ratio'),
'loudness_ratio' => getProp($item, 'loudness_ratio'),
'emotion_scale' => getProp($item, 'emotion_scale'),
'paragraph_audio_url' => getProp($item, 'paragraph_audio_url'),
];
}
// 更新角色-音色信息
$existed_role_info = DB::table('mp_book_version')->where('bid', $bid)->where('id', $version_id)->value('role_info');
$existed_role_info = json_decode($existed_role_info, true);
if (!$existed_role_info) $existed_role_info = [];
// 获取情感信息
$emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->pluck('emotion_name', 'emotion_code')->toArray();
$emotion_list = array_flip($emotion_list);
// 获取音色对应情感组
$timbre_emotions = DB::table('mp_timbres')->where('is_enabled', 1)->select('timbre_type', 'emotion')->get();
$timbre_emotion_list = [];
foreach($timbre_emotions as $item) {
$timbre_emotion_list[getProp($item, 'timbre_type')] = explode(',', getProp($item, 'emotion'));
}
// 构造生成音频的json
$words = json_decode($generate_json, true);
// 最终合成前的参数组
$mp_chapter_paragraph_audios = [];
$sequence = 1;
$complete_paragraph_sequences = [];
foreach($words as &$word) {
if (!isset($word['text']) || !isset($word['emotion']) || !isset($word['voice_type']) || !isset($word['voice_name']) || !isset($word['speed_ratio']) || !isset($word['loudness_ratio']) || !isset($word['emotion_scale'])) Utils::throwError('20003:参数格式有误');
if (!($word['text']) || !($word['voice_type']) || !($word['voice_name'])) Utils::throwError('20003:参数不得为空');
$role = getProp($word, 'role');
$word['gender'] = (int)$word['gender'];
// 判断音色对应情感是否支持,不支持则调整为中性
$access_emotion = isset($timbre_emotion_list[$word['voice_type']]) ? $timbre_emotion_list[$word['voice_type']] : [];
if (!in_array($word['emotion'], $access_emotion)) $word['emotion'] = '中性';
// 如果有对应情感则赋值,没有则默认为中性(neutral)
if (isset($emotion_list[getProp($word, 'emotion')])) {
$word['emotion_type'] = $emotion_list[getProp($word, 'emotion')];
}else {
$word['emotion'] = '中性';
$word['emotion_type'] = 'neutral';
}
$existed_role_info[$role] = [
'timbre_type' => $word['voice_type'],
'timbre_name' => $word['voice_name'],
'emotion' => $word['emotion'],
'emotion_type' => $word['emotion_type'],
'speed_ratio' => $word['speed_ratio'],
'loudness_ratio'=> $word['loudness_ratio'],
'emotion_scale' => $word['emotion_scale'],
'pitch' => $word['pitch']
];
$word['paragraph_audio_url'] = '';
// 判断生成参数是否相同,相同则直接使用已生成的音频
$paragraph = isset($paragraph_list[$sequence]) ? $paragraph_list[$sequence] : [];
// 如果音频存在并且参数都未改变则使用已生成的音频
if (getProp($paragraph, 'paragraph_audio_url')) {
// if (getProp($paragraph, 'role') == getProp($word, 'role') && getProp($paragraph, 'text') == getProp($word, 'text') &&
// getProp($paragraph, 'voice_type') == getProp($word, 'voice_type') &&
// getProp($paragraph, 'speed_ratio') == getProp($word, 'speed_ratio') && getProp($paragraph, 'loudness_ratio') == getProp($word, 'loudness_ratio') &&
// getProp($paragraph, 'emotion_scale') == getProp($word, 'emotion_scale'))
// {
// $word['paragraph_audio_url'] = getProp($paragraph, 'paragraph_audio_url');
// }
$word['paragraph_audio_url'] = getProp($paragraph, 'paragraph_audio_url');
}
$tmp = $word;
// 组装章节分句音频数据
// $tmp['sequence'] = getProp($word, 'sequence');
// $tmp['text'] = getProp($word, 'text');
// $tmp['emotion'] = getProp($word, 'emotion');
// $tmp['emotion_type'] = getProp($word, 'emotion_type');
// $tmp['voice_name'] = getProp($word, 'voice_name');
// $tmp['voice_type'] = getProp($word, 'voice_type');
// $tmp['speed_ratio'] = getProp($word, 'speed_ratio');
// $tmp['loudness_ratio'] = getProp($word, 'loudness_ratio');
// $tmp['emotion_scale'] = getProp($word, 'emotion_scale');
$tmp['bid'] = $bid;
$tmp['version_id'] = $version_id;
$tmp['cid'] = $cid;
$tmp['sequence'] = $sequence;
$tmp['created_at'] = date('Y-m-d H:i:s');
$tmp['updated_at'] = date('Y-m-d H:i:s');
if (!getProp($tmp, 'paragraph_audio_url')) Utils::throwError('20003:段落未生成音频,请等待完成后提交');
$mp_chapter_paragraph_audios[] = $tmp;
$complete_paragraph_sequences[] = $sequence;
$sequence++;
}
$generate_json = json_encode($words, 256);
// 判断是否有未生成字幕的段落
$no_subtitle_sequences = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->whereIn('sequence', $complete_paragraph_sequences)->whereNull('subtitle_info')->pluck('sequence')->toArray();
if ($no_subtitle_sequences) {
Utils::throwError('20003:请先将以下段落序号生成字幕('.implode('、', $no_subtitle_sequences).')');
}
try {
DB::beginTransaction();
$role_info = json_encode($existed_role_info, 256);
$boolen = DB::table('mp_book_version')->where('bid', $bid)->where('id', $version_id)->update(['role_info' => $role_info, 'updated_at' => date('Y-m-d H:i:s')]);
if (!$boolen) {
DB::rollBack();
Utils::throwError('20003:更新角色信息失败');
}
$count = DB::table('mp_audio_tasks')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->count('id');
$chapter_audio = DB::table('mp_chapter_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->first();
if (!$count) {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】';
}else {
$task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')';
}
$boolen1 = DB::table('mp_chapter_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->update(['generate_status'=>'制作中', 'generate_json' => $generate_json, 'updated_at' => date('Y-m-d H:i:s')]);
if (!$boolen1) {
DB::rollBack();
Utils::throwError('20003:更新生成数据失败');
}
$id = DB::table('mp_audio_tasks')->insertGetId([
'audio_id' => getProp($chapter_audio, 'id'),
'generate_status' => '执行中',
'generate_json' => $generate_json,
'bid' => $bid,
'book_name' => getProp($chapter_audio, 'book_name'),
'version_id' => $version_id,
'version_name' => getProp($chapter_audio, 'version_name'),
'cid' => $cid,
'chapter_name' => getProp($chapter_audio, 'chapter_name'),
'task_name' => $task_name,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
if (!$id) {
DB::rollBack();
Utils::throwError('20003:创建任务失败');
}
// 删除不在段落序号范围内的其他数据
if ($complete_paragraph_sequences) {
DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->whereNotIn('sequence', $complete_paragraph_sequences)->delete();
}
// // 删除章节分句音频数据并重新插入
// DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->delete();
// $boolen3 = DB::table('mp_chapter_paragraph_audios')->insert($mp_chapter_paragraph_audios);
// if (!$boolen3) {
// DB::rollBack();
// Utils::throwError('20003:更新章节分句音频失败');
// }
} catch (\Exception $e) {
DB::rollBack();
Utils::throwError('20003:'.$e->getMessage());
}
DB::commit();
// 通知火山生成音频
$client = new Client(['timeout' => 300, 'verify' => false]);
// 根据ID通过API通知合成音频
// $result = $client->get("http://47.240.171.155:5000/api/chapterTask?taskId={$id}");
$result = $client->get("http://122.9.129.83:5000/api/chapterTask?taskId={$id}");
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
$error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
Log::info('通知火山生成音频失败: '.$error_msg);
Utils::throwError('20003:通知火山生成音频失败');
}
return true;
}
public function timbreList($data) {
$gender = getProp($data, 'gender');
$timbre_name = getProp($data, 'voice_name');
$category_id = getProp($data, 'category_id');
$query = DB::table('mp_timbres')->where('is_enabled', 1)->select('timbre_name as voice_name', 'timbre_type as voice_type', 'gender', 'language', 'emotion', 'label', 'first_category_id', 'first_category_name', 'second_category_id', 'second_category_name', 'third_category_id', 'third_category_name', 'audio_url');
if ($gender) {
$query->where('gender', $gender);
}
if ($timbre_name) {
$query->where('timbre_name', 'like', "%{$timbre_name}%");
}
if ($category_id) {
$query->where(function ($query) use ($category_id) {
$query->where('first_category_id', $category_id)->orWhere('second_category_id', $category_id)->orWhere('third_category_id', $category_id);
});
}
$list = $query->get()->map(function ($value) {
$value = (array)$value;
$value['voice_name'] = str_replace('(多情感)', '', $value['voice_name']);
return $value;
})->toArray();
return $list;
}
// 生成火山临时token
public function setStsToken() {
// ************* 配置参数 *************
$method = 'GET';
$service = 'sts';
$host = 'open.volcengineapi.com';
$region = env('VOLC_REGION');
$endpoint = 'https://open.volcengineapi.com';
// $endpoint = 'https://tos-cn-beijing.volces.com';
$access_key = env('VOLC_AK');
$secret_key = env('VOLC_SK');
// 获取缓存中的token,如果没有则请求接口
$token = Redis::get('volc_sts_token');
if (!$token) {
// 查询参数
$query_parameters = [
'Action' => 'AssumeRole',
'RoleSessionName' => 'user@zw',
'RoleTrn' => 'trn:iam::2102575520:role/tos_role',
'Version' => '2018-01-01'
];
// 生成URL编码的查询字符串
$request_parameters = http_build_query($query_parameters, '', '&', PHP_QUERY_RFC3986);
// 获取签名头信息
$headers = $this->getSignHeaders($method, $service, $host, $region, $request_parameters, $access_key, $secret_key);
// 构建完整URL
$request_url = $endpoint . '?' . $request_parameters;
$client = new Client(['verify' => false]);
$response = $client->get($request_url, ['headers' => $headers]);
$response_arr = json_decode($response->getBody()->getContents(), true);
$result = [
'SessionToken' => isset($response_arr['Result']['Credentials']['SessionToken']) ? $response_arr['Result']['Credentials']['SessionToken'] : '',
'AccessKeyId' => isset($response_arr['Result']['Credentials']['AccessKeyId']) ? $response_arr['Result']['Credentials']['AccessKeyId'] : '',
'SecretAccessKey' => isset($response_arr['Result']['Credentials']['SecretAccessKey']) ? $response_arr['Result']['Credentials']['SecretAccessKey'] : '',
'Region' => env('VOLC_REGION'),
'Endpoint' => env('VOLC_END_POINT'),
'Bucket' => env('VOLC_BUCKET'),
];
// 缓存token
Redis::setex('volc_sts_token', 3000, json_encode($result));
return $result;
} else {
return json_decode($token, true);
}
// $response = $response['Response'];
// $access_key = $response['Credentials']['AccessKeyId'];
// $secret_key = $response['Credentials']['AccessKeySecret'];
// $security_token = $response['Credentials']['SecurityToken'];
// $expiration = $response['Credentials']['Expiration'];
// dd($response_arr);
}
public function emotionGroups($data) {
$id = getProp($data, 'group_id');
$group_name = getProp($data, 'group_name');
$voice_type = getProp($data, 'voice_type');
$query = DB::table('mp_emotion_groups')->where('is_enabled', 1)->select('id as group_id', 'group_name',
'emotion', 'emotion_type', 'voice_name', 'voice_type', 'speed_ratio', 'loudness_ratio', 'emotion_scale',
'pitch', 'generate_status', 'audio_url', 'error_msg');
if ($group_name) {
$query->where('group_name', 'like', "%{$group_name}%");
}
if ($id) {
$query->where('id', $id);
}
if ($voice_type) {
$query->where('voice_type', $voice_type);
}
return $query->orderBy('id')->get()->map(function ($value) {
return (array)$value;
})->toArray();
}
private function sign($key, $msg) {
return hash_hmac('sha256', $msg, $key, true);
}
// 生成签名密钥
private function getSignatureKey($key, $dateStamp, $regionName, $serviceName) {
$kDate = $this->sign($key, $dateStamp);
$kRegion = $this->sign($kDate, $regionName);
$kService = $this->sign($kRegion, $serviceName);
$kSigning = $this->sign($kService, 'request');
return $kSigning;
}
// 获取签名头信息
private function getSignHeaders($method, $service, $host, $region, $request_parameters, $access_key, $secret_key) {
$contenttype = 'application/x-www-form-urlencoded';
$accept = 'application/json';
// 获取当前UTC时间
$t = new DateTime('now', new DateTimeZone('UTC'));
$xdate = $t->format('Ymd\THis\Z');
$datestamp = $t->format('Ymd');
// 1. 拼接规范请求串
$canonical_uri = '/';
$canonical_querystring = $request_parameters;
$canonical_headers = "content-type:{$contenttype}\nhost:{$host}\nx-date:{$xdate}\n";
$signed_headers = 'content-type;host;x-date';
// 空请求体的SHA256哈希
$payload_hash = hash('sha256', '');
$canonical_request = implode("\n", [
$method,
$canonical_uri,
$canonical_querystring,
$canonical_headers,
$signed_headers,
$payload_hash
]);
// 2. 拼接待签名字符串
$algorithm = 'HMAC-SHA256';
$credential_scope = implode('/', [$datestamp, $region, $service, 'request']);
$hashed_canonical_request = hash('sha256', $canonical_request);
$string_to_sign = implode("\n", [
$algorithm,
$xdate,
$credential_scope,
$hashed_canonical_request
]);
// 3. 计算签名
$signing_key = $this->getSignatureKey($secret_key, $datestamp, $region, $service);
$signature = hash_hmac('sha256', $string_to_sign, $signing_key);
// 4. 添加签名到请求头
$authorization_header = sprintf(
'%s Credential=%s/%s, SignedHeaders=%s, Signature=%s',
$algorithm,
$access_key,
$credential_scope,
$signed_headers,
$signature
);
return [
'Accept' => $accept,
'Content-Type' => $contenttype,
'X-Date' => $xdate,
'Authorization' => $authorization_header
];
}
// 文字合成语音(火山引擎)
public function tts($data) {
$url = 'https://openspeech.bytedance.com/api/v1/tts';
$headers = [
'Authorization' => 'Bearer;'.env('VOLC_TOKEN'),
'Content-Type' => 'application/json; charset=UTF-8'
];
$post_data = [
'app' => [
'appid' => env('VOLC_APPID'),
'token' => env('VOLC_TOKEN'),
'cluster' => 'volcano_tts'
],
'user' => [
'uid' => 'mp_audio'
],
// 'audio' => [
// 'voice_type' =>
// ],
];
}
public function scriptList($data) {
$script_id = getProp($data, 'script_id');
$script_name = getProp($data, 'script_name');
$query = DB::table('mp_scripts')->where('is_deleted', 0)->select('*');
if ($script_id) {
$query->where('id', $script_id);
}
if ($script_name) {
$query->where('script_name', 'like', "%{$script_name}%");
}
return $query->orderBy('created_at', 'desc')->paginate(12);
}
public function scriptInfo($data) {
$script_id = getProp($data, 'script_id');
$script = DB::table('mp_scripts')->where('id', $script_id)->where('is_deleted', 0)
->selectRaw('id as script_id, script_name, intro, highlights, roles, role_relationship, core_contradiction, art_style, scenes, status, start_episode_sequence, end_episode_sequence, episode_num, remark, created_at')->first();
if (!$script) Utils::throwError('20003:该剧本不存在!');
$script = (array)$script;
$script['roles'] = json_decode($script['roles']);
$script['scenes'] = json_decode($script['scenes']);
// 获取分集信息
$episodes = DB::table('mp_script_episodes')->where('script_id', $script_id)->orderBy('episode_number')
->select('id', 'episode_number', 'title', 'content', 'segment_number', 'segment_content')
->orderBy('episode_number')->orderBy('segment_number')->get()->map(function ($value) {
return (array)$value;
})->toArray();
$return_episodes = [];
foreach($episodes as $episode) {
$episode_number = getProp($episode, 'episode_number');
if (isset($return_episodes[$episode_number])) {
$return_episodes[$episode_number]['segments'][] = [
'segment_number' => getProp($episode, 'segment_number'),
'segment_content' => getProp($episode, 'segment_content')
];
}else {
$return_episodes[$episode_number] = [
'episode_number' => $episode_number,
'title' => getProp($episode, 'title'),
'segments' => [
[
'segment_number' => getProp($episode, 'segment_number'),
'segment_content' => getProp($episode, 'segment_content')
]
]
];
}
}
$script['episodes'] = array_values($return_episodes);
return $script;
}
public function createScript($data) {
$script_name = getProp($data, 'script_name');
$uid = Site::getUid(); // 获取当前用户ID
if (!$script_name) Utils::throwError('20001:剧本名称不能为空');
return DB::table('mp_scripts')->insertGetId([
'script_name' => $script_name,
'user_id' => $uid,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]);
}
/**
* 上传文件识别文字内容并与 DeepSeek 进行对话(流式输出版本)
*
* @param array $data 包含以下参数:
* - file: 上传的文件(UploadedFile 对象)或文件路径
* - question: 用户问题(可选)
* - model: 使用的模型(r1 或 v3,默认 v3)
* @return \Generator 返回生成器,用于流式输出
*/
public function chatWithFileStream($data) {
$script_id = getProp($data, 'script_id');
$file = getProp($data, 'file');
$content = getProp($data, 'content', '');
$bid = getProp($data, 'bid', 0);
$start_episode_sequence = getProp($data, 'start_episode_sequence', 1);
$total_episode_num = getProp($data, 'total_episode_num', 30);
$question = getProp($data, 'question', "请根据实例完成一个{$total_episode_num}集的剧本,要求: 1.从第{$start_episode_sequence}集开始; 2.每集内容独立成章; 3.避免重复内容; 4.确保内容连贯且逻辑清晰; 5.所有章节合起来是给出的文档内容全文大概剧情。6.要求一定要给出我需要的分镜集数,不能少7.分集序号用01、02、03显示,不得使用01-1等子集序号,并且需要匹配开始集数的序号并往后顺序显示。");
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
$script = DB::table('mp_scripts')->where('id', $script_id)->first();
if (!$script) {
Utils::throwError('20003:剧本不存在');
}
if (!$file && !$content && !$bid) {
Utils::throwError('20003:请上传文件或选择书籍或输入内容');
}
// 提取文件内容
if ($content) {
$content = filterContent($content);
} elseif ($file) {
$content = $this->extractFileContent($file);
if (!$content) {
Utils::throwError('20003:无法提取文件内容,请检查文件格式');
}
} elseif ($bid) {
$content = $this->getContentByBid($bid);
if (!$content) {
Utils::throwError('20003:无法获取书籍内容,请检查书籍');
}
} else {
Utils::throwError('20003:请上传文件或选择书籍或输入内容');
}
// 构建消息
$messages = [
$this->sys_message,
[
'role' => 'user',
'content' => "以下是文档内容:\n\n{$content}\n\n用户问题:{$question}"
]
];
$post_data = [
'model' => $model,
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 0.7,
'frequency_penalty' => 0,
'presence_penalty' => 0,
'response_format' => ['type' => 'text'],
'stream' => true // 启用流式输出
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$response = $client->post($this->url, [
'json' => $post_data,
'headers' => $this->headers,
'stream' => true // 启用流式响应
]);
$body = $response->getBody();
$fullContent = '';
$fullReasoningContent = '';
$usage = [];
$buffer = '';
Log::info('开始读取 DeepSeek 流式响应');
// 逐行读取流式响应
while (!$body->eof()) {
$chunk = $body->read(1024); // 每次读取 1KB
$buffer .= $chunk;
// 按行分割
$lines = explode("\n", $buffer);
// 保留最后一个不完整的行
$buffer = array_pop($lines);
foreach ($lines as $line) {
$line = trim($line);
// 跳过空行
if (empty($line)) {
continue;
}
// Log::info('收到原始行数据', ['line' => $line]);
// 检查是否是 SSE 数据行
if (strpos($line, 'data: ') !== 0) {
Log::warning('非 SSE 数据行', ['line' => $line]);
continue;
}
$data = substr($line, 6); // 移除 "data: " 前缀
if ($data === '[DONE]') {
Log::info('收到结束标记');
break 2; // 跳出两层循环
}
try {
$json = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Log::warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]);
continue;
}
// Log::info('成功解析 JSON', ['has_choices' => isset($json['choices'])]);
if (isset($json['choices'][0]['delta'])) {
$delta = $json['choices'][0]['delta'];
// 处理思维链内容(仅 deepseek-reasoner 模型)
// 注释掉以下内容则不返回d思维链内容
if (isset($delta['reasoning_content'])) {
$fullReasoningContent .= $delta['reasoning_content'];
// Log::info('输出思维链内容', ['length' => strlen($delta['reasoning_content'])]);
yield [
'type' => 'reasoning',
'content' => $delta['reasoning_content'],
'full_reasoning' => $fullReasoningContent
];
}
// 处理最终回答内容
if (isset($delta['content'])) {
$fullContent .= $delta['content'];
// Log::info('输出回答内容', ['length' => strlen($delta['content'])]);
yield [
'type' => 'content',
'content' => $delta['content'],
'full_content' => $fullContent
];
}
}
// 获取使用统计信息
if (isset($json['usage'])) {
$usage = $json['usage'];
Log::info('收到使用统计', ['usage' => $usage]);
}
} catch (\Exception $e) {
Log::error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
continue;
}
}
}
Log::info('流式读取完成', [
'content_length' => strlen($fullContent),
'reasoning_length' => strlen($fullReasoningContent)
]);
// 处理完整内容并返回最终结果
$script_arr = [];
if ($fullContent) {
$script_arr = handleScriptContent($fullContent);
}
yield [
'type' => 'done',
'script' => $script_arr,
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage
];
}
/**
* 上传文件识别文字内容并与 DeepSeek 进行对话
*
* @param array $data 包含以下参数:
* - file: 上传的文件(UploadedFile 对象)或文件路径
* - question: 用户问题(可选,默认为"请总结这个文件的内容")
* - model: 使用的模型(r1 或 v3,默认 v3)
* @return array
*/
public function chatWithFile($data) {
// dd(handleScriptContent(''));
$script_id = getProp($data, 'script_id');
$file = getProp($data, 'file');
$content = getProp($data, 'content', '');
$bid = getProp($data, 'bid', 0);
$start_episode_sequence = getProp($data, 'start_episode_sequence', 1);
$total_episode_num = getProp($data, 'total_episode_num', 30);
$question = getProp($data, 'question', "请根据实例完成一个{$total_episode_num}集的剧本,要求: 1.从第{$start_episode_sequence}集开始; 2.每集内容独立成章; 3.避免重复内容; 4.确保内容连贯且逻辑清晰; 5.所有章节合起来是给出的文档内容全文大概剧情。6.要求一定要给出我需要的分镜集数,不能少7.分集序号用01、02、03显示,不得使用01-1等子集序号,并且需要匹配开始集数的序号并往后顺序显示。");
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
$script = DB::table('mp_scripts')->where('id', $script_id)->first();
if (!$script) {
Utils::throwError('20003:剧本不存在');
}
if (!$file && !$content && !$bid) {
Utils::throwError('20003:请上传文件或选择书籍或输入内容');
}
// 提取文件内容
if ($content) {
$content = filterContent($content);
} elseif ($file) {
$content = $this->extractFileContent($file);
if (!$content) {
Utils::throwError('20003:无法提取文件内容,请检查文件格式');
}
} elseif ($bid) {
$content = $this->getContentByBid($bid);
if (!$content) {
Utils::throwError('20003:无法获取书籍内容,请检查书籍');
}
} else {
Utils::throwError('20003:请上传文件或选择书籍或输入内容');
}
// 构建消息
$messages = [
$this->sys_message,
[
'role' => 'user',
'content' => "以下是文档内容:\n\n{$content}\n\n用户问题:{$question}"
]
];
$post_data = [
'model' => $model,
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 0.7,
'frequency_penalty' => 0,
'presence_penalty' => 0,
'response_format' => ['type' => 'text'],
'stream' => false
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]);
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
$content = '';
$usage = [];
if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
$content = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
$usage = isset($response_arr['usage']) ? $response_arr['usage'] : [];
}
// \Log::info('返回值内容: '.$content);
$script_arr = [];
// 处理结果
if ($content) {
$script_arr = handleScriptContent($content);
}
return [
'script' => $script_arr,
'answer' => $content,
'usage' => $usage
];
}
public function saveScript($data) {
$script_arr =getProp($data, 'script', []);
if (!$script_arr) Utils::throwError('20003:剧本内容不能为空');
$start_episode_sequence = getProp($data, 'start_episode_sequence', 1);
$script_id = getProp($data, 'script_id');
if (!$script_id) Utils::throwError('20003:剧本ID不能为空');
try {
DB::beginTransaction();
$update_data = [
'intro' => isset($script_arr['intro']) ? $script_arr['intro'] : '',
'highlights' => isset($script_arr['highlights']) ? $script_arr['highlights'] : '',
'role_relationship' => isset($script_arr['role_relationship']) ? $script_arr['role_relationship'] : '',
'core_contradiction' => isset($script_arr['core_contradiction']) ? $script_arr['core_contradiction'] : '',
'roles' => isset($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '',
'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '',
'scenes' => isset($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '',
'status' => 3,
'start_episode_sequence' => $start_episode_sequence,
'end_episode_sequence' => $start_episode_sequence + count($script_arr['episodes']) - 1,
'episode_num' => count($script_arr['episodes']),
'updated_at' => date('Y-m-d H:i:s')
];
// 保存剧本内容
$boolen = DB::table('mp_scripts')->where('id', $script_id)->update($update_data);
if (!$boolen) {
dLog('deepseek')->info('剧本内容保存失败', $update_data);
Utils::throwError('20003:剧本内容保存失败');
}
// 保存分集内容
// 删除历史分集内容
$boolen1 = DB::table('mp_script_episodes')->where('script_id', $script_id)->delete();
$episodes = [];
$sequence = 1;
foreach ($script_arr['episodes'] as $episode) {
$segment_number = 1;
foreach($episode['segments'] as $segment) {
$episodes[] = [
'script_id' => $script_id,
'episode_number' => $episode['episode_number'],
'title' => $episode['title'],
// 'content' => $episode['content'],
'content' => '',
'segment_number' => $segment_number,
'segment_content' => $segment['segment_content'],
'sequence' => $sequence,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
$sequence++;
$segment_number++;
}
}
$boolen2 = DB::table('mp_script_episodes')->insert($episodes);
if (!$boolen2) {
dLog('deepseek')->info('分集内容保存失败', $episodes);
Utils::throwError('20003:分集内容保存失败');
}
} catch (\Exception $e) {
DB::rollBack();
Utils::throwError('20003:'.$e->getMessage());
}
DB::commit();
return true;
}
public function exportScript($data) {
$script = $this->scriptInfo($data);
$export_type = getProp($data, 'export_type', 'txt');
// 生成文件名
$filename = $script['script_name'] . '_' . date('YmdHis');
// 根据导出类型生成不同格式的文件
switch ($export_type) {
case 'txt':
return $this->exportScriptAsTxt($script, $filename);
case 'pdf':
return $this->exportScriptAsPdf($script, $filename);
default:
Utils::throwError('20003:不支持的导出格式,支持:txt、pdf');
}
}
/**
* 导出剧本为TXT格式
*/
private function exportScriptAsTxt($script, $filename) {
// 构建TXT内容
$content = $this->buildScriptContent($script);
// 设置响应头,直接下载
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.txt"');
header('Content-Length: ' . strlen($content));
// 输出内容并结束
echo $content;
exit();
}
/**
* 导出剧本为PDF格式
*/
private function exportScriptAsPdf($script, $filename) {
// 使用HTML转PDF的方式来更好地支持中文
$htmlContent = $this->buildScriptHtmlForPdf($script);
// 创建PDF实例
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
// 设置文档信息
$pdf->SetCreator('剧本导出系统');
$pdf->SetAuthor('系统');
$pdf->SetTitle($script['script_name']);
$pdf->SetSubject('剧本导出');
// 设置边距
$pdf->SetMargins(15, 15, 15);
$pdf->SetAutoPageBreak(TRUE, 15);
// 添加页面
$pdf->AddPage();
// 注册并使用 simsun.ttf 字体
$pdf->SetFont('simsun', '', 12); // 设置字体和大小
// 使用HTML内容生成PDF
$pdf->writeHTML($htmlContent, true, false, true, false, '');
// 直接输出PDF文件供下载
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '.pdf"');
// 直接输出PDF内容
$pdf->Output($filename . '.pdf', 'D');
exit();
}
/**
* 构建用于PDF的HTML内容
*/
private function buildScriptHtmlForPdf($script) {
$html = '';
// 标题
$html .= '
' . htmlspecialchars($script['script_name']) . '
';
// 剧本简介
if (!empty($script['intro'])) {
$html .= '剧本简介
';
$html .= '' . nl2br(htmlspecialchars($script['intro'])) . '
';
}
// 亮点
if (!empty($script['highlights'])) {
$html .= '亮点
';
$html .= '' . nl2br(htmlspecialchars($script['highlights'])) . '
';
}
// 核心矛盾
if (!empty($script['core_contradiction'])) {
$html .= '核心矛盾
';
$html .= '' . nl2br(htmlspecialchars($script['core_contradiction'])) . '
';
}
// 艺术风格
if (!empty($script['art_style'])) {
$html .= '艺术风格
';
$html .= '' . nl2br(htmlspecialchars($script['art_style'])) . '
';
}
// 角色列表
if (!empty($script['roles']) && is_array($script['roles'])) {
$html .= '角色列表
';
$html .= '';
foreach ($script['roles'] as $role) {
if (is_array($role) || is_object($role)) {
$html .= '- ' . htmlspecialchars(getProp($role, 'role', '')) . ':' . htmlspecialchars(getProp($role, 'description', '')) . '
';
} else {
$html .= '- ' . htmlspecialchars($role) . '
';
}
}
$html .= '
';
}
// 角色关系
if (!empty($script['role_relationship'])) {
$html .= '角色关系
';
$html .= '' . nl2br(htmlspecialchars($script['role_relationship'])) . '
';
}
// 场景列表
if (!empty($script['scenes']) && is_array($script['scenes'])) {
$html .= '场景列表
';
$html .= '';
foreach ($script['scenes'] as $scene) {
if (is_array($scene) || is_object($scene)) {
$html .= '- ' . htmlspecialchars(getProp($scene, 'scene', '')) . ':' . htmlspecialchars(getProp($scene, 'description', '')) . '
';
} else {
$html .= '- ' . htmlspecialchars($scene) . '
';
}
}
$html .= '
';
}
// 分集内容
if (!empty($script['episodes']) && is_array($script['episodes'])) {
$html .= '分集内容
';
foreach ($script['episodes'] as $episode) {
$html .= '';
$html .= '
第' . htmlspecialchars($episode['episode_number']) . '集';
if (!empty($episode['title'])) {
$html .= ':' . htmlspecialchars($episode['title']);
}
$html .= '
';
// 处理分段内容
if (!empty($episode['segments']) && is_array($episode['segments'])) {
foreach ($episode['segments'] as $segment) {
if (!empty($segment['segment_content'])) {
$html .= '
';
if (!empty($segment['segment_number'])) {
$html .= '第' . htmlspecialchars($segment['segment_number']) . '段
';
}
$html .= nl2br(htmlspecialchars($segment['segment_content']));
$html .= '
';
}
}
}
$html .= '
';
}
}
return $html;
}
/**
* 构建PDF内容
*/
private function buildPdfContent($pdf, $script) {
// 标题
$pdf->SetFont('dejavusans', 'B', 18);
$pdf->Cell(0, 15, $script['script_name'], 0, 1, 'C');
$pdf->Ln(5);
// 基本信息
$pdf->SetFont('dejavusans', '', 12);
$pdf->Cell(0, 8, '创建时间:' . $script['created_at'], 0, 1);
$pdf->Cell(0, 8, '状态:' . $script['status_info'], 0, 1);
$pdf->Ln(5);
// 剧本简介
if (!empty($script['intro'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '剧本简介', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['intro'], 0, 'L');
$pdf->Ln(3);
}
// 亮点
if (!empty($script['highlights'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '亮点', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['highlights'], 0, 'L');
$pdf->Ln(3);
}
// 核心矛盾
if (!empty($script['core_contradiction'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '核心矛盾', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['core_contradiction'], 0, 'L');
$pdf->Ln(3);
}
// 艺术风格
if (!empty($script['art_style'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '艺术风格', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['art_style'], 0, 'L');
$pdf->Ln(3);
}
// 备注
if (!empty($script['remark'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '备注', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['remark'], 0, 'L');
$pdf->Ln(3);
}
// 角色列表
if (!empty($script['roles']) && is_array($script['roles'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '角色列表', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
foreach ($script['roles'] as $index => $role) {
if (is_array($role) || is_object($role)) {
$pdf->Cell(0, 8, ($index + 1) . '. ' . getProp($role, 'role', '') . ':' . getProp($role, 'description', ''), 0, 1);
} else {
$pdf->Cell(0, 8, ($index + 1) . '. ' . $role, 0, 1);
}
}
$pdf->Ln(3);
}
// 角色关系
if (!empty($script['role_relationship'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '角色关系', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
$pdf->MultiCell(0, 8, $script['role_relationship'], 0, 'L');
$pdf->Ln(3);
}
// 场景列表
if (!empty($script['scenes']) && is_array($script['scenes'])) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, '场景列表', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
foreach ($script['scenes'] as $index => $scene) {
if (is_array($scene) || is_object($scene)) {
$pdf->Cell(0, 8, ($index + 1) . '. ' . getProp($scene, 'scene', '') . ':' . getProp($scene, 'description', ''), 0, 1);
} else {
$pdf->Cell(0, 8, ($index + 1) . '. ' . $scene, 0, 1);
}
}
$pdf->Ln(5);
}
// 分集内容
if (!empty($script['episodes']) && is_array($script['episodes'])) {
$pdf->SetFont('dejavusans', 'B', 16);
$pdf->Cell(0, 12, '分集内容', 0, 1);
$pdf->Ln(3);
foreach ($script['episodes'] as $episode) {
// 检查是否需要新页面
if ($pdf->GetY() > 250) {
$pdf->AddPage();
}
$pdf->SetFont('dejavusans', 'B', 14);
$episodeTitle = '第' . $episode['episode_number'] . '集';
if (!empty($episode['title'])) {
$episodeTitle .= ':' . $episode['title'];
}
$pdf->Cell(0, 10, $episodeTitle, 0, 1);
// 处理分段内容
if (!empty($episode['segments']) && is_array($episode['segments'])) {
$pdf->SetFont('dejavusans', '', 12);
foreach ($episode['segments'] as $segment) {
if (!empty($segment['segment_content'])) {
// 如果有分段编号,显示分段标题
if (!empty($segment['segment_number'])) {
$pdf->SetFont('dejavusans', 'B', 12);
$pdf->Cell(0, 8, '第' . $segment['segment_number'] . '段', 0, 1);
$pdf->SetFont('dejavusans', '', 12);
}
$pdf->MultiCell(0, 8, $segment['segment_content'], 0, 'L');
$pdf->Ln(2);
}
}
}
$pdf->Ln(5);
}
}
}
/**
* 构建剧本文本内容
*/
private function buildScriptContent($script) {
$content = '';
// 剧本基本信息
if (!empty($script['intro'])) {
$content .= "###故事梗概\n" . $script['intro'] . "\n\n";
}
if (!empty($script['highlights'])) {
$content .= "###剧本亮点\n" . $script['highlights'] . "\n\n";
}
if (!empty($script['role_relationship'])) {
$content .= "###人物关系\n" . $script['role_relationship'] . "\n\n";
}
if (!empty($script['core_contradiction'])) {
$content .= "###核心矛盾\n" . $script['core_contradiction'] . "\n\n";
}
if (!empty($script['roles']) && is_array($script['roles'])) {
$content .= "###主体列表\n";
foreach ($script['roles'] as $role) {
$content .= getProp($role, 'role') . ":" . getProp($role, 'description') . "\n";
}
$content .= "\n\n";
}
if (!empty($script['art_style'])) {
$content .= "###美术风格\n" . $script['art_style'] . "\n\n";
}
// 场景信息
if (!empty($script['scenes']) && is_array($script['scenes'])) {
$content .= "###场景列表\n";
foreach ($script['scenes'] as $scene) {
$content .= getProp($scene, 'scene') . ":" . getProp($scene, 'description') . "\n";
}
$content .= "\n\n";
}
// 分集内容
if (!empty($script['episodes']) && is_array($script['episodes'])) {
$content .= "###分集剧本\n";
foreach ($script['episodes'] as $episode) {
$content .= "##分集" . $episode['episode_number'] . "\n分集名";
if (!empty($episode['title'])) {
$content .= ":" . $episode['title'];
}
$content .= "\n";
// 处理分镜
if (!empty($episode['segments']) && is_array($episode['segments'])) {
foreach ($episode['segments'] as $segment) {
$content .= "分镜" . $episode['episode_number'] . '-' . $segment['segment_number'] . "\n";
$content .= $segment['segment_content'] . "\n\n";
}
}
}
}
return $content;
}
/**
* 上传文件识别文字内容并与 DeepSeek 进行对话(流式输出版本)
*
* @param array $data 包含以下参数:
* - file: 上传的文件(UploadedFile 对象)或文件路径
* - question: 用户问题(可选)
* - model: 使用的模型(r1 或 v3,默认 v3)
* @return \Generator 返回生成器,用于流式输出
*/
public function addChat($data) {
$uid = Site::getUid();
$file = getProp($data, 'file');
$content = getProp($data, 'content', '');
$bid = getProp($data, 'bid', 0);
$question =getProp($data, 'question', "请根据实例格式完成剧本大纲");
$prompt = getProp($data, 'prompt', '帮我根据给出的内容生成短剧大纲');
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
if (!$file && !$content && !$bid) {
Utils::throwError('20003:请上传文件或选择书籍或输入内容');
}
// 提取文件内容
if ($content) {
$content = filterContent($content);
} elseif ($file) {
$content = $this->extractFileContent($file);
if (!$content) {
Utils::throwError('20003:无法提取文件内容,请检查文件格式');
}
} elseif ($bid) {
$content = $this->getContentByBid($bid);
if (!$content) {
Utils::throwError('20003:无法获取书籍内容,请检查书籍');
}
} else {
Utils::throwError('20003:请上传文件或选择书籍或输入内容');
}
// 美术风格
$input_art_style = getProp($data, 'art_style');
// 构建消息
$messages = [
[
'role' => 'system',
'content' => "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n
1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n
2.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述\n
3.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答\n
示例如下:\n\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。
阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
阴阳球:天地异宝,能化生精气,救人于危难。
问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。"
],
[
'role' => 'user',
'content' => "以下是文档内容:
{$content}
用户问题:
{$question}"
]
];
$post_data = [
'model' => $model,
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 0.7,
'frequency_penalty' => 0,
'presence_penalty' => 0,
'response_format' => ['type' => 'text'],
'stream' => true // 启用流式输出
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$response = $client->post($this->url, [
'json' => $post_data,
'headers' => $this->headers,
'stream' => true // 启用流式响应
]);
$body = $response->getBody();
$fullContent = '';
$fullReasoningContent = '';
$usage = [];
$buffer = '';
dLog('deepseek')->info('开始读取 DeepSeek 流式响应');
// 逐行读取流式响应
while (!$body->eof()) {
$chunk = $body->read(1024); // 每次读取 1KB
$buffer .= $chunk;
// 按行分割
$lines = explode("\n", $buffer);
// 保留最后一个不完整的行
$buffer = array_pop($lines);
foreach ($lines as $line) {
$line = trim($line);
// 跳过空行
if (empty($line)) {
continue;
}
// dLog('deepseek')->info('收到原始行数据', ['line' => $line]);
// 检查是否是 SSE 数据行
if (strpos($line, 'data: ') !== 0) {
dLog('deepseek')->warning('非 SSE 数据行', ['line' => $line]);
continue;
}
$data = substr($line, 6); // 移除 "data: " 前缀
if ($data === '[DONE]') {
dLog('deepseek')->info('收到结束标记');
break 2; // 跳出两层循环
}
try {
$json = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
dLog('deepseek')->warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]);
continue;
}
// dLog('deepseek')->info('成功解析 JSON', ['has_choices' => isset($json['choices'])]);
if (isset($json['choices'][0]['delta'])) {
$delta = $json['choices'][0]['delta'];
// 处理思维链内容(仅 deepseek-reasoner 模型)
// 注释掉以下内容则不返回d思维链内容
if (isset($delta['reasoning_content'])) {
$fullReasoningContent .= $delta['reasoning_content'];
// dLog('deepseek')->info('输出思维链内容', ['length' => strlen($delta['reasoning_content'])]);
yield [
'type' => 'reasoning',
'content' => $delta['reasoning_content'],
'full_reasoning' => $fullReasoningContent
];
}
// 处理最终回答内容
if (isset($delta['content'])) {
$fullContent .= $delta['content'];
// dLog('deepseek')->info('输出回答内容', ['length' => strlen($delta['content'])]);
yield [
'type' => 'content',
'content' => $delta['content'],
'full_content' => $fullContent
];
}
}
// 获取使用统计信息
if (isset($json['usage'])) {
$usage = $json['usage'];
dLog('deepseek')->info('收到使用统计', ['usage' => $usage]);
}
} catch (\Exception $e) {
dLog('deepseek')->error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
continue;
}
}
}
dLog('deepseek')->info('流式读取完成', [
'content_length' => strlen($fullContent),
'reasoning_length' => strlen($fullReasoningContent)
]);
dLog('deepseek')->info('完整内容: '.$fullContent);
// 处理完整内容并返回最终结果
$script_arr = [];
if ($fullContent) {
$script_arr = handleScriptContent($fullContent);
}
// 替换美术风格
$mappedArtStyle = $this->getArtStylePromptByInput($input_art_style);
if ($mappedArtStyle !== '') {
$script_arr['art_style'] = $mappedArtStyle;
$fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle);
}
// 新建动漫
$anime_name = getProp($script_arr, 'script_name');
if (!$anime_name) {
yield [
'type' => 'done',
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => '未能识别到剧本名称,请确认内容是否符合要求'
];
}
// 确认对话名称唯一性
if (DB::table('mp_animes')->where('anime_name', $anime_name)->value('id')) {
$anime_name = $anime_name . '_' . date('YmdHis');
}
$table_data = [
'user_id' => $uid,
'anime_name' => $anime_name,
'intro' => isset($script_arr['intro']) ? $script_arr['intro'] : '',
'highlights' => isset($script_arr['highlights']) ? $script_arr['highlights'] : '',
'role_relationship' => isset($script_arr['role_relationship']) ? $script_arr['role_relationship'] : '',
'core_contradiction' => isset($script_arr['core_contradiction']) ? $script_arr['core_contradiction'] : '',
'roles' => isset($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '',
'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '',
'scenes' => isset($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '',
'status' => '解析完成',
'content' => $content,
// 'start_episode_sequence' => 1,
// 'end_episode_sequence' => count($script_arr['episodes']),
// 'episode_num' => count($script_arr['episodes']),
'generate_status' => '待执行',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
try {
DB::beginTransaction();
// 新建动漫大纲
$anime_id = DB::table('mp_animes')->insertGetId($table_data);
if (!$anime_id) {
dLog('deepseek')->info('对话保存失败', $table_data);
Utils::throwError('20003:对话保存失败');
}
// 保存对话记录
$recrods = [
[
'uid' => $uid,
'anime_id' => $anime_id,
'sequence' => 0,
'role' => 'user',
'content' => "用户问题:\n
{$prompt}\n\n
以下是文档内容:\n
{$content}",
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
],
[
'uid' => $uid,
'anime_id' => $anime_id,
'sequence' => 0,
'role' => 'assistant',
'content' => $fullContent,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]
];
// 保存对话记录
$boolen3 = DB::table('mp_anime_records')->insert($recrods);
if (!$boolen3) {
Utils::throwError('20003:对话记录保存失败');
}
}catch (\Exception $e) {
DB::rollBack();
dLog('deepseek')->info('新建对话错误信息: '.$e->getMessage());
yield [
'type' => 'done',
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => $e->getMessage(),
];
}
DB::commit();
$table_data['anime_id'] = $anime_id;
$table_data['roles'] = json_decode($table_data['roles'], true);
$table_data['scenes'] = json_decode($table_data['scenes'], true);
// $table_data['episodes'] = $script_arr['episodes'];
unset($table_data['created_at']);
unset($table_data['updated_at']);
unset($table_data['status']);
yield [
'type' => 'done',
'script' => $table_data,
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage
];
}
public function reGenerateAnime($data) {
$uid = Site::getUid();
$file = getProp($data, 'file');
$content = getProp($data, 'content', '');
$bid = getProp($data, 'bid', 0);
$anime_id = getProp($data, 'anime_id');
$anime = DB::table('mp_animes')->where('id', $anime_id)->first();
if (!$anime) {
Utils::throwError('20003:该对话不存在');
}
$question =getProp($data, 'question', "请根据实例格式完成剧本大纲");
$prompt = getProp($data, 'prompt', '帮我根据给出的内容生成短剧大纲');
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
// 提取文件内容
if ($content) {
$content = filterContent($content);
} elseif ($file) {
$content = $this->extractFileContent($file);
if (!$content) {
Utils::throwError('20003:无法提取文件内容,请检查文件格式');
}
} elseif ($bid) {
$content = $this->getContentByBid($bid);
if (!$content) {
Utils::throwError('20003:无法获取书籍内容,请检查书籍');
}
} else {
$content = filterContent(getProp($anime, 'content'));
}
// 美术风格
$input_art_style = getProp($data, 'art_style');
$system_message = [
'role' => 'system',
'content' => "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n
1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n
2.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述\n
3.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答\n
示例如下:\n\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。
阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
阴阳球:天地异宝,能化生精气,救人于危难。
问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。"
];
// 如果提示词$prompt有类似于改成N集或调整成N集这类的文字信息,则提取出N这个集数
$episode_count = $this->extractEpisodeNumber($prompt);
if ($episode_count) {
$question = "请根据实例格式完成{$episode_count}集剧本大纲";
$system_message = [
'role' => 'system',
'content' => "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容及下面提供的示例格式完成剧本大纲(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分集详细内容>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n
1.<主体列表>中出现的主体必须尽可能统一,只允许同一个角色年龄段相差较大的情况下可重复,比如梁萧-少年、梁萧-青年、梁萧-中年、梁萧-老年这种情况,不得出现梁萧-愤怒、梁萧-隐忍、梁萧-求生等情绪和状态变化的同一角色;并且角色的样貌、形体及穿着描述尽可能详细\n
2.<场景列表>与主体列表同理,需尽可能的对场景环境进行详细描述\n
3.<分集详细内容>中每一集的内容需尽可能的详细,保留对话内容(使用原文内容,不得自己生成),一般每集字数尽量不低于500字,每集结尾需以##第N章 作为章节标题\n
4.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答\n
示例如下:\n\n
###剧本名:西昆仑
###故事梗概
内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###剧本亮点
亮点1:绝境奇药,生死一线
画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
亮点2:结拜兄妹,化解尴尬
阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
亮点3:纤发夺命,情愫暗涌
拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
###人物关系
阿雪与梁萧之间存在兄妹之情。
###核心矛盾
阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
###主体列表
阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。
阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。
梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
阴阳球:天地异宝,能化生精气,救人于危难。
问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
###美术风格
基础画风风格词:厚涂古风
整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
###场景列表
林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。
###分集详细内容
##第1章 重生
我重生了。
源于一个男人偏执的暗恋。
##第2章 相救
我一到家,徐母的扫帚就落在我身上,嘴里一个劲地骂我,问我夜不归宿死哪儿去了。
我眼睛扫向站在锅炉后的卞大伟。"
];
// 构建消息
$messages = [
$system_message,
[
'role' => 'user',
'content' => "以下是文档内容:
{$content}
用户问题:
{$question}"
]
];
}else {
if (strstr($prompt, '重新生成') || strstr($prompt, '再生成')) {
// 构建消息
$messages = [
$system_message,
[
'role' => 'user',
'content' => "以下是文档内容:
{$content}
用户问题:
{$question}"
]
];
}else {
$messages = DB::table('mp_anime_records')->where('anime_id', $anime_id)->where('sequence', 0)
->orderBy('id')->select('role', 'content')->get()->map(function ($value) {
return (array)$value;
})->toArray();
array_unshift($messages, $system_message);
$messages[] = [
'role' => 'user',
'content' => $prompt
];
}
}
$post_data = [
'model' => $model,
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 0.7,
'frequency_penalty' => 0,
'presence_penalty' => 0,
'response_format' => ['type' => 'text'],
'stream' => true // 启用流式输出
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$response = $client->post($this->url, [
'json' => $post_data,
'headers' => $this->headers,
'stream' => true // 启用流式响应
]);
$body = $response->getBody();
$fullContent = '';
$fullReasoningContent = '';
$usage = [];
$buffer = '';
dLog('deepseek')->info('开始读取 DeepSeek 流式响应');
// 逐行读取流式响应
while (!$body->eof()) {
$chunk = $body->read(1024); // 每次读取 1KB
$buffer .= $chunk;
// 按行分割
$lines = explode("\n", $buffer);
// 保留最后一个不完整的行
$buffer = array_pop($lines);
foreach ($lines as $line) {
$line = trim($line);
// 跳过空行
if (empty($line)) {
continue;
}
// dLog('deepseek')->info('收到原始行数据', ['line' => $line]);
// 检查是否是 SSE 数据行
if (strpos($line, 'data: ') !== 0) {
dLog('deepseek')->warning('非 SSE 数据行', ['line' => $line]);
continue;
}
$data = substr($line, 6); // 移除 "data: " 前缀
if ($data === '[DONE]') {
dLog('deepseek')->info('收到结束标记');
break 2; // 跳出两层循环
}
try {
$json = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
dLog('deepseek')->warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]);
continue;
}
// dLog('deepseek')->info('成功解析 JSON', ['has_choices' => isset($json['choices'])]);
if (isset($json['choices'][0]['delta'])) {
$delta = $json['choices'][0]['delta'];
// 处理思维链内容(仅 deepseek-reasoner 模型)
// 注释掉以下内容则不返回d思维链内容
if (isset($delta['reasoning_content'])) {
$fullReasoningContent .= $delta['reasoning_content'];
// dLog('deepseek')->info('输出思维链内容', ['length' => strlen($delta['reasoning_content'])]);
yield [
'type' => 'reasoning',
'content' => $delta['reasoning_content'],
'full_reasoning' => $fullReasoningContent
];
}
// 处理最终回答内容
if (isset($delta['content'])) {
$fullContent .= $delta['content'];
// dLog('deepseek')->info('输出回答内容', ['length' => strlen($delta['content'])]);
yield [
'type' => 'content',
'content' => $delta['content'],
'full_content' => $fullContent
];
}
}
// 获取使用统计信息
if (isset($json['usage'])) {
$usage = $json['usage'];
dLog('deepseek')->info('收到使用统计', ['usage' => $usage]);
}
} catch (\Exception $e) {
dLog('deepseek')->error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
continue;
}
}
}
dLog('deepseek')->info('流式读取完成', [
'content_length' => strlen($fullContent),
'reasoning_length' => strlen($fullReasoningContent)
]);
dLog('deepseek')->info('完整内容: '.$fullContent);
// 处理完整内容并返回最终结果
$script_arr = [];
if ($fullContent) {
$script_arr = handleScriptContent($fullContent);
}
// 替换美术风格
$mappedArtStyle = $this->getArtStylePromptByInput($input_art_style);
if ($mappedArtStyle !== '') {
$script_arr['art_style'] = $mappedArtStyle;
$fullContent = $this->replaceArtStyleSection($fullContent, $mappedArtStyle);
}
// // 新建动漫
// $anime_name = getProp($script_arr, 'script_name');
// if (!$anime_name) {
// yield [
// 'type' => 'done',
// 'answer' => $fullContent,
// 'reasoning' => $fullReasoningContent,
// 'usage' => $usage,
// 'error' => '未能识别到剧本名称,请确认内容是否符合要求'
// ];
// }
// // 确认对话名称唯一性
// if (DB::table('mp_animes')->where('anime_name', $anime_name)->value('id')) {
// $anime_name = $anime_name . '_' . date('YmdHis');
// }
$table_data = [
'user_id' => $uid,
// 'anime_name' => $anime_name,
'intro' => isset($script_arr['intro']) ? $script_arr['intro'] : '',
'highlights' => isset($script_arr['highlights']) ? $script_arr['highlights'] : '',
'role_relationship' => isset($script_arr['role_relationship']) ? $script_arr['role_relationship'] : '',
'core_contradiction' => isset($script_arr['core_contradiction']) ? $script_arr['core_contradiction'] : '',
'roles' => isset($script_arr['roles']) ? json_encode($script_arr['roles'], 256) : '',
'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '',
'scenes' => isset($script_arr['scenes']) ? json_encode($script_arr['scenes'], 256) : '',
'status' => '解析完成',
'generate_status' => '待执行',
'updated_at' => date('Y-m-d H:i:s')
];
// 如果是修改集数,则将content内容更新(会改变原文)
if (isset($episode_count) && $episode_count > 0) {
if (isset($script_arr['content']) && $script_arr['content']) $table_data['content'] = $script_arr['content'];
$table_data['end_episode_sequence'] = $table_data['episode_num'] = $episode_count;
}else {
$chapters = $this->splitContent($content);
$chapter_count = count($chapters);
if ($chapter_count > 0) {
$table_data['end_episode_sequence'] = $table_data['episode_num'] = $chapter_count;
}
}
try {
DB::beginTransaction();
// 新建动漫大纲
$boolen = DB::table('mp_animes')->where('id', $anime_id)->update($table_data);
if (!$boolen) {
dLog('deepseek')->info('对话修改失败', $table_data);
Utils::throwError('20003:对话修改失败');
}
// 保存对话记录
$recrods = [
[
'uid' => $uid,
'anime_id' => $anime_id,
'sequence' => 0,
'role' => 'user',
'content' => $prompt,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
],
[
'uid' => $uid,
'anime_id' => $anime_id,
'sequence' => 0,
'role' => 'assistant',
'content' => $fullContent,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]
];
// 保存对话记录
$boolen3 = DB::table('mp_anime_records')->insert($recrods);
if (!$boolen3) {
Utils::throwError('20003:对话记录保存失败');
}
}catch (\Exception $e) {
DB::rollBack();
dLog('deepseek')->info('新建对话错误信息: '.$e->getMessage());
yield [
'type' => 'done',
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => $e->getMessage(),
];
}
DB::commit();
$table_data['anime_id'] = $anime_id;
$table_data['roles'] = json_decode($table_data['roles'], true);
$table_data['scenes'] = json_decode($table_data['scenes'], true);
unset($table_data['updated_at']);
unset($table_data['status']);
yield [
'type' => 'done',
'script' => $table_data,
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage
];
}
public function chat($data) {
$uid = Site::getUid();
$anime_id = getProp($data, 'anime_id');
$episode_number = getProp($data, 'episode_number', 1);
$anime = DB::table('mp_animes')->where('id', $anime_id)->select('content', 'roles', 'scenes')->first();
if (!$anime) Utils::throwError('20003:该对话不存在!');
$roles = json_decode(getProp($anime, 'roles'), true);
$scenes = json_decode(getProp($anime, 'scenes'), true);
// 转换主体列表和场景列表的格式
$roles_content = '';
foreach ($roles as $role) {
$roles_content .= getProp($role, 'role').': '.getProp($role, 'description')."\n";
}
$scenes_content = '';
foreach($scenes as $scene) {
$scenes_content .= getProp($scene, 'scene').': '.getProp($scene, 'description')."\n";
}
$prompt = getProp($data, 'prompt', '确认分镜大纲');
$demo = "你是一个专业的文档分析助手及资深编剧,请根据用户提供的文档内容完成示例格式的内容输出(需包含以下几个板块: <剧集标题><故事梗概><美术风格><主体列表><场景列表><分镜剧本>,每个板块之间用###分隔;同时板块之间需满足以下要求:\n
0.你只负责与剧本相关的内容,如果用户输入与剧本无关的内容,请告知用户并拒绝继续回答\n
1.剧集标题必须与文档内容高度相关,剧集序号是{$episode_number}
2.<主体列表>必须在以下主体中选择多个(根据拆分出的分镜剧本来确认具体几个),不得自定义(需带上冒号后的具体描述):{$roles_content}\n
3.<场景列表>必须在以下场景中选择多个(根据拆分出的分镜剧本来确认具体几个),不得自定义(需带上冒号后的具体描述):{$scenes_content}\n
4.<分镜剧本>要求包含“分幕标题”以及“分镜信息”: \n
4.1“分幕标题”是根据章节中的场景进行拆分,类似于“第1幕 场景、时间及室内或室外”的信息,其中场景只能在所给出的场景列表中选(不带场景详细描述)。\n
4.2“分镜信息”需同一场景内的对话内容完成分镜(每一幕分镜尽量不得低于5个),需包含“画面描述”、“构图设计”、“运镜调度”、“配音音色”、“台词内容”和“画面类型”这几个部分,其中“画面描述”如果出现主体,只能选择上述主体列表中的一个,主体的名称也需要保持一致(不带主体具体描述)。\n
4.3分镜序号用1、2、3显示,如分镜1、分镜2,并且分镜序号需连续(跨分幕中的分镜序号需连续,但跨剧集中的不需要连续,如第2集的第1幕分镜序号从1开始)。\n
4.4“分镜信息”中的“台词内容”需与原文中的台词完全一致,不得修改或添加任何内容。\n
4.5“分镜信息”中的“配音角色”需对应本集中的<主体列表>中的一个(非对话内容则统一固定为“旁白”角色),不得自己生成。\n
4.6“分镜信息”中的“场景”需对应本集中的<场景列表>中的一个(不带详细表述)\n
4.7“分镜信息”中的“出镜角色”需对应本集中的<主体列表>中的一个或多个(不带详细描述),如果没有出镜角色则填无\n
4.8“分镜信息”中的“尾帧描述”需根据本镜和下一镜的剧本信息进行过渡生成,用于后续转视频进行衔接上下镜头的画面\n
示例格式:\n\n
第1集:隐形的守护者
###故事梗概
内容梗概:许芸归家遭徐母毒打,她冷静周旋并编造谎言掩盖行踪,引得一旁阴沉的卞大伟愈发急躁。半个月后,徐母离家,卞大伟兽性大发欲行不轨。生死关头,隐形的程景现身肉搏,屋内家具横飞,卞大伟被“空气”重创。许芸趁乱逃出,却惊见地面凭空浮现一串血脚印。意识到守护者为救自己负伤,许芸不再逃避,对着虚无的空气执拗告白,誓要与这看不见的爱人共进退。
###美术风格
基础画风风格词:韩漫二次元
视觉风格描述:整体采用精致的韩漫厚涂风格,以浅灰冷调为基调,营造出重生复仇的清冷与压抑感。画面强调高对比度的光影处理,网吧场景通过低饱和度的杂乱色调表现颓废感,而清晨车祸瞬间则利用强烈的动态模糊与局部高光,突显“透明守护者”存在的神秘感与宿命感。人物形象细腻,眼神充满坚毅与破碎交织的故事感,完美契合偏执救赎的主题。
###主体列表
许芸-校服装:重生后的“假千金”,清醒果敢,不再任人唯亲。她是一名高中生,身材纤细,留着乌黑齐肩短发,眼神坚定。
许芸-卫衣装:半个月后居家或外出时的装扮,在清冷的环境中透着一丝坚毅与破碎感。
程景:偏执深情的学霸,为救许芸变得“不可见”,是始终默默守护的隐形爱人。他身材高大挺拔,面容英俊。
卞大伟:许芸名义上的表哥,阴险暴戾的无业游民。他身材肥胖,面部肉感重,一双小眼睛透着阴冷。
徐母:许芸亲生母亲,重男轻女且极度刻薄,面容严厉。
###场景列表
徐家老旧厨房:这是徐母劳作和惩罚许芸的地方。空间狭窄阴暗,充满了生活琐碎的压抑感。室内有一个旧式锅炉,案板上散落着面粉和包好的馄饨,光影对比强烈,凸显出家庭环境的冷酷。
徐家简陋客厅:半个月后冲突爆发的中心。陈设简单且陈旧,只有几把木凳和一张方桌。窗帘半掩,透进微弱的冷光,营造出一种暴风雨前的宁静与不安感。
老旧公寓门口:许芸逃出家门后回望的视点。门框斑驳,透出屋内昏暗压抑的光线,与室外略显空旷的走廊形成对比,象征着逃离与未知的守护。
破败巷弄与臭水沟:家门外的外部环境。一条狭窄、潮湿的巷弄,旁边是散发着冷气的臭水沟,水面偶尔泛起涟漪。这里是“透明守护者”受伤后经过的地方,氛围凄清。
潮湿的巷弄地面:特写视角。灰色的水泥地面湿迹斑斑,这是许芸发现血脚印并进行最后告白的地方。强调地面的质感和清冷的基调。
###分镜剧本
##第1幕:徐家老旧厨房 白天 室内
分镜1
画面描述:许芸-校服装站在厨房门口,书包背在肩上,徐母手持扫帚,正对着许芸的背部猛烈挥打。卞大伟站在锅炉后方,双手抱胸,阴沉的目光落在许芸身上。
场景:徐家老旧厨房
构图设计:全景,低角度仰视
运镜调度:手持拍摄
配音角色:旁白
出镜角色:许芸-校服装、徐母
台词内容:我一到家,徐母的扫帚就落在我身上,嘴里一个劲地骂我,
画面类型:普通画面
尾帧描述:手持拍摄微晃镜头,中年妇女猛力挥动扫帚击打少女背部,少女身体因冲击微颤,阴影中的肥胖男子目光冷冷跟随。
分镜2
画面描述:徐母面容扭曲,嘴唇快速开合,扫帚挥舞在空中。
场景:徐家老旧厨房
构图设计:近景,徐母面部特写
运镜调度:固定镜头
配音角色:徐母
出镜角色:徐母
台词内容:问我夜不归宿死哪儿去了。
画面类型:对口型
尾帧描述:固定镜头,中年妇女面部肌肉剧烈抖动,嘴唇快速开合进行辱骂,背景中的扫帚在空中上下挥动。
##第2幕:徐家简陋客厅 黄昏 室内
分镜3
画面描述:客厅窗户透出昏黄的夕阳余晖,空荡荡的客厅中央只有几把木凳和一张方桌,显得异常安静。
场景:徐家简陋客厅
构图设计:全景,景深虚化
运镜调度:固定镜头
配音角色:旁白
出镜角色:无
台词内容:可我没想到,半个月后,徐母居然再一次外出,
画面类型:普通画面
尾帧描述:固定镜头。昏黄的夕阳余晖在地面上缓慢移动,空气中的浮尘在光束中跳动,室内维持着死寂的安静。
分镜4
画面描述:一张泛黄的火车票特写,上面显示着隔壁市的站名和日期。
场景:徐家简陋客厅
构图设计:特写,火车票
运镜调度:固定镜头
配音角色:旁白
出镜角色:无
台词内容:这次是去隔壁市看望她生病的好姐妹。
画面类型:普通画面
尾帧描述:固定镜头。光影在火车票表面微微晃动,强调其陈旧的纸张纹理与关键的行程信息。
\n\n";
// 获取章节内容
$full_content = getProp($anime, 'content');
// 根据章节内容拆分章节
$chapters = $this->splitContent($full_content);
if ($prompt == '确认分镜大纲') {
$content = isset($chapters[($episode_number-1)]['content']) ? $chapters[($episode_number-1)]['content'] : '';
if (!$content) {
Utils::throwError('20003:章节内容无法获取,请确认');
}
$question = $demo."请根据以上要求分析本章剧情,完成分镜剧本,本章剧情内容如下: \n\n$content";
// 构建消息
$messages[] =
[
'role' => 'user',
'content' => $question
];
}else if ($prompt == '继续策划下一集') {
$content = isset($chapters[($episode_number-1)]['content']) ? $chapters[($episode_number-1)]['content'] : '';
if (!$content) {
Utils::throwError('20003:章节内容无法获取,请确认');
}
$question = $demo."请根据以上要求分析本章剧情,完成分镜剧本(需衔接上一章内容),本章剧情内容如下: \n\n$content";
// 获取上一集最后记录
$prev_sequence = $episode_number - 1;
$messages = DB::table('mp_anime_records')->where('anime_id', $anime_id)->where('sequence', $prev_sequence)
->select('role', 'content')->orderBy('created_at', 'desc')->orderBy('id')->get()->map(function($value) {
return (array)$value;
})->toArray();
// 构建消息
$messages[] =
[
'role' => 'user',
'content' => $question
];
}else {
$content = $full_content;
$question = $prompt;
$messages[] =
[
'role' => 'user',
'content' => $question
];
}
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
$post_data = [
'model' => $model,
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 0.7,
'frequency_penalty' => 0,
'presence_penalty' => 0,
'response_format' => ['type' => 'text'],
'stream' => true // 启用流式输出
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$response = $client->post($this->url, [
'json' => $post_data,
'headers' => $this->headers,
'stream' => true // 启用流式响应
]);
$body = $response->getBody();
$fullContent = '';
$fullReasoningContent = '';
$usage = [];
$buffer = '';
dLog('deepseek')->info('开始读取 DeepSeek 流式响应');
// 逐行读取流式响应
while (!$body->eof()) {
$chunk = $body->read(1024); // 每次读取 1KB
$buffer .= $chunk;
// 按行分割
$lines = explode("\n", $buffer);
// 保留最后一个不完整的行
$buffer = array_pop($lines);
foreach ($lines as $line) {
$line = trim($line);
// 跳过空行
if (empty($line)) {
continue;
}
// dLog('deepseek')->info('收到原始行数据', ['line' => $line]);
// 检查是否是 SSE 数据行
if (strpos($line, 'data: ') !== 0) {
dLog('deepseek')->warning('非 SSE 数据行', ['line' => $line]);
continue;
}
$data = substr($line, 6); // 移除 "data: " 前缀
if ($data === '[DONE]') {
dLog('deepseek')->info('收到结束标记');
break 2; // 跳出两层循环
}
try {
$json = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
dLog('deepseek')->warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]);
continue;
}
// dLog('deepseek')->info('成功解析 JSON', ['has_choices' => isset($json['choices'])]);
if (isset($json['choices'][0]['delta'])) {
$delta = $json['choices'][0]['delta'];
// 处理思维链内容(仅 deepseek-reasoner 模型)
// 注释掉以下内容则不返回d思维链内容
if (isset($delta['reasoning_content'])) {
$fullReasoningContent .= $delta['reasoning_content'];
// dLog('deepseek')->info('输出思维链内容', ['length' => strlen($delta['reasoning_content'])]);
yield [
'type' => 'reasoning',
'content' => $delta['reasoning_content'],
'full_reasoning' => $fullReasoningContent
];
}
// 处理最终回答内容
if (isset($delta['content'])) {
$fullContent .= $delta['content'];
// dLog('deepseek')->info('输出回答内容', ['length' => strlen($delta['content'])]);
yield [
'type' => 'content',
'content' => $delta['content'],
'full_content' => $fullContent
];
}
}
// 获取使用统计信息
if (isset($json['usage'])) {
$usage = $json['usage'];
dLog('deepseek')->info('收到使用统计', ['usage' => $usage]);
}
} catch (\Exception $e) {
dLog('deepseek')->error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
continue;
}
}
}
dLog('deepseek')->info('流式读取完成', [
'content_length' => strlen($fullContent),
'reasoning_length' => strlen($fullReasoningContent)
]);
// // $fullContent = '111';
// // $fullReasoningContent = [];
// // $usage = [];
// // $uid = 0;
// // $content = '';
// // $question = '';
// // $episode_number = 1;
// // $anime_id = 13;
// $fullContent = DB::table('mp_anime_records')->orderBy('id', 'desc')->value('content');
dLog('deepseek')->info('完整内容: '.$fullContent);
// 处理完整内容并返回最终结果
$episode_arr = handleEpisodeContent($fullContent);
try {
DB::beginTransaction();
// 新建动漫剧集
$episode = [
'anime_id' => $anime_id,
'episode_number' => $episode_number,
'title' => $episode_arr['episode_title'],
'content' => $content,
// 'content' => '',
'intro' => $episode_arr['intro'],
'art_style' => $episode_arr['art_style'],
'roles' => json_encode($episode_arr['roles'], 256),
'scenes' => json_encode($episode_arr['scenes'], 256),
'sequence' => 1,
'generate_status' => '待执行',
'generate_at' => date('Y-m-d H:i:s'),
'is_default' => 1,
'updated_at' => date('Y-m-d H:i:s'),
];
$current_episode = DB::table('mp_anime_episodes')->where('anime_id', $anime_id)->where('episode_number', $episode_number)->where('is_default', 1)->select('id', 'sequence')->first();
if ($current_episode) {
$episode['sequence'] = getProp($current_episode, 'sequence');
$episode_id = getProp($current_episode, 'id');
$boolen2 = DB::table('mp_anime_episodes')->where('anime_id', $anime_id)->where('episode_id', $episode_id)->update($episode);
}else {
$episode['created_at'] = date('Y-m-d H:i:s');
$episode_id = DB::table('mp_anime_episodes')->insertGetId($episode);
$boolen2 = $episode_id ? true : false;
}
if (!$boolen2) {
dLog('deepseek')->info('分集内容保存失败', $episode);
Utils::throwError('20003:分集内容保存失败');
}
// 删除历史分集内容
DB::table('mp_episode_segments')->where('anime_id', $anime_id)->where('episode_id', $episode_id)->delete();
$segments = [];
foreach($episode_arr['acts'] as $act) {
foreach($act['segments'] as $segment) {
$segments[] = [
'anime_id' => $anime_id,
'episode_id' => $episode_id,
'episode_number' => $episode_number,
'act_number' => $act['act_number'],
'act_title' => $act['act_title'],
'segment_id' => getProp($segment, 'segment_id'),
'segment_number' => getProp($segment, 'segment_number'),
'segment_content' => getProp($segment, 'segment_content'),
'description' => getProp($segment, 'description'),
'composition' => getProp($segment, 'composition'),
'camera_movement' => getProp($segment, 'camera_movement'),
'voice_actor' => getProp($segment, 'voice_actor'),
'dialogue' => getProp($segment, 'dialogue'),
'frame_type' => getProp($segment, 'frame_type'),
'scene' => getProp($segment, 'scene'),
'characters' => getProp($segment, 'characters'),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
];
}
}
$boolen3 = DB::table('mp_episode_segments')->insert($segments);
if (!$boolen3) {
dLog('deepseek')->info('分镜内容保存失败', $episode);
Utils::throwError('20003:分镜内容保存失败');
}
// 保存对话记录
$recrods = [
[
'uid' => $uid,
'anime_id' => $anime_id,
'role' => 'user',
'content' => $prompt,
'sequence' => $episode_number,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
],
[
'uid' => $uid,
'anime_id' => $anime_id,
'role' => 'assistant',
'content' => $fullContent,
'sequence' => $episode_number,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s')
]
];
// 保存对话记录
$boolen3 = DB::table('mp_anime_records')->insert($recrods);
if (!$boolen3) {
Utils::throwError('20003:对话记录保存失败');
}
}catch (\Exception $e) {
DB::rollBack();
dLog('deepseek')->info('新建对话错误信息: '.$e->getMessage());
yield [
'type' => 'done',
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage,
'error' => $e->getMessage(),
];
}
DB::commit();
// dd('end');
$episode['episode_id'] = $episode_id;
$episode['roles'] = json_decode($episode['roles'], true);
$episode['scenes'] = json_decode($episode['scenes'], true);
$episode['acts'] = json_decode($episode['acts'], true);
yield [
'type' => 'done',
'episode' => $episode,
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage
];
}
public function chatChangeImg($data) {
$segment = getProp($data, 'segment');
$segment_content = getProp($segment, 'segment_content');
$prompt = getProp($data, 'prompt');
$ref_img = getProp($data, 'ref_img');
$question = "请修改已有的内容{$segment_content}\n修改要求如下:\n{$prompt}";
$model = getProp($data, 'model', 'v3');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
$messages[] =
[
'role' => 'user',
'content' => $question
];
$post_data = [
'model' => $model,
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 0.7,
'frequency_penalty' => 0,
'presence_penalty' => 0,
'response_format' => ['type' => 'text'],
'stream' => false // 是否启用流式输出
];
// 调用流式输出的deepseek
$chatResult = $this->chatOnly($post_data);
if (is_array($chatResult)) {
extract($chatResult);
}else {
Utils::throwError('20003: 接口调用失败!');
}
return [
'type' => 'done',
// 'episode' => $episode,
'answer' => $fullContent,
'reasoning' => $fullReasoningContent,
'usage' => $usage
];
}
// 仅调用deepseek对话接口
private function chatOnly($post_data) {
$client = new Client(['timeout' => 1200, 'verify' => false]);
$result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]);
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
dLog('deepseek')->info('deepseek请求完成', ['response' => $response_arr]);
$fullContent = '';
$fullReasoningContent = [];
$usage = isset($response_arr['usage']) ? $response_arr['usage'] : [];
if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
$fullContent = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
$fullReasoningContent = isset($response_arr['choices'][0]['message']['reasoning_content']) ? $response_arr['choices'][0]['message']['reasoning_content'] : '';
}
return [
'fullContent' => $fullContent,
'fullReasoningContent' => $fullReasoningContent,
'usage' => $usage
];
}
private function splitContent($content) {
// 根据章节标题来拆分章节内容,返回包含标题和内容的二维数组
$chapters = [];
// 匹配不同格式的章节标题:###第X章、##第X章、第X章
$pattern = '/^(#{0,3}\s*第[^章]*章[^\n]*)\n(.*?)(?=\n#{0,3}\s*第[^章]*章|\z)/ms';
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$title = trim($match[1], "# \t\n\r\0\x0B"); // 去除标题前的#号和空白字符
$chapterContent = trim($match[2]); // 去除内容前后的空白字符
if (!empty($title) && !empty($chapterContent)) {
$chapters[] = [
'title' => $title,
'content' => $chapterContent
];
}
}
}
return $chapters;
}
public function getContentByBid($bid) {
$chapters = DB::table('chapters as c')->select('c.id', 'c.name', 'cc.content')
->leftJoin('chapter_contents as cc', 'c.chapter_content_id', '=', 'cc.id')
->where('c.bid', $bid)->where('c.is_check', 1)->where('c.is_deleted', 0)->orderBy('c.sequence')->get();
$content = '';
foreach ($chapters as $chapter) {
$content .= $chapter->content . PHP_EOL . PHP_EOL;
}
return $content;
}
/**
* 根据文件类型提取文本内容
*
* @param mixed $file 文件对象或文件路径
* @return string
*/
public function extractFileContent($file) {
// 获取文件路径和扩展名
if (is_string($file)) {
$filePath = $file;
$extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
} else {
// Laravel UploadedFile 对象
$filePath = $file->getRealPath();
$extension = strtolower($file->getClientOriginalExtension());
}
$content = '';
switch ($extension) {
case 'txt':
$content = $this->extractTxtContent($filePath);
break;
case 'pdf':
$content = $this->extractPdfContent($filePath);
break;
case 'doc':
case 'docx':
$content = $this->extractWordContent($filePath);
break;
// case 'jpg':
// case 'jpeg':
// case 'png':
// case 'gif':
// case 'bmp':
// case 'webp':
// $content = $this->extractImageContent($filePath);
// break;
default:
Utils::throwError('20003:不支持的文件格式,支持:txt、pdf');
}
return $content;
}
/**
* 提取 TXT 文件内容
*/
private function extractTxtContent($filePath) {
if (!file_exists($filePath)) {
Utils::throwError('20003:文件不存在');
}
$content = file_get_contents($filePath);
// 尝试检测并转换编码
$encoding = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312', 'BIG5'], true);
if ($encoding && $encoding !== 'UTF-8') {
$content = mb_convert_encoding($content, 'UTF-8', $encoding);
}
return trim($content);
}
/**
* 提取 PDF 文件内容
*/
private function extractPdfContent($filePath) {
if (!file_exists($filePath)) {
Utils::throwError('20003:文件不存在');
}
try {
$parser = new PdfParser();
$pdf = $parser->parseFile($filePath);
$content = $pdf->getText();
return trim($content);
} catch (\Exception $e) {
dLog('deepseek')->error('PDF解析失败: ' . $e->getMessage());
Utils::throwError('20003:PDF解析失败,请确保文件格式正确');
}
}
/**
* 提取 Word 文件内容(支持 doc 和 docx)
*/
private function extractWordContent($filePath) {
if (!file_exists($filePath)) {
Utils::throwError('20003:文件不存在');
}
try {
$phpWord = WordIOFactory::load($filePath);
$content = '';
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
$content .= $this->extractWordElementText($element);
}
}
return trim($content);
} catch (\Exception $e) {
dLog('deepseek')->error('Word解析失败: ' . $e->getMessage());
Utils::throwError('20003:Word解析失败,请确保文件格式正确');
}
}
/**
* 递归提取 Word 元素中的文本
*/
private function extractWordElementText($element) {
$text = '';
if (method_exists($element, 'getText')) {
$text .= $element->getText() . "\n";
} elseif (method_exists($element, 'getElements')) {
foreach ($element->getElements() as $childElement) {
$text .= $this->extractWordElementText($childElement);
}
}
return $text;
}
/**
* 提取图片内容(使用火山引擎 OCR)
*/
private function extractImageContent($filePath) {
if (!file_exists($filePath)) {
Utils::throwError('20003:文件不存在');
}
try {
// 读取图片并转换为 base64
$imageData = file_get_contents($filePath);
$base64Image = base64_encode($imageData);
// 调用火山引擎 OCR 服务
$content = $this->callVolcEngineOCR($base64Image);
return trim($content);
} catch (\Exception $e) {
dLog('deepseek')->error('图片OCR识别失败: ' . $e->getMessage());
Utils::throwError('20003:图片识别失败');
}
}
/**
* 调用火山引擎 OCR 服务识别图片文字
*
* @param string $base64Image base64 编码的图片数据
* @return string 识别的文字内容
*/
private function callVolcEngineOCR($base64Image) {
$method = 'POST';
$service = 'cv';
$host = 'visual.volcengineapi.com';
$region = env('VOLC_REGION', 'cn-north-1');
$access_key = env('VOLC_AK');
$secret_key = env('VOLC_SK');
$action = 'OCRNormal';
$version = '2020-08-26';
if (!$access_key || !$secret_key) {
Utils::throwError('20003:请配置火山引擎 AK/SK');
}
// 请求体
$body = json_encode([
'image_base64' => $base64Image
]);
// 生成签名
$headers = $this->getVolcEngineSignHeaders(
$method, $service, $host, $region,
"Action={$action}&Version={$version}",
$access_key, $secret_key, $body
);
$client = new Client(['timeout' => 60, 'verify' => false]);
$response = $client->post("https://{$host}/?Action={$action}&Version={$version}", [
'headers' => $headers,
'body' => $body
]);
$response_arr = json_decode($response->getBody()->getContents(), true);
// 提取识别的文字
$textLines = [];
if (isset($response_arr['data']['line_texts'])) {
$textLines = $response_arr['data']['line_texts'];
} elseif (isset($response_arr['data']['ocr_infos'])) {
foreach ($response_arr['data']['ocr_infos'] as $info) {
if (isset($info['text'])) {
$textLines[] = $info['text'];
}
}
}
if (empty($textLines)) {
dLog('deepseek')->warning('OCR识别结果为空', ['response' => $response_arr]);
return '';
}
return implode("\n", $textLines);
}
/**
* 生成火山引擎 API 签名头(用于 OCR 等 POST 请求)
*/
private function getVolcEngineSignHeaders($method, $service, $host, $region, $queryString, $access_key, $secret_key, $body = '') {
$contentType = 'application/json';
$accept = 'application/json';
// 获取当前UTC时间
$t = new DateTime('now', new DateTimeZone('UTC'));
$xDate = $t->format('Ymd\THis\Z');
$dateStamp = $t->format('Ymd');
// 计算 body 的 sha256 哈希
$payloadHash = hash('sha256', $body);
// 1. 拼接规范请求串
$canonicalUri = '/';
$canonicalQueryString = $queryString;
$canonicalHeaders = "content-type:{$contentType}\nhost:{$host}\nx-content-sha256:{$payloadHash}\nx-date:{$xDate}\n";
$signedHeaders = 'content-type;host;x-content-sha256;x-date';
$canonicalRequest = implode("\n", [
$method,
$canonicalUri,
$canonicalQueryString,
$canonicalHeaders,
$signedHeaders,
$payloadHash
]);
// 2. 拼接待签名字符串
$algorithm = 'HMAC-SHA256';
$credentialScope = implode('/', [$dateStamp, $region, $service, 'request']);
$hashedCanonicalRequest = hash('sha256', $canonicalRequest);
$stringToSign = implode("\n", [
$algorithm,
$xDate,
$credentialScope,
$hashedCanonicalRequest
]);
// 3. 计算签名
$signingKey = $this->getSignatureKey($secret_key, $dateStamp, $region, $service);
$signature = hash_hmac('sha256', $stringToSign, $signingKey);
// 4. 添加签名到请求头
$authorizationHeader = sprintf(
'%s Credential=%s/%s, SignedHeaders=%s, Signature=%s',
$algorithm,
$access_key,
$credentialScope,
$signedHeaders,
$signature
);
return [
'Accept' => $accept,
'Content-Type' => $contentType,
'Host' => $host,
'X-Date' => $xDate,
'X-Content-Sha256'=> $payloadHash,
'Authorization' => $authorizationHeader
];
}
public function generateScriptWords($cid, $model = 'r1') {
ini_set('max_execution_time', 0);
if (!$cid) Utils::throwError('20003: 请选择章节!');
$content = DB::table('chapters as c')->leftJoin('chapter_contents as cc', 'c.chapter_content_id', '=', 'cc.id')->where('c.id', $cid)->value('cc.content');
$model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
$messages = [
[
'role' => 'system',
'content' => '下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男或女):台词{情感}”输出,需要注意以下几点要求:
1.角色名后不要加入任何其他词语,只能加不包括旁白的性别,在男或女中选
2.非对话部分请全部用旁白角色代替
3.情感必须在【开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰鼓励、咆哮、温柔、自然讲述、情感电台、磁性、广告营销、气泡音、新闻播报、娱乐八卦】中选一个,不得使用其他词语'
],
[
'role' => 'user',
'content' => $content
]
];
$post_data = [
'model' => $model, // R1模型: deepseek-reasoner V3模型: deepseek-chat
'messages' => $messages,
'max_tokens' => 8192,
'temperature' => 1, // 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。
// 'top_p' => 1, // 作为调节采样温度的替代方案(<=1),模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。
'frequency_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。
'presence_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。
'response_format' => [
'type' => 'text' // 默认值text,回答的结果输出文字(非接口返回值是text,接口返回值还是json字串),还可选:json_object,输出json格式
],
'stream' => false // 是否流式输出,如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾。
];
$client = new Client(['timeout' => 1200, 'verify' => false]);
$result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]);
$response = $result->getBody()->getContents();
$response_arr = json_decode($response, true);
$update_data = [];
$content = '';
if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
$content = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
$update_data = [
'role' => 'assistant',
'content' => $response_arr['choices'][0]['message']['content'],
'usage' => isset($response_arr['usage']) ? $response_arr['usage'] : []
];
}
return $content;
}
/**
* 智能化解析集数信息
* 支持多种表达方式,如:改成3集、调整成5集、修改为10集等
*
* @param string $prompt 用户输入的提示词
* @return int|null 解析出的集数,如果没有找到则返回null
*/
private function extractEpisodeNumber($prompt)
{
if (empty($prompt)) {
return null;
}
// 定义各种可能的表达模式
$patterns = [
// 基本模式:改成N集、调整成N集、修改成N集
'/(?:改成|调整成|修改成|变成|设置成|设为|改为|调为|修改为|变为)\s*(\d+)\s*集/u',
// 扩展模式:分成N集、拆分成N集、分割成N集
'/(?:分成|拆分成|分割成|划分成|分为)\s*(\d+)\s*集/u',
// 数量模式:N集、共N集、总共N集
'/(?:共|总共|一共|总计)?\s*(\d+)\s*集/u',
// 制作模式:制作N集、生成N集、创建N集
'/(?:制作|生成|创建|产出|输出)\s*(\d+)\s*集/u',
// 计划模式:计划N集、预计N集、打算N集
'/(?:计划|预计|打算|准备|想要)\s*(\d+)\s*集/u',
// 需要模式:需要N集、要N集
'/(?:需要|要|需)\s*(\d+)\s*集/u',
// 中文数字模式:改成三集、调整成五集等
'/(?:改成|调整成|修改成|变成|设置成|设为|改为|调为|修改为|变为|分成|拆分成|分割成|划分成|分为|制作|生成|创建|产出|输出|计划|预计|打算|准备|想要|需要|要|需)\s*([一二三四五六七八九十]+)\s*集/u',
// 英文数字模式
'/(?:change\s+to|adjust\s+to|modify\s+to|set\s+to|make\s+it|split\s+into|divide\s+into)\s*(\d+)\s*(?:episodes?|eps?)/i',
];
// 中文数字转阿拉伯数字的映射
$chineseNumbers = [
'一' => 1, '二' => 2, '三' => 3, '四' => 4, '五' => 5,
'六' => 6, '七' => 7, '八' => 8, '九' => 9, '十' => 10,
'十一' => 11, '十二' => 12, '十三' => 13, '十四' => 14, '十五' => 15,
'十六' => 16, '十七' => 17, '十八' => 18, '十九' => 19, '二十' => 20
];
// 逐个尝试匹配模式
foreach ($patterns as $pattern) {
if (preg_match($pattern, $prompt, $matches)) {
$number = trim($matches[1]);
// 如果是中文数字,转换为阿拉伯数字
if (isset($chineseNumbers[$number])) {
return $chineseNumbers[$number];
}
// 如果是阿拉伯数字,直接返回
if (is_numeric($number)) {
$episodeNum = intval($number);
// 合理性检查:集数应该在1-100之间
if ($episodeNum >= 1 && $episodeNum <= 100) {
return $episodeNum;
}
}
}
}
return null;
}
}