feat(M2.1): Web UI 统一入口+角色类型策略+Live2D 渲染支持+Gemma4 清单
- chat.html: 统一入口, 当前角色信息条, 四种角色类型策略 (pet/human/singer/live2d) - 对话时根据 render_type 触发不同策略: video 切 talk/idle, live2d 显示动效 - config.rs: 新增 CharacterType::Live2d + RenderType 枚举 + live2d_model/live2d_config 字段 - model_manager.rs: 默认清单加 Gemma 4 E2B Q4_K_M (下载链接, 不含文件)
This commit is contained in:
@@ -314,6 +314,21 @@ pub enum CharacterType {
|
||||
Pet,
|
||||
Human,
|
||||
Singer,
|
||||
/// Live2D 数字人 (渲染策略不同于视频)
|
||||
Live2d,
|
||||
}
|
||||
|
||||
/// 渲染类型 (决定显示策略)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RenderType {
|
||||
/// 视频播放 (OpenCV 播放预渲染 mp4)
|
||||
#[default]
|
||||
Video,
|
||||
/// Live2D 渲染 (Web 前端渲染 Live2D 模型)
|
||||
Live2d,
|
||||
/// 纯文字 (无画面)
|
||||
Text,
|
||||
}
|
||||
|
||||
/// 角色元信息配置块(内容包的一部分,切角色即切人设)
|
||||
@@ -326,6 +341,9 @@ pub struct CharacterConfig {
|
||||
/// 角色类型
|
||||
#[serde(default)]
|
||||
pub character_type: CharacterType,
|
||||
/// 渲染类型 (决定显示策略)
|
||||
#[serde(default)]
|
||||
pub render_type: RenderType,
|
||||
/// 封面图相对路径 (内容包内)
|
||||
#[serde(default)]
|
||||
pub cover_image: Option<String>,
|
||||
@@ -341,6 +359,12 @@ pub struct CharacterConfig {
|
||||
/// talk 状态名 (语音回合期间切换到,留空用 "talk")
|
||||
#[serde(default = "default_talk_state")]
|
||||
pub talk_state: String,
|
||||
/// Live2D 模型路径 (render_type=live2d 时使用)
|
||||
#[serde(default)]
|
||||
pub live2d_model: Option<String>,
|
||||
/// Live2D 模型配置 JSON 路径
|
||||
#[serde(default)]
|
||||
pub live2d_config: Option<String>,
|
||||
}
|
||||
|
||||
fn default_character_max_tokens() -> u16 {
|
||||
|
||||
@@ -176,6 +176,18 @@ impl ModelManager {
|
||||
url: "https://hf-mirror.com/google/gemma-3-1b-it-GGUF/resolve/main/gemma-3-1b-it-q4_k_m.gguf".to_string(),
|
||||
sha256: String::new(),
|
||||
filename: "gemma3-1b-q4_k_m.gguf".to_string(),
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gemma-4-e2b-q4_k_m".to_string(),
|
||||
kind: ModelKind::Llm,
|
||||
name: "Gemma 4 E2B Q4 (最新代,多模态)".to_string(),
|
||||
version: "e2b".to_string(),
|
||||
size: 3269 * 1024 * 1024,
|
||||
memory_required: 3 * 1024 * 1024 * 1024,
|
||||
recommended_tier: "需 4G+ 内存,可能触发 swap".to_string(),
|
||||
url: "https://huggingface.co/dahus/gemma-4-e2b-it-Q4_K_M-GGUF/resolve/main/gemma-4-e2b-Q4_K_M.gguf".to_string(),
|
||||
sha256: "2de6e5baaea40a8d54fb37ff418730ba4e5c002b".to_string(),
|
||||
filename: "gemma-4-e2b-Q4_K_M.gguf".to_string(),
|
||||
},
|
||||
ModelInfo {
|
||||
id: "whisper-tiny".to_string(),
|
||||
|
||||
@@ -3,23 +3,32 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Showen AI 对话</title>
|
||||
<title>Showen 数字生命控制台</title>
|
||||
<style>
|
||||
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#242836;--border:#2e3345;--text:#e4e6ef;--muted:#8b8fa3;--accent:#6366f1;--accent-glow:rgba(99,102,241,.15);--green:#22c55e;--amber:#f59e0b;--red:#ef4444;--radius:12px}
|
||||
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#242836;--border:#2e3345;--text:#e4e6ef;--muted:#8b8fa3;--accent:#6366f1;--accent-glow:rgba(99,102,241,.15);--green:#22c55e;--amber:#f59e0b;--red:#ef4444;--radius:12px;--pet:#f59e0b;--human:#6366f1;--singer:#ec4899;--live2d:#22c55e}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;min-height:100vh}
|
||||
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||
.app{max-width:900px;margin:0 auto;padding:16px 20px}
|
||||
h1{font-size:20px;font-weight:600;margin-bottom:16px;display:flex;align-items:center;gap:8px}
|
||||
.app{max-width:1000px;margin:0 auto;padding:16px 20px}
|
||||
h1{font-size:20px;font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:8px}
|
||||
h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
|
||||
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border);padding-bottom:0}
|
||||
.subtitle{font-size:12px;color:var(--muted);margin-bottom:16px}
|
||||
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border)}
|
||||
.tab{padding:8px 16px;cursor:pointer;border-radius:8px 8px 0 0;color:var(--muted);font-size:14px;transition:.2s;border-bottom:2px solid transparent;margin-bottom:-1px}
|
||||
.tab:hover{color:var(--text)}
|
||||
.tab.active{color:var(--text);border-bottom-color:var(--accent);background:var(--accent-glow)}
|
||||
.panel{display:none}
|
||||
.panel.active{display:block}
|
||||
/* 对话区 */
|
||||
.chat-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:50vh;overflow-y:auto;padding:16px;margin-bottom:12px}
|
||||
.panel{display:none}.panel.active{display:block}
|
||||
.char-bar{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;margin-bottom:12px}
|
||||
.char-bar .avatar{width:40px;height:40px;border-radius:50%;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-size:20px}
|
||||
.char-bar .info{flex:1}
|
||||
.char-bar .info .name{font-weight:600;font-size:15px}
|
||||
.char-bar .info .type-badge{font-size:11px;padding:2px 8px;border-radius:4px;margin-left:8px;text-transform:uppercase}
|
||||
.type-badge.pet{background:rgba(245,158,11,.2);color:var(--pet)}
|
||||
.type-badge.human{background:rgba(99,102,241,.2);color:var(--human)}
|
||||
.type-badge.singer{background:rgba(236,72,153,.2);color:var(--singer)}
|
||||
.type-badge.live2d{background:rgba(34,197,94,.2);color:var(--live2d)}
|
||||
.char-bar .render-badge{font-size:11px;color:var(--muted);padding:2px 8px;border:1px solid var(--border);border-radius:4px}
|
||||
.chat-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;overflow-y:auto;padding:16px;margin-bottom:12px}
|
||||
.msg{margin-bottom:12px;max-width:80%}
|
||||
.msg.user{margin-left:auto;text-align:right}
|
||||
.msg .bubble{display:inline-block;padding:10px 14px;border-radius:14px;font-size:14px;line-height:1.5}
|
||||
@@ -31,21 +40,23 @@ h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
|
||||
.chat-input{display:flex;gap:8px}
|
||||
.chat-input input{flex:1;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px;outline:none}
|
||||
.chat-input input:focus{border-color:var(--accent)}
|
||||
.chat-input button{padding:10px 20px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-size:14px;white-space:nowrap;transition:.2s}
|
||||
.chat-input button:hover{filter:brightness(1.1)}
|
||||
.chat-input button{padding:10px 20px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-size:14px;white-space:nowrap}
|
||||
.chat-input button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.status-bar{font-size:12px;color:var(--muted);margin-top:8px;min-height:18px}
|
||||
.status-bar.error{color:var(--red)}
|
||||
.status-bar.busy{color:var(--amber)}
|
||||
/* 角色切换 */
|
||||
.char-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}
|
||||
.live2d-container{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;margin-bottom:12px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}
|
||||
.live2d-placeholder{color:var(--muted);text-align:center}
|
||||
.live2d-placeholder .icon{font-size:48px;margin-bottom:8px}
|
||||
.live2d-talking{position:absolute;top:8px;right:8px;background:var(--green);color:#fff;padding:4px 10px;border-radius:4px;font-size:12px;animation:pulse 1s infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
||||
.char-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
|
||||
.char-card{background:var(--surface);border:2px solid var(--border);border-radius:var(--radius);padding:16px;cursor:pointer;transition:.2s}
|
||||
.char-card:hover{border-color:var(--accent)}
|
||||
.char-card.active{border-color:var(--green);background:var(--accent-glow)}
|
||||
.char-card .name{font-size:16px;font-weight:600;margin-bottom:4px}
|
||||
.char-card .type{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
|
||||
.char-card .strat{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}
|
||||
.char-card .badge{display:inline-block;margin-top:8px;padding:2px 8px;background:var(--green);color:#fff;border-radius:4px;font-size:11px}
|
||||
/* 模型管理 */
|
||||
.model-bar{display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap}
|
||||
.model-stat{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 14px;font-size:12px}
|
||||
.model-stat .label{color:var(--muted);margin-right:4px}
|
||||
@@ -56,7 +67,7 @@ h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
|
||||
.model-item .info .name{font-weight:600;font-size:14px}
|
||||
.model-item .info .desc{font-size:12px;color:var(--muted);margin-top:2px}
|
||||
.model-item .actions{display:flex;gap:6px}
|
||||
.model-item .actions button{padding:6px 12px;border:1px solid var(--border);background:var(--surface2);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px;transition:.2s}
|
||||
.model-item .actions button{padding:6px 12px;border:1px solid var(--border);background:var(--surface2);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px}
|
||||
.model-item .actions button:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.model-item .actions button.danger:hover{border-color:var(--red);color:var(--red)}
|
||||
.model-item .actions button.active{background:var(--green);color:#fff;border-color:var(--green)}
|
||||
@@ -70,215 +81,51 @@ h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<h1><span class="dot"></span> Showen AI 对话控制端</h1>
|
||||
<h1><span class="dot"></span> Showen 数字生命控制台</h1>
|
||||
<div class="subtitle">统一入口 · 角色对话 · 模型管理</div>
|
||||
<div class="char-bar" id="charBar">
|
||||
<div class="avatar" id="charAvatar">🐶</div>
|
||||
<div class="info"><span class="name" id="charName">加载中...</span><span class="type-badge pet" id="charType">pet</span></div>
|
||||
<div class="render-badge" id="charRender">video</div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="chat">对话</div>
|
||||
<div class="tab" data-tab="character">角色</div>
|
||||
<div class="tab" data-tab="models">模型</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话面板 -->
|
||||
<div class="panel active" id="panel-chat">
|
||||
<div class="chat-box" id="chatBox"></div>
|
||||
<div class="chat-input">
|
||||
<input type="text" id="chatInput" placeholder="输入文字与角色对话..." autocomplete="off">
|
||||
<button id="sendBtn" onclick="sendChat()">发送</button>
|
||||
</div>
|
||||
<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="status-bar" id="chatStatus"></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>
|
||||
<div class="char-grid" id="charGrid"><div style="color:var(--muted)">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 模型面板 -->
|
||||
<div class="panel" id="panel-models">
|
||||
<div class="model-bar" id="modelBar"></div>
|
||||
<div class="model-list" id="modelList"><div style="color:var(--muted)">加载中...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $=s=>document.querySelector(s);
|
||||
const sessionId='web_'+Date.now();
|
||||
let chatBusy=false;
|
||||
|
||||
// Tab 切换
|
||||
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();
|
||||
};
|
||||
});
|
||||
|
||||
// 对话
|
||||
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);
|
||||
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 audioUrl='/download/'+encodeURIComponent(data.reply_audio_path.replace(/^\//,''));
|
||||
const audioDiv=document.createElement('div');
|
||||
audioDiv.className='audio';
|
||||
audioDiv.innerHTML='<span>🔊 回复音频:</span> <audio controls src="'+audioUrl+'"></audio>';
|
||||
botMsg.appendChild(audioDiv);
|
||||
}
|
||||
$('#chatStatus').textContent='';
|
||||
$('#chatStatus').className='status-bar';
|
||||
}
|
||||
}catch(e){
|
||||
botMsg.querySelector('.bubble').textContent='请求失败: '+e.message;
|
||||
botMsg.querySelector('.bubble').style.color='var(--red)';
|
||||
$('#chatStatus').textContent='请求失败: '+e.message;
|
||||
$('#chatStatus').className='status-bar error';
|
||||
}
|
||||
chatBusy=false;
|
||||
$('#sendBtn').disabled=false;
|
||||
$('#chatInput').focus();
|
||||
}
|
||||
|
||||
function addMsg(role,text){
|
||||
const box=$('#chatBox');
|
||||
const div=document.createElement('div');
|
||||
div.className='msg '+role;
|
||||
const time=new Date().toLocaleTimeString('zh-CN',{hour12:false});
|
||||
div.innerHTML='<div class="bubble"></div><div class="meta">'+time+'</div>';
|
||||
div.querySelector('.bubble').textContent=text;
|
||||
box.appendChild(div);
|
||||
box.scrollTop=box.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
const $=s=>document.querySelector(s);const sessionId='web_'+Date.now();let chatBusy=false;let currentChar={name:'未知',character_type:'pet',render_type:'video',talk_state:'talk'};
|
||||
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();}
|
||||
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;}
|
||||
$('#chatInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat();}});
|
||||
|
||||
// 角色切换
|
||||
async function loadCharacters(){
|
||||
try{
|
||||
const res=await fetch('/api/config/available');
|
||||
const data=await res.json();
|
||||
const grid=$('#charGrid');
|
||||
grid.innerHTML='';
|
||||
const configs=data.configs||[];
|
||||
const active=data.active||'';
|
||||
configs.forEach(c=>{
|
||||
const card=document.createElement('div');
|
||||
card.className='char-card'+(c===active?' active':'');
|
||||
const name=c.replace(/\.json$/,'').replace(/_/g,' ');
|
||||
card.innerHTML='<div class="name">'+name+'</div><div class="type">配置文件</div>'+(c===active?'<span class="badge">使用中</span>':'');
|
||||
card.onclick=()=>switchCharacter(c);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
if(configs.length===0)grid.innerHTML='<div style="color:var(--muted)">无可用配置</div>';
|
||||
}catch(e){
|
||||
$('#charGrid').innerHTML='<div style="color:var(--red)">加载失败: '+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function switchCharacter(filename){
|
||||
if(!confirm('切换到 '+filename+' ?'))return;
|
||||
try{
|
||||
const res=await fetch('/api/config/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename})});
|
||||
const data=await res.json();
|
||||
if(data.status==='ok'||data.message&&data.message.includes('成功')){
|
||||
alert('已切换到 '+filename);
|
||||
loadCharacters();
|
||||
}else{
|
||||
alert('切换失败: '+(data.message||'未知错误'));
|
||||
}
|
||||
}catch(e){
|
||||
alert('请求失败: '+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 模型管理
|
||||
async function loadModels(){
|
||||
try{
|
||||
const res=await fetch('/api/models');
|
||||
const data=await res.json();
|
||||
const bar=$('#modelBar');
|
||||
const usedMB=Math.round((data.used_space||0)/1048576);
|
||||
const quotaMB=Math.round((data.quota||0)/1048576);
|
||||
bar.innerHTML='<div class="model-stat"><span class="label">磁盘配额:</span><span class="val">'+usedMB+'/'+quotaMB+' MB</span></div>'+
|
||||
'<div class="model-stat"><span class="label">已下载:</span><span class="val">'+(data.models||[]).filter(m=>m.downloaded).length+'/'+(data.models||[]).length+'</span></div>';
|
||||
const list=$('#modelList');
|
||||
list.innerHTML='';
|
||||
const active=data.active||{};
|
||||
(data.models||[]).forEach(m=>{
|
||||
const item=document.createElement('div');
|
||||
item.className='model-item';
|
||||
const isActive=active[m.kind]===m.id;
|
||||
const sizeMB=Math.round(m.size/1048576);
|
||||
const memMB=Math.round(m.memory_required/1048576);
|
||||
let actions='';
|
||||
if(m.downloaded){
|
||||
if(isActive){
|
||||
actions='<button class="active" disabled>使用中</button>';
|
||||
}else{
|
||||
actions='<button onclick="switchModel(\''+m.id+'\',\''+m.kind+'\')">切换</button> <button class="danger" onclick="deleteModel(\''+m.id+'\')">删除</button>';
|
||||
}
|
||||
}else{
|
||||
actions='<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>':'')+(isActive?'<span class="tag active">活跃</span>':'')+'</div><div class="desc">'+sizeMB+'MB | 需 '+memMB+'MB 内存 | '+m.recommended_tier+'</div></div><div class="actions">'+actions+'</div>';
|
||||
list.appendChild(item);
|
||||
});
|
||||
}catch(e){
|
||||
$('#modelList').innerHTML='<div style="color:var(--red)">加载失败: '+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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,2000);
|
||||
}catch(e){alert('请求失败: '+e.message);}
|
||||
}
|
||||
|
||||
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('请求失败: '+e.message);}
|
||||
}
|
||||
|
||||
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('请求失败: '+e.message);}
|
||||
}
|
||||
async function loadCharacters(){try{const res=await fetch('/api/config/available');const data=await res.json();const grid=$('#charGrid');grid.innerHTML='';const cfgRes=await fetch('/api/config');const cfg=await cfgRes.json();const char=cfg.character||{};const strat=charStrategies[char.character_type||'pet']||charStrategies.pet;(data.configs||[]).forEach(c=>{const isActive=c===(data.active||'');const card=document.createElement('div');card.className='char-card'+(isActive?' active':'');const 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=()=>switchCharacter(c);grid.appendChild(card);});}catch(e){$('#charGrid').innerHTML='<div style="color:var(--red)">加载失败</div>';}}
|
||||
async function switchCharacter(filename){if(!confirm('切换到 '+filename+' ?'))return;try{const res=await fetch('/api/config/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename})});const data=await res.json();if(data.status==='ok'||(data.message&&data.message.includes('成功'))){alert('已切换');loadCurrentChar();loadCharacters();}else alert('失败: '+(data.message||'未知'));}catch(e){alert('失败: '+e.message);}}
|
||||
async function loadCurrentChar(){try{const res=await fetch('/api/config');const cfg=await res.json();applyCharStrategy(cfg.character||{name:'未知',character_type:'pet',render_type:'video'});}catch(e){console.error(e);}}
|
||||
async function loadModels(){try{const res=await fetch('/api/models');const data=await res.json();const bar=$('#modelBar');const u=Math.round((data.used_space||0)/1048576);const 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(m=>m.downloaded).length+'/'+(data.models||[]).length+'</span></div>';const list=$('#modelList');list.innerHTML='';const active=data.active||{};(data.models||[]).forEach(m=>{const item=document.createElement('div');item.className='model-item';const ia=active[m.kind]===m.id;const s=Math.round(m.size/1048576);const me=Math.round(m.memory_required/1048576);let 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{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('失败');}}
|
||||
loadCurrentChar();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user