Files
Haocode/ui/web/app.js
T

2362 lines
99 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ========== 诊断日志(排查流式显示问题的根本手段) ==========
// ========== JS 控制台桥:console.* 全部捕获进 window.__jslogPython 每 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';
// 流式逐 token 诊断默认关闭。逐 token 读取 DOM 高度/文本并写 console 会
// 触发强制布局和跨进程日志传输,长时间运行会明显拖慢渲染器;需要排查时
// 由 Python 显式调用 setStreamDiagnostics(true) 打开。
window.__STREAM_DIAG = false;
window.setStreamDiagnostics = function(enabled) {
window.__STREAM_DIAG = !!enabled;
if (window.__STREAM_DIAG && window.__startStreamHeartbeat) {
window.__startStreamHeartbeat();
}
};
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();
var _started = false;
function tick() {
var _now = Date.now();
var _dt = _now - _hbLast;
_hbLast = _now;
if (_dt >= 1500) {
console.log('[JS] 心跳 dt=' + _dt + 'ms (渲染器主线程曾卡顿)');
}
}
window.__startStreamHeartbeat = function() {
if (_started) return;
_started = true;
setInterval(tick, 1000);
};
})();
window.__diag = { events: [], cap: 300, tokenN: 0, thinkN: 0 };
function diagEvent(name, extra) {
if (!window.__STREAM_DIAG) return;
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);
// 🆕 P1-01:自动模式顶部自动加载旧页 / 双模式底部自动恢复新页
if (typeof rwAutoCheck === 'function') requestAnimationFrame(rwAutoCheck);
}, { 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 HTMLthrowOnError=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' && !window.__rwPageRendering) {
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 };
if (!window.__rwPageRendering) 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 };
if (!window.__rwPageRendering) 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.__STREAM_DIAG &&
(!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.__STREAM_DIAG && 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; }
if (window.__STREAM_DIAG) {
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;
if (window.__STREAM_DIAG) {
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; }
if (window.__STREAM_DIAG) {
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;
}
if (window.__STREAM_DIAG) {
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];
delete __lastSyncRender[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');
// 🆕 P1-01:流结束 → 解除渲染窗口流式保护(恢复可裁剪)
if (typeof rwState !== 'undefined' && rwState && typeof RenderWindowState !== 'undefined') {
RenderWindowState.streamFinished(rwState, msgId);
}
// --- E. 代码高亮(增量渲染已渐进高亮过的会被跳过?这里统一兜底一次) ---
wrapper.querySelectorAll('pre code').forEach(function(block) {
try { hljs.highlightElement(block); } catch(e) {}
});
// --- F. 等重排完成 ---
// 🆕 P1-01:在调度时刻捕获渲染批次标志(延迟回调触发时批次已结束,不能届时再读)
var rwBatchSuppressed = !!window.__rwPageRendering;
requestAnimationFrame(function() {
setTimeout(function() {
collapsedBlocks.forEach(function(block) {
if (block.classList.contains('collapsed')) {
block.classList.remove('scroll-locked');
block.scrollTop = block.scrollHeight;
}
});
// 🆕 P1-01:窗口换页/初始窗口批次渲染期间不自动滚底(锚点由 rw 控制器恢复)
if (!rwBatchSuppressed) {
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];
delete __lastSyncRender[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];
delete __lastSyncRender[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);
}
// 消息从当前窗口移除时一并释放附件缓存。附件使用 msgId-att-N 作为键,
// 只删除父消息键会让长文本和元数据跨会话持续留在内存中。
function clearMessageStores(msgId) {
if (!msgId) return;
delete messageBuffer[msgId];
delete __lastSyncRender[msgId];
delete longTextStore[msgId];
delete finalContentStore[msgId];
delete attachmentMetaStore[msgId];
var prefix = msgId + '-att-';
[longTextStore, attachmentMetaStore].forEach(function(store) {
Object.keys(store).forEach(function(key) {
if (key.indexOf(prefix) === 0) delete store[key];
});
});
}
// ==================== 历史记录与视图控制 ====================
function clearChat() {
var bubbles = chatContainer.querySelectorAll('.message-wrapper, .system-note');
bubbles.forEach(function(b) { b.remove(); });
// 🌟 修复:attachmentMetaStore 之前漏清,切会话后附件卡片会指向旧元数据
messageBuffer = {};
longTextStore = {};
finalContentStore = {};
attachmentMetaStore = {};
__lastSyncRender = {};
// 🆕 P1-01:清渲染窗口状态机(游标/缓存/未决请求/代次;配置模式与大小保留)
if (typeof rwState !== 'undefined' && rwState && typeof RenderWindowState !== 'undefined') {
RenderWindowState.clear(rwState);
rwUpdateLoadButtons();
}
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);
}
clearMessageStores(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);
}
// ==================== 🆕 P1-01 双向渲染窗口(DOM 层;状态机见 render_window.js ====================
var rwState = null; // RenderWindowState 实例(rwApplyConfig 注入配置后创建)
function rwApplyConfig(cfg) {
rwState = RenderWindowState.create(cfg || {});
}
/* 一轮窗口化加载开始:清屏 + 重同步 (session, generation)。 */
function rwBegin(sessionId, generation, total) {
clearChat();
if (!rwState) rwState = RenderWindowState.create({});
RenderWindowState.clear(rwState);
rwState.sessionId = sessionId;
rwState.generation = (generation | 0);
if (total <= 0) rwUpdateLoadButtons();
}
/* 初始窗口就绪:状态机登记 + 入口按钮 + 对齐底部(与既开开会话在最新处的行为一致)。 */
function rwInitWindow(payload) {
if (!rwState || !payload) return;
RenderWindowState.initFullChain(rwState, payload);
rwUpdateLoadButtons();
var anchor = document.getElementById('scroll-anchor');
if (anchor) anchor.scrollIntoView({ behavior: 'auto', block: 'end' });
reportWebScroll();
}
/* 分页响应:state 机校验 (session, generation, 未决请求)stale 丢弃并清理已渲染节点。 */
function rwPageResponse(payload) {
if (!rwState || !payload) return;
var req = { direction: payload.direction, boundaryId: payload.boundaryId };
var res = RenderWindowState.applyPage(rwState, req, payload);
if (res.stale) {
(payload.items || []).forEach(function(it) { rwRemoveMessageDom(it.id); });
rwUpdateLoadButtons();
return;
}
if (res.side === 'older') rwApplyOlder(res);
else rwApplyNewer(res);
}
/* 向上换页:锚点捕获 → 新页移到头部 → 裁尾 → 锚点恢复(误差≤2px)→ 入口更新。 */
function rwApplyOlder(res) {
var anchor = rwCaptureAnchor();
var oldScroll = getScroller().scrollTop;
var firstExisting = chatContainer.querySelector('.message-wrapper');
res.addedIds.forEach(function(id) {
var el = document.getElementById(id);
if (el) chatContainer.insertBefore(el, firstExisting);
});
res.removedIds.forEach(rwRemoveMessageDom);
if (anchor) {
var el = document.getElementById(anchor.msgId);
if (el) {
var newTop = el.getBoundingClientRect().top + window.scrollY;
if (oldScroll <= 1) {
// 绝对顶部:停在顶部露出新页(自动模式会沿顶部继续补页)
getScroller().scrollTop = 0;
} else {
getScroller().scrollTop = oldScroll + (newTop - anchor.docTop);
}
}
}
rwUpdateLoadButtons();
reportWebScroll();
// 自动模式:停在顶部且还有旧页 → 沿顶部继续补页(pending 防并发,逐页推进)
if (rwState && rwState.mode === 'auto' && getScroller().scrollTop <= 1 &&
RenderWindowState.canRequest(rwState, 'older')) {
setTimeout(function() { if (typeof rwAutoCheck === 'function') rwAutoCheck(); }, 60);
}
}
/* 向下换页:新页已在尾部 → 裁头 → 锚点恢复(无跳动)。 */
function rwApplyNewer(res) {
var anchor = rwCaptureAnchor();
var oldScroll = getScroller().scrollTop;
res.removedIds.forEach(rwRemoveMessageDom);
if (anchor) {
var el = document.getElementById(anchor.msgId);
if (el) {
var newTop = el.getBoundingClientRect().top + window.scrollY;
getScroller().scrollTop = oldScroll + (newTop - anchor.docTop);
}
}
rwUpdateLoadButtons();
reportWebScroll();
}
/* 新消息追加进窗口(发送用户消息 / 助手占位 / 切回续流);超限裁旧端。
* chainIndex/chainLen 为 -1 表示未知(安全降级)。带 streaming 类的消息自动接管流式保护。 */
function rwNoteLive(sessionId, generation, msgId, chainIndex, chainLen) {
if (!rwState || rwState.sessionId !== sessionId || rwState.generation !== generation) return;
if (chainLen !== undefined && chainLen !== null && chainLen >= 0) {
rwState.chainLen = chainLen | 0;
}
var ix = (chainIndex === undefined || chainIndex === null || chainIndex < 0) ? -1 : (chainIndex | 0);
var r = RenderWindowState.noteLive(rwState, msgId, ix);
var el = document.getElementById(msgId);
if (el && el.classList.contains('streaming')) {
RenderWindowState.noteStream(rwState, msgId);
}
(r.removedIds || []).forEach(rwRemoveMessageDom);
rwUpdateLoadButtons();
}
/* 发起换页请求(双引擎同构:QtWebChannel slot / WebView2 postMessage 同名方法)。 */
function rwRequestPage(direction) {
if (!rwState) return;
var req = RenderWindowState.beginRequest(rwState, direction);
if (!req) return;
if (window.bridge && typeof window.bridge.onRequestWindowPage === 'function') {
window.bridge.onRequestWindowPage(rwState.sessionId, req.direction, req.boundaryId, rwState.generation);
}
}
/* 滚动驱动:双模式底部自动恢复新页;自动模式顶部自动加载旧页。 */
function rwAutoCheck() {
if (!rwState) return;
if (RenderWindowState.canRequest(rwState, 'newer') && isNearBottom()) {
rwRequestPage('newer');
} else if (rwState.mode === 'auto' && getScroller().scrollTop <= 1 &&
RenderWindowState.canRequest(rwState, 'older')) {
rwRequestPage('older');
}
}
/* 锚点:换页前首个可见消息的 id + 文档坐标 top。 */
function rwCaptureAnchor() {
var top = getScroller().scrollTop;
var bot = top + window.innerHeight;
var els = chatContainer.querySelectorAll('.message-wrapper');
for (var i = 0; i < els.length; i++) {
var r = els[i].getBoundingClientRect();
if (r.bottom > top && r.top < bot) {
return { msgId: els[i].id, docTop: r.top + window.scrollY, offset: Math.max(0, top - r.top) };
}
}
return null;
}
/* 从 DOM 移除一条消息并清理全部相关存储(活动流不受裁剪,状态机保证)。 */
function rwRemoveMessageDom(msgId) {
var wrapper = document.getElementById(msgId);
if (wrapper) wrapper.remove();
clearMessageStores(msgId);
}
/* 加载入口:固定在 chat-container 首/尾;无更多消息时隐藏。 */
function rwEnsureLoadButtons() {
if (!document.getElementById('load-older')) {
var b = document.createElement('div');
b.id = 'load-older';
b.className = 'load-window-btn';
b.hidden = true;
b.innerText = '⌃ 加载更早消息';
b.addEventListener('click', function() { rwRequestPage('older'); });
chatContainer.appendChild(b);
}
if (!document.getElementById('load-newer')) {
var n = document.createElement('div');
n.id = 'load-newer';
n.className = 'load-window-btn';
n.hidden = true;
n.innerText = '⌄ 加载更新消息';
n.addEventListener('click', function() { rwRequestPage('newer'); });
chatContainer.appendChild(n);
}
chatContainer.insertBefore(document.getElementById('load-older'), chatContainer.firstChild);
chatContainer.appendChild(document.getElementById('load-newer'));
}
function rwUpdateLoadButtons() {
if (!rwState) return;
rwEnsureLoadButtons();
document.getElementById('load-older').hidden = !rwState.hasMoreOlder;
document.getElementById('load-newer').hidden = !rwState.hasMoreNewer;
}
// ==================== JS 引擎就绪标志 ====================
window.jsReady = true;
console.log('[JS]: 引擎已就绪');