修复实时更新偶发失效的问题;删除AI对话有关组件与依赖

This commit is contained in:
cc
2026-02-22 15:26:13 +08:00
parent 70481fd468
commit 4b9d94eb62
15 changed files with 162 additions and 3507 deletions

View File

@@ -21,7 +21,7 @@ import { videoService } from './services/videoService'
import { snsService, isVideoUrl } from './services/snsService'
import { contactExportService } from './services/contactExportService'
import { windowsHelloService } from './services/windowsHelloService'
import { llamaService } from './services/llamaService'
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
import { httpService } from './services/httpService'
@@ -825,63 +825,6 @@ 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)

View File

@@ -288,27 +288,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir')
},
// 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)
}
},
// HTTP API 服务
http: {
start: (port?: number) => ipcRenderer.invoke('http:start', port),

View File

@@ -1,371 +0,0 @@
import fs from "fs";
import { app, BrowserWindow } from "electron";
import path from "path";
import { ConfigService } from './config';
// Define interfaces locally to avoid static import of types that might not be available or cause issues
type LlamaModel = any;
type LlamaContext = any;
type LlamaChatSession = any;
export class LlamaService {
private _model: LlamaModel | null = null;
private _context: LlamaContext | null = null;
private _sequence: any = null;
private _session: LlamaChatSession | null = null;
private _llama: any = null;
private _nodeLlamaCpp: any = null;
private configService = new ConfigService();
private _initialized = false;
constructor() {
// 延迟初始化,只在需要时初始化
}
public async init() {
if (this._initialized) return;
try {
// Dynamic import to handle ESM module in CJS context
this._nodeLlamaCpp = await import("node-llama-cpp");
this._llama = await this._nodeLlamaCpp.getLlama();
this._initialized = true;
console.log("[LlamaService] Llama initialized");
} catch (error) {
console.error("[LlamaService] Failed to initialize Llama:", error);
}
}
public async loadModel(modelPath: string) {
if (!this._llama) await this.init();
try {
console.log("[LlamaService] Loading model from:", modelPath);
if (!this._llama) {
throw new Error("Llama not initialized");
}
this._model = await this._llama.loadModel({
modelPath: modelPath,
gpuLayers: 'max', // Offload all layers to GPU if possible
useMlock: false // Disable mlock to avoid "VirtualLock" errors (common on Windows)
});
if (!this._model) throw new Error("Failed to load model");
this._context = await this._model.createContext({
contextSize: 8192, // Balanced context size for better performance
batchSize: 2048 // Increase batch size for better prompt processing speed
});
if (!this._context) throw new Error("Failed to create context");
this._sequence = this._context.getSequence();
const { LlamaChatSession } = this._nodeLlamaCpp;
this._session = new LlamaChatSession({
contextSequence: this._sequence
});
console.log("[LlamaService] Model loaded successfully");
return true;
} catch (error) {
console.error("[LlamaService] Failed to load model:", error);
throw error;
}
}
public async createSession(systemPrompt?: string) {
if (!this._context) throw new Error("Model not loaded");
if (!this._nodeLlamaCpp) await this.init();
const { LlamaChatSession } = this._nodeLlamaCpp;
if (!this._sequence) {
this._sequence = this._context.getSequence();
}
this._session = new LlamaChatSession({
contextSequence: this._sequence,
systemPrompt: systemPrompt
});
return true;
}
public async chat(message: string, options: { thinking?: boolean } = {}, onToken: (token: string) => void) {
if (!this._session) throw new Error("Session not initialized");
const thinking = options.thinking ?? false;
// Sampling parameters based on mode
const samplingParams = thinking ? {
temperature: 0.6,
topP: 0.95,
topK: 20,
repeatPenalty: 1.5 // PresencePenalty=1.5
} : {
temperature: 0.7,
topP: 0.8,
topK: 20,
repeatPenalty: 1.5
};
try {
const response = await this._session.prompt(message, {
...samplingParams,
onTextChunk: (chunk: string) => {
onToken(chunk);
}
});
return response;
} catch (error) {
console.error("[LlamaService] Chat error:", error);
throw error;
}
}
public async getModelStatus(modelPath: string) {
try {
const exists = fs.existsSync(modelPath);
if (!exists) {
return { exists: false, path: modelPath };
}
const stats = fs.statSync(modelPath);
return {
exists: true,
path: modelPath,
size: stats.size
};
} catch (error) {
return { exists: false, error: String(error) };
}
}
private resolveModelDir(): string {
const configured = this.configService.get('whisperModelDir') as string | undefined;
if (configured) return configured;
return path.join(app.getPath('documents'), 'WeFlow', 'models');
}
public async downloadModel(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
// Ensure directory exists
const dir = path.dirname(savePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
console.info(`[LlamaService] Multi-threaded download check for: ${savePath}`);
if (fs.existsSync(savePath)) {
fs.unlinkSync(savePath);
}
// 1. Get total size and check range support
let probeResult;
try {
probeResult = await this.probeUrl(url);
} catch (err) {
console.warn("[LlamaService] Probe failed, falling back to single-thread.", err);
return this.downloadSingleThread(url, savePath, onProgress);
}
const { totalSize, acceptRanges, finalUrl } = probeResult;
console.log(`[LlamaService] Total size: ${totalSize}, Accept-Ranges: ${acceptRanges}`);
if (totalSize <= 0 || !acceptRanges) {
console.warn("[LlamaService] Ranges not supported or size unknown, falling back to single-thread.");
return this.downloadSingleThread(finalUrl, savePath, onProgress);
}
const threadCount = 4;
const chunkSize = Math.ceil(totalSize / threadCount);
const fd = fs.openSync(savePath, 'w');
let downloadedLength = 0;
let lastDownloadedLength = 0;
let lastTime = Date.now();
let speed = 0;
const speedInterval = setInterval(() => {
const now = Date.now();
const duration = (now - lastTime) / 1000;
if (duration > 0) {
speed = (downloadedLength - lastDownloadedLength) / duration;
lastDownloadedLength = downloadedLength;
lastTime = now;
onProgress({ downloaded: downloadedLength, total: totalSize, speed });
}
}, 1000);
try {
const promises = [];
for (let i = 0; i < threadCount; i++) {
const start = i * chunkSize;
const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1;
promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => {
downloadedLength += bytes;
}));
}
await Promise.all(promises);
console.log("[LlamaService] Multi-threaded download complete");
// Final progress update
onProgress({ downloaded: totalSize, total: totalSize, speed: 0 });
} catch (err) {
console.error("[LlamaService] Multi-threaded download failed:", err);
throw err;
} finally {
clearInterval(speedInterval);
fs.closeSync(fd);
}
}
private async probeUrl(url: string): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
const protocol = url.startsWith('https') ? require('https') : require('http');
const options = {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.modelscope.cn/',
'Range': 'bytes=0-0'
}
};
return new Promise((resolve, reject) => {
const req = protocol.get(url, options, (res: any) => {
if ([301, 302, 307, 308].includes(res.statusCode)) {
const location = res.headers.location;
const nextUrl = new URL(location, url).href;
this.probeUrl(nextUrl).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 206 && res.statusCode !== 200) {
reject(new Error(`Probe failed: HTTP ${res.statusCode}`));
return;
}
const contentRange = res.headers['content-range'];
let totalSize = 0;
if (contentRange) {
const parts = contentRange.split('/');
totalSize = parseInt(parts[parts.length - 1], 10);
} else {
totalSize = parseInt(res.headers['content-length'] || '0', 10);
}
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange;
resolve({ totalSize, acceptRanges, finalUrl: url });
res.destroy();
});
req.on('error', reject);
});
}
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
const protocol = url.startsWith('https') ? require('https') : require('http');
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.modelscope.cn/',
'Range': `bytes=${start}-${end}`
}
};
return new Promise((resolve, reject) => {
const req = protocol.get(url, options, (res: any) => {
if (res.statusCode !== 206) {
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`));
return;
}
let currentOffset = start;
res.on('data', (chunk: Buffer) => {
try {
fs.writeSync(fd, chunk, 0, chunk.length, currentOffset);
currentOffset += chunk.length;
onData(chunk.length);
} catch (err) {
reject(err);
res.destroy();
}
});
res.on('end', () => resolve());
res.on('error', reject);
});
req.on('error', reject);
});
}
private async downloadSingleThread(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? require('https') : require('http');
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.modelscope.cn/'
}
};
const request = protocol.get(url, options, (response: any) => {
if ([301, 302, 307, 308].includes(response.statusCode)) {
const location = response.headers.location;
const nextUrl = new URL(location, url).href;
this.downloadSingleThread(nextUrl, savePath, onProgress).then(resolve).catch(reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`));
return;
}
const totalLength = parseInt(response.headers['content-length'] || '0', 10);
let downloadedLength = 0;
let lastDownloadedLength = 0;
let lastTime = Date.now();
let speed = 0;
const fileStream = fs.createWriteStream(savePath);
response.pipe(fileStream);
const speedInterval = setInterval(() => {
const now = Date.now();
const duration = (now - lastTime) / 1000;
if (duration > 0) {
speed = (downloadedLength - lastDownloadedLength) / duration;
lastDownloadedLength = downloadedLength;
lastTime = now;
onProgress({ downloaded: downloadedLength, total: totalLength, speed });
}
}, 1000);
response.on('data', (chunk: any) => {
downloadedLength += chunk.length;
});
fileStream.on('finish', () => {
clearInterval(speedInterval);
fileStream.close();
resolve();
});
fileStream.on('error', (err: any) => {
clearInterval(speedInterval);
fs.unlink(savePath, () => { });
reject(err);
});
});
request.on('error', reject);
});
}
public getModelsPath() {
return this.resolveModelDir();
}
}
export const llamaService = new LlamaService();

