feat(M2.1): Web 对话控制端 (/chat 页面 - 文字对话+角色切换+模型管理)
This commit is contained in:
@@ -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
284
src/plugins/http/chat.html
Normal 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>
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user