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 = '