// ========== 诊断日志(排查流式显示问题的根本手段) ========== // ========== 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); // 🆕 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, '&') .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 '
' + '
' + '' + safeLangLabel + '' + '
' + '' + '' + svgBtnHtml + '
' + '
' + '' + '
'; }; // 流式渲染是全量重渲,围栏未闭合的瞬态或回答中的无围栏 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' && !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 = '' + '' + '' + '' + ''; 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'); // 🆕 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]; // --- 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 = {}; // 🆕 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); } 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); } // ==================== 🆕 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(); delete messageBuffer[msgId]; delete longTextStore[msgId]; delete finalContentStore[msgId]; delete attachmentMetaStore[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]: 引擎已就绪');