feat: TypeScript + Electron v2 重构 - 纯桌面版
- 全面迁移到 TypeScript,严格类型定义 - 放弃 Web 版,专注 Electron 桌面应用 - 主进程模块化:main.ts, preload.ts, menu.ts, tray.ts, ipc.ts, utils.ts - 渲染进程完整迁移所有功能组件 - 删除 PWA 相关文件 (sw.js, manifest.json) - 删除 Web 版降级逻辑 - 保留所有核心功能:流式对话、多模型、Think推理、多模态、RAG知识库、Agent预设、历史管理 - 保留 Windows 11 Fluent Design 暗色主题样式
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* ChatArea - 聊天区域组件
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { marked } from '../utils/marked-config.js';
|
||||
import { escapeHtml, formatTime, downloadFile } from '../utils/utils.js';
|
||||
import type { ChatSession, ChatMessage } 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;
|
||||
|
||||
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 = '';
|
||||
|
||||
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)
|
||||
? `<span class="model-tag">${escapeHtml(msg.model)}</span>`
|
||||
: '';
|
||||
|
||||
let contentHtml = '';
|
||||
const safeContent = (msg.content != null) ? String(msg.content) : '';
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
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>', '');
|
||||
}
|
||||
|
||||
const stats: string[] = [];
|
||||
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 += `<div class="msg-stats">${stats.map(s => `<span>${s}</span>`).join(' · ')}</div>`;
|
||||
}
|
||||
|
||||
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 `<div class="rag-source-item">
|
||||
<span class="rag-source-name">📄 来源 ${i + 1}: ${escapeHtml(s.filename)}</span>
|
||||
${scorePercent ? `<span class="rag-source-score">${scorePercent}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
contentHtml += `<div class="rag-sources"><div class="rag-sources-header">🧠 基于知识库回答</div>${sourcesHtml}</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);
|
||||
}
|
||||
|
||||
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 = `
|
||||
<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 (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) 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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export function getMessagesContainer(): HTMLElement {
|
||||
return messagesContainerEl;
|
||||
}
|
||||
|
||||
export function clearMessages(): void {
|
||||
messagesContainerEl.innerHTML = '';
|
||||
currentPlaceholder = null;
|
||||
}
|
||||
|
||||
export function enableAutoScroll(): void {
|
||||
autoScroll = true;
|
||||
if (scrollBtnEl) scrollBtnEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// ── 导出功能 ──
|
||||
|
||||
export function exportAsMarkdown(session: ChatSession): 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`;
|
||||
});
|
||||
downloadFile(`${session.title}.md`, md, 'text/markdown');
|
||||
}
|
||||
|
||||
export function exportAsHtml(session: ChatSession): 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:#202020;color:#fff;}
|
||||
.user{background:rgba(25,25,60,0.5);padding:12px;border-radius:8px;margin:8px 0;}
|
||||
.assistant{background:rgba(96,205,255,0.03);padding:12px;border-radius:8px;margin:8px 0;border:1px solid rgba(96,205,255,0.08);}
|
||||
pre{background:rgba(0,0,0,0.3);padding:12px;border-radius:8px;overflow-x:auto;}
|
||||
code{background:rgba(0,0,0,0.2);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>';
|
||||
downloadFile(`${session.title}.html`, html, 'text/html');
|
||||
}
|
||||
|
||||
export function exportAsTxt(session: ChatSession): 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`;
|
||||
});
|
||||
downloadFile(`${session.title}.txt`, txt, 'text/plain');
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Header - 顶部导航组件
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { formatSize } from '../utils/utils.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
|
||||
let connStatusEl: HTMLElement;
|
||||
|
||||
export function initHeader(): void {
|
||||
connStatusEl = document.querySelector('#connStatus')!;
|
||||
}
|
||||
|
||||
export async function checkConnection(): Promise<{ ok: boolean; version?: { version: string }; error?: Error }> {
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
if (!api) return { ok: false, error: new Error('API 未初始化') };
|
||||
|
||||
connStatusEl.className = 'conn-status pending';
|
||||
connStatusEl.querySelector('.status-label')!.textContent = '连接中...';
|
||||
|
||||
try {
|
||||
const version = await api.getVersion();
|
||||
connStatusEl.className = 'conn-status connected';
|
||||
connStatusEl.title = `已连接 (v${version.version})`;
|
||||
connStatusEl.querySelector('.status-label')!.textContent = '已连接';
|
||||
return { ok: true, version };
|
||||
} catch (err) {
|
||||
connStatusEl.className = 'conn-status disconnected';
|
||||
connStatusEl.title = '未连接';
|
||||
connStatusEl.querySelector('.status-label')!.textContent = '未连接';
|
||||
return { ok: false, error: err as Error };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateConnectionInfo(): Promise<void> {
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
if (!api) return;
|
||||
|
||||
const badge = document.querySelector('#connBadge')!;
|
||||
const verEl = document.querySelector('#serverVersion')!;
|
||||
const corsHint = document.querySelector('#corsHint') as HTMLElement;
|
||||
|
||||
badge.textContent = '检测中...';
|
||||
badge.className = 'status-badge warning';
|
||||
verEl.textContent = '';
|
||||
|
||||
try {
|
||||
const version = await api.getVersion();
|
||||
badge.textContent = '✓ 已连接';
|
||||
badge.className = 'status-badge success';
|
||||
verEl.textContent = `v${version.version}`;
|
||||
corsHint.style.display = 'none';
|
||||
} catch (err) {
|
||||
badge.textContent = '✗ 未连接';
|
||||
badge.className = 'status-badge error';
|
||||
verEl.textContent = '';
|
||||
|
||||
if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError') || (err as Error).message.includes('CORS')) {
|
||||
corsHint.style.display = '';
|
||||
} else {
|
||||
corsHint.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRunningModels(): Promise<void> {
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
if (!api) return;
|
||||
|
||||
const container = document.querySelector('#runningModels')!;
|
||||
try {
|
||||
const data = await api.psModels();
|
||||
if (!data.models || data.models.length === 0) {
|
||||
container.innerHTML = '<p class="text-muted">无运行中的模型</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = data.models.map(m => `
|
||||
<div class="running-model">
|
||||
<span class="model-name">${m.name}</span>
|
||||
<span class="model-vram">${formatSize(m.size_vram || 0)} VRAM</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch {
|
||||
container.innerHTML = '<p class="text-muted">无法获取运行信息</p>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* HistoryModal - 历史记录面板组件
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { debounce, escapeHtml, formatTime } from '../utils/utils.js';
|
||||
import { exportAsMarkdown, exportAsHtml, exportAsTxt, renderMessages, clearMessages } from './chat-area.js';
|
||||
import { ChatDB } from '../db/chat-db.js';
|
||||
import type { ChatSession } from '../types.js';
|
||||
|
||||
const HISTORY_PAGE_SIZE = 10;
|
||||
|
||||
let historyModalEl: HTMLElement;
|
||||
let historyListEl: HTMLElement;
|
||||
let historyPaginationEl: HTMLElement;
|
||||
let historyPage = 1;
|
||||
let historySearchQuery = '';
|
||||
|
||||
export function initHistoryModal(): void {
|
||||
historyModalEl = document.querySelector('#historyModal')!;
|
||||
historyListEl = document.querySelector('#historyList')!;
|
||||
historyPaginationEl = document.querySelector('#historyPagination')!;
|
||||
|
||||
document.querySelector('#btnHistory')!.addEventListener('click', openHistoryModal);
|
||||
document.querySelector('#btnCloseHistory')!.addEventListener('click', closeHistoryModal);
|
||||
historyModalEl.addEventListener('click', (e) => {
|
||||
if (e.target === historyModalEl) closeHistoryModal();
|
||||
});
|
||||
|
||||
document.querySelector('#btnExitHistory')!.addEventListener('click', () => {
|
||||
(document.querySelector('#btnNewChat') as HTMLElement).click();
|
||||
});
|
||||
|
||||
const historySearchInput = document.querySelector('#historySearchInput') as HTMLInputElement;
|
||||
const historySearchClear = document.querySelector('#historySearchClear') as HTMLElement;
|
||||
const doHistorySearch = debounce(() => {
|
||||
historySearchQuery = historySearchInput.value.trim();
|
||||
historySearchClear.style.display = historySearchQuery ? '' : 'none';
|
||||
historyPage = 1;
|
||||
loadHistory();
|
||||
}, 250);
|
||||
historySearchInput.addEventListener('input', doHistorySearch);
|
||||
historySearchClear.addEventListener('click', () => {
|
||||
historySearchInput.value = '';
|
||||
historySearchQuery = '';
|
||||
historySearchClear.style.display = 'none';
|
||||
historyPage = 1;
|
||||
loadHistory();
|
||||
});
|
||||
|
||||
historyListEl.addEventListener('click', async (e) => {
|
||||
const target = (e.target as HTMLElement).closest('[data-id]') as HTMLElement;
|
||||
if (!target) return;
|
||||
const sessionId = target.dataset.id!;
|
||||
const db = state.get<ChatDB>(KEYS.DB);
|
||||
|
||||
if (target.classList.contains('btn-delete-session')) {
|
||||
if (confirm('确定删除此会话?')) {
|
||||
await db!.deleteSession(sessionId);
|
||||
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||
if (currentSession && currentSession.id === sessionId) {
|
||||
(document.querySelector('#btnNewChat') as HTMLElement).click();
|
||||
}
|
||||
loadHistory();
|
||||
}
|
||||
} else if (target.classList.contains('btn-export-md')) {
|
||||
const session = await db!.getSession(sessionId);
|
||||
if (session) exportAsMarkdown(session);
|
||||
} else if (target.classList.contains('btn-export-html')) {
|
||||
const session = await db!.getSession(sessionId);
|
||||
if (session) exportAsHtml(session);
|
||||
} else if (target.classList.contains('btn-export-txt')) {
|
||||
const session = await db!.getSession(sessionId);
|
||||
if (session) exportAsTxt(session);
|
||||
} else if (target.classList.contains('history-info') || target.classList.contains('history-item')) {
|
||||
loadHistorySession(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
historyPaginationEl.addEventListener('click', (e) => {
|
||||
const btn = (e.target as HTMLElement).closest('.page-btn') as HTMLElement;
|
||||
if (!btn || btn.classList.contains('disabled')) return;
|
||||
const page = parseInt(btn.dataset.page!);
|
||||
if (page && page !== historyPage) {
|
||||
historyPage = page;
|
||||
loadHistory();
|
||||
historyListEl.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHistory(): Promise<void> {
|
||||
const db = state.get<ChatDB>(KEYS.DB);
|
||||
if (!db) return;
|
||||
|
||||
let allSessions = await db.getAllSessions();
|
||||
|
||||
if (historySearchQuery) {
|
||||
const q = historySearchQuery.toLowerCase();
|
||||
allSessions = allSessions.filter(s => {
|
||||
if (s.title?.toLowerCase().includes(q)) return true;
|
||||
if (s.model?.toLowerCase().includes(q)) return true;
|
||||
return s.messages.some(m => m.content?.toLowerCase().includes(q));
|
||||
});
|
||||
}
|
||||
|
||||
allSessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
if (allSessions.length === 0) {
|
||||
historyListEl.innerHTML = `<div class="empty-history"><p class="text-muted">暂无历史记录</p></div>`;
|
||||
historyPaginationEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(allSessions.length / HISTORY_PAGE_SIZE);
|
||||
if (historyPage > totalPages) historyPage = totalPages;
|
||||
|
||||
const start = (historyPage - 1) * HISTORY_PAGE_SIZE;
|
||||
const pageSessions = allSessions.slice(start, start + HISTORY_PAGE_SIZE);
|
||||
|
||||
historyListEl.innerHTML = pageSessions.map(s => `
|
||||
<div class="history-item" data-id="${s.id}">
|
||||
<div class="history-info" data-id="${s.id}">
|
||||
<div class="history-title">${escapeHtml(s.title)}</div>
|
||||
<div class="history-meta">
|
||||
<span>${formatTime(s.updatedAt)}</span>
|
||||
<span>${s.messages.length} 条消息</span>
|
||||
<span>${s.model || '无模型'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-actions">
|
||||
<button class="icon-btn sm btn-export-md" data-id="${s.id}" title="导出为 Markdown">📄</button>
|
||||
<button class="icon-btn sm btn-export-html" data-id="${s.id}" title="导出为 HTML">🌐</button>
|
||||
<button class="icon-btn sm btn-export-txt" data-id="${s.id}" title="导出为 TXT">📝</button>
|
||||
<button class="icon-btn sm danger btn-delete-session" data-id="${s.id}" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
if (totalPages <= 1) {
|
||||
historyPaginationEl.innerHTML = `<span class="page-info">共 ${allSessions.length} 条</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<span class="page-info">共 ${allSessions.length} 条</span><div class="page-buttons">`;
|
||||
html += `<button class="page-btn${historyPage <= 1 ? ' disabled' : ''}" data-page="${historyPage - 1}">‹</button>`;
|
||||
const range = 2;
|
||||
const pages: number[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= historyPage - range && i <= historyPage + range)) pages.push(i);
|
||||
}
|
||||
let prev = 0;
|
||||
for (const p of pages) {
|
||||
if (p - prev > 1) html += '<span class="page-ellipsis">…</span>';
|
||||
html += `<button class="page-btn${p === historyPage ? ' active' : ''}" data-page="${p}">${p}</button>`;
|
||||
prev = p;
|
||||
}
|
||||
html += `<button class="page-btn${historyPage >= totalPages ? ' disabled' : ''}" data-page="${historyPage + 1}">›</button></div>`;
|
||||
historyPaginationEl.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadHistorySession(sessionId: string): Promise<void> {
|
||||
const db = state.get<ChatDB>(KEYS.DB);
|
||||
if (!db) return;
|
||||
|
||||
const session = await db.getSession(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
state.set(KEYS.CURRENT_SESSION, session);
|
||||
state.set(KEYS.IS_HISTORY_VIEW, true);
|
||||
|
||||
(document.querySelector('#historyBar') as HTMLElement).style.display = '';
|
||||
(document.querySelector('#inputArea') as HTMLElement).style.display = 'none';
|
||||
|
||||
clearMessages();
|
||||
renderMessages();
|
||||
closeHistoryModal();
|
||||
}
|
||||
|
||||
export function openHistoryModal(): void {
|
||||
historyPage = 1;
|
||||
historySearchQuery = '';
|
||||
(document.querySelector('#historySearchInput') as HTMLInputElement).value = '';
|
||||
(document.querySelector('#historySearchClear') as HTMLElement).style.display = 'none';
|
||||
loadHistory();
|
||||
historyModalEl.style.display = '';
|
||||
}
|
||||
|
||||
export function closeHistoryModal(): void {
|
||||
historyModalEl.style.display = 'none';
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* InputArea - 输入区域组件
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { fileToBase64, fileToText, detectLanguage, truncate, escapeHtml, formatSize } from '../utils/utils.js';
|
||||
import { getSelectedModel, isThinkEnabled, isVisionAvailable } from './model-bar.js';
|
||||
import {
|
||||
renderMessages, appendAssistantPlaceholder, appendSystemMessage,
|
||||
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll
|
||||
} from './chat-area.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { isRagEnabled, performRagRetrieval } from './kb-modal.js';
|
||||
import { ChatDB } from '../db/chat-db.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import type { ChatSession, ChatMessage, OllamaStreamChunk, RagSource, FileContent, ChatFile } from '../types.js';
|
||||
|
||||
let chatInputEl: HTMLTextAreaElement;
|
||||
let btnSendEl: HTMLButtonElement;
|
||||
let fileInputEl: HTMLInputElement;
|
||||
let textFileInputEl: HTMLInputElement;
|
||||
let imagePreviewEl: HTMLElement;
|
||||
let filePreviewEl: HTMLElement;
|
||||
let pendingImages: Array<{ name: string; base64: string }> = [];
|
||||
let pendingFiles: Array<{ name: string; content: string; language: string; size: number }> = [];
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'txt','md','markdown','rst','log','csv','tsv',
|
||||
'py','pyw','pyi','js','mjs','cjs','ts','tsx','jsx',
|
||||
'java','c','h','cpp','cc','cxx','hpp','go','rs','rb','php',
|
||||
'sh','bash','zsh','fish','sql','html','htm','css',
|
||||
'json','jsonl','xml','svg','yaml','yml','toml','ini','cfg','conf',
|
||||
'swift','kt','kts','scala','lua','r','m','mm',
|
||||
'cs','fs','vb','ex','exs','erl','hs','clj','lisp',
|
||||
'diff','patch','pl','dockerfile','makefile'
|
||||
]);
|
||||
|
||||
const MAX_FILE_SIZE = 500 * 1024;
|
||||
|
||||
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: '🔷', java: '☕', go: '🐹', rs: '🦀', cpp: '⚙️', c: '⚙️', sh: '🐚', html: '🌐', css: '🎨', json: '📋', md: '📝', txt: '📄', sql: '🗃️', csv: '📊' };
|
||||
return map[ext] || '📄';
|
||||
}
|
||||
|
||||
function isTextFile(file: File): boolean {
|
||||
const name = file.name.toLowerCase();
|
||||
if (name === 'dockerfile' || name === 'makefile') return true;
|
||||
const ext = name.split('.').pop() || '';
|
||||
return TEXT_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
export function initInputArea(): void {
|
||||
chatInputEl = document.querySelector('#chatInput')!;
|
||||
btnSendEl = document.querySelector('#btnSend')!;
|
||||
fileInputEl = document.querySelector('#fileInput')!;
|
||||
textFileInputEl = document.querySelector('#textFileInput')!;
|
||||
imagePreviewEl = document.querySelector('#imagePreview')!;
|
||||
filePreviewEl = document.querySelector('#filePreview')!;
|
||||
|
||||
btnSendEl.addEventListener('click', () => {
|
||||
if (state.get<boolean>(KEYS.IS_STREAMING)) {
|
||||
stopGeneration();
|
||||
} else {
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
chatInputEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
chatInputEl.addEventListener('input', autoResizeTextarea);
|
||||
|
||||
document.querySelector('#btnAttachImg')!.addEventListener('click', () => {
|
||||
if (!isVisionAvailable()) {
|
||||
showToast('当前模型不支持图片分析', 'warning');
|
||||
return;
|
||||
}
|
||||
fileInputEl.click();
|
||||
});
|
||||
fileInputEl.addEventListener('change', (e) => {
|
||||
handleImageSelect((e.target as HTMLInputElement).files!);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
});
|
||||
|
||||
document.querySelector('#btnAttachFile')!.addEventListener('click', () => textFileInputEl.click());
|
||||
textFileInputEl.addEventListener('change', (e) => {
|
||||
handleTextFileSelect((e.target as HTMLInputElement).files!);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
});
|
||||
|
||||
imagePreviewEl.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('remove-img')) {
|
||||
pendingImages.splice(parseInt((target as HTMLElement).dataset.index!), 1);
|
||||
renderImagePreviews();
|
||||
}
|
||||
});
|
||||
|
||||
filePreviewEl.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('remove-file')) {
|
||||
pendingFiles.splice(parseInt((target as HTMLElement).dataset.index!), 1);
|
||||
renderFilePreviews();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function autoResizeTextarea(): void {
|
||||
chatInputEl.style.height = 'auto';
|
||||
chatInputEl.style.height = Math.min(chatInputEl.scrollHeight, 120) + 'px';
|
||||
}
|
||||
|
||||
async function handleImageSelect(files: FileList): Promise<void> {
|
||||
const maxSize = 20 * 1024 * 1024;
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
if (file.size > maxSize) {
|
||||
appendSystemMessage(`⚠️ 图片 ${file.name} 超过 20MB 限制`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
pendingImages.push({ name: file.name, base64 });
|
||||
renderImagePreviews();
|
||||
} catch (err) {
|
||||
console.error('[InputArea] 图片读取失败:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderImagePreviews(): void {
|
||||
if (pendingImages.length === 0) {
|
||||
imagePreviewEl.style.display = 'none';
|
||||
imagePreviewEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
imagePreviewEl.style.display = '';
|
||||
imagePreviewEl.innerHTML = pendingImages.map((img, i) => `
|
||||
<div class="preview-thumb">
|
||||
<img src="data:image/png;base64,${img.base64}" alt="${escapeHtml(img.name)}">
|
||||
<button class="remove-img" data-index="${i}">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function handleTextFileSelect(files: FileList): Promise<void> {
|
||||
for (const file of Array.from(files)) {
|
||||
if (!isTextFile(file)) {
|
||||
appendSystemMessage(`⚠️ ${file.name} 不是支持的文本/代码文件`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
appendSystemMessage(`⚠️ ${file.name} 超过 500KB 限制(${formatSize(file.size)})`);
|
||||
continue;
|
||||
}
|
||||
if (pendingFiles.some(f => f.name === file.name && f.size === file.size)) {
|
||||
appendSystemMessage(`ℹ️ ${file.name} 已添加`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const content = await fileToText(file);
|
||||
pendingFiles.push({ name: file.name, content, language: detectLanguage(file.name), size: file.size });
|
||||
renderFilePreviews();
|
||||
} catch (err) {
|
||||
console.error('[InputArea] 文件读取失败:', err);
|
||||
appendSystemMessage(`❌ 读取 ${file.name} 失败`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderFilePreviews(): void {
|
||||
if (pendingFiles.length === 0) {
|
||||
filePreviewEl.style.display = 'none';
|
||||
filePreviewEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
filePreviewEl.style.display = '';
|
||||
filePreviewEl.innerHTML = pendingFiles.map((f, i) => `
|
||||
<div class="file-chip">
|
||||
<span class="file-icon">${getFileIcon(f.name)}</span>
|
||||
<span class="file-name">${escapeHtml(f.name)}</span>
|
||||
<span class="file-size">${formatSize(f.size)}</span>
|
||||
<button class="remove-file" data-index="${i}" title="移除">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function updateSendButton(streaming: boolean): void {
|
||||
if (streaming) {
|
||||
btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
|
||||
btnSendEl.classList.add('stop-btn');
|
||||
btnSendEl.classList.remove('disabled');
|
||||
btnSendEl.title = '停止生成';
|
||||
} else {
|
||||
btnSendEl.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`;
|
||||
btnSendEl.classList.remove('stop-btn');
|
||||
btnSendEl.classList.remove('disabled');
|
||||
btnSendEl.title = '发送';
|
||||
}
|
||||
}
|
||||
|
||||
function stopGeneration(): void {
|
||||
const abortController = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER);
|
||||
if (abortController) abortController.abort();
|
||||
}
|
||||
|
||||
function buildApiMessages(messages: ChatMessage[]): Array<{ role: string; content: string; images?: string[] }> {
|
||||
return messages.map(m => {
|
||||
let content = m.content || '';
|
||||
if (m._fileContents && m._fileContents.length > 0) {
|
||||
const fileParts = m._fileContents.map(f => {
|
||||
const lang = f.language || '';
|
||||
return `📄 以下是一个文件内容:\n\n\`\`\`${lang}\n${f.content}\n\`\`\``;
|
||||
});
|
||||
if (content) {
|
||||
content += '\n\n---\n' + fileParts.join('\n\n---\n');
|
||||
} else {
|
||||
const count = m._fileContents.length;
|
||||
content = `请分析以下 ${count > 1 ? count + ' 个' : ''}文件:\n\n${fileParts.join('\n\n---\n')}`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
role: m.role,
|
||||
content,
|
||||
...(m.images && { images: m.images })
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendMessage(): Promise<void> {
|
||||
const text = chatInputEl.value.trim();
|
||||
if (!text && pendingImages.length === 0 && pendingFiles.length === 0) return;
|
||||
if (state.get<boolean>(KEYS.IS_STREAMING)) return;
|
||||
|
||||
const model = getSelectedModel();
|
||||
if (!model) {
|
||||
appendSystemMessage('⚠️ 请先选择一个模型');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingImages.length > 0 && !isVisionAvailable()) {
|
||||
appendSystemMessage('⚠️ 当前模型不支持图片分析,请移除图片后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||
if (!currentSession) return;
|
||||
|
||||
const now = Date.now();
|
||||
const msgsToAdd: ChatMessage[] = [];
|
||||
|
||||
const userFiles: ChatFile[] = pendingFiles.map(f => ({ name: f.name, language: f.language, size: f.size }));
|
||||
|
||||
if (pendingImages.length > 0) {
|
||||
msgsToAdd.push({
|
||||
role: 'user',
|
||||
content: pendingImages.length === 1 ? `[上传了图片: ${pendingImages[0].name}]` : `[上传了 ${pendingImages.length} 张图片]`,
|
||||
images: pendingImages.map(img => img.base64),
|
||||
timestamp: now
|
||||
});
|
||||
}
|
||||
|
||||
if (text || pendingFiles.length > 0) {
|
||||
const msg: ChatMessage = {
|
||||
role: 'user',
|
||||
content: text || '',
|
||||
timestamp: now
|
||||
};
|
||||
if (userFiles.length > 0) {
|
||||
msg.files = userFiles;
|
||||
msg._fileContents = pendingFiles.map(f => ({ language: f.language, content: f.content }));
|
||||
}
|
||||
msgsToAdd.push(msg);
|
||||
}
|
||||
|
||||
const isFirstMsg = currentSession.messages.length === 0;
|
||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||
...session,
|
||||
...(isFirstMsg && {
|
||||
title: truncate(text || (pendingFiles.length > 0 ? `[文件: ${pendingFiles.map(f => f.name).join(', ')}]` : '[图片消息]'), 30),
|
||||
model
|
||||
}),
|
||||
messages: [...session.messages, ...msgsToAdd],
|
||||
updatedAt: Date.now()
|
||||
}));
|
||||
|
||||
const freshSession = state.get<ChatSession>(KEYS.CURRENT_SESSION);
|
||||
|
||||
enableAutoScroll();
|
||||
renderMessages();
|
||||
await saveCurrentSession();
|
||||
|
||||
chatInputEl.value = '';
|
||||
pendingImages = [];
|
||||
pendingFiles = [];
|
||||
imagePreviewEl.style.display = 'none';
|
||||
imagePreviewEl.innerHTML = '';
|
||||
filePreviewEl.style.display = 'none';
|
||||
filePreviewEl.innerHTML = '';
|
||||
autoResizeTextarea();
|
||||
|
||||
appendAssistantPlaceholder();
|
||||
|
||||
state.set(KEYS.IS_STREAMING, true);
|
||||
updateSendButton(true);
|
||||
|
||||
const api = state.get<OllamaAPI>(KEYS.API)!;
|
||||
const abortController = new AbortController();
|
||||
state.set(KEYS.ABORT_CONTROLLER, abortController);
|
||||
const thinkMode = isThinkEnabled();
|
||||
const systemPromptEnabled = state.get<boolean>(KEYS.SYSTEM_PROMPT_ENABLED);
|
||||
const systemPrompt = state.get<string>(KEYS.SYSTEM_PROMPT);
|
||||
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
|
||||
|
||||
let assistantContent = '';
|
||||
let thinkContent = '';
|
||||
let finalStats: { eval_count?: number; total_duration?: number } | null = null;
|
||||
let modelName = '';
|
||||
|
||||
try {
|
||||
const apiMessages = buildApiMessages(freshSession.messages);
|
||||
const temperature = state.get<number>('temperature', 0.7);
|
||||
|
||||
const chatParams: Record<string, unknown> = {
|
||||
model,
|
||||
messages: apiMessages,
|
||||
stream: true,
|
||||
think: thinkMode,
|
||||
};
|
||||
if (systemPromptEnabled && systemPrompt) chatParams.system = systemPrompt;
|
||||
if (numCtx) chatParams.options = { num_ctx: numCtx, temperature };
|
||||
|
||||
let ragSources: RagSource[] | null = null;
|
||||
|
||||
if (isRagEnabled()) {
|
||||
appendSystemMessage('🧠 正在检索知识库...', 'rag-status');
|
||||
try {
|
||||
const textForRag = text;
|
||||
const ragResult = await performRagRetrieval(textForRag);
|
||||
if (ragResult && ragResult.results && ragResult.results.length > 0) {
|
||||
const ctxLimit = numCtx || 24576;
|
||||
const outputReserve = 2048;
|
||||
const msgChars = apiMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0);
|
||||
const systemChars = ((chatParams.system as string)?.length || 0);
|
||||
const usedTokens = Math.ceil((msgChars + systemChars) / 2);
|
||||
const ragBudget = Math.max(0, ctxLimit - outputReserve - usedTokens);
|
||||
const ragBudgetChars = ragBudget * 2;
|
||||
|
||||
let ragContext = ragResult.results.map((r: any, i: number) =>
|
||||
`[来源 ${i + 1}: ${r.filename}]\n${r.text}`
|
||||
).join('\n\n---\n\n');
|
||||
|
||||
const ragTemplate = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。\n\n=== 检索到的相关内容 ===\n${ragContext}\n=== 内容结束 ===`;
|
||||
if (ragContext.length + ragTemplate.length > ragBudgetChars) {
|
||||
ragContext = ragContext.slice(0, Math.max(0, ragBudgetChars - ragTemplate.length - 50)) + '\n\n[知识库内容过长,已截取最相关的部分]';
|
||||
appendSystemMessage(`⚠️ 知识库内容已截取(预算 ${ragBudgetChars} 字符)`, 'rag-status');
|
||||
}
|
||||
|
||||
const ragPrompt = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。\n\n=== 检索到的相关内容 ===\n${ragContext}\n=== 内容结束 ===\n\n请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`;
|
||||
|
||||
chatParams.system = chatParams.system
|
||||
? (chatParams.system as string) + '\n\n' + ragPrompt
|
||||
: ragPrompt;
|
||||
|
||||
ragSources = ragResult.results.map((r: any) => ({
|
||||
filename: r.filename,
|
||||
score: r.score,
|
||||
text: truncate(r.text, 100)
|
||||
}));
|
||||
const sourceNames = [...new Set(ragSources.map(s => s.filename))];
|
||||
appendSystemMessage(`🧠 已检索 ${ragSources.length} 个相关片段(来源:${sourceNames.join('、')})`, 'rag-status');
|
||||
} else {
|
||||
appendSystemMessage('🧠 知识库未检索到相关内容,使用通用知识回答', 'rag-status');
|
||||
}
|
||||
} catch (ragErr) {
|
||||
console.error('[RAG] 检索失败:', ragErr);
|
||||
appendSystemMessage(`🧠 知识库检索失败: ${(ragErr as Error).message}`, 'rag-status');
|
||||
}
|
||||
}
|
||||
|
||||
let ragStatusCleared = false;
|
||||
|
||||
await api.chatStream(chatParams as any, (chunk: OllamaStreamChunk) => {
|
||||
if (!ragStatusCleared && chunk.message?.content) {
|
||||
ragStatusCleared = true;
|
||||
document.querySelectorAll('.rag-status').forEach(el => el.remove());
|
||||
}
|
||||
if (chunk.message) {
|
||||
if (chunk.message.content) assistantContent += chunk.message.content;
|
||||
const think = chunk.message.thinking || chunk.message.reasoning_content;
|
||||
if (think && typeof think === 'string') thinkContent += think;
|
||||
}
|
||||
if (chunk.model) modelName = chunk.model;
|
||||
updateLastAssistantMessage(assistantContent, thinkContent || null, null, modelName || undefined);
|
||||
if (chunk.done) {
|
||||
finalStats = { eval_count: chunk.eval_count, total_duration: chunk.total_duration };
|
||||
}
|
||||
}, abortController);
|
||||
|
||||
if (!assistantContent && !thinkContent) {
|
||||
appendSystemMessage('⚠️ 模型未返回任何内容,可能是上下文过长或模型不支持当前请求');
|
||||
}
|
||||
|
||||
const assistantMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: assistantContent || '',
|
||||
timestamp: Date.now(),
|
||||
...(modelName && { model: modelName }),
|
||||
...(thinkContent && { think: thinkContent }),
|
||||
...(finalStats && { eval_count: finalStats.eval_count, total_duration: finalStats.total_duration }),
|
||||
...(ragSources && { ragSources })
|
||||
};
|
||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||
...session,
|
||||
messages: [...session.messages, assistantMsg],
|
||||
updatedAt: Date.now()
|
||||
}));
|
||||
|
||||
if (finalStats) {
|
||||
updateLastAssistantMessage(assistantContent, thinkContent || null, finalStats, modelName || undefined);
|
||||
}
|
||||
|
||||
if (ragSources && ragSources.length > 0) {
|
||||
const lastAssistant = document.querySelector('#messagesContainer .message.assistant:last-of-type');
|
||||
if (lastAssistant) {
|
||||
const sourcesHtml = ragSources.map((s, i) => {
|
||||
const scorePercent = s.score ? `${(s.score * 100).toFixed(0)}%` : '';
|
||||
return `<div class="rag-source-item"><span class="rag-source-name">📄 来源 ${i + 1}: ${escapeHtml(s.filename)}</span>${scorePercent ? `<span class="rag-source-score">${scorePercent}</span>` : ''}</div>`;
|
||||
}).join('');
|
||||
const ragDiv = document.createElement('div');
|
||||
ragDiv.className = 'rag-sources';
|
||||
ragDiv.innerHTML = `<div class="rag-sources-header">🧠 基于知识库回答</div>${sourcesHtml}`;
|
||||
lastAssistant.querySelector('.msg-body')!.appendChild(ragDiv);
|
||||
}
|
||||
}
|
||||
|
||||
await saveCurrentSession();
|
||||
} catch (err) {
|
||||
console.error('[InputArea] 流式聊天错误:', err);
|
||||
document.querySelectorAll('.rag-status').forEach(el => el.remove());
|
||||
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
|
||||
const loadingDots = placeholder?.querySelector('.loading-dots');
|
||||
const loadingText = placeholder?.querySelector('.loading-text');
|
||||
if (loadingDots) loadingDots.remove();
|
||||
if (loadingText) loadingText.remove();
|
||||
placeholder?.classList.remove('loading');
|
||||
|
||||
const contentDiv = placeholder?.querySelector('.msg-content');
|
||||
const partialMsg: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
timestamp: Date.now(),
|
||||
...(modelName && { model: modelName }),
|
||||
...(thinkContent && { think: thinkContent }),
|
||||
stopped: true
|
||||
};
|
||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||
...session,
|
||||
messages: [...session.messages, partialMsg],
|
||||
updatedAt: Date.now()
|
||||
}));
|
||||
|
||||
if (contentDiv) {
|
||||
let html = assistantContent ? safeMarkdown(assistantContent) : '';
|
||||
html += '<p><code>[已停止]</code></p>';
|
||||
contentDiv.innerHTML = html;
|
||||
}
|
||||
appendSystemMessage('⏹ 已停止生成');
|
||||
await saveCurrentSession();
|
||||
state.set(KEYS.IS_STREAMING, false);
|
||||
updateSendButton(false);
|
||||
state.set(KEYS.ABORT_CONTROLLER, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const placeholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
|
||||
const loadingDots = placeholder?.querySelector('.loading-dots');
|
||||
const loadingText = placeholder?.querySelector('.loading-text');
|
||||
if (loadingDots) loadingDots.remove();
|
||||
if (loadingText) loadingText.remove();
|
||||
placeholder?.classList.remove('loading');
|
||||
|
||||
const contentDiv = placeholder?.querySelector('.msg-content');
|
||||
|
||||
if (assistantContent && contentDiv) {
|
||||
state.update(KEYS.CURRENT_SESSION, (session: any) => ({
|
||||
...session,
|
||||
messages: [...session.messages, { role: 'assistant', content: assistantContent + '\n\n`[已中断]`', timestamp: Date.now() } as ChatMessage],
|
||||
updatedAt: Date.now()
|
||||
}));
|
||||
contentDiv.innerHTML = safeMarkdown(assistantContent) + '<p><code>[已中断]</code></p>';
|
||||
await saveCurrentSession();
|
||||
} else {
|
||||
if (placeholder) placeholder.remove();
|
||||
}
|
||||
|
||||
let errMsg = `❌ 错误: ${(err as Error).message}`;
|
||||
if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError')) {
|
||||
errMsg = '❌ 连接失败。请检查:\n1. Ollama 是否正在运行\n2. 地址是否正确\n3. 是否设置了 OLLAMA_ORIGINS="*"';
|
||||
} else if ((err as Error).message.includes('400')) {
|
||||
errMsg = '❌ 请求参数错误,可能是知识库内容超出模型上下文限制';
|
||||
} else if ((err as Error).message.includes('500')) {
|
||||
errMsg = '❌ Ollama 服务器错误,请检查 Ollama 日志';
|
||||
}
|
||||
appendSystemMessage(errMsg);
|
||||
} finally {
|
||||
state.set(KEYS.IS_STREAMING, false);
|
||||
updateSendButton(false);
|
||||
state.set(KEYS.ABORT_CONTROLLER, null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentSession(): Promise<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||
const isHistoryView = state.get<boolean>(KEYS.IS_HISTORY_VIEW);
|
||||
if (!currentSession || !db || isHistoryView) return;
|
||||
currentSession.updatedAt = Date.now();
|
||||
try {
|
||||
await db.saveSession(currentSession);
|
||||
} catch (err) {
|
||||
console.error('[InputArea] 保存会话失败:', err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* KBModal - 知识库管理面板
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import {
|
||||
initVectorStore, getVectorStore, addDocumentToKB,
|
||||
removeDocumentFromKB, getDocumentsInCollection, retrieveContext,
|
||||
buildRagSystemPrompt
|
||||
} from '../services/rag.js';
|
||||
import { fileToText, formatSize, escapeHtml, truncate } from '../utils/utils.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import type { VectorCollection, SearchResult } from '../types.js';
|
||||
|
||||
let kbModalEl: HTMLElement;
|
||||
let currentColId: string | null = null;
|
||||
let ragEnabled = false;
|
||||
let ragCollectionId: string | null = null;
|
||||
|
||||
export function initKBModal(): void {
|
||||
kbModalEl = document.querySelector('#kbModal')!;
|
||||
|
||||
document.querySelector('#btnKB')!.addEventListener('click', openKBModal);
|
||||
document.querySelector('#btnCloseKB')!.addEventListener('click', closeKBModal);
|
||||
kbModalEl.addEventListener('click', (e) => {
|
||||
if (e.target === kbModalEl) closeKBModal();
|
||||
});
|
||||
|
||||
document.querySelector('#btnNewCollection')!.addEventListener('click', createCollection);
|
||||
|
||||
document.querySelector('#btnUploadDoc')!.addEventListener('click', () => {
|
||||
if (!currentColId) { showToast('请先选择一个知识库集合', 'warning'); return; }
|
||||
(document.querySelector('#kbFileInput') as HTMLInputElement).click();
|
||||
});
|
||||
document.querySelector('#kbFileInput')!.addEventListener('change', handleDocUpload);
|
||||
|
||||
document.querySelector('#toggleRAG')!.addEventListener('change', (e) => {
|
||||
ragEnabled = (e.target as HTMLInputElement).checked;
|
||||
if (ragEnabled && currentColId) {
|
||||
ragCollectionId = currentColId;
|
||||
showToast('RAG 知识检索已开启', 'success');
|
||||
} else if (ragEnabled && !currentColId) {
|
||||
(e.target as HTMLInputElement).checked = false;
|
||||
ragEnabled = false;
|
||||
showToast('请先选择一个知识库集合', 'warning');
|
||||
return;
|
||||
} else {
|
||||
ragCollectionId = null;
|
||||
showToast('RAG 知识检索已关闭', 'info');
|
||||
}
|
||||
updateRagBadge();
|
||||
});
|
||||
|
||||
document.querySelector('#selectEmbedModel')!.addEventListener('change', async (e) => {
|
||||
if (!currentColId) return;
|
||||
const vs = getVectorStore();
|
||||
const col = await vs!.getCollection(currentColId);
|
||||
if (col) {
|
||||
col.embeddingModel = (e.target as HTMLSelectElement).value;
|
||||
await vs!.updateCollection(col);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function isRagEnabled(): boolean {
|
||||
return ragEnabled && !!ragCollectionId;
|
||||
}
|
||||
|
||||
export function getRagCollectionId(): string | null {
|
||||
return ragCollectionId;
|
||||
}
|
||||
|
||||
export async function performRagRetrieval(query: string): Promise<{ context: string; results: SearchResult[]; ragPrompt: string } | null> {
|
||||
if (!isRagEnabled()) return null;
|
||||
try {
|
||||
const { context, results } = await retrieveContext(query, ragCollectionId!, 5);
|
||||
if (!context) return null;
|
||||
return { context, results, ragPrompt: buildRagSystemPrompt(context) };
|
||||
} catch (err) {
|
||||
console.warn('[KB] RAG 检索失败:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateRagBadge(): void {
|
||||
const badge = document.querySelector('#badgeRAG') as HTMLElement;
|
||||
if (!badge) return;
|
||||
badge.style.display = ragEnabled ? '' : 'none';
|
||||
}
|
||||
|
||||
async function openKBModal(): Promise<void> {
|
||||
kbModalEl.style.display = '';
|
||||
await refreshCollections();
|
||||
await populateEmbedModels();
|
||||
}
|
||||
|
||||
function closeKBModal(): void {
|
||||
kbModalEl.style.display = 'none';
|
||||
}
|
||||
|
||||
async function refreshCollections(): Promise<void> {
|
||||
const vs = await initVectorStore();
|
||||
const collections = await vs.getCollections();
|
||||
const listEl = document.querySelector('#kbCollectionList')!;
|
||||
|
||||
if (collections.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">暂无知识库,点击上方「新建集合」创建</p>';
|
||||
currentColId = null;
|
||||
refreshDocList();
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = collections.map(c => `
|
||||
<div class="kb-collection-item ${c.id === currentColId ? 'active' : ''}" data-id="${c.id}">
|
||||
<div class="kb-col-info">
|
||||
<span class="kb-col-name">${escapeHtml(c.name)}</span>
|
||||
<span class="kb-col-stats">${c.docCount || 0} 文档 · ${(c.chunkCount || 0)} 分块</span>
|
||||
</div>
|
||||
<button class="kb-col-delete icon-btn" data-id="${c.id}" title="删除集合">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
listEl.querySelectorAll('.kb-collection-item').forEach(el => {
|
||||
el.addEventListener('click', async (e) => {
|
||||
if ((e.target as HTMLElement).closest('.kb-col-delete')) return;
|
||||
currentColId = (el as HTMLElement).dataset.id!;
|
||||
await refreshCollections();
|
||||
await refreshDocList();
|
||||
const vs = getVectorStore();
|
||||
const col = await vs!.getCollection(currentColId);
|
||||
if (col?.embeddingModel) {
|
||||
(document.querySelector('#selectEmbedModel') as HTMLSelectElement).value = col.embeddingModel;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
listEl.querySelectorAll('.kb-col-delete').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const id = (btn as HTMLElement).dataset.id!;
|
||||
if (!confirm('确定删除此知识库集合?所有文档将被清除。')) return;
|
||||
const vs = getVectorStore();
|
||||
await vs!.deleteCollection(id);
|
||||
if (currentColId === id) currentColId = null;
|
||||
if (ragCollectionId === id) {
|
||||
ragEnabled = false;
|
||||
ragCollectionId = null;
|
||||
(document.querySelector('#toggleRAG') as HTMLInputElement).checked = false;
|
||||
updateRagBadge();
|
||||
}
|
||||
showToast('集合已删除', 'success');
|
||||
await refreshCollections();
|
||||
await refreshDocList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshDocList(): Promise<void> {
|
||||
const docListEl = document.querySelector('#kbDocList')!;
|
||||
if (!currentColId) {
|
||||
docListEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">← 选择一个知识库集合</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const docs = await getDocumentsInCollection(currentColId);
|
||||
if (docs.length === 0) {
|
||||
docListEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">此集合暂无文档,上传文件开始构建知识库</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
docListEl.innerHTML = docs.map(d => `
|
||||
<div class="kb-doc-item">
|
||||
<span class="kb-doc-icon">📄</span>
|
||||
<div class="kb-doc-info">
|
||||
<span class="kb-doc-name">${escapeHtml(d.filename)}</span>
|
||||
<span class="kb-doc-meta">${d.chunkCount} 个分块</span>
|
||||
</div>
|
||||
<button class="kb-doc-delete icon-btn" data-docid="${d.docId}" title="删除文档">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
docListEl.querySelectorAll('.kb-doc-delete').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const docId = (btn as HTMLElement).dataset.docid!;
|
||||
if (!confirm('确定删除此文档?')) return;
|
||||
await removeDocumentFromKB(currentColId!, docId);
|
||||
showToast('文档已删除', 'success');
|
||||
await refreshCollections();
|
||||
await refreshDocList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createCollection(): Promise<void> {
|
||||
const nameInput = document.querySelector('#inputKBName') as HTMLInputElement;
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) { showToast('请输入知识库名称', 'warning'); return; }
|
||||
const vs = await initVectorStore();
|
||||
const col = await vs.createCollection(name);
|
||||
currentColId = col.id;
|
||||
nameInput.value = '';
|
||||
showToast(`知识库「${name}」已创建`, 'success');
|
||||
await refreshCollections();
|
||||
await refreshDocList();
|
||||
}
|
||||
|
||||
async function handleDocUpload(e: Event): Promise<void> {
|
||||
const fileArr = Array.from((e.target as HTMLInputElement).files || []);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
|
||||
if (fileArr.length === 0) return;
|
||||
|
||||
const embedModel = (document.querySelector('#selectEmbedModel') as HTMLSelectElement).value;
|
||||
if (!embedModel) { showToast('请先选择一个嵌入模型', 'warning'); return; }
|
||||
|
||||
const progressEl = document.querySelector('#kbProgress') as HTMLElement;
|
||||
const progressTextEl = document.querySelector('#kbProgressText')!;
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const file of fileArr) {
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
showToast(`${file.name} 超过 5MB 限制`, 'warning');
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
progressEl.style.display = '';
|
||||
progressTextEl.textContent = `正在处理 ${file.name}...`;
|
||||
|
||||
const content = await fileToText(file);
|
||||
const result = await addDocumentToKB(
|
||||
currentColId!, file.name, content, embedModel,
|
||||
(done, total, msg) => {
|
||||
progressTextEl.textContent = `${file.name}: ${msg} (${done}/${total})`;
|
||||
}
|
||||
);
|
||||
|
||||
showToast(`${file.name} 已添加到知识库(${result.chunkCount} 个分块)`, 'success');
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
console.error('[KB] 文档处理失败:', err);
|
||||
showToast(`${file.name} 处理失败: ${(err as Error).message}`, 'error');
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
progressEl.style.display = 'none';
|
||||
|
||||
if (fileArr.length > 1) {
|
||||
showToast(`上传完成:成功 ${successCount} 个,失败 ${failCount} 个`, failCount > 0 ? 'warning' : 'success');
|
||||
}
|
||||
|
||||
await refreshCollections();
|
||||
await refreshDocList();
|
||||
}
|
||||
|
||||
async function populateEmbedModels(): Promise<void> {
|
||||
const select = document.querySelector('#selectEmbedModel') as HTMLSelectElement;
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
const data = await api.listModels();
|
||||
select.innerHTML = '<option value="">选择嵌入模型...</option>';
|
||||
if (!data.models || data.models.length === 0) {
|
||||
select.innerHTML = '<option value="">未安装任何模型</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const checks = await Promise.allSettled(
|
||||
data.models.map(async (m) => {
|
||||
const info = await api.showModel(m.name);
|
||||
const caps = info.capabilities || [];
|
||||
return { name: m.name, size: m.size, isEmbed: caps.includes('embedding') };
|
||||
})
|
||||
);
|
||||
|
||||
const embedModels = checks
|
||||
.filter(r => r.status === 'fulfilled' && r.value.isEmbed)
|
||||
.map(r => (r as PromiseFulfilledResult<{ name: string; size?: number; isEmbed: boolean }>).value);
|
||||
|
||||
embedModels.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.name;
|
||||
opt.textContent = m.name + (m.size ? ` (${formatSize(m.size)})` : '');
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
if (embedModels.length === 0) {
|
||||
select.innerHTML = '<option value="">未检测到嵌入模型,请先 ollama pull</option>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[KB] 加载模型列表失败:', err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Lightbox - 图片预览组件
|
||||
*/
|
||||
|
||||
let lightboxEl: HTMLElement, lightboxImg: HTMLImageElement;
|
||||
|
||||
export function initLightbox(): void {
|
||||
lightboxEl = document.querySelector('#lightbox')!;
|
||||
lightboxImg = document.querySelector('#lightboxImg')!;
|
||||
|
||||
lightboxEl.addEventListener('click', (e) => {
|
||||
if (e.target === lightboxEl) closeLightbox();
|
||||
});
|
||||
document.querySelector('#lightboxClose')!.addEventListener('click', closeLightbox);
|
||||
|
||||
document.querySelector('#messagesContainer')?.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if ((target as HTMLImageElement).dataset.lightbox === 'true') {
|
||||
openLightbox((target as HTMLImageElement).src);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function openLightbox(src: string): void {
|
||||
lightboxImg.src = src;
|
||||
lightboxEl.style.display = '';
|
||||
}
|
||||
|
||||
export function closeLightbox(): void {
|
||||
lightboxEl.style.display = 'none';
|
||||
lightboxImg.src = '';
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* ModelBar - 模型选择栏组件
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { formatSize } from '../utils/utils.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { ChatDB } from '../db/chat-db.js';
|
||||
import type { OllamaModelDetail } from '../types.js';
|
||||
|
||||
let modelSelectEl: HTMLSelectElement;
|
||||
let badgeThinkEl: HTMLElement;
|
||||
let badgeVisionEl: HTMLElement;
|
||||
let thinkCheckbox: HTMLInputElement;
|
||||
let thinkWrap: HTMLElement;
|
||||
let btnAttachImg: HTMLButtonElement;
|
||||
|
||||
const modelCapabilityCache = new Map<string, { think: boolean; vision: boolean; completion: boolean }>();
|
||||
|
||||
export function initModelBar(): void {
|
||||
modelSelectEl = document.querySelector('#modelSelect')!;
|
||||
badgeThinkEl = document.querySelector('#badgeThink')!;
|
||||
badgeVisionEl = document.querySelector('#badgeVision')!;
|
||||
thinkCheckbox = document.querySelector('#toggleThink')!;
|
||||
thinkWrap = document.querySelector('#thinkToggleWrap')!;
|
||||
btnAttachImg = document.querySelector('#btnAttachImg')!;
|
||||
|
||||
modelSelectEl.addEventListener('change', async () => {
|
||||
const model = modelSelectEl.value;
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (db) await db.saveSetting('selectedModel', model);
|
||||
state.set('_defaultModel', model);
|
||||
state.update(KEYS.CURRENT_SESSION, (session: any) => session ? ({ ...session, model }) : session);
|
||||
if (model) {
|
||||
await checkModelCapability(model);
|
||||
} else {
|
||||
setThinkAvailable(false);
|
||||
setVisionAvailable(false);
|
||||
badgeThinkEl.style.display = 'none';
|
||||
badgeVisionEl.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadModels(): Promise<void> {
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
const data = await api.listModels();
|
||||
modelSelectEl.innerHTML = '';
|
||||
|
||||
if (!data.models || data.models.length === 0) {
|
||||
modelSelectEl.innerHTML = '<option value="">未安装任何模型</option>';
|
||||
badgeThinkEl.style.display = 'none'; badgeVisionEl.style.display = 'none';
|
||||
setThinkAvailable(false); setVisionAvailable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
data.models
|
||||
.sort((a, b) => {
|
||||
const nameA = a.name.toLowerCase(), nameB = b.name.toLowerCase();
|
||||
if (nameA !== nameB) return nameA.localeCompare(nameB);
|
||||
return (a.size || 0) - (b.size || 0);
|
||||
})
|
||||
.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.name;
|
||||
const diskSize = formatSize(m.size || 0);
|
||||
opt.textContent = diskSize ? `${m.name} · ${diskSize}` : m.name;
|
||||
modelSelectEl.appendChild(opt);
|
||||
});
|
||||
|
||||
const defaultModel = state.get<string>('_defaultModel', '');
|
||||
if (defaultModel) modelSelectEl.value = defaultModel;
|
||||
|
||||
if (modelSelectEl.value) {
|
||||
await checkModelCapability(modelSelectEl.value);
|
||||
}
|
||||
|
||||
filterEmbedModels(data.models);
|
||||
} catch (err) {
|
||||
console.error('[ModelBar] 加载模型失败:', err);
|
||||
modelSelectEl.innerHTML = '<option value="">加载失败 - 检查连接</option>';
|
||||
badgeThinkEl.style.display = 'none'; badgeVisionEl.style.display = 'none';
|
||||
setThinkAvailable(false); setVisionAvailable(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function filterEmbedModels(models: Array<{ name: string }>): Promise<void> {
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
if (!api) return;
|
||||
|
||||
for (const m of models) {
|
||||
try {
|
||||
const info = await api.showModel(m.name);
|
||||
const caps = info.capabilities || [];
|
||||
const isCompletion = caps.includes('completion');
|
||||
modelCapabilityCache.set(m.name, {
|
||||
think: caps.includes('thinking'),
|
||||
vision: caps.includes('vision'),
|
||||
completion: isCompletion,
|
||||
});
|
||||
if (!isCompletion) {
|
||||
const opt = modelSelectEl.querySelector(`option[value="${CSS.escape(m.name)}"]`);
|
||||
if (opt) opt.remove();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!modelSelectEl.value && modelSelectEl.options.length > 0) {
|
||||
modelSelectEl.value = modelSelectEl.options[0].value;
|
||||
state.set('_defaultModel', modelSelectEl.value);
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (db) await db.saveSetting('selectedModel', modelSelectEl.value);
|
||||
await checkModelCapability(modelSelectEl.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkModelCapability(modelName: string): Promise<void> {
|
||||
if (modelCapabilityCache.has(modelName)) {
|
||||
applyCapability(modelName, modelCapabilityCache.get(modelName)!);
|
||||
return;
|
||||
}
|
||||
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
const info = await api.showModel(modelName);
|
||||
const capabilities = info.capabilities || [];
|
||||
const caps = {
|
||||
think: capabilities.includes('thinking'),
|
||||
vision: capabilities.includes('vision'),
|
||||
completion: capabilities.includes('completion'),
|
||||
};
|
||||
modelCapabilityCache.set(modelName, caps);
|
||||
applyCapability(modelName, caps);
|
||||
} catch (err) {
|
||||
console.warn('[ModelBar] 获取模型能力失败:', err);
|
||||
const fallback = { think: false, vision: false, completion: true };
|
||||
modelCapabilityCache.set(modelName, fallback);
|
||||
applyCapability(modelName, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
function applyCapability(_modelName: string, caps: { think: boolean; vision: boolean }): void {
|
||||
setThinkAvailable(caps.think);
|
||||
setVisionAvailable(caps.vision);
|
||||
badgeThinkEl.style.display = caps.think ? '' : 'none';
|
||||
badgeVisionEl.style.display = caps.vision ? '' : 'none';
|
||||
}
|
||||
|
||||
function setThinkAvailable(available: boolean): void {
|
||||
if (available) {
|
||||
thinkCheckbox.disabled = false;
|
||||
thinkWrap.title = 'Think 模式:启用深度推理';
|
||||
thinkWrap.classList.remove('unavailable');
|
||||
} else {
|
||||
thinkCheckbox.checked = false;
|
||||
thinkCheckbox.disabled = true;
|
||||
thinkWrap.title = '当前模型不支持 Think 模式';
|
||||
thinkWrap.classList.add('unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
function setVisionAvailable(available: boolean): void {
|
||||
if (!btnAttachImg) return;
|
||||
if (available) {
|
||||
btnAttachImg.disabled = false;
|
||||
btnAttachImg.title = '上传图片';
|
||||
btnAttachImg.classList.remove('disabled');
|
||||
} else {
|
||||
btnAttachImg.disabled = true;
|
||||
btnAttachImg.title = '当前模型不支持图片分析';
|
||||
btnAttachImg.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
export function isVisionAvailable(): boolean {
|
||||
return btnAttachImg && !btnAttachImg.disabled;
|
||||
}
|
||||
|
||||
export function getSelectedModel(): string {
|
||||
return modelSelectEl.value;
|
||||
}
|
||||
|
||||
export function setSelectedModel(modelName: string): void {
|
||||
modelSelectEl.value = modelName;
|
||||
modelSelectEl.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
export function isThinkEnabled(): boolean {
|
||||
return thinkCheckbox.checked && !thinkCheckbox.disabled;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* PresetBar - 预设栏组件
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import {
|
||||
getPresets, getActivePresetId, activatePreset,
|
||||
createPreset, updatePreset, deletePreset
|
||||
} from '../services/preset-manager.js';
|
||||
import { showToast } from './toast.js';
|
||||
import type { Preset } from '../types.js';
|
||||
|
||||
let presetBarEl: HTMLElement;
|
||||
let presetListEl: HTMLElement;
|
||||
let editingPresetId: string | null = null;
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
const map: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
return String(str).replace(/[&<>"']/g, c => map[c]);
|
||||
}
|
||||
|
||||
export function initPresetBar(): void {
|
||||
const modelBar = document.querySelector('.model-bar');
|
||||
if (!modelBar) return;
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'preset-bar';
|
||||
bar.innerHTML = `
|
||||
<div class="preset-list" id="presetList"></div>
|
||||
<button class="preset-add-btn" id="btnAddPreset" title="管理预设">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
modelBar.after(bar);
|
||||
|
||||
presetBarEl = bar;
|
||||
presetListEl = bar.querySelector('#presetList')!;
|
||||
|
||||
bar.querySelector('#btnAddPreset')!.addEventListener('click', () => openPresetModal());
|
||||
|
||||
state.on('activePresetId', renderPresetBar);
|
||||
state.on('presets', renderPresetBar);
|
||||
|
||||
renderPresetBar();
|
||||
}
|
||||
|
||||
function renderPresetBar(): void {
|
||||
if (!presetListEl) return;
|
||||
|
||||
const presets = getPresets();
|
||||
const activeId = getActivePresetId();
|
||||
|
||||
presetListEl.innerHTML = '';
|
||||
|
||||
for (const preset of presets) {
|
||||
const chip = document.createElement('button');
|
||||
chip.className = 'preset-chip' + (preset.id === activeId ? ' active' : '');
|
||||
chip.title = preset.systemPrompt ? preset.systemPrompt.slice(0, 100) : '无系统提示词';
|
||||
chip.innerHTML = `<span class="preset-chip-icon">${preset.icon}</span><span class="preset-chip-name">${escapeHtml(preset.name)}</span>`;
|
||||
|
||||
chip.addEventListener('click', () => handlePresetClick(preset.id));
|
||||
chip.addEventListener('contextmenu', (e) => {
|
||||
if (!preset.builtIn) {
|
||||
e.preventDefault();
|
||||
editingPresetId = preset.id;
|
||||
openPresetModal(preset);
|
||||
}
|
||||
});
|
||||
|
||||
presetListEl.appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePresetClick(presetId: string): Promise<void> {
|
||||
const currentId = getActivePresetId();
|
||||
if (presetId === currentId) return;
|
||||
|
||||
await activatePreset(presetId);
|
||||
|
||||
const preset = getPresets().find(p => p.id === presetId);
|
||||
if (preset) {
|
||||
showToast(`已切换到 ${preset.icon} ${preset.name}`, 'success', 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function openPresetModal(editPreset?: Preset): void {
|
||||
const modal = document.querySelector('#presetModal') as HTMLElement;
|
||||
const titleEl = modal.querySelector('#presetModalTitle')!;
|
||||
const nameInput = modal.querySelector('#inputPresetName') as HTMLInputElement;
|
||||
const iconInput = modal.querySelector('#inputPresetIcon') as HTMLInputElement;
|
||||
const spInput = modal.querySelector('#inputPresetSystemPrompt') as HTMLTextAreaElement;
|
||||
const tempInput = modal.querySelector('#inputPresetTemp') as HTMLInputElement;
|
||||
const tempVal = modal.querySelector('#presetTempValue')!;
|
||||
const ctxInput = modal.querySelector('#inputPresetNumCtx') as HTMLInputElement;
|
||||
const thinkCheck = modal.querySelector('#togglePresetThink') as HTMLInputElement;
|
||||
const btnSave = modal.querySelector('#btnSavePreset') as HTMLButtonElement;
|
||||
const btnDelete = modal.querySelector('#btnDeletePreset') as HTMLElement;
|
||||
|
||||
if (editPreset && editPreset.id) {
|
||||
editingPresetId = editPreset.id;
|
||||
titleEl.textContent = '✏️ 编辑预设';
|
||||
nameInput.value = editPreset.name;
|
||||
iconInput.value = editPreset.icon;
|
||||
spInput.value = editPreset.systemPrompt;
|
||||
tempInput.value = String(editPreset.temperature ?? 0.7);
|
||||
tempVal.textContent = (editPreset.temperature ?? 0.7).toFixed(1);
|
||||
ctxInput.value = String(editPreset.numCtx || 24576);
|
||||
thinkCheck.checked = editPreset.think ?? false;
|
||||
btnDelete.style.display = editPreset.builtIn ? 'none' : '';
|
||||
btnSave.textContent = '保存';
|
||||
} else {
|
||||
editingPresetId = null;
|
||||
titleEl.textContent = '✨ 新建预设';
|
||||
nameInput.value = '';
|
||||
iconInput.value = '🤖';
|
||||
spInput.value = '';
|
||||
tempInput.value = '0.7';
|
||||
tempVal.textContent = '0.7';
|
||||
ctxInput.value = '24576';
|
||||
thinkCheck.checked = false;
|
||||
btnDelete.style.display = 'none';
|
||||
btnSave.textContent = '创建';
|
||||
}
|
||||
|
||||
modal.style.display = '';
|
||||
nameInput.focus();
|
||||
|
||||
tempInput.oninput = () => {
|
||||
tempVal.textContent = parseFloat(tempInput.value).toFixed(1);
|
||||
};
|
||||
}
|
||||
|
||||
export function initPresetModal(): void {
|
||||
const modal = document.querySelector('#presetModal') as HTMLElement;
|
||||
if (!modal) return;
|
||||
|
||||
modal.querySelector('#btnClosePresetModal')!.addEventListener('click', () => {
|
||||
modal.style.display = 'none';
|
||||
});
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) modal.style.display = 'none';
|
||||
});
|
||||
|
||||
modal.querySelector('#btnSavePreset')!.addEventListener('click', async () => {
|
||||
const name = (modal.querySelector('#inputPresetName') as HTMLInputElement).value.trim();
|
||||
if (!name) { showToast('请输入预设名称', 'warning'); return; }
|
||||
|
||||
const data = {
|
||||
name,
|
||||
icon: (modal.querySelector('#inputPresetIcon') as HTMLInputElement).value.trim() || '🤖',
|
||||
systemPrompt: (modal.querySelector('#inputPresetSystemPrompt') as HTMLTextAreaElement).value.trim(),
|
||||
temperature: parseFloat((modal.querySelector('#inputPresetTemp') as HTMLInputElement).value) || 0.7,
|
||||
numCtx: parseInt((modal.querySelector('#inputPresetNumCtx') as HTMLInputElement).value) || 24576,
|
||||
think: (modal.querySelector('#togglePresetThink') as HTMLInputElement).checked
|
||||
};
|
||||
|
||||
if (editingPresetId) {
|
||||
await updatePreset(editingPresetId, data);
|
||||
showToast('预设已更新', 'success', 2000);
|
||||
if (editingPresetId === getActivePresetId()) {
|
||||
await activatePreset(editingPresetId);
|
||||
}
|
||||
} else {
|
||||
const created = await createPreset(data);
|
||||
await activatePreset(created.id);
|
||||
showToast(`已创建并激活 ${data.icon} ${data.name}`, 'success', 2000);
|
||||
}
|
||||
|
||||
modal.style.display = 'none';
|
||||
editingPresetId = null;
|
||||
});
|
||||
|
||||
modal.querySelector('#btnDeletePreset')!.addEventListener('click', async () => {
|
||||
if (!editingPresetId) return;
|
||||
const preset = getPresets().find(p => p.id === editingPresetId);
|
||||
if (!preset) return;
|
||||
|
||||
if (confirm(`确定删除预设 "${preset.icon} ${preset.name}" ?`)) {
|
||||
await deletePreset(editingPresetId);
|
||||
showToast('预设已删除', 'info', 2000);
|
||||
modal.style.display = 'none';
|
||||
editingPresetId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* SettingsModal - 设置面板组件
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { debounce } from '../utils/utils.js';
|
||||
import { encryptData, decryptData, isMetonaFile } from '../services/crypto.js';
|
||||
import { updateConnectionInfo, updateRunningModels } from './header.js';
|
||||
import { loadModels } from './model-bar.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { ChatDB } from '../db/chat-db.js';
|
||||
import type { ChatSession } from '../types.js';
|
||||
|
||||
let settingsModalEl: HTMLElement;
|
||||
|
||||
export function initSettingsModal(): void {
|
||||
settingsModalEl = document.querySelector('#settingsModal')!;
|
||||
|
||||
document.querySelector('#btnSettings')!.addEventListener('click', openSettingsModal);
|
||||
document.querySelector('#btnCloseSettings')!.addEventListener('click', closeSettingsModal);
|
||||
settingsModalEl.addEventListener('click', (e) => {
|
||||
if (e.target === settingsModalEl) closeSettingsModal();
|
||||
});
|
||||
|
||||
const saveServerUrl = debounce(async () => {
|
||||
const url = (document.querySelector('#inputServerUrl') as HTMLInputElement).value.trim();
|
||||
if (!url) return;
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const api = new OllamaAPI(url);
|
||||
state.set(KEYS.API, api);
|
||||
if (db) await db.saveSetting('serverUrl', url);
|
||||
updateConnectionInfo();
|
||||
loadModels();
|
||||
}, 500);
|
||||
document.querySelector('#inputServerUrl')!.addEventListener('input', saveServerUrl);
|
||||
|
||||
document.querySelector('#toggleSystemPrompt')!.addEventListener('change', async (e) => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
state.set(KEYS.SYSTEM_PROMPT_ENABLED, (e.target as HTMLInputElement).checked);
|
||||
(document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).disabled = !(e.target as HTMLInputElement).checked;
|
||||
if (db) await db.saveSetting('systemPromptEnabled', (e.target as HTMLInputElement).checked);
|
||||
});
|
||||
|
||||
const saveSystemPrompt = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).value.trim();
|
||||
state.set(KEYS.SYSTEM_PROMPT, val);
|
||||
if (db) await db.saveSetting('systemPrompt', val);
|
||||
}, 500);
|
||||
document.querySelector('#inputSystemPrompt')!.addEventListener('input', saveSystemPrompt);
|
||||
|
||||
const saveNumCtx = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputNumCtx') as HTMLInputElement).value.trim();
|
||||
const numCtx = val ? parseInt(val) : 24576;
|
||||
state.set(KEYS.NUM_CTX, numCtx);
|
||||
if (db) await db.saveSetting('numCtx', numCtx);
|
||||
}, 500);
|
||||
document.querySelector('#inputNumCtx')!.addEventListener('input', saveNumCtx);
|
||||
|
||||
const tempSlider = document.querySelector('#inputTemperature') as HTMLInputElement;
|
||||
const tempDisplay = document.querySelector('#tempValue')!;
|
||||
tempSlider.addEventListener('input', () => {
|
||||
tempDisplay.textContent = parseFloat(tempSlider.value).toFixed(1);
|
||||
});
|
||||
const saveTemperature = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = parseFloat(tempSlider.value);
|
||||
state.set('temperature', val);
|
||||
if (db) await db.saveSetting('temperature', val);
|
||||
}, 300);
|
||||
tempSlider.addEventListener('change', saveTemperature);
|
||||
|
||||
document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
|
||||
try {
|
||||
await api!.chat({ model: model || 'any', messages: [], keep_alive: 0 } as any);
|
||||
showToast('显存已释放', 'success');
|
||||
updateRunningModels();
|
||||
} catch (err) {
|
||||
showToast(`释放失败: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#btnClearAllHistory')!.addEventListener('click', async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (confirm('确定清空所有历史记录?此操作不可恢复!')) {
|
||||
await db!.clearAll();
|
||||
(document.querySelector('#btnNewChat') as HTMLElement).click();
|
||||
showToast('已清空所有历史记录', 'success');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#btnExportAll')!.addEventListener('click', exportAllSessions);
|
||||
|
||||
const importFileInput = document.querySelector('#importFileInput') as HTMLInputElement;
|
||||
document.querySelector('#btnImportSessions')!.addEventListener('click', () => importFileInput.click());
|
||||
importFileInput.addEventListener('change', (e) => {
|
||||
if ((e.target as HTMLInputElement).files!.length > 0) {
|
||||
importSessions((e.target as HTMLInputElement).files![0]);
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function openSettingsModal(): void {
|
||||
settingsModalEl.style.display = '';
|
||||
(document.querySelector('#inputServerUrl') as HTMLInputElement).value = state.get<OllamaAPI>(KEYS.API)?.baseUrl || '';
|
||||
updateConnectionInfo();
|
||||
updateRunningModels();
|
||||
}
|
||||
|
||||
export function closeSettingsModal(): void {
|
||||
settingsModalEl.style.display = 'none';
|
||||
}
|
||||
|
||||
async function exportAllSessions(): Promise<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
const sessions = await db.getAllSessions();
|
||||
if (sessions.length === 0) {
|
||||
showToast('没有可导出的会话', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const backup = {
|
||||
app: 'Metona Ollama Client',
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
count: sessions.length,
|
||||
sessions
|
||||
};
|
||||
|
||||
try {
|
||||
const blob = await encryptData(backup);
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `metona-backup-${ts}.metona`;
|
||||
a.click();
|
||||
showToast(`已导出 ${sessions.length} 个会话(已加密)`, 'success');
|
||||
} catch (err) {
|
||||
console.error('[Settings] 导出失败:', err);
|
||||
showToast(`导出失败: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function importSessions(file: File): Promise<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
|
||||
if (!isMetonaFile(file)) {
|
||||
showToast('仅支持导入 .metona 格式文件', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const data = await decryptData(buffer);
|
||||
|
||||
let sessions: ChatSession[];
|
||||
if (Array.isArray(data)) {
|
||||
sessions = data as ChatSession[];
|
||||
} else if ((data as any).sessions && Array.isArray((data as any).sessions)) {
|
||||
sessions = (data as any).sessions;
|
||||
} else {
|
||||
showToast('文件内容格式错误:未找到会话数据', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.importSessions(sessions);
|
||||
showToast(`导入完成:${result.imported} 个会话${result.skipped > 0 ? `,跳过 ${result.skipped} 个` : ''}`, 'success', 4000);
|
||||
} catch (err) {
|
||||
console.error('[Settings] 导入失败:', err);
|
||||
showToast(`导入失败: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Toast - 通知组件
|
||||
*/
|
||||
|
||||
let toastContainer: HTMLElement | null = null;
|
||||
|
||||
export function initToast(): void {
|
||||
toastContainer = document.querySelector('#toastContainer');
|
||||
}
|
||||
|
||||
export function showToast(text: string, type: 'info' | 'success' | 'warning' | 'error' = 'info', duration = 3000): void {
|
||||
if (!toastContainer) return;
|
||||
|
||||
const iconMap: Record<string, string> = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `<span class="toast-icon">${iconMap[type] || 'ℹ'}</span><span>${text}</span>`;
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('removing');
|
||||
toast.addEventListener('animationend', () => toast.remove());
|
||||
}, duration);
|
||||
}
|
||||
Reference in New Issue
Block a user