feat(M2.1): Web 端语音录放闭环
1. 新增 GET /api/chat/audio/file?path=<tmp_dir内路径> 路由
- 路径白名单:canonicalize 后必须位于 config.ai.tmp_dir 内
- 返回 audio/wav 二进制流(浏览器可直接 <audio> 播放)
- 60s 后自动清理临时文件
- 修复:原 /download/:filename 只能读 downloads_dir,TTS 输出在 tmp_dir 无法访问
2. POST /api/chat/audio 支持 X-Audio-Format 头
- 浏览器 MediaRecorder 产 webm/ogg/m4a,whisper-cli 靠扩展名判断格式
- 服务端按头指示决定文件扩展名(webm/ogg/m4a/mp3/wav)
3. chat.html 录音 UI + 客户端 WAV 转码
- 按住🎙按钮录音(MediaRecorder),松开发送
- 客户端用 Web Audio API 解码 + 重采样到 16k 单声道 PCM16
- 手写 WAV header,输出标准 16k mono wav(whisper-cli 直接可处理)
- 非安全上下文(HTTP)下禁用录音并显示 Chrome flag 启用指引
- 回复音频改用 /api/chat/audio/file 路径播放
- 显示 ASR 转写文本(🎤 你说: xxx)
This commit is contained in:
@@ -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}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -96,8 +105,9 @@ h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
|
||||
<div class="panel active" id="panel-chat">
|
||||
<div id="renderArea"></div>
|
||||
<div class="chat-box" id="chatBox" style="display:none"></div>
|
||||
<div class="chat-input"><input type="text" id="chatInput" placeholder="输入文字与角色对话..." autocomplete="off"><button id="sendBtn" onclick="sendChat()">发送</button></div>
|
||||
<div class="chat-input"><input type="text" id="chatInput" placeholder="输入文字与角色对话..." autocomplete="off"><button id="sendBtn" onclick="sendChat()">发送</button><button class="mic-btn" id="micBtn" title="按住说话(松开发送)" disabled>🎙</button></div>
|
||||
<div class="status-bar" id="chatStatus"></div>
|
||||
<div class="rec-tip" id="recTip"></div>
|
||||
</div>
|
||||
<div class="panel" id="panel-character">
|
||||
<div style="margin-bottom:12px;font-size:13px;color:var(--muted)">不同角色类型采用不同渲染和对话策略:pet(视频+拟声) / human(视频+自然) / singer(视频+元气) / live2d(Canvas渲染+动效)</div>
|
||||
@@ -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='<div class="live2d-container" id="live2dContainer"><div class="live2d-placeholder"><div class="icon">✨</div>Live2D 渲染区<br><small>模型: '+(char.live2d_model||'未配置')+'</small></div></div>';}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='<span>🔊</span> <audio controls src="'+au+'"></audio>';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='<span style="font-size:12px;color:var(--muted)">🔊 回复语音</span><audio controls src="'+audioUrl(data.reply_audio_path)+'"></audio>';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='<div class="bubble"></div><div class="meta">'+t+'</div>';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 访问,录音已禁用。<br>启用方法(桌面 Chrome/Edge):右键浏览器图标 → 属性 → 在目标末尾加 <code>--unsafely-treat-insecure-origin-as-secure=http://192.168.31.105:5000</code> → 重启浏览器并访问本页。<br>或直接用文字对话,回复语音仍可正常播放。';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='<span style="font-size:11px;color:var(--muted)">你的录音</span><audio controls src="'+playUrl+'"></audio>';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<numCh;ch++){const data=audioBuffer.getChannelData(ch);for(let i=0;i<outLen;i++){const srcIdx=i*srcRate/targetRate;const i0=Math.floor(srcIdx);const i1=Math.min(i0+1,len-1);const frac=srcIdx-i0;tmp[i]+=data[i0]*(1-frac)+data[i1]*frac;}}for(let i=0;i<outLen;i++){let s=tmp[i]/numCh;s=Math.max(-1,Math.min(1,s));out[i]=s<0?s*0x8000:s*0x7FFF;}return out;}
|
||||
function encodeWavBlob(samples,sampleRate){const buf=new ArrayBuffer(44+samples.length*2);const view=new DataView(buf);const writeStr=(off,s)=>{for(let i=0;i<s.length;i++)view.setUint8(off+i,s.charCodeAt(i));};writeStr(0,'RIFF');view.setUint32(4,36+samples.length*2,true);writeStr(8,'WAVE');writeStr(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,1,true);view.setUint16(22,1,true);view.setUint32(24,sampleRate,true);view.setUint32(28,sampleRate*2,true);view.setUint16(32,2,true);view.setUint16(34,16,true);writeStr(36,'data');view.setUint32(40,samples.length*2,true);let off=44;for(let i=0;i<samples.length;i++){view.setInt16(off,samples[i],true);off+=2;}return new Blob([buf],{type:'audio/wav'});}
|
||||
loadCurrentChar();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -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<HttpState>,
|
||||
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + 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::<String>("X-Audio-Format"))
|
||||
.and(with_state(state))
|
||||
.and_then(|bytes: bytes::Bytes, state: Arc<HttpState>| 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<String>,
|
||||
state: Arc<HttpState>| 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<HttpState>,
|
||||
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||
warp::path!("api" / "chat" / "audio" / "file")
|
||||
.and(warp::get())
|
||||
.and(warp::query::<HashMap<String, String>>())
|
||||
.and(with_state(state))
|
||||
.and_then(
|
||||
|params: HashMap<String, String>, state: Arc<HttpState>| 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) ──
|
||||
|
||||
Reference in New Issue
Block a user