/* 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({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]})}
function ea(v){return esc(v).replace(/'/g,'&#39;')}
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='<div class="item"><span class="name">目录中没有视频文件</span></div>';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 '<div class="item"><span class="name">'+esc(f.name)+'</span><span class="meta">'+sz+'</span><button class="btn-d" onclick="deleteVideo(\''+js(f.name)+'\')">删除</button></div>'}).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<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);fetch('/api/videos/upload',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';loadVideoList()}).catch(function(){toast('上传失败',true)})}
function deleteVideo(name){if(!confirm('确定删除 '+name+' ?'))return;fetch('/api/videos/'+encodeURIComponent(name),{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');loadVideoList()}).catch(function(){toast('删除失败',true)})}
function fmRefresh(){fmNavigate(fmCurrentPath)}
function fmNavigate(subpath){fmCurrentDir=$('fm-dir').value;fmCurrentPath=subpath||'';var url='/api/files/'+fmCurrentDir;if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);var bc=$('fm-breadcrumb');var parts=fmCurrentPath?fmCurrentPath.split('/'):[];var html='<span onclick="fmNavigate(\'\')">'+({'videos':'视频目录','configs':'配置目录','plugins':'插件目录'}[fmCurrentDir]||fmCurrentDir)+'</span>';var acc='';for(var i=0;i<parts.length;i++){if(parts[i]){acc+=(acc?'/':'')+parts[i];html+='<span class="sep">/</span><span onclick="fmNavigate(\''+js(acc)+'\')">'+esc(parts[i])+'</span>'}}bc.innerHTML=html;fetch(url).then(function(r){return r.json()}).then(function(entries){var el=$('fm-list');if(entries.status==='error'){el.innerHTML='<div class="item"><span class="name">'+esc(entries.message)+'</span></div>';return}if(!entries.length){el.innerHTML='<div class="item"><span class="name">空目录</span></div>';$('fm-del-btn').style.display='none';return}var html='';for(var i=0;i<entries.length;i++){var e=entries[i];var sz=e.is_dir?'':e.size<1048576?(e.size/1024).toFixed(1)+' KB':(e.size/1048576).toFixed(1)+' MB';var fp=fmCurrentPath?(fmCurrentPath+'/'+e.name):e.name;html+='<div class="item"><label class="checkbox-label"><input type="checkbox" class="fm-check" data-path="'+esc(fp)+'" data-isdir="'+(e.is_dir?'1':'0')+'"></label>';if(e.is_dir){html+='<span class="dir-icon">&#128193;</span><span class="name" style="cursor:pointer;color:var(--amber)" onclick="fmNavigate(\''+js(fp)+'\')">'+esc(e.name)+'</span>'}else{html+='<span class="name">'+esc(e.name)+'</span>'}html+='<span class="meta">'+sz+'</span>';if(!e.is_dir){html+='<button class="btn-s" onclick="fmDownload(\''+js(fp)+'\')">下载</button>'}html+='</div>'}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<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);var url='/api/files/'+fmCurrentDir+'/upload';if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);fetch(url,{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';fmRefresh()}).catch(function(){toast('上传失败',true)})}
function fmMkdir(){var name=prompt('输入新文件夹名称:');if(!name)return;var path=fmCurrentPath?(fmCurrentPath+'/'+name):name;fetch('/api/files/'+fmCurrentDir+'/mkdir',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:path})}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');fmRefresh()}).catch(function(){toast('创建失败',true)})}
function loadWifiStatus(){fetch('/api/wifi/status').then(function(r){return r.json()}).then(applyWifi).catch(function(){})}
function scanWifi(){$('wifi-list').innerHTML='<div class="item"><span class="name">扫描中...</span></div>';fetch('/api/wifi/scan').then(function(r){return r.json()}).then(function(list){if(!list.length){$('wifi-list').innerHTML='<div class="item"><span class="name">未发现 WiFi</span></div>';return}$('wifi-list').innerHTML=list.map(function(n){return '<div class="item"><span class="name">'+esc(n.ssid||'隐藏网络')+'</span><span class="meta">'+esc(String(n.signal||0))+' / '+esc(n.security||'OPEN')+'</span><button class="btn-s" onclick="selectWifi(\''+js(n.ssid||'')+'\')">选择</button></div>'}).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(item){var f=typeof item==='string'?item:item.filename;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+='<label class="checkbox-label"><input id="d-fullscreen" type="checkbox" '+(d.fullscreen?'checked':'')+'> 全屏模式</label>';h+='<label>窗口标题</label><input id="d-title" type="text" value="'+ea(d.window_title||'')+'">';h+='<label>旋转角度</label><input id="d-rotation" type="number" value="'+ea(String(d.rotation||0))+'">';h+='<label>渲染宽度</label><input id="d-render-width" type="number" value="'+ea(String(d.render_width||1024))+'">';h+='<label>渲染高度</label><input id="d-render-height" type="number" value="'+ea(String(d.render_height||1024))+'">';h+='<label>色键下限 (H,S,V)</label><input id="d-ck-min" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_min?d.chroma_key.hsv_min.join(','):'0,0,200'))+'">';h+='<label>色键上限 (H,S,V)</label><input id="d-ck-max" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_max?d.chroma_key.hsv_max.join(','):'180,30,255'))+'">';h+='<label>透视校正点 (JSON)</label><input id="d-points" type="text" value="'+ea(JSON.stringify((d.perspective_correction&&d.perspective_correction.points)||[]))+'">';$('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='<div class="live2d-container" id="live2dContainer"><canvas id="live2dCanvas"></canvas><div class="live2d-placeholder" id="live2dPlaceholder"><div class="icon">✨</div>加载 Live2D 模型中...<br><small>模型: '+(char.live2d_model||'未配置')+'</small></div></div>';initLive2D(char)}else{ra.innerHTML='';cb.style.display='block';destroyLive2D()}$('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'&&currentChar.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='<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';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){live2dTalking=talking;if(!talking&&live2dModel&&live2dModel.internalModel&&live2dModel.internalModel.coreModel){live2dModel.internalModel.coreModel.setParameterValueById('ParamMouthOpenY',0)}}
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='<div class="bubble"></div><div class="meta">'+t+'</div>';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='';(data.configs||[]).forEach(function(item){var filename=item.filename||item;var char=item.character||{};var ct=char.character_type||'pet';var strat=charStrategies[ct]||charStrategies.pet;var isActive=filename===(data.active||'');var name=char.name||filename.replace(/\.json$/,'').replace(/_/g,' ');var rt=char.render_type||strat.render;var modelInfo=char.live2d_model?'<br><small style="color:var(--muted)">模型: '+char.live2d_model+'</small>':'';var card=document.createElement('div');card.className='char-card'+(isActive?' active':'');card.innerHTML='<div class="name">'+(strat.icon||'📦')+' '+name+'</div><div><span class="type-badge '+ct+'">'+strat.label+'</span> <span class="render-badge">'+rt+'</span></div><div class="strat">'+(rt==='live2d'?'Canvas 渲染 + 嘴部动效':'视频播放 + 状态机')+modelInfo+'</div>'+(isActive?'<span class="badge">使用中</span>':'');card.onclick=function(){switchCharacter(filename)};grid.appendChild(card)})}catch(e){$('charGrid').innerHTML='<div style="color:var(--red)">加载失败</div>'}}
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('已切换');await 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='<div class="model-stat"><span class="label">配额:</span><span class="val">'+u+'/'+q+' MB</span></div><div class="model-stat"><span class="label">已下载:</span><span class="val">'+(data.models||[]).filter(function(m){return m.downloaded}).length+'/'+(data.models||[]).length+'</span></div>';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='<button class="active" disabled>使用中</button>';else a='<button onclick="switchModel(\''+m.id+'\',\''+m.kind+'\')">切换</button> <button class="danger" onclick="deleteModel(\''+m.id+'\')">删除</button>'}else a='<button onclick="downloadModel(\''+m.id+'\')">下载</button>';item.innerHTML='<div class="info"><div class="name">'+m.name+' <span class="tag '+m.kind+'">'+m.kind.toUpperCase()+'</span>'+(m.downloaded?'<span class="tag downloaded">已下载</span>':'')+(ia?'<span class="tag active">活跃</span>':'')+'</div><div class="desc">'+s+'MB | 需 '+me+'MB | '+m.recommended_tier+'</div></div><div class="actions">'+a+'</div>';list.appendChild(item)})}catch(e){$('modelList').innerHTML='<div style="color:var(--red)">失败</div>'}}
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 访问，录音已禁用。<br>启用方法（桌面 Chrome/Edge）：右键浏览器图标 → 属性 → 在目标末尾加 <code>--unsafely-treat-insecure-origin-as-secure=http://192.168.31.105:5000</code> → 重启浏览器并访问本页。<br>或直接用文字对话，回复语音仍可正常播放。';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='<span style="font-size:11px;color:var(--muted)">你的录音</span><audio controls src="'+playUrl+'"></audio>';userMsg.appendChild(up);if(currentChar.render_type!=='live2d'&&currentChar.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','X-Session-Id':sessionId},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<numCh;ch++){var data=audioBuffer.getChannelData(ch);for(var i=0;i<outLen;i++){var srcIdx=i*srcRate/targetRate;var i0=Math.floor(srcIdx);var i1=Math.min(i0+1,len-1);var frac=srcIdx-i0;tmp[i]+=data[i0]*(1-frac)+data[i1]*frac}}for(var i=0;i<outLen;i++){var 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){var buf=new ArrayBuffer(44+samples.length*2);var view=new DataView(buf);var writeStr=function(off,s){for(var 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);var off=44;for(var i=0;i<samples.length;i++){view.setInt16(off,samples[i],true);off+=2}return new Blob([buf],{type:'audio/wav'})}
/* ==================== Live2D 渲染 ==================== */
var live2dApp=null,live2dModel=null,live2dTalking=false,live2dTickerFn=null;
function initLive2D(char){destroyLive2D();if(typeof PIXI==='undefined'||typeof PIXI.live2d==='undefined'){$('live2dPlaceholder').innerHTML='<div class="icon">⚠️</div>Live2D SDK 加载失败<br><small>请检查网络连接（CDN 需联网）</small>';return}if(!char.live2d_model){$('live2dPlaceholder').innerHTML='<div class="icon">✨</div>未配置 Live2D 模型';return}try{var canvas=$('live2dCanvas');var container=$('live2dContainer');var w=container.clientWidth||400;var h=container.clientHeight||300;live2dApp=new PIXI.Application({view:canvas,backgroundAlpha:0,width:w,height:h});window.PIXI=PIXI;loadLive2DModel(char.live2d_model)}catch(e){$('live2dPlaceholder').innerHTML='<div class="icon">⚠️</div>初始化失败: '+e.message}}
async function loadLive2DModel(modelPath){var modelUrl='/live2d/'+modelPath.replace(/^\//,'').split('/').map(encodeURIComponent).join('/');var ph=$('live2dPlaceholder');try{var resolvedUrl=modelUrl;if(!resolvedUrl.endsWith('.model3.json')&&!resolvedUrl.endsWith('.model.json')){resolvedUrl=resolvedUrl.replace(/\/$/,'')+'/index.model3.json'}live2dModel=await PIXI.live2d.Live2DModel.from(resolvedUrl);live2dApp.stage.addChild(live2dModel);live2dModel.anchor.set(0.5,0.5);live2dModel.x=live2dApp.screen.width/2;live2dModel.y=live2dApp.screen.height/2;var scale=Math.min(live2dApp.screen.width/live2dModel.width,live2dApp.screen.height/live2dModel.height)*0.9;live2dModel.scale.set(scale);ph.style.display='none';live2dTickerFn=function(){if(live2dModel&&live2dModel.internalModel&&live2dModel.internalModel.coreModel){if(live2dTalking){var v=Math.abs(Math.sin(Date.now()/120))*0.8+0.2;live2dModel.internalModel.coreModel.setParameterValueById('ParamMouthOpenY',v)}else{live2dModel.internalModel.coreModel.setParameterValueById('ParamMouthOpenY',0)}var breath=Math.sin(Date.now()/2000)*0.05;live2dModel.internalModel.coreModel.setParameterValueById('ParamBreath',breath)}};live2dApp.ticker.add(live2dTickerFn)}catch(e){ph.innerHTML='<div class="icon">⚠️</div>模型加载失败: '+e.message+'<br><small>路径: '+modelUrl+'</small>';ph.style.display='block'}}
function destroyLive2D(){if(live2dTickerFn&&live2dApp){live2dApp.ticker.remove(live2dTickerFn)}live2dTickerFn=null;if(live2dModel){try{live2dModel.destroy()}catch(e){}live2dModel=null}if(live2dApp){try{live2dApp.destroy(true)}catch(e){}live2dApp=null}}
/* 启动 */
connectWS();refreshStatus();loadConfig();
