mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
修复实时更新偶发失效的问题;删除AI对话有关组件与依赖
This commit is contained in:
@@ -21,7 +21,7 @@ import { videoService } from './services/videoService'
|
|||||||
import { snsService, isVideoUrl } from './services/snsService'
|
import { snsService, isVideoUrl } from './services/snsService'
|
||||||
import { contactExportService } from './services/contactExportService'
|
import { contactExportService } from './services/contactExportService'
|
||||||
import { windowsHelloService } from './services/windowsHelloService'
|
import { windowsHelloService } from './services/windowsHelloService'
|
||||||
import { llamaService } from './services/llamaService'
|
|
||||||
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||||
import { httpService } from './services/httpService'
|
import { httpService } from './services/httpService'
|
||||||
|
|
||||||
@@ -825,63 +825,6 @@ function registerIpcHandlers() {
|
|||||||
return await chatService.getContact(username)
|
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) => {
|
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
|
||||||
return await chatService.getContactAvatar(username)
|
return await chatService.getContactAvatar(username)
|
||||||
|
|||||||
@@ -288,27 +288,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir')
|
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 API 服务
|
||||||
http: {
|
http: {
|
||||||
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -66,8 +66,12 @@ export class WcdbCore {
|
|||||||
private wcdbVerifyUser: any = null
|
private wcdbVerifyUser: any = null
|
||||||
private wcdbStartMonitorPipe: any = null
|
private wcdbStartMonitorPipe: any = null
|
||||||
private wcdbStopMonitorPipe: any = null
|
private wcdbStopMonitorPipe: any = null
|
||||||
|
private wcdbGetMonitorPipeName: any = null
|
||||||
|
|
||||||
private monitorPipeClient: 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()
|
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||||
@@ -92,23 +96,46 @@ export class WcdbCore {
|
|||||||
// 使用命名管道 IPC
|
// 使用命名管道 IPC
|
||||||
startMonitor(callback: (type: string, json: string) => void): boolean {
|
startMonitor(callback: (type: string, json: string) => void): boolean {
|
||||||
if (!this.wcdbStartMonitorPipe) {
|
if (!this.wcdbStartMonitorPipe) {
|
||||||
this.writeLog('startMonitor: wcdbStartMonitorPipe not available')
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.monitorCallback = callback
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = this.wcdbStartMonitorPipe()
|
const result = this.wcdbStartMonitorPipe()
|
||||||
if (result !== 0) {
|
if (result !== 0) {
|
||||||
this.writeLog(`startMonitor: wcdbStartMonitorPipe failed with ${result}`)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 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.connectMonitorPipe(pipePath)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[wcdbCore] startMonitor exception:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接命名管道,支持断开后自动重连
|
||||||
|
private connectMonitorPipe(pipePath: string) {
|
||||||
|
this.monitorPipePath = pipePath
|
||||||
const net = require('net')
|
const net = require('net')
|
||||||
const PIPE_PATH = '\\\\.\\pipe\\weflow_monitor'
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.monitorPipeClient = net.createConnection(PIPE_PATH, () => {
|
if (!this.monitorCallback) return
|
||||||
this.writeLog('Monitor pipe connected')
|
|
||||||
|
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
@@ -120,35 +147,43 @@ export class WcdbCore {
|
|||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(line)
|
const parsed = JSON.parse(line)
|
||||||
callback(parsed.action || 'update', line)
|
this.monitorCallback?.(parsed.action || 'update', line)
|
||||||
} catch {
|
} catch {
|
||||||
callback('update', line)
|
this.monitorCallback?.('update', line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.monitorPipeClient.on('error', (err: Error) => {
|
this.monitorPipeClient.on('error', () => {
|
||||||
this.writeLog(`Monitor pipe error: ${err.message}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.monitorPipeClient.on('close', () => {
|
this.monitorPipeClient.on('close', () => {
|
||||||
this.writeLog('Monitor pipe closed')
|
|
||||||
this.monitorPipeClient = null
|
this.monitorPipeClient = null
|
||||||
|
this.scheduleReconnect()
|
||||||
})
|
})
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
this.writeLog('Monitor started via named pipe IPC')
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
|
||||||
console.error('打开数据库异常:', e)
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 定时重连
|
||||||
|
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 {
|
stopMonitor(): void {
|
||||||
|
this.monitorCallback = null
|
||||||
|
if (this.monitorReconnectTimer) {
|
||||||
|
clearTimeout(this.monitorReconnectTimer)
|
||||||
|
this.monitorReconnectTimer = null
|
||||||
|
}
|
||||||
if (this.monitorPipeClient) {
|
if (this.monitorPipeClient) {
|
||||||
this.monitorPipeClient.destroy()
|
this.monitorPipeClient.destroy()
|
||||||
this.monitorPipeClient = null
|
this.monitorPipeClient = null
|
||||||
@@ -569,11 +604,13 @@ export class WcdbCore {
|
|||||||
try {
|
try {
|
||||||
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
||||||
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_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')
|
this.writeLog('Monitor pipe functions loaded')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to load monitor pipe functions:', e)
|
console.warn('Failed to load monitor pipe functions:', e)
|
||||||
this.wcdbStartMonitorPipe = null
|
this.wcdbStartMonitorPipe = null
|
||||||
this.wcdbStopMonitorPipe = null
|
this.wcdbStopMonitorPipe = null
|
||||||
|
this.wcdbGetMonitorPipeName = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
|
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ export class WcdbService {
|
|||||||
*/
|
*/
|
||||||
setMonitor(callback: (type: string, json: string) => void): void {
|
setMonitor(callback: (type: string, json: string) => void): void {
|
||||||
this.monitorListener = callback;
|
this.monitorListener = callback;
|
||||||
// Notify worker to enable monitor
|
|
||||||
this.callWorker('setMonitor').catch(() => { });
|
this.callWorker('setMonitor').catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1918
package-lock.json
generated
1918
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,6 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"node-llama-cpp": "^3.15.1",
|
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
Binary file not shown.
@@ -22,7 +22,6 @@ import SnsPage from './pages/SnsPage'
|
|||||||
import ContactsPage from './pages/ContactsPage'
|
import ContactsPage from './pages/ContactsPage'
|
||||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||||
import NotificationWindow from './pages/NotificationWindow'
|
import NotificationWindow from './pages/NotificationWindow'
|
||||||
import AIChatPage from './pages/AIChatPage'
|
|
||||||
|
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||||
@@ -457,7 +456,7 @@ function App() {
|
|||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/home" element={<HomePage />} />
|
<Route path="/home" element={<HomePage />} />
|
||||||
<Route path="/chat" element={<ChatPage />} />
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
|
||||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export function GlobalSessionMonitor() {
|
|||||||
} = useChatStore()
|
} = useChatStore()
|
||||||
|
|
||||||
const sessionsRef = useRef(sessions)
|
const sessionsRef = useRef(sessions)
|
||||||
|
|
||||||
// 保持 ref 同步
|
// 保持 ref 同步
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionsRef.current = sessions
|
sessionsRef.current = sessions
|
||||||
@@ -47,9 +46,10 @@ export function GlobalSessionMonitor() {
|
|||||||
return () => {
|
return () => {
|
||||||
removeListener()
|
removeListener()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
}
|
}
|
||||||
return () => { }
|
return () => { }
|
||||||
}, []) // 空依赖数组 - 主要是静态的
|
}, [])
|
||||||
|
|
||||||
const refreshSessions = async () => {
|
const refreshSessions = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -90,10 +90,6 @@ function SettingsPage() {
|
|||||||
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
|
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
|
||||||
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 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 [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) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
@@ -336,8 +332,7 @@ function SettingsPage() {
|
|||||||
|
|
||||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||||
|
|
||||||
// Load Llama status after config
|
|
||||||
void checkLlamaModelStatus()
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('加载配置失败:', e)
|
console.error('加载配置失败:', e)
|
||||||
}
|
}
|
||||||
@@ -653,7 +648,6 @@ function SettingsPage() {
|
|||||||
setWhisperModelDir(dir)
|
setWhisperModelDir(dir)
|
||||||
await configService.setWhisperModelDir(dir)
|
await configService.setWhisperModelDir(dir)
|
||||||
showMessage('已选择 Whisper 模型目录', true)
|
showMessage('已选择 Whisper 模型目录', true)
|
||||||
await checkLlamaModelStatus()
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showMessage('选择目录失败', false)
|
showMessage('选择目录失败', false)
|
||||||
@@ -689,68 +683,6 @@ function SettingsPage() {
|
|||||||
const handleResetWhisperModelDir = async () => {
|
const handleResetWhisperModelDir = async () => {
|
||||||
setWhisperModelDir('')
|
setWhisperModelDir('')
|
||||||
await configService.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 () => {
|
const handleAutoGetDbKey = async () => {
|
||||||
@@ -1452,7 +1384,7 @@ function SettingsPage() {
|
|||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>模型管理</label>
|
<label>模型管理</label>
|
||||||
<span className="form-hint">管理语音识别和 AI 对话模型</span>
|
<span className="form-hint">管理语音识别模型</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1522,50 +1454,6 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="form-group">
|
||||||
<label>自动转文字</label>
|
<label>自动转文字</label>
|
||||||
<span className="form-hint">收到语音消息时自动转换为文字</span>
|
<span className="form-hint">收到语音消息时自动转换为文字</span>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
11
src/types/electron.d.ts
vendored
11
src/types/electron.d.ts
vendored
@@ -505,17 +505,6 @@ export interface ElectronAPI {
|
|||||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||||
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: 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: {
|
http: {
|
||||||
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
||||||
stop: () => Promise<{ success: boolean }>
|
stop: () => Promise<{ success: boolean }>
|
||||||
|
|||||||
Reference in New Issue
Block a user