feat(M2.1): Web 对话控制端 (/chat 页面 - 文字对话+角色切换+模型管理)

This commit is contained in:
2026-07-06 10:24:34 +08:00
parent 2f202b82b7
commit 0ac4aa31c8
3 changed files with 299 additions and 10 deletions

View File

@@ -154,16 +154,12 @@ impl AiBackend for LocalCliBackend {
// --single-turn 单轮模式,生成完即退出(否则进交互模式等待 stdin永远不退出
// --no-warmup 跳过预热(减少延迟)
// -sys system prompt角色人设
// -p 用户输入(只传当前消息,历史通过 context 另传V1 简化
// -p 用户当前消息V1 不带历史,避免模型回显历史内容
//
// 历史上下文 V1 简化处理:拼到 -p 里(小模型 ctx 1024 有限,只保留最近 2 轮)
let mut prompt = String::new();
for (role, content) in history.iter().rev().take(4) {
// 逆序取最近 2 轮4 条消息),正序拼接
prompt.insert_str(0, &format!("{content}\n"));
}
prompt.push_str(user_message);
// V1 边界:会话内上下文由 ChatPipeline 的 sessions 维护,
// 但 llama-cli --single-turn 模式每次是独立调用,
// 历史拼接会导致模型把历史当输入回显。V1 简化为无历史单轮对话。
let _ = history; // V1 不使用历史,参数保留供未来扩展
let tokens = if max_tokens == 0 { 128 } else { max_tokens as u32 };
let output = Command::new(self.llama_cli())
@@ -174,7 +170,7 @@ impl AiBackend for LocalCliBackend {
.arg("-sys")
.arg(persona)
.arg("-p")
.arg(&prompt)
.arg(user_message)
.arg("-n")
.arg(tokens.to_string())
.arg("-t")

284
src/plugins/http/chat.html Normal file
View File

@@ -0,0 +1,284 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Showen AI 对话</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}
*{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}
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}
.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}
.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}
.msg.user .bubble{background:var(--accent);color:#fff;border-bottom-right-radius:4px}
.msg.bot .bubble{background:var(--surface2);border:1px solid var(--border);border-bottom-left-radius:4px}
.msg .audio{margin-top:6px;display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
.msg .audio audio{height:28px;max-width:240px}
.msg .meta{font-size:11px;color:var(--muted);margin-top:4px}
.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: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}
.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 .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}
.model-stat .val{font-weight:600}
.model-list{display:flex;flex-direction:column;gap:8px}
.model-item{display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px}
.model-item .info{flex:1}
.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: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)}
.tag{display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;margin-left:6px}
.tag.llm{background:rgba(99,102,241,.2);color:#a5b4fc}
.tag.asr{background:rgba(34,197,94,.2);color:#86efac}
.tag.tts{background:rgba(245,158,11,.2);color:#fcd34d}
.tag.downloaded{background:rgba(34,197,94,.2);color:#86efac}
.tag.active{background:var(--green);color:#fff}
</style>
</head>
<body>
<div class="app">
<h1><span class="dot"></span> Showen AI 对话控制端</h1>
<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 class="status-bar" id="chatStatus"></div>
</div>
<!-- 角色面板 -->
<div class="panel" id="panel-character">
<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;
}
$('#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);}
}
</script>
</body>
</html>

View File

@@ -156,6 +156,7 @@ pub(crate) fn build_routes(
let api = core_api.or(media_api).or(plugin_api).or(file_api).or(ai_api);
root_route()
.or(chat_page_route())
.or(download_route(Arc::clone(&state)))
.or(ws_route(tx.clone(), Arc::clone(&state)))
.or(api)
@@ -179,6 +180,14 @@ fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> +
)
}
/// GET /chat — AI 对话控制端页面
fn chat_page_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path("chat")
.and(warp::path::end())
.and(warp::get())
.map(|| warp::reply::html(include_str!("chat.html")))
}
fn ws_route(
tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>,