// ========== 诊断日志(排查流式显示问题的根本手段) ==========
// ========== 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, '"');
}
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
? ''
: '';
var safeCode = escapeHtml(code);
var safeLang = escapeHtml(lang);
var safeLangLabel = escapeHtml(langLabel);
return '
'
+ ''
+ '
' + safeCode + '
'
+ '
';
};
// 流式渲染是全量重渲,围栏未闭合的瞬态或回答中的无围栏 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 = '' + warnText + '';
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 = '';
var copyMsgBtn = document.createElement('button');
copyMsgBtn.className = 'action-btn copy-msg-btn';
copyMsgBtn.title = '复制';
copyMsgBtn.innerHTML = ''
+ '';
var compressBtn = document.createElement('button');
compressBtn.className = 'action-btn compress-btn';
compressBtn.title = '压缩对话';
compressBtn.innerHTML = '';
var deleteBtn = document.createElement('button');
deleteBtn.className = 'action-btn delete-btn';
deleteBtn.title = '删除';
deleteBtn.innerHTML = '';
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 = '';
var copySvg = '';
var delSvg = '';
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 = '' + _tHtml + '
';
}
}
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 = '';
if (attType === 'pdf') {
icon.innerHTML = '';
}
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 = '';
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('' +
marked.parse(c + '\n```') + '
');
} else if (c && tc.innerHTML === '') {
// rAF 还没跑过(极快结束)→ 一次性全量解析
tc.innerHTML = safeHtml('' +
marked.parse(c) + '
');
}
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('' + marked.parse(c) + '
');
} 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('' +
marked.parse(c) + '
');
}
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\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('' +
marked.parse(e.text || '') + '
');
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('' +
marked.parse(payload.summary || '(无摘要内容)') + '
');
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('' + marked.parse(reasoningText) + '
');
contentDiv.insertBefore(thinkBlock, replyDiv);
}
// ==================== JS 引擎就绪标志 ====================
window.jsReady = true;
console.log('[JS]: 引擎已就绪');