更新
This commit is contained in:
@@ -14,6 +14,7 @@ use app\model\User;
|
||||
use app\model\UserDailyStat;
|
||||
use app\service\AdminScopeService;
|
||||
use app\service\ComfyUIService;
|
||||
use app\service\CosyVoiceService;
|
||||
use app\service\DepartmentService;
|
||||
use app\service\DifyService;
|
||||
use app\service\OpenAIService;
|
||||
@@ -666,11 +667,87 @@ class Admin extends BaseApi
|
||||
AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']);
|
||||
$input = $this->request->put();
|
||||
foreach ($input as $key => $value) {
|
||||
if ($key === 'voice_persona') {
|
||||
if (!is_array($value)) {
|
||||
return $this->error('AI 客服人物配置格式无效', 422);
|
||||
}
|
||||
$value = CosyVoiceService::normalizePersona($value);
|
||||
}
|
||||
SettingsService::set($key, $value);
|
||||
}
|
||||
CosyVoiceService::clearFailure();
|
||||
return $this->success(null, '设置已更新');
|
||||
}
|
||||
|
||||
public function uploadVoiceReference()
|
||||
{
|
||||
AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']);
|
||||
$file = $this->request->file('file');
|
||||
if (!$file) {
|
||||
return $this->error('请选择 WAV 参考音频', 422);
|
||||
}
|
||||
|
||||
$originalName = basename((string) $file->getOriginalName());
|
||||
$extension = strtolower($file->extension() ?: pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
if ($extension !== 'wav') {
|
||||
return $this->error('音色样本只支持 WAV 文件', 422);
|
||||
}
|
||||
if ((int) $file->getSize() > 15 * 1024 * 1024) {
|
||||
return $this->error('音色样本不能超过 15MB', 422);
|
||||
}
|
||||
|
||||
$header = @file_get_contents($file->getPathname(), false, null, 0, 12);
|
||||
if (!is_string($header) || strlen($header) < 12 || substr($header, 0, 4) !== 'RIFF' || substr($header, 8, 4) !== 'WAVE') {
|
||||
return $this->error('文件不是有效的 WAV 音频', 422);
|
||||
}
|
||||
|
||||
$targetDir = root_path() . 'storage' . DIRECTORY_SEPARATOR . 'cosyvoice';
|
||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
|
||||
return $this->error('无法创建音色样本目录', 500);
|
||||
}
|
||||
|
||||
$storedName = 'voice-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.wav';
|
||||
$moved = $file->move($targetDir, $storedName);
|
||||
if (!$moved) {
|
||||
return $this->error('音色样本保存失败', 500);
|
||||
}
|
||||
|
||||
$path = $targetDir . DIRECTORY_SEPARATOR . $storedName;
|
||||
$persona = CosyVoiceService::getPersona();
|
||||
$persona['prompt_wav'] = $path;
|
||||
$persona['prompt_wav_name'] = $originalName;
|
||||
$persona = CosyVoiceService::normalizePersona($persona, $persona);
|
||||
SettingsService::set('voice_persona', $persona);
|
||||
CosyVoiceService::clearFailure($persona);
|
||||
|
||||
return $this->success([
|
||||
'name' => $persona['prompt_wav_name'],
|
||||
'path' => $persona['prompt_wav'],
|
||||
], '音色样本上传成功');
|
||||
}
|
||||
|
||||
public function previewVoicePersona()
|
||||
{
|
||||
AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']);
|
||||
$text = trim((string) ($this->request->post('text') ?: '您好,我是您的 AI 客服,很高兴为您服务。'));
|
||||
$text = mb_substr($text, 0, 160);
|
||||
|
||||
try {
|
||||
CosyVoiceService::clearFailure();
|
||||
$speech = CosyVoiceService::speech($text);
|
||||
} catch (\Throwable $error) {
|
||||
return $this->error($error->getMessage(), 502);
|
||||
}
|
||||
|
||||
return response($speech['audio'], 200, [
|
||||
'Content-Type' => $speech['content_type'],
|
||||
'Content-Length' => (string) strlen($speech['audio']),
|
||||
'Cache-Control' => 'no-store',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'X-TTS-Provider' => 'cosyvoice',
|
||||
]);
|
||||
}
|
||||
|
||||
public function models()
|
||||
{
|
||||
AdminScopeService::requireAny($this->authUser(), ['menu:models', 'can_manage_models']);
|
||||
@@ -872,6 +949,9 @@ class Admin extends BaseApi
|
||||
'inpaint_seed_node',
|
||||
'inpaint_image_node',
|
||||
'inpaint_mask_node',
|
||||
'tts_model',
|
||||
'tts_voice',
|
||||
'tts_instructions',
|
||||
] as $key) {
|
||||
if (!array_key_exists($key, $raw)) {
|
||||
continue;
|
||||
|
||||
@@ -9,6 +9,7 @@ use app\model\UploadFile;
|
||||
use app\service\AgentCatalog;
|
||||
use app\service\ComfyJobDeferredException;
|
||||
use app\service\ComfyUIService;
|
||||
use app\service\CosyVoiceService;
|
||||
use app\service\DifyService;
|
||||
use app\service\DocumentTextService;
|
||||
use app\service\OpenAIService;
|
||||
@@ -18,6 +19,133 @@ use think\facade\Log;
|
||||
|
||||
class Chat extends BaseApi
|
||||
{
|
||||
public function speech()
|
||||
{
|
||||
$this->authUser();
|
||||
$input = $this->request->post();
|
||||
$text = trim((string) ($input['text'] ?? ''));
|
||||
|
||||
if ($text === '') {
|
||||
return $this->error('语音内容不能为空', 422);
|
||||
}
|
||||
if (mb_strlen($text) > 600) {
|
||||
return $this->error('单次语音内容不能超过 600 个字符', 422);
|
||||
}
|
||||
|
||||
$modelId = isset($input['model_id']) && $input['model_id'] !== ''
|
||||
? (int) $input['model_id']
|
||||
: null;
|
||||
$persona = CosyVoiceService::getPersona();
|
||||
$voice = trim((string) ($persona['fallback_voice'] ?? $input['voice'] ?? 'marin'));
|
||||
$allowedVoices = [
|
||||
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
|
||||
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
|
||||
];
|
||||
if (!in_array($voice, $allowedVoices, true)) {
|
||||
$voice = 'marin';
|
||||
}
|
||||
|
||||
$speech = null;
|
||||
if (CosyVoiceService::canAttempt()) {
|
||||
try {
|
||||
$speech = CosyVoiceService::speech($text);
|
||||
} catch (\Throwable $error) {
|
||||
Log::warning('CosyVoice speech fallback: ' . $error->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!$speech) {
|
||||
$model = OpenAIService::getSpeechModel($modelId);
|
||||
$speech = OpenAIService::speech($model, $text, $voice);
|
||||
$speech['provider'] = 'openai';
|
||||
}
|
||||
|
||||
return response($speech['audio'], 200, [
|
||||
'Content-Type' => $speech['content_type'],
|
||||
'Content-Length' => (string) strlen($speech['audio']),
|
||||
'Cache-Control' => 'no-store',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'X-TTS-Provider' => $speech['provider'] ?? 'unknown',
|
||||
]);
|
||||
}
|
||||
|
||||
public function speechStream(): never
|
||||
{
|
||||
$this->authUser();
|
||||
$input = $this->request->post();
|
||||
$text = trim((string) ($input['text'] ?? ''));
|
||||
$requestId = trim((string) ($input['request_id'] ?? ''));
|
||||
if (!preg_match('/^[A-Za-z0-9._:-]{8,128}$/', $requestId)) {
|
||||
$requestId = bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
if ($text === '' || mb_strlen($text) > 600 || !CosyVoiceService::canAttempt()) {
|
||||
http_response_code($text === '' || mb_strlen($text) > 600 ? 422 : 503);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode([
|
||||
'code' => 1,
|
||||
'message' => $text === ''
|
||||
? '语音内容不能为空'
|
||||
: (mb_strlen($text) > 600 ? '单次语音内容不能超过 600 个字符' : 'CosyVoice 暂时不可用'),
|
||||
'data' => null,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
@ini_set('zlib.output_compression', '0');
|
||||
ignore_user_abort(false);
|
||||
OpenAIService::sseHeaders();
|
||||
|
||||
$persona = CosyVoiceService::getPersona();
|
||||
OpenAIService::sseEvent('meta', [
|
||||
'provider' => 'cosyvoice',
|
||||
'request_id' => $requestId,
|
||||
'cancel_url' => rtrim((string) ($persona['base_url'] ?? ''), '/')
|
||||
. '/cancel/' . rawurlencode($requestId),
|
||||
'format' => 'pcm_s16le',
|
||||
'sample_rate' => (int) ($persona['sample_rate'] ?? 24000),
|
||||
'channels' => 1,
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = CosyVoiceService::streamSpeech($text, static function (string $pcm): void {
|
||||
OpenAIService::sseEvent('audio', [
|
||||
'audio' => base64_encode($pcm),
|
||||
]);
|
||||
}, $requestId);
|
||||
|
||||
if (empty($result['aborted'])) {
|
||||
OpenAIService::sseEvent('done', [
|
||||
'bytes' => (int) ($result['bytes'] ?? 0),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $error) {
|
||||
Log::warning('CosyVoice stream failed: ' . $error->getMessage());
|
||||
OpenAIService::sseEvent('error', [
|
||||
'message' => $error->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
public function speechCancel()
|
||||
{
|
||||
$this->authUser();
|
||||
$requestId = trim((string) $this->request->post('request_id', ''));
|
||||
if (!preg_match('/^[A-Za-z0-9._:-]{8,128}$/', $requestId)) {
|
||||
return $this->error('语音请求标识无效', 422);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'cancelled' => CosyVoiceService::cancelSpeech($requestId),
|
||||
'request_id' => $requestId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function completions()
|
||||
{
|
||||
$user = $this->authUser();
|
||||
@@ -29,6 +157,7 @@ class Chat extends BaseApi
|
||||
$attachments = $input['attachments'] ?? [];
|
||||
$agentId = trim((string) ($input['agent_id'] ?? ''));
|
||||
$imageTool = trim((string) ($input['image_tool'] ?? ''));
|
||||
$voiceMode = !empty($input['voice_mode']);
|
||||
$stream = ($input['stream'] ?? true) !== false;
|
||||
|
||||
$allowedImageTools = ['enhance', 'erase', 'watermark', 'cutout', 'outpaint', 'replace', 'text', 'restore', 'creative', 'commit'];
|
||||
@@ -153,7 +282,7 @@ class Chat extends BaseApi
|
||||
$history = Message::where('conversation_id', $conversationId)
|
||||
->field('role,content,attachments')
|
||||
->order('id', 'desc')
|
||||
->limit(50)
|
||||
->limit($voiceMode ? 16 : 50)
|
||||
->select()
|
||||
->toArray();
|
||||
$history = array_reverse($history);
|
||||
@@ -167,6 +296,16 @@ class Chat extends BaseApi
|
||||
}
|
||||
|
||||
$apiMessages = $this->buildApiMessages($history, $model);
|
||||
if ($voiceMode) {
|
||||
$voicePersona = CosyVoiceService::getPersona();
|
||||
$personaName = trim((string) ($voicePersona['name'] ?? 'AI 客服')) ?: 'AI 客服';
|
||||
$personaPrompt = trim((string) ($voicePersona['role_prompt'] ?? ''));
|
||||
array_unshift($apiMessages, [
|
||||
'role' => 'system',
|
||||
'content' => '你是名为“' . $personaName . '”的 AI 客服。人物设定:' . $personaPrompt
|
||||
. ' 当前正在进行低延迟实时语音对话。像真人客服一样先回应用户的真实诉求,语气口语化、有耐心、有适度共情,不复述问题,不使用 Markdown 列表,不说“作为 AI”。先给结论,通常控制在 1 到 3 句;信息不足时每轮只追问一个最关键的问题,除非用户明确要求详细说明。',
|
||||
]);
|
||||
}
|
||||
$agentImageActionAllowed = false;
|
||||
if ($agent) {
|
||||
$agentSystemPrompt = $agent['system_prompt'];
|
||||
@@ -1590,6 +1729,12 @@ class Chat extends BaseApi
|
||||
},
|
||||
function (string $message) use (&$streamError) {
|
||||
$streamError = $message;
|
||||
},
|
||||
function () use ($conversationId, &$resolvedExternalConversationId) {
|
||||
$resolvedExternalConversationId = null;
|
||||
ConversationModel::where('id', $conversationId)->update([
|
||||
'external_conversation_id' => null,
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1723,6 +1868,10 @@ class Chat extends BaseApi
|
||||
ConversationModel::where('id', $conversationId)->update([
|
||||
'external_conversation_id' => $resolvedExternalConversationId,
|
||||
]);
|
||||
} elseif (!empty($result['conversation_reset'])) {
|
||||
ConversationModel::where('id', $conversationId)->update([
|
||||
'external_conversation_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$imageAction = $agent
|
||||
|
||||
@@ -93,6 +93,14 @@ class Conversation extends BaseApi
|
||||
} else {
|
||||
$data['model_id'] = (int) $mid;
|
||||
}
|
||||
|
||||
$currentModelId = $conversation->model_id === null
|
||||
? null
|
||||
: (int) $conversation->model_id;
|
||||
if ($data['model_id'] !== $currentModelId) {
|
||||
// Dify conversation_id 只属于创建它的应用/模型;切换模型后不可复用。
|
||||
$data['external_conversation_id'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($data)) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace app\controller\api;
|
||||
|
||||
use app\model\AiModel;
|
||||
use app\service\AgentCatalog;
|
||||
use app\service\CosyVoiceService;
|
||||
use app\service\SettingsService;
|
||||
|
||||
class Settings extends BaseApi
|
||||
@@ -20,6 +21,7 @@ class Settings extends BaseApi
|
||||
'site_name' => SettingsService::get('site_name', 'AI Chat'),
|
||||
'allow_register' => $allow === true || $allow === 'true',
|
||||
'features' => SettingsService::getFeatures(),
|
||||
'voice_persona' => CosyVoiceService::publicPersona(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
<?php
|
||||
|
||||
namespace app\service;
|
||||
|
||||
use think\facade\Cache;
|
||||
|
||||
class CosyVoiceService
|
||||
{
|
||||
private const MODES = ['sft', 'instruct', 'zero_shot', 'cross_lingual', 'instruct2'];
|
||||
private const FALLBACK_VOICES = [
|
||||
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
|
||||
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
|
||||
];
|
||||
|
||||
public static function getPersona(): array
|
||||
{
|
||||
$defaults = [
|
||||
'enabled' => (bool) config('cosyvoice.enabled', true),
|
||||
'name' => '小暖',
|
||||
'greeting' => '您好,我是 AI 客服小暖,请问有什么可以帮您?',
|
||||
'role_prompt' => '温暖、专业、耐心,像经验丰富的真人客服一样理解用户的真实诉求。',
|
||||
'base_url' => (string) config('cosyvoice.base_url', 'http://127.0.0.1:50000'),
|
||||
'mode' => (string) config('cosyvoice.mode', 'sft'),
|
||||
'speaker' => (string) config('cosyvoice.speaker', '中文女'),
|
||||
'instruct_text' => (string) config('cosyvoice.instruct_text', ''),
|
||||
'prompt_text' => (string) config('cosyvoice.prompt_text', ''),
|
||||
'prompt_wav' => (string) config('cosyvoice.prompt_wav', ''),
|
||||
'prompt_wav_name' => '',
|
||||
'sample_rate' => (int) config('cosyvoice.sample_rate', 22050),
|
||||
'fallback_voice' => 'marin',
|
||||
'connect_timeout_ms' => (int) config('cosyvoice.connect_timeout_ms', 800),
|
||||
'timeout_seconds' => (int) config('cosyvoice.timeout_seconds', 8),
|
||||
'failure_ttl' => (int) config('cosyvoice.failure_ttl', 20),
|
||||
];
|
||||
$stored = SettingsService::get('voice_persona', []);
|
||||
|
||||
return array_merge($defaults, is_array($stored) ? $stored : []);
|
||||
}
|
||||
|
||||
public static function publicPersona(): array
|
||||
{
|
||||
$persona = self::getPersona();
|
||||
return [
|
||||
'name' => $persona['name'],
|
||||
'greeting' => $persona['greeting'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function normalizePersona(array $input, ?array $current = null): array
|
||||
{
|
||||
$current ??= self::getPersona();
|
||||
$mode = trim((string) ($input['mode'] ?? $current['mode'] ?? 'sft'));
|
||||
if (!in_array($mode, self::MODES, true)) {
|
||||
$mode = 'sft';
|
||||
}
|
||||
|
||||
$baseUrl = rtrim(trim((string) ($input['base_url'] ?? $current['base_url'] ?? '')), '/');
|
||||
if ($baseUrl !== '' && !preg_match('#^https?://#i', $baseUrl)) {
|
||||
$baseUrl = (string) ($current['base_url'] ?? '');
|
||||
}
|
||||
|
||||
$fallbackVoice = trim((string) ($input['fallback_voice'] ?? $current['fallback_voice'] ?? 'marin'));
|
||||
if (!in_array($fallbackVoice, self::FALLBACK_VOICES, true)) {
|
||||
$fallbackVoice = 'marin';
|
||||
}
|
||||
|
||||
$promptWav = self::safePromptPath((string) ($input['prompt_wav'] ?? $current['prompt_wav'] ?? ''));
|
||||
$sampleRate = (int) ($input['sample_rate'] ?? $current['sample_rate'] ?? 22050);
|
||||
if (!in_array($sampleRate, [16000, 22050, 24000, 44100, 48000], true)) {
|
||||
$sampleRate = 22050;
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => filter_var($input['enabled'] ?? $current['enabled'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||
'name' => self::limitedText($input['name'] ?? $current['name'] ?? '小暖', 40),
|
||||
'greeting' => self::limitedText($input['greeting'] ?? $current['greeting'] ?? '', 200),
|
||||
'role_prompt' => self::limitedText($input['role_prompt'] ?? $current['role_prompt'] ?? '', 2000),
|
||||
'base_url' => $baseUrl,
|
||||
'mode' => $mode,
|
||||
'speaker' => self::limitedText($input['speaker'] ?? $current['speaker'] ?? '中文女', 80),
|
||||
'instruct_text' => self::limitedText($input['instruct_text'] ?? $current['instruct_text'] ?? '', 1000),
|
||||
'prompt_text' => self::limitedText($input['prompt_text'] ?? $current['prompt_text'] ?? '', 1500),
|
||||
'prompt_wav' => $promptWav,
|
||||
'prompt_wav_name' => self::limitedText($input['prompt_wav_name'] ?? $current['prompt_wav_name'] ?? '', 180),
|
||||
'sample_rate' => $sampleRate,
|
||||
'fallback_voice' => $fallbackVoice,
|
||||
'connect_timeout_ms' => max(200, min(5000, (int) ($input['connect_timeout_ms'] ?? 800))),
|
||||
'timeout_seconds' => max(2, min(60, (int) ($input['timeout_seconds'] ?? 8))),
|
||||
'failure_ttl' => max(5, min(300, (int) ($input['failure_ttl'] ?? 20))),
|
||||
];
|
||||
}
|
||||
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
$persona = self::getPersona();
|
||||
return !empty($persona['enabled']) && trim((string) $persona['base_url']) !== '';
|
||||
}
|
||||
|
||||
public static function canAttempt(): bool
|
||||
{
|
||||
if (!self::isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !Cache::get(self::failureCacheKey(self::getPersona()), false);
|
||||
}
|
||||
|
||||
public static function clearFailure(?array $persona = null): void
|
||||
{
|
||||
Cache::delete(self::failureCacheKey($persona ?? self::getPersona()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用官方 FastAPI 服务。上游返回裸 PCM16 流,这里封装为浏览器可播放的 WAV。
|
||||
*
|
||||
* @return array{audio: string, content_type: string, provider: string}
|
||||
*/
|
||||
public static function speech(string $text): array
|
||||
{
|
||||
$persona = self::getPersona();
|
||||
if (empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||
throw new \RuntimeException('CosyVoice 未启用');
|
||||
}
|
||||
|
||||
$mode = trim((string) $persona['mode']);
|
||||
if (!in_array($mode, self::MODES, true)) {
|
||||
throw new \RuntimeException('CosyVoice 模式无效: ' . $mode);
|
||||
}
|
||||
|
||||
$fields = self::requestFields($mode, $text, $persona);
|
||||
$url = rtrim((string) $persona['base_url'], '/') . '/inference_' . $mode;
|
||||
$headers = ['Accept: application/octet-stream'];
|
||||
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||
if ($apiKey !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $fields,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => (int) $persona['connect_timeout_ms'],
|
||||
CURLOPT_TIMEOUT => (int) $persona['timeout_seconds'],
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$pcm = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$contentType = strtolower((string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: ''));
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($pcm === false) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 连接失败: ' . ($curlError ?: '网络不可达'));
|
||||
}
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
self::rememberFailure($persona);
|
||||
$detail = self::errorDetail($pcm);
|
||||
throw new \RuntimeException('CosyVoice 返回 HTTP ' . $httpCode . ($detail ? ': ' . $detail : ''));
|
||||
}
|
||||
if (str_contains($contentType, 'json')) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 返回了错误响应: ' . (self::errorDetail($pcm) ?: '未知错误'));
|
||||
}
|
||||
if (strlen($pcm) < 2) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 返回了空音频');
|
||||
}
|
||||
|
||||
// PCM16 每个采样占两个字节;丢弃异常的尾部半个采样。
|
||||
if (strlen($pcm) % 2 !== 0) {
|
||||
$pcm = substr($pcm, 0, -1);
|
||||
}
|
||||
|
||||
Cache::delete(self::failureCacheKey($persona));
|
||||
return [
|
||||
'audio' => self::pcm16ToWav($pcm, (int) $persona['sample_rate']),
|
||||
'content_type' => 'audio/wav',
|
||||
'provider' => 'cosyvoice',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 CosyVoice 上游产生的 PCM16 数据块原样向下游推送,避免等待整段音频生成完成。
|
||||
*
|
||||
* @return array{bytes: int, sample_rate: int, provider: string, aborted: bool}
|
||||
*/
|
||||
public static function streamSpeech(string $text, callable $onChunk, string $requestId = ''): array
|
||||
{
|
||||
$persona = self::getPersona();
|
||||
if (empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||
throw new \RuntimeException('CosyVoice 未启用');
|
||||
}
|
||||
|
||||
$mode = trim((string) $persona['mode']);
|
||||
if (!in_array($mode, self::MODES, true)) {
|
||||
throw new \RuntimeException('CosyVoice 模式无效: ' . $mode);
|
||||
}
|
||||
|
||||
$fields = self::requestFields($mode, $text, $persona);
|
||||
if ($requestId !== '') {
|
||||
$fields['request_id'] = $requestId;
|
||||
}
|
||||
$url = rtrim((string) $persona['base_url'], '/') . '/inference_' . $mode;
|
||||
$headers = ['Accept: application/octet-stream'];
|
||||
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||
if ($apiKey !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||
}
|
||||
|
||||
$httpCode = 0;
|
||||
$contentType = '';
|
||||
$errorBody = '';
|
||||
$bytes = 0;
|
||||
$aborted = false;
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $fields,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$httpCode, &$contentType): int {
|
||||
if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+(\d+)/i', trim($header), $matches)) {
|
||||
$httpCode = (int) $matches[1];
|
||||
} elseif (stripos($header, 'Content-Type:') === 0) {
|
||||
$contentType = strtolower(trim(substr($header, strlen('Content-Type:'))));
|
||||
}
|
||||
return strlen($header);
|
||||
},
|
||||
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (
|
||||
&$httpCode,
|
||||
&$contentType,
|
||||
&$errorBody,
|
||||
&$bytes,
|
||||
&$aborted,
|
||||
$onChunk
|
||||
): int {
|
||||
if ($httpCode < 200 || $httpCode >= 300 || str_contains($contentType, 'json')) {
|
||||
$errorBody .= $chunk;
|
||||
return strlen($chunk);
|
||||
}
|
||||
if (connection_aborted()) {
|
||||
$aborted = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
$bytes += strlen($chunk);
|
||||
$onChunk($chunk);
|
||||
return strlen($chunk);
|
||||
},
|
||||
CURLOPT_CONNECTTIMEOUT_MS => (int) $persona['connect_timeout_ms'],
|
||||
CURLOPT_TIMEOUT => (int) $persona['timeout_seconds'],
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$curlErrno = curl_errno($ch);
|
||||
if ($httpCode === 0) {
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
}
|
||||
curl_close($ch);
|
||||
|
||||
if ($aborted || ($result === false && $curlErrno === CURLE_WRITE_ERROR && connection_aborted())) {
|
||||
return [
|
||||
'bytes' => $bytes,
|
||||
'sample_rate' => (int) $persona['sample_rate'],
|
||||
'provider' => 'cosyvoice',
|
||||
'aborted' => true,
|
||||
];
|
||||
}
|
||||
if ($result === false) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 流连接失败: ' . ($curlError ?: '网络不可达'));
|
||||
}
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
self::rememberFailure($persona);
|
||||
$detail = self::errorDetail($errorBody);
|
||||
throw new \RuntimeException('CosyVoice 返回 HTTP ' . $httpCode . ($detail ? ': ' . $detail : ''));
|
||||
}
|
||||
if (str_contains($contentType, 'json')) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 返回了错误响应: ' . (self::errorDetail($errorBody) ?: '未知错误'));
|
||||
}
|
||||
if ($bytes < 2) {
|
||||
self::rememberFailure($persona);
|
||||
throw new \RuntimeException('CosyVoice 返回了空音频');
|
||||
}
|
||||
|
||||
Cache::delete(self::failureCacheKey($persona));
|
||||
return [
|
||||
'bytes' => $bytes,
|
||||
'sample_rate' => (int) $persona['sample_rate'],
|
||||
'provider' => 'cosyvoice',
|
||||
'aborted' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public static function cancelSpeech(string $requestId): bool
|
||||
{
|
||||
$persona = self::getPersona();
|
||||
$requestId = trim($requestId);
|
||||
if ($requestId === '' || empty($persona['enabled']) || trim((string) $persona['base_url']) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = rtrim((string) $persona['base_url'], '/') . '/cancel/' . rawurlencode($requestId);
|
||||
$headers = ['Accept: application/json'];
|
||||
$apiKey = trim((string) config('cosyvoice.api_key', ''));
|
||||
if ($apiKey !== '') {
|
||||
$headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => '',
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => min(800, (int) $persona['connect_timeout_ms']),
|
||||
CURLOPT_TIMEOUT_MS => 1500,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $httpCode < 200 || $httpCode >= 300) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_decode($body, true);
|
||||
return is_array($payload) && !empty($payload['cancelled']);
|
||||
}
|
||||
|
||||
private static function requestFields(string $mode, string $text, array $persona): array
|
||||
{
|
||||
$fields = ['tts_text' => $text];
|
||||
$speaker = trim((string) $persona['speaker']);
|
||||
$instruct = trim((string) $persona['instruct_text']);
|
||||
|
||||
if (in_array($mode, ['sft', 'instruct'], true)) {
|
||||
$fields['spk_id'] = $speaker;
|
||||
}
|
||||
if (in_array($mode, ['instruct', 'instruct2'], true)) {
|
||||
$fields['instruct_text'] = $instruct;
|
||||
}
|
||||
if (in_array($mode, ['zero_shot', 'instruct2'], true)) {
|
||||
$fields['prompt_text'] = trim((string) $persona['prompt_text']);
|
||||
}
|
||||
if (in_array($mode, ['zero_shot', 'cross_lingual', 'instruct2'], true)) {
|
||||
$promptWav = trim((string) $persona['prompt_wav']);
|
||||
if ($promptWav === '' || !is_file($promptWav)) {
|
||||
throw new \RuntimeException('CosyVoice 音色样本不存在: ' . $promptWav);
|
||||
}
|
||||
$fields['prompt_wav'] = new \CURLFile($promptWav, 'audio/wav', basename($promptWav));
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
private static function pcm16ToWav(string $pcm, int $sampleRate): string
|
||||
{
|
||||
$channels = 1;
|
||||
$bitsPerSample = 16;
|
||||
$dataSize = strlen($pcm);
|
||||
$blockAlign = (int) ($channels * $bitsPerSample / 8);
|
||||
$byteRate = $sampleRate * $blockAlign;
|
||||
|
||||
return 'RIFF'
|
||||
. pack('V', 36 + $dataSize)
|
||||
. 'WAVEfmt '
|
||||
. pack('VvvVVvv', 16, 1, $channels, $sampleRate, $byteRate, $blockAlign, $bitsPerSample)
|
||||
. 'data'
|
||||
. pack('V', $dataSize)
|
||||
. $pcm;
|
||||
}
|
||||
|
||||
private static function errorDetail(string $body): string
|
||||
{
|
||||
$data = json_decode($body, true);
|
||||
if (is_array($data)) {
|
||||
$detail = $data['detail'] ?? $data['message'] ?? $data['error'] ?? '';
|
||||
if (is_array($detail)) {
|
||||
return json_encode($detail, JSON_UNESCAPED_UNICODE) ?: '';
|
||||
}
|
||||
return trim((string) $detail);
|
||||
}
|
||||
|
||||
return mb_substr(trim(strip_tags($body)), 0, 240);
|
||||
}
|
||||
|
||||
private static function rememberFailure(array $persona): void
|
||||
{
|
||||
Cache::set(
|
||||
self::failureCacheKey($persona),
|
||||
true,
|
||||
(int) $persona['failure_ttl']
|
||||
);
|
||||
}
|
||||
|
||||
private static function failureCacheKey(array $persona): string
|
||||
{
|
||||
return 'cosyvoice_unavailable_' . md5((string) $persona['base_url']);
|
||||
}
|
||||
|
||||
private static function limitedText(mixed $value, int $maxLength): string
|
||||
{
|
||||
return mb_substr(trim((string) $value), 0, $maxLength);
|
||||
}
|
||||
|
||||
private static function safePromptPath(string $path): string
|
||||
{
|
||||
$path = trim($path);
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$realPath = realpath($path);
|
||||
$allowedRoot = realpath(root_path() . 'storage' . DIRECTORY_SEPARATOR . 'cosyvoice');
|
||||
if (!$realPath || !$allowedRoot || !is_file($realPath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$normalizedPath = strtolower(str_replace('\\', '/', $realPath));
|
||||
$normalizedRoot = rtrim(strtolower(str_replace('\\', '/', $allowedRoot)), '/') . '/';
|
||||
return str_starts_with($normalizedPath, $normalizedRoot) ? $realPath : '';
|
||||
}
|
||||
}
|
||||
@@ -21,61 +21,147 @@ class DifyService
|
||||
public static function chat(AiModel $model, string $query, array $files, ?string $conversationId, string $userId): array
|
||||
{
|
||||
$url = rtrim($model->api_base_url, '/') . '/chat-messages';
|
||||
$payload = self::buildPayload($query, $files, $conversationId, $userId, false);
|
||||
$conversationReset = false;
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $model->api_key,
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
$requestConversationId = $attempt === 0 ? $conversationId : null;
|
||||
$payload = self::buildPayload($query, $files, $requestConversationId, $userId, false);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $model->api_key,
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_CONNECTTIMEOUT => 15,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
if ($response === false) {
|
||||
throw new HttpResponseException(json([
|
||||
'code' => 1,
|
||||
'message' => 'Dify 请求失败: ' . ($curlError ?: '网络错误'),
|
||||
'data' => null,
|
||||
], 502));
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new HttpResponseException(json([
|
||||
'code' => 1,
|
||||
'message' => 'Dify 请求失败: ' . ($curlError ?: '网络错误'),
|
||||
'data' => null,
|
||||
], 502));
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
|
||||
if (
|
||||
$attempt === 0 &&
|
||||
$conversationId &&
|
||||
self::isConversationNotFoundError($response . ' ' . $detail)
|
||||
) {
|
||||
$conversationReset = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new HttpResponseException(json([
|
||||
'code' => 1,
|
||||
'message' => self::humanizeError('Dify 请求失败: ' . $detail . self::urlHint($httpCode)),
|
||||
'data' => null,
|
||||
], 502));
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!$data) {
|
||||
throw new HttpResponseException(json([
|
||||
'code' => 1,
|
||||
'message' => 'Dify 响应解析失败',
|
||||
'data' => null,
|
||||
], 502));
|
||||
}
|
||||
|
||||
return [
|
||||
'answer' => $data['answer'] ?? '',
|
||||
'conversation_id' => $data['conversation_id'] ?? null,
|
||||
'conversation_reset' => $conversationReset,
|
||||
'tokens' => $data['metadata']['usage']['total_tokens'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
|
||||
throw new HttpResponseException(json([
|
||||
'code' => 1,
|
||||
'message' => self::humanizeError('Dify 请求失败: ' . $detail . self::urlHint($httpCode)),
|
||||
'data' => null,
|
||||
], 502));
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!$data) {
|
||||
throw new HttpResponseException(json([
|
||||
'code' => 1,
|
||||
'message' => 'Dify 响应解析失败',
|
||||
'data' => null,
|
||||
], 502));
|
||||
}
|
||||
|
||||
return [
|
||||
'answer' => $data['answer'] ?? '',
|
||||
'conversation_id' => $data['conversation_id'] ?? null,
|
||||
'tokens' => $data['metadata']['usage']['total_tokens'] ?? 0,
|
||||
];
|
||||
throw new \RuntimeException('Dify 会话恢复失败');
|
||||
}
|
||||
|
||||
public static function streamChat(
|
||||
AiModel $model,
|
||||
string $query,
|
||||
array $files,
|
||||
?string $conversationId,
|
||||
string $userId,
|
||||
callable $onChunk,
|
||||
callable $onDone,
|
||||
?callable $onError = null,
|
||||
?callable $onConversationReset = null
|
||||
): void {
|
||||
$emittedContent = false;
|
||||
$attemptError = null;
|
||||
$attemptDone = null;
|
||||
|
||||
$chunkProxy = function (string $delta) use (&$emittedContent, $onChunk) {
|
||||
$emittedContent = true;
|
||||
$onChunk($delta);
|
||||
};
|
||||
$doneProxy = function (?string $newConversationId, int $tokens) use (&$attemptDone) {
|
||||
$attemptDone = [$newConversationId, $tokens];
|
||||
};
|
||||
$errorProxy = function (string $message) use (&$attemptError) {
|
||||
$attemptError = $message;
|
||||
};
|
||||
|
||||
self::streamChatAttempt(
|
||||
$model,
|
||||
$query,
|
||||
$files,
|
||||
$conversationId,
|
||||
$userId,
|
||||
$chunkProxy,
|
||||
$doneProxy,
|
||||
$errorProxy
|
||||
);
|
||||
|
||||
if (
|
||||
$attemptDone === null &&
|
||||
!$emittedContent &&
|
||||
$conversationId &&
|
||||
self::isConversationNotFoundError((string) $attemptError)
|
||||
) {
|
||||
if ($onConversationReset) {
|
||||
$onConversationReset();
|
||||
}
|
||||
$attemptError = null;
|
||||
self::streamChatAttempt(
|
||||
$model,
|
||||
$query,
|
||||
$files,
|
||||
null,
|
||||
$userId,
|
||||
$chunkProxy,
|
||||
$doneProxy,
|
||||
$errorProxy
|
||||
);
|
||||
}
|
||||
|
||||
if ($attemptDone !== null) {
|
||||
$onDone($attemptDone[0], $attemptDone[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($onError && $attemptError !== null) {
|
||||
$onError($attemptError);
|
||||
}
|
||||
}
|
||||
|
||||
private static function streamChatAttempt(
|
||||
AiModel $model,
|
||||
string $query,
|
||||
array $files,
|
||||
@@ -373,6 +459,25 @@ class DifyService
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function isConversationNotFoundError(string $message): bool
|
||||
{
|
||||
$message = mb_strtolower($message);
|
||||
foreach ([
|
||||
'conversation not exists',
|
||||
'conversation does not exist',
|
||||
'conversation not found',
|
||||
'conversation_not_exists',
|
||||
'conversation_not_found',
|
||||
'dify 会话已失效',
|
||||
] as $needle) {
|
||||
if (str_contains($message, $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function parseErrorBody(?string $body): ?string
|
||||
{
|
||||
if (!$body) {
|
||||
@@ -390,6 +495,10 @@ class DifyService
|
||||
*/
|
||||
public static function humanizeError(string $message): string
|
||||
{
|
||||
if (self::isConversationNotFoundError($message)) {
|
||||
return 'Dify 会话已失效,系统创建新会话后仍未恢复,请稍后重新发送。';
|
||||
}
|
||||
|
||||
if (str_contains($message, "Unsupported chat content part type: 'file'")
|
||||
|| str_contains($message, 'Unsupported chat content part type')) {
|
||||
return 'Dify 模型层仍不接受 file 类型。请确认 Dify 应用已开启文档上传,且 files.type 使用 document(不是 file)。'
|
||||
|
||||
@@ -55,6 +55,35 @@ class OpenAIService
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音合成必须使用 OpenAI 兼容协议模型。Dify/ComfyUI 的 API 地址不提供
|
||||
* /audio/speech,因此所选对话模型不兼容时自动回落到已启用的 OpenAI 模型。
|
||||
*/
|
||||
public static function getSpeechModel(?int $preferredModelId = null): AiModel
|
||||
{
|
||||
$model = null;
|
||||
if ($preferredModelId) {
|
||||
$model = AiModel::where('id', $preferredModelId)
|
||||
->where('enabled', 1)
|
||||
->where('provider', 'openai')
|
||||
->find();
|
||||
}
|
||||
|
||||
if (!$model) {
|
||||
$model = AiModel::where('enabled', 1)
|
||||
->where('provider', 'openai')
|
||||
->order('is_default', 'desc')
|
||||
->order('sort_order')
|
||||
->find();
|
||||
}
|
||||
|
||||
if (!$model) {
|
||||
self::throwUnavailableModel('未配置支持语音合成的 OpenAI 兼容模型');
|
||||
}
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
public static function getImageModel(?int $preferredModelId = null): AiModel
|
||||
{
|
||||
$model = null;
|
||||
@@ -252,6 +281,106 @@ class OpenAIService
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用神经语音模型生成短句 WAV。短句由前端在文本流式输出期间提前提交,
|
||||
* WAV 则避免浏览器额外的解码启动开销。
|
||||
*
|
||||
* @return array{audio: string, content_type: string}
|
||||
*/
|
||||
public static function speech(AiModel $model, string $input, string $voice = 'marin'): array
|
||||
{
|
||||
$extra = is_array($model->extra_config ?? null) ? $model->extra_config : [];
|
||||
$ttsModel = trim((string) ($extra['tts_model'] ?? 'gpt-4o-mini-tts'));
|
||||
$ttsVoice = trim((string) ($extra['tts_voice'] ?? $voice));
|
||||
$instructions = trim((string) ($extra['tts_instructions'] ?? (
|
||||
'Speak in natural, warm, conversational Mandarin Chinese. '
|
||||
. 'Use relaxed pacing, subtle emotion, human-like phrasing and short natural pauses. '
|
||||
. 'Avoid an announcer, customer-service, robotic, or overly enthusiastic tone.'
|
||||
)));
|
||||
|
||||
$allowedVoices = [
|
||||
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova',
|
||||
'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar',
|
||||
];
|
||||
if (!in_array($ttsVoice, $allowedVoices, true)) {
|
||||
$ttsVoice = 'marin';
|
||||
}
|
||||
if ($ttsModel === '') {
|
||||
$ttsModel = 'gpt-4o-mini-tts';
|
||||
}
|
||||
|
||||
$legacyTts = in_array($ttsModel, ['tts-1', 'tts-1-hd'], true);
|
||||
if ($legacyTts && !in_array($ttsVoice, ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'], true)) {
|
||||
$ttsVoice = 'nova';
|
||||
}
|
||||
|
||||
$url = rtrim((string) $model->api_base_url, '/') . '/audio/speech';
|
||||
$payload = [
|
||||
'model' => $ttsModel,
|
||||
'input' => $input,
|
||||
'voice' => $ttsVoice,
|
||||
'response_format' => 'wav',
|
||||
];
|
||||
if (!$legacyTts && $instructions !== '') {
|
||||
$payload['instructions'] = $instructions;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: audio/wav',
|
||||
'Authorization: Bearer ' . $model->api_key,
|
||||
],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$contentType = (string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'audio/wav');
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
throw new HttpResponseException(json([
|
||||
'code' => 1,
|
||||
'message' => '语音合成连接失败: ' . ($curlError ?: '网络不可达'),
|
||||
'data' => null,
|
||||
], 502));
|
||||
}
|
||||
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$detail = self::parseErrorBody($response) ?: ('HTTP ' . $httpCode);
|
||||
throw new HttpResponseException(json([
|
||||
'code' => 1,
|
||||
'message' => '自然语音生成失败: ' . $detail,
|
||||
'data' => null,
|
||||
], 502));
|
||||
}
|
||||
|
||||
if ($response === '') {
|
||||
throw new HttpResponseException(json([
|
||||
'code' => 1,
|
||||
'message' => '语音服务返回了空音频',
|
||||
'data' => null,
|
||||
], 502));
|
||||
}
|
||||
|
||||
if (!str_starts_with(strtolower($contentType), 'audio/')) {
|
||||
$contentType = 'audio/wav';
|
||||
}
|
||||
|
||||
return [
|
||||
'audio' => $response,
|
||||
'content_type' => $contentType,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试模型连接是否正常
|
||||
* @return array{success: bool, latency_ms: int, reply: string, model: string}
|
||||
|
||||
Reference in New Issue
Block a user