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:
2026-09-10 18:22:20 +08:00
parent 1c2b440dce
commit 4b5b836d31
8 changed files with 477 additions and 31 deletions
+163
View File
@@ -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('');
}