DeepSeekService.php 76 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616
  1. <?php
  2. namespace App\Services\DeepSeek;
  3. use App\Consts\ErrorConst;
  4. use App\Facade\Site;
  5. use App\Libs\Utils;
  6. use GuzzleHttp\Client;
  7. use DateTime;
  8. use DateTimeZone;
  9. use Illuminate\Support\Facades\DB;
  10. use Illuminate\Support\Facades\Log;
  11. use Illuminate\Support\Facades\Redis;
  12. use OSS\Core\OssException;
  13. use OSS\OssClient;
  14. use Smalot\PdfParser\Parser as PdfParser;
  15. use PhpOffice\PhpWord\IOFactory as WordIOFactory;
  16. class DeepSeekService
  17. {
  18. private $url;
  19. private $api_key;
  20. private $client;
  21. private $headers;
  22. public function __construct() {
  23. $this->url = 'https://api.deepseek.com/chat/completions';
  24. $this->api_key = env('DEEPSEEK_API_KEY');
  25. $this->headers = [
  26. 'Authorization' => 'Bearer '.$this->api_key,
  27. 'Content-Type' => 'application/json; charset=UTF-8'
  28. ];
  29. }
  30. // 与推理模型对话
  31. public function chatWithReasoner($data) {
  32. $content = getProp($data, 'content');
  33. $model = getProp($data, 'model', 'r1');
  34. $model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
  35. // 获取可选情感(根据音色可支持情感选)
  36. $timbre_emotion = DB::table('mp_timbres')->where('is_enabled', 1)->pluck('emotion')->toArray();
  37. $emotion_list = [];
  38. foreach ($timbre_emotion as $emotion) {
  39. $tmp = explode(',', $emotion);
  40. $emotion_list = array_merge($emotion_list, $tmp);
  41. }
  42. $emotion_list = array_unique($emotion_list);
  43. $emotion_str = implode('、', $emotion_list);
  44. // // 获取可选情感
  45. // $emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->select('emotion_name')->orderBy('id')->get()->pluck('emotion_name')->toArray();
  46. // $emotion_str = implode('、', $emotion_list);
  47. // 是否启用情感
  48. $enable_emotion = getProp($data, 'enable_emotion', 0);
  49. if ($enable_emotion) {
  50. $sys_content = "下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男、女、中性):台词{情感}”输出,需要注意以下几点要求:
  51. 1.角色名后不要加入任何其他词语,只能加性别,在男、女或中性中选
  52. 2.非对话部分请全部用旁白角色代替,旁白的情感统一选择中性
  53. 3.情感必须在【{$emotion_str}】中选一个,不得使用其他词语";
  54. }else {
  55. $sys_content = "下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男、女、中性):台词”输出,需要注意以下几点要求:
  56. 1.角色名后不要加入任何其他词语,只能加性别,在男、女或中性中选
  57. 2.非对话部分请全部用旁白角色代替,旁白的情感统一选择中性";
  58. }
  59. $messages = [
  60. [
  61. 'role' => 'system',
  62. 'content' => $sys_content
  63. ],
  64. [
  65. 'role' => 'user',
  66. 'content' => $content
  67. ]
  68. ];
  69. $post_data = [
  70. 'model' => $model, // R1模型: deepseek-reasoner V3模型: deepseek-chat
  71. 'messages' => $messages,
  72. 'max_tokens' => 8192,
  73. 'temperature' => 1, // 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。
  74. // 'top_p' => 1, // 作为调节采样温度的替代方案(<=1),模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。
  75. 'frequency_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。
  76. 'presence_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。
  77. 'response_format' => [
  78. 'type' => 'text' // 默认值text,回答的结果输出文字(非接口返回值是text,接口返回值还是json字串),还可选:json_object,输出json格式
  79. ],
  80. 'stream' => false // 是否流式输出,如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾。
  81. ];
  82. $client = new Client(['timeout' => 1200, 'verify' => false]);
  83. $result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]);
  84. $response = $result->getBody()->getContents();
  85. $response_arr = json_decode($response, true);
  86. $update_data = [];
  87. $content = '';
  88. if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
  89. $content = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
  90. $update_data = [
  91. 'role' => 'assistant',
  92. 'content' => $response_arr['choices'][0]['message']['content'],
  93. 'usage' => isset($response_arr['usage']) ? $response_arr['usage'] : []
  94. ];
  95. }
  96. // 处理获取到的剧本数据
  97. $script_content = handleScriptWords($content, $enable_emotion);
  98. $result = [
  99. 'origin_content' => $content,
  100. 'roles' => getProp($script_content, 'roles'),
  101. 'words' => getProp($script_content, 'words'),
  102. ];
  103. return $result;
  104. }
  105. public function resetParagraphAudio($data) {
  106. $bid = getProp($data, 'bid');
  107. $cid = getProp($data, 'cid');
  108. $version_id = getProp($data, 'version_id');
  109. if (!DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->value('id')) return true;
  110. return DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->update([
  111. 'generate_status' => '待制作',
  112. 'updated_at' => date('Y-m-d H:i:s')
  113. ]);
  114. }
  115. public function saveParagraphAudio($data) {
  116. $bid = getProp($data, 'bid');
  117. $cid = getProp($data, 'cid');
  118. $version_id = getProp($data, 'version_id');
  119. $sequence = getProp($data, 'sequence');
  120. $id = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->where('sequence', $sequence)->value('id');
  121. // 获取所有情感
  122. $emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->pluck('emotion_name', 'emotion_code')->toArray();
  123. $emotion_list = array_flip($emotion_list);
  124. // 获取音色支持情感
  125. $timbre_emotion = DB::table('mp_timbres')->where('timbre_type', getProp($data, 'voice_type'))->value('emotion');
  126. $timbre_emotion = explode(',', $timbre_emotion);
  127. $emotion = getProp($data, 'emotion');
  128. if (!in_array($emotion, $timbre_emotion)) $emotion = '中性';
  129. $emotion_type = isset($emotion_list[$emotion]) ? $emotion_list[$emotion] : 'neutral';
  130. $list = [
  131. 'bid' => $bid,
  132. 'cid' => $cid,
  133. 'version_id' => $version_id,
  134. 'sequence' => $sequence,
  135. 'role' => getProp($data, 'role'),
  136. 'gender' => getProp($data, 'gender'),
  137. 'text' => trim(getProp($data, 'text')),
  138. 'emotion' => $emotion,
  139. 'emotion_type' => $emotion_type,
  140. 'voice_type' => getProp($data, 'voice_type'),
  141. 'voice_name' => getProp($data, 'voice_name'),
  142. 'speed_ratio' => getProp($data, 'speed_ratio', 0),
  143. 'loudness_ratio'=> getProp($data, 'loudness_ratio', 0),
  144. 'emotion_scale' => getProp($data, 'emotion_scale', 4),
  145. 'pitch' => getProp($data, 'pitch', 0),
  146. // 'paragraph_audio_url' => '',
  147. 'generate_status' => '制作中',
  148. 'error_msg' => '',
  149. 'updated_at' => date('Y-m-d H:i:s')
  150. ];
  151. $continue = false;
  152. // 判断是否含有中文、数字或英文,如果没有则跳过合成
  153. if (!preg_match('/[\x{4e00}-\x{9fa5}]+/u', $list['text']) && !preg_match('/[0-9]+/', $list['text']) && !preg_match('/[a-zA-Z]+/', $list['text'])) {
  154. $list['generate_status'] = '制作成功';
  155. $list['paragraph_audio_url'] = 'https://zw-audiobook.tos-cn-beijing.volces.com/effects/ellipses_2s.mp3';
  156. $list['subtitle_info'] = '{"duration": 2.0, "words": []}';
  157. $continue = true;
  158. }
  159. // if (getProp($data, 'paragraph_audio_url')) $list['paragraph_audio_url'] = getProp($data, 'paragraph_audio_url');
  160. if ($id) {
  161. $boolen = DB::table('mp_chapter_paragraph_audios')->where('id', $id)->update($list);
  162. }else {
  163. $list['created_at'] = date('Y-m-d H:i:s');
  164. $id = DB::table('mp_chapter_paragraph_audios')->insertGetId($list);
  165. $boolen = $id ? true : false;
  166. }
  167. // 如果更新成功则加入查询队列
  168. if ($boolen) {
  169. $redis_key = "select-{$bid}-{$version_id}-{$cid}";
  170. Redis::sadd($redis_key, $id);
  171. }
  172. if ($boolen && !$continue) {
  173. $boolen = false;
  174. $client = new Client(['timeout' => 300, 'verify' => false]);
  175. // 根据ID通过API通知合成音频
  176. // $result = $client->get("http://47.240.171.155:5000/api/previewTask?taskId={$id}");
  177. $result = $client->get("http://122.9.129.83:5000/api/previewTask?taskId={$id}");
  178. $response = $result->getBody()->getContents();
  179. $response_arr = json_decode($response, true);
  180. if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
  181. $error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
  182. Log::info('通知火山生成段落音频失败: '.$error_msg);
  183. Utils::throwError('20003:通知火山生成段落音频失败');
  184. }
  185. $boolen = true;
  186. }
  187. return $boolen;
  188. }
  189. public function insertAudioEffect($data) {
  190. $audio_id = getProp($data, 'audio_id');
  191. $chapter_audio = DB::table('mp_chapter_audios')->where('id', $audio_id)->first();
  192. $bid = getProp($chapter_audio, 'bid');
  193. $version_id = getProp($chapter_audio, 'version_id');
  194. $cid = getProp($chapter_audio, 'cid');
  195. if (getProp($chapter_audio, 'generate_status') != '制作成功') Utils::throwError('20003:请先完成有声制作');
  196. $audio_effect_json = getProp($data, 'audio_effect_json');
  197. if (!is_string($audio_effect_json)) $audio_effect_json = json_encode($audio_effect_json, 256);
  198. try {
  199. DB::beginTransaction();
  200. $count = DB::table('mp_audio_tasks')->where('audio_id', $audio_id)->count('id');
  201. if (!$count) {
  202. $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】';
  203. }else {
  204. $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')';
  205. }
  206. $boolen1 = DB::table('mp_chapter_audios')->where('id', $audio_id)->update([
  207. 'audio_effect_status' => '添加中',
  208. 'audio_effect_json' => $audio_effect_json,
  209. 'updated_at' => date('Y-m-d H:i:s')
  210. ]);
  211. if (!$boolen1) {
  212. DB::rollBack();
  213. Utils::throwError('20003:更新生成数据失败');
  214. }
  215. $id = DB::table('mp_audio_tasks')->insertGetId([
  216. 'audio_id' => $audio_id,
  217. 'generate_status' => '执行中',
  218. 'generate_json' => json_encode([], 256),
  219. 'bid' => $bid,
  220. 'book_name' => getProp($chapter_audio, 'book_name'),
  221. 'version_id' => $version_id,
  222. 'version_name' => getProp($chapter_audio, 'version_name'),
  223. 'cid' => $cid,
  224. 'chapter_name' => getProp($chapter_audio, 'chapter_name'),
  225. 'task_name' => $task_name,
  226. 'created_at' => date('Y-m-d H:i:s'),
  227. 'updated_at' => date('Y-m-d H:i:s')
  228. ]);
  229. if (!$id) {
  230. DB::rollBack();
  231. Utils::throwError('20003:创建任务失败');
  232. }
  233. }catch (\Exception $e) {
  234. DB::rollBack();
  235. Utils::throwError('20003:'.$e->getMessage());
  236. }
  237. DB::commit();
  238. $client = new Client(['timeout' => 300, 'verify' => false]);
  239. // 根据ID通过API通知合成音频
  240. // $result = $client->get("http://47.240.171.155:5000/api/postTask?taskId={$id}");
  241. $result = $client->get("http://122.9.129.83:5000/api/postTask?taskId={$id}");
  242. $response = $result->getBody()->getContents();
  243. $response_arr = json_decode($response, true);
  244. if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
  245. $error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
  246. Log::info('通知火山插入音效失败: '.$error_msg);
  247. Utils::throwError('20003:通知火山插入音效失败');
  248. }
  249. return true;
  250. }
  251. public function insertBgm($data) {
  252. $audio_id = getProp($data, 'audio_id');
  253. $chapter_audio = DB::table('mp_chapter_audios')->where('id', $audio_id)->first();
  254. $bid = getProp($chapter_audio, 'bid');
  255. $version_id = getProp($chapter_audio, 'version_id');
  256. $cid = getProp($chapter_audio, 'cid');
  257. if (getProp($chapter_audio, 'generate_status') != '制作成功') Utils::throwError('20003:请先完成有声制作');
  258. $bgm_json = getProp($data, 'bgm_json');
  259. if (!is_string($bgm_json)) $bgm_json = json_encode($bgm_json, 256);
  260. if (!$bgm_json) {
  261. $bgm_json = getProp($data, 'audio_effect_json');
  262. if (!is_string($bgm_json)) $bgm_json = json_encode($bgm_json, 256);
  263. }
  264. try {
  265. DB::beginTransaction();
  266. $count = DB::table('mp_audio_tasks')->where('audio_id', $audio_id)->count('id');
  267. if (!$count) {
  268. $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】';
  269. }else {
  270. $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')';
  271. }
  272. $boolen1 = DB::table('mp_chapter_audios')->where('id', $audio_id)->update([
  273. 'bgm_status' => '添加中',
  274. 'bgm_json' => $bgm_json,
  275. 'updated_at' => date('Y-m-d H:i:s')
  276. ]);
  277. if (!$boolen1) {
  278. DB::rollBack();
  279. Utils::throwError('20003:更新生成数据失败');
  280. }
  281. $id = DB::table('mp_audio_tasks')->insertGetId([
  282. 'audio_id' => $audio_id,
  283. 'generate_status' => '执行中',
  284. 'generate_json' => json_encode([], 256),
  285. 'bid' => $bid,
  286. 'book_name' => getProp($chapter_audio, 'book_name'),
  287. 'version_id' => $version_id,
  288. 'version_name' => getProp($chapter_audio, 'version_name'),
  289. 'cid' => $cid,
  290. 'chapter_name' => getProp($chapter_audio, 'chapter_name'),
  291. 'task_name' => $task_name,
  292. 'created_at' => date('Y-m-d H:i:s'),
  293. 'updated_at' => date('Y-m-d H:i:s')
  294. ]);
  295. if (!$id) {
  296. DB::rollBack();
  297. Utils::throwError('20003:创建任务失败');
  298. }
  299. }catch (\Exception $e) {
  300. DB::rollBack();
  301. Utils::throwError('20003:'.$e->getMessage());
  302. }
  303. DB::commit();
  304. $client = new Client(['timeout' => 300, 'verify' => false]);
  305. // 根据ID通过API通知合成音频
  306. // $result = $client->get("http://47.240.171.155:5000/api/postTask?taskId={$id}");
  307. $result = $client->get("http://122.9.129.83:5000/api/postTask?taskId={$id}");
  308. $response = $result->getBody()->getContents();
  309. $response_arr = json_decode($response, true);
  310. if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
  311. $error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
  312. Log::info('通知火山插入bgm失败: '.$error_msg);
  313. Utils::throwError('20003:通知火山插入bgm失败');
  314. }
  315. return true;
  316. }
  317. public function getParagraphAudios($data) {
  318. $bid = getProp($data, 'bid');
  319. $cid = getProp($data, 'cid');
  320. $version_id = getProp($data, 'version_id');
  321. $sequence = getProp($data, 'sequence');
  322. // 获取已生成的音频
  323. $query = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id);
  324. if ($sequence) $query->where('sequence', $sequence);
  325. $paragraph_audios = $query->orderBy('sequence')->get();
  326. $result = [];
  327. foreach($paragraph_audios as $item) {
  328. $result[] = [
  329. 'sequence' => getProp($item, 'sequence'),
  330. 'role' => getProp($item, 'role'),
  331. 'gender' => getProp($item, 'gender'),
  332. 'text' => trim(getProp($item, 'text')),
  333. 'emotion' => getProp($item, 'emotion'),
  334. 'emotion_type' => getProp($item, 'emotion_type'),
  335. 'voice_type' => getProp($item, 'voice_type'),
  336. 'voice_name' => getProp($item, 'voice_name'),
  337. 'speed_ratio' => getProp($item, 'speed_ratio'),
  338. 'loudness_ratio' => getProp($item, 'loudness_ratio'),
  339. 'emotion_scale' => getProp($item, 'emotion_scale'),
  340. 'paragraph_audio_url' => getProp($item, 'paragraph_audio_url'),
  341. 'subtitle_info' => json_decode(getProp($item, 'subtitle_info'), true),
  342. ];
  343. }
  344. return $result;
  345. }
  346. // 新增合成任务
  347. public function addGenerateTask($data) {
  348. $bid = getProp($data, 'bid');
  349. $cid = getProp($data, 'cid');
  350. $version_id = getProp($data, 'version_id');
  351. $generate_json = getProp($data, 'generate_json');
  352. // 获取已生成的音频
  353. $paragraph_audios = DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('cid', $cid)->where('version_id', $version_id)->get();
  354. $paragraph_list = [];
  355. foreach($paragraph_audios as $item) {
  356. $paragraph_list[getProp($item, 'sequence')] = [
  357. 'role' => getProp($item, 'role'),
  358. 'gender' => getProp($item, 'gender'),
  359. 'text' => trim(getProp($item, 'text')),
  360. 'emotion' => getProp($item, 'emotion'),
  361. 'emotion_type' => getProp($item, 'emotion_type'),
  362. 'voice_type' => getProp($item, 'voice_type'),
  363. 'voice_name' => getProp($item, 'voice_name'),
  364. 'speed_ratio' => getProp($item, 'speed_ratio'),
  365. 'loudness_ratio' => getProp($item, 'loudness_ratio'),
  366. 'emotion_scale' => getProp($item, 'emotion_scale'),
  367. 'paragraph_audio_url' => getProp($item, 'paragraph_audio_url'),
  368. ];
  369. }
  370. // 更新角色-音色信息
  371. $existed_role_info = DB::table('mp_book_version')->where('bid', $bid)->where('id', $version_id)->value('role_info');
  372. $existed_role_info = json_decode($existed_role_info, true);
  373. if (!$existed_role_info) $existed_role_info = [];
  374. // 获取情感信息
  375. $emotion_list = DB::table('mp_emotion_list')->where('is_enabled', 1)->pluck('emotion_name', 'emotion_code')->toArray();
  376. $emotion_list = array_flip($emotion_list);
  377. // 获取音色对应情感组
  378. $timbre_emotions = DB::table('mp_timbres')->where('is_enabled', 1)->select('timbre_type', 'emotion')->get();
  379. $timbre_emotion_list = [];
  380. foreach($timbre_emotions as $item) {
  381. $timbre_emotion_list[getProp($item, 'timbre_type')] = explode(',', getProp($item, 'emotion'));
  382. }
  383. // 构造生成音频的json
  384. $words = json_decode($generate_json, true);
  385. // 最终合成前的参数组
  386. $mp_chapter_paragraph_audios = [];
  387. $sequence = 1;
  388. $complete_paragraph_sequences = [];
  389. foreach($words as &$word) {
  390. 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:参数格式有误');
  391. if (!($word['text']) || !($word['voice_type']) || !($word['voice_name'])) Utils::throwError('20003:参数不得为空');
  392. $role = getProp($word, 'role');
  393. $word['gender'] = (int)$word['gender'];
  394. // 判断音色对应情感是否支持,不支持则调整为中性
  395. $access_emotion = isset($timbre_emotion_list[$word['voice_type']]) ? $timbre_emotion_list[$word['voice_type']] : [];
  396. if (!in_array($word['emotion'], $access_emotion)) $word['emotion'] = '中性';
  397. // 如果有对应情感则赋值,没有则默认为中性(neutral)
  398. if (isset($emotion_list[getProp($word, 'emotion')])) {
  399. $word['emotion_type'] = $emotion_list[getProp($word, 'emotion')];
  400. }else {
  401. $word['emotion'] = '中性';
  402. $word['emotion_type'] = 'neutral';
  403. }
  404. $existed_role_info[$role] = [
  405. 'timbre_type' => $word['voice_type'],
  406. 'timbre_name' => $word['voice_name'],
  407. 'emotion' => $word['emotion'],
  408. 'emotion_type' => $word['emotion_type'],
  409. 'speed_ratio' => $word['speed_ratio'],
  410. 'loudness_ratio'=> $word['loudness_ratio'],
  411. 'emotion_scale' => $word['emotion_scale'],
  412. 'pitch' => $word['pitch']
  413. ];
  414. $word['paragraph_audio_url'] = '';
  415. // 判断生成参数是否相同,相同则直接使用已生成的音频
  416. $paragraph = isset($paragraph_list[$sequence]) ? $paragraph_list[$sequence] : [];
  417. // 如果音频存在并且参数都未改变则使用已生成的音频
  418. if (getProp($paragraph, 'paragraph_audio_url')) {
  419. // if (getProp($paragraph, 'role') == getProp($word, 'role') && getProp($paragraph, 'text') == getProp($word, 'text') &&
  420. // getProp($paragraph, 'voice_type') == getProp($word, 'voice_type') &&
  421. // getProp($paragraph, 'speed_ratio') == getProp($word, 'speed_ratio') && getProp($paragraph, 'loudness_ratio') == getProp($word, 'loudness_ratio') &&
  422. // getProp($paragraph, 'emotion_scale') == getProp($word, 'emotion_scale'))
  423. // {
  424. // $word['paragraph_audio_url'] = getProp($paragraph, 'paragraph_audio_url');
  425. // }
  426. $word['paragraph_audio_url'] = getProp($paragraph, 'paragraph_audio_url');
  427. }
  428. $tmp = $word;
  429. // 组装章节分句音频数据
  430. // $tmp['sequence'] = getProp($word, 'sequence');
  431. // $tmp['text'] = getProp($word, 'text');
  432. // $tmp['emotion'] = getProp($word, 'emotion');
  433. // $tmp['emotion_type'] = getProp($word, 'emotion_type');
  434. // $tmp['voice_name'] = getProp($word, 'voice_name');
  435. // $tmp['voice_type'] = getProp($word, 'voice_type');
  436. // $tmp['speed_ratio'] = getProp($word, 'speed_ratio');
  437. // $tmp['loudness_ratio'] = getProp($word, 'loudness_ratio');
  438. // $tmp['emotion_scale'] = getProp($word, 'emotion_scale');
  439. $tmp['bid'] = $bid;
  440. $tmp['version_id'] = $version_id;
  441. $tmp['cid'] = $cid;
  442. $tmp['sequence'] = $sequence;
  443. $tmp['created_at'] = date('Y-m-d H:i:s');
  444. $tmp['updated_at'] = date('Y-m-d H:i:s');
  445. if (!getProp($tmp, 'paragraph_audio_url')) Utils::throwError('20003:段落未生成音频,请等待完成后提交');
  446. $mp_chapter_paragraph_audios[] = $tmp;
  447. $complete_paragraph_sequences[] = $sequence;
  448. $sequence++;
  449. }
  450. $generate_json = json_encode($words, 256);
  451. // 判断是否有未生成字幕的段落
  452. $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();
  453. if ($no_subtitle_sequences) {
  454. Utils::throwError('20003:请先将以下段落序号生成字幕('.implode('、', $no_subtitle_sequences).')');
  455. }
  456. try {
  457. DB::beginTransaction();
  458. $role_info = json_encode($existed_role_info, 256);
  459. $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')]);
  460. if (!$boolen) {
  461. DB::rollBack();
  462. Utils::throwError('20003:更新角色信息失败');
  463. }
  464. $count = DB::table('mp_audio_tasks')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->count('id');
  465. $chapter_audio = DB::table('mp_chapter_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->first();
  466. if (!$count) {
  467. $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】';
  468. }else {
  469. $task_name = getProp($chapter_audio, 'book_name').' '.getProp($chapter_audio, 'chapter_name').'【'.getProp($chapter_audio, 'version_name').'】('.($count+1).')';
  470. }
  471. $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')]);
  472. if (!$boolen1) {
  473. DB::rollBack();
  474. Utils::throwError('20003:更新生成数据失败');
  475. }
  476. $id = DB::table('mp_audio_tasks')->insertGetId([
  477. 'audio_id' => getProp($chapter_audio, 'id'),
  478. 'generate_status' => '执行中',
  479. 'generate_json' => $generate_json,
  480. 'bid' => $bid,
  481. 'book_name' => getProp($chapter_audio, 'book_name'),
  482. 'version_id' => $version_id,
  483. 'version_name' => getProp($chapter_audio, 'version_name'),
  484. 'cid' => $cid,
  485. 'chapter_name' => getProp($chapter_audio, 'chapter_name'),
  486. 'task_name' => $task_name,
  487. 'created_at' => date('Y-m-d H:i:s'),
  488. 'updated_at' => date('Y-m-d H:i:s')
  489. ]);
  490. if (!$id) {
  491. DB::rollBack();
  492. Utils::throwError('20003:创建任务失败');
  493. }
  494. // 删除不在段落序号范围内的其他数据
  495. if ($complete_paragraph_sequences) {
  496. DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->whereNotIn('sequence', $complete_paragraph_sequences)->delete();
  497. }
  498. // // 删除章节分句音频数据并重新插入
  499. // DB::table('mp_chapter_paragraph_audios')->where('bid', $bid)->where('version_id', $version_id)->where('cid', $cid)->delete();
  500. // $boolen3 = DB::table('mp_chapter_paragraph_audios')->insert($mp_chapter_paragraph_audios);
  501. // if (!$boolen3) {
  502. // DB::rollBack();
  503. // Utils::throwError('20003:更新章节分句音频失败');
  504. // }
  505. } catch (\Exception $e) {
  506. DB::rollBack();
  507. Utils::throwError('20003:'.$e->getMessage());
  508. }
  509. DB::commit();
  510. // 通知火山生成音频
  511. $client = new Client(['timeout' => 300, 'verify' => false]);
  512. // 根据ID通过API通知合成音频
  513. // $result = $client->get("http://47.240.171.155:5000/api/chapterTask?taskId={$id}");
  514. $result = $client->get("http://122.9.129.83:5000/api/chapterTask?taskId={$id}");
  515. $response = $result->getBody()->getContents();
  516. $response_arr = json_decode($response, true);
  517. if (!isset($response_arr['code']) || (int)$response_arr['code'] !== 0) {
  518. $error_msg = isset($response_arr['msg']) ? $response_arr['msg'] : '未知错误';
  519. Log::info('通知火山生成音频失败: '.$error_msg);
  520. Utils::throwError('20003:通知火山生成音频失败');
  521. }
  522. return true;
  523. }
  524. public function timbreList($data) {
  525. $gender = getProp($data, 'gender');
  526. $timbre_name = getProp($data, 'voice_name');
  527. $category_id = getProp($data, 'category_id');
  528. $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');
  529. if ($gender) {
  530. $query->where('gender', $gender);
  531. }
  532. if ($timbre_name) {
  533. $query->where('timbre_name', 'like', "%{$timbre_name}%");
  534. }
  535. if ($category_id) {
  536. $query->where(function ($query) use ($category_id) {
  537. $query->where('first_category_id', $category_id)->orWhere('second_category_id', $category_id)->orWhere('third_category_id', $category_id);
  538. });
  539. }
  540. $list = $query->get()->map(function ($value) {
  541. $value = (array)$value;
  542. $value['voice_name'] = str_replace('(多情感)', '', $value['voice_name']);
  543. return $value;
  544. })->toArray();
  545. return $list;
  546. }
  547. // 生成火山临时token
  548. public function setStsToken() {
  549. // ************* 配置参数 *************
  550. $method = 'GET';
  551. $service = 'sts';
  552. $host = 'open.volcengineapi.com';
  553. $region = env('VOLC_REGION');
  554. $endpoint = 'https://open.volcengineapi.com';
  555. // $endpoint = 'https://tos-cn-beijing.volces.com';
  556. $access_key = env('VOLC_AK');
  557. $secret_key = env('VOLC_SK');
  558. // 获取缓存中的token,如果没有则请求接口
  559. $token = Redis::get('volc_sts_token');
  560. if (!$token) {
  561. // 查询参数
  562. $query_parameters = [
  563. 'Action' => 'AssumeRole',
  564. 'RoleSessionName' => 'user@zw',
  565. 'RoleTrn' => 'trn:iam::2102575520:role/tos_role',
  566. 'Version' => '2018-01-01'
  567. ];
  568. // 生成URL编码的查询字符串
  569. $request_parameters = http_build_query($query_parameters, '', '&', PHP_QUERY_RFC3986);
  570. // 获取签名头信息
  571. $headers = $this->getSignHeaders($method, $service, $host, $region, $request_parameters, $access_key, $secret_key);
  572. // 构建完整URL
  573. $request_url = $endpoint . '?' . $request_parameters;
  574. $client = new Client(['verify' => false]);
  575. $response = $client->get($request_url, ['headers' => $headers]);
  576. $response_arr = json_decode($response->getBody()->getContents(), true);
  577. $result = [
  578. 'SessionToken' => isset($response_arr['Result']['Credentials']['SessionToken']) ? $response_arr['Result']['Credentials']['SessionToken'] : '',
  579. 'AccessKeyId' => isset($response_arr['Result']['Credentials']['AccessKeyId']) ? $response_arr['Result']['Credentials']['AccessKeyId'] : '',
  580. 'SecretAccessKey' => isset($response_arr['Result']['Credentials']['SecretAccessKey']) ? $response_arr['Result']['Credentials']['SecretAccessKey'] : '',
  581. 'Region' => env('VOLC_REGION'),
  582. 'Endpoint' => env('VOLC_END_POINT'),
  583. 'Bucket' => env('VOLC_BUCKET'),
  584. ];
  585. // 缓存token
  586. Redis::setex('volc_sts_token', 3000, json_encode($result));
  587. return $result;
  588. } else {
  589. return json_decode($token, true);
  590. }
  591. // $response = $response['Response'];
  592. // $access_key = $response['Credentials']['AccessKeyId'];
  593. // $secret_key = $response['Credentials']['AccessKeySecret'];
  594. // $security_token = $response['Credentials']['SecurityToken'];
  595. // $expiration = $response['Credentials']['Expiration'];
  596. // dd($response_arr);
  597. }
  598. public function emotionGroups($data) {
  599. $id = getProp($data, 'group_id');
  600. $group_name = getProp($data, 'group_name');
  601. $voice_type = getProp($data, 'voice_type');
  602. $query = DB::table('mp_emotion_groups')->where('is_enabled', 1)->select('id as group_id', 'group_name',
  603. 'emotion', 'emotion_type', 'voice_name', 'voice_type', 'speed_ratio', 'loudness_ratio', 'emotion_scale',
  604. 'pitch', 'generate_status', 'audio_url', 'error_msg');
  605. if ($group_name) {
  606. $query->where('group_name', 'like', "%{$group_name}%");
  607. }
  608. if ($id) {
  609. $query->where('id', $id);
  610. }
  611. if ($voice_type) {
  612. $query->where('voice_type', $voice_type);
  613. }
  614. return $query->orderBy('id')->get()->map(function ($value) {
  615. return (array)$value;
  616. })->toArray();
  617. }
  618. private function sign($key, $msg) {
  619. return hash_hmac('sha256', $msg, $key, true);
  620. }
  621. // 生成签名密钥
  622. private function getSignatureKey($key, $dateStamp, $regionName, $serviceName) {
  623. $kDate = $this->sign($key, $dateStamp);
  624. $kRegion = $this->sign($kDate, $regionName);
  625. $kService = $this->sign($kRegion, $serviceName);
  626. $kSigning = $this->sign($kService, 'request');
  627. return $kSigning;
  628. }
  629. // 获取签名头信息
  630. private function getSignHeaders($method, $service, $host, $region, $request_parameters, $access_key, $secret_key) {
  631. $contenttype = 'application/x-www-form-urlencoded';
  632. $accept = 'application/json';
  633. // 获取当前UTC时间
  634. $t = new DateTime('now', new DateTimeZone('UTC'));
  635. $xdate = $t->format('Ymd\THis\Z');
  636. $datestamp = $t->format('Ymd');
  637. // 1. 拼接规范请求串
  638. $canonical_uri = '/';
  639. $canonical_querystring = $request_parameters;
  640. $canonical_headers = "content-type:{$contenttype}\nhost:{$host}\nx-date:{$xdate}\n";
  641. $signed_headers = 'content-type;host;x-date';
  642. // 空请求体的SHA256哈希
  643. $payload_hash = hash('sha256', '');
  644. $canonical_request = implode("\n", [
  645. $method,
  646. $canonical_uri,
  647. $canonical_querystring,
  648. $canonical_headers,
  649. $signed_headers,
  650. $payload_hash
  651. ]);
  652. // 2. 拼接待签名字符串
  653. $algorithm = 'HMAC-SHA256';
  654. $credential_scope = implode('/', [$datestamp, $region, $service, 'request']);
  655. $hashed_canonical_request = hash('sha256', $canonical_request);
  656. $string_to_sign = implode("\n", [
  657. $algorithm,
  658. $xdate,
  659. $credential_scope,
  660. $hashed_canonical_request
  661. ]);
  662. // 3. 计算签名
  663. $signing_key = $this->getSignatureKey($secret_key, $datestamp, $region, $service);
  664. $signature = hash_hmac('sha256', $string_to_sign, $signing_key);
  665. // 4. 添加签名到请求头
  666. $authorization_header = sprintf(
  667. '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s',
  668. $algorithm,
  669. $access_key,
  670. $credential_scope,
  671. $signed_headers,
  672. $signature
  673. );
  674. return [
  675. 'Accept' => $accept,
  676. 'Content-Type' => $contenttype,
  677. 'X-Date' => $xdate,
  678. 'Authorization' => $authorization_header
  679. ];
  680. }
  681. // 文字合成语音(火山引擎)
  682. public function tts($data) {
  683. $url = 'https://openspeech.bytedance.com/api/v1/tts';
  684. $headers = [
  685. 'Authorization' => 'Bearer;'.env('VOLC_TOKEN'),
  686. 'Content-Type' => 'application/json; charset=UTF-8'
  687. ];
  688. $post_data = [
  689. 'app' => [
  690. 'appid' => env('VOLC_APPID'),
  691. 'token' => env('VOLC_TOKEN'),
  692. 'cluster' => 'volcano_tts'
  693. ],
  694. 'user' => [
  695. 'uid' => 'mp_audio'
  696. ],
  697. // 'audio' => [
  698. // 'voice_type' =>
  699. // ],
  700. ];
  701. }
  702. public function scriptList($data) {
  703. $script_id = getProp($data, 'script_id');
  704. $script_name = getProp($data, 'script_name');
  705. $query = DB::table('mp_scripts')->where('is_deleted', 0)->select('*');
  706. if ($script_id) {
  707. $query->where('id', $script_id);
  708. }
  709. if ($script_name) {
  710. $query->where('script_name', 'like', "%{$script_name}%");
  711. }
  712. return $query->orderBy('created_at', 'desc')->paginate(12);
  713. }
  714. public function scriptInfo($data) {
  715. $script_id = getProp($data, 'script_id');
  716. $script = DB::table('mp_scripts')->where('id', $script_id)->where('is_deleted', 0)
  717. ->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();
  718. if (!!$script) Utils::throwError('20003:该剧本不存在!');
  719. $script = (array)$script;
  720. $script['roles'] = json_decode($script['roles']);
  721. $script['scenes'] = json_decode($script['scenes']);
  722. // 获取分集信息
  723. $episodes = DB::table('mp_script_episodes')->where('script_id', $script_id)->orderBy('episode_number')
  724. ->select('script_id', 'episode_number', 'title', 'content')
  725. ->get()->map(function ($value) {
  726. return (array)$value;
  727. })->toArray();
  728. $script['episodes'] = $episodes;
  729. return $script;
  730. }
  731. public function createScript($data) {
  732. $script_name = getProp($data, 'script_name');
  733. $uid = Site::getUid(); // 获取当前用户ID
  734. if (!$script_name) Utils::throwError('20001:剧本名称不能为空');
  735. return DB::table('mp_scripts')->insertGetId([
  736. 'script_name' => $script_name,
  737. 'user_id' => $uid,
  738. 'created_at' => date('Y-m-d H:i:s'),
  739. 'updated_at' => date('Y-m-d H:i:s')
  740. ]);
  741. }
  742. /**
  743. * 上传文件识别文字内容并与 DeepSeek 进行对话(流式输出版本)
  744. *
  745. * @param array $data 包含以下参数:
  746. * - file: 上传的文件(UploadedFile 对象)或文件路径
  747. * - question: 用户问题(可选)
  748. * - model: 使用的模型(r1 或 v3,默认 v3)
  749. * @return \Generator 返回生成器,用于流式输出
  750. */
  751. public function chatWithFileStream($data) {
  752. $script_id = getProp($data, 'script_id');
  753. $file = getProp($data, 'file');
  754. $content = getProp($data, 'content', '');
  755. $bid = getProp($data, 'bid', 0);
  756. $start_episode_sequence = getProp($data, 'start_episode_sequence', 1);
  757. $total_episode_num = getProp($data, 'total_episode_num', 30);
  758. $question = getProp($data, 'question', "请根据实例完成一个{$total_episode_num}集的剧本,要求: 1.从第{$start_episode_sequence}集开始; 2.每集内容独立成章; 3.避免重复内容; 4.确保内容连贯且逻辑清晰; 5.所有章节合起来是给出的文档内容全文大概剧情。6.分集序号用01、02、03显示,不得使用01-1等子集序号,并且需要匹配开始集数的序号并往后顺序显示。");
  759. $model = getProp($data, 'model', 'r1');
  760. $model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
  761. $script = DB::table('mp_scripts')->where('id', $script_id)->first();
  762. if (!$script) {
  763. Utils::throwError('20003:剧本不存在');
  764. }
  765. if (!$file && !$content && !$bid) {
  766. Utils::throwError('20003:请上传文件或选择书籍或输入内容');
  767. }
  768. // 提取文件内容
  769. if ($content) {
  770. $content = filterContent($content);
  771. } elseif ($file) {
  772. $content = $this->extractFileContent($file);
  773. if (!$content) {
  774. Utils::throwError('20003:无法提取文件内容,请检查文件格式');
  775. }
  776. } elseif ($bid) {
  777. $content = $this->getContentByBid($bid);
  778. if (!$content) {
  779. Utils::throwError('20003:无法获取书籍内容,请检查书籍');
  780. }
  781. } else {
  782. Utils::throwError('20003:请上传文件或选择书籍或输入内容');
  783. }
  784. // 构建消息
  785. $messages = [
  786. [
  787. 'role' => 'system',
  788. 'content' => '你是一个专业的文档分析助手及编剧,请根据用户提供的文档内容完成示例格式的内容输出(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分集剧本>,每个板块之间用###分隔;同时<分集剧本>要求包含"分集序号"、"场景描述"、"出场角色"和"台词内容"这几个部分。示例如下:\n\n
  789. ###故事梗概
  790. 内容梗概:古风厚涂的世界中,少女阿雪身中奇毒"问心刺",气息奄奄。少年梁萧心急如焚,用奇珍"阴阳球"稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出"问心刺",竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
  791. ###剧本亮点
  792. 亮点1:绝境奇药,生死一线
  793. 画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将"阴阳球"塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
  794. 亮点2:结拜兄妹,化解尴尬
  795. 阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句"我……我愿意的"带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
  796. 亮点3:纤发夺命,情愫暗涌
  797. 拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当"问心刺"被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
  798. ###人物关系
  799. 阿雪与梁萧之间存在兄妹之情。
  800. ###核心矛盾
  801. 阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出"问心刺",竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
  802. ###主体列表
  803. 阿雪-病重:身中奇毒"问心刺",气息奄奄,面色苍白,身体虚弱。
  804. 阿雪-羞涩:经"阴阳球"救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。
  805. 梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
  806. 阴阳球:天地异宝,能化生精气,救人于危难。
  807. 问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
  808. ###美术风格
  809. 基础画风风格词:厚涂古风
  810. 整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出"阴阳球"的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
  811. ###场景列表
  812. 林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。
  813. ###分集剧本
  814. ##分集01
  815. 分集名: 第一集
  816. 场景描述:地点、时间、场景
  817. 出场角色:男主、女主
  818. 台词内容:
  819. 女主: 女主对话
  820. 男主:男主对话
  821. 女主: 女主对话
  822. 男主: 男主对话
  823. ##分集02
  824. 分集名: 第二集
  825. 场景描述:地点、时间、场景
  826. 出场角色:男主、女主
  827. 台词内容:
  828. 女主: 女主对话
  829. 男主:男主对话
  830. 女主: 女主对话
  831. 男主: 男主对话'
  832. ],
  833. [
  834. 'role' => 'user',
  835. 'content' => "以下是文档内容:\n\n{$content}\n\n用户问题:{$question}"
  836. ]
  837. ];
  838. $post_data = [
  839. 'model' => $model,
  840. 'messages' => $messages,
  841. 'max_tokens' => 8192,
  842. 'temperature' => 0.7,
  843. 'frequency_penalty' => 0,
  844. 'presence_penalty' => 0,
  845. 'response_format' => ['type' => 'text'],
  846. 'stream' => true // 启用流式输出
  847. ];
  848. $client = new Client(['timeout' => 1200, 'verify' => false]);
  849. $response = $client->post($this->url, [
  850. 'json' => $post_data,
  851. 'headers' => $this->headers,
  852. 'stream' => true // 启用流式响应
  853. ]);
  854. $body = $response->getBody();
  855. $fullContent = '';
  856. $fullReasoningContent = '';
  857. $usage = [];
  858. $buffer = '';
  859. Log::info('开始读取 DeepSeek 流式响应');
  860. // 逐行读取流式响应
  861. while (!$body->eof()) {
  862. $chunk = $body->read(1024); // 每次读取 1KB
  863. $buffer .= $chunk;
  864. // 按行分割
  865. $lines = explode("\n", $buffer);
  866. // 保留最后一个不完整的行
  867. $buffer = array_pop($lines);
  868. foreach ($lines as $line) {
  869. $line = trim($line);
  870. // 跳过空行
  871. if (empty($line)) {
  872. continue;
  873. }
  874. Log::info('收到原始行数据', ['line' => $line]);
  875. // 检查是否是 SSE 数据行
  876. if (strpos($line, 'data: ') !== 0) {
  877. Log::warning('非 SSE 数据行', ['line' => $line]);
  878. continue;
  879. }
  880. $data = substr($line, 6); // 移除 "data: " 前缀
  881. if ($data === '[DONE]') {
  882. Log::info('收到结束标记');
  883. break 2; // 跳出两层循环
  884. }
  885. try {
  886. $json = json_decode($data, true);
  887. if (json_last_error() !== JSON_ERROR_NONE) {
  888. Log::warning('JSON 解析错误: ' . json_last_error_msg(), ['data' => $data]);
  889. continue;
  890. }
  891. Log::info('成功解析 JSON', ['has_choices' => isset($json['choices'])]);
  892. if (isset($json['choices'][0]['delta'])) {
  893. $delta = $json['choices'][0]['delta'];
  894. // 处理思维链内容(仅 deepseek-reasoner 模型)
  895. // 注释掉以下内容则不返回d思维链内容
  896. if (isset($delta['reasoning_content'])) {
  897. $fullReasoningContent .= $delta['reasoning_content'];
  898. Log::info('输出思维链内容', ['length' => strlen($delta['reasoning_content'])]);
  899. yield [
  900. 'type' => 'reasoning',
  901. 'content' => $delta['reasoning_content'],
  902. 'full_reasoning' => $fullReasoningContent
  903. ];
  904. }
  905. // 处理最终回答内容
  906. if (isset($delta['content'])) {
  907. $fullContent .= $delta['content'];
  908. Log::info('输出回答内容', ['length' => strlen($delta['content'])]);
  909. yield [
  910. 'type' => 'content',
  911. 'content' => $delta['content'],
  912. 'full_content' => $fullContent
  913. ];
  914. }
  915. }
  916. // 获取使用统计信息
  917. if (isset($json['usage'])) {
  918. $usage = $json['usage'];
  919. Log::info('收到使用统计', ['usage' => $usage]);
  920. }
  921. } catch (\Exception $e) {
  922. Log::error('解析流式响应失败: ' . $e->getMessage(), ['line' => $line]);
  923. continue;
  924. }
  925. }
  926. }
  927. Log::info('流式读取完成', [
  928. 'content_length' => strlen($fullContent),
  929. 'reasoning_length' => strlen($fullReasoningContent)
  930. ]);
  931. // 处理完整内容并返回最终结果
  932. $script_arr = [];
  933. if ($fullContent) {
  934. $script_arr = handleScriptContent($fullContent);
  935. }
  936. yield [
  937. 'type' => 'done',
  938. 'script' => $script_arr,
  939. 'answer' => $fullContent,
  940. 'reasoning' => $fullReasoningContent,
  941. 'usage' => $usage
  942. ];
  943. }
  944. /**
  945. * 上传文件识别文字内容并与 DeepSeek 进行对话
  946. *
  947. * @param array $data 包含以下参数:
  948. * - file: 上传的文件(UploadedFile 对象)或文件路径
  949. * - question: 用户问题(可选,默认为"请总结这个文件的内容")
  950. * - model: 使用的模型(r1 或 v3,默认 v3)
  951. * @return array
  952. */
  953. public function chatWithFile($data) {
  954. // dd(handleScriptContent(''));
  955. $script_id = getProp($data, 'script_id');
  956. $file = getProp($data, 'file');
  957. $content = getProp($data, 'content', '');
  958. $bid = getProp($data, 'bid', 0);
  959. $start_episode_sequence = getProp($data, 'start_episode_sequence', 1);
  960. $total_episode_num = getProp($data, 'total_episode_num', 30);
  961. $question = getProp($data, 'question', "请根据实例完成一个{$total_episode_num}集的剧本,要求: 1.从第{$start_episode_sequence}集开始; 2.每集内容独立成章; 3.避免重复内容; 4.确保内容连贯且逻辑清晰; 5.所有章节合起来是给出的文档内容全文大概剧情。6.分集序号用01、02、03显示,不得使用01-1等子集序号,并且需要匹配开始集数的序号并往后顺序显示。");
  962. $model = getProp($data, 'model', 'v3');
  963. $model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
  964. $script = DB::table('mp_scripts')->where('id', $script_id)->first();
  965. if (!$script) {
  966. Utils::throwError('20003:剧本不存在');
  967. }
  968. if (!$file && !$content && !$bid) {
  969. Utils::throwError('20003:请上传文件或选择书籍或输入内容');
  970. }
  971. // 提取文件内容
  972. if ($content) {
  973. $content = filterContent($content);
  974. } elseif ($file) {
  975. $content = $this->extractFileContent($file);
  976. if (!$content) {
  977. Utils::throwError('20003:无法提取文件内容,请检查文件格式');
  978. }
  979. } elseif ($bid) {
  980. $content = $this->getContentByBid($bid);
  981. if (!$content) {
  982. Utils::throwError('20003:无法获取书籍内容,请检查书籍');
  983. }
  984. } else {
  985. Utils::throwError('20003:请上传文件或选择书籍或输入内容');
  986. }
  987. // 构建消息
  988. $messages = [
  989. [
  990. 'role' => 'system',
  991. 'content' => '你是一个专业的文档分析助手及编剧,请根据用户提供的文档内容完成示例格式的内容输出(需通过以下几个板块进行回复: <故事梗概><剧本亮点><人物关系><核心矛盾><主体列表><美术风格><场景列表><分集剧本>,每个板块之间用###分隔;同时<分集剧本>要求包含“分集序号”、“场景描述”、“出场角色”和“台词内容”这几个部分。示例如下:\n\n
  992. ###故事梗概
  993. 内容梗概:古风厚涂的世界中,少女阿雪身中奇毒“问心刺”,气息奄奄。少年梁萧心急如焚,用奇珍“阴阳球”稳住阿雪性命,却发现必须解衣拔刺。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
  994. ###剧本亮点
  995. 亮点1:绝境奇药,生死一线
  996. 画面开场即是阿雪苍白无力的脸庞,梁萧眼中泪光闪烁,急促的呼吸声与心跳声烘托紧张气氛。特写梁萧将“阴阳球”塞入阿雪舌底,球体散发微光,阿雪气息从若有若无到逐渐平稳,生死逆转的瞬间,视觉与听觉冲击力十足。
  997. 亮点2:结拜兄妹,化解尴尬
  998. 阿雪因解衣拔刺而羞愤欲绝的特写,梁萧眼神中的挣扎与无奈。在紧迫的背景音下,梁萧急中生智,牵起阿雪颤抖的手,跪地匆匆结拜,阿雪眼神从惊惶到迷茫,最终一句“我……我愿意的”带着哭腔,将救命与羞耻的矛盾推向高潮,又以出人意料的方式解决。
  999. 亮点3:纤发夺命,情愫暗涌
  1000. 拔刺过程的紧张特写,梁萧指尖小心翼翼地捏住细丝,缓慢抽出。当“问心刺”被完全拔出,特写它竟是一根细长、泛着寒光的女子秀发,诡异而令人震惊。阿雪苏醒后,两人眼神交汇,阿雪低头羞涩,梁萧眼中复杂的情绪,无声地传递出超越兄妹的微妙情愫。
  1001. ###人物关系
  1002. 阿雪与梁萧之间存在兄妹之情。
  1003. ###核心矛盾
  1004. 阿雪与梁萧之间存在兄妹之情,但因情愫暗生而产生微妙的矛盾。阿雪羞涩难当,梁萧情急之下提出结拜兄妹,以化解尴尬。两人匆匆结拜后,梁萧小心翼翼地拔出“问心刺”,竟发现它是一根诡异的女子秀发。阿雪转危为安,两人之间,在生死与羞涩交织的瞬间,情愫暗生,却又被兄妹之名束缚,留下无尽遐想。
  1005. ###主体列表
  1006. 阿雪-病重:身中奇毒“问心刺”,气息奄奄,面色苍白,身体虚弱。
  1007. 阿雪-羞涩:经“阴阳球”救治后逐渐恢复,脸颊泛起红晕,眼神娇羞,情愫暗生。
  1008. 梁萧:少年侠士,心系阿雪安危,沉着冷静,内心挣扎于情义之间。
  1009. 阴阳球:天地异宝,能化生精气,救人于危难。
  1010. 问心刺:一根诡异的女子秀发,细长泛寒光,是致命的奇毒。
  1011. ###美术风格
  1012. 基础画风风格词:厚涂古风
  1013. 整体采用精致细腻的厚涂古风,色彩浓郁而富有层次感,注重光影对比,营造出古典而富有戏剧张力的氛围。色调以深沉的蓝绿、灰调为主,衬托阿雪病弱的苍白,同时用温暖的金色、琥珀色光晕突出“阴阳球”的生命力。拔刺与结拜场景则以柔和的暖色调为主,辅以人物脸颊的红晕,烘托羞涩与情愫暗生的微妙感。人物造型飘逸,服饰细节考究,背景虚实结合,既有古韵,又不失奇幻色彩。
  1014. ###场景列表
  1015. 林间疗伤地:幽静的古风林间空地,一棵高大古树下,是梁萧为阿雪疗伤的地点。环境清幽,光影斑驳,既有古韵又不失奇幻色彩。
  1016. ###分集剧本
  1017. ##分集01
  1018. 分集名: 第一集
  1019. 场景描述:地点、时间、场景
  1020. 出场角色:男主、女主
  1021. 台词内容:
  1022. 女主: 女主对话
  1023. 男主:男主对话
  1024. 女主: 女主对话
  1025. 男主: 男主对话
  1026. ##分集02
  1027. 分集名: 第二集
  1028. 场景描述:地点、时间、场景
  1029. 出场角色:男主、女主
  1030. 台词内容:
  1031. 女主: 女主对话
  1032. 男主:男主对话
  1033. 女主: 女主对话
  1034. 男主: 男主对话'
  1035. ],
  1036. [
  1037. 'role' => 'user',
  1038. 'content' => "以下是文档内容:\n\n{$content}\n\n用户问题:{$question}"
  1039. ]
  1040. ];
  1041. $post_data = [
  1042. 'model' => $model,
  1043. 'messages' => $messages,
  1044. 'max_tokens' => 8192,
  1045. 'temperature' => 0.7,
  1046. 'frequency_penalty' => 0,
  1047. 'presence_penalty' => 0,
  1048. 'response_format' => ['type' => 'text'],
  1049. 'stream' => false
  1050. ];
  1051. $client = new Client(['timeout' => 1200, 'verify' => false]);
  1052. $result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]);
  1053. $response = $result->getBody()->getContents();
  1054. $response_arr = json_decode($response, true);
  1055. $content = '';
  1056. $usage = [];
  1057. if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
  1058. $content = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
  1059. $usage = isset($response_arr['usage']) ? $response_arr['usage'] : [];
  1060. }
  1061. // 处理结果
  1062. if ($content) {
  1063. $script_arr = handleScriptContent($content);
  1064. }
  1065. return [
  1066. 'script' => $script_arr,
  1067. 'answer' => $content,
  1068. 'usage' => $usage
  1069. ];
  1070. }
  1071. public function saveScript($data) {
  1072. $script_arr =getProp($data, 'script', []);
  1073. if (!$script_arr) Utils::throwError('20003:剧本内容不能为空');
  1074. $start_episode_sequence = getProp($data, 'start_episode_sequence', 1);
  1075. $script_id = getProp($data, 'script_id');
  1076. if (!$script_id) Utils::throwError('20003:剧本ID不能为空');
  1077. try {
  1078. DB::beginTransaction();
  1079. $update_data = [
  1080. 'intro' => isset($script_arr['intro']) ? $script_arr['intro'] : '',
  1081. 'highlights' => isset($script_arr['highlights']) ? $script_arr['highlights'] : '',
  1082. 'role_relationship' => isset($script_arr['role_relationship']) ? $script_arr['role_relationship'] : '',
  1083. 'core_contradiction' => isset($script_arr['core_contradiction']) ? $script_arr['core_contradiction'] : '',
  1084. 'roles' => isset($script_arr['roles']) ? json_encode($script_arr['roles'], true) : '',
  1085. 'art_style' => isset($script_arr['art_style']) ? $script_arr['art_style'] : '',
  1086. 'scenes' => isset($script_arr['scenes']) ? json_encode($script_arr['scenes'], true) : '',
  1087. 'status' => 3,
  1088. 'start_episode_sequence' => $start_episode_sequence,
  1089. 'end_episode_sequence' => $start_episode_sequence + count($script_arr['episodes']) - 1,
  1090. 'episode_num' => count($script_arr['episodes']),
  1091. 'updated_at' => date('Y-m-d H:i:s')
  1092. ];
  1093. // 保存剧本内容
  1094. $boolen = DB::table('mp_scripts')->where('id', $script_id)->update($update_data);
  1095. if (!$boolen) {
  1096. dLog('deepseek')->info('剧本内容保存失败', $update_data);
  1097. Utils::throwError('20003:剧本内容保存失败');
  1098. }
  1099. // 保存分集内容
  1100. // 删除历史分集内容
  1101. $boolen1 = DB::table('mp_script_episodes')->where('script_id', $script_id)->delete();
  1102. $episodes = [];
  1103. foreach ($script_arr['episodes'] as $episode) {
  1104. $episodes[] = [
  1105. 'script_id' => $script_id,
  1106. 'episode_number' => $episode['episode_number'],
  1107. 'title' => $episode['title'],
  1108. 'content' => $episode['content'],
  1109. 'created_at' => date('Y-m-d H:i:s'),
  1110. 'updated_at' => date('Y-m-d H:i:s')
  1111. ];
  1112. }
  1113. $boolen2 = DB::table('mp_script_episodes')->insert($episodes);
  1114. if (!$boolen2) {
  1115. dLog('deepseek')->info('分集内容保存失败', $episodes);
  1116. Utils::throwError('20003:分集内容保存失败');
  1117. }
  1118. } catch (\Exception $e) {
  1119. DB::rollBack();
  1120. Utils::throwError('20003:'.$e->getMessage());
  1121. }
  1122. DB::commit();
  1123. return true;
  1124. }
  1125. public function getContentByBid($bid) {
  1126. $chapters = DB::table('chapters as c')->select('c.id', 'c.name', 'cc.content')
  1127. ->leftJoin('chapter_contents as cc', 'c.chapter_content_id', '=', 'cc.id')
  1128. ->where('c.bid', $bid)->where('c.is_check', 1)->where('c.is_deleted', 0)->orderBy('c.sequence')->get();
  1129. $content = '';
  1130. foreach ($chapters as $chapter) {
  1131. $content .= $chapter->content . PHP_EOL . PHP_EOL;
  1132. }
  1133. return $content;
  1134. }
  1135. /**
  1136. * 根据文件类型提取文本内容
  1137. *
  1138. * @param mixed $file 文件对象或文件路径
  1139. * @return string
  1140. */
  1141. public function extractFileContent($file) {
  1142. // 获取文件路径和扩展名
  1143. if (is_string($file)) {
  1144. $filePath = $file;
  1145. $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
  1146. } else {
  1147. // Laravel UploadedFile 对象
  1148. $filePath = $file->getRealPath();
  1149. $extension = strtolower($file->getClientOriginalExtension());
  1150. }
  1151. $content = '';
  1152. switch ($extension) {
  1153. case 'txt':
  1154. $content = $this->extractTxtContent($filePath);
  1155. break;
  1156. case 'pdf':
  1157. $content = $this->extractPdfContent($filePath);
  1158. break;
  1159. case 'doc':
  1160. case 'docx':
  1161. $content = $this->extractWordContent($filePath);
  1162. break;
  1163. // case 'jpg':
  1164. // case 'jpeg':
  1165. // case 'png':
  1166. // case 'gif':
  1167. // case 'bmp':
  1168. // case 'webp':
  1169. // $content = $this->extractImageContent($filePath);
  1170. // break;
  1171. default:
  1172. Utils::throwError('20003:不支持的文件格式,支持:txt、pdf');
  1173. }
  1174. return $content;
  1175. }
  1176. /**
  1177. * 提取 TXT 文件内容
  1178. */
  1179. private function extractTxtContent($filePath) {
  1180. if (!file_exists($filePath)) {
  1181. Utils::throwError('20003:文件不存在');
  1182. }
  1183. $content = file_get_contents($filePath);
  1184. // 尝试检测并转换编码
  1185. $encoding = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312', 'BIG5'], true);
  1186. if ($encoding && $encoding !== 'UTF-8') {
  1187. $content = mb_convert_encoding($content, 'UTF-8', $encoding);
  1188. }
  1189. return trim($content);
  1190. }
  1191. /**
  1192. * 提取 PDF 文件内容
  1193. */
  1194. private function extractPdfContent($filePath) {
  1195. if (!file_exists($filePath)) {
  1196. Utils::throwError('20003:文件不存在');
  1197. }
  1198. try {
  1199. $parser = new PdfParser();
  1200. $pdf = $parser->parseFile($filePath);
  1201. $content = $pdf->getText();
  1202. return trim($content);
  1203. } catch (\Exception $e) {
  1204. Log::error('PDF解析失败: ' . $e->getMessage());
  1205. Utils::throwError('20003:PDF解析失败,请确保文件格式正确');
  1206. }
  1207. }
  1208. /**
  1209. * 提取 Word 文件内容(支持 doc 和 docx)
  1210. */
  1211. private function extractWordContent($filePath) {
  1212. if (!file_exists($filePath)) {
  1213. Utils::throwError('20003:文件不存在');
  1214. }
  1215. try {
  1216. $phpWord = WordIOFactory::load($filePath);
  1217. $content = '';
  1218. foreach ($phpWord->getSections() as $section) {
  1219. foreach ($section->getElements() as $element) {
  1220. $content .= $this->extractWordElementText($element);
  1221. }
  1222. }
  1223. return trim($content);
  1224. } catch (\Exception $e) {
  1225. Log::error('Word解析失败: ' . $e->getMessage());
  1226. Utils::throwError('20003:Word解析失败,请确保文件格式正确');
  1227. }
  1228. }
  1229. /**
  1230. * 递归提取 Word 元素中的文本
  1231. */
  1232. private function extractWordElementText($element) {
  1233. $text = '';
  1234. if (method_exists($element, 'getText')) {
  1235. $text .= $element->getText() . "\n";
  1236. } elseif (method_exists($element, 'getElements')) {
  1237. foreach ($element->getElements() as $childElement) {
  1238. $text .= $this->extractWordElementText($childElement);
  1239. }
  1240. }
  1241. return $text;
  1242. }
  1243. /**
  1244. * 提取图片内容(使用火山引擎 OCR)
  1245. */
  1246. private function extractImageContent($filePath) {
  1247. if (!file_exists($filePath)) {
  1248. Utils::throwError('20003:文件不存在');
  1249. }
  1250. try {
  1251. // 读取图片并转换为 base64
  1252. $imageData = file_get_contents($filePath);
  1253. $base64Image = base64_encode($imageData);
  1254. // 调用火山引擎 OCR 服务
  1255. $content = $this->callVolcEngineOCR($base64Image);
  1256. return trim($content);
  1257. } catch (\Exception $e) {
  1258. Log::error('图片OCR识别失败: ' . $e->getMessage());
  1259. Utils::throwError('20003:图片识别失败');
  1260. }
  1261. }
  1262. /**
  1263. * 调用火山引擎 OCR 服务识别图片文字
  1264. *
  1265. * @param string $base64Image base64 编码的图片数据
  1266. * @return string 识别的文字内容
  1267. */
  1268. private function callVolcEngineOCR($base64Image) {
  1269. $method = 'POST';
  1270. $service = 'cv';
  1271. $host = 'visual.volcengineapi.com';
  1272. $region = env('VOLC_REGION', 'cn-north-1');
  1273. $access_key = env('VOLC_AK');
  1274. $secret_key = env('VOLC_SK');
  1275. $action = 'OCRNormal';
  1276. $version = '2020-08-26';
  1277. if (!$access_key || !$secret_key) {
  1278. Utils::throwError('20003:请配置火山引擎 AK/SK');
  1279. }
  1280. // 请求体
  1281. $body = json_encode([
  1282. 'image_base64' => $base64Image
  1283. ]);
  1284. // 生成签名
  1285. $headers = $this->getVolcEngineSignHeaders(
  1286. $method, $service, $host, $region,
  1287. "Action={$action}&Version={$version}",
  1288. $access_key, $secret_key, $body
  1289. );
  1290. $client = new Client(['timeout' => 60, 'verify' => false]);
  1291. $response = $client->post("https://{$host}/?Action={$action}&Version={$version}", [
  1292. 'headers' => $headers,
  1293. 'body' => $body
  1294. ]);
  1295. $response_arr = json_decode($response->getBody()->getContents(), true);
  1296. // 提取识别的文字
  1297. $textLines = [];
  1298. if (isset($response_arr['data']['line_texts'])) {
  1299. $textLines = $response_arr['data']['line_texts'];
  1300. } elseif (isset($response_arr['data']['ocr_infos'])) {
  1301. foreach ($response_arr['data']['ocr_infos'] as $info) {
  1302. if (isset($info['text'])) {
  1303. $textLines[] = $info['text'];
  1304. }
  1305. }
  1306. }
  1307. if (empty($textLines)) {
  1308. Log::warning('OCR识别结果为空', ['response' => $response_arr]);
  1309. return '';
  1310. }
  1311. return implode("\n", $textLines);
  1312. }
  1313. /**
  1314. * 生成火山引擎 API 签名头(用于 OCR 等 POST 请求)
  1315. */
  1316. private function getVolcEngineSignHeaders($method, $service, $host, $region, $queryString, $access_key, $secret_key, $body = '') {
  1317. $contentType = 'application/json';
  1318. $accept = 'application/json';
  1319. // 获取当前UTC时间
  1320. $t = new DateTime('now', new DateTimeZone('UTC'));
  1321. $xDate = $t->format('Ymd\THis\Z');
  1322. $dateStamp = $t->format('Ymd');
  1323. // 计算 body 的 sha256 哈希
  1324. $payloadHash = hash('sha256', $body);
  1325. // 1. 拼接规范请求串
  1326. $canonicalUri = '/';
  1327. $canonicalQueryString = $queryString;
  1328. $canonicalHeaders = "content-type:{$contentType}\nhost:{$host}\nx-content-sha256:{$payloadHash}\nx-date:{$xDate}\n";
  1329. $signedHeaders = 'content-type;host;x-content-sha256;x-date';
  1330. $canonicalRequest = implode("\n", [
  1331. $method,
  1332. $canonicalUri,
  1333. $canonicalQueryString,
  1334. $canonicalHeaders,
  1335. $signedHeaders,
  1336. $payloadHash
  1337. ]);
  1338. // 2. 拼接待签名字符串
  1339. $algorithm = 'HMAC-SHA256';
  1340. $credentialScope = implode('/', [$dateStamp, $region, $service, 'request']);
  1341. $hashedCanonicalRequest = hash('sha256', $canonicalRequest);
  1342. $stringToSign = implode("\n", [
  1343. $algorithm,
  1344. $xDate,
  1345. $credentialScope,
  1346. $hashedCanonicalRequest
  1347. ]);
  1348. // 3. 计算签名
  1349. $signingKey = $this->getSignatureKey($secret_key, $dateStamp, $region, $service);
  1350. $signature = hash_hmac('sha256', $stringToSign, $signingKey);
  1351. // 4. 添加签名到请求头
  1352. $authorizationHeader = sprintf(
  1353. '%s Credential=%s/%s, SignedHeaders=%s, Signature=%s',
  1354. $algorithm,
  1355. $access_key,
  1356. $credentialScope,
  1357. $signedHeaders,
  1358. $signature
  1359. );
  1360. return [
  1361. 'Accept' => $accept,
  1362. 'Content-Type' => $contentType,
  1363. 'Host' => $host,
  1364. 'X-Date' => $xDate,
  1365. 'X-Content-Sha256'=> $payloadHash,
  1366. 'Authorization' => $authorizationHeader
  1367. ];
  1368. }
  1369. public function generateScriptWords($cid, $model = 'r1') {
  1370. ini_set('max_execution_time', 0);
  1371. if (!$cid) Utils::throwError('20003: 请选择章节!');
  1372. $content = DB::table('chapters as c')->leftJoin('chapter_contents as cc', 'c.chapter_content_id', '=', 'cc.id')->where('c.id', $cid)->value('cc.content');
  1373. $model = $model == 'r1' ? 'deepseek-reasoner' : 'deepseek-chat';
  1374. $messages = [
  1375. [
  1376. 'role' => 'system',
  1377. 'content' => '下面有一段小说文本,请帮我将文本中的每句话按从上到下的顺序拆分成角色不同的剧本文稿(不得更改上下文顺序和内容),文稿形式严格按照“角色名(男或女):台词{情感}”输出,需要注意以下几点要求:
  1378. 1.角色名后不要加入任何其他词语,只能加不包括旁白的性别,在男或女中选
  1379. 2.非对话部分请全部用旁白角色代替
  1380. 3.情感必须在【开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰鼓励、咆哮、温柔、自然讲述、情感电台、磁性、广告营销、气泡音、新闻播报、娱乐八卦】中选一个,不得使用其他词语'
  1381. ],
  1382. [
  1383. 'role' => 'user',
  1384. 'content' => $content
  1385. ]
  1386. ];
  1387. $post_data = [
  1388. 'model' => $model, // R1模型: deepseek-reasoner V3模型: deepseek-chat
  1389. 'messages' => $messages,
  1390. 'max_tokens' => 8192,
  1391. 'temperature' => 1, // 采样温度,介于 0 和 2 之间。更高的值,如 0.8,会使输出更随机,而更低的值,如 0.2,会使其更加集中和确定。 我们通常建议可以更改这个值或者更改 top_p,但不建议同时对两者进行修改。
  1392. // 'top_p' => 1, // 作为调节采样温度的替代方案(<=1),模型会考虑前 top_p 概率的 token 的结果。所以 0.1 就意味着只有包括在最高 10% 概率中的 token 会被考虑。 我们通常建议修改这个值或者更改 temperature,但不建议同时对两者进行修改。
  1393. 'frequency_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其在已有文本中的出现频率受到相应的惩罚,降低模型重复相同内容的可能性。
  1394. 'presence_penalty' => 0, // 介于 -2.0 和 2.0 之间的数字。如果该值为正,那么新 token 会根据其是否已在已有文本中出现受到相应的惩罚,从而增加模型谈论新主题的可能性。
  1395. 'response_format' => [
  1396. 'type' => 'text' // 默认值text,回答的结果输出文字(非接口返回值是text,接口返回值还是json字串),还可选:json_object,输出json格式
  1397. ],
  1398. 'stream' => false // 是否流式输出,如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾。
  1399. ];
  1400. $client = new Client(['timeout' => 1200, 'verify' => false]);
  1401. $result = $client->post($this->url, ['json' => $post_data, 'headers' => $this->headers]);
  1402. $response = $result->getBody()->getContents();
  1403. $response_arr = json_decode($response, true);
  1404. $update_data = [];
  1405. $content = '';
  1406. if (isset($response_arr['choices']) && count($response_arr['choices']) > 0) {
  1407. $content = isset($response_arr['choices'][0]['message']['content']) ? $response_arr['choices'][0]['message']['content'] : '';
  1408. $update_data = [
  1409. 'role' => 'assistant',
  1410. 'content' => $response_arr['choices'][0]['message']['content'],
  1411. 'usage' => isset($response_arr['usage']) ? $response_arr['usage'] : []
  1412. ];
  1413. }
  1414. return $content;
  1415. }
  1416. }