更新
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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user