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();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// 轻量 Markdown 渲染(AI 控制台回答用)——不引外部库/CDN(生产 220 在内网)。
|
||||
// 安全:**先 esc() 转义,再套标记**——模型输出里的原始 HTML 只会以文本显示,
|
||||
// 不会变成可执行标签。
|
||||
// 支持:标题 / 段落 / 换行 / 粗体 / 斜体 / 删除线 / 行内代码 / 围栏代码块 /
|
||||
// 有序与无序列表(含一层以上嵌套)/ 引用 / 表格 / 分隔线 / 链接。
|
||||
// 依赖:esc()(base.js,本文件须在其后加载)
|
||||
|
||||
// 行内代码占位符(控制字符,正文几乎不可能出现)
|
||||
const _MD_PH = '\u0001';
|
||||
|
||||
function _mdInline(s){
|
||||
const codes = [];
|
||||
// 行内代码先摘出,避免其中的 ** * ~~ 被继续当标记解析
|
||||
s = s.replace(/`([^`]+)`/g, (m, c)=>{
|
||||
codes.push(c);
|
||||
return _MD_PH + (codes.length - 1) + _MD_PH;
|
||||
});
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
return s.replace(new RegExp(_MD_PH + '(\\d+)' + _MD_PH, 'g'),
|
||||
(m, i)=>'<code>' + codes[+i] + '</code>');
|
||||
}
|
||||
|
||||
// 表格行 → 单元格数组
|
||||
function _mdCells(line){
|
||||
return line.trim().replace(/^\|/, '').replace(/\|$/, '')
|
||||
.split('|').map(c=>c.trim());
|
||||
}
|
||||
|
||||
// 列表项递归渲染(缩进 = 层级;同类标记合并,类型变了外层另起一个列表)
|
||||
function _mdList(items, out, idx, indent){
|
||||
const ordered = items[idx].ordered;
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
out.push('<' + tag + ' class="md-list">');
|
||||
while(idx < items.length && items[idx].indent >= indent){
|
||||
const it = items[idx];
|
||||
if(it.indent > indent) break; // 更深缩进块由外层另起列表(避免非法嵌套)
|
||||
if(it.ordered !== ordered) break; // 同级但标记类型变了 → 交回外层
|
||||
out.push('<li>' + _mdInline(esc(it.text)));
|
||||
if(idx + 1 < items.length && items[idx + 1].indent > indent){
|
||||
idx = _mdList(items, out, idx + 1, items[idx + 1].indent);
|
||||
out.push('</li>');
|
||||
continue;
|
||||
}
|
||||
out.push('</li>');
|
||||
idx++;
|
||||
}
|
||||
out.push('</' + tag + '>');
|
||||
return idx;
|
||||
}
|
||||
|
||||
// 主入口:Markdown 文本 → HTML 字符串
|
||||
function renderMarkdown(src){
|
||||
const lines = String(src == null ? '' : src).replace(/\r\n?/g, '\n').split('\n');
|
||||
const out = [];
|
||||
let para = [];
|
||||
let i = 0;
|
||||
|
||||
const flushPara = ()=>{
|
||||
if(!para.length) return;
|
||||
out.push('<p>' + para.map(l=>_mdInline(esc(l))).join('<br>') + '</p>');
|
||||
para = [];
|
||||
};
|
||||
|
||||
while(i < lines.length){
|
||||
const ln = lines[i];
|
||||
let m;
|
||||
|
||||
// 围栏代码块 ```lang … ```
|
||||
if(/^\s*```/.test(ln)){
|
||||
flushPara();
|
||||
const body = [];
|
||||
i++;
|
||||
while(i < lines.length && !/^\s*```/.test(lines[i])){ body.push(lines[i]); i++; }
|
||||
if(i < lines.length) i++; // 跳过结束围栏
|
||||
out.push('<pre class="md-code"><code>' + esc(body.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 表格:表头行 + 分隔行(|---|---|)
|
||||
if(/^\s*\|.*\|\s*$/.test(ln) && i + 1 < lines.length
|
||||
&& /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])){
|
||||
flushPara();
|
||||
const head = _mdCells(ln);
|
||||
i += 2;
|
||||
const rows = [];
|
||||
while(i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])){
|
||||
rows.push(_mdCells(lines[i])); i++;
|
||||
}
|
||||
out.push('<table class="md-table"><thead><tr>'
|
||||
+ head.map(c=>'<th>' + _mdInline(esc(c)) + '</th>').join('') + '</tr></thead><tbody>'
|
||||
+ rows.map(r=>'<tr>' + r.map(c=>'<td>' + _mdInline(esc(c)) + '</td>').join('')
|
||||
+ '</tr>').join('')
|
||||
+ '</tbody></table>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 标题 #~######
|
||||
m = ln.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
|
||||
if(m){
|
||||
flushPara();
|
||||
const lv = m[1].length;
|
||||
out.push('<h' + lv + ' class="md-h">' + _mdInline(esc(m[2])) + '</h' + lv + '>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 分隔线 --- / *** / ___
|
||||
if(/^\s{0,3}([-*_])\s*(\1\s*){2,}$/.test(ln)){
|
||||
flushPara(); out.push('<hr>'); i++; continue;
|
||||
}
|
||||
|
||||
// 引用 >
|
||||
if(/^\s{0,3}>\s?/.test(ln)){
|
||||
flushPara();
|
||||
const body = [];
|
||||
while(i < lines.length && /^\s{0,3}>\s?/.test(lines[i])){
|
||||
body.push(lines[i].replace(/^\s{0,3}>\s?/, '')); i++;
|
||||
}
|
||||
out.push('<blockquote class="md-quote">'
|
||||
+ body.map(l=>_mdInline(esc(l))).join('<br>') + '</blockquote>');
|
||||
continue;
|
||||
}
|
||||
|
||||
// 列表(- * + / 1. 1))——含缩进续行
|
||||
m = ln.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
||||
if(m){
|
||||
flushPara();
|
||||
const items = [];
|
||||
while(i < lines.length){
|
||||
const mm = lines[i].match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
|
||||
if(!mm) break;
|
||||
items.push({indent: mm[1].replace(/\t/g, ' ').length,
|
||||
ordered: /\d/.test(mm[2]), text: mm[3]});
|
||||
i++;
|
||||
// 紧跟的纯缩进行算上一条的续行
|
||||
while(i < lines.length && /^\s+\S/.test(lines[i])
|
||||
&& !/^\s*([-*+]|\d+[.)])\s+/.test(lines[i])){
|
||||
items[items.length - 1].text += ' ' + lines[i].trim();
|
||||
i++;
|
||||
}
|
||||
}
|
||||
let k = 0;
|
||||
while(k < items.length){
|
||||
const n = _mdList(items, out, k, items[k].indent);
|
||||
if(n <= k) break; // 防死循环
|
||||
k = n;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 空行 = 段落分隔
|
||||
if(!ln.trim()){ flushPara(); i++; continue; }
|
||||
|
||||
para.push(ln);
|
||||
i++;
|
||||
}
|
||||
flushPara();
|
||||
return out.join('');
|
||||
}
|
||||
Reference in New Issue
Block a user