/** * ChatArea - 聊天区域组件 * 负责消息渲染、流式更新、导出功能 */ import { state, KEYS } from '../state.js'; import { marked } from '../marked-config.js'; import { escapeHtml, formatTime, downloadFile } from '../utils.js'; /** 根据文件名返回合适的图标 */ function getFileIcon(filename) { const name = filename.toLowerCase(); if (name === 'dockerfile') return '🐳'; if (name === 'makefile' || name === 'gnumakefile') return '🔨'; const ext = filename.split('.').pop().toLowerCase(); const map = { py: '🐍', pyw: '🐍', pyi: '🐍', js: '💛', mjs: '💛', cjs: '💛', ts: '🔷', tsx: '⚛️', jsx: '⚛️', java: '☕', go: '🐹', rs: '🦀', rb: '💎', php: '🐘', swift: '🍎', kt: '🤖', scala: '🔴', lua: '🌙', pl: '🐪', r: '📐', m: '📐', cs: '🟪', fs: '🔵', vb: '🔷', ex: '💧', exs: '💧', erl: '📞', hs: '🟣', clj: '🟢', lisp: '🟢', cpp: '⚙️', cc: '⚙️', cxx: '⚙️', hpp: '⚙️', c: '⚙️', h: '⚙️', sh: '🐚', bash: '🐚', zsh: '🐚', fish: '🐚', html: '🌐', htm: '🌐', css: '🎨', svg: '🎨', json: '📋', yaml: '📋', yml: '📋', toml: '📋', ini: '📋', cfg: '📋', conf: '📋', xml: '📰', sql: '🗃️', md: '📝', markdown: '📝', rst: '📝', txt: '📄', log: '📜', csv: '📊', tsv: '📊', diff: '🔀', patch: '🔀', }; return map[ext] || '📄'; } let chatAreaEl, messagesContainerEl, emptyStateEl, scrollBtnEl; let autoScroll = true; // 是否自动滚动到底部 export function initChatArea() { 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' }); }); } /** 判断是否在底部附近(60px 容差) */ function isAtBottom() { return chatAreaEl.scrollHeight - chatAreaEl.scrollTop - chatAreaEl.clientHeight < 60; } /** 安全地将 Markdown 转为 HTML */ export function safeMarkdown(text) { if (!text && text !== 0) return ''; const str = typeof text === 'string' ? text : String(text); if (!str) return ''; try { const result = marked.parse(str); return typeof result === 'string' ? result : escapeHtml(str); } catch (e) { return escapeHtml(str); } } /** 滚动到底部(仅当 autoScroll 开启时) */ export function scrollToBottom() { if (!autoScroll) return; requestAnimationFrame(() => { chatAreaEl.scrollTop = chatAreaEl.scrollHeight; }); } /** 渲染所有消息(增量渲染) */ export function renderMessages() { 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); } // 新消息按 autoScroll 决定;首次渲染(加载历史)强制滚到底部 if (isNewMessage) { scrollToBottom(); } else if (msgs.length > 0) { chatAreaEl.scrollTop = chatAreaEl.scrollHeight; } } /** 追加单条消息 DOM */ export function appendMessageDOM(msg, index) { const div = document.createElement('div'); div.className = `message ${msg.role}`; div.dataset.index = 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 += '
'; } // 文件 chip(展示用,不暴露文件内容) 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 || msg.files?.length > 0)) { contentHtml = contentHtml.replace('
', ''); } else if (msg.role === 'user' && msg.files?.length > 0 && msg.content) { // 有文本也有文件,保留文本内容展示 } // 统计信息 const stats = []; if (msg.timestamp) stats.push(formatTime(msg.timestamp)); if (msg.eval_count) stats.push(`${msg.eval_count} tokens`); if (msg.total_duration) stats.push(`${(msg.total_duration / 1e9).toFixed(1)}s`); if (stats.length > 0) { contentHtml += `
${stats.map(s => `${s}`).join(' · ')}
`; } div.innerHTML = `
${avatar}
${roleLabel}${modelTag}
${contentHtml}
`; messagesContainerEl.appendChild(div); } /** 更新最后一条 assistant 消息(流式输出) */ export function updateLastAssistantMessage(content, think, stats, model) { const lastMsg = messagesContainerEl.querySelector('.message.assistant:last-child'); if (!lastMsg) return; 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 = []; if (stats.eval_count) parts.push(`${stats.eval_count} tokens`); if (stats.total_duration) parts.push(`${(stats.total_duration / 1e9).toFixed(1)}s`); if (parts.length) statsDiv.innerHTML = parts.join(' · '); } scrollToBottom(); } /** 追加临时的 assistant 消息占位 */ export function appendAssistantPlaceholder() { const div = document.createElement('div'); div.className = 'message assistant'; div.innerHTML = `
🤖
AI
正在思考...
`; messagesContainerEl.appendChild(div); scrollToBottom(); } /** 追加系统提示消息 */ export function appendSystemMessage(text) { const div = document.createElement('div'); div.style.cssText = 'text-align:center;padding:8px;font-size:12px;color:var(--text-muted);'; div.textContent = text; emptyStateEl.style.display = 'none'; messagesContainerEl.style.display = ''; messagesContainerEl.appendChild(div); scrollToBottom(); } /** 获取消息容器 */ export function getMessagesContainer() { return messagesContainerEl; } /** 清空消息 DOM */ export function clearMessages() { messagesContainerEl.innerHTML = ''; } /** 重置自动滚动(用户发送消息、新建会话时调用) */ export function enableAutoScroll() { autoScroll = true; if (scrollBtnEl) scrollBtnEl.style.display = 'none'; } // ── 导出功能 ── /** 导出为 Markdown */ export function exportAsMarkdown(session) { let md = `# ${session.title}\n\n`; md += `**模型:** ${session.model} \n`; md += `**时间:** ${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`; }); downloadFile(`${session.title}.md`, md, 'text/markdown'); } /** 导出为 HTML */ export function exportAsHtml(session) { let html = `${escapeHtml(session.title)}

${escapeHtml(session.title)}

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


`; session.messages.forEach(m => { const fileChips = (m.files && m.files.length > 0) ? '
' + m.files.map(f => `📎 ${escapeHtml(f.name)}`).join(' ') : ''; html += `
${m.role === 'user' ? '👤 用户' : '🤖 AI'}${fileChips}
${(m.content || '').replace(/\n/g, '
')}
`; }); html += ''; downloadFile(`${session.title}.html`, html, 'text/html'); } /** 导出为 TXT */ export function exportAsTxt(session) { 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`; if (m.files && m.files.length > 0) { txt += m.files.map(f => `📎 ${f.name}`).join(' · ') + '\n'; } txt += `${m.content || ''}\n\n`; }); downloadFile(`${session.title}.txt`, txt, 'text/plain'); }