diff --git a/configs/live2d_anime.json b/configs/live2d_anime.json new file mode 100644 index 0000000..4bc0039 --- /dev/null +++ b/configs/live2d_anime.json @@ -0,0 +1,71 @@ +{ + "display": { + "fullscreen": true, + "window_title": "Showen - Live2D Anime", + "rotation": 0, + "flip_horizontal": false, + "flip_vertical": false, + "offset_x": 0, + "offset_y": 0, + "prevent_screen_lock": true, + "render_width": 800, + "render_height": 800, + "output_width": null, + "output_height": null, + "scale_mode": "stretch", + "allow_upscale": true, + "perspective_correction": { + "enabled": false, + "points": [[0, 0], [800, 0], [800, 800], [0, 800]] + }, + "chroma_key": { + "enabled": false, + "hsv_min": [0, 0, 200], + "hsv_max": [180, 30, 255] + } + }, + "playback": { + "auto_start": true, + "loop": true, + "volume": 0.8, + "fps": 30 + }, + "playlist": [ + { + "id": "live2d_idle", + "path": "../../宠物嵌入式开发视频2/1.动画0.mp4", + "loop_count": 0, + "next_state": "idle" + } + ], + "states": { + "initial": "idle", + "idle": { + "video": "live2d_idle", + "next": "idle", + "loop": true + } + }, + "triggers": {}, + "remote_control": { + "enabled": true, + "host": "0.0.0.0", + "port": 5000 + }, + "character": { + "name": "小雅", + "character_type": "live2d", + "render_type": "live2d", + "persona_prompt": "你是一个温柔的二次元少女,名叫小雅。说话活泼可爱,偶尔用语气词'呢''哦''嘛',每次回复不超过两句话。喜欢聊日常和动漫话题。", + "max_tokens": 128, + "talk_state": "talk", + "live2d_model": "haru/index.model3.json" + }, + "ai": { + "backend": "local", + "whisper_lib_dir": "/home/showen/ai_spike/whisper.cpp/build/bin", + "piper_lib_dir": "/home/showen/ai_spike/piper/piper", + "piper_config": "/home/showen/ai_spike/piper/zh_CN-huayan-medium.onnx.json", + "espeak_data_dir": "/home/showen/ai_spike/piper/piper/espeak-ng-data" + } +} diff --git a/src/plugins/http/chat.html b/src/plugins/http/chat.html index 844f6fd..c97634f 100644 --- a/src/plugins/http/chat.html +++ b/src/plugins/http/chat.html @@ -42,6 +42,11 @@ header{display:flex;align-items:center;justify-content:space-between;gap:12px;pa
+ + + + + diff --git a/src/plugins/http/chat.js b/src/plugins/http/chat.js index a9dc747..44b1ee6 100644 --- a/src/plugins/http/chat.js +++ b/src/plugins/http/chat.js @@ -48,13 +48,13 @@ function updateDownloadModal(info){var meta=$('download-meta'),link=$('download- 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} +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||'未配置')+'
';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'&¤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 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='
'+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='
加载失败
'}} @@ -70,5 +70,10 @@ async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$(' 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⚠️Live2D SDK 加载失败
请检查网络连接(CDN 需联网)';return}if(!char.live2d_model){$('live2dPlaceholder').innerHTML='
未配置 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='
⚠️
初始化失败: '+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='
⚠️
模型加载失败: '+e.message+'
路径: '+modelUrl+'';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(); diff --git a/src/plugins/http/routes.rs b/src/plugins/http/routes.rs index 2bc8db4..ddda3f1 100644 --- a/src/plugins/http/routes.rs +++ b/src/plugins/http/routes.rs @@ -159,6 +159,7 @@ pub(crate) fn build_routes( root_route() .or(download_route(Arc::clone(&state))) + .or(live2d_static_route(Arc::clone(&state))) .or(ws_route(tx.clone(), Arc::clone(&state))) .or(api) .with( @@ -248,6 +249,97 @@ fn download_route( }) } +/// GET /live2d/** — Live2D 模型静态文件(.moc3/.model3.json/.png/.physics3.json 等) +/// +/// 从 source_dir/live2d/ 目录托管,支持子目录。用于浏览器 Cubism SDK 加载模型。 +fn live2d_static_route( + state: Arc, +) -> impl Filter + Clone { + warp::path("live2d") + .and(warp::path::peek()) + .and(warp::get()) + .and(with_state(state)) + .and_then(|tail: warp::path::Peek, state: Arc| async move { + let rel = tail.as_str(); + // 防路径穿越:不允许 .. 段 + if rel.contains("..") || rel.starts_with('/') { + return Ok::<_, Infallible>( + warp::reply::with_status( + warp::reply::html("无效路径".to_string()), + StatusCode::BAD_REQUEST, + ) + .into_response(), + ); + } + let base = state.config().source_dir.join("live2d"); + let full = base.join(rel); + // canonicalize 校验最终路径仍在 base 内 + let canonical_base = match base.canonicalize() { + Ok(p) => p, + Err(_) => { + return Ok(warp::reply::with_status( + warp::reply::html("live2d 目录不存在".to_string()), + StatusCode::NOT_FOUND, + ) + .into_response()); + } + }; + let canonical_full = match full.canonicalize() { + Ok(p) => p, + Err(_) => { + return Ok(warp::reply::with_status( + warp::reply::html("文件不存在".to_string()), + StatusCode::NOT_FOUND, + ) + .into_response()); + } + }; + if !canonical_full.starts_with(&canonical_base) || !canonical_full.is_file() { + return Ok(warp::reply::with_status( + warp::reply::html("文件不存在".to_string()), + StatusCode::NOT_FOUND, + ) + .into_response()); + } + + // 按扩展名设 Content-Type + let ext = canonical_full + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let content_type = match ext.as_str() { + "json" => "application/json; charset=utf-8", + "png" => "image/png", + "moc3" => "application/octet-stream", + "physics3" => "application/json", + "cdi3" => "application/json", + "mot3" => "application/json", + _ => "application/octet-stream", + }; + match std::fs::read(&canonical_full) { + Ok(data) => match warp::http::Response::builder() + .header("Content-Type", content_type) + .header("Content-Length", data.len().to_string()) + .header("Cache-Control", "no-store") + .body(data) + { + Ok(resp) => Ok(resp.into_response()), + Err(_) => Ok(warp::reply::with_status( + warp::reply::html("响应构建失败".to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response()), + }, + Err(_) => Ok(warp::reply::with_status( + warp::reply::html("读取失败".to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response()), + } + }) +} + fn status_route( state: Arc, ) -> impl Filter + Clone {