Import the pre-repair source tree as the history baseline. Runtime data (data/), virtualenvs, bytecode caches and logs are gitignored so local secrets and user state stay out of the repo.
2112 lines
88 KiB
JavaScript
2112 lines
88 KiB
JavaScript
// ========== 诊断日志(排查流式显示问题的根本手段) ==========
|
||
// ========== JS 控制台桥:console.* 全部捕获进 window.__jslog,Python 每 500ms 抽到控制台 ==========
|
||
(function() {
|
||
try {
|
||
var realLog = console.log.bind(console);
|
||
var realWarn = console.warn.bind(console);
|
||
var realErr = console.error.bind(console);
|
||
window.__jslog = [];
|
||
function fmt(a) {
|
||
if (typeof a === 'string') return a;
|
||
if (typeof a === 'object' && a !== null) {
|
||
try { return JSON.stringify(a); } catch (e) { return String(a); }
|
||
}
|
||
return String(a);
|
||
}
|
||
function push(level, args) {
|
||
var str = '';
|
||
for (var i = 0; i < args.length; i++) str += (i ? ' ' : '') + fmt(args[i]);
|
||
window.__jslog.push('[' + level + '] ' + str);
|
||
if (window.__jslog.length > 500) window.__jslog.splice(0, window.__jslog.length - 500);
|
||
}
|
||
console.log = function() { push('log', Array.prototype.slice.call(arguments)); realLog.apply(null, arguments); };
|
||
console.warn = function() { push('warn', Array.prototype.slice.call(arguments)); realWarn.apply(null, arguments); };
|
||
console.error = function() { push('err', Array.prototype.slice.call(arguments)); realErr.apply(null, arguments); };
|
||
window.__jslogDrain = function() {
|
||
if (!window.__jslog || !window.__jslog.length) return '';
|
||
var out = window.__jslog.join('\n');
|
||
window.__jslog = [];
|
||
return out;
|
||
};
|
||
} catch (e) {}
|
||
})();
|
||
window.__APP_VER = '20260721-v7';
|
||
console.log('[JS] ===== app.js 加载 ver=20260721-v7 =====');
|
||
// 公式渲染依赖本地 KaTeX(离线);此处确认资源加载结果,缺失时打印告警便于定位
|
||
console.log('[JS] KaTeX ' + (typeof katex !== 'undefined' ? katex.version + ' 就绪' : '缺失(公式将退化为纯文本)'));
|
||
// 渲染器主线程心跳:dt 异常大 = 渲染器被阻塞(截图/重绘/GPU 等)
|
||
(function() {
|
||
var _hbLast = Date.now();
|
||
setInterval(function() {
|
||
var _now = Date.now();
|
||
var _dt = _now - _hbLast;
|
||
_hbLast = _now;
|
||
if (_dt >= 1500) {
|
||
console.log('[JS] 心跳 dt=' + _dt + 'ms (渲染器主线程曾卡顿)');
|
||
}
|
||
}, 1000);
|
||
})();
|
||
window.__diag = { events: [], cap: 300, tokenN: 0, thinkN: 0 };
|
||
function diagEvent(name, extra) {
|
||
try {
|
||
var d = window.__diag;
|
||
d.events.push({ t: Date.now() % 1000000, e: name, x: extra });
|
||
if (d.events.length > d.cap) d.events.splice(0, d.events.length - d.cap);
|
||
} catch (e) {}
|
||
}
|
||
function dumpDiag() {
|
||
try {
|
||
var c = {};
|
||
var last = {};
|
||
var evs = window.__diag.events;
|
||
for (var i = 0; i < evs.length; i++) {
|
||
var e = evs[i].e;
|
||
c[e] = (c[e] || 0) + 1;
|
||
last[e] = evs[i].x; // 每类最后一条
|
||
}
|
||
return JSON.stringify({ ver: window.__APP_VER, n: evs.length,
|
||
counts: c, last: last,
|
||
recent: evs.slice(-30) });
|
||
} catch (e) { return '{}'; }
|
||
}
|
||
|
||
// ★ 实时 DOM 体检:缓冲区字符 vs DOM 实际字符 vs 可见性
|
||
// 返回每个正文段/思考段的 {b:缓冲, d:DOM, h:高度, op:透明度, dis:display}
|
||
function probeStream(msgId) {
|
||
try {
|
||
var out = { id: msgId, buf: false, segs: [], thinks: [], raf: 0, due: 0 };
|
||
var buf = messageBuffer[msgId];
|
||
if (!buf) return JSON.stringify(out);
|
||
out.buf = true;
|
||
out.raf = buf.raf ? 1 : 0;
|
||
out.due = buf.rafDue ? 1 : 0;
|
||
buf.textSegs = buf.textSegs || [];
|
||
buf.thinkSegs = buf.thinkSegs || [];
|
||
var i, el;
|
||
for (i = 0; i < buf.textSegs.length; i++) {
|
||
el = buf.textSegs[i];
|
||
var cs = (el.ownerDocument.defaultView.getComputedStyle)
|
||
? el.ownerDocument.defaultView.getComputedStyle(el) : null;
|
||
out.segs.push({
|
||
b: (el.__buf || '').length,
|
||
d: (el.textContent || '').length,
|
||
h: el.offsetHeight,
|
||
op: cs ? cs.opacity : '?',
|
||
dis: cs ? cs.display : '?',
|
||
child: el.childNodes.length
|
||
});
|
||
}
|
||
for (i = 0; i < buf.thinkSegs.length; i++) {
|
||
el = buf.thinkSegs[i];
|
||
out.thinks.push({
|
||
b: (el.__buf || '').length,
|
||
d: (el.textContent || '').length,
|
||
h: el.offsetHeight
|
||
});
|
||
}
|
||
return JSON.stringify(out);
|
||
} catch (e) {
|
||
return JSON.stringify({ err: String(e) });
|
||
}
|
||
}
|
||
|
||
var chatContainer = document.getElementById('chat-container');
|
||
var messageBuffer = {};
|
||
var longTextStore = {};
|
||
var finalContentStore = {};
|
||
var attachmentMetaStore = {};
|
||
function isNearBottom() {
|
||
return (window.innerHeight + window.scrollY) >= (document.body.scrollHeight - 150);
|
||
}
|
||
|
||
// ==================== 自定义滚动条镜像上报 ====================
|
||
function getScroller() {
|
||
return document.scrollingElement || document.documentElement;
|
||
}
|
||
|
||
function webScrollTo(y) {
|
||
getScroller().scrollTop = y;
|
||
}
|
||
|
||
function reportWebScroll() {
|
||
if (window.bridge && window.bridge.onScrollChanged) {
|
||
var se = getScroller();
|
||
window.bridge.onScrollChanged(se.scrollTop, se.scrollHeight, se.clientHeight);
|
||
}
|
||
}
|
||
document.addEventListener('scroll', function() {
|
||
requestAnimationFrame(reportWebScroll);
|
||
}, { passive: true, capture: true });
|
||
window.addEventListener('resize', function() {
|
||
requestAnimationFrame(reportWebScroll);
|
||
});
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
setTimeout(reportWebScroll, 300);
|
||
});
|
||
|
||
// ==================== Markdown 渲染器配置 ====================
|
||
function escapeHtml(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function safeHtml(html) {
|
||
if (typeof window !== 'undefined' && window.DOMPurify) {
|
||
return DOMPurify.sanitize(html, { ADD_TAGS: ['details', 'summary'] });
|
||
}
|
||
return html;
|
||
}
|
||
|
||
var renderer = new marked.Renderer();
|
||
|
||
renderer.code = function(obj) {
|
||
var code = (typeof obj === 'string') ? obj : (obj.text || '');
|
||
var lang = (typeof obj === 'string') ? (arguments[1] || '') : (obj.lang || '');
|
||
var langLabel = lang ? lang : 'code';
|
||
var isSvg = String(lang || '').trim().toLowerCase() === 'svg';
|
||
var svgBtnHtml = isSvg
|
||
? '<button class="svg-render-btn" title="渲染为 SVG 图像(透明背景)">绘制</button>'
|
||
: '';
|
||
var safeCode = escapeHtml(code);
|
||
var safeLang = escapeHtml(lang);
|
||
var safeLangLabel = escapeHtml(langLabel);
|
||
return '<div class="code-block-wrapper">'
|
||
+ '<div class="code-header">'
|
||
+ '<span class="code-lang-label">' + safeLangLabel + '</span>'
|
||
+ '<div class="code-header-actions">'
|
||
+ '<button class="fold-btn">展开</button>'
|
||
+ '<button class="copy-btn">'
|
||
+ '<svg class="icon-copy" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
|
||
+ '<svg class="icon-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none"><polyline points="20 6 9 17 4 12"/></svg>'
|
||
+ '<span class="btn-text">复制</span>'
|
||
+ '</button>'
|
||
+ svgBtnHtml
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<pre class="code-body collapsed"><code class="language-' + safeLang + '">' + safeCode + '</code></pre>'
|
||
+ '</div>';
|
||
};
|
||
|
||
// 流式渲染是全量重渲,围栏未闭合的瞬态或回答中的无围栏 HTML 片段都会被打成活体 DOM。
|
||
// 覆盖 renderer.html,把一切裸 HTML 转义为可见文本。
|
||
renderer.html = function(html) {
|
||
return escapeHtml(html);
|
||
};
|
||
|
||
marked.setOptions({ renderer: renderer, breaks: true });
|
||
|
||
// ==================== 🌟 公式渲染(KaTeX + 部分供应商单括号 [...] 格式) ====================
|
||
//
|
||
// 部分大模型提供商把块公式输出成单括号 [ ... ](非标准 $$...$$),
|
||
// 前端原先无任何公式渲染。统一管线(包在 marked.parse 里,17+ 个调用点
|
||
// ——正文/思考/工具/压缩摘要——自动生效):
|
||
// extractMath: 冻结代码区 → 抽公式为占位符 → 还原代码
|
||
// marked.parse: 正常 markdown 渲染(占位符当纯文本通过)
|
||
// restoreMath: 占位符 → KaTeX HTML(throwOnError=false,流式半截公式红色自愈)
|
||
// safeHtml(DOMPurify): 调用点原有逻辑,KaTeX 产物也过一遍消毒
|
||
//
|
||
// 块定界符: $$...$$、\[...\]、[ ... ](供应商格式,严格启发式防误伤)
|
||
// 行内定界符: $...$(货币/空格守卫)、\(...\)
|
||
|
||
var _mathItems = [];
|
||
|
||
function _mathHasFeature(c) {
|
||
// 数学特征:\ 命令、上下标、等号、矩阵 &、希腊字母
|
||
return /\\[a-zA-Z]/.test(c) || /[_^]/.test(c) || /=/.test(c) || /&/.test(c)
|
||
|| /[\u0370-\u03FF]/.test(c);
|
||
}
|
||
|
||
function _putMath(tex, display) {
|
||
_mathItems.push({ tex: tex, display: display });
|
||
return (display ? '@@KBMA' : '@@KMIA') + (_mathItems.length - 1) + '@@';
|
||
}
|
||
|
||
function _katexRender(tex, display) {
|
||
if (typeof katex === 'undefined') return escapeHtml(tex);
|
||
try {
|
||
return katex.renderToString(tex, { displayMode: display, throwOnError: false, errorColor: '#cf1322' });
|
||
} catch (e) {
|
||
return escapeHtml(tex);
|
||
}
|
||
}
|
||
|
||
function extractMath(text) {
|
||
_mathItems = [];
|
||
// ---- 1) 冻结代码区(围栏含流式未闭合瞬态 + 行内代码):公式提取不碰代码 ----
|
||
var codeFrozen = [];
|
||
var s = String(text).replace(/```[\s\S]*?(?:```|$)|`[^`\n]*`/g, function(m){
|
||
codeFrozen.push(m);
|
||
return '@@KOCE' + (codeFrozen.length - 1) + '@@';
|
||
});
|
||
// ---- 2) 块 $$...$$ ----
|
||
s = s.replace(/\$\$([\s\S]+?)\$\$/g, function(_, tex){ return _putMath(tex, true); });
|
||
// ---- 3) 块 \[...\] ----
|
||
s = s.replace(/\\\[[\s\S]+?\\\]/g, function(m){ return _putMath(m.slice(2, -2), true); });
|
||
// ---- 4) 供应商单括号块 [ ... ](严格启发式)----
|
||
// 判公式条件:行首 [ + 行尾 ] + 后不紧跟 ((排除链接)+ 不含 http
|
||
// + 多行 或 含数学特征;[1] 编号/[a, b] 列表/单行链接全部排除
|
||
{
|
||
var out = '', pos = 0, n = s.length;
|
||
while (pos < n) {
|
||
var nl = s.indexOf('\n', pos);
|
||
var lineEnd = nl === -1 ? n : nl;
|
||
var line = s.substring(pos, lineEnd);
|
||
var m = /^(\s*)\[/.exec(line);
|
||
var consumed = false;
|
||
if (m) {
|
||
var searchFrom = pos + m[0].length;
|
||
var brPos = pos + m[1].length; // '[' 的位置(保留括号前空白,丢括号)
|
||
var closeIdx = -1;
|
||
var p2 = searchFrom;
|
||
while (p2 < n) {
|
||
var nl2 = s.indexOf('\n', p2);
|
||
var le2 = nl2 === -1 ? n : nl2;
|
||
var l2 = s.substring(p2, le2);
|
||
var cm = l2.match(/\](\s*)$/);
|
||
if (cm) { closeIdx = p2 + cm.index; break; }
|
||
p2 = (nl2 === -1) ? n : nl2 + 1;
|
||
}
|
||
if (closeIdx > searchFrom) {
|
||
var content = s.substring(searchFrom, closeIdx);
|
||
var ok = content.trim() !== ''
|
||
&& s.charAt(closeIdx + 1) !== '('
|
||
&& content.indexOf('http') === -1
|
||
&& content.indexOf('@@KOCE') === -1
|
||
&& (content.indexOf('\n') !== -1 || _mathHasFeature(content.trim()));
|
||
if (ok) {
|
||
out += s.substring(pos, brPos) + _putMath(content, true);
|
||
pos = closeIdx + 1;
|
||
consumed = true;
|
||
}
|
||
}
|
||
}
|
||
if (!consumed) {
|
||
out += line + (nl === -1 ? '' : '\n');
|
||
pos = nl === -1 ? n : nl + 1;
|
||
}
|
||
}
|
||
s = out;
|
||
}
|
||
// ---- 5) 行内 \(...\) ----
|
||
s = s.replace(/\\\(([\s\S]+?)\\\)/g, function(_, tex){ return _putMath(tex, false); });
|
||
// ---- 6) 行内 $...$(守卫:首尾无空格、非纯数字、含数学特征 或 纯 ASCII 单词如 $x$)----
|
||
s = s.replace(/\$([^\n$]+?)\$/g, function(full, tex){
|
||
if (/^\s|\s$/.test(tex)) return full; // 首尾空格 → 货币/普通
|
||
if (/^[\d,.\s]+$/.test(tex)) return full; // 纯数字 → 货币
|
||
if (!_mathHasFeature(tex) && !/^[A-Za-z][A-Za-z0-9]*$/.test(tex.trim())) return full;
|
||
return _putMath(tex, false);
|
||
});
|
||
// ---- 7) 还原代码区 ----
|
||
s = s.replace(/@@KOCE(\d+)@@/g, function(_, i){ return codeFrozen[+i]; });
|
||
return { md: s, items: _mathItems };
|
||
}
|
||
|
||
function restoreMath(html, items) {
|
||
items = items || _mathItems;
|
||
for (var i = 0; i < items.length; i++) {
|
||
var tok = (items[i].display ? '@@KBMA' : '@@KMIA') + i + '@@';
|
||
html = html.split(tok).join(_katexRender(items[i].tex, items[i].display));
|
||
}
|
||
return html;
|
||
}
|
||
|
||
// 🌟 统一拦截点:包 marked.parse,所有调用点(正文/流式/思考/工具/摘要)自动获得公式渲染
|
||
var _markedParseRaw = marked.parse.bind(marked);
|
||
marked.parse = function(text, opts) {
|
||
if (typeof text !== 'string') return _markedParseRaw(text, opts);
|
||
// 快速路径:无任何公式特征字符直接过([...] 供应商格式也靠 [ 触发)
|
||
if (text.indexOf('$') === -1 && text.indexOf('\\[') === -1
|
||
&& text.indexOf('\\(') === -1 && text.indexOf('[') === -1) {
|
||
return _markedParseRaw(text, opts);
|
||
}
|
||
var ex = extractMath(text);
|
||
return restoreMath(_markedParseRaw(ex.md, opts), ex.items);
|
||
};
|
||
|
||
// 🌟 流式稳定前缀防切分:前缀内有未闭合公式起点 → 返回应回退到的位置(0=无)
|
||
// (启发式;误判只延迟一帧固化,无功能影响)
|
||
function findUnclosedMathFrom(s) {
|
||
var inFence = false, pos = 0, n = s.length;
|
||
var dollarOpen = -1, bsOpen = -1, parenOpen = -1, inlineOpen = -1, bracketOpen = -1;
|
||
while (pos < n) {
|
||
var nl = s.indexOf('\n', pos);
|
||
var lineEnd = nl === -1 ? n : nl + 1;
|
||
var line = s.substring(pos, lineEnd);
|
||
if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
|
||
if (!inFence) {
|
||
var i2 = 0;
|
||
while ((i2 = line.indexOf('$$', i2)) !== -1) {
|
||
dollarOpen = (dollarOpen === -1) ? (pos + i2) : -1;
|
||
i2 += 2;
|
||
}
|
||
var ib = line.indexOf('\\[');
|
||
if (ib !== -1) bsOpen = (bsOpen === -1) ? (pos + ib) : -1;
|
||
if (line.indexOf('\\]') !== -1) bsOpen = -1;
|
||
var ip = line.indexOf('\\(');
|
||
if (ip !== -1) parenOpen = (parenOpen === -1) ? (pos + ip) : -1;
|
||
if (line.indexOf('\\)') !== -1) parenOpen = -1;
|
||
for (var k = 0; k < line.length; k++) {
|
||
if (line.charAt(k) === '$'
|
||
&& line.charAt(k + 1) !== '$'
|
||
&& (k === 0 || line.charAt(k - 1) !== '$')) {
|
||
inlineOpen = (inlineOpen === -1) ? (pos + k) : -1;
|
||
}
|
||
}
|
||
if (bracketOpen !== -1) {
|
||
if (/\]\s*$/.test(line)) bracketOpen = -1;
|
||
} else {
|
||
var mb = /^(\s*)\[/.exec(line);
|
||
if (mb && line.indexOf('](') === -1 && !/\]\s*$/.test(line)) {
|
||
// 行首 [ 未同行闭合、非链接;裸 [ 行或含数学特征才视为公式开
|
||
if (line.trim() === '[' || _mathHasFeature(line)) bracketOpen = pos;
|
||
}
|
||
}
|
||
}
|
||
pos = lineEnd;
|
||
}
|
||
var opens = [];
|
||
if (dollarOpen !== -1) opens.push(dollarOpen);
|
||
if (bsOpen !== -1) opens.push(bsOpen);
|
||
if (parenOpen !== -1) opens.push(parenOpen);
|
||
if (inlineOpen !== -1) opens.push(inlineOpen);
|
||
if (bracketOpen !== -1) opens.push(bracketOpen);
|
||
return opens.length ? Math.min.apply(null, opens) : 0;
|
||
}
|
||
|
||
// ==================== 全局事件委托 ====================
|
||
if (chatContainer) {
|
||
chatContainer.addEventListener('click', function(e) {
|
||
var target = e.target;
|
||
|
||
// --- 1. 消息操作栏:复制整条消息 ---
|
||
var copyMsgBtn = target.closest('.copy-msg-btn');
|
||
if (copyMsgBtn) {
|
||
var actionBar = copyMsgBtn.closest('.message-actions');
|
||
if (actionBar) {
|
||
var targetMsgId = actionBar.getAttribute('data-msg-id');
|
||
var rawContent = (typeof finalContentStore !== 'undefined' && finalContentStore[targetMsgId]) || '';
|
||
copyToClipboard(rawContent).then(function() {
|
||
var iconCopy = copyMsgBtn.querySelector('.icon-copy');
|
||
var iconCheck = copyMsgBtn.querySelector('.icon-check');
|
||
if (iconCopy && iconCheck) {
|
||
iconCopy.style.display = 'none';
|
||
iconCheck.style.display = 'inline';
|
||
setTimeout(function() {
|
||
iconCopy.style.display = 'inline';
|
||
iconCheck.style.display = 'none';
|
||
}, 2000);
|
||
}
|
||
}).catch(function(err) {
|
||
console.error('复制失败:', err);
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// --- 2. 消息操作栏:重新回答 ---
|
||
var regenBtn = target.closest('.regenerate-btn');
|
||
if (regenBtn) {
|
||
var actionBar = regenBtn.closest('.message-actions');
|
||
if (actionBar) {
|
||
var targetMsgId = actionBar.getAttribute('data-msg-id');
|
||
if (window.bridge && typeof window.bridge.onRegenerateClicked === 'function') {
|
||
window.bridge.onRegenerateClicked(targetMsgId);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// --- 3. 消息操作栏:压缩对话(预留) ---
|
||
if (target.closest('.compress-btn')) {
|
||
console.log('[JS]: 压缩对话功能预留');
|
||
return;
|
||
}
|
||
|
||
// --- 4. 代码块复制 ---
|
||
var copyBtn = target.closest('.copy-btn');
|
||
if (copyBtn) {
|
||
var wrapper = copyBtn.closest('.code-block-wrapper');
|
||
var codeElem = wrapper ? wrapper.querySelector('code') : null;
|
||
if (codeElem) {
|
||
var rawCode = codeElem.textContent;
|
||
copyToClipboard(rawCode).then(function() {
|
||
copyBtn.classList.add('copied');
|
||
var iconCopy = copyBtn.querySelector('.icon-copy');
|
||
var iconCheck = copyBtn.querySelector('.icon-check');
|
||
var btnText = copyBtn.querySelector('.btn-text');
|
||
if (iconCopy) iconCopy.style.display = 'none';
|
||
if (iconCheck) iconCheck.style.display = 'inline';
|
||
if (btnText) btnText.textContent = '已复制';
|
||
setTimeout(function() {
|
||
copyBtn.classList.remove('copied');
|
||
if (iconCopy) iconCopy.style.display = 'inline';
|
||
if (iconCheck) iconCheck.style.display = 'none';
|
||
if (btnText) btnText.textContent = '复制';
|
||
}, 2000);
|
||
}).catch(function(err) {
|
||
console.error('代码复制失败:', err);
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// --- 5. 代码块折叠 ---
|
||
var foldBtn = target.closest('.fold-btn');
|
||
if (foldBtn) {
|
||
var wrapper = foldBtn.closest('.code-block-wrapper');
|
||
var pre = wrapper ? wrapper.querySelector('.code-body') : null;
|
||
if (pre) {
|
||
var isCollapsed = pre.classList.toggle('collapsed');
|
||
foldBtn.textContent = isCollapsed ? '展开' : '收起';
|
||
}
|
||
return;
|
||
}
|
||
|
||
// --- 5.5 SVG 代码块:绘制/源码 切换 ---
|
||
var svgRenderBtn = target.closest('.svg-render-btn');
|
||
if (svgRenderBtn) {
|
||
var svgWrapper = svgRenderBtn.closest('.code-block-wrapper');
|
||
if (svgWrapper && typeof toggleSvgRender === 'function') {
|
||
toggleSvgRender(svgWrapper, svgRenderBtn);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// --- 6. 附件卡片点击 (呼叫 Python 原生窗口) ---
|
||
var card = target.closest('.long-text-card');
|
||
if (card) {
|
||
var attId = card.getAttribute('data-msg-id');
|
||
var meta = attachmentMetaStore[attId];
|
||
if (meta && window.bridge && typeof window.bridge.onAttachmentClicked === 'function') {
|
||
window.bridge.onAttachmentClicked(JSON.stringify(meta));
|
||
} else if (typeof showContentModal === 'function') {
|
||
showContentModal(attId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// --- 7. 分支切换:左侧上一分支 ---
|
||
var prevBranchBtn = target.closest('.prev-branch');
|
||
if (prevBranchBtn && !prevBranchBtn.disabled) {
|
||
var selector = prevBranchBtn.closest('.branch-selector');
|
||
if (selector) {
|
||
var msgId = selector.getAttribute('data-msg-id');
|
||
if (window.bridge && typeof window.bridge.onBranchSwitch === 'function') {
|
||
window.bridge.onBranchSwitch(msgId, -1);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// --- 8. 分支切换:右侧下一分支 ---
|
||
var nextBranchBtn = target.closest('.next-branch');
|
||
if (nextBranchBtn && !nextBranchBtn.disabled) {
|
||
var selector = nextBranchBtn.closest('.branch-selector');
|
||
if (selector) {
|
||
var msgId = selector.getAttribute('data-msg-id');
|
||
if (window.bridge && typeof window.bridge.onBranchSwitch === 'function') {
|
||
window.bridge.onBranchSwitch(msgId, 1);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// --- 9. 优先检查:是否点击了“确认删除气泡”内部 ---
|
||
var confirmPopover = target.closest('.delete-confirm-popover');
|
||
if (confirmPopover) {
|
||
var actionBar = confirmPopover.closest('.message-actions');
|
||
if (actionBar) {
|
||
var targetMsgId = actionBar.getAttribute('data-msg-id');
|
||
if (window.bridge && typeof window.bridge.onDeleteMessageClicked === 'function') {
|
||
window.bridge.onDeleteMessageClicked(targetMsgId);
|
||
}
|
||
}
|
||
confirmPopover.remove();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
|
||
// --- 10. 垃圾桶按钮点击:弹出确认气泡 ---
|
||
var delBtn = target.closest('.delete-btn');
|
||
if (delBtn) {
|
||
document.querySelectorAll('.delete-confirm-popover').forEach(function(el) { el.remove(); });
|
||
var popover = document.createElement('div');
|
||
popover.className = 'delete-confirm-popover';
|
||
var warnText = delBtn.classList.contains('user-delete-btn') ? '将连带下方对话一并删除,确认?' : '确认删除当前回答?';
|
||
popover.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg><span class="delete-confirm-text">' + warnText + '</span>';
|
||
delBtn.appendChild(popover);
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
|
||
});
|
||
}
|
||
document.addEventListener('click', function(e) {
|
||
if (e.target.closest('.modal-close-btn')) { closeContentModal(); return; }
|
||
var overlay = e.target.closest('.content-modal-overlay');
|
||
if (overlay && e.target === overlay) { closeContentModal(); return; }
|
||
document.querySelectorAll('.delete-confirm-popover').forEach(function(el) { el.remove(); });
|
||
});
|
||
|
||
// ==================== 剪贴板 ====================
|
||
function copyToClipboard(text) {
|
||
var ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0';
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
ta.select();
|
||
return new Promise(function(resolve, reject) {
|
||
try { document.execCommand('copy'); resolve(); }
|
||
catch (err) { reject(err); }
|
||
finally { ta.remove(); }
|
||
});
|
||
}
|
||
|
||
// ==================== 模态框 ====================
|
||
function showContentModal(msgId) {
|
||
var text = longTextStore[msgId];
|
||
if (!text) return;
|
||
closeContentModal();
|
||
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'content-modal-overlay';
|
||
|
||
var modal = document.createElement('div');
|
||
modal.className = 'content-modal';
|
||
|
||
var header = document.createElement('div');
|
||
header.className = 'modal-header';
|
||
var title = document.createElement('span');
|
||
title.textContent = '文件内容';
|
||
var closeBtn = document.createElement('button');
|
||
closeBtn.className = 'modal-close-btn';
|
||
closeBtn.innerHTML = '✕';
|
||
header.appendChild(title);
|
||
header.appendChild(closeBtn);
|
||
|
||
var body = document.createElement('pre');
|
||
body.className = 'modal-body';
|
||
body.textContent = text;
|
||
|
||
modal.appendChild(header);
|
||
modal.appendChild(body);
|
||
overlay.appendChild(modal);
|
||
document.body.appendChild(overlay);
|
||
requestAnimationFrame(function() { overlay.classList.add('visible'); });
|
||
}
|
||
|
||
function closeContentModal() {
|
||
var overlay = document.querySelector('.content-modal-overlay');
|
||
if (overlay) {
|
||
overlay.classList.remove('visible');
|
||
setTimeout(function() { overlay.remove(); }, 200);
|
||
}
|
||
}
|
||
|
||
// ==================== 消息操作栏(只定义一次) ====================
|
||
function createActionBar(msgId) {
|
||
var bar = document.createElement('div');
|
||
bar.className = 'message-actions';
|
||
bar.setAttribute('data-msg-id', msgId);
|
||
|
||
var regenBtn = document.createElement('button');
|
||
regenBtn.className = 'action-btn regenerate-btn';
|
||
regenBtn.title = '重新回答';
|
||
regenBtn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
|
||
+ '<path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
||
|
||
var copyMsgBtn = document.createElement('button');
|
||
copyMsgBtn.className = 'action-btn copy-msg-btn';
|
||
copyMsgBtn.title = '复制';
|
||
copyMsgBtn.innerHTML = '<svg class="icon-copy" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">'
|
||
+ '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>'
|
||
+ '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
|
||
+ '<svg class="icon-check" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2" style="display:none">'
|
||
+ '<polyline points="20 6 9 17 4 12"/></svg>';
|
||
|
||
var compressBtn = document.createElement('button');
|
||
compressBtn.className = 'action-btn compress-btn';
|
||
compressBtn.title = '压缩对话';
|
||
compressBtn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
|
||
+ '<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>'
|
||
+ '<line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||
|
||
var deleteBtn = document.createElement('button');
|
||
deleteBtn.className = 'action-btn delete-btn';
|
||
deleteBtn.title = '删除';
|
||
deleteBtn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>';
|
||
deleteBtn.style.position = 'relative';
|
||
|
||
bar.appendChild(regenBtn);
|
||
bar.appendChild(copyMsgBtn);
|
||
bar.appendChild(compressBtn);
|
||
bar.appendChild(deleteBtn);
|
||
|
||
return bar;
|
||
}
|
||
|
||
// ==================== 用户消息专属操作栏 ====================
|
||
function createUserActionBar(msgId) {
|
||
var bar = document.createElement('div');
|
||
bar.className = 'message-actions user-actions';
|
||
bar.setAttribute('data-msg-id', msgId);
|
||
bar.style.cssText = 'display: flex; justify-content: flex-end; margin-top: 8px; gap: 8px;';
|
||
|
||
function createBtn(title, svgContent, isDelete) {
|
||
var btn = document.createElement('button');
|
||
btn.className = 'action-btn ' + (isDelete ? 'delete-btn user-delete-btn' : (title.includes('重') ? 'regenerate-btn' : 'copy-msg-btn'));
|
||
btn.title = title;
|
||
btn.innerHTML = svgContent;
|
||
btn.style.cssText = 'background: transparent; border: none; cursor: pointer; padding: 6px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; transition: background 0.2s;';
|
||
btn.onmouseenter = function() {
|
||
this.style.backgroundColor = isDelete ? '#fee2e2' : '#eaeaea';
|
||
var svgs = this.querySelectorAll('svg');
|
||
svgs.forEach(function(s) {
|
||
if (!s.classList.contains('icon-check')) {
|
||
s.style.stroke = isDelete ? '#ef4444' : '#333333';
|
||
}
|
||
});
|
||
};
|
||
btn.onmouseleave = function() {
|
||
this.style.backgroundColor = 'transparent';
|
||
var svgs = this.querySelectorAll('svg');
|
||
svgs.forEach(function(s) {
|
||
if (!s.classList.contains('icon-check')) {
|
||
s.style.stroke = '#999999';
|
||
}
|
||
});
|
||
};
|
||
return btn;
|
||
}
|
||
|
||
var regenSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999999" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
||
var copySvg = '<svg class="icon-copy" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999999" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2" style="display:none"><polyline points="20 6 9 17 4 12"/></svg>';
|
||
var delSvg = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999999" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>';
|
||
|
||
var regenBtn = createBtn('重新提问', regenSvg, false);
|
||
var copyMsgBtn = createBtn('复制提问', copySvg, false);
|
||
var deleteBtn = createBtn('删除提问', delSvg, true);
|
||
deleteBtn.style.position = 'relative';
|
||
|
||
bar.appendChild(regenBtn);
|
||
bar.appendChild(copyMsgBtn);
|
||
bar.appendChild(deleteBtn);
|
||
|
||
return bar;
|
||
}
|
||
// ==================== 消息创建 ====================
|
||
function createMessage(msgId, role, initialText, senderName, branchInfo) {
|
||
console.log('[JS] createMessage id=' + msgId + ' role=' + role);
|
||
initialText = initialText || '';
|
||
senderName = senderName || '';
|
||
var welcome = document.querySelector('.welcome-screen');
|
||
if (welcome) {
|
||
welcome.style.display = 'none';
|
||
}
|
||
var wrapper = document.createElement('div');
|
||
wrapper.className = 'message-wrapper ' + role;
|
||
wrapper.id = msgId;
|
||
var avatar = document.createElement('div');
|
||
avatar.className = 'avatar';
|
||
avatar.innerText = role === 'user' ? 'U' : 'AI';
|
||
var content = document.createElement('div');
|
||
content.className = 'message-content';
|
||
var nameLabel = document.createElement('div');
|
||
nameLabel.className = 'sender-name';
|
||
nameLabel.innerText = senderName || (role === 'user' ? 'You' : 'Assistant');
|
||
|
||
if (role === 'assistant' && branchInfo && branchInfo.total > 1) {
|
||
var branchUI = document.createElement('div');
|
||
branchUI.className = 'branch-selector';
|
||
branchUI.setAttribute('data-msg-id', msgId);
|
||
var prevBtn = document.createElement('button');
|
||
prevBtn.className = 'branch-btn prev-branch';
|
||
prevBtn.innerHTML = '❮';
|
||
prevBtn.disabled = (branchInfo.current <= 1);
|
||
var textSpan = document.createElement('span');
|
||
textSpan.className = 'branch-text';
|
||
textSpan.innerText = branchInfo.current + ' / ' + branchInfo.total;
|
||
var nextBtn = document.createElement('button');
|
||
nextBtn.className = 'branch-btn next-branch';
|
||
nextBtn.innerHTML = '❯';
|
||
nextBtn.disabled = (branchInfo.current >= branchInfo.total);
|
||
branchUI.appendChild(prevBtn);
|
||
branchUI.appendChild(textSpan);
|
||
branchUI.appendChild(nextBtn);
|
||
nameLabel.appendChild(branchUI);
|
||
}
|
||
content.appendChild(nameLabel);
|
||
var replyDiv = document.createElement('div');
|
||
replyDiv.className = 'reply-content markdown-body';
|
||
|
||
if (initialText) {
|
||
if (role === 'user') {
|
||
replyDiv.innerText = initialText;
|
||
} else {
|
||
// 🆕 助手纯文本消息(无时间线历史路径)也包进 md-segment → 与时间线路径同样有浅灰气泡背景
|
||
var _tHtml = typeof marked !== 'undefined' ? safeHtml(marked.parse(initialText)) : safeHtml(initialText);
|
||
replyDiv.innerHTML = '<div class="md-segment markdown-body">' + _tHtml + '</div>';
|
||
}
|
||
}
|
||
content.appendChild(replyDiv);
|
||
if (role === 'assistant') {
|
||
if (typeof createActionBar === 'function') {
|
||
content.appendChild(createActionBar(msgId));
|
||
}
|
||
if (typeof finalContentStore !== 'undefined') {
|
||
finalContentStore[msgId] = initialText || '';
|
||
}
|
||
} else if (role === 'user') {
|
||
if (typeof createUserActionBar === 'function') {
|
||
content.appendChild(createUserActionBar(msgId));
|
||
}
|
||
if (typeof finalContentStore !== 'undefined') {
|
||
finalContentStore[msgId] = initialText || '';
|
||
}
|
||
}
|
||
wrapper.appendChild(avatar);
|
||
wrapper.appendChild(content);
|
||
|
||
if (typeof chatContainer !== 'undefined') {
|
||
chatContainer.appendChild(wrapper);
|
||
} else {
|
||
document.getElementById('chat-container').appendChild(wrapper);
|
||
}
|
||
if (typeof messageBuffer !== 'undefined') {
|
||
messageBuffer[msgId] = { reasoning: '', content: initialText, follow: true };
|
||
}
|
||
if (role === 'assistant') {
|
||
wrapper.classList.add('streaming');
|
||
}
|
||
diagEvent('createMessage', { id: msgId, role: role, stream: role === 'assistant' });
|
||
if (typeof softScroll === 'function') {
|
||
softScroll();
|
||
}
|
||
}
|
||
|
||
// ==================== 长文本消息 ====================
|
||
function createLongMessage(msgId, role, fullText, senderName, sizeKb) {
|
||
senderName = senderName || 'You';
|
||
var welcome = document.querySelector('.welcome-screen');
|
||
if (welcome) welcome.style.display = 'none';
|
||
|
||
longTextStore[msgId] = fullText;
|
||
var lineCount = fullText.split('\n').length;
|
||
|
||
var wrapper = document.createElement('div');
|
||
wrapper.className = 'message-wrapper ' + role;
|
||
wrapper.id = msgId;
|
||
|
||
var avatar = document.createElement('div');
|
||
avatar.className = 'avatar';
|
||
avatar.innerText = 'U';
|
||
|
||
var content = document.createElement('div');
|
||
content.className = 'message-content';
|
||
|
||
var nameLabel = document.createElement('div');
|
||
nameLabel.className = 'sender-name';
|
||
nameLabel.innerText = senderName;
|
||
content.appendChild(nameLabel);
|
||
|
||
content.appendChild(buildAttachmentCard(msgId, fullText, sizeKb, lineCount));
|
||
|
||
wrapper.appendChild(avatar);
|
||
wrapper.appendChild(content);
|
||
chatContainer.appendChild(wrapper);
|
||
|
||
messageBuffer[msgId] = { reasoning: '', content: '', follow: true };
|
||
softScroll();
|
||
}
|
||
|
||
// ==================== 带附件的用户消息 ====================
|
||
function createUserMessageWithAttachments(msgId, plainText, attachments) {
|
||
var welcome = document.querySelector('.welcome-screen');
|
||
if (welcome) welcome.style.display = 'none';
|
||
|
||
var wrapper = document.createElement('div');
|
||
wrapper.className = 'message-wrapper user';
|
||
wrapper.id = msgId;
|
||
|
||
var avatar = document.createElement('div');
|
||
avatar.className = 'avatar';
|
||
avatar.innerText = 'U';
|
||
|
||
var content = document.createElement('div');
|
||
content.className = 'message-content';
|
||
|
||
var nameLabel = document.createElement('div');
|
||
nameLabel.className = 'sender-name';
|
||
nameLabel.innerText = 'You';
|
||
content.appendChild(nameLabel);
|
||
|
||
var cardsContainer = document.createElement('div');
|
||
cardsContainer.className = 'attachments-container';
|
||
for (var i = 0; i < attachments.length; i++) {
|
||
var att = attachments[i];
|
||
var attId = msgId + '-att-' + i;
|
||
longTextStore[attId] = att.content;
|
||
attachmentMetaStore[attId] = att;
|
||
cardsContainer.appendChild(buildAttachmentCard(attId, att.content, att.size_kb, att.lines, att.name, att.type, att.pages, att.mode));
|
||
}
|
||
content.appendChild(cardsContainer);
|
||
|
||
if (plainText && plainText.trim()) {
|
||
var replyDiv = document.createElement('div');
|
||
replyDiv.className = 'reply-content markdown-body';
|
||
replyDiv.innerText = plainText;
|
||
content.appendChild(replyDiv);
|
||
}
|
||
|
||
content.appendChild(createUserActionBar(msgId));
|
||
|
||
wrapper.appendChild(avatar);
|
||
wrapper.appendChild(content);
|
||
chatContainer.appendChild(wrapper);
|
||
|
||
messageBuffer[msgId] = { reasoning: '', content: '', follow: true };
|
||
softScroll();
|
||
}
|
||
|
||
// ==================== 附件卡片构建器 ====================
|
||
function buildAttachmentCard(id, text, sizeKb, lineCount, fileName, attType, pages, mode) {
|
||
var card = document.createElement('div');
|
||
card.className = 'long-text-card';
|
||
card.setAttribute('data-msg-id', id);
|
||
|
||
var icon = document.createElement('div');
|
||
icon.className = 'card-icon';
|
||
icon.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="1.5">'
|
||
+ '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>'
|
||
+ '<polyline points="14 2 14 8 20 8"/>'
|
||
+ '<line x1="16" y1="13" x2="8" y2="13"/>'
|
||
+ '<line x1="16" y1="17" x2="8" y2="17"/></svg>';
|
||
if (attType === 'pdf') {
|
||
icon.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="#fff1f0" stroke="#cf1322" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
|
||
+ '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>'
|
||
+ '<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>'
|
||
+ '<line x1="9" y1="7" x2="16" y2="7"/>'
|
||
+ '<line x1="9" y1="11" x2="14" y2="11"/></svg>';
|
||
}
|
||
|
||
var info = document.createElement('div');
|
||
info.className = 'card-info';
|
||
var cardTitle = document.createElement('div');
|
||
cardTitle.className = 'card-title';
|
||
if (fileName) {
|
||
cardTitle.textContent = fileName;
|
||
} else {
|
||
var preview = text.substring(0, 40).replace(/\n/g, ' ');
|
||
if (text.length > 40) preview += '...';
|
||
cardTitle.textContent = preview;
|
||
}
|
||
var cardMeta = document.createElement('div');
|
||
cardMeta.className = 'card-meta';
|
||
if (attType === 'pdf') {
|
||
if (mode === 'image') {
|
||
cardMeta.textContent = 'PDF 图片 · ' + sizeKb + ' KB · ' + (pages || 0) + ' 页 · ' + (lineCount || 0) + ' 张';
|
||
} else {
|
||
cardMeta.textContent = 'PDF 文本 · ' + sizeKb + ' KB · ' + (pages || 0) + ' 页';
|
||
}
|
||
} else {
|
||
cardMeta.textContent = (fileName ? '' : 'TXT · ') + sizeKb + ' KB · ' + lineCount + ' 行';
|
||
}
|
||
|
||
info.appendChild(cardTitle);
|
||
info.appendChild(cardMeta);
|
||
card.appendChild(icon);
|
||
card.appendChild(info);
|
||
return card;
|
||
}
|
||
|
||
// ==================== SVG 代码块:绘制/源码 切换 ====================
|
||
function toggleSvgRender(wrapper, btn) {
|
||
var codeElem = wrapper.querySelector('.code-body code');
|
||
var foldBtn = wrapper.querySelector('.fold-btn');
|
||
var isRenderMode = wrapper.classList.contains('svg-mode');
|
||
|
||
if (isRenderMode) {
|
||
wrapper.classList.remove('svg-mode');
|
||
btn.textContent = '绘制';
|
||
btn.classList.remove('active');
|
||
if (foldBtn) foldBtn.disabled = false;
|
||
var renderBody = wrapper.querySelector('.svg-render-body');
|
||
if (renderBody) renderBody.remove();
|
||
} else {
|
||
if (!codeElem) return;
|
||
var raw = codeElem.textContent;
|
||
var safe = '';
|
||
if (typeof DOMPurify !== 'undefined') {
|
||
safe = DOMPurify.sanitize(raw, { USE_PROFILES: { svg: true, svgCustom: true } });
|
||
}
|
||
if (!safe) return;
|
||
var body = document.createElement('div');
|
||
body.className = 'svg-render-body';
|
||
body.innerHTML = safe;
|
||
wrapper.appendChild(body);
|
||
wrapper.classList.add('svg-mode');
|
||
btn.textContent = '源码';
|
||
btn.classList.add('active');
|
||
if (foldBtn) foldBtn.disabled = true;
|
||
}
|
||
}
|
||
|
||
// ==================== 流式渲染(长上下文优化) ====================
|
||
// 🌟 核心优化:稳定前缀增量渲染 + rAF 批量,替代「每个 token 全量 marked.parse」。
|
||
//
|
||
// 原理:
|
||
// - 把已流式内容切成 [稳定前缀 | 尾部] 两段。
|
||
// - 稳定前缀 = 可以独立解析的完整 markdown 块(段落以空行结束、
|
||
// 代码围栏已闭合、行内代码反引号配对),解析一次后固化进 .md-stable,
|
||
// 之后永不再碰。
|
||
// - 尾部(正在生长的那个块)每个 rAF 帧重渲一次。
|
||
// - 多个 token 在一帧内到达时只渲一次(原来每 token 一次全量 parse → O(n²);
|
||
// 现在每帧只 parse「尾部小段」+ 偶尔 parse「新完成的块」→ 近似 O(n))。
|
||
//
|
||
// function computeSafeLen(s, stableLen) {
|
||
// 返回 s 中最长「可固化前缀」长度(> stableLen 才算有进展)。
|
||
// 规则:
|
||
// 1) 只算到最后一个完整行(末尾半行永远留尾部)
|
||
// 2) 代码围栏(```/~~~)闭合前,围栏及其所在块全部留尾部
|
||
// 3) 块边界 = 围栏外的空行、或刚闭合的围栏之后
|
||
// 4) 稳定区内行内代码反引号必须成对(不成对则回退到该行行首)
|
||
// }
|
||
function computeSafeLen(s, stableLen) {
|
||
var lastNl = s.lastIndexOf('\n');
|
||
var limit = lastNl >= 0 ? lastNl + 1 : 0;
|
||
if (limit <= stableLen) return 0;
|
||
|
||
var inFence = false;
|
||
var candidate = 0; // 最新的块边界位置(行首索引)
|
||
var pos = 0;
|
||
while (pos < limit) {
|
||
var nl = s.indexOf('\n', pos);
|
||
if (nl === -1 || nl >= limit) break;
|
||
var line = s.substring(pos, nl);
|
||
var next = nl + 1;
|
||
var fence = /^\s*(```|~~~)/.test(line);
|
||
if (!inFence && fence) {
|
||
inFence = true;
|
||
} else if (inFence && fence) {
|
||
inFence = false;
|
||
candidate = next; // 围栏闭合后是块边界
|
||
} else if (!inFence && line.trim() === '') {
|
||
candidate = next; // 空行是块边界
|
||
}
|
||
pos = next;
|
||
}
|
||
if (candidate > stableLen) {
|
||
var seg = s.substring(0, candidate);
|
||
var bt = (seg.match(/`/g) || []).length;
|
||
if (bt % 2 === 1) {
|
||
// 行内代码未闭合 → 回退到该段最后一行的行首
|
||
var ln = seg.lastIndexOf('\n');
|
||
candidate = ln >= 0 ? ln + 1 : 0;
|
||
}
|
||
// 🌟 公式感知:稳定区内有未闭合公式起点 → 回退到起点之前
|
||
//(防公式前半被固化为纯文本、后半在尾部永远拼不上)
|
||
var unc = findUnclosedMathFrom(seg);
|
||
if (unc > 0) candidate = Math.min(candidate, unc);
|
||
}
|
||
return candidate > stableLen ? candidate : 0;
|
||
}
|
||
|
||
// 初始化/获取某个容器 div 的增量渲染状态
|
||
function mdStateOf(el) {
|
||
if (!el.__mdState) {
|
||
el.innerHTML = '';
|
||
var stableEl = document.createElement('div');
|
||
stableEl.className = 'md-stable';
|
||
var tailEl = document.createElement('div');
|
||
tailEl.className = 'md-tail';
|
||
el.appendChild(stableEl);
|
||
el.appendChild(tailEl);
|
||
el.__mdState = { stableEl: stableEl, tailEl: tailEl,
|
||
stableLen: 0, lastTail: null, hlCount: 0 };
|
||
}
|
||
return el.__mdState;
|
||
}
|
||
|
||
// 对新固化进 .md-stable 的代码块做渐进高亮(只处理新增的)
|
||
function highlightNewCode(st) {
|
||
if (typeof hljs === 'undefined') return;
|
||
var codes = st.stableEl.querySelectorAll('pre code');
|
||
for (var i = st.hlCount; i < codes.length; i++) {
|
||
try { hljs.highlightElement(codes[i]); } catch (e) {}
|
||
}
|
||
st.hlCount = codes.length;
|
||
}
|
||
|
||
// 增量渲染:content 全量文本 → [固化段追加 + 尾部重渲]
|
||
function renderMarkdownStreaming(el, content) {
|
||
var st = mdStateOf(el);
|
||
var safe = computeSafeLen(content, st.stableLen);
|
||
if (safe > st.stableLen) {
|
||
var seg = content.slice(st.stableLen, safe);
|
||
st.stableEl.insertAdjacentHTML('beforeend', safeHtml(marked.parse(seg)));
|
||
st.stableLen = safe;
|
||
highlightNewCode(st);
|
||
}
|
||
var tail = content.slice(st.stableLen);
|
||
if (tail !== st.lastTail) {
|
||
st.tailEl.innerHTML = safeHtml(marked.parse(tail));
|
||
st.lastTail = tail;
|
||
}
|
||
}
|
||
|
||
// ==================== 时间线渲染(思考 / 工具 / 正文 按事件顺序穿插) ====================
|
||
//
|
||
// 🌟 修复:原来「所有思考堆进顶部一个气泡、所有正文挤进一个块、
|
||
// 工具气泡全堆在末尾」。现在 .reply-content 就是时间线容器,
|
||
// 思考块 / 正文段 / 工具 chip 按事件到达顺序插入:
|
||
//
|
||
// .reply-content
|
||
// .think-block ← 思考段 1
|
||
// .tool-chip ← 工具调用 1
|
||
// .md-segment ← 正文段 1(工具前模型说的话)
|
||
// .think-block ← 思考段 2
|
||
// .md-segment ← 正文段 2(最终回答)
|
||
// .streaming-typing ← 光标(永远最后)
|
||
|
||
var CHEVRON_SVG = '<svg class="chev" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">'
|
||
+ '<path d="M5.5 3.5 L10.5 8 L5.5 12.5" fill="none" stroke="currentColor" '
|
||
+ 'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||
|
||
function makeChevron() {
|
||
var span = document.createElement('span');
|
||
span.innerHTML = CHEVRON_SVG;
|
||
return span.firstElementChild;
|
||
}
|
||
|
||
function makeThinkBlock(openNow, label) {
|
||
var block = document.createElement('details');
|
||
block.className = 'think-block';
|
||
block.open = !!openNow;
|
||
var summary = document.createElement('summary');
|
||
summary.appendChild(makeChevron());
|
||
var lab = document.createElement('span');
|
||
lab.className = 'think-label';
|
||
lab.textContent = label;
|
||
summary.appendChild(lab);
|
||
var tc = document.createElement('div');
|
||
tc.className = 'think-content markdown-body';
|
||
block.appendChild(summary);
|
||
block.appendChild(tc);
|
||
return block;
|
||
}
|
||
|
||
// 惰性把消息的 .reply-content 变成时间线容器
|
||
function ensureTimeline(wrapper, buf) {
|
||
var replyDiv = wrapper.querySelector('.reply-content');
|
||
if (!replyDiv) return null;
|
||
if (!buf.timeline) {
|
||
buf.timeline = true;
|
||
buf.textSegs = [];
|
||
buf.thinkSegs = [];
|
||
var typing = document.createElement('span');
|
||
typing.className = 'streaming-typing';
|
||
typing.textContent = 'Chasing a shining star ';
|
||
var dotEl = document.createElement('span');
|
||
dotEl.className = 'typing-dot';
|
||
dotEl.textContent = '●';
|
||
typing.appendChild(dotEl);
|
||
replyDiv.appendChild(typing);
|
||
buf.typingEl = typing;
|
||
}
|
||
return replyDiv;
|
||
}
|
||
|
||
// ==================== 流式光标:Chasing a shining star ====================
|
||
// 光标(.streaming-typing)自消息第一个时间线事件出现即显示并循环脉冲(同款蓝 #3b82f6),
|
||
// 思考/工具/压缩/正文全程保持;finishMessage 时移除。无休眠态。
|
||
|
||
// 「思考结束」时立即就地固化进行中的思考块(不等消息结束)—— 激发→结束的态转换点:
|
||
// 一旦来了非思考事件(正文 token / 工具开始 / 压缩开始),当前思考段立即:
|
||
// 最终渲染(未闭合代码围栏补围栏)→ 删 __buf/__mdState 冻结 DOM → 去 .streaming-think
|
||
// (蓝色脉冲消失)→ 折叠 → 标签「已完成深度思考」,并从 thinkSegs 摘除(渲染不再碰它)。
|
||
// 与 finishTimelineMessage 的固化逻辑同款(幂等兑底保留)。
|
||
function finalizeOpenThinkingBlocks(msgId) {
|
||
var buf = messageBuffer[msgId];
|
||
if (!buf || !buf.thinkSegs) return;
|
||
for (var i = buf.thinkSegs.length - 1; i >= 0; i--) {
|
||
var tc = buf.thinkSegs[i];
|
||
var block = tc.closest ? tc.closest('.think-block') : null;
|
||
if (!block || !block.classList.contains('streaming-think')) continue;
|
||
var c = tc.__buf || '';
|
||
if ((c.match(/```/g) || []).length % 2 !== 0) {
|
||
tc.innerHTML = safeHtml('<div class="think-inner">' +
|
||
marked.parse(c + '\n```') + '</div>');
|
||
} else if (c && tc.innerHTML === '') {
|
||
// rAF 还没跑过(极快结束)→ 一次性全量解析
|
||
tc.innerHTML = safeHtml('<div class="think-inner">' +
|
||
marked.parse(c) + '</div>');
|
||
}
|
||
delete tc.__mdState;
|
||
delete tc.__buf;
|
||
block.classList.remove('streaming-think');
|
||
if (block.open) block.open = false;
|
||
var lab = block.querySelector('.think-label');
|
||
if (lab) lab.textContent = '已完成深度思考';
|
||
buf.thinkSegs.splice(i, 1);
|
||
}
|
||
}
|
||
|
||
// rAF 批量:一帧内到达的所有 token 只触发一次渲染
|
||
// 兜底:rAF 依赖合成器 BeginFrame(窗口隐藏/远程桌面/GPU 驱动异常时可能长时间
|
||
// 不出帧)→ 40ms 后若 rAF 仍未触发,用 setTimeout 强制同步渲染(≈25fps 下限)。
|
||
// 保证流式正文在任何渲染环境下都能实时显示。
|
||
function scheduleStreamingRender(msgId) {
|
||
var buf = messageBuffer[msgId];
|
||
if (!buf || buf.raf) return;
|
||
buf.raf = requestAnimationFrame(function() {
|
||
buf.raf = 0;
|
||
if (buf.rafDue) { clearTimeout(buf.rafDue); buf.rafDue = 0; }
|
||
buf.lastRenderAt = Date.now();
|
||
doStreamingRender(msgId);
|
||
});
|
||
buf.rafDue = setTimeout(function() {
|
||
if (buf.raf) {
|
||
buf.raf = 0;
|
||
buf.rafDue = 0;
|
||
buf.lastRenderAt = Date.now();
|
||
doStreamingRender(msgId);
|
||
}
|
||
}, 40);
|
||
}
|
||
|
||
// 若上一帧迟迟未触发(>60ms 无渲染)→ 放弃 rAF,本 token 直接同步渲染
|
||
function forceRenderIfStale(msgId) {
|
||
var buf = messageBuffer[msgId];
|
||
if (buf && buf.raf && buf.lastRenderAt && Date.now() - buf.lastRenderAt > 60) {
|
||
buf.raf = 0;
|
||
if (buf.rafDue) { clearTimeout(buf.rafDue); buf.rafDue = 0; }
|
||
buf.lastRenderAt = Date.now();
|
||
doStreamingRender(msgId);
|
||
}
|
||
}
|
||
|
||
// ★ 终极兜底:同步渲染节流(≈33fps 上限)。
|
||
// 内容可见性不再依赖 rAF / 页面定时器 —— token 由 Python runJavaScript 送达,
|
||
// 本函数在同一 JS 任务内直接把内容写进 DOM。即使页面被判定隐藏
|
||
// (rAF 停发 + timer 钳制 1Hz)也能逐 token 实时显示。
|
||
var __lastSyncRender = {};
|
||
function syncRenderThrottled(msgId, minGapMs) {
|
||
var now = Date.now();
|
||
if (now - (__lastSyncRender[msgId] || 0) < (minGapMs == null ? 30 : minGapMs)) return;
|
||
__lastSyncRender[msgId] = now;
|
||
var buf = messageBuffer[msgId];
|
||
if (!buf) return;
|
||
doStreamingRender(msgId);
|
||
}
|
||
|
||
// Qt 看门狗入口:强制渲染(幂等,尾部无变化时开销极小)
|
||
function forceRenderNow(msgId) {
|
||
var _frNow = Date.now();
|
||
if (!window.__frLast || _frNow - window.__frLast > 1000) {
|
||
window.__frLast = _frNow;
|
||
console.log('[JS] forceRenderNow id=' + msgId + ' (看门狗)');
|
||
}
|
||
if (messageBuffer[msgId]) doStreamingRender(msgId);
|
||
}
|
||
|
||
function cancelStreamingRender(msgId) {
|
||
var buf = messageBuffer[msgId];
|
||
if (!buf) return;
|
||
if (buf.raf) { cancelAnimationFrame(buf.raf); buf.raf = 0; }
|
||
if (buf.rafDue) { clearTimeout(buf.rafDue); buf.rafDue = 0; }
|
||
}
|
||
|
||
// 每帧一次:增量渲染时间线上所有段 + 跟随滚动
|
||
// 兜底:任何渲染异常(marked 解析/HTML 注入)→ 对该段强制全量重解析,
|
||
// 保证流式正文永不因单次解析异常而永久空白。
|
||
function _fullRenderSegment(el, c, isThink) {
|
||
if (!c) return;
|
||
if (isThink) {
|
||
el.innerHTML = safeHtml('<div class="think-inner">' + marked.parse(c) + '</div>');
|
||
} else {
|
||
el.innerHTML = safeHtml(marked.parse(c));
|
||
}
|
||
delete el.__mdState; // 增量状态作废,下一帧从零重建
|
||
}
|
||
function doStreamingRender(msgId) {
|
||
window.__renderN = (window.__renderN || 0) + 1;
|
||
if (window.__renderN % 10 === 1) {
|
||
console.log('[JS] render#' + window.__renderN + ' 开始 id=' + msgId);
|
||
}
|
||
var buf = messageBuffer[msgId];
|
||
if (!buf) return;
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) return;
|
||
var wasNearBottom = isNearBottom();
|
||
|
||
if (buf.timeline) {
|
||
var i, seg, tc;
|
||
for (i = 0; i < buf.textSegs.length; i++) {
|
||
seg = buf.textSegs[i];
|
||
try {
|
||
renderMarkdownStreaming(seg, seg.__buf || '');
|
||
} catch (e) {
|
||
try { _fullRenderSegment(seg, seg.__buf || '', false); } catch (e2) {}
|
||
}
|
||
}
|
||
for (i = 0; i < buf.thinkSegs.length; i++) {
|
||
tc = buf.thinkSegs[i];
|
||
try {
|
||
renderMarkdownStreaming(tc, tc.__buf || '');
|
||
tc.scrollTop = tc.scrollHeight;
|
||
} catch (e) {
|
||
try { _fullRenderSegment(tc, tc.__buf || '', true); } catch (e2) {}
|
||
}
|
||
}
|
||
} else if (buf.content) {
|
||
var replyDiv = wrapper.querySelector('.reply-content');
|
||
if (replyDiv) {
|
||
try {
|
||
renderMarkdownStreaming(replyDiv, buf.content);
|
||
} catch (e) {
|
||
try { _fullRenderSegment(replyDiv, buf.content, false); } catch (e2) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 跟随滚动(每帧最多一次;用户上滚自动暂停跟随)
|
||
if (wasNearBottom && buf.follow !== false) {
|
||
var anchor = document.getElementById('scroll-anchor');
|
||
if (anchor) anchor.scrollIntoView({ behavior: 'auto', block: 'end' });
|
||
}
|
||
}
|
||
|
||
function appendReasoning(msgId, token) {
|
||
if (!messageBuffer[msgId]) return;
|
||
var buf = messageBuffer[msgId];
|
||
buf.reasoning += token;
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) { diagEvent('appendReasoning', 'NO_WRAPPER:' + msgId); return; }
|
||
window.__diag.thinkN++;
|
||
if (window.__diag.thinkN === 1 || window.__diag.thinkN % 25 === 0) {
|
||
diagEvent('appendReasoning', 'n=' + window.__diag.thinkN);
|
||
}
|
||
wrapper.classList.add('streaming');
|
||
var tl = ensureTimeline(wrapper, buf);
|
||
if (!tl) return;
|
||
// 续接规则:紧邻上一个块是「进行中」的思考块才续写,否则新开一段
|
||
var last = buf.typingEl.previousElementSibling;
|
||
var tc = (last && last.classList && last.classList.contains('streaming-think'))
|
||
? last.querySelector('.think-content') : null;
|
||
if (!tc) {
|
||
// 默认收起;进行中由 .streaming-think 蓝色呼吸动画提示(CSS)
|
||
var block = makeThinkBlock(false, '深度思考');
|
||
block.classList.add('streaming-think');
|
||
tl.insertBefore(block, buf.typingEl);
|
||
tc = block.querySelector('.think-content');
|
||
buf.thinkSegs.push(tc);
|
||
}
|
||
tc.__buf = (tc.__buf || '') + token;
|
||
console.log('[JS] think#' + window.__diag.thinkN + ' 段buf=' + tc.__buf.length + 'c dom=' + (tc.textContent || '').length + 'c h=' + tc.offsetHeight);
|
||
syncRenderThrottled(msgId); // ★ 同步通道
|
||
scheduleStreamingRender(msgId);
|
||
}
|
||
|
||
function appendToken(msgId, token) {
|
||
if (!messageBuffer[msgId]) return;
|
||
var buf = messageBuffer[msgId];
|
||
buf.content += token;
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) { diagEvent('appendToken', 'NO_WRAPPER:' + msgId); return; }
|
||
window.__diag.tokenN++;
|
||
if (window.__diag.tokenN === 1 || window.__diag.tokenN % 25 === 0) {
|
||
diagEvent('appendToken', 'n=' + window.__diag.tokenN);
|
||
}
|
||
wrapper.classList.add('streaming');
|
||
var tl = ensureTimeline(wrapper, buf);
|
||
if (!tl) return;
|
||
finalizeOpenThinkingBlocks(msgId); // 首个正文 token → 思考必然已结束:思考段就地定格「已完成深度思考」
|
||
// 续接规则:紧邻上一个块是正文段才续写,否则新开一段
|
||
var last = buf.typingEl.previousElementSibling;
|
||
var seg = (last && last.classList && last.classList.contains('md-segment'))
|
||
? last : null;
|
||
if (!seg) {
|
||
seg = document.createElement('div');
|
||
seg.className = 'md-segment markdown-body';
|
||
buf.textSegs.push(seg);
|
||
seg.__buf = token;
|
||
// ★ 根因修复(2026-07):Chromium 布局失效 bug —— 空 .md-segment 进文档时
|
||
// 匹配 :empty{display:none},随后写入内容不重排,盒永久 0x0(正文不可见,
|
||
// 切会话重渲因"带内容插入"而正常)。必须先同步渲染、带内容再插入。
|
||
mdStateOf(seg);
|
||
renderMarkdownStreaming(seg, seg.__buf);
|
||
tl.insertBefore(seg, buf.typingEl);
|
||
} else {
|
||
seg.__buf = (seg.__buf || '') + token;
|
||
}
|
||
console.log('[JS] token#' + window.__diag.tokenN + ' 段buf=' + seg.__buf.length + 'c dom=' + (seg.textContent || '').length + 'c 段数=' + buf.textSegs.length + ' h=' + seg.offsetHeight);
|
||
if (window.__diag.tokenN === 1 || window.__diag.tokenN % 25 === 0) {
|
||
diagEvent('tokenDOM', { buf: seg.__buf.length, dom: (seg.textContent || '').length });
|
||
}
|
||
syncRenderThrottled(msgId); // ★ 同步通道:token 到 → 内容必现
|
||
scheduleStreamingRender(msgId); // rAF 通道:更平滑(环境允许时)
|
||
}
|
||
|
||
// ==================== 完成消息 ====================
|
||
function finishMessage(msgId) {
|
||
console.log('[JS] finishMessage id=' + msgId);
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) { delete messageBuffer[msgId]; diagEvent('finish', 'NO_WRAPPER:' + msgId); return; }
|
||
diagEvent('finish', { id: msgId });
|
||
var buf = messageBuffer[msgId];
|
||
cancelStreamingRender(msgId);
|
||
|
||
if (buf && buf.timeline) {
|
||
// ---- 时间线路径:各段原地冻结(不重排、不重渲) ----
|
||
finishTimelineMessage(wrapper, buf);
|
||
} else {
|
||
// ---- 静态路径(历史 / 非流式) ----
|
||
// --- A. 修复未闭合 Markdown(围栏奇数 → 补围栏后全量重渲一次)---
|
||
var content = buf ? buf.content : '';
|
||
var replyDiv = wrapper.querySelector('.reply-content');
|
||
var codeBlockCount = (content.match(/```/g) || []).length;
|
||
if (codeBlockCount % 2 !== 0) {
|
||
content += '\n```';
|
||
if (buf) buf.content = content;
|
||
if (replyDiv) {
|
||
delete replyDiv.__mdState;
|
||
replyDiv.innerHTML = safeHtml(marked.parse(content));
|
||
}
|
||
} else if (replyDiv && replyDiv.__mdState) {
|
||
// 正常路径:增量 DOM 已经完整,零全量重渲,保留用户的折叠状态
|
||
delete replyDiv.__mdState;
|
||
}
|
||
|
||
// --- D. 折叠思考框(压缩气泡 .compaction-think 有自己名字,不碰) ---
|
||
var thinkBlock = wrapper.querySelector('.think-block:not(.compaction-think)');
|
||
if (thinkBlock && thinkBlock.open) {
|
||
thinkBlock.open = false;
|
||
var lab = thinkBlock.querySelector('.think-label');
|
||
if (lab) lab.textContent = '已完成深度思考';
|
||
}
|
||
if (thinkBlock) {
|
||
var tc = thinkBlock.querySelector('.think-content');
|
||
if (tc && tc.__mdState) delete tc.__mdState;
|
||
}
|
||
}
|
||
|
||
// --- B. 折叠代码块过渡 ---
|
||
var collapsedBlocks = wrapper.querySelectorAll('.code-body.collapsed');
|
||
collapsedBlocks.forEach(function(block) {
|
||
block.classList.add('scroll-locked');
|
||
block.scrollTop = block.scrollHeight;
|
||
});
|
||
|
||
// --- C. 移除 streaming(操作栏自动显示) ---
|
||
wrapper.classList.remove('streaming');
|
||
|
||
// --- E. 代码高亮(增量渲染已渐进高亮过的会被跳过?这里统一兜底一次) ---
|
||
wrapper.querySelectorAll('pre code').forEach(function(block) {
|
||
try { hljs.highlightElement(block); } catch(e) {}
|
||
});
|
||
|
||
// --- F. 等重排完成 ---
|
||
requestAnimationFrame(function() {
|
||
setTimeout(function() {
|
||
collapsedBlocks.forEach(function(block) {
|
||
if (block.classList.contains('collapsed')) {
|
||
block.classList.remove('scroll-locked');
|
||
block.scrollTop = block.scrollHeight;
|
||
}
|
||
});
|
||
var anchor = document.getElementById('scroll-anchor');
|
||
if (anchor) anchor.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||
}, 50);
|
||
});
|
||
|
||
// --- G. 更新最终内容存储 + 清理帧任务 ---
|
||
if (buf) {
|
||
finalContentStore[msgId] = buf.content;
|
||
if (buf.raf) { cancelAnimationFrame(buf.raf); buf.raf = 0; }
|
||
}
|
||
delete messageBuffer[msgId];
|
||
|
||
// --- H. 刷新自定义滚动条 ---
|
||
reportWebScroll();
|
||
}
|
||
|
||
// 时间线路径的收尾:逐段冻结
|
||
function finishTimelineMessage(wrapper, buf) {
|
||
console.log('[JS] finishTimeline 正文段=' + (buf ? buf.textSegs.length : 0) +
|
||
' 思考段=' + (buf ? buf.thinkSegs.length : 0) +
|
||
' 正文总量=' + (buf && buf.content ? buf.content.length : 0) + 'c');
|
||
// 收尾:取消未触发的 rAF / 兜底定时器
|
||
if (buf.raf) { cancelAnimationFrame(buf.raf); buf.raf = 0; }
|
||
if (buf.rafDue) { clearTimeout(buf.rafDue); buf.rafDue = 0; }
|
||
// 正文段:围栏奇数 → 补围栏后重渲该段;偶数 → 增量 DOM 已完整,只固化尾部
|
||
buf.textSegs.forEach(function(seg) {
|
||
var c = seg.__buf || '';
|
||
if ((c.match(/```/g) || []).length % 2 !== 0) {
|
||
seg.innerHTML = safeHtml(marked.parse(c + '\n```'));
|
||
} else if (seg.__mdState) {
|
||
renderMarkdownStreaming(seg, c);
|
||
delete seg.__mdState;
|
||
} else if (c) {
|
||
// rAF 还没跑过(极快响应 / offscreen)→ 一次性全量解析
|
||
seg.innerHTML = safeHtml(marked.parse(c));
|
||
}
|
||
delete seg.__buf;
|
||
});
|
||
// 思考块:固化 + 折叠
|
||
buf.thinkSegs.forEach(function(tc) {
|
||
var c = tc.__buf || '';
|
||
if ((c.match(/```/g) || []).length % 2 !== 0) {
|
||
tc.innerHTML = safeHtml(marked.parse(c + '\n```'));
|
||
} else if (c && tc.innerHTML === '') {
|
||
// rAF 还没跑过(极快响应 / offscreen)→ 一次性全量解析
|
||
tc.innerHTML = safeHtml('<div class="think-inner">' +
|
||
marked.parse(c) + '</div>');
|
||
}
|
||
delete tc.__mdState;
|
||
delete tc.__buf;
|
||
var block = tc.closest('.think-block');
|
||
if (block) {
|
||
block.classList.remove('streaming-think');
|
||
if (block.open) block.open = false;
|
||
var lab = block.querySelector('.think-label');
|
||
if (lab) lab.textContent = '已完成深度思考';
|
||
}
|
||
});
|
||
// 未结束的工具 chip(中止/出错)
|
||
wrapper.querySelectorAll('.tool-chip.streaming').forEach(function(ch) {
|
||
ch.classList.remove('streaming');
|
||
var st = ch.querySelector('.tool-chip-status');
|
||
if (st && st.classList.contains('running')) {
|
||
st.classList.remove('running');
|
||
st.classList.add('fail');
|
||
st.textContent = '✗ 已中断';
|
||
}
|
||
});
|
||
// 移除流式光标
|
||
if (buf.typingEl && buf.typingEl.parentNode) {
|
||
buf.typingEl.parentNode.removeChild(buf.typingEl);
|
||
}
|
||
// finish 后正文段体检(人肉 debug:确认正文盒是否可见)
|
||
buf.textSegs.forEach(function(seg, i) {
|
||
try {
|
||
var cs = window.getComputedStyle(seg);
|
||
var r = seg.getBoundingClientRect();
|
||
console.log('[JS] finishAudit 正文段' + i +
|
||
' h=' + seg.offsetHeight + ' connected=' + seg.isConnected +
|
||
' rect=' + Math.round(r.top) + '/' + Math.round(r.height) +
|
||
' display=' + cs.display + ' visibility=' + cs.visibility +
|
||
' opacity=' + cs.opacity +
|
||
' parent=' + (seg.parentElement ? seg.parentElement.className : 'NULL'));
|
||
} catch (e) {
|
||
console.log('[JS] finishAudit 正文段' + i + ' 异常 ' + e);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ==================== 错误注入 ====================
|
||
function showError(msgId, errorText) {
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) return;
|
||
var contentDiv = wrapper.querySelector('.message-content');
|
||
|
||
if (contentDiv.querySelector('.system-error')) return;
|
||
|
||
var errDiv = document.createElement('div');
|
||
errDiv.className = 'system-error';
|
||
errDiv.innerText = errorText;
|
||
|
||
var actionBar = contentDiv.querySelector('.message-actions');
|
||
if (actionBar) {
|
||
contentDiv.insertBefore(errDiv, actionBar);
|
||
} else {
|
||
contentDiv.appendChild(errDiv);
|
||
}
|
||
|
||
wrapper.classList.remove('streaming');
|
||
|
||
if (messageBuffer[msgId]) {
|
||
finalContentStore[msgId] = messageBuffer[msgId].content;
|
||
}
|
||
|
||
softScroll();
|
||
}
|
||
|
||
// ==================== 工具执行气泡(pi tool_execution_* 事件,按 call_id 对号入座) ====================
|
||
// 浅色气泡,类似思考气泡:默认收起,摘要只显示「执行了什么」;点开展开参数+输出
|
||
function _toolBrief(name, args) {
|
||
// 从参数里提取一行人类可读的简述(如 bash 的 command、read 的 path)
|
||
try {
|
||
var a = JSON.parse(args);
|
||
var brief = a.command || a.path || a.file || a.pattern || a.query || '';
|
||
if (typeof brief !== 'string') brief = JSON.stringify(brief);
|
||
brief = String(brief).replace(/\s+/g, ' ').trim();
|
||
if (brief.length > 46) brief = brief.slice(0, 46) + '…';
|
||
return brief;
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function _findChip(wrapper, callId) {
|
||
if (callId) {
|
||
var byId = wrapper.querySelector('.tool-chip[data-call-id="' + callId + '"]');
|
||
if (byId) return byId;
|
||
}
|
||
// 兜底:没带 call_id 的旧事件 → 最后一个还在流式中的 chip
|
||
var chips = wrapper.querySelectorAll('.tool-chip.streaming');
|
||
return chips.length ? chips[chips.length - 1] : null;
|
||
}
|
||
|
||
// 工具 chip 构造器(流式/恢复/历史 三种场景共用)
|
||
// opts: {streaming: bool, ok: null|true|false, result: str}
|
||
function buildToolChip(callId, name, args, opts) {
|
||
opts = opts || {};
|
||
var chip = document.createElement('details');
|
||
chip.className = 'tool-chip' + (opts.streaming ? ' streaming' : '');
|
||
chip.setAttribute('data-tool', name);
|
||
chip.setAttribute('data-call-id', callId || '');
|
||
chip.open = false; // 默认收起,只显示执行了什么
|
||
|
||
var sum = document.createElement('summary');
|
||
sum.appendChild(makeChevron());
|
||
var icon = document.createElement('span');
|
||
icon.className = 'tool-chip-icon';
|
||
icon.textContent = '⚙';
|
||
var nameSpan = document.createElement('span');
|
||
nameSpan.className = 'tool-chip-name';
|
||
nameSpan.textContent = name;
|
||
var brief = document.createElement('span');
|
||
brief.className = 'tool-chip-brief';
|
||
brief.textContent = _toolBrief(name, args);
|
||
var status = document.createElement('span');
|
||
if (opts.ok === null || opts.ok === undefined) {
|
||
status.className = 'tool-chip-status running';
|
||
status.textContent = '执行中…';
|
||
} else if (opts.ok) {
|
||
status.className = 'tool-chip-status ok';
|
||
status.textContent = '✓ 完成';
|
||
if (opts.result) {
|
||
var fl = String(opts.result).split('\n')[0].replace(/\s+/g, ' ').trim();
|
||
if (fl.length > 60) fl = fl.slice(0, 60) + '…';
|
||
if (fl) { brief.textContent = fl; brief.classList.add('done'); }
|
||
}
|
||
} else {
|
||
status.className = 'tool-chip-status fail';
|
||
status.textContent = '✗ 失败';
|
||
}
|
||
sum.appendChild(icon);
|
||
sum.appendChild(nameSpan);
|
||
if (brief.textContent) sum.appendChild(brief);
|
||
sum.appendChild(status);
|
||
|
||
var body = document.createElement('div');
|
||
body.className = 'tool-chip-body';
|
||
if (args) {
|
||
var argsLabel = document.createElement('div');
|
||
argsLabel.className = 'tool-chip-label';
|
||
argsLabel.textContent = '参数';
|
||
var argsPre = document.createElement('pre');
|
||
argsPre.className = 'tool-chip-pre tool-chip-args';
|
||
argsPre.textContent = String(args).length > 1500 ? String(args).slice(0, 1500) + '…' : String(args);
|
||
body.appendChild(argsLabel);
|
||
body.appendChild(argsPre);
|
||
}
|
||
var liveLabel = document.createElement('div');
|
||
liveLabel.className = 'tool-chip-label';
|
||
liveLabel.textContent = '输出';
|
||
var live = document.createElement('pre');
|
||
live.className = 'tool-chip-pre tool-chip-live';
|
||
body.appendChild(liveLabel);
|
||
body.appendChild(live);
|
||
|
||
chip.appendChild(sum);
|
||
chip.appendChild(body);
|
||
chip.__sum = sum;
|
||
chip.__liveBuf = '';
|
||
if (opts.ok === null || opts.ok === undefined || !opts.result) {
|
||
// 运行中(或无结果):输出区暂隐
|
||
live.textContent = '执行中…';
|
||
live.style.display = 'none';
|
||
liveLabel.style.display = 'none';
|
||
} else {
|
||
// 已出结果:长结果默认尾部预览 + 「展开完整输出」按钮;
|
||
// 耗时/超时徽章进摘要行
|
||
_renderToolResultBody(chip, String(opts.result));
|
||
_updateChipTimingSummary(chip, String(opts.result));
|
||
}
|
||
return chip;
|
||
}
|
||
|
||
// 从 bash 结果解析耗时与命中超时:
|
||
// 成功: "$ cmd\n<out>\n[exit 0] (1.2s)" 超时: "命令超时(>120s)已终止"
|
||
function _parseToolTiming(resultText) {
|
||
var s = String(resultText || '');
|
||
var dur = null, timeout = null;
|
||
var m = /\[exit -?\d+\]\s*\((\d+(?:\.\d+)?)s\)/.exec(s);
|
||
if (m) dur = parseFloat(m[1]);
|
||
var t = /命令超时(>\s*(\d+)s)/.exec(s); // 工具输出用全角括号
|
||
if (t) timeout = parseInt(t[1], 10);
|
||
return { dur: dur, timeout: timeout };
|
||
}
|
||
|
||
// 摘要行插入 耗时/超时 徽章(收起时也能看到执行了多久、是否超时)
|
||
function _updateChipTimingSummary(chip, resultText) {
|
||
var tm = _parseToolTiming(resultText);
|
||
if (tm.dur === null && tm.timeout === null) return;
|
||
var sum = chip.__sum || chip.querySelector('summary');
|
||
if (!sum) return;
|
||
var status = sum.querySelector('.tool-chip-status');
|
||
var insertAt = status || sum.lastElementChild;
|
||
if (tm.dur !== null) {
|
||
var timeEl = chip.querySelector('.tool-chip-time');
|
||
if (!timeEl) {
|
||
timeEl = document.createElement('span');
|
||
timeEl.className = 'tool-chip-time';
|
||
sum.insertBefore(timeEl, insertAt);
|
||
}
|
||
timeEl.textContent = '⏱ ' + tm.dur + 's';
|
||
}
|
||
if (tm.timeout !== null) {
|
||
var tmoEl = chip.querySelector('.tool-chip-timeout');
|
||
if (!tmoEl) {
|
||
tmoEl = document.createElement('span');
|
||
tmoEl.className = 'tool-chip-timeout';
|
||
sum.insertBefore(tmoEl, insertAt);
|
||
}
|
||
tmoEl.textContent = '⏱ 超时 ' + tm.timeout + 's';
|
||
}
|
||
}
|
||
|
||
// 工具结果正文:短结果完整显示;长结果默认只显示尾部 4000 字 +
|
||
// 「展开完整输出」按钮(再点收起)。chip.__fullResult 保存全文。
|
||
var TOOL_PREVIEW_CHARS = 4000;
|
||
function _updateExpandBtn(btn, full, expanded) {
|
||
btn.textContent = expanded
|
||
? '⬇ 收起(回到尾部预览)'
|
||
: '⬆ 展开完整输出(共 ' + full.length + ' 字)';
|
||
}
|
||
function _renderToolResultBody(chip, resultText) {
|
||
var live = chip.querySelector('.tool-chip-live');
|
||
if (!live) return;
|
||
var label = live.previousElementSibling;
|
||
var body = live.parentElement;
|
||
var full = String(resultText || '');
|
||
chip.__fullResult = full;
|
||
chip.__expanded = false;
|
||
var btn = chip.querySelector('.tool-expand-btn');
|
||
if (!full.trim()) {
|
||
if (label) label.style.display = 'none';
|
||
live.style.display = 'none';
|
||
if (btn) btn.remove();
|
||
return;
|
||
}
|
||
if (label) label.style.display = 'block';
|
||
live.style.display = 'block';
|
||
live.classList.remove('tool-chip-full');
|
||
if (full.length > TOOL_PREVIEW_CHARS) {
|
||
live.textContent = '…\n' + full.slice(-TOOL_PREVIEW_CHARS);
|
||
if (!btn) {
|
||
btn = document.createElement('button');
|
||
btn.className = 'tool-expand-btn';
|
||
btn.type = 'button';
|
||
body.insertBefore(btn, live);
|
||
}
|
||
_updateExpandBtn(btn, full, false);
|
||
btn.onclick = function() {
|
||
chip.__expanded = !chip.__expanded;
|
||
if (chip.__expanded) {
|
||
live.textContent = full;
|
||
live.classList.add('tool-chip-full');
|
||
_updateExpandBtn(btn, full, true);
|
||
} else {
|
||
live.textContent = '…\n' + full.slice(-TOOL_PREVIEW_CHARS);
|
||
live.classList.remove('tool-chip-full');
|
||
_updateExpandBtn(btn, full, false);
|
||
}
|
||
};
|
||
} else {
|
||
if (btn) btn.remove();
|
||
live.textContent = full;
|
||
}
|
||
}
|
||
|
||
function toolExecutionStarted(msgId, callId, name, args) {
|
||
console.log('[JS] tool开始 name=' + name + ' call=' + callId + ' id=' + msgId);
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) return;
|
||
var contentDiv = wrapper.querySelector('.message-content');
|
||
var buf = messageBuffer[msgId];
|
||
|
||
var chip = buildToolChip(callId, name, args, {streaming: true, ok: null});
|
||
|
||
// 🌟 插入到时间线当前位置(思考/正文之间,按事件顺序);
|
||
// 非流式消息(无 messageBuffer)退回气泡底部
|
||
finalizeOpenThinkingBlocks(msgId); // 工具开始 → 思考段就地定格「已完成深度思考」
|
||
var replyDiv = contentDiv.querySelector('.reply-content');
|
||
if (replyDiv && buf) {
|
||
ensureTimeline(wrapper, buf);
|
||
replyDiv.insertBefore(chip, buf.typingEl);
|
||
} else {
|
||
var actionBar = contentDiv.querySelector('.message-actions');
|
||
if (actionBar) contentDiv.insertBefore(chip, actionBar);
|
||
else contentDiv.appendChild(chip);
|
||
}
|
||
scheduleStreamingRender(msgId); // 触发一次滚动
|
||
}
|
||
|
||
function toolExecutionUpdated(msgId, callId, text) {
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) return;
|
||
var chip = _findChip(wrapper, callId);
|
||
if (!chip) return;
|
||
chip.__liveBuf = (chip.__liveBuf || '') + text;
|
||
if (chip.__liveBuf.length > 4000) {
|
||
chip.__liveBuf = chip.__liveBuf.slice(-4000);
|
||
}
|
||
// 只有展开时才实时刷新输出(收起时零 DOM 开销)
|
||
if (chip.open) {
|
||
var live = chip.querySelector('.tool-chip-live');
|
||
live.style.display = 'block';
|
||
live.textContent = chip.__liveBuf;
|
||
live.previousElementSibling.style.display = 'block';
|
||
var body = chip.querySelector('.tool-chip-body');
|
||
body.scrollTop = body.scrollHeight;
|
||
}
|
||
}
|
||
|
||
function toolExecutionTimed(msgId, callId, elapsed, timeout) {
|
||
// 🆕 bash 运行中每秒读秒(Python 每秒推一次 = 唯一事实源):
|
||
// 摘要行显示 ⏱ 0/10s → 1/10s → 2/10s ...
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) return;
|
||
var chip = _findChip(wrapper, callId);
|
||
if (!chip) return;
|
||
var t = chip.querySelector('.tool-chip-livetimer');
|
||
if (!t) {
|
||
t = document.createElement('span');
|
||
t.className = 'tool-chip-livetimer';
|
||
var sum = chip.__sum || chip.querySelector('summary');
|
||
if (sum) sum.appendChild(t);
|
||
}
|
||
t.textContent = '\u23f1 ' + elapsed + '/' + timeout + 's';
|
||
}
|
||
|
||
function toolExecutionFinished(msgId, callId, name, ok, text) {
|
||
console.log('[JS] tool完成 name=' + name + ' ok=' + ok + ' 结果=' + (text ? text.length : 0) + 'c call=' + callId);
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) return;
|
||
var chip = _findChip(wrapper, callId);
|
||
if (!chip) return;
|
||
// 🆕 读秒停止:移除运行中计时,换成完成/超时徽章(_updateChipTimingSummary)
|
||
var _lt = chip.querySelector('.tool-chip-livetimer');
|
||
if (_lt) _lt.remove();
|
||
chip.classList.remove('streaming');
|
||
var status = chip.querySelector('.tool-chip-status');
|
||
var outText = text || chip.__liveBuf || '';
|
||
if (status) {
|
||
status.classList.remove('running');
|
||
status.classList.add(ok ? 'ok' : 'fail');
|
||
status.textContent = ok ? '✓ 完成' : '✗ 失败';
|
||
}
|
||
// 摘要显示结果首行(收起时也能看到「执行了什么 + 得到什么」)
|
||
var brief = chip.querySelector('.tool-chip-brief');
|
||
if (ok && brief && outText) {
|
||
var firstLine = outText.split('\n')[0].replace(/\s+/g, ' ').trim();
|
||
if (firstLine.length > 60) firstLine = firstLine.slice(0, 60) + '…';
|
||
brief.textContent = firstLine;
|
||
brief.classList.add('done');
|
||
}
|
||
// 完整结果入正文 —— 不再依赖 chip 当前是否展开(修复「结束后展开为空」bug);
|
||
// 长结果默认显示尾部 4000 字 + 「展开完整输出」按钮
|
||
if (outText) {
|
||
_renderToolResultBody(chip, outText);
|
||
_updateChipTimingSummary(chip, outText);
|
||
}
|
||
scheduleStreamingRender(msgId);
|
||
}
|
||
|
||
// ==================== 时间线恢复 / 历史渲染(持久化的 agent 时间线) ====================
|
||
// entries: [{"t":"think","text"}, {"t":"text","text"},
|
||
// {"t":"tool","id","name","args","ok":null|bool,"result"}]
|
||
// liveMode=true → 切回进行中的会话:块按流式状态建立,后续 token 无缝续流
|
||
// liveMode=false → 历史消息:静态渲染(思考折叠 / chip 定格 / 文本完整解析)
|
||
function renderTimelineEntries(msgId, entries, liveMode) {
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) return;
|
||
var contentDiv = wrapper.querySelector('.message-content');
|
||
var replyDiv = contentDiv.querySelector('.reply-content');
|
||
if (!replyDiv) return;
|
||
var buf = messageBuffer[msgId];
|
||
|
||
if (liveMode && buf) {
|
||
ensureTimeline(wrapper, buf);
|
||
} else if (!liveMode) {
|
||
// 静态:清掉 createMessage 预先渲染的聚合文本(时间线接管展示)
|
||
replyDiv.innerHTML = '';
|
||
}
|
||
|
||
var textAll = '', thinkAll = '';
|
||
entries.forEach(function(e) {
|
||
if (e.t === 'think') {
|
||
var block = makeThinkBlock(false, liveMode ? '深度思考' : '已完成深度思考');
|
||
var tc = block.querySelector('.think-content');
|
||
if (liveMode) {
|
||
block.classList.add('streaming-think');
|
||
tc.__buf = e.text || '';
|
||
buf.thinkSegs.push(tc);
|
||
replyDiv.insertBefore(block, buf.typingEl);
|
||
} else {
|
||
tc.innerHTML = safeHtml('<div class="think-inner">' +
|
||
marked.parse(e.text || '') + '</div>');
|
||
replyDiv.appendChild(block);
|
||
}
|
||
thinkAll += e.text || '';
|
||
} else if (e.t === 'text') {
|
||
var seg = document.createElement('div');
|
||
seg.className = 'md-segment markdown-body';
|
||
if (liveMode) {
|
||
seg.__buf = e.text || '';
|
||
buf.textSegs.push(seg);
|
||
if (seg.__buf) {
|
||
// ★ 同 appendToken:带内容再插入,规避 :empty 布局失效
|
||
mdStateOf(seg);
|
||
renderMarkdownStreaming(seg, seg.__buf);
|
||
}
|
||
replyDiv.insertBefore(seg, buf.typingEl);
|
||
} else {
|
||
seg.innerHTML = safeHtml(marked.parse(e.text || ''));
|
||
replyDiv.appendChild(seg);
|
||
}
|
||
textAll += e.text || '';
|
||
} else if (e.t === 'tool') {
|
||
var chip = buildToolChip(e.id || '', e.name, e.args || '', {
|
||
streaming: liveMode && e.ok === null,
|
||
ok: e.ok === null ? null : !!e.ok,
|
||
result: e.result || ''
|
||
});
|
||
if (liveMode) replyDiv.insertBefore(chip, buf.typingEl);
|
||
else replyDiv.appendChild(chip);
|
||
}
|
||
});
|
||
|
||
if (liveMode && buf) {
|
||
// 缓冲区对齐:后续流式事件在此基础上继续累积(复制/入库不丢内容)
|
||
buf.content = textAll;
|
||
buf.reasoning = thinkAll;
|
||
// ★ 恢复的内容立即同步上屏(不等待 rAF/timer)
|
||
syncRenderThrottled(msgId, 0);
|
||
scheduleStreamingRender(msgId);
|
||
}
|
||
diagEvent(liveMode ? 'restore' : 'history',
|
||
{ id: msgId, entries: entries.length, text: textAll.length,
|
||
think: thinkAll.length, buf: !!buf });
|
||
}
|
||
|
||
// 切回进行中的会话:恢复时间线(后续 token 无缝续流)
|
||
function restoreStreamingTimeline(msgId, timelineJson) {
|
||
try { console.log('[JS] restoreTimeline id=' + msgId + ' 条目=' + (JSON.parse(timelineJson) || []).length); } catch (e) {}
|
||
try {
|
||
var entries = JSON.parse(timelineJson);
|
||
if (Array.isArray(entries) && entries.length) {
|
||
renderTimelineEntries(msgId, entries, true);
|
||
}
|
||
} catch (e) {
|
||
console.error('[JS]: restoreStreamingTimeline 解析失败', e);
|
||
}
|
||
}
|
||
|
||
// 历史消息:静态时间线渲染
|
||
function renderTimelineHistory(msgId, timelineJson) {
|
||
try { console.log('[JS] historyTimeline id=' + msgId + ' 条目=' + (JSON.parse(timelineJson) || []).length); } catch (e) {}
|
||
try {
|
||
var entries = JSON.parse(timelineJson);
|
||
if (Array.isArray(entries) && entries.length) {
|
||
renderTimelineEntries(msgId, entries, false);
|
||
// 删除 buffer → finishMessage 不再全量重渲(避免破坏时间线 DOM),
|
||
// 但仍会执行代码高亮/滚动等收尾
|
||
delete messageBuffer[msgId];
|
||
}
|
||
} catch (e) {
|
||
console.error('[JS]: renderTimelineHistory 解析失败', e);
|
||
}
|
||
}
|
||
|
||
// ==================== 上下文压缩气泡(照抄深度思考气泡类配置,仅改名字和显示内容) ====================
|
||
// 🌟 不再自写结构 —— 复用 makeThinkBlock / .think-block / .streaming-think:
|
||
// 执行中:蓝色呼吸 + 三点动画(与深度思考完全一致)
|
||
// 位置:当前消息的 .reply-content 时间线内(流式光标之前)—— 时间线位置,不锁底
|
||
// 限高:.think-content max-height 400px + 滚动(CSS 已做)
|
||
// 完成:原地折叠,label 显示 前→后 token,内容为摘要全文(.think-inner,同深度思考固化)
|
||
function _fmtK(n) {
|
||
if (n === null || n === undefined) return '?';
|
||
return (n / 1000).toFixed(1) + 'k';
|
||
}
|
||
|
||
function compactionStarted(msgId, path) {
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) return;
|
||
wrapper.classList.add('streaming');
|
||
var buf = messageBuffer[msgId] || null;
|
||
var tl, typingEl;
|
||
if (buf) {
|
||
tl = ensureTimeline(wrapper, buf); // 惰性把 .reply-content 变时间线(建流式光标)
|
||
typingEl = buf.typingEl;
|
||
} else {
|
||
tl = wrapper.querySelector('.reply-content');
|
||
typingEl = tl ? tl.querySelector('.streaming-typing') : null;
|
||
}
|
||
if (!tl) return;
|
||
finalizeOpenThinkingBlocks(msgId); // 防御:压缩开始 → 若有进行中的思考段也先定格
|
||
// —— 与 appendReasoning 新建思考块完全同款 ——
|
||
var block = makeThinkBlock(false, '上下文压缩');
|
||
block.classList.add('streaming-think', 'compaction-think');
|
||
var inner = document.createElement('div');
|
||
inner.className = 'think-inner';
|
||
inner.textContent = '正在生成摘要…';
|
||
block.querySelector('.think-content').appendChild(inner);
|
||
if (typingEl && typingEl.parentNode === tl) {
|
||
tl.insertBefore(block, typingEl); // 时间线位置:流式光标之前
|
||
} else {
|
||
tl.appendChild(block);
|
||
}
|
||
reportWebScroll();
|
||
}
|
||
|
||
function compactionFinished(msgId, payload) {
|
||
payload = payload || {};
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) return;
|
||
// 精确锁定「仍在执行中」的压缩气泡(最后一条)——一轮内多次压缩时
|
||
// 不会误定/重复定格旧气泡;都已完成时回退到最后一条(幂等)
|
||
var blocks = wrapper.querySelectorAll('.compaction-think');
|
||
var block = null;
|
||
for (var bi = blocks.length - 1; bi >= 0; bi--) {
|
||
if (blocks[bi].classList.contains('streaming-think')) { block = blocks[bi]; break; }
|
||
}
|
||
if (!block && blocks.length) block = blocks[blocks.length - 1];
|
||
if (!block) return; // 气泡未建(非激活会话)→ 丢弃
|
||
// —— 与 finishTimelineMessage 固化思考块同款 ——
|
||
block.classList.remove('streaming-think');
|
||
if (block.open) block.open = false;
|
||
var lab = block.querySelector('.think-label');
|
||
var dur = (payload.duration_ms !== null && payload.duration_ms !== undefined)
|
||
? ' · ' + (payload.duration_ms / 1000).toFixed(1) + 's' : '';
|
||
if (lab) {
|
||
lab.textContent = payload.failed
|
||
? '上下文压缩 · 未执行' + dur
|
||
: '已完成上下文压缩 · ' + _fmtK(payload.before)
|
||
+ ' → ' + _fmtK(payload.after) + dur;
|
||
}
|
||
var tc = block.querySelector('.think-content');
|
||
if (tc) {
|
||
tc.innerHTML = safeHtml('<div class="think-inner">' +
|
||
marked.parse(payload.summary || '(无摘要内容)') + '</div>');
|
||
delete tc.__mdState;
|
||
}
|
||
reportWebScroll();
|
||
}
|
||
|
||
// ==================== 系统提示(居中单行) ====================
|
||
function showSystemNote(text) {
|
||
var note = document.createElement('div');
|
||
note.className = 'system-note';
|
||
note.textContent = text;
|
||
var anchor = document.getElementById('scroll-anchor');
|
||
if (anchor && anchor.parentNode === chatContainer) {
|
||
chatContainer.insertBefore(note, anchor);
|
||
} else {
|
||
chatContainer.appendChild(note);
|
||
}
|
||
reportWebScroll();
|
||
}
|
||
|
||
// ==================== 滚动控制 ====================
|
||
var _softScrollPending = 0;
|
||
function softScroll() {
|
||
// 🌟 rAF 节流:历史记录批量渲染时每帧最多滚一次
|
||
if (_softScrollPending) return;
|
||
_softScrollPending = requestAnimationFrame(function() {
|
||
_softScrollPending = 0;
|
||
var anchor = document.getElementById('scroll-anchor');
|
||
if (anchor) {
|
||
anchor.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||
}
|
||
});
|
||
}
|
||
|
||
// ==================== 登录/会话加载界面 ====================
|
||
function showLoadingOverlay() {
|
||
var el = document.getElementById('session-loading');
|
||
if (!el) return;
|
||
el.classList.remove('hide');
|
||
el.style.display = 'flex';
|
||
}
|
||
function hideLoadingOverlay() {
|
||
var el = document.getElementById('session-loading');
|
||
if (!el) return;
|
||
el.classList.add('hide');
|
||
setTimeout(function() { el.style.display = 'none'; }, 480);
|
||
}
|
||
|
||
// ==================== 历史记录与视图控制 ====================
|
||
function clearChat() {
|
||
var bubbles = chatContainer.querySelectorAll('.message-wrapper, .system-note');
|
||
bubbles.forEach(function(b) { b.remove(); });
|
||
|
||
// 🌟 修复:attachmentMetaStore 之前漏清,切会话后附件卡片会指向旧元数据
|
||
messageBuffer = {};
|
||
longTextStore = {};
|
||
finalContentStore = {};
|
||
attachmentMetaStore = {};
|
||
|
||
var welcome = document.querySelector('.welcome-screen');
|
||
if (welcome) welcome.style.display = 'none';
|
||
reportWebScroll();
|
||
}
|
||
|
||
function deleteMessage(msgId) {
|
||
var wrapper = document.getElementById(msgId);
|
||
if (wrapper) {
|
||
wrapper.style.transition = "opacity 0.3s ease, transform 0.3s ease";
|
||
wrapper.style.opacity = "0";
|
||
wrapper.style.transform = "translateY(-10px)";
|
||
setTimeout(function() { wrapper.remove(); }, 300);
|
||
}
|
||
delete messageBuffer[msgId];
|
||
delete longTextStore[msgId];
|
||
delete finalContentStore[msgId];
|
||
setTimeout(reportWebScroll, 350);
|
||
}
|
||
|
||
function showWelcome() {
|
||
var welcome = document.querySelector('.welcome-screen');
|
||
if (welcome) welcome.style.display = 'flex';
|
||
reportWebScroll();
|
||
}
|
||
|
||
// ==================== 历史记录专用:插入思考块 ====================
|
||
function insertThinkBlock(msgId, reasoningText) {
|
||
var wrapper = document.getElementById(msgId);
|
||
if (!wrapper) {
|
||
console.error('[JS]: 找不到消息气泡 ' + msgId);
|
||
return;
|
||
}
|
||
|
||
var contentDiv = wrapper.querySelector('.message-content');
|
||
var replyDiv = contentDiv.querySelector('.reply-content');
|
||
|
||
var thinkBlock = makeThinkBlock(false, '已完成深度思考');
|
||
var thinkContent = thinkBlock.querySelector('.think-content');
|
||
thinkContent.innerHTML = safeHtml('<div class="think-inner">' + marked.parse(reasoningText) + '</div>');
|
||
|
||
contentDiv.insertBefore(thinkBlock, replyDiv);
|
||
}
|
||
|
||
// ==================== JS 引擎就绪标志 ====================
|
||
window.jsReady = true;
|
||
console.log('[JS]: 引擎已就绪');
|