- 版本号更新: 1.0.0 → 1.1.0,全量同步 package.json/lock/README/docs/UI - 上下文长度自动检测: 切换模型时从 model_info 读取实际 context_length - SOUL.md 支持: 每次发送自动扫描工作空间 SOUL.md,注入为首条 system 消息且不可压缩 - 系统提示词卡片: AI 回复顶部可折叠展示实际发送给模型的完整 system prompt - Token 校准: 利用 Ollama 返回的实际计数动态修正估算器(EMA) - 消息重要性评分: 纯规则打分,trim/summarize 按重要性而非时间顺序 - 结构化压缩: LLM 压缩输出 JSON 格式(topics/decisions/pendingTasks/constraints) - 技能系统 v1.1: 语义匹配(embedding) + 参数自优化 + 未使用衰减 + 技能链合并 - 修复: 100+ TypeScript strict 模式编译错误清零
553 lines
23 KiB
TypeScript
553 lines
23 KiB
TypeScript
/**
|
||
* 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 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 renderMessages(): void {
|
||
const currentSession = state.get<ChatSession | null>(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 = '';
|
||
|
||
// 只计算已渲染的 .message 元素(排除 loading placeholder 和其他非消息元素)
|
||
const existingCount = messagesContainerEl.querySelectorAll('.message:not(.loading)').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)
|
||
? `<span class="model-tag">${escapeHtml(msg.model)}</span>`
|
||
: '';
|
||
|
||
let contentHtml = '';
|
||
const safeContent = (msg.content != null) ? String(msg.content) : '';
|
||
|
||
if (msg.role === 'assistant') {
|
||
// ── 系统提示词折叠卡片(每条助理消息顶部展示)──
|
||
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
|
||
if (sysPrompt) {
|
||
contentHtml += renderSystemPromptCard(sysPrompt);
|
||
}
|
||
|
||
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.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 icons: Record<string, string> = {
|
||
read_file: '📄', write_file: '✏️', list_directory: '📁',
|
||
search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻',
|
||
move_file: '📦', copy_file: '📋', web_fetch: '🌐', append_file: '➕',
|
||
edit_file: '✂️', get_file_info: 'ℹ️', tree: '🌳', download_file: '⬇️',
|
||
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
|
||
git: '🔖', compress: '🗜️', web_search: '🔍',
|
||
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖',
|
||
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
|
||
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
|
||
};
|
||
const names: Record<string, string> = {
|
||
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
|
||
search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令',
|
||
move_file: '移动文件', copy_file: '复制文件', web_fetch: '网页抓取', append_file: '追加文件',
|
||
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
|
||
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
|
||
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
|
||
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
|
||
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
|
||
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
|
||
};
|
||
const statusLabels: Record<string, string> = {
|
||
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 += `<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;
|
||
|
||
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();
|
||
|
||
// 流式消息首次转正:注入系统提示词折叠卡片
|
||
const sysPrompt = state.get<string>('_lastSystemPrompt', '');
|
||
if (sysPrompt && !lastMsg.querySelector('.system-prompt-card')) {
|
||
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) {
|
||
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 = `
|
||
<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 的卡片
|
||
const existing = container.querySelector(`.tool-call-card[data-tool-name="${tc.name}"].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 = '';
|
||
}
|
||
|
||
export function clearMessages(): void {
|
||
messagesContainerEl.innerHTML = '';
|
||
currentPlaceholder = null;
|
||
}
|
||
|
||
/** 清理 currentPlaceholder 引用(当 placeholder 从外部被移除时调用) */
|
||
export function resetCurrentPlaceholder(): void {
|
||
if (currentPlaceholder && !currentPlaceholder.isConnected) {
|
||
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`);
|
||
}
|