- 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 用例全通过
164 lines
5.5 KiB
JavaScript
164 lines
5.5 KiB
JavaScript
// 轻量 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('');
|
||
}
|