/** * 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(); /** 标记当前渲染批次中系统提示词卡片是否已渲染(每会话仅首条 assistant 消息展示) */ let _sysPromptRendered = false; export function renderMessages(): void { const currentSession = state.get(KEYS.CURRENT_SESSION); const msgs = currentSession ? currentSession.messages : []; // 全量清空重建,避免增量 diff 的边界 bug messagesContainerEl.innerHTML = ''; currentPlaceholder = null; _renderedMsgIndices.clear(); _sysPromptRendered = false; // 重置:每轮全量渲染时重新判定首条 assistant if (msgs.length === 0) { emptyStateEl.style.display = ''; messagesContainerEl.style.display = 'none'; return; } emptyStateEl.style.display = 'none'; messagesContainerEl.style.display = ''; for (let i = 0; i < msgs.length; i++) { const msg = msgs[i]; // 跳过 system 消息(Plan Mode 进度等内部状态消息不展示给用户) if (msg.role === 'system') continue; // 只跳过既无内容也无 think 也无工具的空消息 if (msg.role === 'assistant' && !msg.content && !msg.think && (!msg.toolCalls || msg.toolCalls.length === 0)) continue; appendMessageDOM(msgs[i], i); _renderedMsgIndices.add(i); } 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}
`; } 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) { // P0-1: 流式期间纯文本追加(避免每 chunk 全量 Markdown + DOMPurify),最终渲染时才做 Markdown if (isFinal) { contentDiv.innerHTML = safeMarkdown(safeContent); } else { contentDiv.textContent = safeContent; } } 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'; div.innerHTML = `
🤖
AI
正在思考...
`; messagesContainerEl.appendChild(div); currentPlaceholder = div; scrollToBottom(); } 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; } } /** 从 DOM 移除当前 placeholder 并清空引用 */ export function removeCurrentPlaceholder(): void { if (currentPlaceholder) { currentPlaceholder.remove(); currentPlaceholder = null; } } export function enableAutoScroll(): void { autoScroll = true; if (scrollBtnEl) scrollBtnEl.style.display = 'none'; } // ── 导出功能 ── 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`); }