diff --git a/src/plugins/http/chat.html b/src/plugins/http/chat.html index ea630c2..844f6fd 100644 --- a/src/plugins/http/chat.html +++ b/src/plugins/http/chat.html @@ -6,145 +6,42 @@ Showen 数字生命控制台
-

Showen 数字生命控制台

-
统一入口 · 角色对话 · 模型管理
-
-
🐶
-
加载中...pet
-
video
-
+

Showen 数字生命控制台

WS 连接中
+
统一入口 · 角色对话 · 模型管理 · 设备控制
+
🐶
加载中...pet
video
-
对话
-
角色
-
模型
+
-
-
- -
-
-
+
+
不同角色类型采用不同渲染和对话策略:pet(视频+拟声) / human(视频+自然) / singer(视频+元气) / live2d(Canvas渲染+动效)
加载中...
+
加载中...
+

播放状态

状态--
当前视频--
索引--
列表长度--

播放操作

触发器

+

上传视频

设备视频文件

加载中...
+

文件管理器

选择目录后加载...
+

网络状态

WiFi--
SSID--
IP--
BLE--

WiFi 扫描

点击扫描搜索附近网络

连接 WiFi

热点 / BLE

+

配置文件切换

显示设置

配置编辑器

-
-
不同角色类型采用不同渲染和对话策略:pet(视频+拟声) / human(视频+自然) / singer(视频+元气) / live2d(Canvas渲染+动效)
-
加载中...
-
-
-
-
加载中...
-
-
- +
+ + diff --git a/src/plugins/http/chat.js b/src/plugins/http/chat.js new file mode 100644 index 0000000..a9dc747 --- /dev/null +++ b/src/plugins/http/chat.js @@ -0,0 +1,74 @@ +/* Showen 统一控制台 JS — chat.js */ +var $=function(id){return typeof id==='string'?document.getElementById(id):id}; +var ws=null,wsReady=false,cachedConfig=null,fmCurrentDir='videos',fmCurrentPath='',chatBusy=false,currentChar={name:'未知',character_type:'pet',render_type:'video',talk_state:'talk'},sessionId='web_'+Date.now(); +var 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数字人对话...'}}; +function toast(m,e){var el=$('toast');el.textContent=m;el.className='toast '+(e?'err':'ok');el.style.display='block';clearTimeout(el._t);el._t=setTimeout(function(){el.style.display='none'},3000)} +function esc(v){return String(v).replace(/[&<>"]/g,function(c){return({'&':'&','<':'<','>':'>','"':'"'})[c]})} +function ea(v){return esc(v).replace(/'/g,''')} +function js(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")} +function formatBytes(s){if(s===undefined||s===null||isNaN(s))return'--';if(s<1024)return s+' B';if(s<1048576)return(s/1024).toFixed(1)+' KB';if(s<1073741824)return(s/1048576).toFixed(1)+' MB';return(s/1073741824).toFixed(1)+' GB'} +document.querySelectorAll('.tab').forEach(function(t){t.onclick=function(){document.querySelectorAll('.tab').forEach(function(e){e.classList.remove('active')});document.querySelectorAll('.panel').forEach(function(e){e.classList.remove('active')});t.classList.add('active');$('panel-'+t.dataset.tab).classList.add('active');var tab=t.dataset.tab;if(tab==='character')loadCharacters();if(tab==='models')loadModels();if(tab==='videos')loadVideoList();if(tab==='files')fmRefresh();if(tab==='wifi'){loadWifiStatus();loadBleStatus()}if(tab==='settings'&&!cachedConfig){loadConfig();loadAvailableConfigs()}}}); +function connectWS(){var proto=location.protocol==='https:'?'wss:':'ws:';ws=new WebSocket(proto+'//'+location.host+'/ws');ws.onopen=function(){wsReady=true;$('ws-badge').textContent='WS 已连接';$('ws-badge').style.borderColor='rgba(34,197,94,.4)';$('ws-badge').style.color='#22c55e';$('ws-badge').style.background='rgba(34,197,94,.1)'};ws.onclose=function(){wsReady=false;$('ws-badge').textContent='WS 断开';$('ws-badge').style.borderColor='rgba(239,68,68,.3)';$('ws-badge').style.color='#ef4444';$('ws-badge').style.background='rgba(239,68,68,.1)';setTimeout(connectWS,2000)};ws.onerror=function(){wsReady=false};ws.onmessage=function(ev){try{var msg=JSON.parse(ev.data);if(msg.type==='status_update')applyStatus(msg.data);else if(msg.type==='state_update')toast('场景: '+msg.data.old_state+' -> '+msg.data.new_state);else if(msg.type==='wifi_update')applyWifi(msg.data);else if(msg.type==='ble_update')applyBle(msg.data);else if(msg.type==='config_update'){cachedConfig=msg.data;applyCharStrategy(msg.data.character||{})}}catch(e){}}} +function wsCmd(obj){if(wsReady)ws.send(JSON.stringify(obj));else toast('WebSocket 未连接',true)} +function api(method,path,body){var opts={method:method,headers:{}};if(body!==undefined){if(typeof body==='string'){opts.headers['Content-Type']='application/json';opts.body=body}else if(body instanceof FormData){opts.body=body}else{opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}}return fetch(path,opts).then(function(r){return r.json().then(function(d){if(!r.ok)throw d;return d})}).then(function(d){if(d.message)toast(d.message,d.status==='error');return d}).catch(function(e){toast((e&&e.message)||'请求失败',true);throw e})} +function applyStatus(d){var el=$('st-state');if(!d.running){el.textContent='已停止';el.className='val warn'}else if(d.paused){el.textContent='已暂停';el.className='val warn'}else{el.textContent='播放中';el.className='val ok'}$('st-video').textContent=d.current_video||'无';$('st-index').textContent=d.current_index;$('st-len').textContent=d.playlist_length} +function refreshStatus(){fetch('/api/status').then(function(r){return r.json()}).then(applyStatus).catch(function(){})} +function applyWifi(d){if(d.connected!==undefined){$('wifi-connected').textContent=d.connected?'已连接':'未连接';$('wifi-connected').className=d.connected?'val ok':'val err';$('wifi-ssid').textContent=d.ssid||'--';$('wifi-ip').textContent=d.ip||'--'}} +function applyBle(d){if(d.ready!==undefined){var el=$('ble-status');el.textContent=d.ready?'运行中':'未就绪';el.className=d.ready?'val ok':'val warn'}} +function gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}if(wsReady)wsCmd({cmd:'goto',index:parseInt(idx,10)});else api('POST','/api/goto/'+idx)} +function triggerPreset(n,v){if(wsReady)wsCmd({cmd:'trigger',name:n,value:v||''});else api('POST','/api/trigger/'+encodeURIComponent(n)+'/'+encodeURIComponent(v||''))} +function triggerCustom(){var n=$('tr-name').value,v=$('tr-value').value;if(!n){toast('请输入触发器名',true);return}triggerPreset(n,v)} +function loadVideoList(){fetch('/api/videos').then(function(r){return r.json()}).then(function(files){var el=$('video-list');if(!files.length){el.innerHTML='
目录中没有视频文件
';return}el.innerHTML=files.map(function(f){var sz=f.size<1048576?(f.size/1024).toFixed(1)+' KB':(f.size/1048576).toFixed(1)+' MB';return '
'+esc(f.name)+''+sz+'
'}).join('')}).catch(function(){toast('加载视频列表失败',true)})} +function uploadVideos(){var input=$('upload-file');if(!input.files.length){toast('请先选择文件',true);return}var fd=new FormData();for(var i=0;i'+({'videos':'视频目录','configs':'配置目录','plugins':'插件目录'}[fmCurrentDir]||fmCurrentDir)+'';var acc='';for(var i=0;i'+esc(parts[i])+''}}bc.innerHTML=html;fetch(url).then(function(r){return r.json()}).then(function(entries){var el=$('fm-list');if(entries.status==='error'){el.innerHTML='
'+esc(entries.message)+'
';return}if(!entries.length){el.innerHTML='
空目录
';$('fm-del-btn').style.display='none';return}var html='';for(var i=0;i';if(e.is_dir){html+='📁'+esc(e.name)+''}else{html+=''+esc(e.name)+''}html+=''+sz+'';if(!e.is_dir){html+=''}html+=''}el.innerHTML=html;el.querySelectorAll('.fm-check').forEach(function(cb){cb.onchange=fmUpdateDelBtn});fmUpdateDelBtn()}).catch(function(){toast('加载文件列表失败',true)})} +function fmUpdateDelBtn(){var cbs=document.querySelectorAll('.fm-check:checked');$('fm-del-btn').style.display=cbs.length?'':'none';$('fm-del-btn').textContent='删除选中 ('+cbs.length+')'} +function fmDeleteSelected(){var cbs=document.querySelectorAll('.fm-check:checked');if(!cbs.length)return;if(!confirm('确定删除 '+cbs.length+' 个项目?'))return;var promises=[];cbs.forEach(function(cb){var p=cb.dataset.path;promises.push(fetch('/api/files/'+fmCurrentDir+'/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p})}).then(function(r){return r.json()}))});Promise.all(promises).then(function(results){var ok=0,fail=0;results.forEach(function(r){if(r.status==='ok')ok++;else fail++});toast('删除完成: '+ok+' 成功'+(fail?' / '+fail+' 失败':''),fail>0);fmRefresh()})} +function fmDownload(path){window.open('/api/files/'+fmCurrentDir+'/download?path='+encodeURIComponent(path),'_blank')} +function fmUpload(input){if(!input.files.length)return;var fd=new FormData();for(var i=0;i'+esc(n.ssid||'隐藏网络')+''+esc(String(n.signal||0))+' / '+esc(n.security||'OPEN')+''}).join('')}).catch(function(){toast('扫描失败',true)})} +function selectWifi(ssid){$('wifi-ssid-input').value=ssid} +function connectWifi(){var s=$('wifi-ssid-input').value,p=$('wifi-pass-input').value;if(!s){toast('请输入 WiFi 名称',true);return}if(wsReady)wsCmd({cmd:'connect',ssid:s,password:p});else api('POST','/api/wifi/connect',{ssid:s,password:p}).then(function(){setTimeout(loadWifiStatus,1500)})} +function startAP(){var s=$('ap-ssid').value||'showen',p=$('ap-pass').value||'12345678';if(p.length<8){toast('热点密码至少 8 位',true);return}if(wsReady)wsCmd({cmd:'ap_start',ssid:s,password:p});else api('POST','/api/wifi/ap/start',{ssid:s,password:p})} +function stopAP(){if(wsReady)wsCmd({cmd:'ap_stop'});else api('POST','/api/wifi/ap/stop')} +function loadBleStatus(){fetch('/api/ble/status').then(function(r){return r.json()}).then(function(d){var el=$('ble-status');if(d.running){el.textContent='运行中 / '+(d.device_name||'showen');el.className='val ok'}else{el.textContent='未就绪';el.className='val warn'}}).catch(function(){})} +function startBLE(){api('POST','/api/ble/start',{device_name:$('ble-name').value||'showen'}).then(loadBleStatus)} +function stopBLE(){api('POST','/api/ble/stop').then(loadBleStatus)} +function loadAvailableConfigs(){fetch('/api/config/available').then(function(r){return r.json()}).then(function(d){var sel=$('cfg-select');sel.innerHTML='';d.configs.forEach(function(f){var opt=document.createElement('option');opt.value=f;opt.textContent=f;if(f===d.active)opt.selected=true;sel.appendChild(opt)})}).catch(function(){})} +function switchConfig(){var f=$('cfg-select').value;if(!f){toast('请选择配置',true);return}if(!confirm('确定切换到 '+f+' ?'))return;api('POST','/api/config/switch',{filename:f}).then(function(){loadConfig();loadAvailableConfigs()})} +function loadConfig(){fetch('/api/config').then(function(r){return r.json()}).then(function(cfg){cachedConfig=cfg;$('cfg-editor').value=JSON.stringify(cfg,null,2);renderDisplay(cfg.display);applyCharStrategy(cfg.character||{})}).catch(function(){toast('加载配置失败',true)})} +function formatConfig(){try{$('cfg-editor').value=JSON.stringify(JSON.parse($('cfg-editor').value),null,2)}catch(_){toast('JSON 格式错误',true)}} +function saveConfig(){var raw=$('cfg-editor').value;try{JSON.parse(raw)}catch(_){toast('JSON 格式错误',true);return}api('POST','/api/config',raw).then(loadConfig)} +function renderDisplay(d){if(!d)return;var h='';h+='';h+='';h+='';h+='';h+='';h+='';h+='';h+='';$('display-form').innerHTML=h} +function saveDisplay(){if(!cachedConfig){loadConfig();return}var n=JSON.parse(JSON.stringify(cachedConfig));n.display.fullscreen=$('d-fullscreen').checked;n.display.window_title=$('d-title').value;n.display.rotation=parseInt($('d-rotation').value||'0',10);n.display.render_width=parseInt($('d-render-width').value||'1024',10);n.display.render_height=parseInt($('d-render-height').value||'1024',10);n.display.chroma_key=n.display.chroma_key||{};n.display.chroma_key.hsv_min=$('d-ck-min').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.chroma_key.hsv_max=$('d-ck-max').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.perspective_correction=n.display.perspective_correction||{};try{n.display.perspective_correction.points=JSON.parse($('d-points').value)}catch(_){toast('透视点 JSON 无效',true);return}cachedConfig=n;$('cfg-editor').value=JSON.stringify(n,null,2);saveConfig()} +function updateDownloadModal(info){var meta=$('download-meta'),link=$('download-apk-link'),qr=$('download-qr-image');if(!info||!info.apk_available){meta.textContent='APK 尚未发布,请稍后再试';meta.className='download-meta warn';link.style.display='none';qr.style.display='none';qr.removeAttribute('src');return}var url=location.origin+(info.download_url||'/download/showen-app.apk');meta.textContent='版本 '+(info.version||'0.1.0')+' · '+formatBytes(info.apk_size);meta.className='download-meta';link.style.display='inline-flex';link.href=info.download_url||'/download/showen-app.apk';qr.style.display='block';qr.src='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(url)} +function openDownloadModal(){var modal=$('download-modal');$('download-meta').textContent='正在获取 App 信息...';$('download-meta').className='download-meta';$('download-apk-link').style.display='none';$('download-qr-image').style.display='none';$('download-qr-image').removeAttribute('src');modal.classList.add('open');fetch('/api/app/info').then(function(r){if(!r.ok)throw new Error('加载 App 信息失败');return r.json()}).then(updateDownloadModal).catch(function(){$('download-meta').textContent='APK 信息加载失败,请稍后再试';$('download-meta').className='download-meta warn';$('download-apk-link').style.display='none';$('download-qr-image').style.display='none'})} +function closeDownloadModal(ev){if(ev&&ev.target!==$('download-modal'))return;$('download-modal').classList.remove('open')} +document.addEventListener('keydown',function(ev){if(ev.key==='Escape')closeDownloadModal()}); +function applyCharStrategy(char){currentChar=char;var strat=charStrategies[char.character_type||'pet']||charStrategies.pet;$('charAvatar').textContent=strat.icon;$('charName').textContent=char.name||'未知角色';var tb=$('charType');tb.textContent=strat.label;tb.className='type-badge '+(char.character_type||'pet');$('charRender').textContent=char.render_type||strat.render;var ra=$('renderArea');var 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(){var input=$('chatInput');var 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);var botMsg=addMsg('bot','思考中...');try{var res=await fetch('/api/chat/text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:text,session_id:sessionId})});var 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()){var 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){var ad=document.createElement('div');ad.className='audio-inline';ad.innerHTML='🔊 回复语音';botMsg.appendChild(ad)}$('chatStatus').textContent='';$('chatStatus').className='status-bar';var rl=(data.reply_text||'').length;var tm=Math.max(2000,rl*200);setTimeout(function(){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){var scene=talking?(currentChar.talk_state||'talk'):'idle';fetch('/api/scene',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scene:scene})}).catch(function(){})} +function showLive2dTalking(talking){var c=$('live2dContainer');if(!c)return;var ex=c.querySelector('.live2d-talking');if(talking&&!ex){var b=document.createElement('div');b.className='live2d-talking';b.textContent='说话中';c.appendChild(b)}else if(!talking&&ex)ex.remove()} +function addMsg(role,text){var box=$('chatBox');var div=document.createElement('div');div.className='msg '+role;var 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} +$('chatInput').addEventListener('keydown',function(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat()}}); +async function loadCharacters(){try{var res=await fetch('/api/config/available');var data=await res.json();var grid=$('charGrid');grid.innerHTML='';var cfgRes=await fetch('/api/config');var cfg=await cfgRes.json();var char=cfg.character||{};var strat=charStrategies[char.character_type||'pet']||charStrategies.pet;(data.configs||[]).forEach(function(c){var isActive=c===(data.active||'');var card=document.createElement('div');card.className='char-card'+(isActive?' active':'');var name=char.name||c.replace(/\.json$/,'').replace(/_/g,' ');card.innerHTML='
'+(strat.icon||'📦')+' '+name+'
'+strat.label+' '+(char.render_type||strat.render)+'
策略: 拟声/自然/元气 (依据角色)
'+(isActive?'使用中':'');card.onclick=function(){switchCharacter(c)};grid.appendChild(card)})}catch(e){$('charGrid').innerHTML='
加载失败
'}} +async function switchCharacter(filename){if(!confirm('切换到 '+filename+' ?'))return;try{var res=await fetch('/api/config/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename:filename})});var data=await res.json();if(data.status==='ok'||(data.message&&data.message.includes('成功'))){toast('已切换');loadConfig();loadCharacters()}else toast('失败: '+(data.message||'未知'),true)}catch(e){toast('失败: '+e.message,true)}} +async function loadModels(){try{var res=await fetch('/api/models');var data=await res.json();var bar=$('modelBar');var u=Math.round((data.used_space||0)/1048576);var q=Math.round((data.quota||0)/1048576);bar.innerHTML='
配额:'+u+'/'+q+' MB
已下载:'+(data.models||[]).filter(function(m){return m.downloaded}).length+'/'+(data.models||[]).length+'
';var list=$('modelList');list.innerHTML='';var active=data.active||{};(data.models||[]).forEach(function(m){var item=document.createElement('div');item.className='model-item';var ia=active[m.kind]===m.id;var s=Math.round(m.size/1048576);var me=Math.round(m.memory_required/1048576);var a='';if(m.downloaded){if(ia)a='';else a=' '}else a='';item.innerHTML='
'+m.name+' '+m.kind.toUpperCase()+''+(m.downloaded?'已下载':'')+(ia?'活跃':'')+'
'+s+'MB | 需 '+me+'MB | '+m.recommended_tier+'
'+a+'
';list.appendChild(item)})}catch(e){$('modelList').innerHTML='
失败
'}} +async function downloadModel(id){try{var res=await fetch('/api/models/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});var data=await res.json();toast(data.message||'下载已启动');setTimeout(loadModels,3000)}catch(e){toast('失败',true)}} +async function switchModel(id,kind){try{var res=await fetch('/api/models/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id,kind:kind})});var data=await res.json();toast(data.message||'已切换');loadModels()}catch(e){toast('失败',true)}} +async function deleteModel(id){if(!confirm('删除 '+id+'?'))return;try{var res=await fetch('/api/models/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});var data=await res.json();toast(data.message||'已删除');loadModels()}catch(e){toast('失败',true)}} +initMic(); +async function initMic(){var micBtn=$('micBtn');var 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}var mediaRecorder=null,chunks=[],recStream=null,recTimer=null;var startRec=async function(){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=function(e){if(e.data.size>0)chunks.push(e.data)};mediaRecorder.onstop=function(){if(recTimer){clearTimeout(recTimer);recTimer=null}recStream.getTracks().forEach(function(t){t.stop()});recStream=null;var 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(function(){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'}};var stopRec=function(){if(mediaRecorder&&mediaRecorder.state==='recording'){mediaRecorder.stop();micBtn.classList.remove('recording');micBtn.textContent='🎙'}};micBtn.disabled=false;micBtn.addEventListener('mousedown',startRec);micBtn.addEventListener('touchstart',function(e){e.preventDefault();startRec()});micBtn.addEventListener('mouseup',stopRec);micBtn.addEventListener('mouseleave',stopRec);micBtn.addEventListener('touchend',function(e){e.preventDefault();stopRec()})} +async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$('sendBtn').disabled=true;var micBtn=$('micBtn');micBtn.disabled=true;$('chatStatus').textContent='转码中...';$('chatStatus').className='status-bar busy';var userMsg=addMsg('user','🎤 [语音消息]');var playUrl=URL.createObjectURL(blob);var 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);var botMsg=addMsg('bot','思考中...');try{var wavBlob=await blobToWav(blob);$('chatStatus').textContent='识别中...';var res=await fetch('/api/chat/audio',{method:'POST',headers:{'Content-Type':'audio/wav','X-Audio-Format':'wav'},body:wavBlob});var data=await res.json();handleChatResponse(data,botMsg)}catch(e){showChatError(botMsg,'请求失败: '+e.message)}chatBusy=false;$('sendBtn').disabled=false;micBtn.disabled=false} +/* WAV 转码:浏览器录音(webm/opus) → 16k mono PCM16 wav,whisper-cli 直接可处理 */ +async function blobToWav(blob){var arrayBuf=await blob.arrayBuffer();var audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:16000});var decoded=await audioCtx.decodeAudioData(arrayBuf);var pcm=decodeToMonoPCM16(decoded,16000);audioCtx.close();return encodeWavBlob(pcm,16000)} +function decodeToMonoPCM16(audioBuffer,targetRate){var numCh=audioBuffer.numberOfChannels;var len=audioBuffer.length;var srcRate=audioBuffer.sampleRate;var outLen=Math.round(len*targetRate/srcRate);var out=new Int16Array(outLen);var tmp=new Float32Array(outLen);for(var ch=0;ch impl Filter + Clone { - warp::path::end() + let index = warp::path::end() .and(warp::get()) - .map(|| warp::reply::html(WEB_UI_HTML)) - .or( - warp::path("index.html") - .and(warp::path::end()) - .and(warp::get()) - .map(|| warp::reply::html(WEB_UI_HTML)), - ) -} - -/// GET /chat — AI 对话控制端页面 -fn chat_page_route() -> impl Filter + Clone { - warp::path("chat") + .map(|| warp::reply::html(include_str!("chat.html"))); + let index_html = warp::path("index.html") .and(warp::path::end()) .and(warp::get()) - .map(|| warp::reply::html(include_str!("chat.html"))) + .map(|| warp::reply::html(include_str!("chat.html"))); + let chat = warp::path("chat") + .and(warp::path::end()) + .and(warp::get()) + .map(|| warp::reply::html(include_str!("chat.html"))); + let chat_js = warp::path("chat.js") + .and(warp::path::end()) + .and(warp::get()) + .map(|| { + let body = include_str!("chat.js"); + match warp::http::Response::builder() + .header("Content-Type", "application/javascript; charset=utf-8") + .header("Cache-Control", "no-store") + .header("Content-Length", body.len().to_string()) + .body(body.to_string()) + { + Ok(resp) => resp.into_response(), + Err(_) => warp::reply::with_status( + warp::reply::html("JS 加载失败".to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response(), + } + }); + index.or(index_html).or(chat).or(chat_js) } fn ws_route( @@ -2345,427 +2359,3 @@ fn json_response(status: StatusCode, payload: &T) -> warp::reply:: warp::reply::with_status(warp::reply::json(payload), status).into_response() } -const WEB_UI_HTML: &str = r##" - - - - - Showen 控制台 - - - -
- -
-
-

Showen 控制台

-
- - WS 连接中... -
-
- -
- - - - - -
- - -
-
-
-

播放状态

-
-
状态--
-
当前视频--
-
索引--
-
列表长度--
-
-
-
-

播放操作

-
- - - - -
-
-
-
-
-
-
-

触发器

-
- - - - -
-
-
-
-
-
-
-
-
- - -
-
-
-

上传视频

- -
-
-
-

设备视频文件

-
加载中...
-
-
-
-
- - -
-
-

文件管理器

-
- - - - - -
-
-
选择目录后加载...
-
- -
-
-
- - -
-
-
-

网络状态

-
-
WiFi--
-
SSID--
-
IP--
-
BLE--
-
-
-
-
-

WiFi 扫描

-
点击扫描搜索附近网络
-
-
-
-

连接 WiFi

- - -
-
-
-

热点 / BLE

- - -
- - -
- -
- - -
-
-
-
- - -
-
-
-

配置文件切换

-
-
-
-
-
-
-

显示设置

-
-
-
-
-

配置编辑器

- -
- - - -
-
-
-
-
- - - -"##;