feat: AI 控制台回答支持 Markdown 渲染 + 推理链可折叠 + token 用量显示
- markdown.js(新增,无 CDN 依赖):轻量 Markdown 渲染(标题/列表含嵌套/表格/ 代码块/引用/链接…),先 esc() 转义再套标记,模型输出的 HTML 只当文本显示 - agent.js:回答改走 Markdown;推理链改为 <details> 可折叠(流式时展开、正文开始 自动收起、手动点过后不再自动改);单条消息 token 脚注 + 顶栏「本会话累计」 - monitor.html:消息结构加 .reasoning/.agent-usage、顶栏 token 徽标、md 相关样式, 引入 markdown.js(base.js 之后、agent.js 之前) - mcp_agent/agent.py:请求带 stream_options.include_usage,按「每次模型调用」累计 usage(末尾 chunk),on_usage 回调吐累计值;网关不认该参数(400/422/点名)时 自动降级重试一次 - web/agent_api.py:SSE 新增 usage 事件、done 带 usage;推理链与用量随会话落库 (_REASONING_KEEP=6000 截断),回灌模型时只取 role/content - 文档:API.md(usage 事件/done/会话消息字段)、ARCHITECTURE §5.4.1、DEVELOPMENT 前端 JS 清单 自测:假模型端点单测 3/3(正常/降级/多轮累加);Edge headless 全链路 27 项全通过 (真实 Flask+SSE+SQLite,含 XSS 转义、刷新后回看);Markdown 渲染器 18 用例全通过
This commit is contained in:
+131
-17
@@ -120,6 +120,11 @@ function pollRunState(){
|
||||
? '将操作:' + document.getElementById('agent-target-select').value : '');
|
||||
}
|
||||
document.getElementById('btn-agent-send').disabled = running;
|
||||
// 运行中同步 token(本窗口没订阅事件流时也能看到进度)
|
||||
if(running && !_agentStream && r.usage && r.usage.total_tokens){
|
||||
_liveUsage = r.usage;
|
||||
updateSessionTokens();
|
||||
}
|
||||
if(!running && _agentBusy){ setRunning(false); } // 流异常丢失时复位
|
||||
});
|
||||
}
|
||||
@@ -173,11 +178,91 @@ function newAssistantMsg(){
|
||||
clearEmpty();
|
||||
const div = document.createElement('div');
|
||||
div.className = 'agent-msg assistant';
|
||||
div.innerHTML = '<div class="agent-toolcards"></div><div class="agent-text"></div>';
|
||||
// 结构:工具卡片 → 可折叠推理链 → 正文(Markdown)→ token 用量脚注
|
||||
div.innerHTML =
|
||||
'<div class="agent-toolcards"></div>'
|
||||
+ '<details class="reasoning" style="display:none"><summary>💭 思考过程</summary>'
|
||||
+ '<div class="reasoning-text"></div></details>'
|
||||
+ '<div class="agent-text"></div>'
|
||||
+ '<div class="agent-usage" style="display:none"></div>';
|
||||
chat.appendChild(div);
|
||||
scrollChat();
|
||||
return div;
|
||||
}
|
||||
|
||||
// ---------- 推理链(可折叠) ----------
|
||||
function _appendReasoning(msgEl, text){
|
||||
const box = msgEl.querySelector('.reasoning');
|
||||
if(!box) return;
|
||||
const body = box.querySelector('.reasoning-text');
|
||||
body.textContent += text;
|
||||
box.querySelector('summary').textContent =
|
||||
'💭 思考过程(' + body.textContent.length + ' 字)';
|
||||
box.style.display = 'block';
|
||||
_bindReasoning(box);
|
||||
if(!box.open) box.open = true; // 流式思考时展开,正文开始时自动收起
|
||||
}
|
||||
// 用户手动点过 summary 后不再自动收起/展开(尊重手动状态)
|
||||
function _bindReasoning(box){
|
||||
const sum = box.querySelector('summary');
|
||||
if(sum && !sum.dataset.bound){
|
||||
sum.dataset.bound = '1';
|
||||
sum.addEventListener('click', ()=>{ box.dataset.touched = '1'; });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 正文 Markdown 渲染(渲染器见 markdown.js) ----------
|
||||
function _renderMd(msgEl){
|
||||
const el = msgEl.querySelector('.agent-text');
|
||||
if(el) el.innerHTML = renderMarkdown(msgEl._md || '');
|
||||
}
|
||||
function _scheduleMd(msgEl){
|
||||
if(msgEl._mdRaf) return; // 流式增量按帧合并渲染,避免每个 token 重排
|
||||
msgEl._mdRaf = requestAnimationFrame(()=>{
|
||||
msgEl._mdRaf = 0;
|
||||
_renderMd(msgEl);
|
||||
scrollChat();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- token 用量 ----------
|
||||
let _convUsage = {}; // 当前会话历史累计(读会话时算)
|
||||
let _liveUsage = {}; // 本轮运行中累计(SSE usage 事件)
|
||||
function _fmtInt(n){ return String(n || 0).replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
|
||||
function _usageSum(a, b){
|
||||
const o = {prompt_tokens:0, completion_tokens:0, total_tokens:0, calls:0};
|
||||
[a, b].forEach(u=>{
|
||||
if(!u) return;
|
||||
o.prompt_tokens += (u.prompt_tokens || 0);
|
||||
o.completion_tokens += (u.completion_tokens || 0);
|
||||
o.total_tokens += (u.total_tokens || 0);
|
||||
o.calls += (u.calls || 0);
|
||||
});
|
||||
return o;
|
||||
}
|
||||
// 单条消息脚注
|
||||
function renderUsage(msgEl, u){
|
||||
const el = msgEl.querySelector('.agent-usage');
|
||||
if(!el) return;
|
||||
if(!u || !u.total_tokens){ el.style.display = 'none'; return; }
|
||||
el.style.display = 'block';
|
||||
el.title = '输入 ' + _fmtInt(u.prompt_tokens) + ' + 输出 ' + _fmtInt(u.completion_tokens)
|
||||
+ ' = ' + _fmtInt(u.total_tokens) + ' tokens,共 ' + (u.calls || 0) + ' 次模型调用';
|
||||
el.textContent = '🪙 ' + _fmtInt(u.total_tokens) + ' tokens'
|
||||
+ '(↑' + _fmtInt(u.prompt_tokens) + ' ↓' + _fmtInt(u.completion_tokens)
|
||||
+ ' · ' + (u.calls || 0) + ' 次调用)';
|
||||
}
|
||||
// 顶栏「本会话累计」= 已落库历史 + 运行中本轮
|
||||
function updateSessionTokens(){
|
||||
const el = document.getElementById('agent-token-total');
|
||||
if(!el) return;
|
||||
const t = _usageSum(_convUsage, _liveUsage);
|
||||
if(!t.total_tokens){ el.textContent = ''; el.title = ''; return; }
|
||||
el.textContent = '🪙 本会话 ' + _fmtInt(t.total_tokens) + ' tokens';
|
||||
el.title = '本会话累计:输入 ' + _fmtInt(t.prompt_tokens)
|
||||
+ ' + 输出 ' + _fmtInt(t.completion_tokens)
|
||||
+ ' = ' + _fmtInt(t.total_tokens) + ' tokens,共 ' + t.calls + ' 次模型调用';
|
||||
}
|
||||
function clearEmpty(){
|
||||
const empty = document.querySelector('#agent-chat .agent-empty');
|
||||
if(empty) empty.remove();
|
||||
@@ -197,6 +282,9 @@ function bindChatScroll(){
|
||||
}
|
||||
function clearAgentChat(){
|
||||
document.getElementById('agent-chat').innerHTML = '';
|
||||
_convUsage = {}; // 换会话:token 累计重新算
|
||||
_liveUsage = {};
|
||||
updateSessionTokens();
|
||||
}
|
||||
|
||||
// ================== 历史会话(DeepSeek 式:左侧列表,多会话持久化) ==================
|
||||
@@ -293,15 +381,26 @@ function loadCurrentConvMessages(){
|
||||
apiGet('/api/agent/conversations/'+_currentConvId).then(r=>{
|
||||
if(!r||!r.ok)return;
|
||||
chat.innerHTML = '';
|
||||
_convUsage = {};
|
||||
_liveUsage = {};
|
||||
(r.messages||[]).forEach(m=>{
|
||||
const txt = (m.content||'').trim();
|
||||
if(!txt) return;
|
||||
if(m.role==='user'){ addUserMsg(txt); }
|
||||
else{
|
||||
const div = newAssistantMsg();
|
||||
div.querySelector('.agent-text').textContent = txt;
|
||||
if(m.role==='user'){ if(txt) addUserMsg(txt); return; }
|
||||
if(!txt && !m.reasoning && !(m.usage && m.usage.total_tokens)) return;
|
||||
const div = newAssistantMsg();
|
||||
if(txt){ div._md = txt; _renderMd(div); }
|
||||
if(m.reasoning){
|
||||
_appendReasoning(div, m.reasoning);
|
||||
const box = div.querySelector('.reasoning');
|
||||
_bindReasoning(box);
|
||||
box.open = false; // 历史消息默认折叠推理链
|
||||
}
|
||||
if(m.usage){
|
||||
renderUsage(div, m.usage);
|
||||
_convUsage = _usageSum(_convUsage, m.usage);
|
||||
}
|
||||
});
|
||||
updateSessionTokens();
|
||||
if(!chat.children.length){
|
||||
chat.innerHTML = '<div class="agent-empty">新会话。给 AI 下达指令'
|
||||
+ '(先选 🎯 目标设备)…</div>';
|
||||
@@ -379,21 +478,29 @@ function listenStream(runId){
|
||||
es.addEventListener('delta', ev=>{
|
||||
if(!msgEl) msgEl = newAssistantMsg();
|
||||
const d = JSON.parse(ev.data);
|
||||
const textEl = msgEl.querySelector('.agent-text');
|
||||
if(d.kind === 'reasoning'){
|
||||
let r = msgEl.querySelector('.reasoning');
|
||||
if(!r){
|
||||
r = document.createElement('div');
|
||||
r.className = 'reasoning';
|
||||
msgEl.insertBefore(r, textEl);
|
||||
}
|
||||
r.textContent += d.text;
|
||||
_appendReasoning(msgEl, d.text || '');
|
||||
}else{
|
||||
textEl.textContent += d.text;
|
||||
// 正文开始 → 收起推理链(用户手动开过的保持原样)
|
||||
const r = msgEl.querySelector('.reasoning');
|
||||
if(r && r.open && !r.dataset.touched) r.open = false;
|
||||
msgEl._md = (msgEl._md || '') + (d.text || '');
|
||||
msgEl._mdPending = false;
|
||||
_scheduleMd(msgEl);
|
||||
}
|
||||
scrollChat();
|
||||
});
|
||||
|
||||
// 每次模型调用完成 → 实时刷新 token 计数(本轮累计)
|
||||
es.addEventListener('usage', ev=>{
|
||||
if(!msgEl) msgEl = newAssistantMsg();
|
||||
let d = {};
|
||||
try{ d = JSON.parse(ev.data) || {}; }catch(e){}
|
||||
_liveUsage = d;
|
||||
renderUsage(msgEl, d);
|
||||
updateSessionTokens();
|
||||
});
|
||||
|
||||
es.addEventListener('step', ev=>{
|
||||
const d = JSON.parse(ev.data);
|
||||
followSerialFromArgs(d.args);
|
||||
@@ -417,10 +524,16 @@ function listenStream(runId){
|
||||
|
||||
es.addEventListener('done', ev=>{
|
||||
const d = JSON.parse(ev.data);
|
||||
if(d.answer){
|
||||
if(d.answer || d.usage || !msgEl){
|
||||
if(!msgEl) msgEl = newAssistantMsg();
|
||||
msgEl.querySelector('.agent-text').textContent = d.answer;
|
||||
if(d.answer){ msgEl._md = d.answer; _renderMd(msgEl); }
|
||||
if(d.usage) renderUsage(msgEl, d.usage);
|
||||
}
|
||||
// 本轮用量并入会话累计(脚注已展示,顶栏不该因此变小),再清空「运行中」
|
||||
_convUsage = _usageSum(_convUsage, _liveUsage);
|
||||
_liveUsage = {};
|
||||
updateSessionTokens();
|
||||
scrollChat();
|
||||
endRun();
|
||||
renderConvList(); // 落库已完成:刷新标题/时间/轮数
|
||||
});
|
||||
@@ -432,6 +545,7 @@ function listenStream(runId){
|
||||
}catch(e){}
|
||||
if(!msgEl) msgEl = newAssistantMsg();
|
||||
msgEl.querySelector('.agent-text').textContent = '⚠ ' + _friendlyAgentError(msg);
|
||||
_liveUsage = {}; // 本轮可能已花费 token,但结果无效——不并入会话累计
|
||||
endRun();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user