From 551995df683527b83bcb0adb50f77f5b519dd4ea Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:45:17 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E8=B6=85=E7=BA=A7=E6=97=A0=E6=95=8C?= =?UTF-8?q?=E5=B8=85=E6=B0=94=E5=88=B0=E7=88=86=E7=82=B8=E8=B5=B7=E9=A3=9E?= =?UTF-8?q?=E7=9A=84=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 59 + electron/preload.ts | 21 + electron/services/exportHtml.css | 30 + electron/services/exportService.ts | 465 ++- electron/services/llamaService.ts | 371 ++ electron/services/voiceTranscribeService.ts | 297 +- package-lock.json | 3361 ++++++++++++++++++- package.json | 3 + src/App.tsx | 2 + src/components/MessageBubble.tsx | 36 + src/components/Sidebar.tsx | 2 +- src/pages/AIChatPage.scss | 552 +++ src/pages/AIChatPage.tsx | 391 +++ src/pages/SettingsPage.scss | 302 +- src/pages/SettingsPage.tsx | 318 +- src/services/EngineService.ts | 108 + src/types/electron.d.ts | 11 + vite.config.ts | 3 +- 18 files changed, 5938 insertions(+), 394 deletions(-) create mode 100644 electron/services/llamaService.ts create mode 100644 src/components/MessageBubble.tsx create mode 100644 src/pages/AIChatPage.scss create mode 100644 src/pages/AIChatPage.tsx create mode 100644 src/services/EngineService.ts diff --git a/electron/main.ts b/electron/main.ts index 20d0215..b6637a4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -21,6 +21,7 @@ import { videoService } from './services/videoService' import { snsService } from './services/snsService' import { contactExportService } from './services/contactExportService' import { windowsHelloService } from './services/windowsHelloService' +import { llamaService } from './services/llamaService' import { registerNotificationHandlers, showNotification } from './windows/notificationWindow' @@ -800,6 +801,64 @@ function registerIpcHandlers() { return await chatService.getContact(username) }) + // Llama AI + ipcMain.handle('llama:init', async () => { + return await llamaService.init() + }) + + ipcMain.handle('llama:loadModel', async (_, modelPath: string) => { + return llamaService.loadModel(modelPath) + }) + + ipcMain.handle('llama:createSession', async (_, systemPrompt?: string) => { + return llamaService.createSession(systemPrompt) + }) + + ipcMain.handle('llama:chat', async (event, message: string, options?: { thinking?: boolean }) => { + // We use a callback to stream back to the renderer + const webContents = event.sender + try { + if (!webContents) return { success: false, error: 'No sender' } + + const response = await llamaService.chat(message, options, (token) => { + if (!webContents.isDestroyed()) { + webContents.send('llama:token', token) + } + }) + return { success: true, response } + } catch (e) { + return { success: false, error: String(e) } + } + }) + + ipcMain.handle('llama:downloadModel', async (event, url: string, savePath: string) => { + const webContents = event.sender + try { + await llamaService.downloadModel(url, savePath, (payload) => { + if (!webContents.isDestroyed()) { + webContents.send('llama:downloadProgress', payload) + } + }) + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + }) + + ipcMain.handle('llama:getModelsPath', async () => { + return llamaService.getModelsPath() + }) + + ipcMain.handle('llama:checkFileExists', async (_, filePath: string) => { + const { existsSync } = await import('fs') + return existsSync(filePath) + }) + + ipcMain.handle('llama:getModelStatus', async (_, modelPath: string) => { + return llamaService.getModelStatus(modelPath) + }) + + ipcMain.handle('chat:getContactAvatar', async (_, username: string) => { return await chatService.getContactAvatar(username) }) diff --git a/electron/preload.ts b/electron/preload.ts index a579ca1..ce5e94f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -265,5 +265,26 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url) + }, + + // Llama AI + llama: { + loadModel: (modelPath: string) => ipcRenderer.invoke('llama:loadModel', modelPath), + createSession: (systemPrompt?: string) => ipcRenderer.invoke('llama:createSession', systemPrompt), + chat: (message: string, options?: any) => ipcRenderer.invoke('llama:chat', message, options), + downloadModel: (url: string, savePath: string) => ipcRenderer.invoke('llama:downloadModel', url, savePath), + getModelsPath: () => ipcRenderer.invoke('llama:getModelsPath'), + checkFileExists: (filePath: string) => ipcRenderer.invoke('llama:checkFileExists', filePath), + getModelStatus: (modelPath: string) => ipcRenderer.invoke('llama:getModelStatus', modelPath), + onToken: (callback: (token: string) => void) => { + const listener = (_: any, token: string) => callback(token) + ipcRenderer.on('llama:token', listener) + return () => ipcRenderer.removeListener('llama:token', listener) + }, + onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => { + const listener = (_: any, payload: { downloaded: number; total: number; speed: number }) => callback(payload) + ipcRenderer.on('llama:downloadProgress', listener) + return () => ipcRenderer.removeListener('llama:downloadProgress', listener) + } } }) diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css index 53698d2..c7898d2 100644 --- a/electron/services/exportHtml.css +++ b/electron/services/exportHtml.css @@ -299,3 +299,33 @@ body[data-theme="teal-water"] { color: var(--muted); padding: 40px; } + +/* Virtual Scroll */ +.virtual-scroll-container { + height: calc(100vh - 180px); + /* Adjust based on header height */ + overflow-y: auto; + position: relative; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + margin-top: 20px; +} + +.virtual-scroll-spacer { + opacity: 0; + pointer-events: none; + width: 1px; +} + +.virtual-scroll-content { + position: absolute; + top: 0; + left: 0; + width: 100%; +} + +.message-list { + /* Override message-list to be inside virtual scroll */ + display: block; +} \ No newline at end of file diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 054d2ea..86b7d77 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -159,7 +159,7 @@ class ExportService { } const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const cleaned = suffixMatch ? suffixMatch[1] : trimmed - + return cleaned } @@ -1148,11 +1148,11 @@ class ExportService { const emojiMd5 = msg.emojiMd5 if (!emojiUrl && !emojiMd5) { - + return null } - + const key = emojiMd5 || String(msg.localId) // 根据 URL 判断扩展名 @@ -3013,6 +3013,165 @@ class ExportService { } } + private getVirtualScrollScript(): string { + return ` + class VirtualScroller { + constructor(container, list, data, renderItem) { + this.container = container; + this.list = list; + this.data = data; + this.renderItem = renderItem; + + this.rowHeight = 80; // Estimated height + this.buffer = 5; + this.heightCache = new Map(); + this.visibleItems = new Set(); + + this.spacer = document.createElement('div'); + this.spacer.className = 'virtual-scroll-spacer'; + this.content = document.createElement('div'); + this.content.className = 'virtual-scroll-content'; + + this.container.appendChild(this.spacer); + this.container.appendChild(this.content); + + this.container.addEventListener('scroll', () => this.onScroll()); + window.addEventListener('resize', () => this.onScroll()); + + this.updateTotalHeight(); + this.onScroll(); + } + + setData(newData) { + this.data = newData; + this.heightCache.clear(); + this.content.innerHTML = ''; + this.container.scrollTop = 0; + this.updateTotalHeight(); + this.onScroll(); + + // Show/Hide empty state + if (this.data.length === 0) { + this.content.innerHTML = '