diff --git a/src/plugins/http/chat.html b/src/plugins/http/chat.html index 658cdf7..ea630c2 100644 --- a/src/plugins/http/chat.html +++ b/src/plugins/http/chat.html @@ -77,6 +77,15 @@ h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)} .tag.tts{background:rgba(245,158,11,.2);color:#fcd34d} .tag.downloaded{background:rgba(34,197,94,.2);color:#86efac} .tag.active{background:var(--green);color:#fff} +.mic-btn{width:44px;height:44px;border-radius:50%;border:none;background:var(--surface2);color:var(--text);cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:.2s;flex-shrink:0} +.mic-btn:hover:not(:disabled){background:var(--accent);color:#fff} +.mic-btn.recording{background:var(--red);color:#fff;animation:micPulse 1s infinite} +.mic-btn:disabled{opacity:.4;cursor:not-allowed} +@keyframes micPulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.5)}50%{box-shadow:0 0 0 8px rgba(239,68,68,0)}} +.rec-tip{font-size:11px;color:var(--amber);margin-top:6px;line-height:1.5} +.rec-tip code{background:var(--surface2);padding:1px 5px;border-radius:3px;font-size:10px} +.audio-inline{margin-top:8px} +.audio-inline audio{width:100%;max-width:280px;height:32px} @@ -96,8 +105,9 @@ h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
-
+
+
不同角色类型采用不同渲染和对话策略:pet(视频+拟声) / human(视频+自然) / singer(视频+元气) / live2d(Canvas渲染+动效)
@@ -113,7 +123,10 @@ const $=s=>document.querySelector(s);const sessionId='web_'+Date.now();let chatB const charStrategies={pet:{icon:'🐶',label:'宠物',render:'video',placeholder:'和宠物说说话...'},human:{icon:'🧑',label:'数字人',render:'video',placeholder:'和数字人对话...'},singer:{icon:'🎤',label:'歌姬',render:'video',placeholder:'和歌姬聊天...'},live2d:{icon:'✨',label:'Live2D数字人',render:'live2d',placeholder:'和Live2D数字人对话...'}}; document.querySelectorAll('.tab').forEach(t=>{t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');$('#panel-'+t.dataset.tab).classList.add('active');if(t.dataset.tab==='character')loadCharacters();if(t.dataset.tab==='models')loadModels();};}); function applyCharStrategy(char){currentChar=char;const strat=charStrategies[char.character_type||'pet']||charStrategies.pet;$('#charAvatar').textContent=strat.icon;$('#charName').textContent=char.name||'未知角色';const tb=$('#charType');tb.textContent=strat.label;tb.className='type-badge '+(char.character_type||'pet');$('#charRender').textContent=char.render_type||strat.render;const ra=$('#renderArea');const cb=$('#chatBox');if((char.render_type||strat.render)==='live2d'){cb.style.display='none';ra.innerHTML='
Live2D 渲染区
模型: '+(char.live2d_model||'未配置')+'
';}else{ra.innerHTML='';cb.style.display='block';}$('#chatInput').placeholder=strat.placeholder;} -async function sendChat(){const input=$('#chatInput');const text=input.value.trim();if(!text||chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;$('#chatStatus').textContent='思考中...';$('#chatStatus').className='status-bar busy';input.value='';addMsg('user',text);if(currentChar.render_type!=='live2d'&¤tChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const res=await fetch('/api/chat/text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,session_id:sessionId})});const data=await res.json();if(data.error){botMsg.querySelector('.bubble').textContent='错误: '+data.error;botMsg.querySelector('.bubble').style.color='var(--red)';$('#chatStatus').textContent='错误: '+data.error;$('#chatStatus').className='status-bar error';}else{botMsg.querySelector('.bubble').textContent=data.reply_text||'(空回复)';if(data.reply_audio_path){const au='/download/'+encodeURIComponent(data.reply_audio_path.replace(/^\//,''));const ad=document.createElement('div');ad.className='audio';ad.innerHTML='🔊 ';botMsg.appendChild(ad);}$('#chatStatus').textContent='';$('#chatStatus').className='status-bar';const rl=(data.reply_text||'').length;const tm=Math.max(2000,rl*200);setTimeout(()=>{triggerTalkState(false);showLive2dTalking(false);},tm);}}catch(e){botMsg.querySelector('.bubble').textContent='请求失败: '+e.message;botMsg.querySelector('.bubble').style.color='var(--red)';$('#chatStatus').textContent='请求失败: '+e.message;$('#chatStatus').className='status-bar error';triggerTalkState(false);showLive2dTalking(false);}chatBusy=false;$('#sendBtn').disabled=false;$('#chatInput').focus();} +async function sendChat(){const input=$('#chatInput');const text=input.value.trim();if(!text||chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;$('#chatStatus').textContent='思考中...';$('#chatStatus').className='status-bar busy';input.value='';addMsg('user',text);if(currentChar.render_type!=='live2d'&¤tChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const res=await fetch('/api/chat/text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,session_id:sessionId})});const data=await res.json();handleChatResponse(data,botMsg);}catch(e){showChatError(botMsg,'请求失败: '+e.message);}chatBusy=false;$('#sendBtn').disabled=false;$('#chatInput').focus();} +function audioUrl(path){return '/api/chat/audio/file?path='+encodeURIComponent(path);} +function handleChatResponse(data,botMsg){if(data.error){showChatError(botMsg,'错误: '+data.error);return;}botMsg.querySelector('.bubble').textContent=data.reply_text||(data.transcription?'(无回复)':'(空回复)');if(data.transcription&&data.transcription.trim()){const tr=document.createElement('div');tr.className='meta';tr.style.color='var(--muted)';tr.textContent='🎤 你说: '+data.transcription;botMsg.querySelector('.bubble').insertAdjacentElement('beforebegin',tr);}if(data.reply_audio_path){const ad=document.createElement('div');ad.className='audio-inline';ad.innerHTML='🔊 回复语音';botMsg.appendChild(ad);}$('#chatStatus').textContent='';$('#chatStatus').className='status-bar';const rl=(data.reply_text||'').length;const tm=Math.max(2000,rl*200);setTimeout(()=>{triggerTalkState(false);showLive2dTalking(false);},tm);} +function showChatError(botMsg,msg){botMsg.querySelector('.bubble').textContent=msg;botMsg.querySelector('.bubble').style.color='var(--red)';$('#chatStatus').textContent=msg;$('#chatStatus').className='status-bar error';triggerTalkState(false);showLive2dTalking(false);} function triggerTalkState(talking){const scene=talking?(currentChar.talk_state||'talk'):'idle';fetch('/api/scene',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scene})}).catch(()=>{});} function showLive2dTalking(talking){const c=$('#live2dContainer');if(!c)return;const ex=c.querySelector('.live2d-talking');if(talking&&!ex){const b=document.createElement('div');b.className='live2d-talking';b.textContent='说话中';c.appendChild(b);}else if(!talking&&ex)ex.remove();} function addMsg(role,text){const box=$('#chatBox');const div=document.createElement('div');div.className='msg '+role;const t=new Date().toLocaleTimeString('zh-CN',{hour12:false});div.innerHTML='
'+t+'
';div.querySelector('.bubble').textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div;} @@ -125,6 +138,12 @@ async function loadModels(){try{const res=await fetch('/api/models');const data= async function downloadModel(id){try{const res=await fetch('/api/models/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'下载已启动');setTimeout(loadModels,3000);}catch(e){alert('失败');}} async function switchModel(id,kind){try{const res=await fetch('/api/models/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id,kind})});const data=await res.json();alert(data.message||'已切换');loadModels();}catch(e){alert('失败');}} async function deleteModel(id){if(!confirm('删除 '+id+'?'))return;try{const res=await fetch('/api/models/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'已删除');loadModels();}catch(e){alert('失败');}} +initMic(); +async function initMic(){const micBtn=$('#micBtn');const tip=$('#recTip');if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){tip.innerHTML='当前浏览器不支持录音。请使用文字对话。';return;}if(!window.isSecureContext){micBtn.disabled=true;tip.innerHTML='⚠️ 录音需要安全上下文(HTTPS 或 localhost)。当前为 HTTP 访问,录音已禁用。
启用方法(桌面 Chrome/Edge):右键浏览器图标 → 属性 → 在目标末尾加 --unsafely-treat-insecure-origin-as-secure=http://192.168.31.105:5000 → 重启浏览器并访问本页。
或直接用文字对话,回复语音仍可正常播放。';return;}let mediaRecorder=null;let chunks=[];let recStream=null;let recTimer=null;const startRec=async()=>{if(chatBusy)return;try{recStream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,sampleRate:16000,echoCancellation:true,noiseSuppression:true}});chunks=[];mediaRecorder=new MediaRecorder(recStream);mediaRecorder.ondataavailable=e=>{if(e.data.size>0)chunks.push(e.data);};mediaRecorder.onstop=()=>{if(recTimer){clearTimeout(recTimer);recTimer=null;}recStream.getTracks().forEach(t=>t.stop());recStream=null;const blob=new Blob(chunks,{type:mediaRecorder.mimeType||'audio/webm'});if(blob.size<1000){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('#chatStatus').textContent='录音过短,已取消';$('#chatStatus').className='status-bar error';return;}sendAudioBlob(blob,mediaRecorder.mimeType||'audio/webm');};mediaRecorder.start();micBtn.classList.add('recording');micBtn.textContent='⏹';$('#chatStatus').textContent='录音中...(松开发送)';$('#chatStatus').className='status-bar busy';recTimer=setTimeout(()=>{if(mediaRecorder&&mediaRecorder.state==='recording'){stopRec();$('#chatStatus').textContent='录音达到 30s 上限,自动发送';}},30000);}catch(e){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('#chatStatus').textContent='麦克风启动失败: '+e.message;$('#chatStatus').className='status-bar error';}};const stopRec=()=>{if(mediaRecorder&&mediaRecorder.state==='recording'){mediaRecorder.stop();micBtn.classList.remove('recording');micBtn.textContent='🎙';}};micBtn.disabled=false;micBtn.addEventListener('mousedown',startRec);micBtn.addEventListener('touchstart',e=>{e.preventDefault();startRec();});micBtn.addEventListener('mouseup',stopRec);micBtn.addEventListener('mouseleave',stopRec);micBtn.addEventListener('touchend',e=>{e.preventDefault();stopRec();});} +async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;const micBtn=$('#micBtn');micBtn.disabled=true;$('#chatStatus').textContent='转码中...';$('#chatStatus').className='status-bar busy';const userMsg=addMsg('user','🎤 [语音消息]');const playUrl=URL.createObjectURL(blob);const up=document.createElement('div');up.className='audio-inline';up.innerHTML='你的录音';userMsg.appendChild(up);if(currentChar.render_type!=='live2d'&¤tChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const wavBlob=await blobToWav(blob);$('#chatStatus').textContent='识别中...';const res=await fetch('/api/chat/audio',{method:'POST',headers:{'Content-Type':'audio/wav','X-Audio-Format':'wav'},body:wavBlob});const data=await res.json();handleChatResponse(data,botMsg);}catch(e){showChatError(botMsg,'请求失败: '+e.message);}chatBusy=false;$('#sendBtn').disabled=false;micBtn.disabled=false;} +async function blobToWav(blob){const arrayBuf=await blob.arrayBuffer();const audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:16000});const decoded=await audioCtx.decodeAudioData(arrayBuf);const pcm=decodeToMonoPCM16(decoded,16000);audioCtx.close();return encodeWavBlob(pcm,16000);} +function decodeToMonoPCM16(audioBuffer,targetRate){const numCh=audioBuffer.numberOfChannels;const len=audioBuffer.length;const srcRate=audioBuffer.sampleRate;const outLen=Math.round(len*targetRate/srcRate);const out=new Int16Array(outLen);const tmp=new Float32Array(outLen);for(let ch=0;ch{for(let i=0;i diff --git a/src/plugins/http/routes.rs b/src/plugins/http/routes.rs index f8cacb7..1a36340 100644 --- a/src/plugins/http/routes.rs +++ b/src/plugins/http/routes.rs @@ -7,6 +7,7 @@ use futures_util::{SinkExt, StreamExt, TryStreamExt}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashMap; use std::convert::Infallible; use std::path::{Path, PathBuf}; use std::sync::{mpsc, Arc}; @@ -147,6 +148,7 @@ pub(crate) fn build_routes( let ai_api = chat_text_route(Arc::clone(&state)) .or(chat_audio_route(Arc::clone(&state))) + .or(chat_audio_file_route(Arc::clone(&state))) .or(models_list_route(Arc::clone(&state))) .or(model_download_route(Arc::clone(&state))) .or(model_switch_route(Arc::clone(&state))) @@ -2031,7 +2033,11 @@ fn chat_text_route( }) } -/// POST /api/chat/audio — 语音对话(App 主路径,上传 wav) +/// POST /api/chat/audio — 语音对话(App/Web 主路径,上传 wav/webm/ogg 等浏览器录音) +/// +/// 注意:上传的音频会被传给 whisper-cli,它通过文件扩展名判断格式。 +/// 浏览器 MediaRecorder 默认产 webm/opus,移动端可能产 m4a/aac。 +/// 服务端不强校验格式,但建议客户端尽量转 wav/16k 单声道。 fn chat_audio_route( state: Arc, ) -> impl Filter + Clone { @@ -2039,65 +2045,165 @@ fn chat_audio_route( .and(warp::post()) .and(warp::body::content_length_limit(20 * 1024 * 1024)) .and(warp::body::bytes()) + .and(warp::header::optional::("X-Audio-Format")) .and(with_state(state)) - .and_then(|bytes: bytes::Bytes, state: Arc| async move { - let pipeline = match state.ai_pipeline() { - Some(p) => p, - None => { - return Ok::<_, Infallible>(error_json( - StatusCode::SERVICE_UNAVAILABLE, - "AI 插件未就绪", - )) - } - }; + .and_then( + |bytes: bytes::Bytes, + fmt_hint: Option, + state: Arc| async move { + let pipeline = match state.ai_pipeline() { + Some(p) => p, + None => { + return Ok::<_, Infallible>(error_json( + StatusCode::SERVICE_UNAVAILABLE, + "AI 插件未就绪", + )) + } + }; - let config = state.config(); - let tmp_dir = config.ai.tmp_dir.clone(); - let _ = std::fs::create_dir_all(&tmp_dir); - let audio_path = tmp_dir.join(format!( - "asr_{}.wav", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) - )); - if let Err(e) = std::fs::write(&audio_path, &bytes) { - return Ok(error_json( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("保存音频失败: {e}"), + let config = state.config(); + let tmp_dir = config.ai.tmp_dir.clone(); + let _ = std::fs::create_dir_all(&tmp_dir); + + // 根据客户端上报的格式或默认 wav 决定扩展名(whisper-cli 靠扩展名判断) + let ext = match fmt_hint.as_deref() { + Some("webm") => "webm", + Some("ogg") => "ogg", + Some("m4a") => "m4a", + Some("mp3") => "mp3", + Some("wav") | _ => "wav", + }; + let audio_path = tmp_dir.join(format!( + "asr_{}.{ext}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) )); - } + if let Err(e) = std::fs::write(&audio_path, &bytes) { + return Ok(error_json( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("保存音频失败: {e}"), + )); + } - let session_id = format!("app_{}", std::process::id()); - let chat_req = ChatRequest { - session_id, - input: ChatInput::Audio { - path: audio_path.to_string_lossy().into_owned(), - }, - persona_prompt: config.character.persona_prompt.clone(), - max_tokens: config.character.max_tokens, - }; + let session_id = format!("app_{}", std::process::id()); + let chat_req = ChatRequest { + session_id, + input: ChatInput::Audio { + path: audio_path.to_string_lossy().into_owned(), + }, + persona_prompt: config.character.persona_prompt.clone(), + max_tokens: config.character.max_tokens, + }; - let audio_path_clone = audio_path.clone(); - let resp = tokio::task::spawn_blocking(move || pipeline.run(&chat_req)) - .await - .unwrap_or_else(|e| ChatResponse { - session_id: String::new(), - transcription: None, - reply_text: String::new(), - reply_audio_path: None, - error: Some(format!("AI 管线执行失败: {e}")), - }); + let audio_path_clone = audio_path.clone(); + let resp = tokio::task::spawn_blocking(move || pipeline.run(&chat_req)) + .await + .unwrap_or_else(|e| ChatResponse { + session_id: String::new(), + transcription: None, + reply_text: String::new(), + reply_audio_path: None, + error: Some(format!("AI 管线执行失败: {e}")), + }); - // 清理上传的临时音频 - let _ = std::fs::remove_file(&audio_path_clone); + // 清理上传的临时音频 + let _ = std::fs::remove_file(&audio_path_clone); - if resp.error.is_some() { - Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, &resp)) - } else { - Ok(json_response(StatusCode::OK, &resp)) - } - }) + if resp.error.is_some() { + Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, &resp)) + } else { + Ok(json_response(StatusCode::OK, &resp)) + } + }, + ) +} + +/// GET /api/chat/audio/file?path=<绝对路径> — 读取 TTS 生成的临时回复音频 +/// +/// 安全:path 必须在 config.ai.tmp_dir 内,且文件存在。返回 wav 二进制流。 +fn chat_audio_file_route( + state: Arc, +) -> impl Filter + Clone { + warp::path!("api" / "chat" / "audio" / "file") + .and(warp::get()) + .and(warp::query::>()) + .and(with_state(state)) + .and_then( + |params: HashMap, state: Arc| async move { + let raw_path = match params.get("path") { + Some(p) => p.clone(), + None => { + return Ok::<_, Infallible>( + error_json(StatusCode::BAD_REQUEST, "缺少 path 参数"), + ) + } + }; + + let config = state.config(); + let tmp_dir = config.ai.tmp_dir.clone(); + + // 路径白名单:必须规范化后位于 tmp_dir 内 + let candidate = std::path::Path::new(&raw_path); + let canonical_target = match candidate.canonicalize() { + Ok(p) => p, + Err(_) => { + return Ok(error_json( + StatusCode::NOT_FOUND, + "音频文件不存在或无法访问", + )) + } + }; + let canonical_tmp = match tmp_dir.canonicalize() { + Ok(p) => p, + Err(_) => { + return Ok(error_json( + StatusCode::INTERNAL_SERVER_ERROR, + "tmp_dir 未配置或不存在", + )) + } + }; + if !canonical_target.starts_with(&canonical_tmp) { + return Ok(error_json( + StatusCode::FORBIDDEN, + "路径越界:只允许访问 tmp_dir 内的音频文件", + )); + } + if !canonical_target.is_file() { + return Ok(error_json(StatusCode::NOT_FOUND, "音频文件不存在")); + } + + // 返回内联音频(浏览器可直接播放) + match std::fs::read(&canonical_target) { + Ok(data) => { + let cleanup_target = canonical_target.clone(); + let _ = std::thread::spawn(move || { + // 60s 后清理(足够浏览器播放完) + std::thread::sleep(std::time::Duration::from_secs(60)); + let _ = std::fs::remove_file(cleanup_target); + }); + match warp::http::Response::builder() + .header("Content-Type", "audio/wav") + .header("Content-Length", data.len().to_string()) + .header("Cache-Control", "no-store") + .body(data) + { + Ok(response) => Ok(response.into_response()), + Err(_) => Ok(warp::reply::with_status( + warp::reply::html("响应构建失败".to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response()), + } + } + Err(e) => Ok(error_json( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("读取音频失败: {e}"), + )), + } + }, + ) } // ── AI 模型管理 API (M2.1) ──