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
+80
View File
@@ -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;
+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
@@ -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)) {
+2
View File
@@ -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(),
]);
}
+433
View File
@@ -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 : '';
}
}
+156 -47
View File
@@ -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)。'
+129
View 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}