diff --git a/package.json b/package.json index e0b5082..2443a32 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,13 @@ { "name": "metona-ollama-desktop", - "version": "1.1.0", - "description": "Metona Ollama - 桌面端 AI 聊天客户端", - "main": "electron/main.js", + "version": "2.0.0", + "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", + "main": "dist/main/main.js", "author": "thzxx", "license": "MIT", "scripts": { + "dev": "vite && electron .", + "build": "tsc && vite build", "start": "electron . --no-sandbox", "pack": "electron-builder --dir", "dist": "electron-builder --win", @@ -19,27 +21,19 @@ "output": "release" }, "files": [ - "electron/**/*", - "js/**/*", - "css/**/*", + "dist/**/*", "assets/**/*", - "index.html", - "manifest.json", "!node_modules/**/*" ], "win": { "target": [ { "target": "nsis", - "arch": [ - "x64" - ] + "arch": ["x64"] }, { "target": "portable", - "arch": [ - "x64" - ] + "arch": ["x64"] } ], "icon": "assets/icons/llama.ico", @@ -60,7 +54,10 @@ } }, "devDependencies": { + "@types/node": "^20.17.0", "electron": "^33.4.11", - "electron-builder": "^25.0.0" + "electron-builder": "^25.1.8", + "typescript": "^5.7.0", + "vite": "^5.4.0" } } diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 0000000..3739d6c --- /dev/null +++ b/src/main/ipc.ts @@ -0,0 +1,72 @@ +/** + * Metona Ollama Desktop - IPC 处理器 + */ + +import { ipcMain, dialog, shell } from 'electron'; +import * as fs from 'fs'; +import * as path from 'path'; +import { mainWindow } from './main.js'; +import { showNotification } from './utils.js'; + +export function setupIPC(): void { + ipcMain.handle('dialog:openFile', async (_, options?: { filters?: Array<{ name: string; extensions: string[] }> }) => { + const result = await dialog.showOpenDialog(mainWindow!, { + properties: ['openFile', 'multiSelections'], + filters: options?.filters || [{ name: '所有文件', extensions: ['*'] }] + }); + if (result.canceled) return null; + return result.filePaths; + }); + + ipcMain.handle('dialog:saveFile', async (_, options?: { defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }) => { + const result = await dialog.showSaveDialog(mainWindow!, { + defaultPath: options?.defaultPath || 'export', + filters: options?.filters || [{ name: '所有文件', extensions: ['*'] }] + }); + if (result.canceled) return null; + return result.filePath; + }); + + ipcMain.handle('fs:readFile', async (_, filePath: string) => { + try { + const content = await fs.promises.readFile(filePath, 'utf-8'); + return { success: true, content, name: path.basename(filePath) }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }); + + ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string) => { + try { + await fs.promises.writeFile(filePath, content, 'utf-8'); + return { success: true }; + } catch (err) { + return { success: false, error: (err as Error).message }; + } + }); + + ipcMain.handle('notify', (_: unknown, title: string, body: string) => { + showNotification(title, body); + }); + + ipcMain.handle('app:info', () => ({ + version: require('electron').app.getVersion(), + platform: process.platform, + arch: process.arch, + electronVersion: process.versions.electron, + userDataPath: require('electron').app.getPath('userData') + })); + + ipcMain.handle('window:minimize', () => mainWindow?.minimize()); + ipcMain.handle('window:maximize', () => { + if (mainWindow?.isMaximized()) mainWindow.unmaximize(); + else mainWindow?.maximize(); + }); + ipcMain.handle('window:close', () => mainWindow?.close()); + + ipcMain.handle('shell:openExternal', (_, url: string) => { + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('mailto:')) { + shell.openExternal(url); + } + }); +} diff --git a/src/main/main.ts b/src/main/main.ts new file mode 100644 index 0000000..8b5460a --- /dev/null +++ b/src/main/main.ts @@ -0,0 +1,159 @@ +/** + * Metona Ollama Desktop - 主进程入口 + */ + +import { app, BrowserWindow } from 'electron'; +import * as path from 'path'; +import * as fs from 'fs'; +import { setupIPC } from './ipc.js'; +import { createTray } from './tray.js'; +import { createMenu } from './menu.js'; +import { showNotification } from './utils.js'; + +const APP_NAME = 'Metona Ollama'; +const ICON_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.png'); +const ICO_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.ico'); +const IS_DEV = !app.isPackaged; + +export let mainWindow: BrowserWindow | null = null; +export let isQuitting = false; + +export function getIconPath(): string { + return process.platform === 'win32' ? ICO_PATH : ICON_PATH; +} + +function createMainWindow(): BrowserWindow { + const userDataPath = app.getPath('userData'); + const configPath = path.join(userDataPath, 'window-state.json'); + let windowState = { width: 1200, height: 800, x: undefined as number | undefined, y: undefined as number | undefined, maximized: false }; + + try { + if (fs.existsSync(configPath)) { + windowState = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } + } catch { /* use defaults */ } + + mainWindow = new BrowserWindow({ + width: windowState.width || 1200, + height: windowState.height || 800, + x: windowState.x, + y: windowState.y, + minWidth: 800, + minHeight: 600, + icon: process.platform === 'win32' ? ICO_PATH : ICON_PATH, + title: APP_NAME, + backgroundColor: '#202020', + show: false, + autoHideMenuBar: true, + menuBarVisible: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + webSecurity: false, + allowRunningInsecureContent: false + } + }); + + mainWindow.loadFile(path.join(__dirname, '..', '..', 'index.html')); + mainWindow.setMenuBarVisibility(false); + + mainWindow.once('ready-to-show', () => { + if (windowState.maximized) { + mainWindow!.maximize(); + } + mainWindow!.show(); + }); + + const saveWindowState = (): void => { + if (!mainWindow || mainWindow.isMaximized() || mainWindow.isMinimized()) return; + const bounds = mainWindow.getBounds(); + try { + fs.writeFileSync(configPath, JSON.stringify(bounds)); + } catch { /* ignore */ } + }; + + mainWindow.on('resize', saveWindowState); + mainWindow.on('move', saveWindowState); + + mainWindow.on('maximize', () => { + try { + const state = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + state.maximized = true; + fs.writeFileSync(configPath, JSON.stringify(state)); + } catch { /* ignore */ } + }); + + mainWindow.on('unmaximize', () => { + try { + const state = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + state.maximized = false; + fs.writeFileSync(configPath, JSON.stringify(state)); + } catch { /* ignore */ } + }); + + mainWindow.on('close', (e) => { + if (!isQuitting) { + e.preventDefault(); + mainWindow!.hide(); + if (!(global as Record)._trayHintShown) { + (global as Record)._trayHintShown = true; + showNotification('Metona 已最小化到系统托盘', '点击托盘图标可重新打开'); + } + } + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://') || url.startsWith('https://')) { + const { shell } = require('electron'); + shell.openExternal(url); + return { action: 'deny' as const }; + } + return { action: 'allow' as const }; + }); + + return mainWindow; +} + +// ── 单实例锁 ── +const gotTheLock = app.requestSingleInstanceLock(); +if (!gotTheLock) { + app.quit(); +} else { + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); +} + +app.whenReady().then(() => { + setupIPC(); + createMainWindow(); + createTray(); + createMenu(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createMainWindow(); + } else if (mainWindow) { + mainWindow.show(); + } + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + // keep running with tray + } +}); + +app.on('before-quit', () => { + isQuitting = true; +}); diff --git a/src/main/menu.ts b/src/main/menu.ts new file mode 100644 index 0000000..6d59670 --- /dev/null +++ b/src/main/menu.ts @@ -0,0 +1,131 @@ +/** + * Metona Ollama Desktop - 原生菜单 + */ + +import { Menu, dialog, shell } from 'electron'; +import { mainWindow, isQuitting, getIconPath } from './main.js'; + +export function createMenu(): void { + const template: Electron.MenuItemConstructorOptions[] = [ + { + label: '文件', + submenu: [ + { + label: '新建会话', + accelerator: 'Ctrl+N', + click: () => mainWindow?.webContents.send('menu-action', 'new-chat') + }, + { type: 'separator' }, + { + label: '导入会话', + click: () => mainWindow?.webContents.send('menu-action', 'import') + }, + { + label: '导出会话', + accelerator: 'Ctrl+Shift+E', + click: () => mainWindow?.webContents.send('menu-action', 'export') + }, + { type: 'separator' }, + { + label: '退出', + accelerator: 'Ctrl+Q', + click: () => { + const { app } = require('electron'); + (require('./main.js') as Record).isQuitting = true; + app.quit(); + } + } + ] + }, + { + label: '编辑', + submenu: [ + { role: 'undo', label: '撤销' }, + { role: 'redo', label: '重做' }, + { type: 'separator' }, + { role: 'cut', label: '剪切' }, + { role: 'copy', label: '复制' }, + { role: 'paste', label: '粘贴' }, + { role: 'selectAll', label: '全选' } + ] + }, + { + label: '视图', + submenu: [ + { role: 'reload', label: '刷新' }, + { role: 'forceReload', label: '强制刷新' }, + { type: 'separator' }, + { role: 'zoomIn', label: '放大' }, + { role: 'zoomOut', label: '缩小' }, + { role: 'resetZoom', label: '重置缩放' }, + { type: 'separator' }, + { role: 'togglefullscreen', label: '全屏' } + ] + }, + { + label: '窗口', + submenu: [ + { role: 'minimize', label: '最小化' }, + { + label: '最大化/还原', + click: () => { + if (mainWindow?.isMaximized()) mainWindow.unmaximize(); + else mainWindow?.maximize(); + } + }, + { type: 'separator' }, + { + label: '置顶', + type: 'checkbox', + checked: false, + click: (menuItem: Electron.MenuItem) => { + mainWindow?.setAlwaysOnTop(menuItem.checked); + } + } + ] + }, + { + label: '帮助', + submenu: [ + { + label: '使用帮助', + click: () => mainWindow?.webContents.send('menu-action', 'help') + }, + { type: 'separator' }, + { + label: '关于 Ollama', + click: () => shell.openExternal('https://ollama.com') + }, + { + label: '关于 Metona', + click: () => { + dialog.showMessageBox(mainWindow!, { + type: 'info', + title: '关于 Metona Ollama', + message: 'Metona Ollama Desktop v2.0.0', + detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', + icon: getIconPath() + }); + } + } + ] + } + ]; + + const IS_DEV = !require('electron').app.isPackaged; + if (IS_DEV) { + template.push({ + label: 'Dev', + submenu: [ + { + label: '切换开发者工具', + accelerator: 'F12', + click: () => mainWindow?.webContents.toggleDevTools() + } + ] + }); + } + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +} diff --git a/src/main/preload.ts b/src/main/preload.ts new file mode 100644 index 0000000..0c4dd54 --- /dev/null +++ b/src/main/preload.ts @@ -0,0 +1,34 @@ +/** + * Metona Ollama Desktop - Preload 脚本 + */ + +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('metonaDesktop', { + isDesktop: true, + info: () => ipcRenderer.invoke('app:info'), + dialog: { + openFile: (filters?: unknown) => ipcRenderer.invoke('dialog:openFile', { filters }), + saveFile: (options?: unknown) => ipcRenderer.invoke('dialog:saveFile', options) + }, + fs: { + readFile: (filePath: string) => ipcRenderer.invoke('fs:readFile', filePath), + writeFile: (filePath: string, content: string) => ipcRenderer.invoke('fs:writeFile', filePath, content) + }, + notify: (title: string, body: string) => ipcRenderer.invoke('notify', title, body), + window: { + minimize: () => ipcRenderer.invoke('window:minimize'), + maximize: () => ipcRenderer.invoke('window:maximize'), + close: () => ipcRenderer.invoke('window:close') + }, + openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), + onMenuAction: (callback: (action: string) => void) => { + ipcRenderer.on('menu-action', (_: unknown, action: string) => callback(action)); + }, + onTrayAction: (callback: (action: string) => void) => { + ipcRenderer.on('tray-action', (_: unknown, action: string) => callback(action)); + }, + removeAllListeners: (channel: string) => { + ipcRenderer.removeAllListeners(channel); + } +}); diff --git a/src/main/tray.ts b/src/main/tray.ts new file mode 100644 index 0000000..a7d911f --- /dev/null +++ b/src/main/tray.ts @@ -0,0 +1,49 @@ +/** + * Metona Ollama Desktop - 系统托盘 + */ + +import { Tray, Menu, nativeImage } from 'electron'; +import { mainWindow, isQuitting, getIconPath } from './main.js'; + +let tray: Tray | null = null; + +export function createTray(): void { + const icon = nativeImage.createFromPath(getIconPath()); + tray = new Tray(icon.resize({ width: 16, height: 16 })); + + const contextMenu = Menu.buildFromTemplate([ + { + label: '打开 Metona', + click: () => { + mainWindow?.show(); + mainWindow?.focus(); + } + }, + { type: 'separator' }, + { + label: '新建会话', + click: () => { + mainWindow?.show(); + mainWindow?.focus(); + mainWindow?.webContents.send('tray-action', 'new-chat'); + } + }, + { type: 'separator' }, + { + label: '退出', + click: () => { + const main = require('./main.js'); + main.isQuitting = true; + require('electron').app.quit(); + } + } + ]); + + tray.setToolTip('Metona Ollama'); + tray.setContextMenu(contextMenu); + + tray.on('double-click', () => { + mainWindow?.show(); + mainWindow?.focus(); + }); +} diff --git a/src/main/utils.ts b/src/main/utils.ts new file mode 100644 index 0000000..3e004ab --- /dev/null +++ b/src/main/utils.ts @@ -0,0 +1,14 @@ +/** + * Metona Ollama Desktop - 主进程工具函数 + */ + +import { Notification } from 'electron'; +import * as path from 'path'; + +const ICON_PATH = path.join(__dirname, '..', '..', 'assets', 'icons', 'llama.png'); + +export function showNotification(title: string, body: string): void { + if (Notification.isSupported()) { + new Notification({ title, body, icon: ICON_PATH }).show(); + } +} diff --git a/src/renderer/api/ollama.ts b/src/renderer/api/ollama.ts new file mode 100644 index 0000000..0806c38 --- /dev/null +++ b/src/renderer/api/ollama.ts @@ -0,0 +1,136 @@ +/** + * Ollama API 客户端封装 + */ + +import type { + OllamaChatParams, OllamaStreamChunk, OllamaModelsResponse, + OllamaPsResponse, OllamaVersionResponse, OllamaModelDetail, + OllamaEmbedResponse +} from '../types.js'; + +export class OllamaAPI { + baseUrl: string; + + constructor(baseUrl = 'http://127.0.0.1:11434') { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + } + + private async _request(path: string, options: RequestInit = {}): Promise { + const url = `${this.baseUrl}${path}`; + const config: RequestInit = { + ...options, + headers: { 'Content-Type': 'application/json', ...(options.headers as Record || {}) } + }; + const response = await fetch(url, config); + if (!response.ok) { + const errorBody = await response.text().catch(() => ''); + throw new Error(`Ollama API 错误 ${response.status}: ${errorBody || response.statusText}`); + } + return response; + } + + private async _json(path: string, body: unknown = null): Promise { + const options: RequestInit = body ? { method: 'POST', body: JSON.stringify(body) } : {}; + const response = await this._request(path, options); + return response.json() as Promise; + } + + async listModels(): Promise { + return this._json('/api/tags'); + } + + async psModels(): Promise { + return this._json('/api/ps'); + } + + async getVersion(): Promise { + return this._json('/api/version'); + } + + async showModel(model: string): Promise { + return this._json('/api/show', { model }); + } + + async chat(params: Omit): Promise { + const body: Record = { + model: params.model, + messages: params.messages, + stream: false, + }; + if (params.think !== undefined) body.think = params.think; + if (params.system) body.system = params.system; + if (params.keep_alive !== undefined) body.keep_alive = params.keep_alive; + if (params.options) body.options = params.options; + return this._json('/api/chat', body); + } + + async chatStream( + params: OllamaChatParams, + onChunk: (chunk: OllamaStreamChunk) => void, + abortController?: AbortController + ): Promise { + const body: Record = { + model: params.model, + messages: params.messages, + stream: true, + }; + if (params.think !== undefined) body.think = params.think; + if (params.system) body.system = params.system; + if (params.keep_alive !== undefined) body.keep_alive = params.keep_alive; + if (params.options) body.options = params.options; + + const fetchOptions: RequestInit = { + method: 'POST', + body: JSON.stringify(body) + }; + + if (abortController) { + fetchOptions.signal = abortController.signal; + } + + const response = await this._request('/api/chat', fetchOptions); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + if (abortController) { + abortController.signal.addEventListener('abort', () => { + reader.cancel(); + }, { once: true }); + } + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const chunk: OllamaStreamChunk = JSON.parse(line); + if (onChunk) onChunk(chunk); + if (chunk.done) { + reader.cancel(); + return; + } + } catch (err) { + console.warn('[OllamaAPI] 流式数据解析警告:', (err as Error).message); + } + } + } + + if (buffer.trim()) { + try { + const chunk: OllamaStreamChunk = JSON.parse(buffer); + if (onChunk) onChunk(chunk); + } catch { /* ignore */ } + } + } + + async embed(model: string, input: string): Promise { + return this._json('/api/embed', { model, input }); + } +} diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts new file mode 100644 index 0000000..533d8ef --- /dev/null +++ b/src/renderer/components/chat-area.ts @@ -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 = { + 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(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) + ? `${escapeHtml(msg.model)}` + : ''; + + let contentHtml = ''; + const safeContent = (msg.content != null) ? String(msg.content) : ''; + + if (msg.role === 'assistant') { + if (msg.think) { + contentHtml += ` +
+
+ 🤔 思考过程 + +
+
${escapeHtml(msg.think)}
+
`; + } + if (safeContent) { + contentHtml += `
${safeMarkdown(safeContent)}
`; + } else { + contentHtml += `
`; + } + } else { + contentHtml += `
${escapeHtml(safeContent)}
`; + } + + if (msg.images && msg.images.length > 0) { + contentHtml += '
'; + for (const img of msg.images) { + contentHtml += `用户图片`; + } + contentHtml += '
'; + } + + if (msg.files && msg.files.length > 0) { + contentHtml += '
'; + for (const f of msg.files) { + const icon = getFileIcon(f.name); + contentHtml += `
${icon}${escapeHtml(f.name)}
`; + } + contentHtml += '
'; + } + + if (msg.role === 'user' && (!msg.content || msg.content.trim() === '') && (msg.images?.length || 0) > 0) { + contentHtml = contentHtml.replace('
', ''); + } + + 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 += `
${stats.map(s => `${s}`).join(' · ')}
`; + } + + 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 `
+ 📄 来源 ${i + 1}: ${escapeHtml(s.filename)} + ${scorePercent ? `${scorePercent}` : ''} +
`; + }).join(''); + contentHtml += `
🧠 基于知识库回答
${sourcesHtml}
`; + } + + div.innerHTML = ` +
${avatar}
+
+
${roleLabel}${modelTag}
+ ${contentHtml} +
+ `; + + 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 = ` +
+ 🤔 思考过程 + +
+
+ `; + 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 = ` +
🤖
+
+
AI
+
+
+ 正在思考... +
+
+ `; + 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 = `${escapeHtml(session.title)} + +

${escapeHtml(session.title)}

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


`; + session.messages.forEach(m => { + html += `
${m.role === 'user' ? '👤 用户' : '🤖 AI'}
${(m.content || '').replace(/\n/g, '
')}
`; + }); + 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'); +} diff --git a/src/renderer/components/header.ts b/src/renderer/components/header.ts new file mode 100644 index 0000000..c486dd4 --- /dev/null +++ b/src/renderer/components/header.ts @@ -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(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 { + const api = state.get(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 { + const api = state.get(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 = '

无运行中的模型

'; + return; + } + container.innerHTML = data.models.map(m => ` +
+ ${m.name} + ${formatSize(m.size_vram || 0)} VRAM +
+ `).join(''); + } catch { + container.innerHTML = '

无法获取运行信息

'; + } +} diff --git a/src/renderer/components/history-modal.ts b/src/renderer/components/history-modal.ts new file mode 100644 index 0000000..d8f93c5 --- /dev/null +++ b/src/renderer/components/history-modal.ts @@ -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(KEYS.DB); + + if (target.classList.contains('btn-delete-session')) { + if (confirm('确定删除此会话?')) { + await db!.deleteSession(sessionId); + const currentSession = state.get(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 { + const db = state.get(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 = `

暂无历史记录

`; + 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 => ` +
+
+
${escapeHtml(s.title)}
+
+ ${formatTime(s.updatedAt)} + ${s.messages.length} 条消息 + ${s.model || '无模型'} +
+
+
+ + + + +
+
+ `).join(''); + + if (totalPages <= 1) { + historyPaginationEl.innerHTML = `共 ${allSessions.length} 条`; + return; + } + + let html = `共 ${allSessions.length} 条
`; + html += ``; + 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 += ''; + html += ``; + prev = p; + } + html += `
`; + historyPaginationEl.innerHTML = html; +} + +async function loadHistorySession(sessionId: string): Promise { + const db = state.get(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'; +} diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts new file mode 100644 index 0000000..b1a904d --- /dev/null +++ b/src/renderer/components/input-area.ts @@ -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 = { 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(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 { + 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) => ` +
+ ${escapeHtml(img.name)} + +
+ `).join(''); +} + +async function handleTextFileSelect(files: FileList): Promise { + 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) => ` +
+ ${getFileIcon(f.name)} + ${escapeHtml(f.name)} + ${formatSize(f.size)} + +
+ `).join(''); +} + +function updateSendButton(streaming: boolean): void { + if (streaming) { + btnSendEl.innerHTML = ``; + btnSendEl.classList.add('stop-btn'); + btnSendEl.classList.remove('disabled'); + btnSendEl.title = '停止生成'; + } else { + btnSendEl.innerHTML = ``; + btnSendEl.classList.remove('stop-btn'); + btnSendEl.classList.remove('disabled'); + btnSendEl.title = '发送'; + } +} + +function stopGeneration(): void { + const abortController = state.get(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 { + const text = chatInputEl.value.trim(); + if (!text && pendingImages.length === 0 && pendingFiles.length === 0) return; + if (state.get(KEYS.IS_STREAMING)) return; + + const model = getSelectedModel(); + if (!model) { + appendSystemMessage('⚠️ 请先选择一个模型'); + return; + } + + if (pendingImages.length > 0 && !isVisionAvailable()) { + appendSystemMessage('⚠️ 当前模型不支持图片分析,请移除图片后重试'); + return; + } + + const currentSession = state.get(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(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(KEYS.API)!; + const abortController = new AbortController(); + state.set(KEYS.ABORT_CONTROLLER, abortController); + const thinkMode = isThinkEnabled(); + const systemPromptEnabled = state.get(KEYS.SYSTEM_PROMPT_ENABLED); + const systemPrompt = state.get(KEYS.SYSTEM_PROMPT); + const numCtx = state.get(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('temperature', 0.7); + + const chatParams: Record = { + 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 `
📄 来源 ${i + 1}: ${escapeHtml(s.filename)}${scorePercent ? `${scorePercent}` : ''}
`; + }).join(''); + const ragDiv = document.createElement('div'); + ragDiv.className = 'rag-sources'; + ragDiv.innerHTML = `
🧠 基于知识库回答
${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 += '

[已停止]

'; + 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) + '

[已中断]

'; + 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 { + const db = state.get(KEYS.DB); + const currentSession = state.get(KEYS.CURRENT_SESSION); + const isHistoryView = state.get(KEYS.IS_HISTORY_VIEW); + if (!currentSession || !db || isHistoryView) return; + currentSession.updatedAt = Date.now(); + try { + await db.saveSession(currentSession); + } catch (err) { + console.error('[InputArea] 保存会话失败:', err); + } +} diff --git a/src/renderer/components/kb-modal.ts b/src/renderer/components/kb-modal.ts new file mode 100644 index 0000000..e3983dd --- /dev/null +++ b/src/renderer/components/kb-modal.ts @@ -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 { + kbModalEl.style.display = ''; + await refreshCollections(); + await populateEmbedModels(); +} + +function closeKBModal(): void { + kbModalEl.style.display = 'none'; +} + +async function refreshCollections(): Promise { + const vs = await initVectorStore(); + const collections = await vs.getCollections(); + const listEl = document.querySelector('#kbCollectionList')!; + + if (collections.length === 0) { + listEl.innerHTML = '

暂无知识库,点击上方「新建集合」创建

'; + currentColId = null; + refreshDocList(); + return; + } + + listEl.innerHTML = collections.map(c => ` +
+
+ ${escapeHtml(c.name)} + ${c.docCount || 0} 文档 · ${(c.chunkCount || 0)} 分块 +
+ +
+ `).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 { + const docListEl = document.querySelector('#kbDocList')!; + if (!currentColId) { + docListEl.innerHTML = '

← 选择一个知识库集合

'; + return; + } + + const docs = await getDocumentsInCollection(currentColId); + if (docs.length === 0) { + docListEl.innerHTML = '

此集合暂无文档,上传文件开始构建知识库

'; + return; + } + + docListEl.innerHTML = docs.map(d => ` +
+ 📄 +
+ ${escapeHtml(d.filename)} + ${d.chunkCount} 个分块 +
+ +
+ `).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 { + 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 { + 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 { + const select = document.querySelector('#selectEmbedModel') as HTMLSelectElement; + const api = state.get(KEYS.API); + if (!api) return; + + try { + const data = await api.listModels(); + select.innerHTML = ''; + if (!data.models || data.models.length === 0) { + select.innerHTML = ''; + 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 = ''; + } + } catch (err) { + console.warn('[KB] 加载模型列表失败:', err); + } +} diff --git a/src/renderer/components/lightbox.ts b/src/renderer/components/lightbox.ts new file mode 100644 index 0000000..9545b1c --- /dev/null +++ b/src/renderer/components/lightbox.ts @@ -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 = ''; +} diff --git a/src/renderer/components/model-bar.ts b/src/renderer/components/model-bar.ts new file mode 100644 index 0000000..04ee151 --- /dev/null +++ b/src/renderer/components/model-bar.ts @@ -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(); + +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(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 { + const api = state.get(KEYS.API); + if (!api) return; + + try { + const data = await api.listModels(); + modelSelectEl.innerHTML = ''; + + if (!data.models || data.models.length === 0) { + modelSelectEl.innerHTML = ''; + 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('_defaultModel', ''); + if (defaultModel) modelSelectEl.value = defaultModel; + + if (modelSelectEl.value) { + await checkModelCapability(modelSelectEl.value); + } + + filterEmbedModels(data.models); + } catch (err) { + console.error('[ModelBar] 加载模型失败:', err); + modelSelectEl.innerHTML = ''; + badgeThinkEl.style.display = 'none'; badgeVisionEl.style.display = 'none'; + setThinkAvailable(false); setVisionAvailable(false); + } +} + +async function filterEmbedModels(models: Array<{ name: string }>): Promise { + const api = state.get(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(KEYS.DB); + if (db) await db.saveSetting('selectedModel', modelSelectEl.value); + await checkModelCapability(modelSelectEl.value); + } +} + +async function checkModelCapability(modelName: string): Promise { + if (modelCapabilityCache.has(modelName)) { + applyCapability(modelName, modelCapabilityCache.get(modelName)!); + return; + } + + const api = state.get(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; +} diff --git a/src/renderer/components/preset-bar.ts b/src/renderer/components/preset-bar.ts new file mode 100644 index 0000000..0fdac69 --- /dev/null +++ b/src/renderer/components/preset-bar.ts @@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + 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 = ` +
+ + `; + 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 = `${preset.icon}${escapeHtml(preset.name)}`; + + 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 { + 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; + } + }); +} diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts new file mode 100644 index 0000000..2a7084e --- /dev/null +++ b/src/renderer/components/settings-modal.ts @@ -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(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(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(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(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(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(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(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(KEYS.API)?.baseUrl || ''; + updateConnectionInfo(); + updateRunningModels(); +} + +export function closeSettingsModal(): void { + settingsModalEl.style.display = 'none'; +} + +async function exportAllSessions(): Promise { + const db = state.get(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 { + const db = state.get(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'); + } +} diff --git a/src/renderer/components/toast.ts b/src/renderer/components/toast.ts new file mode 100644 index 0000000..135cc2e --- /dev/null +++ b/src/renderer/components/toast.ts @@ -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 = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' }; + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.innerHTML = `${iconMap[type] || 'ℹ'}${text}`; + toastContainer.appendChild(toast); + + setTimeout(() => { + toast.classList.add('removing'); + toast.addEventListener('animationend', () => toast.remove()); + }, duration); +} diff --git a/src/renderer/db/chat-db.ts b/src/renderer/db/chat-db.ts new file mode 100644 index 0000000..180a46e --- /dev/null +++ b/src/renderer/db/chat-db.ts @@ -0,0 +1,154 @@ +/** + * ChatDB - IndexedDB 封装层 + */ + +import type { ChatSession } from '../types.js'; + +export class ChatDB { + private dbName: string; + private version: number; + private db: IDBDatabase | null = null; + + constructor(dbName = 'metona-ollama', version = 1) { + this.dbName = dbName; + this.version = version; + } + + async init(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version); + request.onerror = () => { + console.error('[ChatDB] 数据库打开失败:', request.error); + reject(request.error); + }; + request.onsuccess = () => { + this.db = request.result; + console.log('[ChatDB] 数据库已连接'); + resolve(); + }; + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains('sessions')) { + const sessionStore = db.createObjectStore('sessions', { keyPath: 'id' }); + sessionStore.createIndex('updatedAt', 'updatedAt', { unique: false }); + sessionStore.createIndex('model', 'model', { unique: false }); + } + if (!db.objectStoreNames.contains('settings')) { + db.createObjectStore('settings', { keyPath: 'key' }); + } + }; + }); + } + + private _ensureDB(): void { + if (!this.db) throw new Error('数据库未初始化,请先调用 init()'); + } + + private _tx(storeName: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore { + this._ensureDB(); + const tx = this.db!.transaction(storeName, mode); + return tx.objectStore(storeName); + } + + async saveSession(session: ChatSession): Promise { + return new Promise((resolve, reject) => { + const store = this._tx('sessions', 'readwrite'); + const request = store.put(session); + request.onsuccess = () => resolve(session.id); + request.onerror = () => reject(request.error); + }); + } + + async getSession(id: string): Promise { + return new Promise((resolve, reject) => { + const store = this._tx('sessions'); + const request = store.get(id); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); + } + + async getAllSessions(): Promise { + return new Promise((resolve, reject) => { + const store = this._tx('sessions'); + const request = store.getAll(); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + } + + async deleteSession(id: string): Promise { + return new Promise((resolve, reject) => { + const store = this._tx('sessions', 'readwrite'); + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + async clearAll(): Promise { + return new Promise((resolve, reject) => { + const store = this._tx('sessions', 'readwrite'); + const request = store.clear(); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + async importSessions(sessions: ChatSession[]): Promise<{ imported: number; skipped: number }> { + return new Promise((resolve, reject) => { + const tx = this.db!.transaction('sessions', 'readwrite'); + const store = tx.objectStore('sessions'); + let imported = 0; + let skipped = 0; + + tx.oncomplete = () => resolve({ imported, skipped }); + tx.onerror = () => reject(tx.error); + + for (const session of sessions) { + if (!session.id || !Array.isArray(session.messages)) { + skipped++; + continue; + } + const getRequest = store.get(session.id); + getRequest.onsuccess = () => { + if (getRequest.result) { + skipped++; + } else { + store.put(session); + imported++; + } + }; + } + }); + } + + async getSessionsByTimeRange(startTime: number, endTime: number): Promise { + return new Promise((resolve, reject) => { + const store = this._tx('sessions'); + const index = store.index('updatedAt'); + const range = IDBKeyRange.bound(startTime, endTime); + const request = index.getAll(range); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + } + + async saveSetting(key: string, value: unknown): Promise { + return new Promise((resolve, reject) => { + const store = this._tx('settings', 'readwrite'); + const request = store.put({ key, value }); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + async getSetting(key: string, defaultValue: T | null = null): Promise { + return new Promise((resolve, reject) => { + const store = this._tx('settings'); + const request = store.get(key); + request.onsuccess = () => resolve(request.result ? request.result.value : defaultValue); + request.onerror = () => reject(request.error); + }); + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..9e1ef0d --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,379 @@ + + + + + + + + + Metona Ollama + + +
+ + +
+
+ + Metona Ollama + v2.0.0 + +
+
+
+ + 未连接 +
+ + + + +
+
+ + +
+
+ + + + +
+
+ + + + +
+
+ + + +
+
+ + +
+
+
+ + + + + + + + +
+

开始对话

+

选择一个模型,输入消息开始聊天

+
+ + +
+ + + + + +
+ + +
+ + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/src/renderer/main.ts b/src/renderer/main.ts new file mode 100644 index 0000000..7adcfb8 --- /dev/null +++ b/src/renderer/main.ts @@ -0,0 +1,243 @@ +/** + * App - 主入口模块 + * 负责初始化所有组件、协调事件流、管理生命周期 + */ + +import '../styles/style.css'; +import './types.js'; + +import { ChatDB } from './db/chat-db.js'; +import { OllamaAPI } from './api/ollama.js'; +import { state, KEYS } from './state/state.js'; +import { generateId } from './utils/utils.js'; + +// 组件 +import { initToast, showToast } from './components/toast.js'; +import { initLightbox, closeLightbox } from './components/lightbox.js'; +import { initHeader, checkConnection } from './components/header.js'; +import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js'; +import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js'; +import { initInputArea } from './components/input-area.js'; +import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js'; +import { initHistoryModal, closeHistoryModal } from './components/history-modal.js'; +import { initKBModal } from './components/kb-modal.js'; +import { initPresetBar, initPresetModal } from './components/preset-bar.js'; +import { initPresetManager } from './services/preset-manager.js'; +import { initVectorStore } from './services/rag.js'; +import type { ChatSession } from './types.js'; + +function initHelpModal(): void { + const helpModal = document.querySelector('#helpModal') as HTMLElement; + document.querySelector('#btnHelp')!.addEventListener('click', () => { + helpModal.style.display = ''; + }); + document.querySelector('#btnCloseHelp')!.addEventListener('click', () => { + helpModal.style.display = 'none'; + }); + helpModal.addEventListener('click', (e) => { + if (e.target === helpModal) helpModal.style.display = 'none'; + }); +} + +function setupDesktopIntegration(): void { + const bridge = window.__metonaBridge; + if (!bridge || !bridge.isDesktop) return; + + console.log('[Desktop Integration] 初始化桌面集成...'); + + bridge.onMenuAction((action: string) => { + switch (action) { + case 'new-chat': document.querySelector('#btnNewChat')?.dispatchEvent(new Event('click')); break; + case 'import': document.querySelector('#btnImportSessions')?.dispatchEvent(new Event('click')); break; + case 'export': document.querySelector('#btnExportAll')?.dispatchEvent(new Event('click')); break; + case 'help': document.querySelector('#btnHelp')?.dispatchEvent(new Event('click')); break; + } + }); + + bridge.onTrayAction((action: string) => { + switch (action) { + case 'new-chat': document.querySelector('#btnNewChat')?.dispatchEvent(new Event('click')); break; + } + }); + + // 标题更新 + window.addEventListener('metona:model-changed', ((e: CustomEvent) => { + if (e.detail?.model) { + document.title = `${e.detail.model} - Metona Ollama`; + } else { + document.title = 'Metona Ollama'; + } + }) as EventListener); + + // 桌面端快捷键 + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.key === 'n') { + e.preventDefault(); + document.querySelector('#btnNewChat')?.dispatchEvent(new Event('click')); + } + }); + + console.log('[Desktop Integration] 桌面集成完成 ✓'); +} + +console.log('[Metona] TypeScript + Electron v2.0.0 ' + new Date().toISOString()); + +function createNewSession(): ChatSession { + const selectedModel = (document.querySelector('#modelSelect') as HTMLSelectElement)?.value || ''; + const defaultModel = selectedModel || state.get('_defaultModel', ''); + return { + id: generateId(), + title: '新会话', + model: defaultModel, + messages: [], + createdAt: Date.now(), + updatedAt: Date.now() + }; +} + +async function startNewSession(): Promise { + const db = state.get(KEYS.DB); + const currentSession = state.get(KEYS.CURRENT_SESSION); + if (db && currentSession && currentSession.messages.length > 0) { + currentSession.updatedAt = Date.now(); + await db.saveSession(currentSession); + } + + const api = state.get(KEYS.API); + const model = currentSession?.model || state.get('_defaultModel', ''); + if (api && model) { + try { + await api.chat({ model, messages: [], keep_alive: 0 } as any); + } catch (e) { + console.warn('[App] 释放显存失败:', e); + } + } + + const session = createNewSession(); + state.set(KEYS.CURRENT_SESSION, session); + state.set(KEYS.IS_HISTORY_VIEW, false); + + (document.querySelector('#historyBar') as HTMLElement).style.display = 'none'; + (document.querySelector('#inputArea') as HTMLElement).style.display = ''; + + clearMessages(); + enableAutoScroll(); + renderMessages(); + (document.querySelector('#chatInput') as HTMLElement)?.focus(); +} + +async function init(): Promise { + let db: ChatDB | undefined; + let api: OllamaAPI; + + try { + db = new ChatDB(); + await db.init(); + state.set(KEYS.DB, db); + + const serverUrl = await db.getSetting('serverUrl', 'http://127.0.0.1:11434'); + api = new OllamaAPI(serverUrl); + state.set(KEYS.API, api); + + state.set(KEYS.CURRENT_SESSION, createNewSession()); + state.set(KEYS.IS_STREAMING, false); + state.set(KEYS.IS_HISTORY_VIEW, false); + state.set(KEYS.NUM_CTX, 24576); + + initToast(); + initLightbox(); + initHeader(); + initModelBar(); + initChatArea(); + initInputArea(); + initSettingsModal(); + initHistoryModal(); + initKBModal(); + initPresetBar(); + initPresetModal(); + initHelpModal(); + setupDesktopIntegration(); + + bindGlobalEvents(); + + await checkConnection(); + await loadModels(); + await initVectorStore(); + await initPresetManager(); + + const savedModel = await db.getSetting('selectedModel', ''); + if (savedModel) { + setSelectedModel(savedModel); + state.set('_defaultModel', savedModel); + await db.saveSetting('selectedModel', savedModel); + } + + const systemPromptEnabled = await db.getSetting('systemPromptEnabled', false); + const systemPrompt = await db.getSetting('systemPrompt', ''); + const numCtx = await db.getSetting('numCtx', 24576); + const temperature = await db.getSetting('temperature', 0.7); + + state.set(KEYS.SYSTEM_PROMPT_ENABLED, systemPromptEnabled); + state.set(KEYS.SYSTEM_PROMPT, systemPrompt); + state.set(KEYS.NUM_CTX, numCtx); + state.set('temperature', temperature); + + (document.querySelector('#toggleSystemPrompt') as HTMLInputElement).checked = systemPromptEnabled; + (document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).disabled = !systemPromptEnabled; + (document.querySelector('#inputSystemPrompt') as HTMLTextAreaElement).value = systemPrompt; + (document.querySelector('#inputNumCtx') as HTMLInputElement).value = String(numCtx); + (document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature); + document.querySelector('#tempValue')!.textContent = parseFloat(temperature).toFixed(1); + + (document.querySelector('#chatInput') as HTMLElement)?.focus(); + + console.log('[App] 初始化完成'); + } catch (err) { + console.error('[App] 初始化失败:', err); + if (!api!) { + api = new OllamaAPI(); + state.set(KEYS.API, api); + } + state.set(KEYS.CURRENT_SESSION, createNewSession()); + bindGlobalEvents(); + initToast(); + initLightbox(); + initHeader(); + initModelBar(); + initChatArea(); + initInputArea(); + initSettingsModal(); + initHistoryModal(); + initPresetBar(); + initPresetModal(); + setupDesktopIntegration(); + checkConnection().then(loadModels); + } +} + +function bindGlobalEvents(): void { + document.querySelector('#btnNewChat')?.addEventListener('click', startNewSession); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeSettingsModal(); + closeHistoryModal(); + closeLightbox(); + (document.querySelector('#helpModal') as HTMLElement).style.display = 'none'; + (document.querySelector('#kbModal') as HTMLElement).style.display = 'none'; + } + }); + + window.addEventListener('error', (e) => { + console.error('[App] 未捕获错误:', e.error || e.message); + showToast(`发生错误: ${e.message}`, 'error', 5000); + }); + + window.addEventListener('unhandledrejection', (e) => { + console.error('[App] 未处理 Promise 拒绝:', e.reason); + const msg = e.reason?.message || String(e.reason); + showToast(`操作失败: ${msg}`, 'error', 5000); + }); +} + +init(); diff --git a/src/renderer/services/crypto.ts b/src/renderer/services/crypto.ts new file mode 100644 index 0000000..f335d51 --- /dev/null +++ b/src/renderer/services/crypto.ts @@ -0,0 +1,163 @@ +/** + * Crypto - Metona 会话加密模块 + */ + +const MAGIC = new TextEncoder().encode('METONA1\0'); +const PASSPHRASE = 'metona-ollama-v2'; +const PBKDF2_ITERATIONS = 100000; + +const SECURE = !!(globalThis.crypto && globalThis.crypto.subtle); + +async function sha256(data: Uint8Array): Promise { + if (SECURE) { + const buf = await crypto.subtle.digest('SHA-256', data); + return new Uint8Array(buf); + } + return sha256js(data); +} + +function sha256js(message: Uint8Array): Uint8Array { + const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + ]); + + const bytes = message instanceof Uint8Array ? message : new Uint8Array(message); + const bitLen = bytes.length * 8; + const paddedLen = Math.ceil((bytes.length + 9) / 64) * 64; + const padded = new Uint8Array(paddedLen); + padded.set(bytes); + padded[bytes.length] = 0x80; + const view = new DataView(padded.buffer); + view.setUint32(paddedLen - 4, bitLen, false); + + let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a; + let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19; + + const w = new Uint32Array(64); + + for (let offset = 0; offset < paddedLen; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false); + for (let i = 16; i < 64; i++) { + const s0 = (w[i - 15] >>> 7 | w[i - 15] << 25) ^ (w[i - 15] >>> 18 | w[i - 15] << 14) ^ (w[i - 15] >>> 3); + const s1 = (w[i - 2] >>> 17 | w[i - 2] << 15) ^ (w[i - 2] >>> 19 | w[i - 2] << 13) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7; + for (let i = 0; i < 64; i++) { + const S1 = (e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7); + const ch = (e & f) ^ (~e & g); + const temp1 = (h + S1 + ch + K[i] + w[i]) >>> 0; + const S0 = (a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + maj) >>> 0; + h = g; g = f; f = e; + e = (d + temp1) >>> 0; + d = c; c = b; b = a; + a = (temp1 + temp2) >>> 0; + } + h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0; h6 = (h6 + g) >>> 0; h7 = (h7 + h) >>> 0; + } + + const result = new Uint8Array(32); + const rv = new DataView(result.buffer); + rv.setUint32(0, h0, false); rv.setUint32(4, h1, false); + rv.setUint32(8, h2, false); rv.setUint32(12, h3, false); + rv.setUint32(16, h4, false); rv.setUint32(20, h5, false); + rv.setUint32(24, h6, false); rv.setUint32(28, h7, false); + return result; +} + +async function deriveKeyBytes(salt: Uint8Array): Promise { + const passBytes = new TextEncoder().encode(PASSPHRASE); + const input = new Uint8Array(passBytes.length + salt.length); + input.set(passBytes); + input.set(salt, passBytes.length); + + if (SECURE) { + const keyMaterial = await crypto.subtle.importKey('raw', passBytes, 'PBKDF2', false, ['deriveKey']); + const key = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + const raw = await crypto.subtle.exportKey('raw', key); + return new Uint8Array(raw); + } + + let key = input; + for (let i = 0; i < 10000; i++) { + key = await sha256(key); + } + return key; +} + +function xorBytes(data: Uint8Array, keyBytes: Uint8Array): Uint8Array { + const out = new Uint8Array(data.length); + for (let i = 0; i < data.length; i++) { + out[i] = data[i] ^ keyBytes[i % keyBytes.length]; + } + return out; +} + +export async function encryptData(data: unknown): Promise { + const json = new TextEncoder().encode(JSON.stringify(data)); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const keyBytes = await deriveKeyBytes(salt); + + let payload: Uint8Array; + if (SECURE) { + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['encrypt']); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, json); + payload = new Uint8Array(ciphertext); + } else { + const lenBytes = new Uint32Array([json.length]); + const withLen = new Uint8Array(4 + json.length); + withLen.set(new Uint8Array(lenBytes.buffer)); + withLen.set(json, 4); + payload = xorBytes(withLen, keyBytes); + } + + const flag = new Uint8Array([SECURE ? 1 : 0]); + return new Blob([MAGIC, salt, iv, flag, payload]); +} + +export async function decryptData(buffer: ArrayBuffer): Promise { + const data = new Uint8Array(buffer); + for (let i = 0; i < 8; i++) { + if (data[i] !== MAGIC[i]) throw new Error('不是有效的 .metona 文件'); + } + + const salt = data.slice(8, 24); + const iv = data.slice(24, 36); + const mode = data[36]; + const payload = data.slice(37); + const keyBytes = await deriveKeyBytes(salt); + + let jsonBytes: Uint8Array; + if (mode === 1) { + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']); + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, payload); + jsonBytes = new Uint8Array(decrypted); + } else { + const withLen = xorBytes(payload, keyBytes); + const len = new DataView(withLen.buffer).getUint32(0, true); + jsonBytes = withLen.slice(4, 4 + len); + } + + return JSON.parse(new TextDecoder().decode(jsonBytes)); +} + +export function isMetonaFile(file: File): boolean { + return file.name.endsWith('.metona'); +} diff --git a/src/renderer/services/document-processor.ts b/src/renderer/services/document-processor.ts new file mode 100644 index 0000000..81ac8a3 --- /dev/null +++ b/src/renderer/services/document-processor.ts @@ -0,0 +1,74 @@ +/** + * DocumentProcessor - 文档分块处理 + */ + +export function chunkText(text: string, { maxChunkSize = 1500, overlap = 200 } = {}): string[] { + if (!text || text.trim().length === 0) return []; + + const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim()); + const chunks: string[] = []; + let current = ''; + + for (const para of paragraphs) { + const trimmed = para.trim(); + if (!trimmed) continue; + + if (trimmed.length > maxChunkSize) { + if (current) { + chunks.push(current.trim()); + current = ''; + } + const sentences = splitSentences(trimmed); + let sentenceBuf = ''; + for (const sent of sentences) { + if ((sentenceBuf + sent).length > maxChunkSize && sentenceBuf) { + chunks.push(sentenceBuf.trim()); + sentenceBuf = overlap > 0 ? sentenceBuf.slice(-overlap) + sent : sent; + } else { + sentenceBuf += sent; + } + } + if (sentenceBuf.trim()) { + current = sentenceBuf; + } + continue; + } + + if ((current + '\n\n' + trimmed).length > maxChunkSize && current) { + chunks.push(current.trim()); + if (overlap > 0 && current.length > overlap) { + current = current.slice(-overlap) + '\n\n' + trimmed; + } else { + current = trimmed; + } + } else { + current = current ? current + '\n\n' + trimmed : trimmed; + } + } + + if (current.trim()) { + chunks.push(current.trim()); + } + + return chunks; +} + +function splitSentences(text: string): string[] { + const parts = text.split(/(?<=[。!?.!?])\s*/); + return parts.filter(p => p.trim()).map(p => p.trim() + ' '); +} + +export function extractDocument(content: string, filename: string): { text: string; filename: string } { + return { text: content, filename }; +} + +export function createChunkMetadata(chunk: string, index: number, docId: string, filename: string) { + return { + id: `${docId}_chunk_${index}`, + docId, + filename, + chunkIndex: index, + text: chunk, + charCount: chunk.length + }; +} diff --git a/src/renderer/services/preset-manager.ts b/src/renderer/services/preset-manager.ts new file mode 100644 index 0000000..49b1280 --- /dev/null +++ b/src/renderer/services/preset-manager.ts @@ -0,0 +1,179 @@ +/** + * PresetManager - Agent 预设管理 + */ + +import { state, KEYS } from '../state/state.js'; +import { generateId } from '../utils/utils.js'; +import type { Preset, ChatDB } from '../types.js'; + +const PRESETS_KEY = 'agentPresets'; +const ACTIVE_PRESET_KEY = 'activePresetId'; + +const BUILT_IN_PRESETS: Preset[] = [ + { id: '_default', name: '默认', icon: '💬', systemPrompt: '', temperature: 0.7, numCtx: 24576, think: false, builtIn: true, createdAt: 0, updatedAt: 0 }, + { id: '_translator', name: '翻译官', icon: '🌐', systemPrompt: '你是一位专业翻译官。你的任务是将用户输入的内容准确翻译为目标语言。\n\n规则:\n1. 自动识别源语言,翻译为用户指定的目标语言(如未指定则翻译为中文)\n2. 保持原文的格式、语气和风格\n3. 专有名词保留原文并给出通用译名\n4. 对于有多种含义的词语,结合上下文选择最合适的翻译\n5. 直接输出翻译结果,不需要额外解释(除非用户要求)', temperature: 0.3, numCtx: 16384, think: false, builtIn: true, createdAt: 0, updatedAt: 0 }, + { id: '_code_review', name: '代码审查', icon: '🔍', systemPrompt: '你是一位资深代码审查专家。对用户提交的代码进行全面审查。\n\n审查维度:\n1. **Bug 与逻辑错误** — 潜在的运行时错误、边界条件、空指针等\n2. **安全性** — SQL注入、XSS、敏感信息泄露等安全隐患\n3. **性能** — 时间/空间复杂度、不必要的计算、内存泄漏\n4. **可读性** — 命名规范、代码结构、注释质量\n5. **最佳实践** — 设计模式、语言惯用法、框架约定\n\n输出格式:\n- 按严重程度排列问题(严重 → 轻微)\n- 每个问题给出:位置、问题描述、修复建议、改进后的代码\n- 最后给出整体评价和改进建议', temperature: 0.2, numCtx: 32768, think: true, builtIn: true, createdAt: 0, updatedAt: 0 }, + { id: '_writer', name: '写作助手', icon: '✍️', systemPrompt: '你是一位才华横溢的写作助手。擅长多种文体和写作风格。\n\n能力范围:\n1. 文章撰写 — 议论文、说明文、散文、故事\n2. 文案创作 — 广告文案、产品描述、社交媒体文案\n3. 内容润色 — 修改语法、优化措辞、提升表达力\n4. 风格调整 — 正式/口语/文艺/简洁等多种风格\n5. 创意写作 — 小说、诗歌、剧本、对话\n\n工作方式:\n- 仔细理解用户的写作需求(读者、用途、风格偏好)\n- 如需信息不全,主动询问确认\n- 提供多种版本供选择\n- 解释重要的写作决策', temperature: 0.9, numCtx: 24576, think: false, builtIn: true, createdAt: 0, updatedAt: 0 }, + { id: '_analyst', name: '数据分析师', icon: '📊', systemPrompt: '你是一位专业的数据分析师。擅长从数据中提取洞察并给出可执行的建议。\n\n分析方法:\n1. 数据概览 — 数据量、字段类型、缺失值、异常值\n2. 统计分析 — 描述性统计、相关性分析、趋势分析\n3. 可视化建议 — 推荐合适的图表类型和展示方式\n4. 洞察提取 — 关键发现、模式识别、因果推断\n5. 行动建议 — 基于数据的具体可执行建议\n\n输出格式:\n- 先给出核心结论(Executive Summary)\n- 再展开详细分析过程\n- 使用表格和列表组织数据\n- 给出代码(Python/SQL)时附带注释', temperature: 0.4, numCtx: 32768, think: true, builtIn: true, createdAt: 0, updatedAt: 0 }, + { id: '_tutor', name: '学习导师', icon: '🎓', systemPrompt: '你是一位耐心且善于启发的学习导师。用苏格拉底式教学法帮助用户深入理解知识。\n\n教学原则:\n1. **先问后答** — 通过引导性问题帮助用户自己找到答案\n2. **由浅入深** — 从基础概念开始,逐步构建知识体系\n3. **类比教学** — 用生活中的类比解释抽象概念\n4. **实践导向** — 每个知识点配一个动手练习\n5. **鼓励探索** — 提供延伸阅读方向和思考题', temperature: 0.6, numCtx: 24576, think: true, builtIn: true, createdAt: 0, updatedAt: 0 } +]; + +let presets: Preset[] = []; +let activePresetId: string | null = null; + +export async function initPresetManager(): Promise { + const db = state.get(KEYS.DB); + + let userPresets: Preset[] = []; + if (db) { + userPresets = await db.getSetting(PRESETS_KEY, []); + } + + presets = [...BUILT_IN_PRESETS, ...userPresets]; + + if (db) { + activePresetId = await db.getSetting(ACTIVE_PRESET_KEY, '_default'); + } else { + activePresetId = '_default'; + } + + state.set('presets', presets); + state.set('activePresetId', activePresetId); + + const activePreset = presets.find(p => p.id === activePresetId); + if (activePreset && activePreset.id !== '_default') { + applyPresetToState(activePreset, false); + } +} + +export function getPresets(): Preset[] { + return presets; +} + +export function getActivePreset(): Preset { + return presets.find(p => p.id === activePresetId) || presets[0]; +} + +export function getActivePresetId(): string | null { + return activePresetId; +} + +export async function activatePreset(presetId: string): Promise { + const preset = presets.find(p => p.id === presetId); + if (!preset) return; + + activePresetId = presetId; + state.set('activePresetId', presetId); + applyPresetToState(preset, true); + + const db = state.get(KEYS.DB); + if (db) { + await db.saveSetting(ACTIVE_PRESET_KEY, presetId); + } +} + +function applyPresetToState(preset: Preset, saveToDb: boolean): void { + const db = state.get(KEYS.DB); + + if (preset.systemPrompt) { + state.set(KEYS.SYSTEM_PROMPT_ENABLED, true); + state.set(KEYS.SYSTEM_PROMPT, preset.systemPrompt); + } else { + state.set(KEYS.SYSTEM_PROMPT_ENABLED, false); + state.set(KEYS.SYSTEM_PROMPT, ''); + } + + if (preset.numCtx) state.set(KEYS.NUM_CTX, preset.numCtx); + state.set('temperature', preset.temperature ?? 0.7); + state.set('thinkEnabled', preset.think ?? false); + + syncPresetToUI(preset); + + if (saveToDb && db) { + db.saveSetting('systemPromptEnabled', !!preset.systemPrompt); + db.saveSetting('systemPrompt', preset.systemPrompt || ''); + db.saveSetting('numCtx', preset.numCtx || 24576); + db.saveSetting('temperature', preset.temperature ?? 0.7); + } +} + +function syncPresetToUI(preset: Preset): void { + const toggleSP = document.querySelector('#toggleSystemPrompt'); + const inputSP = document.querySelector('#inputSystemPrompt'); + if (toggleSP) toggleSP.checked = !!preset.systemPrompt; + if (inputSP) { inputSP.disabled = !preset.systemPrompt; inputSP.value = preset.systemPrompt || ''; } + + const inputCtx = document.querySelector('#inputNumCtx'); + if (inputCtx) inputCtx.value = String(preset.numCtx || 24576); + + const tempSlider = document.querySelector('#inputTemperature'); + const tempDisplay = document.querySelector('#tempValue'); + if (tempSlider) tempSlider.value = String(preset.temperature ?? 0.7); + if (tempDisplay) tempDisplay.textContent = (preset.temperature ?? 0.7).toFixed(1); + + const thinkCheckbox = document.querySelector('#toggleThink'); + if (thinkCheckbox) { + const modelSupportsThink = !thinkCheckbox.disabled; + thinkCheckbox.checked = modelSupportsThink && (preset.think ?? false); + } +} + +export async function createPreset(data: { name: string; icon: string; systemPrompt: string; temperature: number; numCtx: number; think: boolean }): Promise { + const preset: Preset = { + id: `preset_${generateId()}`, + name: data.name || '新预设', + icon: data.icon || '🤖', + systemPrompt: data.systemPrompt || '', + temperature: data.temperature ?? 0.7, + numCtx: data.numCtx ?? 24576, + think: data.think ?? false, + createdAt: Date.now(), + updatedAt: Date.now() + }; + + presets.push(preset); + state.set('presets', [...presets]); + await saveUserPresets(); + return preset; +} + +export async function updatePreset(presetId: string, updates: Partial): Promise { + const idx = presets.findIndex(p => p.id === presetId); + if (idx === -1) return; + if (presets[idx].builtIn) return; + + presets[idx] = { ...presets[idx], ...updates, updatedAt: Date.now() }; + state.set('presets', [...presets]); + + if (activePresetId === presetId) { + applyPresetToState(presets[idx], true); + } + + await saveUserPresets(); +} + +export async function deletePreset(presetId: string): Promise { + const idx = presets.findIndex(p => p.id === presetId); + if (idx === -1) return; + if (presets[idx].builtIn) return; + + presets.splice(idx, 1); + state.set('presets', [...presets]); + + if (activePresetId === presetId) { + await activatePreset('_default'); + } + + await saveUserPresets(); +} + +async function saveUserPresets(): Promise { + const db = state.get(KEYS.DB); + if (!db) return; + + const userPresets = presets + .filter(p => !p.builtIn) + .map(({ builtIn, ...rest }) => rest); + + await db.saveSetting(PRESETS_KEY, userPresets); +} diff --git a/src/renderer/services/rag.ts b/src/renderer/services/rag.ts new file mode 100644 index 0000000..de9c3c0 --- /dev/null +++ b/src/renderer/services/rag.ts @@ -0,0 +1,145 @@ +/** + * RAG - 检索增强生成管线 + */ + +import { state, KEYS } from '../state/state.js'; +import { VectorStore } from './vector-store.js'; +import { chunkText, createChunkMetadata } from './document-processor.js'; +import type { OllamaAPI, SearchResult, VectorItem } from '../types.js'; + +let vectorStore: VectorStore | null = null; + +export async function initVectorStore(): Promise { + if (vectorStore) return vectorStore; + vectorStore = new VectorStore(); + await vectorStore.init(); + return vectorStore; +} + +export function getVectorStore(): VectorStore | null { + return vectorStore; +} + +export async function embedText(text: string, model: string): Promise { + const api = state.get(KEYS.API); + if (!api) throw new Error('Ollama API 未连接'); + + const result = await api.embed(model, text); + if (result.embedding) return result.embedding; + if (result.embeddings && result.embeddings[0]) return result.embeddings[0]; + throw new Error('嵌入向量返回格式异常'); +} + +export async function embedBatch(texts: string[], model: string, onProgress?: (done: number, total: number) => void): Promise { + const results: number[][] = []; + const batchSize = 5; + for (let i = 0; i < texts.length; i += batchSize) { + const batch = texts.slice(i, i + batchSize); + const embeddings = await Promise.all(batch.map(t => embedText(t, model))); + results.push(...embeddings); + if (onProgress) onProgress(Math.min(i + batchSize, texts.length), texts.length); + } + return results; +} + +export async function addDocumentToKB( + colId: string, filename: string, content: string, embedModel: string, + onProgress?: (done: number, total: number, msg: string) => void +): Promise<{ docId: string; chunkCount: number }> { + const vs = await initVectorStore(); + + const textChunks = chunkText(content); + if (textChunks.length === 0) throw new Error('文档内容为空,无法处理'); + + const docId = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + + if (onProgress) onProgress(0, textChunks.length, '正在生成向量...'); + const embeddings = await embedBatch(textChunks, embedModel, (done, total) => { + if (onProgress) onProgress(done, total, '正在生成向量...'); + }); + + const vectors: VectorItem[] = textChunks.map((chunk, i) => { + const meta = createChunkMetadata(chunk, i, docId, filename); + return { + ...meta, + collectionId: colId, + embedding: embeddings[i] + }; + }); + + if (onProgress) onProgress(textChunks.length, textChunks.length, '正在保存...'); + await vs.addVectors(vectors); + + const col = await vs.getCollection(colId); + if (col) { + col.docCount = (col.docCount || 0) + 1; + col.chunkCount = (col.chunkCount || 0) + textChunks.length; + col.embeddingModel = embedModel; + await vs.updateCollection(col); + } + + return { docId, chunkCount: textChunks.length }; +} + +export async function removeDocumentFromKB(colId: string, docId: string): Promise { + const vs = await initVectorStore(); + const allVectors = await vs.getVectorsByCollection(colId); + const docVectors = allVectors.filter(v => v.docId === docId); + + await vs.deleteVectorsByDocument(colId, docId); + + const col = await vs.getCollection(colId); + if (col) { + col.chunkCount = Math.max(0, (col.chunkCount || 0) - docVectors.length); + col.docCount = Math.max(0, (col.docCount || 0) - 1); + await vs.updateCollection(col); + } +} + +export async function getDocumentsInCollection(colId: string): Promise> { + const vs = await initVectorStore(); + const vectors = await vs.getVectorsByCollection(colId); + const docMap = new Map(); + for (const v of vectors) { + if (!docMap.has(v.docId)) { + docMap.set(v.docId, { docId: v.docId, filename: v.filename, chunkCount: 0 }); + } + docMap.get(v.docId)!.chunkCount++; + } + return Array.from(docMap.values()); +} + +export async function retrieveContext(query: string, colId: string, topK = 5): Promise<{ context: string; results: SearchResult[] }> { + const vs = await initVectorStore(); + const col = await vs.getCollection(colId); + if (!col) throw new Error('知识库集合不存在'); + + const embedModel = col.embeddingModel; + if (!embedModel) throw new Error('未配置嵌入模型'); + + const queryEmbedding = await embedText(query, embedModel); + const results = await vs.search(colId, queryEmbedding, topK); + if (results.length === 0) return { context: '', results: [] }; + + const contextParts = results.map((r, i) => + `[来源 ${i + 1}: ${r.filename}]\n${r.text}` + ); + const context = contextParts.join('\n\n---\n\n'); + + return { context, results }; +} + +export function buildRagSystemPrompt(context: string, originalSystemPrompt = ''): string { + const ragPrompt = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。 + +=== 检索到的相关内容 === +${context} +=== 内容结束 === + +请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`; + + if (originalSystemPrompt) { + return originalSystemPrompt + '\n\n' + ragPrompt; + } + return ragPrompt; +} diff --git a/src/renderer/services/vector-store.ts b/src/renderer/services/vector-store.ts new file mode 100644 index 0000000..c482773 --- /dev/null +++ b/src/renderer/services/vector-store.ts @@ -0,0 +1,368 @@ +/** + * VectorStore - 向量存储与相似度检索 + */ + +import type { VectorCollection, VectorItem, SearchResult } from '../types.js'; + +const MIN_IVF_SIZE = 200; +const DEFAULT_K = 20; +const DEFAULT_NPROBE = 5; +const KMEANS_ITERS = 10; + +interface IVFIndex { + centroids: number[][]; + invertedLists: Map; + collectionId?: string; + version?: string; +} + +export class VectorStore { + private dbName: string; + private db: IDBDatabase | null = null; + private _indexCache = new Map(); + private _vectorCache = new Map>(); + + constructor(dbName = 'metona-ollama-vectors') { + this.dbName = dbName; + } + + async init(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(this.dbName, 2); + req.onerror = () => reject(req.error); + req.onsuccess = () => { this.db = req.result; resolve(); }; + req.onupgradeneeded = (e) => { + const db = (e.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains('vectors')) { + const store = db.createObjectStore('vectors', { keyPath: 'id' }); + store.createIndex('collectionId', 'collectionId', { unique: false }); + } + if (!db.objectStoreNames.contains('collections')) { + db.createObjectStore('collections', { keyPath: 'id' }); + } + if (!db.objectStoreNames.contains('indexes')) { + db.createObjectStore('indexes', { keyPath: 'collectionId' }); + } + }; + }); + } + + private _tx(store: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore { + return this.db!.transaction(store, mode).objectStore(store); + } + + async createCollection(name: string, embeddingModel = ''): Promise { + const col: VectorCollection = { + id: `kb_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + name, embeddingModel, + docCount: 0, chunkCount: 0, + createdAt: Date.now(), updatedAt: Date.now() + }; + return new Promise((resolve, reject) => { + const req = this._tx('collections', 'readwrite').put(col); + req.onsuccess = () => resolve(col); + req.onerror = () => reject(req.error); + }); + } + + async getCollections(): Promise { + return new Promise((resolve, reject) => { + const req = this._tx('collections').getAll(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + } + + async getCollection(id: string): Promise { + return new Promise((resolve, reject) => { + const req = this._tx('collections').get(id); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => reject(req.error); + }); + } + + async updateCollection(col: VectorCollection): Promise { + col.updatedAt = Date.now(); + return new Promise((resolve, reject) => { + const req = this._tx('collections', 'readwrite').put(col); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } + + async deleteCollection(colId: string): Promise { + const vectors = await this.getVectorsByCollection(colId); + await new Promise((resolve, reject) => { + const tx = this.db!.transaction(['vectors', 'collections', 'indexes'], 'readwrite'); + const vStore = tx.objectStore('vectors'); + for (const v of vectors) vStore.delete(v.id); + tx.objectStore('collections').delete(colId); + tx.objectStore('indexes').delete(colId); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + this._indexCache.delete(colId); + this._vectorCache.delete(colId); + } + + async addVectors(items: VectorItem[]): Promise { + if (items.length === 0) return; + await new Promise((resolve, reject) => { + const tx = this.db!.transaction('vectors', 'readwrite'); + const store = tx.objectStore('vectors'); + for (const item of items) store.put(item); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + const colId = items[0].collectionId; + if (!this._vectorCache.has(colId)) { + this._vectorCache.set(colId, new Map()); + } + const cache = this._vectorCache.get(colId)!; + for (const item of items) cache.set(item.id, item); + this._indexCache.delete(colId); + } + + async getVectorsByCollection(colId: string): Promise { + if (this._vectorCache.has(colId)) { + return Array.from(this._vectorCache.get(colId)!.values()); + } + const vectors = await new Promise((resolve, reject) => { + const idx = this._tx('vectors').index('collectionId'); + const req = idx.getAll(colId); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + const cache = new Map(); + for (const v of vectors) cache.set(v.id, v); + this._vectorCache.set(colId, cache); + return vectors; + } + + async deleteVectorsByDocument(colId: string, docId: string): Promise { + const vectors = await this.getVectorsByCollection(colId); + const docVectors = vectors.filter(v => v.docId === docId); + await new Promise((resolve, reject) => { + const tx = this.db!.transaction('vectors', 'readwrite'); + const store = tx.objectStore('vectors'); + for (const v of docVectors) store.delete(v.id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + if (this._vectorCache.has(colId)) { + const cache = this._vectorCache.get(colId)!; + for (const v of docVectors) cache.delete(v.id); + } + this._indexCache.delete(colId); + } + + async search(colId: string, queryEmbedding: number[], topK = 5): Promise { + const vectors = await this.getVectorsByCollection(colId); + if (vectors.length === 0) return []; + + if (vectors.length < MIN_IVF_SIZE) { + return this._bruteForceSearch(vectors, queryEmbedding, topK); + } + + const index = await this._getIndex(colId, vectors); + if (!index) { + return this._bruteForceSearch(vectors, queryEmbedding, topK); + } + return this._ivfSearch(vectors, index, queryEmbedding, topK); + } + + private _bruteForceSearch(vectors: VectorItem[], queryEmbedding: number[], topK: number): SearchResult[] { + return vectors + .map(v => ({ ...v, score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding) })) + .sort((a, b) => b.score - a.score) + .slice(0, topK); + } + + private _ivfSearch(vectors: VectorItem[], index: IVFIndex, queryEmbedding: number[], topK: number): SearchResult[] { + const { centroids, invertedLists } = index; + const clusterScores = centroids.map((c, i) => ({ + id: i, + score: VectorStore.cosineSimilarity(queryEmbedding, c) + })); + clusterScores.sort((a, b) => b.score - a.score); + + const nprobe = Math.min(DEFAULT_NPROBE, centroids.length); + const targetClusters = clusterScores.slice(0, nprobe); + + const candidateIds = new Set(); + for (const { id: clusterId } of targetClusters) { + const list = invertedLists.get(clusterId); + if (list) for (const id of list) candidateIds.add(id); + } + + const vectorMap = new Map(); + for (const v of vectors) vectorMap.set(v.id, v); + + const candidates: VectorItem[] = []; + for (const id of candidateIds) { + const v = vectorMap.get(id); + if (v) candidates.push(v); + } + + return candidates + .map(v => ({ ...v, score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding) })) + .sort((a, b) => b.score - a.score) + .slice(0, topK); + } + + private async _getIndex(colId: string, vectors: VectorItem[]): Promise { + if (this._indexCache.has(colId)) { + const cached = this._indexCache.get(colId)!; + if (cached.version === this._indexVersion(vectors)) return cached; + } + + const saved = await new Promise } | null>((resolve, reject) => { + const req = this._tx('indexes').get(colId); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => reject(req.error); + }); + + if (saved && saved.version === this._indexVersion(vectors)) { + if (!(saved.invertedLists instanceof Map)) { + saved.invertedLists = new Map(Object.entries(saved.invertedListsObj || {})); + } + this._indexCache.set(colId, saved); + return saved; + } + + console.log(`[VectorStore] 构建 IVF 索引: ${colId} (${vectors.length} 向量)`); + const index = this._buildIVFIndex(vectors); + index.collectionId = colId; + index.version = this._indexVersion(vectors); + + const toSave = { + collectionId: index.collectionId, + version: index.version, + centroids: index.centroids, + invertedListsObj: Object.fromEntries(index.invertedLists) + }; + await new Promise((resolve, reject) => { + const req = this._tx('indexes', 'readwrite').put(toSave); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + + this._indexCache.set(colId, index); + return index; + } + + private _indexVersion(vectors: VectorItem[]): string { + if (vectors.length === 0) return '0'; + return `${vectors.length}_${vectors[0]?.id || ''}_${vectors[vectors.length - 1]?.id || ''}`; + } + + private _buildIVFIndex(vectors: VectorItem[]): IVFIndex { + const N = vectors.length; + const dim = vectors[0].embedding.length; + const K = Math.max(2, Math.min(DEFAULT_K, Math.floor(N / 10))); + + const centroids = this._kmeansPPInit(vectors, K, dim); + const assignments = new Array(N); + + for (let iter = 0; iter < KMEANS_ITERS; iter++) { + for (let i = 0; i < N; i++) { + let bestCluster = 0; + let bestScore = -Infinity; + for (let k = 0; k < K; k++) { + const score = VectorStore.cosineSimilarity(vectors[i].embedding, centroids[k]); + if (score > bestScore) { bestScore = score; bestCluster = k; } + } + assignments[i] = bestCluster; + } + + const newCentroids = Array.from({ length: K }, () => new Array(dim).fill(0)); + const counts = new Array(K).fill(0); + + for (let i = 0; i < N; i++) { + const k = assignments[i]; + counts[k]++; + const emb = vectors[i].embedding; + for (let d = 0; d < dim; d++) newCentroids[k][d] += emb[d]; + } + + for (let k = 0; k < K; k++) { + if (counts[k] > 0) { + for (let d = 0; d < dim; d++) newCentroids[k][d] /= counts[k]; + this._normalize(newCentroids[k]); + } else { + newCentroids[k] = [...vectors[Math.floor(Math.random() * N)].embedding]; + } + } + + let converged = true; + for (let k = 0; k < K; k++) { + if (VectorStore.cosineSimilarity(centroids[k], newCentroids[k]) < 0.999) { + converged = false; + break; + } + } + + for (let k = 0; k < K; k++) centroids[k] = newCentroids[k]; + if (converged) break; + } + + const invertedLists = new Map(); + for (let i = 0; i < N; i++) { + const k = assignments[i]; + if (!invertedLists.has(k)) invertedLists.set(k, []); + invertedLists.get(k)!.push(vectors[i].id); + } + + return { centroids, invertedLists }; + } + + private _kmeansPPInit(vectors: VectorItem[], K: number, dim: number): number[][] { + const centroids: number[][] = []; + const first = Math.floor(Math.random() * vectors.length); + centroids.push([...vectors[first].embedding]); + + for (let k = 1; k < K; k++) { + const dists = vectors.map(v => { + let minDist = Infinity; + for (const c of centroids) { + const sim = VectorStore.cosineSimilarity(v.embedding, c); + const dist = 1 - sim; + if (dist < minDist) minDist = dist; + } + return minDist; + }); + + const total = dists.reduce((s, d) => s + d, 0); + if (total === 0) { + centroids.push([...vectors[Math.floor(Math.random() * vectors.length)].embedding]); + continue; + } + + let r = Math.random() * total; + for (let i = 0; i < vectors.length; i++) { + r -= dists[i]; + if (r <= 0) { centroids.push([...vectors[i].embedding]); break; } + } + } + return centroids; + } + + private _normalize(vec: number[]): void { + let norm = 0; + for (let i = 0; i < vec.length; i++) norm += vec[i] * vec[i]; + norm = Math.sqrt(norm); + if (norm > 0) for (let i = 0; i < vec.length; i++) vec[i] /= norm; + } + + static cosineSimilarity(a: number[], b: number[]): number { + if (!a || !b || a.length !== b.length) return 0; + let dot = 0, normA = 0, normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom === 0 ? 0 : dot / denom; + } +} diff --git a/src/renderer/state/state.ts b/src/renderer/state/state.ts new file mode 100644 index 0000000..65df6d5 --- /dev/null +++ b/src/renderer/state/state.ts @@ -0,0 +1,151 @@ +/** + * State - 轻量级响应式状态管理 + */ + +import type { StateKey, ChatSession, ChatDB, OllamaAPI, Preset } from '../types.js'; + +function shallowEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null || b == null) return a === b; + if (typeof a !== 'object' || typeof b !== 'object') return false; + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; + } + if (Array.isArray(a) !== Array.isArray(b)) return false; + const keysA = Object.keys(a as object); + const keysB = Object.keys(b as object); + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if (!Object.prototype.hasOwnProperty.call(b, key) || (a as Record)[key] !== (b as Record)[key]) return false; + } + return true; +} + +type Listener = (value: unknown, old: unknown) => void; + +class AppState { + private _state: Record = {}; + private _listeners = new Map>(); + private _batching = false; + private _batchQueue: Set | null = null; + + set(key: string, value: unknown): void { + const old = this._state[key]; + this._state[key] = value; + if (!shallowEqual(old, value)) { + if (this._batching) { + this._batchQueue!.add(key); + } else { + this._notify(key, value, old); + } + } + } + + update(key: string, updater: (old: unknown) => unknown): void { + const old = this._state[key]; + const value = updater(old); + this._state[key] = value; + if (!shallowEqual(old, value)) { + if (this._batching) { + this._batchQueue!.add(key); + } else { + this._notify(key, value, old); + } + } + } + + setIn(key: string, path: (string | number)[], value: unknown): void { + if (!path || path.length === 0) { + this.set(key, value); + return; + } + const old = this._state[key]; + if (old == null || typeof old !== 'object') { + console.warn(`[State] setIn 失败: ${key} 不是对象`); + return; + } + const newRoot = Array.isArray(old) ? [...(old as unknown[])] : { ...(old as Record) }; + let current: Record = newRoot as Record; + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]; + const next = current[segment as string]; + if (next == null || typeof next !== 'object') { + const isIndex = typeof path[i + 1] === 'number'; + current[segment as string] = isIndex ? [] : {}; + } else { + current[segment as string] = Array.isArray(next) ? [...next] : { ...(next as Record) }; + } + current = current[segment as string] as Record; + } + current[path[path.length - 1] as string] = value; + this._state[key] = newRoot; + if (!shallowEqual(old, newRoot)) { + if (this._batching) { + this._batchQueue!.add(key); + } else { + this._notify(key, newRoot, old); + } + } + } + + batch(fn: () => void): void { + this._batching = true; + this._batchQueue = new Set(); + try { + fn(); + } finally { + const keys = this._batchQueue; + this._batching = false; + this._batchQueue = null; + for (const k of keys) { + this._notify(k, this._state[k], undefined); + } + } + } + + private _notify(key: string, value: unknown, old: unknown): void { + if (!this._listeners.has(key)) return; + for (const cb of this._listeners.get(key)!) { + try { cb(value, old); } catch (e) { + console.error(`[State] 监听器错误 (${key}):`, e); + } + } + } + + get(key: string, defaultValue?: T): T { + return (key in this._state ? this._state[key] : defaultValue) as T; + } + + on(key: string, callback: Listener): () => void { + if (!this._listeners.has(key)) { + this._listeners.set(key, new Set()); + } + this._listeners.get(key)!.add(callback); + return () => this._listeners.get(key)?.delete(callback); + } + + init(initial: Record): void { + Object.assign(this._state, initial); + } +} + +export const state = new AppState(); + +export const KEYS = { + DB: 'db', + API: 'api', + CURRENT_SESSION: 'currentSession', + IS_STREAMING: 'isStreaming', + IS_HISTORY_VIEW: 'isHistoryView', + PENDING_IMAGES: 'pendingImages', + ABORT_CONTROLLER: 'abortController', + SYSTEM_PROMPT: 'systemPrompt', + SYSTEM_PROMPT_ENABLED: 'systemPromptEnabled', + NUM_CTX: 'numCtx', + HISTORY_PAGE: 'historyPage', + HISTORY_SEARCH: 'historySearchQuery', +} as const; diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css new file mode 100644 index 0000000..9198df5 --- /dev/null +++ b/src/renderer/styles/style.css @@ -0,0 +1,2183 @@ +/* ═══════════════════════════════════════════════════════════════ + Metona Ollama — Windows 11 Fluent Design 暗色主题 + Mica 材质 · Segoe UI Variable · WinUI 3 控件风格 + ═══════════════════════════════════════════════════════════════ */ + +/* ── CSS 变量 ── */ +:root { + /* Windows 11 暗色调色板 */ + --bg-solid: #202020; + --bg-card: #2d2d2d; + --bg-card-hover: #323232; + --bg-layer: #383838; + --bg-layer-alt: #2a2a2a; + --bg-smoke: rgba(0, 0, 0, 0.3); + --bg-mica: rgba(32, 32, 32, 0.72); + --bg-acrylic: rgba(32, 32, 32, 0.8); + + --border-subtle: rgba(255, 255, 255, 0.06); + --border-default: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.12); + --border-focus: #60CDFF; + + --text-primary: #FFFFFF; + --text-secondary: #999999; + --text-tertiary: #666666; + --text-disabled: #505050; + + --accent: #60CDFF; + --accent-hover: #7AD8FF; + --accent-subtle: rgba(96, 205, 255, 0.06); + --accent-subtle-hover: rgba(96, 205, 255, 0.1); + + --critical: #F1707B; + --critical-bg: rgba(241, 112, 123, 0.06); + --success: #6CCB5F; + --success-bg: rgba(108, 203, 95, 0.06); + --caution: #FCE100; + --caution-bg: rgba(252, 225, 0, 0.06); + + /* 圆角 — Windows 11 标准 */ + --radius-control: 4px; + --radius-sm: 8px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + + /* 阴影 — Fluent Design 深度 */ + --shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.14); + --shadow-dialog: 0 24px 48px rgba(0, 0, 0, 0.18); + --shadow-tooltip: 0 4px 8px rgba(0, 0, 0, 0.14); + + /* 字体 */ + --font: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif; + --font-mono: 'Cascadia Mono', 'Cascadia Code', 'Consolas', monospace; + + --transition: 167ms cubic-bezier(0, 0, 0, 1); + --transition-fast: 83ms cubic-bezier(0, 0, 0, 1); +} + +/* ── 全局重置 ── */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + overflow: hidden; + background: var(--bg-solid); + color: var(--text-primary); + font-family: var(--font); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#app { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* ═══ 顶部导航 ═══ */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: var(--bg-mica); + backdrop-filter: saturate(180%) blur(20px); + -webkit-backdrop-filter: saturate(180%) blur(20px); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; + z-index: 10; +} + +.header-left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1; +} + +.logo { + font-size: 20px; + flex-shrink: 0; +} + +.app-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; +} + +.app-version { + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + background: var(--bg-layer); + border: 1px solid var(--border-default); + border-radius: 12px; + padding: 1px 8px; + white-space: nowrap; + user-select: none; +} + +.header-right { + display: flex; + align-items: center; + gap: 4px; +} + +/* ── 连接状态 ── */ +.conn-status { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--radius-control); + font-size: 12px; + color: var(--text-secondary); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + transition: var(--transition); +} + +.status-label { + white-space: nowrap; +} + +.conn-status.connected .status-dot { + background: var(--success); + box-shadow: 0 0 4px var(--success); +} + +.conn-status.disconnected .status-dot { + background: var(--critical); +} + +.conn-status.pending .status-dot { + background: var(--caution); + animation: blink 1s ease-in-out infinite; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ── 图标按钮 (WinUI 3 风格) ── */ +.icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: var(--radius-control); + cursor: pointer; + transition: var(--transition); + position: relative; +} + +.icon-btn::before { + content: ''; + position: absolute; + inset: 0; + border-radius: var(--radius-control); + background: currentColor; + opacity: 0; + transition: var(--transition); +} + +.icon-btn:hover::before { + opacity: 0.06; +} + +.icon-btn:active::before { + opacity: 0.04; +} + +.icon-btn:hover { + color: var(--text-primary); +} + +.icon-btn:active { + transform: scale(0.97); +} + +.icon-btn svg { + width: 18px; + height: 18px; + position: relative; + z-index: 1; +} + +.icon-btn.disabled, +.icon-btn:disabled { + opacity: 0.36; + cursor: not-allowed; +} + +/* 图片按钮禁用时保留 pointer-events 以显示 tooltip 和 cursor */ +#btnAttachImg.disabled { + pointer-events: auto; +} + +#btnAttachImg.disabled:hover { + opacity: 0.3; + background: rgba(241, 112, 123, 0.08); +} + +.icon-btn.sm { + width: 28px; + height: 28px; +} + +.icon-btn.sm svg { + width: 14px; + height: 14px; +} + +.icon-btn.danger:hover { + color: var(--critical); +} + +.help-btn { + margin-left: 2px; + opacity: 0.5; +} + +.help-btn:hover { + opacity: 1; +} + +/* ═══ 模型选择栏 ═══ */ +.model-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; + background: var(--bg-layer-alt); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.model-bar-icon { + width: 28px; + height: 28px; + border-radius: var(--radius-control); + background: var(--accent-subtle); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.model-bar-icon svg { + width: 14px; + height: 14px; + color: var(--accent); +} + +.model-select-wrap { + position: relative; + flex: 1; + min-width: 0; + display: flex; + align-items: center; +} + +.model-select { + width: 100%; + padding: 6px 32px 6px 12px; + background: var(--bg-layer); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font); + outline: none; + cursor: pointer; + transition: var(--transition); + appearance: none; + -webkit-appearance: none; +} + +.model-select:focus { + border-color: var(--border-focus); + box-shadow: 0 0 0 1px var(--border-focus); +} + +.model-select:hover { + border-color: var(--border-strong); +} + +.model-select option { + background: var(--bg-card); + color: var(--text-primary); +} + +.model-select-arrow { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--text-tertiary); +} + +.model-select-arrow svg { + width: 14px; + height: 14px; +} + +.model-badges { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.model-badge { + font-size: 11px; + font-weight: 500; + white-space: nowrap; + padding: 3px 8px; + border-radius: var(--radius-control); + border: 1px solid; +} + +.think-badge { + color: var(--accent); + background: var(--accent-subtle); + border-color: rgba(96, 205, 255, 0.15); +} + +.vision-badge { + color: var(--success); + background: var(--success-bg); + border-color: rgba(108, 203, 95, 0.15); +} + +.rag-badge { + color: #B388FF; + background: rgba(179, 136, 255, 0.06); + border-color: rgba(179, 136, 255, 0.15); +} + +/* ── 预设栏 ── */ +.preset-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + background: var(--bg-layer-alt); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.preset-list { + display: flex; + gap: 6px; + flex: 1; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + padding: 2px 0; +} + +.preset-list::-webkit-scrollbar { display: none; } + +.preset-chip { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 12px; + border-radius: var(--radius-control); + border: 1px solid var(--border-default); + background: var(--bg-layer); + color: var(--text-secondary); + font-size: 12px; + font-family: var(--font); + white-space: nowrap; + cursor: pointer; + transition: var(--transition); + flex-shrink: 0; + user-select: none; +} + +.preset-chip:hover { + background: var(--bg-card-hover); + border-color: var(--border-strong); + color: var(--text-primary); +} + +.preset-chip.active { + background: var(--accent-subtle); + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} + +.preset-chip.active:hover { + background: var(--accent-subtle-hover); +} + +.preset-chip-icon { + font-size: 13px; + line-height: 1; +} + +.preset-chip-name { + line-height: 1; +} + +.preset-add-btn { + width: 28px; + height: 28px; + border-radius: var(--radius-control); + border: 1px dashed var(--border-default); + background: transparent; + color: var(--text-tertiary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: var(--transition); + flex-shrink: 0; +} + +.preset-add-btn:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-subtle); +} + +.preset-add-btn svg { + width: 14px; + height: 14px; +} + +/* ── 预设模态框 ── */ +.preset-name-row { + display: flex; + gap: 8px; + align-items: center; +} + +.preset-icon-input { + width: 52px !important; + text-align: center; + font-size: 16px; + flex-shrink: 0; + padding: 6px 4px !important; +} + +.preset-temp-slider { + width: 100%; + -webkit-appearance: none; + appearance: none; + height: 4px; + border-radius: 2px; + background: var(--bg-layer); + outline: none; + margin: 8px 0; +} + +.preset-temp-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + box-shadow: 0 0 0 4px transparent; + transition: box-shadow var(--transition); +} + +.preset-temp-slider::-webkit-slider-thumb:hover { + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.preset-temp-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: none; +} + +.preset-modal-actions { + display: flex; + gap: 8px; + align-items: center; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--border-subtle); +} + +/* ── Think 按钮 ── */ +.think-toggle { + display: flex; + align-items: center; + cursor: pointer; + flex-shrink: 0; + user-select: none; +} + +.think-toggle input { display: none; } + +.think-btn { + width: 36px; + height: 36px; + border-radius: var(--radius-control); + background: var(--bg-layer); + border: 1px solid var(--border-default); + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); + color: var(--text-tertiary); +} + +.think-btn svg { + width: 18px; + height: 18px; +} + +.think-toggle:not(.unavailable):hover .think-btn { + border-color: var(--border-strong); + color: var(--text-secondary); +} + +.think-toggle input:checked + .think-btn { + background: var(--accent-subtle); + border-color: var(--accent); + color: var(--accent); + box-shadow: 0 0 12px rgba(96, 205, 255, 0.4), 0 0 4px rgba(96, 205, 255, 0.2); +} + +.think-toggle input:checked + .think-btn:hover { + background: var(--accent-subtle-hover); + box-shadow: 0 0 18px rgba(96, 205, 255, 0.55), 0 0 6px rgba(96, 205, 255, 0.3); +} + +.think-toggle.unavailable .think-btn { + opacity: 0.36; + cursor: not-allowed; +} + +.think-toggle.unavailable:hover .think-btn { + opacity: 0.3; + background: rgba(241, 112, 123, 0.08); +} + +/* ── 开关控件 (WinUI ToggleSwitch) ── */ +.toggle-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; + white-space: nowrap; +} + +.toggle-label input { display: none; } + +.toggle-slider { + position: relative; + width: 40px; + height: 20px; + background: var(--bg-layer); + border: 1px solid var(--border-default); + border-radius: 10px; + transition: var(--transition); +} + +.toggle-slider::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + background: var(--text-secondary); + border-radius: 50%; + transition: var(--transition); +} + +.toggle-label input:checked + .toggle-slider { + background: var(--accent); + border-color: var(--accent); +} + +.toggle-label input:checked + .toggle-slider::after { + transform: translateX(20px); + background: var(--bg-solid); +} + +/* ═══ 聊天区域 ═══ */ +.chat-area { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 16px; + scroll-behavior: smooth; +} + +.chat-area::-webkit-scrollbar { + width: 8px; +} + +.chat-area::-webkit-scrollbar-track { + background: transparent; +} + +.chat-area::-webkit-scrollbar-thumb { + background: var(--border-default); + border-radius: 4px; +} + +.chat-area::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); +} + +/* ── 回到底部按钮 ── */ +.scroll-to-bottom { + position: sticky; + bottom: 16px; + float: right; + margin-right: 4px; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border: 1px solid var(--border-default); + background: var(--bg-card); + backdrop-filter: blur(20px); + border-radius: var(--radius-control); + color: var(--text-secondary); + font-size: 12px; + font-family: var(--font); + cursor: pointer; + z-index: 5; + box-shadow: var(--shadow-flyout); + transition: var(--transition); + animation: fadeSlideUp 167ms ease; +} + +.scroll-to-bottom:hover { + background: var(--bg-card-hover); + border-color: var(--border-strong); + color: var(--text-primary); +} + +.scroll-to-bottom:active { + transform: scale(0.98); +} + +.scroll-to-bottom svg { + width: 14px; + height: 14px; +} + +@keyframes fadeSlideUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── 空状态 ── */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 12px; + color: var(--text-tertiary); +} + +.empty-icon svg { + width: 80px; + height: 80px; + opacity: 0.3; + color: var(--text-tertiary); +} + +.empty-state h2 { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); +} + +.empty-state p { + font-size: 14px; + color: var(--text-tertiary); +} + +/* ── 消息气泡 ── */ +.messages { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 780px; + margin: 0 auto; +} + +.message { + display: flex; + gap: 12px; + animation: fadeSlideUp 167ms ease; +} + +.msg-avatar { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + border-radius: var(--radius-control); + background: var(--bg-layer); + border: 1px solid var(--border-subtle); +} + +.msg-body { + flex: 1; + min-width: 0; +} + +.msg-role { + font-size: 12px; + font-weight: 600; + color: var(--text-tertiary); + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.model-tag { + display: inline-block; + font-size: 11px; + font-weight: 600; + color: var(--accent); + background: var(--accent-subtle); + border: 1px solid rgba(96, 205, 255, 0.15); + border-radius: var(--radius-control); + padding: 1px 8px; + line-height: 1.5; + white-space: nowrap; +} + +.message.user .msg-body { + background: var(--bg-layer); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 10px 14px; +} + +.message.assistant .msg-body { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 10px 14px; +} + +.msg-content { + font-size: 14px; + line-height: 1.65; + word-break: break-word; +} + +.msg-content pre { + background: var(--bg-solid); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + padding: 12px 14px; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 13px; + margin: 8px 0; +} + +.msg-content code { + background: rgba(255, 255, 255, 0.06); + padding: 1px 5px; + border-radius: 3px; + font-family: var(--font-mono); + font-size: 0.9em; +} + +.msg-content pre code { + background: none; + padding: 0; +} + +.msg-content p { margin-bottom: 8px; } +.msg-content p:last-child { margin-bottom: 0; } + +/* 思考块 */ +.think-block { + margin-bottom: 8px; + border: 1px solid rgba(252, 225, 0, 0.12); + border-radius: var(--radius-control); + overflow: hidden; +} + +.think-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--caution-bg); + color: var(--caution); + font-size: 12px; + cursor: pointer; + user-select: none; +} + +.think-header .chevron { + margin-left: auto; + transition: transform var(--transition); +} + +.think-header .chevron.expanded { + transform: rotate(180deg); +} + +.think-content { + padding: 8px 12px; + background: rgba(252, 225, 0, 0.02); +} + +.think-content pre { + font-size: 12px; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + margin: 0; + background: none; + border: none; + padding: 0; +} + +/* 打字光标 */ +.typing-cursor { + display: inline-block; + width: 2px; + height: 1em; + background: var(--accent); + animation: caretBlink 1s step-end infinite; + vertical-align: text-bottom; + margin-left: 1px; +} + +@keyframes caretBlink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ═══ Loading 动画 ═══ */ +.loading-dots { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 8px 0; +} + +.loading-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-tertiary); + animation: dotPulse 1.4s ease-in-out infinite; +} + +.loading-dots span:nth-child(1) { animation-delay: 0s; } +.loading-dots span:nth-child(2) { animation-delay: 0.2s; } +.loading-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes dotPulse { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; } + 40% { transform: scale(1); opacity: 1; } +} + +.loading-text { + font-size: 12px; + color: var(--text-tertiary); + animation: loadingFade 1.5s ease-in-out infinite; +} + +@keyframes loadingFade { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.8; } +} + +/* 消息图片 */ +.msg-images { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.msg-img { + max-width: 200px; + max-height: 200px; + border-radius: var(--radius-control); + border: 1px solid var(--border-subtle); + object-fit: cover; +} + +.msg-img[data-lightbox] { + cursor: zoom-in; + transition: var(--transition); +} + +.msg-img[data-lightbox]:hover { + opacity: 0.85; +} + +/* 文件 chip */ +.msg-files { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.msg-files .file-chip { + background: var(--accent-subtle); + border-color: rgba(96, 205, 255, 0.12); +} + +/* 统计信息 */ +.msg-stats { + display: flex; + gap: 12px; + margin-top: 6px; + font-size: 11px; + color: var(--text-tertiary); +} + +/* RAG 知识库来源 */ +.rag-sources { + margin-top: 8px; + padding: 8px 12px; + background: var(--accent-subtle); + border: 1px solid rgba(96, 205, 255, 0.1); + border-radius: var(--radius-control); + font-size: 12px; +} + +.rag-sources-header { + color: var(--accent); + font-weight: 600; + margin-bottom: 4px; + font-size: 11px; +} + +.rag-source-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 3px 0; + color: var(--text-tertiary); +} + +.rag-source-item + .rag-source-item { + border-top: 1px solid var(--border-subtle); +} + +.rag-source-name { + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rag-source-score { + color: var(--success); + font-weight: 500; + margin-left: 8px; + flex-shrink: 0; +} + +/* ═══ 输入区域 ═══ */ +.input-area { + flex-shrink: 0; + padding: 10px 16px 14px; + background: var(--bg-mica); + backdrop-filter: saturate(180%) blur(20px); + -webkit-backdrop-filter: saturate(180%) blur(20px); + border-top: 1px solid var(--border-subtle); +} + +.image-preview { + display: flex; + gap: 8px; + padding: 8px 0; + flex-wrap: wrap; +} + +.preview-thumb { + position: relative; + width: 56px; + height: 56px; + border-radius: var(--radius-control); + overflow: hidden; + border: 1px solid var(--border-default); +} + +.preview-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove-img { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + border: none; + background: var(--critical); + color: white; + border-radius: 50%; + font-size: 12px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +/* 文件预览 */ +.file-preview { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 0; +} + +.file-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--radius-control); + background: var(--accent-subtle); + border: 1px solid rgba(96, 205, 255, 0.12); + font-size: 12px; + line-height: 1; + transition: var(--transition); + animation: fadeSlideUp 167ms ease; +} + +.file-chip:hover { + background: var(--accent-subtle-hover); + border-color: rgba(96, 205, 255, 0.2); +} + +.file-icon { + font-size: 13px; + flex-shrink: 0; +} + +.file-name { + font-weight: 600; + color: var(--text-primary); + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-size { + color: var(--text-tertiary); + flex-shrink: 0; +} + +.remove-file { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0 2px; + border-radius: 3px; + transition: var(--transition); +} + +.remove-file:hover { + color: var(--critical); + background: var(--critical-bg); +} + +.input-row { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.attach-btn { + flex-shrink: 0; +} + +.chat-input { + flex: 1; + padding: 8px 12px; + background: var(--bg-layer); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + color: var(--text-primary); + font-family: var(--font); + font-size: 14px; + resize: none; + outline: none; + min-height: 36px; + max-height: 120px; + line-height: 1.5; + transition: var(--transition); +} + +.chat-input:focus { + border-color: var(--border-focus); + box-shadow: 0 0 0 1px var(--border-focus); +} + +.chat-input::placeholder { + color: var(--text-disabled); +} + +.send-btn { + flex-shrink: 0; + width: 36px; + height: 36px; + border: none; + border-radius: var(--radius-control); + background: var(--accent); + color: #1a1a1a; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); +} + +.send-btn:hover { + background: var(--accent-hover); +} + +.send-btn:active { + transform: scale(0.96); +} + +.send-btn.disabled { + opacity: 0.36; + cursor: not-allowed; + pointer-events: none; +} + +.send-btn svg { + width: 16px; + height: 16px; +} + +.send-btn.stop-btn { + background: var(--critical); + color: white; +} + +.send-btn.stop-btn:hover { + opacity: 0.9; +} + +.spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ═══ 历史只读提示栏 ═══ */ +.history-view-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 16px; + background: rgba(96, 205, 255, 0.04); + border-top: 1px solid rgba(96, 205, 255, 0.1); + font-size: 13px; + color: var(--accent); + flex-shrink: 0; +} + +/* ═══ 模态框 (WinUI ContentDialog 风格) ═══ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + animation: overlayIn 167ms ease; + padding: 20px; +} + +@keyframes overlayIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + background: var(--bg-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-dialog); + width: 100%; + max-width: 480px; + max-height: 80vh; + display: flex; + flex-direction: column; + animation: dialogIn 200ms cubic-bezier(0, 0, 0, 1); + overflow: hidden; +} + +@keyframes dialogIn { + from { opacity: 0; transform: scale(0.96) translateY(8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.modal-lg { + max-width: 860px; + width: 96vw; + max-height: 85vh; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.modal-header h3 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.modal-body { + padding: 16px 20px; + overflow-y: auto; + flex: 1; +} + +/* ── 设置项 ── */ +.setting-group { + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.setting-group:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.setting-label { + display: flex; + align-items: center; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.setting-input { + width: 100%; + padding: 8px 10px; + background: var(--bg-layer); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font); + outline: none; + margin-bottom: 6px; + transition: var(--transition); +} + +.setting-input:focus { + border-color: var(--border-focus); + box-shadow: 0 0 0 1px var(--border-focus); +} + +.setting-input::placeholder { + color: var(--text-disabled); +} + +/* ── 按钮 (WinUI Button 风格) ── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 16px; + border: 1px solid transparent; + border-radius: var(--radius-control); + font-size: 13px; + font-weight: 400; + font-family: var(--font); + cursor: pointer; + transition: var(--transition); + gap: 6px; + position: relative; + outline: none; +} + +.btn::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: currentColor; + opacity: 0; + transition: var(--transition); +} + +.btn:hover::before { opacity: 0.06; } +.btn:active::before { opacity: 0.04; } + +.btn:active { + transform: scale(0.98); +} + +.btn-primary { + background: var(--accent); + color: #1a1a1a; + border-color: transparent; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-primary::before { display: none; } + +.btn-outline { + background: var(--bg-layer); + border: 1px solid var(--border-default); + color: var(--text-secondary); +} + +.btn-outline:hover { + border-color: var(--border-strong); + color: var(--text-primary); +} + +.btn-danger { + background: var(--critical-bg); + border: 1px solid rgba(241, 112, 123, 0.15); + color: var(--critical); +} + +.btn-danger:hover { + background: rgba(241, 112, 123, 0.1); +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +/* ── 连接信息 ── */ +.connection-info { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.status-badge { + padding: 3px 10px; + border-radius: var(--radius-control); + font-size: 12px; + font-weight: 500; +} + +.status-badge.success { + background: var(--success-bg); + color: var(--success); + border: 1px solid rgba(108, 203, 95, 0.15); +} + +.status-badge.error { + background: var(--critical-bg); + color: var(--critical); + border: 1px solid rgba(241, 112, 123, 0.15); +} + +.status-badge.warning { + background: var(--caution-bg); + color: var(--caution); + border: 1px solid rgba(252, 225, 0, 0.15); +} + +.server-version { + font-size: 12px; + color: var(--text-tertiary); + font-family: var(--font-mono); +} + +.cors-hint { + padding: 10px; + background: var(--caution-bg); + border: 1px solid rgba(252, 225, 0, 0.1); + border-radius: var(--radius-control); + margin-top: 8px; +} + +.cors-hint p { + font-size: 12px; + color: var(--caution); + margin-bottom: 6px; +} + +.cors-hint code { + display: block; + padding: 8px; + background: var(--bg-solid); + border-radius: 3px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent); + user-select: all; +} + +/* ── 运行中的模型 ── */ +.running-models { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; +} + +.running-model { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: var(--bg-layer); + border-radius: var(--radius-control); + border: 1px solid var(--border-subtle); +} + +.running-model .model-name { + flex: 1; + font-size: 13px; + font-weight: 500; +} + +.running-model .model-vram { + font-size: 12px; + color: var(--text-tertiary); + font-family: var(--font-mono); +} + +.text-muted { + color: var(--text-tertiary); + font-size: 13px; +} + +/* ── 历史记录模态框加宽 ── */ +#historyModal .modal { + max-width: 680px; + max-height: 88vh; +} + +/* ── 历史记录 ── */ +.history-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.history-search-wrap { + position: relative; + margin-bottom: 10px; +} + +.history-search-input { + width: 100%; + padding: 8px 32px 8px 12px; + background: var(--bg-layer); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + color: var(--text-primary); + font-size: 13px; + font-family: var(--font); + outline: none; + transition: var(--transition); +} + +.history-search-input::placeholder { + color: var(--text-disabled); +} + +.history-search-input:focus { + border-color: var(--border-focus); + box-shadow: 0 0 0 1px var(--border-focus); +} + +.history-search-clear { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + font-size: 14px; + padding: 4px; + line-height: 1; +} + +.history-search-clear:hover { + color: var(--text-primary); +} + +.history-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 10px; + border-radius: var(--radius-control); + transition: var(--transition); +} + +.history-item:hover { + background: var(--bg-layer); +} + +.history-info { + flex: 1; + min-width: 0; + cursor: pointer; +} + +.history-title { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-meta { + display: flex; + gap: 10px; + font-size: 11px; + color: var(--text-tertiary); + margin-top: 2px; +} + +.history-actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +/* ── 历史分页 ── */ +.history-pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0 4px; + border-top: 1px solid var(--border-subtle); + margin-top: 8px; +} + +.page-info { + font-size: 12px; + color: var(--text-tertiary); +} + +.page-buttons { + display: flex; + align-items: center; + gap: 4px; +} + +.page-btn { + min-width: 30px; + height: 30px; + border: 1px solid var(--border-default); + background: var(--bg-layer); + color: var(--text-secondary); + border-radius: var(--radius-control); + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); +} + +.page-btn:hover:not(.disabled):not(.active) { + border-color: var(--border-strong); + color: var(--text-primary); +} + +.page-btn.active { + background: var(--accent-subtle); + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} + +.page-btn.disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.page-ellipsis { + color: var(--text-disabled); + font-size: 12px; + padding: 0 2px; +} + +/* ═══ Lightbox ═══ */ +.lightbox-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; + cursor: zoom-out; + animation: overlayIn 167ms ease; +} + +.lightbox-img { + max-width: 90vw; + max-height: 90vh; + border-radius: var(--radius-md); + box-shadow: var(--shadow-dialog); + object-fit: contain; + cursor: default; +} + +.lightbox-close { + position: fixed; + top: 16px; + right: 20px; + width: 36px; + height: 36px; + border: 1px solid var(--border-default); + background: var(--bg-card); + color: var(--text-primary); + font-size: 16px; + border-radius: var(--radius-control); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); + z-index: 301; +} + +.lightbox-close:hover { + background: var(--bg-card-hover); +} + +/* ═══ 帮助弹框 ═══ */ +#helpModal .modal { + max-width: 600px; + max-height: 85vh; +} + +.help-section { + margin-bottom: 16px; + padding: 12px 14px; + background: var(--bg-layer); + border-radius: var(--radius-control); + border-left: 2px solid var(--border-strong); + transition: border-color var(--transition); +} + +.help-section:hover { + border-left-color: var(--accent); +} + +.help-section:last-child { + margin-bottom: 0; +} + +.help-section h4 { + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + color: var(--text-primary); +} + +.help-section ul, +.help-section ol { + padding-left: 18px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.7; +} + +.help-section li { + margin-bottom: 3px; +} + +.help-section code { + background: rgba(255, 255, 255, 0.06); + color: var(--accent); + padding: 1px 6px; + border-radius: 3px; + font-size: 12px; +} + +.help-section kbd { + background: var(--bg-layer); + border: 1px solid var(--border-default); + border-bottom-width: 2px; + border-radius: 3px; + padding: 1px 6px; + font-size: 12px; + font-family: inherit; + color: var(--text-primary); +} + +.help-shortcuts { + width: 100%; + font-size: 13px; + border-collapse: collapse; + border-radius: var(--radius-control); + overflow: hidden; + border: 1px solid var(--border-default); +} + +.help-shortcuts td { + padding: 6px 10px; + border-bottom: 1px solid var(--border-subtle); +} + +.help-shortcuts tr:last-child td { + border-bottom: none; +} + +.help-shortcuts td:first-child { + width: 140px; + text-align: center; + background: var(--bg-layer); +} + +.help-shortcuts tr:hover td { + background: var(--bg-layer-alt); +} + +/* ═══ 知识库管理 ═══ */ +#kbModal .modal { + max-width: 95vw; + width: 1100px; + max-height: 88vh; +} + +#kbModal .modal-body { + padding: 16px 20px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.kb-layout { + display: flex; + gap: 16px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.kb-sidebar { + width: 240px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + overflow: hidden; + padding-right: 16px; + border-right: 1px solid var(--border-subtle); +} + +.kb-new-collection { + display: flex; + gap: 6px; +} + +.kb-new-collection .setting-input { + flex: 1; + margin-bottom: 0; + font-size: 13px; + padding: 6px 10px; +} + +.kb-collection-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; +} + +.kb-collection-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: var(--radius-control); + cursor: pointer; + transition: var(--transition); +} + +.kb-collection-item:hover { + background: var(--bg-layer); +} + +.kb-collection-item.active { + background: var(--accent-subtle); + border: 1px solid rgba(96, 205, 255, 0.12); +} + +.kb-col-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.kb-col-name { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.kb-col-stats { + font-size: 11px; + color: var(--text-tertiary); +} + +.kb-col-delete { + opacity: 0; + transition: opacity var(--transition); +} + +.kb-collection-item:hover .kb-col-delete { + opacity: 1; +} + +.kb-main { + flex: 1; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.kb-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + flex-wrap: wrap; + padding: 8px 12px; + background: var(--bg-layer); + border-radius: var(--radius-control); + border: 1px solid var(--border-subtle); +} + +.kb-embed-select { + flex: 1; + min-width: 0; + max-width: 200px; + padding: 5px 8px; + font-size: 12px; + background: var(--bg-layer-alt); + color: var(--text-primary); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + outline: none; + cursor: pointer; + font-family: var(--font); +} + +.kb-embed-select:focus { + border-color: var(--border-focus); +} + +.kb-rag-toggle { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + white-space: nowrap; + padding-left: 8px; + border-left: 1px solid var(--border-subtle); +} + +.kb-progress { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-tertiary); + padding: 3px 8px; + background: var(--accent-subtle); + border-radius: var(--radius-control); +} + +.kb-doc-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; +} + +.kb-doc-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-control); + transition: var(--transition); +} + +.kb-doc-item:hover { + background: var(--bg-layer); +} + +.kb-doc-icon { + font-size: 16px; + flex-shrink: 0; +} + +.kb-doc-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.kb-doc-name { + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.kb-doc-meta { + font-size: 11px; + color: var(--text-tertiary); +} + +.kb-doc-delete { + opacity: 0; + transition: opacity var(--transition); +} + +.kb-doc-item:hover .kb-doc-delete { + opacity: 1; +} + +/* ── 移动端响应式 ── */ +@media (max-width: 640px) { + .kb-layout { + flex-direction: column; + } + + .kb-sidebar { + width: 100%; + max-height: 160px; + border-right: none; + padding-right: 0; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-subtle); + } + + .kb-main { + min-height: 180px; + } + + .modal-lg { + max-height: 92vh; + } +} + +/* ═══ Toast 通知 ═══ */ +.toast-container { + position: fixed; + top: 56px; + right: 16px; + z-index: 200; + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: var(--radius-control); + background: var(--bg-card); + border: 1px solid var(--border-default); + box-shadow: var(--shadow-flyout); + font-size: 13px; + color: var(--text-primary); + pointer-events: auto; + animation: toastIn 167ms ease; + max-width: 320px; +} + +.toast.removing { + animation: toastOut 167ms ease forwards; +} + +.toast-icon { + font-size: 14px; + flex-shrink: 0; +} + +.toast.success { border-left: 3px solid var(--success); } +.toast.error { border-left: 3px solid var(--critical); } +.toast.warning { border-left: 3px solid var(--caution); } +.toast.info { border-left: 3px solid var(--accent); } + +@keyframes toastIn { + from { opacity: 0; transform: translateX(24px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes toastOut { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(24px); } +} + +/* ═══ 响应式 ═══ */ +@media (min-width: 768px) { + .app-header { padding: 8px 24px; } + .model-bar { padding: 8px 24px; } + .chat-area { padding: 20px 24px; } + .messages { max-width: 820px; } + .input-area { padding: 10px 24px 16px; } + .modal { max-width: 500px; } +} + +@media (min-width: 1024px) { + .messages { max-width: 900px; } +} + +/* ── 选中文本 ── */ +::selection { + background: rgba(96, 205, 255, 0.3); +} + +/* ── 链接 ── */ +.msg-content a { + color: var(--accent); + text-decoration: none; +} + +.msg-content a:hover { + text-decoration: underline; +} + +.msg-content .blocked-link { + color: var(--critical); + text-decoration: line-through; + cursor: not-allowed; + opacity: 0.7; +} + +/* ── 列表 ── */ +.msg-content ul, .msg-content ol { + padding-left: 20px; + margin: 8px 0; +} + +.msg-content li { margin-bottom: 3px; } + +/* ── 表格 ── */ +.msg-content table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 13px; +} + +.msg-content th, .msg-content td { + padding: 6px 10px; + border: 1px solid var(--border-default); + text-align: left; +} + +.msg-content th { + background: var(--bg-layer); + font-weight: 600; +} + +/* ── 引用块 ── */ +.msg-content blockquote { + border-left: 2px solid var(--border-strong); + padding-left: 12px; + margin: 8px 0; + color: var(--text-secondary); +} diff --git a/src/renderer/types.d.ts b/src/renderer/types.d.ts new file mode 100644 index 0000000..2ac288b --- /dev/null +++ b/src/renderer/types.d.ts @@ -0,0 +1,250 @@ +// ── Ollama API 类型 ── + +export interface OllamaMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + images?: string[]; + thinking?: string; + reasoning_content?: string; +} + +export interface OllamaChatParams { + model: string; + messages: OllamaMessage[]; + stream?: boolean; + think?: boolean; + system?: string; + keep_alive?: number | string; + options?: { + num_ctx?: number; + temperature?: number; + [key: string]: unknown; + }; +} + +export interface OllamaStreamChunk { + model?: string; + message?: { + role: string; + content?: string; + thinking?: string; + reasoning_content?: string; + }; + done?: boolean; + eval_count?: number; + total_duration?: number; +} + +export interface OllamaModelInfo { + name: string; + size?: number; + digest?: string; + modified_at?: string; +} + +export interface OllamaModelDetail { + capabilities?: string[]; + [key: string]: unknown; +} + +export interface OllamaVersionResponse { + version: string; +} + +export interface OllamaModelsResponse { + models: OllamaModelInfo[]; +} + +export interface OllamaPsResponse { + models?: Array<{ + name: string; + size_vram?: number; + [key: string]: unknown; + }>; +} + +export interface OllamaEmbedResponse { + embedding?: number[]; + embeddings?: number[][]; +} + +// ── 会话类型 ── + +export interface ChatFile { + name: string; + language?: string; + size?: number; +} + +export interface FileContent { + language: string; + content: string; +} + +export interface RagSource { + filename: string; + score: number; + text: string; +} + +export interface ChatMessage { + role: 'user' | 'assistant'; + content: string; + timestamp: number; + model?: string; + think?: string; + eval_count?: number; + total_duration?: number; + images?: string[]; + files?: ChatFile[]; + _fileContents?: FileContent[]; + ragSources?: RagSource[]; + stopped?: boolean; +} + +export interface ChatSession { + id: string; + title: string; + model: string; + messages: ChatMessage[]; + createdAt: number; + updatedAt: number; +} + +// ── 预设类型 ── + +export interface Preset { + id: string; + name: string; + icon: string; + systemPrompt: string; + temperature: number; + numCtx: number; + think: boolean; + builtIn?: boolean; + createdAt: number; + updatedAt: number; +} + +// ── 知识库类型 ── + +export interface VectorCollection { + id: string; + name: string; + embeddingModel: string; + docCount: number; + chunkCount: number; + createdAt: number; + updatedAt: number; +} + +export interface VectorItem { + id: string; + collectionId: string; + docId: string; + filename: string; + chunkIndex: number; + text: string; + charCount: number; + embedding: number[]; +} + +export interface SearchResult extends VectorItem { + score: number; +} + +export interface DocInfo { + docId: string; + filename: string; + chunkCount: number; +} + +// ── 桌面 Bridge 类型 ── + +export interface MetonaDesktopAPI { + isDesktop: boolean; + info: () => Promise; + dialog: { + openFile: (options?: FileFilterOptions) => Promise; + saveFile: (options?: SaveFileOptions) => Promise; + }; + fs: { + readFile: (filePath: string) => Promise<{ success: boolean; content?: string; name?: string; error?: string }>; + writeFile: (filePath: string, content: string) => Promise<{ success: boolean; error?: string }>; + }; + notify: (title: string, body: string) => void; + window: { + minimize: () => void; + maximize: () => void; + close: () => void; + }; + openExternal: (url: string) => void; + onMenuAction: (callback: (action: string) => void) => void; + onTrayAction: (callback: (action: string) => void) => void; + removeAllListeners: (channel: string) => void; +} + +export interface AppInfo { + version: string; + platform: string; + arch: string; + electronVersion: string; + userDataPath: string; +} + +export interface FileFilterOptions { + filters?: Array<{ name: string; extensions: string[] }>; +} + +export interface SaveFileOptions { + defaultPath?: string; + filters?: Array<{ name: string; extensions: string[] }>; +} + +// ── 桌面 Bridge 接口 ── + +export interface DesktopBridge { + isDesktop: boolean; + getInfo: () => Promise<{ platform: string; version: string; arch?: string; electronVersion?: string; userDataPath?: string }>; + openFile: (filters?: Array<{ name: string; extensions: string[] }>) => Promise; + saveFile: (options?: SaveFileOptions) => Promise; + readFile: (filePath: string) => Promise<{ success: boolean; content?: string; name?: string; error?: string }>; + writeFile: (filePath: string, content: string) => Promise<{ success: boolean; error?: string }>; + notify: (title: string, body: string) => void; + minimize: () => void; + maximize: () => void; + close: () => void; + openExternal: (url: string) => void; + onMenuAction: (callback: (action: string) => void) => void; + onTrayAction: (callback: (action: string) => void) => void; + off: (channel: string) => void; +} + +// ── State Keys ── + +export type StateKey = + | 'db' + | 'api' + | 'currentSession' + | 'isStreaming' + | 'isHistoryView' + | 'pendingImages' + | 'abortController' + | 'systemPrompt' + | 'systemPromptEnabled' + | 'numCtx' + | 'historyPage' + | 'historySearchQuery' + | 'temperature' + | 'thinkEnabled' + | 'presets' + | 'activePresetId'; + +// ── Window global 声明 ── + +declare global { + interface Window { + metonaDesktop?: MetonaDesktopAPI; + __metonaBridge?: DesktopBridge; + } +} diff --git a/src/renderer/utils/marked-config.ts b/src/renderer/utils/marked-config.ts new file mode 100644 index 0000000..f4e488a --- /dev/null +++ b/src/renderer/utils/marked-config.ts @@ -0,0 +1,83 @@ +/** + * Markdown 渲染器配置 + */ + +import { SAFE_URI_SCHEMES } from './sanitizer.js'; + +// 简化的 Markdown 解析器(内联,不依赖外部 marked 库) +// 支持基本 GFM 语法:标题、粗体、斜体、代码块、链接、列表、表格 + +function parseMarkdown(md: string): string { + if (!md) return ''; + let html = escapeForMd(md); + + // 代码块 ```...``` + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, lang, code) => { + return `
${code.trim()}
`; + }); + + // 行内代码 + html = html.replace(/`([^`]+)`/g, '$1'); + + // 标题 + html = html.replace(/^######\s+(.+)$/gm, '
$1
'); + html = html.replace(/^#####\s+(.+)$/gm, '
$1
'); + html = html.replace(/^####\s+(.+)$/gm, '

$1

'); + html = html.replace(/^###\s+(.+)$/gm, '

$1

'); + html = html.replace(/^##\s+(.+)$/gm, '

$1

'); + html = html.replace(/^#\s+(.+)$/gm, '

$1

'); + + // 粗体+斜体 + html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); + // 粗体 + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + // 斜体 + html = html.replace(/\*(.+?)\*/g, '$1'); + // 删除线 + html = html.replace(/~~(.+?)~~/g, '$1'); + + // 链接 [text](url) + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, href) => { + try { + const safe = SAFE_URI_SCHEMES.has(new URL(href, 'https://placeholder').protocol); + if (!safe) return `${text}`; + } catch { /* allow relative */ } + return `${text}`; + }); + + // 图片 ![alt](url) + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + + // 引用块 + html = html.replace(/^>\s+(.+)$/gm, '

$1

'); + + // 水平线 + html = html.replace(/^---+$/gm, '
'); + + // 无序列表 + html = html.replace(/^[\-\*]\s+(.+)$/gm, '
  • $1
  • '); + html = html.replace(/((?:
  • .*<\/li>\n?)+)/g, '
      $1
    '); + + // 有序列表 + html = html.replace(/^\d+\.\s+(.+)$/gm, '
  • $1
  • '); + + // 换行 + html = html.replace(/\n\n/g, '

    '); + html = html.replace(/\n/g, '
    '); + + // 包裹在 p 标签中 + if (!html.startsWith('<')) { + html = '

    ' + html + '

    '; + } + + return html; +} + +function escapeForMd(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>'); +} + +export { parseMarkdown as marked }; diff --git a/src/renderer/utils/sanitizer.ts b/src/renderer/utils/sanitizer.ts new file mode 100644 index 0000000..cf51362 --- /dev/null +++ b/src/renderer/utils/sanitizer.ts @@ -0,0 +1,74 @@ +/** + * Sanitizer - HTML 净化器 + */ + +export const SAFE_URI_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:', 'ftp:', 'ftps:', 'xmpp:']); +export const SAFE_DATA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']); + +const ALLOWED_TAGS = new Set([ + 'a', 'abbr', 'b', 'blockquote', 'br', 'code', 'del', 'details', 'div', + 'dl', 'dt', 'dd', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', + 'i', 'img', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'rp', 'rt', + 'ruby', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', + 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', + 'tr', 'u', 'ul', 'var', 'input', +]); + +const ALLOWED_ATTRS = new Set([ + 'href', 'src', 'alt', 'title', 'width', 'height', 'align', + 'target', 'rel', 'class', 'id', 'type', 'checked', 'disabled', + 'start', 'value', 'scope', 'rowspan', 'colspan', 'lang', +]); + +const DANGEROUS_PREFIXES = ['on', 'xlink:href', 'xmlns:']; + +export function sanitize(html: string): string { + try { + const template = document.createElement('template'); + template.innerHTML = html; + const root = template.content; + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT); + const comments: Comment[] = []; + while (walker.nextNode()) comments.push(walker.currentNode as Comment); + comments.forEach(c => c.remove()); + + const elements = [...root.querySelectorAll('*')]; + for (const el of elements) { + if (!el.parentNode) continue; + const tag = el.tagName.toLowerCase(); + + if (!ALLOWED_TAGS.has(tag)) { + while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el); + el.remove(); + continue; + } + + for (const attr of [...el.attributes]) { + const name = attr.name.toLowerCase(); + if (DANGEROUS_PREFIXES.some(p => name.startsWith(p))) { + el.removeAttribute(attr.name); + continue; + } + if (!ALLOWED_ATTRS.has(name)) { + el.removeAttribute(attr.name); + continue; + } + if ((name === 'href' || name === 'src' || name === 'action') && attr.value) { + const val = attr.value.trim().toLowerCase(); + if (/^(javascript|vbscript|data|file):/i.test(val)) { + const mimeMatch = val.match(/^data:([^;,]+)/i); + if (!mimeMatch || !SAFE_DATA_TYPES.has(mimeMatch[1].toLowerCase())) { + el.removeAttribute(attr.name); + } + } + } + } + } + + return root.innerHTML; + } catch (e) { + console.warn('[Sanitizer] 净化失败:', e); + return html; + } +} diff --git a/src/renderer/utils/utils.ts b/src/renderer/utils/utils.ts new file mode 100644 index 0000000..82ff153 --- /dev/null +++ b/src/renderer/utils/utils.ts @@ -0,0 +1,115 @@ +/** + * Utils - 通用工具函数 + */ + +/** 生成唯一 ID */ +export function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +/** 格式化时间戳 */ +export function formatTime(ts: number): string { + const d = new Date(ts); + const pad = (n: number): string => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** 截断文本 */ +export function truncate(str: string, len = 50): string { + if (!str) return ''; + return str.length > len ? str.substring(0, len) + '...' : str; +} + +/** 防抖 */ +export function debounce unknown>(fn: T, ms = 300): (...args: Parameters) => void { + let timer: ReturnType; + return (...args: Parameters) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; +} + +/** 格式化字节大小 */ +export function formatSize(bytes: number): string { + if (!bytes) return ''; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let size = bytes; + while (size >= 1024 && i < units.length - 1) { + size /= 1024; + i++; + } + return `${size.toFixed(1)} ${units[i]}`; +} + +/** HTML 转义 */ +export function escapeHtml(str: string): string { + const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + return String(str).replace(/[&<>"']/g, (c) => map[c]); +} + +/** 下载文件 */ +export function downloadFile(filename: string, content: string, type: string): void { + const blob = new Blob([content], { type: type + ';charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +/** File → Base64(去前缀) */ +export function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(',')[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +/** File → 文本内容 */ +export function fileToText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsText(file); + }); +} + +/** 根据文件名检测语言标识(用于 Markdown 代码块) */ +export function detectLanguage(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + const map: Record = { + py: 'python', pyw: 'python', pyi: 'python', + js: 'javascript', mjs: 'javascript', cjs: 'javascript', + ts: 'typescript', tsx: 'tsx', jsx: 'jsx', + java: 'java', class: 'java', + c: 'c', h: 'c', + cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', + go: 'go', rs: 'rust', rb: 'ruby', php: 'php', + sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', + sql: 'sql', html: 'html', htm: 'html', css: 'css', + json: 'json', jsonl: 'json', + xml: 'xml', svg: 'svg', + yaml: 'yaml', yml: 'yaml', toml: 'toml', + ini: 'ini', cfg: 'ini', conf: 'ini', + md: 'markdown', markdown: 'markdown', rst: 'rst', + txt: '', csv: 'csv', tsv: 'csv', log: '', + swift: 'swift', kt: 'kotlin', kts: 'kotlin', + scala: 'scala', lua: 'lua', pl: 'perl', r: 'r', + m: 'matlab', mm: 'objectivec', + cs: 'csharp', fs: 'fsharp', vb: 'vbnet', + ex: 'elixir', exs: 'elixir', erl: 'erlang', + hs: 'haskell', clj: 'clojure', lisp: 'lisp', + dockerfile: 'dockerfile', makefile: 'makefile', + diff: 'diff', patch: 'diff', + }; + return map[ext] ?? ext; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..86464f5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "baseUrl": ".", + "paths": { + "@main/*": ["src/main/*"], + "@renderer/*": ["src/renderer/*"] + }, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["node_modules", "dist", "release"] +}