View File

@@ -66,8 +66,12 @@ export class WcdbCore {
private wcdbVerifyUser: any = null
private wcdbStartMonitorPipe: any = null
private wcdbStopMonitorPipe: any = null
private wcdbGetMonitorPipeName: any = null
private monitorPipeClient: any = null
private monitorCallback: ((type: string, json: string) => void) | null = null
private monitorReconnectTimer: any = null
private monitorPipePath: string = ''
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
@@ -92,63 +96,94 @@ export class WcdbCore {
// 使用命名管道 IPC
startMonitor(callback: (type: string, json: string) => void): boolean {
if (!this.wcdbStartMonitorPipe) {
this.writeLog('startMonitor: wcdbStartMonitorPipe not available')
return false
}
this.monitorCallback = callback
try {
const result = this.wcdbStartMonitorPipe()
if (result !== 0) {
this.writeLog(`startMonitor: wcdbStartMonitorPipe failed with ${result}`)
return false
}
const net = require('net')
const PIPE_PATH = '\\\\.\\pipe\\weflow_monitor'
setTimeout(() => {
this.monitorPipeClient = net.createConnection(PIPE_PATH, () => {
this.writeLog('Monitor pipe connected')
})
let buffer = ''
this.monitorPipeClient.on('data', (data: Buffer) => {
buffer += data.toString('utf8')
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line)
callback(parsed.action || 'update', line)
} catch {
callback('update', line)
}
}
// 从 DLL 获取动态管道名(含 PID
let pipePath = '\\\\.\\pipe\\weflow_monitor'
if (this.wcdbGetMonitorPipeName) {
try {
const namePtr = [null as any]
if (this.wcdbGetMonitorPipeName(namePtr) === 0 && namePtr[0]) {
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
this.wcdbFreeString(namePtr[0])
}
})
} catch {}
}
this.monitorPipeClient.on('error', (err: Error) => {
this.writeLog(`Monitor pipe error: ${err.message}`)
})
this.monitorPipeClient.on('close', () => {
this.writeLog('Monitor pipe closed')
this.monitorPipeClient = null
})
}, 100)
this.writeLog('Monitor started via named pipe IPC')
this.connectMonitorPipe(pipePath)
return true
} catch (e) {
console.error('打开数据库异常:', e)
console.error('[wcdbCore] startMonitor exception:', e)
return false
}
}
// 连接命名管道,支持断开后自动重连
private connectMonitorPipe(pipePath: string) {
this.monitorPipePath = pipePath
const net = require('net')
setTimeout(() => {
if (!this.monitorCallback) return
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
})
let buffer = ''
this.monitorPipeClient.on('data', (data: Buffer) => {
buffer += data.toString('utf8')
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line)
this.monitorCallback?.(parsed.action || 'update', line)
} catch {
this.monitorCallback?.('update', line)
}
}
}
})
this.monitorPipeClient.on('error', () => {
})
this.monitorPipeClient.on('close', () => {
this.monitorPipeClient = null
this.scheduleReconnect()
})
}, 100)
}
// 定时重连
private scheduleReconnect() {
if (this.monitorReconnectTimer || !this.monitorCallback) return
this.monitorReconnectTimer = setTimeout(() => {
this.monitorReconnectTimer = null
if (this.monitorCallback && !this.monitorPipeClient) {
this.connectMonitorPipe(this.monitorPipePath)
}
}, 3000)
}
stopMonitor(): void {
this.monitorCallback = null
if (this.monitorReconnectTimer) {
clearTimeout(this.monitorReconnectTimer)
this.monitorReconnectTimer = null
}
if (this.monitorPipeClient) {
this.monitorPipeClient.destroy()
this.monitorPipeClient = null
@@ -569,11 +604,13 @@ export class WcdbCore {
try {
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
this.wcdbGetMonitorPipeName = this.lib.func('int32 wcdb_get_monitor_pipe_name(_Out_ void** outName)')
this.writeLog('Monitor pipe functions loaded')
} catch (e) {
console.warn('Failed to load monitor pipe functions:', e)
this.wcdbStartMonitorPipe = null
this.wcdbStopMonitorPipe = null
this.wcdbGetMonitorPipeName = null
}
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)

View File

@@ -136,7 +136,6 @@ export class WcdbService {
*/
setMonitor(callback: (type: string, json: string) => void): void {
this.monitorListener = callback;
// Notify worker to enable monitor
this.callWorker('setMonitor').catch(() => { });
}

1918
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,6 @@
"jszip": "^3.10.1",
"koffi": "^2.9.0",
"lucide-react": "^0.562.0",
"node-llama-cpp": "^3.15.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",

Binary file not shown.

View File

@@ -22,7 +22,6 @@ import SnsPage from './pages/SnsPage'
import ContactsPage from './pages/ContactsPage'
import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow'
import AIChatPage from './pages/AIChatPage'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
@@ -457,7 +456,7 @@ function App() {
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/view" element={<AnalyticsPage />} />
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />

View File

@@ -14,7 +14,6 @@ export function GlobalSessionMonitor() {
} = useChatStore()
const sessionsRef = useRef(sessions)
// 保持 ref 同步
useEffect(() => {
sessionsRef.current = sessions
@@ -47,9 +46,10 @@ export function GlobalSessionMonitor() {
return () => {
removeListener()
}
} else {
}
return () => { }
}, []) // 空依赖数组 - 主要是静态的
}, [])
const refreshSessions = async () => {
try {

View File

@@ -1,552 +0,0 @@
// AI 对话页面 - 简约大气风格
.ai-chat-page {
display: flex;
height: 100%;
width: 100%;
background: var(--bg-gradient);
color: var(--text-primary);
overflow: hidden;
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
// ========== 顶部 Header - 已移除 ==========
// 模型选择器现已集成到输入框
// ========== 聊天区域 ==========
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
position: relative;
overflow: hidden;
// 空状态
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
.icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--primary-light);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
svg {
width: 40px;
height: 40px;
color: var(--primary);
}
}
h2 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
p {
font-size: 14px;
color: var(--text-tertiary);
margin: 0;
}
}
// 消息列表
.messages-list {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
display: flex;
flex-direction: column;
gap: 20px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.message-row {
display: flex;
gap: 12px;
max-width: 80%;
animation: messageIn 0.3s ease-out;
// 用户消息
&.user {
align-self: flex-end;
flex-direction: row-reverse;
.avatar {
background: var(--primary-light);
color: var(--primary);
}
.bubble {
background: var(--primary-gradient);
color: white;
border-radius: 18px 18px 4px 18px;
box-shadow: 0 2px 10px color-mix(in srgb, var(--primary) 20%, transparent);
.content {
color: white;
}
}
}
// AI 消息
&.ai {
align-self: flex-start;
.avatar {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.bubble {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 18px 18px 18px 4px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
.avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.bubble {
padding: 12px 16px;
flex: 1;
min-width: 0;
.content,
.markdown-content {
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
word-wrap: break-word;
overflow-wrap: break-word;
}
// Markdown 样式
.markdown-content {
p {
margin: 0 0 0.8em;
&:last-child {
margin-bottom: 0;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 1em 0 0.5em;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary);
&:first-child {
margin-top: 0;
}
}
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.3em;
}
h3 {
font-size: 1.1em;
}
ul,
ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
li {
margin: 0.3em 0;
}
code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
pre {
background: var(--bg-tertiary);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 0.8em 0;
code {
background: none;
padding: 0;
}
}
blockquote {
border-left: 3px solid var(--primary);
padding-left: 12px;
margin: 0.8em 0;
color: var(--text-secondary);
}
a {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
strong {
font-weight: 600;
color: var(--text-primary);
}
hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 1em 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0.8em 0;
th,
td {
border: 1px solid var(--border-color);
padding: 8px 12px;
text-align: left;
}
th {
background: var(--bg-tertiary);
font-weight: 600;
}
}
}
}
}
.list-spacer {
height: 100px;
flex-shrink: 0;
}
}
// 输入区域
.input-area {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 64px);
max-width: 800px;
z-index: 10;
.input-wrapper {
display: flex;
align-items: flex-end;
gap: 10px;
background: var(--card-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 10px 14px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
&:focus-within {
border-color: var(--primary);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1),
0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
textarea {
flex: 1;
min-height: 24px;
max-height: 120px;
padding: 8px 0;
background: transparent;
border: none;
resize: none;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
line-height: 1.5;
&:focus {
outline: none;
}
&::placeholder {
color: var(--text-tertiary);
}
&:disabled {
cursor: not-allowed;
}
}
.input-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
// 模型选择器
.model-selector {
position: relative;
.model-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: auto;
height: 36px;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
transition: all 0.2s ease;
flex-shrink: 0;
svg {
flex-shrink: 0;
&.spin {
animation: spin 1s linear infinite;
}
}
&:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--text-tertiary);
color: var(--text-primary);
}
&.loaded {
background: color-mix(in srgb, var(--primary) 15%, transparent);
border-color: var(--primary);
color: var(--primary);
}
&.loading {
opacity: 0.7;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.model-dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--card-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 100;
overflow: hidden;
animation: dropdownIn 0.2s ease-out;
min-width: 140px;
.model-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
transition: background 0.15s ease;
white-space: nowrap;
&:hover:not(.disabled) {
background: var(--bg-hover);
}
&.active {
background: color-mix(in srgb, var(--primary) 20%, transparent);
color: var(--primary);
font-weight: 600;
.check {
color: var(--primary);
}
}
.check {
margin-left: 8px;
color: var(--text-tertiary);
font-weight: 600;
}
}
}
}
.mode-toggle {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
color: var(--text-tertiary);
transition: all 0.2s ease;
flex-shrink: 0;
&:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: color-mix(in srgb, var(--primary) 15%, transparent);
border-color: var(--primary);
color: var(--primary);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.send-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-gradient);
border: none;
border-radius: 10px;
cursor: pointer;
color: white;
transition: all 0.2s ease;
flex-shrink: 0;
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 25%, transparent);
&:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 35%, transparent);
}
&:active:not(:disabled) {
transform: scale(0.98);
}
&:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
box-shadow: none;
cursor: not-allowed;
}
}
}
}
}
}
}
@keyframes messageIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,391 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Send, Bot, User, Cpu, ChevronDown, Loader2 } from 'lucide-react'
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
import { engineService, PRESET_MODELS, ModelInfo } from '../services/EngineService'
import { MessageBubble } from '../components/MessageBubble'
import './AIChatPage.scss'
interface ChatMessage {
id: string;
role: 'user' | 'ai';
content: string;
timestamp: number;
}
// 消息数量限制,避免内存过载
const MAX_MESSAGES = 200
export default function AIChatPage() {
const [input, setInput] = useState('')
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isTyping, setIsTyping] = useState(false)
const [models, setModels] = useState<ModelInfo[]>([...PRESET_MODELS])
const [selectedModel, setSelectedModel] = useState<string | null>(null)
const [modelLoaded, setModelLoaded] = useState(false)
const [loadingModel, setLoadingModel] = useState(false)
const [isThinkingMode, setIsThinkingMode] = useState(true)
const [showModelDropdown, setShowModelDropdown] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// 流式渲染优化:使用 ref 缓存内容,使用 RAF 批量更新
const streamingContentRef = useRef('')
const streamingMessageIdRef = useRef<string | null>(null)
const rafIdRef = useRef<number | null>(null)
useEffect(() => {
checkModelsStatus()
// 初始化Llama服务延迟初始化用户进入此页面时启动
const initLlama = async () => {
try {
await window.electronAPI.llama?.init()
console.log('[AIChatPage] Llama service initialized')
} catch (e) {
console.error('[AIChatPage] Failed to initialize Llama:', e)
}
}
initLlama()
// 清理函数:组件卸载时释放所有资源
return () => {
// 取消未完成的 RAF
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
// 清理 engine service 的回调引用
engineService.clearCallbacks()
}
}, [])
// 监听页面卸载事件,确保资源释放
useEffect(() => {
const handleBeforeUnload = () => {
// 清理回调和监听器
engineService.dispose()
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [])
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowModelDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const scrollToBottom = useCallback(() => {
// 使用 virtuoso 的 scrollToIndex 方法滚动到底部
if (virtuosoRef.current && messages.length > 0) {
virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
behavior: 'smooth'
})
}
}, [messages.length])
const checkModelsStatus = async () => {
const updatedModels = await Promise.all(models.map(async (m) => {
const exists = await engineService.checkModelExists(m.path)
return { ...m, downloaded: exists }
}))
setModels(updatedModels)
// Auto-select first available model
if (!selectedModel) {
const available = updatedModels.find(m => m.downloaded)
if (available) {
setSelectedModel(available.path)
}
}
}
// 自动加载模型
const handleLoadModel = async (modelPath?: string) => {
const pathToLoad = modelPath || selectedModel
if (!pathToLoad) return false
setLoadingModel(true)
try {
await engineService.loadModel(pathToLoad)
// Initialize session with system prompt
await engineService.createSession("You are a helpful AI assistant.")
setModelLoaded(true)
return true
} catch (e) {
console.error("Load failed", e)
alert("模型加载失败: " + String(e))
return false
} finally {
setLoadingModel(false)
}
}
// 选择模型(如果有多个)
const handleSelectModel = (modelPath: string) => {
setSelectedModel(modelPath)
setShowModelDropdown(false)
}
// 获取可用的已下载模型
const availableModels = models.filter(m => m.downloaded)
const selectedModelInfo = models.find(m => m.path === selectedModel)
// 优化的流式更新函数:使用 RAF 批量更新
const updateStreamingMessage = useCallback(() => {
if (!streamingMessageIdRef.current) return
setMessages(prev => prev.map(msg =>
msg.id === streamingMessageIdRef.current
? { ...msg, content: streamingContentRef.current }
: msg
))
rafIdRef.current = null
}, [])
// Token 回调:使用 RAF 批量更新 UI
const handleToken = useCallback((token: string) => {
streamingContentRef.current += token
// 使用 requestAnimationFrame 批量更新,避免频繁渲染
if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(updateStreamingMessage)
}
}, [updateStreamingMessage])
const handleSend = async () => {
if (!input.trim() || isTyping) return
// 如果模型未加载,先自动加载
if (!modelLoaded) {
if (!selectedModel) {
alert("请先下载模型(设置页面)")
return
}
const loaded = await handleLoadModel()
if (!loaded) return
}
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: Date.now()
}
setMessages(prev => {
const newMessages = [...prev, userMsg]
// 限制消息数量,避免内存过载
return newMessages.length > MAX_MESSAGES
? newMessages.slice(-MAX_MESSAGES)
: newMessages
})
setInput('')
setIsTyping(true)
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
const aiMsgId = (Date.now() + 1).toString()
streamingContentRef.current = ''
streamingMessageIdRef.current = aiMsgId
// Optimistic update for AI message start
setMessages(prev => {
const newMessages = [...prev, {
id: aiMsgId,
role: 'ai' as const,
content: '',
timestamp: Date.now()
}]
return newMessages.length > MAX_MESSAGES
? newMessages.slice(-MAX_MESSAGES)
: newMessages
})
// Append thinking command based on mode
const msgWithSuffix = input + (isThinkingMode ? " /think" : " /no_think")
try {
await engineService.chat(msgWithSuffix, handleToken, { thinking: isThinkingMode })
} catch (e) {
console.error("Chat failed", e)
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'ai',
content: "❌ Error: Failed to get response from AI.",
timestamp: Date.now()
}])
} finally {
setIsTyping(false)
streamingMessageIdRef.current = null
// 确保最终状态同步
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
updateStreamingMessage()
}
}
}
// 渲染模型选择按钮(集成在输入框作为下拉项)
const renderModelSelector = () => {
// 没有可用模型
if (availableModels.length === 0) {
return (
<button
className="model-btn disabled"
title="请先在设置页面下载模型"
>
<Bot size={16} />
<span></span>
</button>
)
}
// 只有一个模型,直接显示
if (availableModels.length === 1) {
return (
<button
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
title={modelLoaded ? "模型已就绪" : "发送消息时自动加载"}
>
{loadingModel ? (
<Loader2 size={16} className="spin" />
) : (
<Bot size={16} />
)}
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '模型'}</span>
</button>
)
}
// 多个模型,显示下拉选择
return (
<div className="model-selector" ref={dropdownRef}>
<button
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
onClick={() => !loadingModel && setShowModelDropdown(!showModelDropdown)}
title="点击选择模型"
>
{loadingModel ? (
<Loader2 size={16} className="spin" />
) : (
<Bot size={16} />
)}
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '选择模型'}</span>
<ChevronDown size={13} className={showModelDropdown ? 'rotate' : ''} />
</button>
{showModelDropdown && (
<div className="model-dropdown">
{availableModels.map(model => (
<div
key={model.path}
className={`model-option ${selectedModel === model.path ? 'active' : ''}`}
onClick={() => handleSelectModel(model.path)}
>
<span>{model.name}</span>
{selectedModel === model.path && (
<span className="check"></span>
)}
</div>
))}
</div>
)}
</div>
)
}
return (
<div className="ai-chat-page">
<div className="chat-main">
{messages.length === 0 ? (
<div className="empty-state">
<div className="icon">
<Bot size={40} />
</div>
<h2>AI </h2>
<p>
{availableModels.length === 0
? "请先在设置页面下载模型"
: "输入消息开始对话,模型将自动加载"
}
</p>
</div>
) : (
<Virtuoso
ref={virtuosoRef}
data={messages}
className="messages-list"
initialTopMostItemIndex={messages.length - 1}
followOutput="smooth"
itemContent={(index, message) => (
<MessageBubble key={message.id} message={message} />
)}
components={{
Footer: () => <div className="list-spacer" />
}}
/>
)}
<div className="input-area">
<div className="input-wrapper">
<textarea
ref={textareaRef}
value={input}
onChange={e => {
setInput(e.target.value)
e.target.style.height = 'auto'
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`
}}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
// Reset height after send
if (textareaRef.current) textareaRef.current.style.height = 'auto'
}
}}
placeholder={availableModels.length === 0 ? "请先下载模型..." : "输入消息..."}
disabled={availableModels.length === 0 || loadingModel}
rows={1}
/>
<div className="input-actions">
{renderModelSelector()}
<button
className={`mode-toggle ${isThinkingMode ? 'active' : ''}`}
onClick={() => setIsThinkingMode(!isThinkingMode)}
title={isThinkingMode ? "深度思考模式已开启" : "深度思考模式已关闭"}
disabled={availableModels.length === 0}
>
<Cpu size={18} />
</button>
<button
className="send-btn"
onClick={handleSend}
disabled={!input.trim() || availableModels.length === 0 || isTyping || loadingModel}
>
<Send size={18} />
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -90,10 +90,6 @@ function SettingsPage() {
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
const [llamaModelStatus, setLlamaModelStatus] = useState<{ exists: boolean; path?: string; size?: number } | null>(null)
const [isLlamaDownloading, setIsLlamaDownloading] = useState(false)
const [llamaDownloadProgress, setLlamaDownloadProgress] = useState(0)
const [llamaProgressData, setLlamaProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
@@ -336,8 +332,7 @@ function SettingsPage() {
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
// Load Llama status after config
void checkLlamaModelStatus()
} catch (e: any) {
console.error('加载配置失败:', e)
}
@@ -653,7 +648,6 @@ function SettingsPage() {
setWhisperModelDir(dir)
await configService.setWhisperModelDir(dir)
showMessage('已选择 Whisper 模型目录', true)
await checkLlamaModelStatus()
}
} catch (e: any) {
showMessage('选择目录失败', false)
@@ -689,68 +683,6 @@ function SettingsPage() {
const handleResetWhisperModelDir = async () => {
setWhisperModelDir('')
await configService.setWhisperModelDir('')
await checkLlamaModelStatus()
}
const checkLlamaModelStatus = async () => {
try {
// @ts-ignore
const modelsPath = await window.electronAPI.llama?.getModelsPath()
if (!modelsPath) return
const modelName = "Qwen3-4B-Q4_K_M.gguf" // Hardcoded preset for now
const fullPath = `${modelsPath}\\${modelName}`
// @ts-ignore
const status = await window.electronAPI.llama?.getModelStatus(fullPath)
if (status) {
setLlamaModelStatus({
exists: status.exists,
path: status.path,
size: status.size
})
}
} catch (e) {
console.error("Check llama model status failed", e)
}
}
useEffect(() => {
const handleLlamaProgress = (payload: { downloaded: number; total: number; speed: number }) => {
setLlamaProgressData(payload)
if (payload.total > 0) {
setLlamaDownloadProgress((payload.downloaded / payload.total) * 100)
}
}
// @ts-ignore
const removeListener = window.electronAPI.llama?.onDownloadProgress(handleLlamaProgress)
return () => {
if (typeof removeListener === 'function') removeListener()
}
}, [])
const handleDownloadLlamaModel = async () => {
if (isLlamaDownloading) return
setIsLlamaDownloading(true)
setLlamaDownloadProgress(0)
try {
const modelUrl = "https://www.modelscope.cn/models/Qwen/Qwen3-4B-GGUF/resolve/master/Qwen3-4B-Q4_K_M.gguf"
// @ts-ignore
const modelsPath = await window.electronAPI.llama?.getModelsPath()
const modelName = "Qwen3-4B-Q4_K_M.gguf"
const fullPath = `${modelsPath}\\${modelName}`
// @ts-ignore
const result = await window.electronAPI.llama?.downloadModel(modelUrl, fullPath)
if (result?.success) {
showMessage('Qwen3 模型下载完成', true)
await checkLlamaModelStatus()
} else {
showMessage(`模型下载失败: ${result?.error || '未知错误'}`, false)
}
} catch (e: any) {
showMessage(`模型下载失败: ${e}`, false)
} finally {
setIsLlamaDownloading(false)
}
}
const handleAutoGetDbKey = async () => {
@@ -1452,7 +1384,7 @@ function SettingsPage() {
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"> AI </span>
<span className="form-hint"></span>
</div>
<div className="form-group">
@@ -1522,50 +1454,6 @@ function SettingsPage() {
</div>
</div>
<div className="form-group">
<label>AI (Llama)</label>
<span className="form-hint"> AI </span>
<div className="setting-control vertical has-border">
<div className="model-status-card">
<div className="model-info">
<div className="model-name">Qwen3 4B (Preset) (~2.6GB)</div>
<div className="model-path">
{llamaModelStatus?.exists ? (
<span className="status-indicator success"><Check size={14} /> </span>
) : (
<span className="status-indicator warning"></span>
)}
{llamaModelStatus?.path && <div className="path-text" title={llamaModelStatus.path}>{llamaModelStatus.path}</div>}
</div>
</div>
<div className="model-actions">
{!llamaModelStatus?.exists && !isLlamaDownloading && (
<button
className="btn-download"
onClick={handleDownloadLlamaModel}
>
<Download size={16} />
</button>
)}
{isLlamaDownloading && (
<div className="download-status">
<div className="status-header">
<span className="percent">{Math.floor(llamaDownloadProgress)}%</span>
<span className="metrics">
{formatBytes(llamaProgressData.downloaded)} / {formatBytes(llamaProgressData.total)}
<span className="speed">({formatBytes(llamaProgressData.speed)}/s)</span>
</span>
</div>
<div className="progress-bar-mini">
<div className="fill" style={{ width: `${llamaDownloadProgress}%` }}></div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>

View File

@@ -1,108 +0,0 @@
export interface ModelInfo {
name: string;
path: string;
downloadUrl?: string; // If it's a known preset
size?: number;
downloaded: boolean;
}
export const PRESET_MODELS: ModelInfo[] = [
{
name: "Qwen3 4B (Preset)",
path: "Qwen3-4B-Q4_K_M.gguf",
downloadUrl: "https://www.modelscope.cn/models/Qwen/Qwen3-4B-GGUF/resolve/master/Qwen3-4B-Q4_K_M.gguf",
downloaded: false
}
];
class EngineService {
private onTokenCallback: ((token: string) => void) | null = null;
private onProgressCallback: ((percent: number) => void) | null = null;
private _removeTokenListener: (() => void) | null = null;
private _removeProgressListener: (() => void) | null = null;
constructor() {
// Initialize listeners
this._removeTokenListener = window.electronAPI.llama.onToken((token: string) => {
if (this.onTokenCallback) {
this.onTokenCallback(token);
}
});
this._removeProgressListener = window.electronAPI.llama.onDownloadProgress((percent: number) => {
if (this.onProgressCallback) {
this.onProgressCallback(percent);
}
});
}
public async checkModelExists(filename: string): Promise<boolean> {
const modelsPath = await window.electronAPI.llama.getModelsPath();
const fullPath = `${modelsPath}\\${filename}`; // Windows path separator
// We might need to handle path separator properly or let main process handle it
// Updated preload to take full path or handling in main?
// Let's rely on main process exposing join or just checking relative to models dir if implemented
// Actually main process `checkFileExists` takes a path.
// Let's assume we construct path here or Main helps.
// Better: getModelsPath returns the directory.
return await window.electronAPI.llama.checkFileExists(fullPath);
}
public async getModelsPath(): Promise<string> {
return await window.electronAPI.llama.getModelsPath();
}
public async loadModel(filename: string) {
const modelsPath = await this.getModelsPath();
const fullPath = `${modelsPath}\\${filename}`;
console.log("Loading model:", fullPath);
return await window.electronAPI.llama.loadModel(fullPath);
}
public async createSession(systemPrompt?: string) {
return await window.electronAPI.llama.createSession(systemPrompt);
}
public async chat(message: string, onToken: (token: string) => void, options?: { thinking?: boolean }) {
this.onTokenCallback = onToken;
return await window.electronAPI.llama.chat(message, options);
}
public async downloadModel(url: string, filename: string, onProgress: (percent: number) => void) {
const modelsPath = await this.getModelsPath();
const fullPath = `${modelsPath}\\${filename}`;
this.onProgressCallback = onProgress;
return await window.electronAPI.llama.downloadModel(url, fullPath);
}
/**
* 清除当前的回调函数引用
* 用于避免内存泄漏
*/
public clearCallbacks() {
this.onTokenCallback = null;
this.onProgressCallback = null;
}
/**
* 释放所有资源
* 包括事件监听器和回调引用
*/
public dispose() {
// 清除回调
this.clearCallbacks();
// 移除事件监听器
if (this._removeTokenListener) {
this._removeTokenListener();
this._removeTokenListener = null;
}
if (this._removeProgressListener) {
this._removeProgressListener();
this._removeProgressListener = null;
}
}
}
export const engineService = new EngineService();

View File

@@ -505,17 +505,6 @@ export interface ElectronAPI {
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
}
llama: {
loadModel: (modelPath: string) => Promise<boolean>
createSession: (systemPrompt?: string) => Promise<boolean>
chat: (message: string) => Promise<{ success: boolean; response?: any; error?: string }>
downloadModel: (url: string, savePath: string) => Promise<void>
getModelsPath: () => Promise<string>
checkFileExists: (filePath: string) => Promise<boolean>
getModelStatus: (modelPath: string) => Promise<{ exists: boolean; path?: string; size?: number; error?: string }>
onToken: (callback: (token: string) => void) => () => void
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => () => void
}
http: {
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
stop: () => Promise<{ success: boolean }>