fix(Web): 角色列表显示所有角色 + 区分视频/Live2D 模式
问题:切到 live2d 配置后角色列表只显示一个角色;角色卡片用当前配置的
character 信息渲染,而非各配置自己的。
1. routes.rs config_available_route 扩展返回结构
- configs 从字符串数组改为对象数组,每项含 filename + character 元信息
(name/character_type/render_type/live2d_model)
- 读取每个配置文件提取 character 块
2. chat.js loadCharacters 用各配置自己的 character 元信息渲染卡片
- 显示角色名/类型徽章/渲染模式(video/live2d)
- Live2D 角色额外显示模型路径
- 策略说明区分"视频播放+状态机"和"Canvas渲染+嘴部动效"
3. chat.js switchCharacter 改为 await loadConfig() 确保配置加载后再刷新
loadAvailableConfigs 兼容新旧返回结构
This commit is contained in:
@@ -37,7 +37,7 @@ function stopAP(){if(wsReady)wsCmd({cmd:'ap_stop'});else api('POST','/api/wifi/a
|
||||
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 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)}}
|
||||
@@ -57,8 +57,8 @@ function triggerTalkState(talking){var scene=talking?(currentChar.talk_state||'t
|
||||
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='';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='<div class="name">'+(strat.icon||'📦')+' '+name+'</div><div><span class="type-badge '+(char.character_type||'pet')+'">'+strat.label+'</span> '+(char.render_type||strat.render)+'</div><div class="strat">策略: 拟声/自然/元气 (依据角色)</div>'+(isActive?'<span class="badge">使用中</span>':'');card.onclick=function(){switchCharacter(c)};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('已切换');loadConfig();loadCharacters()}else toast('失败: '+(data.message||'未知'),true)}catch(e){toast('失败: '+e.message,true)}}
|
||||
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)}}
|
||||
|
||||
@@ -542,23 +542,57 @@ fn config_available_route(
|
||||
.and_then(|state: Arc<HttpState>| async move {
|
||||
let config = state.config();
|
||||
let configs_dir = &config.source_dir;
|
||||
let mut files: Vec<String> = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(configs_dir) {
|
||||
for entry in entries.flatten() {
|
||||
// 每个配置文件提取 character 元信息,供前端渲染角色卡片
|
||||
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||
if let Ok(read_entries) = std::fs::read_dir(configs_dir) {
|
||||
for entry in read_entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.ends_with(".json") {
|
||||
files.push(name);
|
||||
if !name.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let char_info = match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Ok(v) => {
|
||||
let c = v.get("character").cloned().unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"name": c.get("name").and_then(|n| n.as_str()).unwrap_or(&name).to_string(),
|
||||
"character_type": c.get("character_type").and_then(|t| t.as_str()).unwrap_or("pet").to_string(),
|
||||
"render_type": c.get("render_type").and_then(|r| r.as_str()).unwrap_or("video").to_string(),
|
||||
"live2d_model": c.get("live2d_model").and_then(|m| m.as_str()).map(|s| s.to_string()),
|
||||
})
|
||||
}
|
||||
Err(_) => serde_json::json!({
|
||||
"name": name.clone(),
|
||||
"character_type": "pet",
|
||||
"render_type": "video",
|
||||
"live2d_model": null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Err(_) => serde_json::json!({
|
||||
"name": name.clone(),
|
||||
"character_type": "pet",
|
||||
"render_type": "video",
|
||||
"live2d_model": null,
|
||||
}),
|
||||
};
|
||||
entries.push(serde_json::json!({
|
||||
"filename": name,
|
||||
"character": char_info,
|
||||
}));
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
// 标记当前活跃的配置
|
||||
entries.sort_by(|a, b| {
|
||||
a["filename"].as_str().cmp(&b["filename"].as_str())
|
||||
});
|
||||
let active = config.source_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let result = serde_json::json!({
|
||||
"configs": files,
|
||||
"configs": entries,
|
||||
"active": active,
|
||||
});
|
||||
Ok::<_, Infallible>(json_response(StatusCode::OK, &result))
|
||||
|
||||
Reference in New Issue
Block a user