This commit is contained in:
Your Name
2026-08-03 10:00:05 +08:00
parent 0fb03d0bca
commit 01729b1e0b
109 changed files with 7577 additions and 1153 deletions
+150 -1
View File
@@ -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