Files
metona-ollama-desktop/src/renderer/components/chat-area.ts
T
thzxx bae993c321
CI / verify (push) Canceled after 0s
v0.17.0: 安全加固 + 数据层性能重构 + 死代码清理 + 测试基建
安全修复:
- 开启 webSecurity(CORS 改为 webRequest 允许清单精确放行 Ollama 地址)
- 新增 net-guard SSRF 防护:web_fetch/download_file/browser_open 拦截环回/内网/链路本地地址(DNS 解析后校验)
- browser_open 协议白名单(仅 http/https,阻止 file:// 绕过路径安全层)
- git 参数注入防护(用户可控参数禁止 - 开头;git add 强制 -- 分隔)
- 身份文件保护:SOUL.md/AGENT.md/USER.md 工具只读(防提示注入持久化劫持)
- 系统目录硬红线 + 工作空间/白名单不可豁免系统目录
- spawn_task 权限只降不升(封顶于用户设置 subAgentMaxPermission)
- 子代理写类工具接入主 Agent 确认管线 + 完整路径沙箱
- toast 改 textContent、HTML 导出 escapeHtml(XSS 修复)
- Agent 浏览器改用 memory: 内存分区(退出清空 cookie/storage)

数据层重构:
- sql.js 写入改防抖批量落盘(300ms 合并快照 + temp 原子替换 + 退出刷盘)
- Schema 迁移改 PRAGMA user_version 顺序迁移数组
- 消息/设置/轨迹批量写(单事务);SearXNG 配置 13 次写合并为 1 次
- 会话摘要查询(getSessionSummaries/searchSessions 单条 SQL)消除 N+1
- 导出改 getAllSessionsData 一次 IPC 取回全部行

Bug 修复:
- edit_file 替换符污染($&/$1 被特殊解释导致文件写坏)
- truncateToolResult 暴力截断拼接非法 JSON 必然崩溃
- diff 算法 100MB dp 数组 → 前缀/后缀裁剪 + LCS 限额 + 回退
- move_file 跨盘 rename 失败回退 copy+delete
- Ctrl+K 快捷键冲突(双注册);全局错误处理器双注册
- ffmpeg stderr 无限累积 + 帧进度 O(n²) 正则
- 搜索可达性预检只取响应头(Range: bytes=0-0)
- 备份导出逐字节 base64 拼接(O(n²))改 FileReader
- MCP clientInfo 版本硬编码 5.0.0 改真实版本;tools/list 支持 nextCursor 分页
- 看门狗默认值统一为 30 分钟;download_file 超时跟随用户配置

架构改进:
- 主进程工具分发注册表 tool-dispatch.ts(消除 switch 硬编码)
- agent-engine 拆分 result-formatter.ts / tool-parsing.ts(纯函数)
- 文本兜底解析白名单改从注册表派生(补齐 browser_*/diff/spawn_task/mcp_*)
- diff 工具默认启用;MODE_TOOLS 单一事实来源(tools-modal 复用)
- 记忆系统:条目缓存 + 访问统计(hits/last)持久化 + removeById 按 ID 删除
- 度量历史启动恢复 + Metrics 仪表盘接入 JSON/Prometheus 导出
- 子代理模型下拉框打开设置时刷新(此前从未填充)

死代码清理(约 1400 行):
- 删除 context-indexer 整模块、agent-safety 震荡检测/性能报告/依赖图/记忆调优/归档取回
- 删除 context-manager 水印/跳过压缩/自适应窗口/趋势分析/预算分配等未接线函数
- 删除 sanitizeToolArgs(污染 write_file 内容,防注入职责移交主进程安全层)
- infra-service 裁剪为全局错误处理器唯一定义

文档对齐:
- 新增内置 AGENT.md(工作空间同名文件可覆盖)
- README/帮助面板/DEVELOPMENT 移除失实描述(WAL/内部URL拦截/5层防御/并行白名单/Hook 数量)
- 工具数量口径统一 33;安全机制表新增 SSRF/身份保护/子代理权限等 9 项

工程化:
- Vitest + 34 个单元测试(myers-diff/calculator/net-guard/MEMORY.md 格式)
- Gitea Actions CI(typecheck + test + build)
- package.json 新增 typecheck/test 脚本
2026-08-24 07:39:34 +08:00

1104 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ChatArea - 聊天区域组件
*/
import { state, KEYS } from '../state/state.js';
import { logError, logSession } from '../services/log-service.js';
import { marked } from '../utils/marked-config.js';
import { escapeHtml, formatTime } from '../utils/utils.js';
import { estimateTokens } from '../services/context-manager.js';
import { getToolIcon, formatToolName } from '../services/tool-registry.js';
import type { ChatSession, ChatMessage, ToolCallRecord } from '../types.js';
function getFileIcon(filename: string): string {
const name = filename.toLowerCase();
if (name === 'dockerfile') return '🐳';
if (name === 'makefile' || name === 'gnumakefile') return '🔨';
const ext = filename.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
py: '🐍', js: '💛', ts: '🔷', tsx: '⚛️', jsx: '⚛️',
java: '☕', go: '🐹', rs: '🦀', rb: '💎', php: '🐘',
cpp: '⚙️', c: '⚙️', sh: '🐚', html: '🌐', css: '🎨',
json: '📋', yaml: '📋', yml: '📋', md: '📝', txt: '📄',
sql: '🗃️', xml: '📰', csv: '📊', diff: '🔀',
};
return map[ext] || '📄';
}
let chatAreaEl: HTMLElement;
let messagesContainerEl: HTMLElement;
let emptyStateEl: HTMLElement;
let scrollBtnEl: HTMLElement;
let autoScroll = true;
let currentPlaceholder: HTMLElement | null = null;
/** 渲染可折叠系统提示词卡片 */
let _sysPromptIdCounter = 0;
function renderSystemPromptCard(sysPrompt: string): string {
const id = `sysprompt_${++_sysPromptIdCounter}`;
const tokenEstimate = estimateTokens(sysPrompt);
const preview = sysPrompt.length > 120 ? sysPrompt.slice(0, 120).replace(/\n/g, ' ') + '…' : sysPrompt.replace(/\n/g, ' ');
return `<div class="system-prompt-card">
<div class="system-prompt-header" onclick="
var el=document.getElementById('${id}');
var chev=this.querySelector('.sp-chevron');
el.style.display=el.style.display==='none'?'':'none';
chev.classList.toggle('sp-expanded');
">
<span class="sp-icon">📋</span>
<span class="sp-title">系统提示词</span>
<span class="sp-tokens">~${tokenEstimate} tokens</span>
<span class="sp-preview">${escapeHtml(preview)}</span>
<svg class="sp-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="system-prompt-body" id="${id}" style="display:none;">
<pre>${escapeHtml(sysPrompt)}</pre>
</div>
</div>`;
}
export function initChatArea(): void {
chatAreaEl = document.querySelector('#chatArea')!;
messagesContainerEl = document.querySelector('#messagesContainer')!;
emptyStateEl = document.querySelector('#emptyState')!;
scrollBtnEl = document.querySelector('#scrollToBottom')!;
chatAreaEl.addEventListener('scroll', () => {
const atBottom = isAtBottom();
if (atBottom) {
autoScroll = true;
scrollBtnEl.style.display = 'none';
} else {
autoScroll = false;
scrollBtnEl.style.display = '';
}
});
scrollBtnEl.addEventListener('click', () => {
autoScroll = true;
scrollBtnEl.style.display = 'none';
chatAreaEl.scrollTo({ top: chatAreaEl.scrollHeight, behavior: 'smooth' });
});
}
function isAtBottom(): boolean {
return chatAreaEl.scrollHeight - chatAreaEl.scrollTop - chatAreaEl.clientHeight < 60;
}
export function safeMarkdown(text: unknown): string {
if (!text && text !== 0) return '';
const str = typeof text === 'string' ? text : String(text);
if (!str) return '';
try {
return marked(str);
} catch {
return escapeHtml(str);
}
}
export function scrollToBottom(): void {
if (!autoScroll) return;
requestAnimationFrame(() => {
chatAreaEl.scrollTop = chatAreaEl.scrollHeight;
});
}
/** 发送新消息时重置自动滚动,确保新消息可见 */
export function resetAutoScroll(): void {
autoScroll = true;
if (scrollBtnEl) scrollBtnEl.style.display = 'none';
}
/** P1-1: 已渲染消息索引集合,用于稳定增量 diff(替代 DOM querySelectorAll 计数) */
const _renderedMsgIndices = new Set<number>();
/** R31: 最大渲染消息数(超过此数时仅渲染最近的消息,避免 DOM 过载) */
const MAX_RENDERED_MESSAGES = 200;
/** 标记当前渲染批次中系统提示词卡片是否已渲染(每会话仅首条 assistant 消息展示) */
let _sysPromptRendered = false;
/** R31: 增量渲染 — 仅追加新消息,避免全量重建 */
export function renderMessages(): void {
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
const msgs = currentSession ? currentSession.messages : [];
if (msgs.length === 0) {
messagesContainerEl.innerHTML = '';
currentPlaceholder = null;
_renderedMsgIndices.clear();
_sysPromptRendered = false;
emptyStateEl.style.display = '';
messagesContainerEl.style.display = 'none';
return;
}
emptyStateEl.style.display = 'none';
messagesContainerEl.style.display = '';
// R31: 增量渲染 — 检查是否需要全量重建
const needsFullRebuild = _renderedMsgIndices.size === 0
|| _renderedMsgIndices.size > msgs.length
|| Array.from(_renderedMsgIndices).some(idx => idx >= msgs.length);
if (needsFullRebuild) {
// 全量重建(首次加载或消息被截断/压缩后)
messagesContainerEl.innerHTML = '';
currentPlaceholder = null;
_renderedMsgIndices.clear();
_sysPromptRendered = false;
// R31: 仅渲染最近 MAX_RENDERED_MESSAGES 条消息,避免 DOM 过载
const startIdx = Math.max(0, msgs.length - MAX_RENDERED_MESSAGES);
if (startIdx > 0) {
const noticeDiv = document.createElement('div');
noticeDiv.className = 'msg-truncated-notice';
noticeDiv.style.cssText = 'text-align:center;padding:8px;font-size:12px;color:var(--text-muted);';
noticeDiv.textContent = `⋯ 已折叠前 ${startIdx} 条消息,仅显示最近 ${MAX_RENDERED_MESSAGES} 条 ⋯`;
messagesContainerEl.appendChild(noticeDiv);
}
for (let i = startIdx; i < msgs.length; i++) {
const msg = msgs[i];
if (msg.role === 'system') continue;
if (msg.role === 'assistant' && !msg.content && !msg.think && (!msg.toolCalls || msg.toolCalls.length === 0)) continue;
appendMessageDOM(msgs[i], i);
_renderedMsgIndices.add(i);
}
} else {
// R31: 增量追加 — 只渲染新增的消息
for (let i = 0; i < msgs.length; i++) {
if (_renderedMsgIndices.has(i)) continue;
const msg = msgs[i];
if (msg.role === 'system') continue;
if (msg.role === 'assistant' && !msg.content && !msg.think && (!msg.toolCalls || msg.toolCalls.length === 0)) continue;
appendMessageDOM(msgs[i], i);
_renderedMsgIndices.add(i);
}
}
// R38: 为代码块添加复制按钮
addCodeBlockCopyButtons();
// R40: 为消息添加操作按钮
addMessageActions();
scrollToBottom();
}
export function appendMessageDOM(msg: ChatMessage, index: number): void {
const div = document.createElement('div');
div.className = `message ${msg.role}`;
div.dataset.index = String(index);
const avatar = msg.role === 'user' ? '👤' : '🤖';
const roleLabel = msg.role === 'user' ? '你' : 'AI';
const modelTag = (msg.role === 'assistant' && msg.model)
? `<span class="model-tag">${escapeHtml(msg.model)}</span>`
: '';
let contentHtml = '';
let safeContent = (msg.content != null) ? String(msg.content) : '';
if (msg.role === 'assistant') {
// ── 系统提示词折叠卡片(仅会话首条 assistant 消息展示,避免重复)──
if (!_sysPromptRendered) {
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
if (sysPrompt) {
contentHtml += renderSystemPromptCard(sysPrompt);
}
_sysPromptRendered = true;
}
if (msg.think) {
contentHtml += `
<div class="think-block">
<div class="think-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'; this.querySelector('.chevron').classList.toggle('expanded');">
<span>🤔 思考过程</span>
<svg class="chevron expanded" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="think-content"><pre>${escapeHtml(msg.think)}</pre></div>
</div>`;
}
if (safeContent) {
contentHtml += `<div class="msg-content">${safeMarkdown(safeContent)}</div>`;
} else {
contentHtml += `<div class="msg-content"></div>`;
}
} else {
contentHtml += `<div class="msg-content">${escapeHtml(safeContent)}</div>`;
}
if (msg.images && msg.images.length > 0) {
contentHtml += '<div class="msg-images">';
for (const img of msg.images) {
contentHtml += `<img class="msg-img" src="data:image/png;base64,${img}" alt="用户图片" data-lightbox="true">`;
}
contentHtml += '</div>';
}
if ((msg as any)._videos && (msg as any)._videos.length > 0) {
for (const v of (msg as any)._videos) {
contentHtml += `<div class="msg-video-indicator">🎬 ${v.fileName} · ${v.frameCount} 帧 · 1fps</div>`;
}
} else if ((msg as any)._videoFrames && (msg as any)._videoFrames.length > 0) {
const n = (msg as any)._videoFrames.length;
contentHtml += `<div class="msg-video-indicator">🎬 ${n} 帧 · 1fps</div>`;
}
if (msg.files && msg.files.length > 0) {
contentHtml += '<div class="msg-files">';
for (const f of msg.files) {
const icon = getFileIcon(f.name);
contentHtml += `<div class="file-chip"><span class="file-icon">${icon}</span><span class="file-name">${escapeHtml(f.name)}</span></div>`;
}
contentHtml += '</div>';
}
if (msg.role === 'user' && (!msg.content || msg.content.trim() === '') && (msg.images?.length || 0) > 0) {
contentHtml = contentHtml.replace('<div class="msg-content"></div>', '');
}
// ── 工具调用历史记录(可折叠)──
if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
const tcCount = msg.toolCalls.length;
const successCount = msg.toolCalls.filter(t => t.status === 'success').length;
const errorCount = msg.toolCalls.filter(t => t.status === 'error').length;
const summary = msg.toolCalls.map(t => {
const names: Record<string, string> = {
read_file: 'read_file', write_file: 'write_file', list_directory: 'list_directory',
search_files: 'search_files', run_command: 'run_command', web_fetch: 'web_fetch',
web_search: 'web_search', git: 'git', browser_open: 'browser_open',
memory_search: 'memory_search', memory_add: 'memory_add', spawn_task: 'spawn_task',
};
return names[t.name] || t.name;
}).slice(0, 5).join(', ') + (tcCount > 5 ? ` +${tcCount - 5}` : '');
contentHtml += `<div class="tool-calls-history">
<div class="tool-calls-toggle" onclick="this.parentElement.classList.toggle('expanded')">
<span class="tool-calls-chevron">▸</span>
<span class="tool-calls-summary">🔧 ${tcCount} 个工具调用(✅${successCount}${errorCount}</span>
</div>
<div class="tool-calls-list">
${msg.toolCalls.map(tc => renderToolCallCard(tc)).join('')}
</div>
</div>`;
}
if (msg.timestamp) {
contentHtml += `<div class="msg-stats"><span>${formatTime(msg.timestamp)}</span></div>`;
}
div.innerHTML = `
<div class="msg-avatar">${avatar}</div>
<div class="msg-body">
<div class="msg-role">${roleLabel}${modelTag}</div>
${contentHtml}
</div>
`;
messagesContainerEl.appendChild(div);
}
function renderToolCallCard(tc: ToolCallRecord): string {
const statusLabels: Record<string, string> = {
pending: '📝 准备中…', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
};
const icon = getToolIcon(tc.name);
const name = formatToolName(tc.name);
const status = statusLabels[tc.status] || tc.status;
const args = tc.arguments || {};
let paramsHtml = '';
if (args.path) paramsHtml += `<div class="tool-param">路径: <code>${escapeHtml(String(args.path))}</code></div>`;
if (args.command) paramsHtml += `<div class="tool-param">命令: <code>${escapeHtml(String(args.command))}</code></div>`;
if (args.query) paramsHtml += `<div class="tool-param">查询: <code>${escapeHtml(String(args.query))}</code></div>`;
if (args.url) paramsHtml += `<div class="tool-param">URL: <code>${escapeHtml(String(args.url))}</code></div>`;
if (args.source) paramsHtml += `<div class="tool-param">源: <code>${escapeHtml(String(args.source))}</code></div>`;
if (args.destination) paramsHtml += `<div class="tool-param">目标: <code>${escapeHtml(String(args.destination))}</code></div>`;
if (args.selector) paramsHtml += `<div class="tool-param">选择器: <code>${escapeHtml(String(args.selector))}</code></div>`;
if (args.action) paramsHtml += `<div class="tool-param">操作: <code>${escapeHtml(String(args.action))}</code></div>`;
// 失败/取消时显示错误信息(成功结果在工作空间工具页签查看)
let errorHtml = '';
if (tc.status === 'error' && tc.result?.error) {
errorHtml = `<div class="tool-call-error">❌ ${escapeHtml(String(tc.result.error))}</div>`;
} else if (tc.status === 'cancelled') {
errorHtml = `<div class="tool-call-error">🚫 用户取消了操作</div>`;
}
return `<div class="tool-call-card tool-call-${tc.status}">
<div class="tool-call-header">
<span class="tool-call-icon">${icon}</span>
<span class="tool-call-name">${name}</span>
<span class="tool-call-status">${status}</span>
</div>
${paramsHtml ? `<div class="tool-call-params">${paramsHtml}</div>` : ''}
${errorHtml}
</div>`;
}
/**
* 流式 Markdown 智能补全:检测并修复流式传输中不完整的 Markdown 语法
* - 未闭合的代码块(``` / ~~~)→ 追加闭合标记
* - 未闭合的行内代码(`)→ 追加闭合 `
* - 未闭合的粗体(**)→ 追加闭合 **
* 返回补全后的字符串(不修改原始内容,仅用于渲染)
*/
function patchIncompleteMarkdown(md: string): string {
let patched = md;
// ── 代码块围栏检测(``` 或 ~~~)──
// 统计未闭合的围栏对
const fenceRegex = /^([`~]{3,})/gm;
const openFences: string[] = [];
let match: RegExpExecArray | null;
while ((match = fenceRegex.exec(patched)) !== null) {
openFences.push(match[1]);
}
// 如果有奇数个围栏,说明最后一个未闭合
if (openFences.length % 2 === 1) {
const lastFence = openFences[openFences.length - 1];
patched += '\n' + lastFence;
}
// ── 行内代码反引号检测(仅当不在代码块内时才有意义)──
// 简化处理:统计不在代码围栏内的奇数个反引号
// 这里用一个保守的检测:如果末尾有奇数个 `(且不是围栏),补一个
const inlineBackticks = (patched.match(/(?<!`)`(?!`)/g) || []).length;
if (inlineBackticks % 2 === 1) {
patched += '`';
}
return patched;
}
// ── 流式渲染节流状态 ──
let _streamRenderPending = false;
let _streamRenderContent = '';
// R58: 流式渲染优化 — 增量阈值 + 内容指纹,避免高频小片段冗余重渲染
let _streamLastRenderedLen = 0; // 上次渲染时的内容长度
let _streamLastRenderedHash = 0; // 上次渲染时的内容哈希(简单 DJB2)
const STREAM_MIN_DELTA = 15; // 最小增量字符数(不足则跳过本次渲染)
/** R58: 快速 DJB2 哈希 */
function _djb2(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0x7fffffff;
}
return hash;
}
/**
* 执行实际的流式 Markdown 渲染
* R58: 添加增量阈值检查 — 内容增长不足 STREAM_MIN_DELTA 字符时跳过渲染
*/
function _doStreamRender(): void {
_streamRenderPending = false;
const lastMsg = currentPlaceholder;
if (!lastMsg) return;
// P1-R1 修复:rAF 回调执行时 currentPlaceholder 可能已被 appendAssistantPlaceholder 替换为新节点,
// 且 _streamRenderContent 已被重置为 '',此时不会误渲染。appendAssistantPlaceholder 中的
// _streamRenderContent = '' 重置确保了旧 rAF 回调拿到的是空内容,不会渲染到新 placeholder。
// R58: 增量阈值检查 — 内容变化不足时跳过本次渲染
const contentLen = _streamRenderContent.length;
const contentHash = _djb2(_streamRenderContent);
if (contentLen > 0 && contentHash === _streamLastRenderedHash) return; // 内容完全相同
if (contentLen - _streamLastRenderedLen < STREAM_MIN_DELTA && contentLen > 100) {
// 内容增长不足且已有内容 → 跳过本次,等下次 rAF
return;
}
_streamLastRenderedLen = contentLen;
_streamLastRenderedHash = contentHash;
const contentDiv = lastMsg.querySelector('.msg-content');
if (contentDiv && _streamRenderContent) {
// 智能补全不完整的 Markdown 语法后渲染
const patched = patchIncompleteMarkdown(_streamRenderContent);
contentDiv.innerHTML = safeMarkdown(patched);
}
scrollToBottom();
}
export function updateLastAssistantMessage(
content: string, think: string | null, _stats: unknown, model?: string,
timestamp?: number, toolCalls?: ToolCallRecord[]
): void {
const lastMsg = currentPlaceholder;
if (!lastMsg) return;
const isFinal = toolCalls !== undefined || timestamp !== undefined;
if (lastMsg.classList.contains('loading')) {
lastMsg.classList.remove('loading');
const loadingDots = lastMsg.querySelector('.loading-dots');
const loadingText = lastMsg.querySelector('.loading-text');
if (loadingDots) loadingDots.remove();
if (loadingText) loadingText.remove();
// 流式消息首次转正:注入系统提示词折叠卡片(仅当会话中无前序 assistant 消息时)
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
if (sysPrompt && !lastMsg.querySelector('.system-prompt-card')) {
const session = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
const hasPrevAssistant = session?.messages.some(m => m.role === 'assistant') ?? false;
if (!hasPrevAssistant) {
const msgBody = lastMsg.querySelector('.msg-body');
const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderSystemPromptCard(sysPrompt);
const card = tempDiv.firstElementChild!;
const thinkBlock = msgBody?.querySelector('.think-block');
if (thinkBlock) {
msgBody!.insertBefore(card, thinkBlock);
} else {
msgBody!.insertBefore(card, msgBody!.firstChild);
}
}
}
}
if (model) {
let modelTag = lastMsg.querySelector('.model-tag');
if (!modelTag) {
modelTag = document.createElement('span');
modelTag.className = 'model-tag';
const roleEl = lastMsg.querySelector('.msg-role');
if (roleEl) roleEl.appendChild(modelTag);
}
modelTag.textContent = model;
}
const contentDiv = lastMsg.querySelector('.msg-content');
const safeContent = (content != null) ? String(content) : '';
if (contentDiv && safeContent) {
if (isFinal) {
// 最终渲染:完整 Markdown + 代码块复制按钮
contentDiv.innerHTML = safeMarkdown(safeContent);
addCodeBlockCopyButtons(contentDiv);
} else {
// 流式期间:实时 Markdown 渲染 + rAF 节流
// R58-fix: 移除对未声明变量 _streamRenderThink/_streamRenderModel 的赋值
// 原代码在严格模式下会抛出 ReferenceError,被 chatStream 的空 catch 静默吞掉,
// 导致 requestAnimationFrame(_doStreamRender) 永远不会被调用,流式渲染完全失效。
// think 和 model 已在上方(model tag)和下方(think block)直接处理,无需传递给 _doStreamRender。
_streamRenderContent = safeContent;
if (!_streamRenderPending) {
_streamRenderPending = true;
requestAnimationFrame(_doStreamRender);
}
}
} else if (isFinal) {
// R58: 最终渲染时重置增量追踪状态
_streamLastRenderedLen = 0;
_streamLastRenderedHash = 0;
}
let thinkBlock = lastMsg.querySelector('.think-block');
if (think) {
if (!thinkBlock) {
const msgBody = lastMsg.querySelector('.msg-body')!;
thinkBlock = document.createElement('div');
thinkBlock.className = 'think-block';
thinkBlock.innerHTML = `
<div class="think-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'; this.querySelector('.chevron').classList.toggle('expanded');">
<span>🤔 思考过程</span>
<svg class="chevron expanded" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="think-content"><pre></pre></div>
`;
msgBody.insertBefore(thinkBlock, contentDiv);
}
thinkBlock.querySelector('pre')!.textContent = think;
}
// ── 工具调用历史记录(可折叠)──
if (toolCalls && toolCalls.length > 0) {
let existingHistory = lastMsg.querySelector('.tool-calls-history');
if (existingHistory) existingHistory.remove();
const tcCount = toolCalls.length;
const successCount = toolCalls.filter(t => t.status === 'success').length;
const errorCount = toolCalls.filter(t => t.status === 'error').length;
const historyDiv = document.createElement('div');
historyDiv.className = 'tool-calls-history';
historyDiv.innerHTML = `
<div class="tool-calls-toggle" onclick="this.parentElement.classList.toggle('expanded')">
<span class="tool-calls-chevron">▸</span>
<span class="tool-calls-summary">🔧 ${tcCount} 个工具调用(✅${successCount}${errorCount}</span>
</div>
<div class="tool-calls-list">
${toolCalls.map(tc => renderToolCallCard(tc)).join('')}
</div>
`;
const msgBody = lastMsg.querySelector('.msg-body');
if (msgBody) {
const refNode = contentDiv?.nextSibling || null;
msgBody.insertBefore(historyDiv, refNode);
}
}
// ── 时间戳 ──
if (timestamp) {
let statsDiv = lastMsg.querySelector('.msg-stats');
if (!statsDiv) {
statsDiv = document.createElement('div');
statsDiv.className = 'msg-stats';
const msgBody = lastMsg.querySelector('.msg-body');
if (msgBody) msgBody.appendChild(statsDiv);
}
statsDiv.innerHTML = `<span>${formatTime(timestamp)}</span>`;
}
scrollToBottom();
}
export function appendAssistantPlaceholder(): void {
const div = document.createElement('div');
div.className = 'message assistant loading';
// R34: 增强加载状态 — 添加旋转提示文本
div.innerHTML = `
<div class="msg-avatar">🤖</div>
<div class="msg-body">
<div class="msg-role">AI</div>
<div class="msg-content">
<div class="loading-dots"><span></span><span></span><span></span></div>
<span class="loading-text" data-hint-index="0">正在思考...</span>
</div>
</div>
`;
messagesContainerEl.appendChild(div);
currentPlaceholder = div;
// P0-R2 修复:新 placeholder 必须重置流式渲染状态,否则上一条的 _streamLastRenderedLen 会压制新消息渲染
_streamLastRenderedLen = 0;
_streamLastRenderedHash = 0;
_streamRenderContent = '';
_streamRenderPending = false;
// R34: 启动旋转提示
startLoadingHintRotation(div);
scrollToBottom();
}
/**
* 确保 loading 占位符存在 — 如果当前没有 loading 占位符,创建一个。
* 用于中间轮次 THINKING 开始前,确保用户看到"正在思考..."状态。
*/
export function ensureLoadingPlaceholder(): void {
if (currentPlaceholder && currentPlaceholder.classList.contains('loading') && currentPlaceholder.isConnected) {
return;
}
// 当前 placeholder 不存在或不是 loading 状态 → 清理旧 placeholder 并创建新的
if (currentPlaceholder && !currentPlaceholder.classList.contains('loading') && currentPlaceholder.isConnected) {
// 上一轮的 placeholder 已经被 updateLastAssistantMessage 转为正常消息但未通过 renderMessages 固化
// 不需要移除,直接追加新的 loading
}
if (currentPlaceholder && !currentPlaceholder.isConnected) {
currentPlaceholder = null;
}
appendAssistantPlaceholder();
}
// R34: 加载提示旋转动画
const LOADING_HINTS = [
'正在思考...',
'正在分析问题...',
'正在组织回答...',
'正在查找信息...',
'正在生成回复...',
];
let _loadingHintInterval: ReturnType<typeof setInterval> | null = null;
function startLoadingHintRotation(el: HTMLElement): void {
if (_loadingHintInterval) clearInterval(_loadingHintInterval);
let idx = 0;
_loadingHintInterval = setInterval(() => {
if (!el.isConnected) {
if (_loadingHintInterval) clearInterval(_loadingHintInterval);
_loadingHintInterval = null;
return;
}
idx = (idx + 1) % LOADING_HINTS.length;
const hintEl = el.querySelector('.loading-text');
if (hintEl) {
hintEl.textContent = LOADING_HINTS[idx];
}
}, 3000);
}
export function appendSystemMessage(text: string, tempClass?: string): void {
const div = document.createElement('div');
div.style.cssText = 'text-align:center;padding:8px;font-size:12px;color:var(--text-muted);';
if (tempClass) div.classList.add(tempClass);
div.textContent = text;
emptyStateEl.style.display = 'none';
messagesContainerEl.style.display = '';
messagesContainerEl.appendChild(div);
scrollToBottom();
}
/** v4.1: 清除所有消息 DOM 元素(用于 undo/compress 后强制重建) */
export function clearMessagesDOM(): void {
messagesContainerEl.innerHTML = '';
_renderedMsgIndices.clear(); // P1-1: 重置 diff 状态
// P1-R2 修复:重置系统提示词标记,否则清空后首条消息不再显示系统卡片
_sysPromptRendered = false;
}
export function clearMessages(): void {
messagesContainerEl.innerHTML = '';
currentPlaceholder = null;
_renderedMsgIndices.clear(); // P1-1: 重置 diff 状态
// P1-R2 修复:与 clearMessagesDOM 保持一致
_sysPromptRendered = false;
}
/** 清理 currentPlaceholder 引用(当 placeholder 从外部被移除时调用) */
export function resetCurrentPlaceholder(): void {
if (currentPlaceholder && !currentPlaceholder.isConnected) {
currentPlaceholder = null;
}
// R34: 清理加载提示旋转
if (_loadingHintInterval) {
clearInterval(_loadingHintInterval);
_loadingHintInterval = null;
}
}
/** 从 DOM 移除当前 placeholder 并清空引用 */
export function removeCurrentPlaceholder(): void {
if (currentPlaceholder) {
currentPlaceholder.remove();
currentPlaceholder = null;
}
}
export function enableAutoScroll(): void {
autoScroll = true;
if (scrollBtnEl) scrollBtnEl.style.display = 'none';
}
// ── R38: 代码块复制按钮 + 语法高亮 ──
/** 轻量级 JSON 语法高亮 — 检测 JSON 并高亮关键字、字符串、数字 */
export function highlightJsonContent(text: string): string {
const escaped = escapeHtml(text);
// 尝试 JSON 解析并格式化
try {
const parsed = JSON.parse(text);
const formatted = JSON.stringify(parsed, null, 2);
return formatJsonSyntax(escapeHtml(formatted));
} catch {
// 非 JSON,返回转义后的原文
return escaped;
}
}
/** JSON 语法着色 — key/string/number/boolean/null 分别着色 */
function formatJsonSyntax(escaped: string): string {
// 高亮 JSON key: "key":
let result = escaped.replace(
/&quot;([^&]*?)&quot;\s*:/g,
'<span class="hl-json-key">&quot;$1&quot;</span>:'
);
// 高亮 string value: : "value" 或 [ "value"
result = result.replace(
/:\s*&quot;([^&]*(?:&amp;|&lt;|&gt;)?[^&]*)&quot;/g,
': <span class="hl-json-string">&quot;$1&quot;</span>'
);
result = result.replace(
/\[\s*&quot;([^&]*(?:&amp;|&lt;|&gt;)?[^&]*)&quot;/g,
'[ <span class="hl-json-string">&quot;$1&quot;</span>'
);
// 高亮数字
result = result.replace(
/:\s*(-?\d+\.?\d*)/g,
': <span class="hl-json-number">$1</span>'
);
// 高亮 boolean
result = result.replace(
/:\s*(true|false)/g,
': <span class="hl-json-boolean">$1</span>'
);
// 高亮 null
result = result.replace(
/:\s*(null)/g,
': <span class="hl-json-null">$1</span>'
);
return result;
}
/** 为代码块添加复制按钮 + 语法高亮 */
function addCodeBlockCopyButtons(scope?: ParentNode): void {
const root = scope || messagesContainerEl;
const codeBlocks = root.querySelectorAll('pre:not([data-copy-added])');
for (const pre of codeBlocks) {
pre.setAttribute('data-copy-added', 'true');
// 语法高亮:检测 JSON 并格式化
const codeEl = pre.querySelector('code');
const rawText = codeEl ? codeEl.textContent : pre.textContent;
if (rawText && rawText.trim().startsWith('{')) {
const highlighted = highlightJsonContent(rawText);
if (codeEl) {
codeEl.innerHTML = highlighted;
} else {
pre.innerHTML = `<code>${highlighted}</code>`;
}
}
const btn = document.createElement('button');
btn.className = 'code-copy-btn';
btn.textContent = '📋 复制';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:4px;cursor:pointer;opacity:0;transition:opacity 0.2s;';
// 确保 pre 是相对定位
if (getComputedStyle(pre).position === 'static') {
(pre as HTMLElement).style.position = 'relative';
}
pre.addEventListener('mouseenter', () => { btn.style.opacity = '1'; });
pre.addEventListener('mouseleave', () => { btn.style.opacity = '0'; });
btn.addEventListener('click', (e) => {
e.preventDefault();
const code = pre.querySelector('code');
const text = code ? code.textContent : pre.textContent;
if (text) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = '✅ 已复制';
setTimeout(() => { btn.textContent = '📋 复制'; }, 2000);
}).catch(() => {
btn.textContent = '❌ 失败';
setTimeout(() => { btn.textContent = '📋 复制'; }, 2000);
});
}
});
pre.appendChild(btn);
}
}
// ── R40: 消息操作按钮 ──
/** R40: 为消息添加操作按钮(复制、重试) */
function addMessageActions(): void {
const messages = messagesContainerEl.querySelectorAll('.message:not([data-actions-added])');
for (const msg of messages) {
msg.setAttribute('data-actions-added', 'true');
const actionBar = document.createElement('div');
actionBar.className = 'msg-actions';
actionBar.style.cssText = 'position:absolute;top:4px;right:4px;display:flex;gap:4px;opacity:0;transition:opacity 0.2s;';
// 确保 message 是相对定位
if (getComputedStyle(msg).position === 'static') {
(msg as HTMLElement).style.position = 'relative';
}
// 复制按钮
const copyBtn = document.createElement('button');
copyBtn.className = 'msg-action-btn msg-copy-btn';
copyBtn.textContent = '📋';
copyBtn.title = '复制消息';
copyBtn.style.cssText = 'padding:2px 6px;font-size:12px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:4px;cursor:pointer;';
copyBtn.addEventListener('click', (e) => {
e.preventDefault();
const contentEl = msg.querySelector('.msg-content');
const text = contentEl ? contentEl.textContent : '';
if (text) {
navigator.clipboard.writeText(text).then(() => {
copyBtn.textContent = '✅';
setTimeout(() => { copyBtn.textContent = '📋'; }, 2000);
}).catch(() => {});
}
});
actionBar.appendChild(copyBtn);
msg.addEventListener('mouseenter', () => { actionBar.style.opacity = '1'; });
msg.addEventListener('mouseleave', () => { actionBar.style.opacity = '0'; });
msg.appendChild(actionBar);
}
}
// ── 导出功能 ──
/** 为工作空间面板的工具结果添加语法高亮和复制按钮 */
export function addToolResultHighlighting(scope: ParentNode): void {
const resultBlocks = scope.querySelectorAll('pre.tool-result-content:not([data-highlighted])');
for (const pre of resultBlocks as NodeListOf<HTMLPreElement>) {
pre.setAttribute('data-highlighted', 'true');
// 确保 pre 是相对定位
if (getComputedStyle(pre).position === 'static') {
pre.style.position = 'relative';
}
// 语法高亮:检测 JSON 并格式化
const rawText = pre.textContent || '';
if (rawText.trim().startsWith('{') || rawText.trim().startsWith('[')) {
const highlighted = highlightJsonContent(rawText);
if (highlighted !== escapeHtml(rawText)) {
pre.innerHTML = highlighted;
}
}
// 复制按钮
const btn = document.createElement('button');
btn.className = 'code-copy-btn';
btn.textContent = '📋 复制';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:4px;cursor:pointer;opacity:0;transition:opacity 0.2s;z-index:10;';
pre.addEventListener('mouseenter', () => { btn.style.opacity = '1'; });
pre.addEventListener('mouseleave', () => { btn.style.opacity = '0'; });
btn.addEventListener('click', (e) => {
e.preventDefault();
const text = pre.textContent || '';
if (text) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = '✅ 已复制';
setTimeout(() => { btn.textContent = '📋 复制'; }, 2000);
}).catch(() => {
btn.textContent = '❌ 失败';
setTimeout(() => { btn.textContent = '📋 复制'; }, 2000);
});
}
});
pre.appendChild(btn);
}
}
async function nativeSaveFile(defaultName: string, content: string): Promise<void> {
const bridge = window.metonaDesktop;
if (bridge) {
const ext = defaultName.split('.').pop() || 'txt';
const filePath = await bridge.dialog.saveFile({
defaultPath: defaultName,
filters: [{ name: ext.toUpperCase(), extensions: [ext] }]
});
if (!filePath) return;
const result = await bridge.fs.writeFile(filePath, content, 'utf-8');
if (!result.success) {
logError('导出失败', result.error);
}
} else {
// 回退
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = defaultName;
a.click();
}
}
export async function exportAsMarkdown(session: ChatSession): Promise<void> {
let md = `# ${session.title}\n\n**模型:** ${session.model} \n**时间:** ${formatTime(session.createdAt)}\n\n---\n\n`;
session.messages.forEach(m => {
const role = m.role === 'user' ? '👤 用户' : '🤖 AI';
md += `### ${role}\n\n${m.content || ''}\n\n`;
if (m.files && m.files.length > 0) {
md += m.files.map(f => `📎 \`${f.name}\``).join(' · ') + '\n\n';
}
if (m.think) md += `> **思考:** ${m.think}\n\n`;
});
await nativeSaveFile(`${session.title}.md`, md);
logSession('导出', `${session.title}.md`);
}
export async function exportAsHtml(session: ChatSession): Promise<void> {
let html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${escapeHtml(session.title)}</title>
<style>body{max-width:800px;margin:0 auto;padding:20px;font-family:system-ui;line-height:1.6;background:#FAF7F2;color:#2D2016;}
.user{background:#F5F0E8;padding:12px;border-radius:12px;margin:8px 0;border:1px solid rgba(0,0,0,0.06);}
.assistant{background:#fff;padding:12px;border-radius:12px;margin:8px 0;border:1px solid rgba(0,0,0,0.06);box-shadow:0 1px 4px rgba(45,32,22,0.04);}
pre{background:#2D2016;color:#F5F0E8;padding:14px;border-radius:12px;overflow-x:auto;}
code{background:#F5F0E8;color:#E8734A;padding:2px 6px;border-radius:4px;}</style></head><body>
<h1>${escapeHtml(session.title)}</h1><p>${formatTime(session.createdAt)} · ${escapeHtml(session.model)}</p><hr>`;
session.messages.forEach(m => {
// escapeHtml 防止 AI 回复中的 HTML/脚本在导出文件中被执行
const safeContent = escapeHtml(m.content || '').replace(/\n/g, '<br>');
html += `<div class="${m.role}"><strong>${m.role === 'user' ? '👤 用户' : '🤖 AI'}</strong><br>${safeContent}</div>`;
});
html += '</body></html>';
await nativeSaveFile(`${session.title}.html`, html);
logSession('导出', `${session.title}.html`);
}
export async function exportAsTxt(session: ChatSession): Promise<void> {
let txt = `${session.title}\n模型: ${session.model}\n时间: ${formatTime(session.createdAt)}\n${'='.repeat(50)}\n\n`;
session.messages.forEach(m => {
txt += `[${m.role === 'user' ? '用户' : 'AI'}]\n${m.content || ''}\n\n`;
});
await nativeSaveFile(`${session.title}.txt`, txt);
logSession('导出', `${session.title}.txt`);
}
// ── 对话内搜索 ──
let searchInputEl: HTMLInputElement;
let searchBarEl: HTMLElement;
let searchCountEl: HTMLElement;
let searchMatches: HTMLElement[] = [];
let searchCurrentIndex = -1;
/** 初始化对话内搜索功能 */
export function initSearchBar(): void {
searchBarEl = document.querySelector('#searchBar')!;
searchInputEl = document.querySelector('#searchInput') as HTMLInputElement;
searchCountEl = document.querySelector('#searchCount')!;
document.querySelector('#btnSearch')?.addEventListener('click', toggleSearchBar);
document.querySelector('#searchClose')?.addEventListener('click', closeSearchBar);
document.querySelector('#searchPrev')?.addEventListener('click', () => navigateMatch(-1));
document.querySelector('#searchNext')?.addEventListener('click', () => navigateMatch(1));
searchInputEl.addEventListener('input', performSearch);
// Ctrl+F 快捷键
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'f') {
e.preventDefault();
toggleSearchBar();
}
if (e.key === 'Escape' && searchBarEl.style.display !== 'none') {
closeSearchBar();
}
if (searchBarEl.style.display !== 'none') {
if (e.key === 'Enter') {
e.preventDefault();
navigateMatch(e.shiftKey ? -1 : 1);
}
}
});
}
function toggleSearchBar(): void {
if (searchBarEl.style.display === 'none') {
searchBarEl.style.display = '';
searchInputEl.focus();
searchInputEl.select();
} else {
closeSearchBar();
}
}
function closeSearchBar(): void {
searchBarEl.style.display = 'none';
clearHighlights();
searchInputEl.value = '';
searchCountEl.textContent = '';
searchMatches = [];
searchCurrentIndex = -1;
}
/** 执行搜索 */
function performSearch(): void {
clearHighlights();
const query = searchInputEl.value.trim();
if (!query) {
searchCountEl.textContent = '';
searchMatches = [];
searchCurrentIndex = -1;
return;
}
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
// 在所有消息内容元素中搜索
const contentEls = messagesContainerEl.querySelectorAll('.msg-content, .msg-thinking, .tool-result-content');
searchMatches = [];
contentEls.forEach(el => {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
// 跳过 script/style 标签内的文本
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
if (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') return NodeFilter.FILTER_REJECT;
return node.textContent && regex.test(node.textContent) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
});
const textNodes: Text[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
for (const textNode of textNodes) {
const text = textNode.textContent || '';
regex.lastIndex = 0;
const parts: string[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const highlight = document.createElement('span');
highlight.className = 'search-highlight';
highlight.textContent = match[0];
parts.push(highlight.outerHTML);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
if (parts.length > 0) {
const wrapper = document.createElement('span');
wrapper.innerHTML = parts.join('');
const highlights = wrapper.querySelectorAll('.search-highlight');
textNode.replaceWith(...wrapper.childNodes);
highlights.forEach(h => searchMatches.push(h as HTMLElement));
}
}
});
searchCurrentIndex = searchMatches.length > 0 ? 0 : -1;
updateSearchCount();
if (searchMatches.length > 0) {
searchMatches[0].classList.add('search-highlight-current');
searchMatches[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
/** 清除所有搜索高亮 */
function clearHighlights(): void {
const highlights = messagesContainerEl.querySelectorAll('.search-highlight, .search-highlight-current');
highlights.forEach(h => {
const parent = h.parentNode;
if (parent) {
parent.replaceChild(document.createTextNode(h.textContent || ''), h);
parent.normalize();
}
});
}
/** 导航到上一个/下一个匹配 */
function navigateMatch(direction: number): void {
if (searchMatches.length === 0) return;
// 移除当前高亮
if (searchCurrentIndex >= 0) {
searchMatches[searchCurrentIndex].classList.remove('search-highlight-current');
}
searchCurrentIndex = (searchCurrentIndex + direction + searchMatches.length) % searchMatches.length;
searchMatches[searchCurrentIndex].classList.add('search-highlight-current');
searchMatches[searchCurrentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
updateSearchCount();
}
function updateSearchCount(): void {
if (searchMatches.length === 0) {
searchCountEl.textContent = searchInputEl.value.trim() ? '无匹配' : '';
} else {
searchCountEl.textContent = `${searchCurrentIndex + 1}/${searchMatches.length}`;
}
}