/** * 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 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; 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 renderMessages(): void { const currentSession = state.get(KEYS.CURRENT_SESSION); const msgs = currentSession ? currentSession.messages : []; if (msgs.length === 0) { emptyStateEl.style.display = ''; messagesContainerEl.style.display = 'none'; return; } emptyStateEl.style.display = 'none'; messagesContainerEl.style.display = ''; const existingCount = messagesContainerEl.children.length; const isNewMessage = existingCount > 0 && existingCount < msgs.length; for (let i = existingCount; i < msgs.length; i++) { appendMessageDOM(msgs[i], i); } if (isNewMessage) { scrollToBottom(); } else if (msgs.length > 0) { chatAreaEl.scrollTop = chatAreaEl.scrollHeight; } } 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 = ''; const safeContent = (msg.content != null) ? String(msg.content) : ''; if (msg.role === 'assistant') { 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.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('
', ''); } const stats: string[] = []; if (msg.timestamp) stats.push(formatTime(msg.timestamp)); if (msg.eval_count != null && msg.eval_count > 0) stats.push(`${msg.eval_count} tokens`); if (msg.total_duration != null && msg.total_duration > 0) stats.push(`${(msg.total_duration / 1e9).toFixed(1)}s`); if (stats.length > 0) { contentHtml += `
${stats.map(s => `${s}`).join(' · ')}
`; } if (msg.role === 'assistant' && msg.ragSources && msg.ragSources.length > 0) { const sourcesHtml = msg.ragSources.map((s, i) => { const scorePercent = s.score ? `${(s.score * 100).toFixed(0)}%` : ''; return `
📄 来源 ${i + 1}: ${escapeHtml(s.filename)} ${scorePercent ? `${scorePercent}` : ''}
`; }).join(''); contentHtml += `
🧠 基于知识库回答
${sourcesHtml}
`; } if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) { contentHtml += '
'; for (const tc of msg.toolCalls) { contentHtml += renderToolCallCard(tc); } contentHtml += '
'; } div.innerHTML = `
${avatar}
${roleLabel}${modelTag}
${contentHtml}
`; messagesContainerEl.appendChild(div); } function renderToolCallCard(tc: ToolCallRecord): string { const icons: Record = { read_file: '📄', write_file: '✏️', list_directory: '📁', search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻' }; const names: Record = { read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令' }; const statusLabels: Record = { pending: '⏳ 等待确认', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消' }; const icon = icons[tc.name] || '🔧'; const name = names[tc.name] || 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))}
`; let resultHtml = ''; if (tc.result) { if (tc.result.success) { const r = tc.result as Record; if (tc.name === 'read_file' && r.content) { const content = String(r.content); const lines = content.split('\n'); const preview = lines.slice(0, 20).join('\n'); resultHtml = `
${escapeHtml(preview)}${lines.length > 20 ? `\n... (共 ${r.lines || lines.length} 行)` : ''}
`; resultHtml += `
📄 ${(r.size as number || 0) > 0 ? formatFileSize(r.size as number) : ''} · ${r.lines || '?'} 行
`; } else if (tc.name === 'list_directory' && r.entries) { const entries = r.entries as Array<{ name: string; type: string; size: number | null }>; resultHtml = entries.slice(0, 30).map(e => `
${e.type === 'directory' ? '📁' : '📄'} ${escapeHtml(e.name)}${e.size != null ? ` (${formatFileSize(e.size)})` : ''}
` ).join(''); if (entries.length > 30) resultHtml += `
... 共 ${(r.total as number) || entries.length} 项
`; } else if (tc.name === 'search_files' && r.results) { const results = r.results as Array<{ path: string; matches: Array<{ line: number; text: string }> }>; resultHtml = results.slice(0, 10).map(file => file.matches.slice(0, 3).map(m => `
📄 ${escapeHtml(file.path)}:${m.line} ${escapeHtml(m.text).slice(0, 80)}
` ).join('') ).join(''); resultHtml += `
共 ${r.total_files || '?'} 个文件,${r.total_matches || '?'} 处匹配
`; } else if (tc.name === 'write_file') { resultHtml = `
✅ 已写入 ${escapeHtml(String(r.path || ''))} (${formatFileSize(r.bytesWritten as number || 0)})
`; } else if (tc.name === 'create_directory') { resultHtml = `
✅ 已创建 ${escapeHtml(String(r.path || ''))}
`; } else if (tc.name === 'delete_file') { resultHtml = `
✅ 已删除 ${escapeHtml(String(r.path || ''))}
`; } else if (tc.name === 'run_command') { const stdout = String(r.stdout || '').slice(0, 1000); const stderr = String(r.stderr || '').slice(0, 500); if (stdout) resultHtml += `
${escapeHtml(stdout)}
`; if (stderr) resultHtml += `
${escapeHtml(stderr)}
`; resultHtml += `
exit: ${r.exitCode || 0} · ${r.duration || 0}ms
`; } else { resultHtml = `
✅ 操作成功
`; } } else { resultHtml = `
❌ ${escapeHtml(tc.result.error || '未知错误')}
`; } } return `
${icon} ${name} ${status}
${paramsHtml ? `
${paramsHtml}
` : ''} ${resultHtml ? `
${resultHtml}
` : ''}
`; } function formatFileSize(bytes: number): string { if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; let i = 0; let size = bytes; while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } return `${size.toFixed(1)} ${units[i]}`; } export function updateLastAssistantMessage(content: string, think: string | null, stats: { eval_count?: number; total_duration?: number } | null, model?: string): void { const lastMsg = currentPlaceholder; if (!lastMsg) return; 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(); } 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) { contentDiv.innerHTML = safeMarkdown(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 (stats) { let statsDiv = lastMsg.querySelector('.msg-stats'); if (!statsDiv) { statsDiv = document.createElement('div'); statsDiv.className = 'msg-stats'; lastMsg.querySelector('.msg-body')!.appendChild(statsDiv); } const parts: string[] = []; if (stats.eval_count != null && stats.eval_count > 0) parts.push(`${stats.eval_count} tokens`); if (stats.total_duration != null && stats.total_duration > 0) parts.push(`${(stats.total_duration / 1e9).toFixed(1)}s`); if (parts.length) statsDiv.innerHTML = parts.join(' · '); } scrollToBottom(); } 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(); } export function clearMessages(): void { messagesContainerEl.innerHTML = ''; 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 as any).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`); }