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:
thzxx
2026-04-06 03:04:20 +08:00
parent 0924497cd6
commit 7ca0e33d77
33 changed files with 7265 additions and 15 deletions
+331
View File
@@ -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');
}
+87
View File
@@ -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>';
}
}
+191
View File
@@ -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';
}
+534
View File
@@ -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);
}
}
+307
View File
@@ -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);
}
}
+32
View File
@@ -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 = '';
}
+195
View File
@@ -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;
}
+187
View File
@@ -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> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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;
}
});
}
+179
View File
@@ -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');
}
}
+24
View File
@@ -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);
}