Files
metona-ollama-desktop/src/renderer/components/chat-area.ts
T

583 lines
23 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>();
/** 标记当前渲染批次中系统提示词卡片是否已渲染(每会话仅首条 assistant 消息展示) */
let _sysPromptRendered = false;
export function renderMessages(): void {
const currentSession = state.get<ChatSession | null>(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)
? `<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>`;
}
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) {
// 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 = `
<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();
}
/** 实时追加工具调用卡片到当前 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 = `
<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">正在思考...</span>
</div>
</div>
`;
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<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)} · ${session.model}</p><hr>`;
session.messages.forEach(m => {
html += `<div class="${m.role}"><strong>${m.role === 'user' ? '👤 用户' : '🤖 AI'}</strong><br>${(m.content || '').replace(/\n/g, '<br>')}</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`);
}