/** * 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 = { 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 `
📋 系统提示词 ~${tokenEstimate} tokens ${escapeHtml(preview)}
`; } 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(); /** R31: 最大渲染消息数(超过此数时仅渲染最近的消息,避免 DOM 过载) */ const MAX_RENDERED_MESSAGES = 200; /** 标记当前渲染批次中系统提示词卡片是否已渲染(每会话仅首条 assistant 消息展示) */ let _sysPromptRendered = false; /** R31: 增量渲染 — 仅追加新消息,避免全量重建 */ export function renderMessages(): void { const currentSession = state.get(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) ? `${escapeHtml(msg.model)}` : ''; let contentHtml = ''; let safeContent = (msg.content != null) ? String(msg.content) : ''; if (msg.role === 'assistant') { // ── 系统提示词折叠卡片(仅会话首条 assistant 消息展示,避免重复)── if (!_sysPromptRendered) { const sysPrompt = state.get('_lastSystemPrompt', ''); if (sysPrompt) { contentHtml += renderSystemPromptCard(sysPrompt); } _sysPromptRendered = true; } if (msg.think) { contentHtml += `
🤔 思考过程
${escapeHtml(msg.think)}
`; } if (safeContent) { contentHtml += `
${safeMarkdown(safeContent)}
`; } else { contentHtml += `
`; } } else { contentHtml += `
${escapeHtml(safeContent)}
`; } if (msg.images && msg.images.length > 0) { contentHtml += '
'; for (const img of msg.images) { contentHtml += `用户图片`; } contentHtml += '
'; } if ((msg as any)._videos && (msg as any)._videos.length > 0) { for (const v of (msg as any)._videos) { contentHtml += `
🎬 ${v.fileName} · ${v.frameCount} 帧 · 1fps
`; } } else if ((msg as any)._videoFrames && (msg as any)._videoFrames.length > 0) { const n = (msg as any)._videoFrames.length; contentHtml += `
🎬 ${n} 帧 · 1fps
`; } if (msg.files && msg.files.length > 0) { contentHtml += '
'; for (const f of msg.files) { const icon = getFileIcon(f.name); contentHtml += `
${icon}${escapeHtml(f.name)}
`; } contentHtml += '
'; } if (msg.role === 'user' && (!msg.content || msg.content.trim() === '') && (msg.images?.length || 0) > 0) { contentHtml = contentHtml.replace('
', ''); } // ── 工具调用历史记录(可折叠)── 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 = { 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 += `
🔧 ${tcCount} 个工具调用(✅${successCount} ❌${errorCount})
${msg.toolCalls.map(tc => renderToolCallCard(tc)).join('')}
`; } if (msg.timestamp) { contentHtml += `
${formatTime(msg.timestamp)}
`; } div.innerHTML = `
${avatar}
${roleLabel}${modelTag}
${contentHtml}
`; messagesContainerEl.appendChild(div); } function renderToolCallCard(tc: ToolCallRecord): string { const statusLabels: Record = { 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 += `
路径: ${escapeHtml(String(args.path))}
`; if (args.command) paramsHtml += `
命令: ${escapeHtml(String(args.command))}
`; if (args.query) paramsHtml += `
查询: ${escapeHtml(String(args.query))}
`; if (args.url) paramsHtml += `
URL: ${escapeHtml(String(args.url))}
`; if (args.source) paramsHtml += `
源: ${escapeHtml(String(args.source))}
`; if (args.destination) paramsHtml += `
目标: ${escapeHtml(String(args.destination))}
`; if (args.selector) paramsHtml += `
选择器: ${escapeHtml(String(args.selector))}
`; if (args.action) paramsHtml += `
操作: ${escapeHtml(String(args.action))}
`; // 失败/取消时显示错误信息(成功结果在工作空间工具页签查看) let errorHtml = ''; if (tc.status === 'error' && tc.result?.error) { errorHtml = `
❌ ${escapeHtml(String(tc.result.error))}
`; } else if (tc.status === 'cancelled') { errorHtml = `
🚫 用户取消了操作
`; } return `
${icon} ${name} ${status}
${paramsHtml ? `
${paramsHtml}
` : ''} ${errorHtml}
`; } /** * 流式 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(/(? 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('_lastSystemPrompt', ''); if (sysPrompt && !lastMsg.querySelector('.system-prompt-card')) { const session = state.get(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 节流 _streamRenderContent = safeContent; _streamRenderThink = think; _streamRenderModel = model; 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 = `
🤔 思考过程
`; 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 = `
🔧 ${tcCount} 个工具调用(✅${successCount} ❌${errorCount})
${toolCalls.map(tc => renderToolCallCard(tc)).join('')}
`; 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 = `${formatTime(timestamp)}`; } scrollToBottom(); } /** 实时追加工具调用卡片到当前 placeholder(直播聊天时使用) */ export function appendToolCallCardToPlaceholder(tc: ToolCallRecord): void { if (!currentPlaceholder) return; let container = currentPlaceholder.querySelector('.tool-calls-container'); if (!container) { container = document.createElement('div'); container.className = 'tool-calls-container'; const msgBody = currentPlaceholder.querySelector('.msg-body'); if (msgBody) msgBody.appendChild(container); } const cardDiv = document.createElement('div'); cardDiv.innerHTML = renderToolCallCard(tc); const card = cardDiv.firstElementChild as HTMLElement; card.dataset.toolName = tc.name; container.appendChild(card); scrollToBottom(); } /** 更新 placeholder 中已有的工具卡片状态(running → success/error) */ export function updateToolCallCardInPlaceholder(tc: ToolCallRecord): void { if (!currentPlaceholder) return; const container = currentPlaceholder.querySelector('.tool-calls-container'); if (!container) return; // 找到第一个同名且状态为 running 的卡片(CSS.escape 防特殊字符) const escapedName = CSS.escape(tc.name); const existing = container.querySelector(`.tool-call-card[data-tool-name="${escapedName}"].tool-call-running`) as HTMLElement; if (existing) { const cardDiv = document.createElement('div'); cardDiv.innerHTML = renderToolCallCard(tc); const newCard = cardDiv.firstElementChild as HTMLElement; newCard.dataset.toolName = tc.name; existing.replaceWith(newCard); } else { // 没找到 running 的卡片,直接追加 appendToolCallCardToPlaceholder(tc); } } export function appendAssistantPlaceholder(): void { const div = document.createElement('div'); div.className = 'message assistant loading'; // R34: 增强加载状态 — 添加旋转提示文本 div.innerHTML = `
🤖
AI
正在思考...
`; messagesContainerEl.appendChild(div); currentPlaceholder = div; // R34: 启动旋转提示 startLoadingHintRotation(div); scrollToBottom(); } // R34: 加载提示旋转动画 const LOADING_HINTS = [ '正在思考...', '正在分析问题...', '正在组织回答...', '正在查找信息...', '正在生成回复...', ]; let _loadingHintInterval: ReturnType | 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 状态 } export function clearMessages(): void { messagesContainerEl.innerHTML = ''; currentPlaceholder = null; _renderedMsgIndices.clear(); // P1-1: 重置 diff 状态 } /** 清理 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: 代码块复制按钮 ── /** R38: 为代码块添加复制按钮 */ 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'); 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); } } // ── 导出功能 ── async function nativeSaveFile(defaultName: string, content: string): Promise { 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 { 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 { let html = `${escapeHtml(session.title)}

${escapeHtml(session.title)}

${formatTime(session.createdAt)} · ${session.model}


`; session.messages.forEach(m => { html += `
${m.role === 'user' ? '👤 用户' : '🤖 AI'}
${(m.content || '').replace(/\n/g, '
')}
`; }); html += ''; await nativeSaveFile(`${session.title}.html`, html); logSession('导出', `${session.title}.html`); } export async function exportAsTxt(session: ChatSession): Promise { 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`); }