mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
测试版本,添加了模拟好友并优化了本地缓存
This commit is contained in:
306
electron/cloneEmbeddingWorker.ts
Normal file
306
electron/cloneEmbeddingWorker.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync } from 'fs'
|
||||
import * as lancedb from '@lancedb/lancedb'
|
||||
import { pipeline, env } from '@xenova/transformers'
|
||||
import { wcdbService } from './services/wcdbService'
|
||||
import { mapRowToCloneMessage, CloneMessage, CloneRole } from './services/cloneMessageUtils'
|
||||
|
||||
interface WorkerConfig {
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
embeddingModel?: string
|
||||
}
|
||||
|
||||
type WorkerRequest =
|
||||
| { id: string; type: 'index'; payload: IndexPayload }
|
||||
| { id: string; type: 'query'; payload: QueryPayload }
|
||||
|
||||
interface IndexPayload {
|
||||
sessionId: string
|
||||
dbPath: string
|
||||
decryptKey: string
|
||||
myWxid: string
|
||||
batchSize?: number
|
||||
chunkGapSeconds?: number
|
||||
maxChunkChars?: number
|
||||
maxChunkMessages?: number
|
||||
reset?: boolean
|
||||
}
|
||||
|
||||
interface QueryPayload {
|
||||
sessionId: string
|
||||
keyword: string
|
||||
topK?: number
|
||||
roleFilter?: CloneRole
|
||||
}
|
||||
|
||||
const config = workerData as WorkerConfig
|
||||
process.env.WEFLOW_WORKER = '1'
|
||||
if (config.resourcesPath) {
|
||||
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||
}
|
||||
|
||||
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||
|
||||
env.allowRemoteModels = true
|
||||
if (env.backends?.onnx) {
|
||||
env.backends.onnx.wasm.enabled = false
|
||||
}
|
||||
|
||||
const embeddingModel = config.embeddingModel || 'Xenova/bge-small-zh-v1.5'
|
||||
let embedder: any | null = null
|
||||
|
||||
async function ensureEmbedder() {
|
||||
if (embedder) return embedder
|
||||
if (config.userDataPath) {
|
||||
env.cacheDir = join(config.userDataPath, 'transformers')
|
||||
}
|
||||
embedder = await pipeline('feature-extraction', embeddingModel)
|
||||
return embedder
|
||||
}
|
||||
|
||||
function getMemoryDir(sessionId: string): string {
|
||||
const safeId = sessionId.replace(/[\\/:"*?<>|]+/g, '_')
|
||||
const base = config.userDataPath || process.cwd()
|
||||
const dir = join(base, 'clone_memory', safeId)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
async function getTable(sessionId: string, reset?: boolean) {
|
||||
const dir = getMemoryDir(sessionId)
|
||||
const db = await lancedb.connect(dir)
|
||||
const tables = await db.tableNames()
|
||||
if (reset && tables.includes('messages')) {
|
||||
await db.dropTable('messages')
|
||||
}
|
||||
const hasTable = tables.includes('messages') && !reset
|
||||
return { db, hasTable }
|
||||
}
|
||||
|
||||
function shouldSkipContent(text: string): boolean {
|
||||
if (!text) return true
|
||||
if (text === '[图片]' || text === '[语音]' || text === '[视频]' || text === '[表情]' || text === '[分享]') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function chunkMessages(
|
||||
messages: CloneMessage[],
|
||||
gapSeconds: number,
|
||||
maxChars: number,
|
||||
maxMessages: number
|
||||
) {
|
||||
const chunks: Array<{
|
||||
role: CloneRole
|
||||
content: string
|
||||
tsStart: number
|
||||
tsEnd: number
|
||||
messageCount: number
|
||||
}> = []
|
||||
let current: typeof chunks[number] | null = null
|
||||
|
||||
for (const msg of messages) {
|
||||
if (shouldSkipContent(msg.content)) continue
|
||||
if (!current) {
|
||||
current = {
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
tsStart: msg.createTime,
|
||||
tsEnd: msg.createTime,
|
||||
messageCount: 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const gap = msg.createTime - current.tsEnd
|
||||
const nextContent = `${current.content}\n${msg.content}`
|
||||
const roleChanged = msg.role !== current.role
|
||||
if (roleChanged || gap > gapSeconds || nextContent.length > maxChars || current.messageCount >= maxMessages) {
|
||||
chunks.push(current)
|
||||
current = {
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
tsStart: msg.createTime,
|
||||
tsEnd: msg.createTime,
|
||||
messageCount: 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
current.content = nextContent
|
||||
current.tsEnd = msg.createTime
|
||||
current.messageCount += 1
|
||||
}
|
||||
|
||||
if (current) {
|
||||
chunks.push(current)
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
async function embedTexts(texts: string[]) {
|
||||
const model = await ensureEmbedder()
|
||||
const output = await model(texts, { pooling: 'mean', normalize: true })
|
||||
if (Array.isArray(output)) return output
|
||||
if (output?.tolist) return output.tolist()
|
||||
return []
|
||||
}
|
||||
|
||||
async function gatherDebugInfo(table: any) {
|
||||
try {
|
||||
const rowCount = await table.countRows()
|
||||
const sample = await table.limit(3).toArray()
|
||||
return { rowCount, sample }
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIndex(requestId: string, payload: IndexPayload) {
|
||||
const {
|
||||
sessionId,
|
||||
dbPath,
|
||||
decryptKey,
|
||||
myWxid,
|
||||
batchSize = 200,
|
||||
chunkGapSeconds = 600,
|
||||
maxChunkChars = 400,
|
||||
maxChunkMessages = 20,
|
||||
reset = false
|
||||
} = payload
|
||||
|
||||
const openOk = await wcdbService.open(dbPath, decryptKey, myWxid)
|
||||
if (!openOk) {
|
||||
throw new Error('WCDB open failed')
|
||||
}
|
||||
|
||||
const cursorResult = await wcdbService.openMessageCursorLite(sessionId, batchSize, true, 0, 0)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
throw new Error(cursorResult.error || 'cursor open failed')
|
||||
}
|
||||
|
||||
const { db, hasTable } = await getTable(sessionId, reset)
|
||||
let table = hasTable ? await db.openTable('messages') : null
|
||||
let cursor = cursorResult.cursor
|
||||
let hasMore = true
|
||||
let chunkId = 0
|
||||
let totalMessages = 0
|
||||
let totalChunks = 0
|
||||
|
||||
try {
|
||||
while (hasMore) {
|
||||
const batchResult = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batchResult.success || !batchResult.rows) {
|
||||
throw new Error(batchResult.error || 'fetch batch failed')
|
||||
}
|
||||
|
||||
totalMessages += batchResult.rows.length
|
||||
const messages: CloneMessage[] = []
|
||||
for (const row of batchResult.rows) {
|
||||
const msg = mapRowToCloneMessage(row, myWxid)
|
||||
if (msg) messages.push(msg)
|
||||
}
|
||||
|
||||
const chunks = chunkMessages(messages, chunkGapSeconds, maxChunkChars, maxChunkMessages)
|
||||
if (chunks.length > 0) {
|
||||
const embeddings = await embedTexts(chunks.map((c) => c.content))
|
||||
if (embeddings.length !== chunks.length) {
|
||||
throw new Error('embedding size mismatch')
|
||||
}
|
||||
const rows = chunks.map((chunk, idx) => ({
|
||||
id: `${sessionId}-${chunkId + idx}`,
|
||||
sessionId,
|
||||
role: chunk.role,
|
||||
content: chunk.content,
|
||||
tsStart: chunk.tsStart,
|
||||
tsEnd: chunk.tsEnd,
|
||||
messageCount: chunk.messageCount,
|
||||
embedding: new Float32Array(embeddings[idx] || [])
|
||||
}))
|
||||
if (!table) {
|
||||
table = await db.createTable('messages', rows)
|
||||
} else {
|
||||
await table.add(rows)
|
||||
}
|
||||
chunkId += chunks.length
|
||||
totalChunks += chunks.length
|
||||
}
|
||||
|
||||
hasMore = batchResult.hasMore === true
|
||||
parentPort?.postMessage({
|
||||
type: 'event',
|
||||
event: 'clone:indexProgress',
|
||||
data: { requestId, totalMessages, totalChunks, hasMore }
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
wcdbService.close()
|
||||
}
|
||||
|
||||
const debug = await gatherDebugInfo(table)
|
||||
return { success: true, totalMessages, totalChunks, debug }
|
||||
}
|
||||
|
||||
async function handleQuery(payload: QueryPayload) {
|
||||
const { sessionId, keyword, topK = 5, roleFilter } = payload
|
||||
const { db, hasTable } = await getTable(sessionId, false)
|
||||
if (!hasTable) {
|
||||
return { success: false, error: 'memory table not found' }
|
||||
}
|
||||
const table = await db.openTable('messages')
|
||||
const embeddings = await embedTexts([keyword])
|
||||
if (!embeddings.length || !embeddings[0]) {
|
||||
return { success: false, error: 'embedding failed' }
|
||||
}
|
||||
const query = table.search(new Float32Array(embeddings[0] || [])).limit(topK)
|
||||
const filtered = roleFilter ? query.where(`role = '${roleFilter}'`) : query
|
||||
let rows = await filtered.toArray()
|
||||
let usedFallback = false
|
||||
|
||||
if (rows.length === 0) {
|
||||
try {
|
||||
usedFallback = true
|
||||
const lowerKeyword = keyword.trim().toLowerCase()
|
||||
const all = await table.toArray()
|
||||
rows = all.filter((row) => {
|
||||
const content = String(row.content || '').toLowerCase()
|
||||
return content.includes(lowerKeyword)
|
||||
}).slice(0, topK)
|
||||
} catch {
|
||||
// fallback remain empty
|
||||
}
|
||||
}
|
||||
|
||||
const debug = {
|
||||
rowsFound: rows.length,
|
||||
usedFallback,
|
||||
sample: rows.slice(0, 2)
|
||||
}
|
||||
|
||||
return { success: true, results: rows, debug }
|
||||
}
|
||||
|
||||
parentPort?.on('message', async (request: WorkerRequest) => {
|
||||
try {
|
||||
if (request.type === 'index') {
|
||||
const data = await handleIndex(request.id, request.payload)
|
||||
parentPort?.postMessage({ type: 'response', id: request.id, ok: true, data })
|
||||
return
|
||||
}
|
||||
if (request.type === 'query') {
|
||||
const data = await handleQuery(request.payload)
|
||||
parentPort?.postMessage({ type: 'response', id: request.id, ok: true, data })
|
||||
return
|
||||
}
|
||||
parentPort?.postMessage({ type: 'response', id: request.id, ok: false, error: 'unknown request' })
|
||||
} catch (err) {
|
||||
parentPort?.postMessage({ type: 'response', id: request.id, ok: false, error: String(err) })
|
||||
}
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||
import { annualReportService } from './services/annualReportService'
|
||||
import { exportService, ExportOptions } from './services/exportService'
|
||||
import { KeyService } from './services/keyService'
|
||||
import { cloneService } from './services/cloneService'
|
||||
|
||||
// 配置自动更新
|
||||
autoUpdater.autoDownload = false
|
||||
@@ -410,6 +411,10 @@ function registerIpcHandlers() {
|
||||
return chatService.getContactAvatar(username)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
|
||||
return chatService.getCachedSessionMessages(sessionId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMyAvatarUrl', async () => {
|
||||
return chatService.getMyAvatarUrl()
|
||||
})
|
||||
@@ -439,6 +444,29 @@ function registerIpcHandlers() {
|
||||
return chatService.getMessageById(sessionId, localId)
|
||||
})
|
||||
|
||||
// 私聊克隆
|
||||
ipcMain.handle('clone:indexSession', async (_, sessionId: string, options?: any) => {
|
||||
return await cloneService.indexSession(sessionId, options, (payload) => {
|
||||
mainWindow?.webContents.send('clone:indexProgress', payload)
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('clone:query', async (_, payload: { sessionId: string; keyword: string; options?: any }) => {
|
||||
return await cloneService.queryMemory(payload.sessionId, payload.keyword, payload.options || {})
|
||||
})
|
||||
|
||||
ipcMain.handle('clone:getToneGuide', async (_, sessionId: string) => {
|
||||
return await cloneService.getToneGuide(sessionId)
|
||||
})
|
||||
|
||||
ipcMain.handle('clone:generateToneGuide', async (_, sessionId: string, sampleSize?: number) => {
|
||||
return await cloneService.generateToneGuide(sessionId, sampleSize || 500)
|
||||
})
|
||||
|
||||
ipcMain.handle('clone:chat', async (_, payload: { sessionId: string; message: string; topK?: number }) => {
|
||||
return await cloneService.chat(payload)
|
||||
})
|
||||
|
||||
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
|
||||
return imageDecryptService.decryptImage(payload)
|
||||
})
|
||||
@@ -675,9 +703,11 @@ function checkForUpdatesOnStartup() {
|
||||
|
||||
app.whenReady().then(() => {
|
||||
configService = new ConfigService()
|
||||
const resourcesPath = app.isPackaged
|
||||
const candidateResources = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
const fallbackResources = join(process.cwd(), 'resources')
|
||||
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
|
||||
const userDataPath = app.getPath('userData')
|
||||
wcdbService.setPaths(resourcesPath, userDataPath)
|
||||
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
||||
|
||||
@@ -101,12 +101,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||
close: () => ipcRenderer.invoke('chat:close'),
|
||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||
getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId)
|
||||
},
|
||||
|
||||
// 私聊克隆
|
||||
clone: {
|
||||
indexSession: (sessionId: string, options?: any) => ipcRenderer.invoke('clone:indexSession', sessionId, options),
|
||||
query: (payload: { sessionId: string; keyword: string; options?: any }) => ipcRenderer.invoke('clone:query', payload),
|
||||
getToneGuide: (sessionId: string) => ipcRenderer.invoke('clone:getToneGuide', sessionId),
|
||||
generateToneGuide: (sessionId: string, sampleSize?: number) =>
|
||||
ipcRenderer.invoke('clone:generateToneGuide', sessionId, sampleSize),
|
||||
chat: (payload: { sessionId: string; message: string; topK?: number }) => ipcRenderer.invoke('clone:chat', payload),
|
||||
onIndexProgress: (callback: (payload: { requestId: string; totalMessages: number; totalChunks: number; hasMore: boolean }) => void) => {
|
||||
ipcRenderer.on('clone:indexProgress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('clone:indexProgress')
|
||||
}
|
||||
},
|
||||
|
||||
// 图片解密
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||
|
||||
@@ -14,6 +14,8 @@ import { app } from 'electron'
|
||||
const execFileAsync = promisify(execFile)
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { MessageCacheService } from './messageCacheService'
|
||||
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
|
||||
|
||||
type HardlinkState = {
|
||||
db: Database.Database
|
||||
@@ -74,13 +76,19 @@ class ChatService {
|
||||
private connected = false
|
||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number }> = new Map()
|
||||
private readonly messageBatchDefault = 50
|
||||
private avatarCache: Map<string, { avatarUrl?: string; displayName?: string; updatedAt: number }> = new Map()
|
||||
private avatarCache: Map<string, ContactCacheEntry>
|
||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||
private hardlinkCache = new Map<string, HardlinkState>()
|
||||
private readonly contactCacheService: ContactCacheService
|
||||
private readonly messageCacheService: MessageCacheService
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
this.contactCacheService = new ContactCacheService(this.configService.get('cachePath'))
|
||||
const persisted = this.contactCacheService.getAllEntries()
|
||||
this.avatarCache = new Map(Object.entries(persisted))
|
||||
this.messageCacheService = new MessageCacheService(this.configService.get('cachePath'))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,7 +239,7 @@ class ChatService {
|
||||
let displayName = username
|
||||
let avatarUrl: string | undefined = undefined
|
||||
const cached = this.avatarCache.get(username)
|
||||
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
|
||||
if (cached) {
|
||||
displayName = cached.displayName || username
|
||||
avatarUrl = cached.avatarUrl
|
||||
}
|
||||
@@ -279,6 +287,7 @@ class ChatService {
|
||||
const now = Date.now()
|
||||
const missing: string[] = []
|
||||
const result: Record<string, { displayName?: string; avatarUrl?: string }> = {}
|
||||
const updatedEntries: Record<string, ContactCacheEntry> = {}
|
||||
|
||||
// 检查缓存
|
||||
for (const username of usernames) {
|
||||
@@ -304,17 +313,20 @@ class ChatService {
|
||||
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
|
||||
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
|
||||
|
||||
result[username] = { displayName, avatarUrl }
|
||||
|
||||
// 更新缓存
|
||||
this.avatarCache.set(username, {
|
||||
const cacheEntry: ContactCacheEntry = {
|
||||
displayName: displayName || username,
|
||||
avatarUrl,
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
result[username] = { displayName, avatarUrl }
|
||||
// 更新缓存并记录持久化
|
||||
this.avatarCache.set(username, cacheEntry)
|
||||
updatedEntries[username] = cacheEntry
|
||||
}
|
||||
if (Object.keys(updatedEntries).length > 0) {
|
||||
this.contactCacheService.setEntries(updatedEntries)
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, contacts: result }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 补充联系人信息失败:', e)
|
||||
@@ -456,6 +468,7 @@ class ChatService {
|
||||
}
|
||||
|
||||
state.fetched += rows.length
|
||||
this.messageCacheService.set(sessionId, normalized)
|
||||
return { success: true, messages: normalized, hasMore }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取消息失败:', e)
|
||||
@@ -463,6 +476,20 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
async getCachedSessionMessages(sessionId: string): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
|
||||
try {
|
||||
if (!sessionId) return { success: true, messages: [] }
|
||||
const entry = this.messageCacheService.get(sessionId)
|
||||
if (!entry || !Array.isArray(entry.messages)) {
|
||||
return { success: true, messages: [] }
|
||||
}
|
||||
return { success: true, messages: entry.messages.slice() }
|
||||
} catch (error) {
|
||||
console.error('ChatService: 获取缓存消息失败:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从 emoticon.db / emotion.db 恢复表情包 CDN URL
|
||||
*/
|
||||
@@ -1610,7 +1637,13 @@ class ChatService {
|
||||
const avatarResult = await wcdbService.getAvatarUrls([username])
|
||||
const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
|
||||
const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username
|
||||
this.avatarCache.set(username, { avatarUrl, displayName, updatedAt: Date.now() })
|
||||
const cacheEntry: ContactCacheEntry = {
|
||||
avatarUrl,
|
||||
displayName,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
this.avatarCache.set(username, cacheEntry)
|
||||
this.contactCacheService.setEntries({ [username]: cacheEntry })
|
||||
return { avatarUrl, displayName }
|
||||
} catch {
|
||||
return null
|
||||
|
||||
123
electron/services/cloneMessageUtils.ts
Normal file
123
electron/services/cloneMessageUtils.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
export type CloneRole = 'target' | 'me'
|
||||
|
||||
export interface CloneMessage {
|
||||
role: CloneRole
|
||||
content: string
|
||||
createTime: number
|
||||
}
|
||||
|
||||
const CONTENT_FIELDS = [
|
||||
'message_content',
|
||||
'messageContent',
|
||||
'content',
|
||||
'msg_content',
|
||||
'msgContent',
|
||||
'WCDB_CT_message_content',
|
||||
'WCDB_CT_messageContent'
|
||||
]
|
||||
const COMPRESS_FIELDS = [
|
||||
'compress_content',
|
||||
'compressContent',
|
||||
'compressed_content',
|
||||
'WCDB_CT_compress_content',
|
||||
'WCDB_CT_compressContent'
|
||||
]
|
||||
const LOCAL_TYPE_FIELDS = ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type']
|
||||
const IS_SEND_FIELDS = ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']
|
||||
const SENDER_FIELDS = ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']
|
||||
const CREATE_TIME_FIELDS = [
|
||||
'create_time',
|
||||
'createTime',
|
||||
'createtime',
|
||||
'msg_create_time',
|
||||
'msgCreateTime',
|
||||
'msg_time',
|
||||
'msgTime',
|
||||
'time',
|
||||
'WCDB_CT_create_time'
|
||||
]
|
||||
|
||||
const TYPE_LABELS: Record<number, string> = {
|
||||
1: '',
|
||||
3: '[图片]',
|
||||
34: '[语音]',
|
||||
43: '[视频]',
|
||||
47: '[表情]',
|
||||
49: '[分享]',
|
||||
62: '[小视频]',
|
||||
10000: '[系统消息]'
|
||||
}
|
||||
|
||||
export function mapRowToCloneMessage(
|
||||
row: Record<string, any>,
|
||||
myWxid?: string | null
|
||||
): CloneMessage | null {
|
||||
const content = decodeMessageContent(getRowField(row, CONTENT_FIELDS), getRowField(row, COMPRESS_FIELDS))
|
||||
const localType = getRowInt(row, LOCAL_TYPE_FIELDS, 1)
|
||||
const createTime = getRowInt(row, CREATE_TIME_FIELDS, 0)
|
||||
const senderUsername = getRowField(row, SENDER_FIELDS)
|
||||
const isSendRaw = getRowField(row, IS_SEND_FIELDS)
|
||||
let isSend = isSendRaw === null ? null : parseInt(String(isSendRaw), 10)
|
||||
|
||||
if (senderUsername && myWxid) {
|
||||
const senderLower = String(senderUsername).toLowerCase()
|
||||
const myLower = myWxid.toLowerCase()
|
||||
if (isSend === null) {
|
||||
isSend = senderLower === myLower ? 1 : 0
|
||||
}
|
||||
}
|
||||
|
||||
const parsedContent = parseMessageContent(content, localType)
|
||||
if (!parsedContent) return null
|
||||
|
||||
const role: CloneRole = isSend === 1 ? 'me' : 'target'
|
||||
return { role, content: parsedContent, createTime }
|
||||
}
|
||||
|
||||
export function parseMessageContent(content: string, localType: number): string {
|
||||
if (!content) {
|
||||
return TYPE_LABELS[localType] || ''
|
||||
}
|
||||
if (Buffer.isBuffer(content as unknown)) {
|
||||
content = (content as unknown as Buffer).toString('utf-8')
|
||||
}
|
||||
if (localType === 1) {
|
||||
return stripSenderPrefix(content)
|
||||
}
|
||||
return TYPE_LABELS[localType] || content
|
||||
}
|
||||
|
||||
function stripSenderPrefix(content: string): string {
|
||||
const trimmed = content.trim()
|
||||
const separatorIdx = trimmed.indexOf(':\n')
|
||||
if (separatorIdx > 0 && separatorIdx < 64) {
|
||||
return trimmed.slice(separatorIdx + 2).trim()
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function decodeMessageContent(raw: unknown, compressed: unknown): string {
|
||||
const source = raw ?? compressed
|
||||
if (source == null) return ''
|
||||
if (typeof source === 'string') return source
|
||||
if (Buffer.isBuffer(source)) return source.toString('utf-8')
|
||||
try {
|
||||
return String(source)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getRowField(row: Record<string, any>, keys: string[]): any {
|
||||
for (const key of keys) {
|
||||
if (row[key] !== undefined && row[key] !== null) return row[key]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getRowInt(row: Record<string, any>, keys: string[], fallback: number): number {
|
||||
const raw = getRowField(row, keys)
|
||||
if (raw === null || raw === undefined) return fallback
|
||||
const parsed = parseInt(String(raw), 10)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
356
electron/services/cloneService.ts
Normal file
356
electron/services/cloneService.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { Worker } from 'worker_threads'
|
||||
import { join } from 'path'
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService } from './chatService'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { mapRowToCloneMessage, CloneMessage, CloneRole } from './cloneMessageUtils'
|
||||
|
||||
interface IndexOptions {
|
||||
reset?: boolean
|
||||
batchSize?: number
|
||||
chunkGapSeconds?: number
|
||||
maxChunkChars?: number
|
||||
maxChunkMessages?: number
|
||||
}
|
||||
|
||||
interface QueryOptions {
|
||||
topK?: number
|
||||
roleFilter?: CloneRole
|
||||
}
|
||||
|
||||
interface ToneGuide {
|
||||
sessionId: string
|
||||
createdAt: string
|
||||
model: string
|
||||
sampleSize: number
|
||||
summary: string
|
||||
details?: Record<string, any>
|
||||
}
|
||||
|
||||
interface ChatRequest {
|
||||
sessionId: string
|
||||
message: string
|
||||
topK?: number
|
||||
}
|
||||
|
||||
type WorkerRequest =
|
||||
| { id: string; type: 'index'; payload: any }
|
||||
| { id: string; type: 'query'; payload: any }
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: any) => void
|
||||
reject: (err: any) => void
|
||||
onProgress?: (payload: any) => void
|
||||
}
|
||||
|
||||
class CloneService {
|
||||
private configService = new ConfigService()
|
||||
private worker: Worker | null = null
|
||||
private pending: Map<string, PendingRequest> = new Map()
|
||||
private requestId = 0
|
||||
|
||||
private resolveResourcesPath(): string {
|
||||
const candidate = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
if (existsSync(candidate)) return candidate
|
||||
const fallback = join(process.cwd(), 'resources')
|
||||
if (existsSync(fallback)) return fallback
|
||||
return candidate
|
||||
}
|
||||
|
||||
private getBaseStoragePath(): string {
|
||||
const cachePath = this.configService.get('cachePath')
|
||||
if (cachePath && cachePath.length > 0) {
|
||||
return cachePath
|
||||
}
|
||||
const documents = app.getPath('documents')
|
||||
const defaultDir = join(documents, 'WeFlow')
|
||||
if (!existsSync(defaultDir)) {
|
||||
mkdirSync(defaultDir, { recursive: true })
|
||||
}
|
||||
return defaultDir
|
||||
}
|
||||
|
||||
private getSessionDir(sessionId: string): string {
|
||||
const safeId = sessionId.replace(/[\\/:"*?<>|]+/g, '_')
|
||||
const base = this.getBaseStoragePath()
|
||||
const dir = join(base, 'clone_memory', safeId)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
private getToneGuidePath(sessionId: string): string {
|
||||
return join(this.getSessionDir(sessionId), 'tone_guide.json')
|
||||
}
|
||||
|
||||
private ensureWorker(): Worker {
|
||||
if (this.worker) return this.worker
|
||||
const workerPath = join(__dirname, 'cloneEmbeddingWorker.js')
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
resourcesPath: this.resolveResourcesPath(),
|
||||
userDataPath: this.getBaseStoragePath(),
|
||||
logEnabled: this.configService.get('logEnabled'),
|
||||
embeddingModel: 'Xenova/bge-small-zh-v1.5'
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg?.type === 'event' && msg.event === 'clone:indexProgress') {
|
||||
const entry = this.pending.get(msg.data?.requestId)
|
||||
if (entry?.onProgress) entry.onProgress(msg.data)
|
||||
return
|
||||
}
|
||||
if (msg?.type === 'response' && msg.id) {
|
||||
const entry = this.pending.get(msg.id)
|
||||
if (!entry) return
|
||||
this.pending.delete(msg.id)
|
||||
if (msg.ok) {
|
||||
entry.resolve(msg.data)
|
||||
} else {
|
||||
entry.reject(new Error(msg.error || 'worker error'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('exit', () => {
|
||||
this.worker = null
|
||||
this.pending.clear()
|
||||
})
|
||||
|
||||
this.worker = worker
|
||||
return worker
|
||||
}
|
||||
|
||||
private callWorker(type: WorkerRequest['type'], payload: any, onProgress?: (payload: any) => void) {
|
||||
const worker = this.ensureWorker()
|
||||
const id = String(++this.requestId)
|
||||
const request: WorkerRequest = { id, type, payload }
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject, onProgress })
|
||||
worker.postMessage(request)
|
||||
})
|
||||
}
|
||||
|
||||
private validatePrivateSession(sessionId: string): { ok: boolean; error?: string } {
|
||||
if (!sessionId) return { ok: false, error: 'sessionId 不能为空' }
|
||||
if (sessionId.includes('@chatroom')) {
|
||||
return { ok: false, error: '当前仅支持私聊' }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async indexSession(sessionId: string, options: IndexOptions = {}, onProgress?: (payload: any) => void) {
|
||||
const check = this.validatePrivateSession(sessionId)
|
||||
if (!check.ok) return { success: false, error: check.error }
|
||||
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const decryptKey = this.configService.get('decryptKey')
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
if (!dbPath || !decryptKey || !myWxid) {
|
||||
return { success: false, error: '数据库配置不完整' }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.callWorker(
|
||||
'index',
|
||||
{ sessionId, dbPath, decryptKey, myWxid, ...options },
|
||||
onProgress
|
||||
)
|
||||
return result
|
||||
} catch (err) {
|
||||
return { success: false, error: String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
async queryMemory(sessionId: string, keyword: string, options: QueryOptions = {}) {
|
||||
const check = this.validatePrivateSession(sessionId)
|
||||
if (!check.ok) return { success: false, error: check.error }
|
||||
if (!keyword) return { success: false, error: 'keyword 不能为空' }
|
||||
try {
|
||||
const result = await this.callWorker('query', { sessionId, keyword, ...options })
|
||||
return result
|
||||
} catch (err) {
|
||||
return { success: false, error: String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
async getToneGuide(sessionId: string) {
|
||||
const check = this.validatePrivateSession(sessionId)
|
||||
if (!check.ok) return { success: false, error: check.error }
|
||||
const filePath = this.getToneGuidePath(sessionId)
|
||||
if (!existsSync(filePath)) {
|
||||
return { success: false, error: '未找到性格说明书' }
|
||||
}
|
||||
const raw = await readFile(filePath, 'utf8')
|
||||
return { success: true, data: JSON.parse(raw) as ToneGuide }
|
||||
}
|
||||
|
||||
async generateToneGuide(sessionId: string, sampleSize = 500) {
|
||||
const check = this.validatePrivateSession(sessionId)
|
||||
if (!check.ok) return { success: false, error: check.error }
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
if (!myWxid) return { success: false, error: '缺少 myWxid 配置' }
|
||||
|
||||
const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 300, true, 0, 0)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '创建游标失败' }
|
||||
}
|
||||
|
||||
const samples: CloneMessage[] = []
|
||||
let seen = 0
|
||||
let hasMore = true
|
||||
let cursor = cursorResult.cursor
|
||||
|
||||
while (hasMore) {
|
||||
const batchResult = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batchResult.success || !batchResult.rows) {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
return { success: false, error: batchResult.error || '读取消息失败' }
|
||||
}
|
||||
|
||||
for (const row of batchResult.rows) {
|
||||
const msg = mapRowToCloneMessage(row, myWxid)
|
||||
if (!msg || msg.role !== 'target') continue
|
||||
seen += 1
|
||||
if (samples.length < sampleSize) {
|
||||
samples.push(msg)
|
||||
} else {
|
||||
const idx = Math.floor(Math.random() * seen)
|
||||
if (idx < sampleSize) samples[idx] = msg
|
||||
}
|
||||
}
|
||||
|
||||
hasMore = batchResult.hasMore === true
|
||||
}
|
||||
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
|
||||
if (samples.length === 0) {
|
||||
return { success: false, error: '样本为空,无法生成说明书' }
|
||||
}
|
||||
|
||||
const toneResult = await this.runToneGuideLlm(sessionId, samples)
|
||||
if (!toneResult.success) return toneResult
|
||||
|
||||
const filePath = this.getToneGuidePath(sessionId)
|
||||
await writeFile(filePath, JSON.stringify(toneResult.data, null, 2), 'utf8')
|
||||
return toneResult
|
||||
}
|
||||
|
||||
async chat(request: ChatRequest) {
|
||||
const { sessionId, message, topK = 5 } = request
|
||||
const check = this.validatePrivateSession(sessionId)
|
||||
if (!check.ok) return { success: false, error: check.error }
|
||||
if (!message) return { success: false, error: '消息不能为空' }
|
||||
|
||||
const toneGuide = await this.getToneGuide(sessionId)
|
||||
const toneText = toneGuide.success ? JSON.stringify(toneGuide.data) : '未找到说明书'
|
||||
|
||||
const toolPrompt = [
|
||||
'你是一个微信好友的私聊分身,只能基于已知事实回答。',
|
||||
'如果需要查询过去的对话事实,请用工具。',
|
||||
'请严格输出 JSON,不要输出多余文本。',
|
||||
'当需要工具时输出:{"tool":"query_chat_history","parameters":{"keyword":"关键词"}}',
|
||||
'当无需工具时输出:{"tool":"none","response":"直接回复"}',
|
||||
`性格说明书: ${toneText}`,
|
||||
`用户: ${message}`
|
||||
].join('\n')
|
||||
|
||||
const decision = await this.runLlm(toolPrompt)
|
||||
const parsed = parseToolJson(decision)
|
||||
if (!parsed || parsed.tool === 'none') {
|
||||
return { success: true, response: parsed?.response || decision }
|
||||
}
|
||||
|
||||
if (parsed.tool === 'query_chat_history') {
|
||||
const keyword = parsed.parameters?.keyword
|
||||
if (!keyword) return { success: true, response: decision }
|
||||
const memory = await this.queryMemory(sessionId, keyword, { topK, roleFilter: 'target' })
|
||||
const finalPrompt = [
|
||||
'你是一个微信好友的私聊分身,请根据工具返回的历史记录回答。',
|
||||
`性格说明书: ${toneText}`,
|
||||
`用户: ${message}`,
|
||||
`工具结果: ${JSON.stringify(memory)}`,
|
||||
'请直接回复用户,不要提及工具调用。'
|
||||
].join('\n')
|
||||
const finalAnswer = await this.runLlm(finalPrompt)
|
||||
return { success: true, response: finalAnswer }
|
||||
}
|
||||
|
||||
if (parsed.tool === 'get_tone_guide') {
|
||||
const finalPrompt = [
|
||||
'你是一个微信好友的私聊分身。',
|
||||
`性格说明书: ${toneText}`,
|
||||
`用户: ${message}`,
|
||||
'请直接回复用户。'
|
||||
].join('\n')
|
||||
const finalAnswer = await this.runLlm(finalPrompt)
|
||||
return { success: true, response: finalAnswer }
|
||||
}
|
||||
|
||||
return { success: true, response: decision }
|
||||
}
|
||||
|
||||
private async runToneGuideLlm(sessionId: string, samples: CloneMessage[]) {
|
||||
const prompt = [
|
||||
'你是对话风格分析助手,请根据聊天样本总结性格说明书。',
|
||||
'输出 JSON:{"summary":"一句话概括","details":{"口癖":[],"情绪价值":"","回复速度":"","表情偏好":"","风格要点":[]}}',
|
||||
'以下是聊天样本(仅该好友的发言):',
|
||||
samples.map((msg) => msg.content).join('\n')
|
||||
].join('\n')
|
||||
|
||||
const response = await this.runLlm(prompt)
|
||||
const parsed = parseToolJson(response)
|
||||
const toneGuide: ToneGuide = {
|
||||
sessionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
model: this.configService.get('llmModelPath') || 'node-llama-cpp',
|
||||
sampleSize: samples.length,
|
||||
summary: parsed?.summary || response,
|
||||
details: parsed?.details || parsed?.data
|
||||
}
|
||||
return { success: true, data: toneGuide }
|
||||
}
|
||||
|
||||
private async runLlm(prompt: string): Promise<string> {
|
||||
const modelPath = this.configService.get('llmModelPath')
|
||||
if (!modelPath) {
|
||||
return 'LLM 未配置,请设置 llmModelPath'
|
||||
}
|
||||
|
||||
const llama = await import('node-llama-cpp').catch(() => null)
|
||||
if (!llama) {
|
||||
return 'node-llama-cpp 未安装'
|
||||
}
|
||||
|
||||
const { LlamaModel, LlamaContext, LlamaChatSession } = llama as any
|
||||
const model = new LlamaModel({ modelPath })
|
||||
const context = new LlamaContext({ model })
|
||||
const session = new LlamaChatSession({ context })
|
||||
const result = await session.prompt(prompt)
|
||||
return typeof result === 'string' ? result : String(result)
|
||||
}
|
||||
}
|
||||
|
||||
function parseToolJson(raw: string): any | null {
|
||||
if (!raw) return null
|
||||
const trimmed = raw.trim()
|
||||
const start = trimmed.indexOf('{')
|
||||
const end = trimmed.lastIndexOf('}')
|
||||
if (start === -1 || end === -1 || end <= start) return null
|
||||
try {
|
||||
return JSON.parse(trimmed.slice(start, end + 1))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const cloneService = new CloneService()
|
||||
@@ -19,6 +19,7 @@ interface ConfigSchema {
|
||||
themeId: string
|
||||
language: string
|
||||
logEnabled: boolean
|
||||
llmModelPath: string
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
@@ -40,7 +41,8 @@ export class ConfigService {
|
||||
theme: 'system',
|
||||
themeId: 'cloud-dancer',
|
||||
language: 'zh-CN',
|
||||
logEnabled: false
|
||||
logEnabled: false,
|
||||
llmModelPath: ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
75
electron/services/contactCacheService.ts
Normal file
75
electron/services/contactCacheService.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
|
||||
export interface ContactCacheEntry {
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export class ContactCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private cache: Record<string, ContactCacheEntry> = {}
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: join(app.getPath('userData'), 'WeFlowCache')
|
||||
this.cacheFilePath = join(basePath, 'contacts.json')
|
||||
this.ensureCacheDir()
|
||||
this.loadCache()
|
||||
}
|
||||
|
||||
private ensureCacheDir() {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private loadCache() {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.cache = parsed
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ContactCacheService: 载入缓存失败', error)
|
||||
this.cache = {}
|
||||
}
|
||||
}
|
||||
|
||||
get(username: string): ContactCacheEntry | undefined {
|
||||
return this.cache[username]
|
||||
}
|
||||
|
||||
getAllEntries(): Record<string, ContactCacheEntry> {
|
||||
return { ...this.cache }
|
||||
}
|
||||
|
||||
setEntries(entries: Record<string, ContactCacheEntry>): void {
|
||||
if (Object.keys(entries).length === 0) return
|
||||
let changed = false
|
||||
for (const [username, entry] of Object.entries(entries)) {
|
||||
const existing = this.cache[username]
|
||||
if (!existing || entry.updatedAt >= existing.updatedAt) {
|
||||
this.cache[username] = entry
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('ContactCacheService: 保存缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
electron/services/messageCacheService.ts
Normal file
68
electron/services/messageCacheService.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
|
||||
export interface SessionMessageCacheEntry {
|
||||
updatedAt: number
|
||||
messages: any[]
|
||||
}
|
||||
|
||||
export class MessageCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private cache: Record<string, SessionMessageCacheEntry> = {}
|
||||
private readonly sessionLimit = 150
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: join(app.getPath('userData'), 'WeFlowCache')
|
||||
this.cacheFilePath = join(basePath, 'session-messages.json')
|
||||
this.ensureCacheDir()
|
||||
this.loadCache()
|
||||
}
|
||||
|
||||
private ensureCacheDir() {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private loadCache() {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.cache = parsed
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MessageCacheService: 载入缓存失败', error)
|
||||
this.cache = {}
|
||||
}
|
||||
}
|
||||
|
||||
get(sessionId: string): SessionMessageCacheEntry | undefined {
|
||||
return this.cache[sessionId]
|
||||
}
|
||||
|
||||
set(sessionId: string, messages: any[]): void {
|
||||
if (!sessionId) return
|
||||
const trimmed = messages.length > this.sessionLimit
|
||||
? messages.slice(-this.sessionLimit)
|
||||
: messages.slice()
|
||||
this.cache[sessionId] = {
|
||||
updatedAt: Date.now(),
|
||||
messages: trimmed
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('MessageCacheService: 保存缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
2725
package-lock.json
generated
2725
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -5,14 +5,18 @@
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "cc",
|
||||
"scripts": {
|
||||
"postinstall": "electron-rebuild -f -w @lancedb/lancedb,node-llama-cpp,onnxruntime-node",
|
||||
"rebuild": "electron-rebuild -f -w @lancedb/lancedb,node-llama-cpp,onnxruntime-node",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"build": "vue-tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "vite --mode electron",
|
||||
"electron:build": "npm run build",
|
||||
"postinstall": "electron-rebuild"
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lancedb/lancedb": "^0.23.1-beta.1",
|
||||
"@lancedb/lancedb-win32-x64-msvc": "^0.22.3",
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"echarts": "^5.5.1",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
@@ -24,6 +28,8 @@
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"node-llama-cpp": "^3.1.0",
|
||||
"onnxruntime-node": "^1.16.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-router-dom": "^7.1.1",
|
||||
@@ -47,6 +53,11 @@
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.WeFlow.app",
|
||||
"asarUnpack": [
|
||||
"**/node_modules/node-llama-cpp/**/*",
|
||||
"**/node_modules/@lancedb/lancedb/**/*",
|
||||
"**/node_modules/onnxruntime-node/**/*"
|
||||
],
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"releaseType": "release"
|
||||
|
||||
@@ -14,6 +14,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||
import DataManagementPage from './pages/DataManagementPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ExportPage from './pages/ExportPage'
|
||||
import ClonePage from './pages/ClonePage'
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||
import * as configService from './services/config'
|
||||
@@ -311,6 +312,7 @@ function App() {
|
||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
<Route path="/clone" element={<ClonePage />} />
|
||||
<Route path="/data-management" element={<DataManagementPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react'
|
||||
import './Sidebar.scss'
|
||||
|
||||
function Sidebar() {
|
||||
@@ -34,6 +34,16 @@ function Sidebar() {
|
||||
<span className="nav-label">聊天</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 好友克隆 */}
|
||||
<NavLink
|
||||
to="/clone"
|
||||
className={`nav-item ${isActive('/clone') ? 'active' : ''}`}
|
||||
title={collapsed ? '好友克隆' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Bot size={20} /></span>
|
||||
<span className="nav-label">好友克隆</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 私聊分析 */}
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
|
||||
@@ -5,6 +5,25 @@ import type { ChatSession, Message } from '../types/models'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
import './ChatPage.scss'
|
||||
|
||||
const SESSION_MESSAGE_CACHE_LIMIT = 150
|
||||
const SESSION_MESSAGE_CACHE_MAX_ENTRIES = 200
|
||||
const sessionMessageCache = new Map<string, Message[]>()
|
||||
|
||||
const cacheSessionMessages = (sessionId: string, messages: Message[]) => {
|
||||
if (!sessionId) return
|
||||
const trimmed = messages.length > SESSION_MESSAGE_CACHE_LIMIT
|
||||
? messages.slice(-SESSION_MESSAGE_CACHE_LIMIT)
|
||||
: messages.slice()
|
||||
sessionMessageCache.set(sessionId, trimmed)
|
||||
if (sessionMessageCache.size > SESSION_MESSAGE_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = sessionMessageCache.keys().next().value
|
||||
if (oldestKey) {
|
||||
sessionMessageCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface ChatPageProps {
|
||||
// 保留接口以备将来扩展
|
||||
}
|
||||
@@ -23,66 +42,6 @@ interface SessionDetail {
|
||||
messageTables: { dbName: string; tableName: string; count: number }[]
|
||||
}
|
||||
|
||||
// 全局头像加载队列管理器(限制并发,避免卡顿)
|
||||
class AvatarLoadQueue {
|
||||
private queue: Array<{ url: string; resolve: () => void; reject: () => void }> = []
|
||||
private loading = new Set<string>()
|
||||
private readonly maxConcurrent = 1 // 一次只加载1个头像,避免卡顿
|
||||
private readonly delayBetweenBatches = 100 // 批次间延迟100ms,给UI喘息时间
|
||||
|
||||
async enqueue(url: string): Promise<void> {
|
||||
// 如果已经在加载中,直接返回
|
||||
if (this.loading.has(url)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.queue.push({ url, resolve, reject })
|
||||
this.processQueue()
|
||||
})
|
||||
}
|
||||
|
||||
private async processQueue() {
|
||||
// 如果已达到最大并发数,等待
|
||||
if (this.loading.size >= this.maxConcurrent) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果队列为空,返回
|
||||
if (this.queue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 取出一个任务
|
||||
const task = this.queue.shift()
|
||||
if (!task) return
|
||||
|
||||
this.loading.add(task.url)
|
||||
|
||||
// 加载图片
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
this.loading.delete(task.url)
|
||||
task.resolve()
|
||||
// 延迟一下再处理下一个,避免一次性加载太多
|
||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||
}
|
||||
img.onerror = () => {
|
||||
this.loading.delete(task.url)
|
||||
task.reject()
|
||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||
}
|
||||
img.src = task.url
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.queue = []
|
||||
this.loading.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const avatarLoadQueue = new AvatarLoadQueue()
|
||||
|
||||
// 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染)
|
||||
// 会话项组件(使用 memo 优化,避免不必要的重渲染)
|
||||
const SessionItem = React.memo(function SessionItem({
|
||||
@@ -142,7 +101,6 @@ const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }:
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [shouldLoad, setShouldLoad] = useState(false)
|
||||
const [isInQueue, setIsInQueue] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isGroup = session.username.includes('@chatroom')
|
||||
@@ -154,59 +112,13 @@ const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }:
|
||||
return chars[0] || '?'
|
||||
}
|
||||
|
||||
// 使用 Intersection Observer 实现懒加载(优化性能)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || shouldLoad || isInQueue) return
|
||||
if (!session.avatarUrl) {
|
||||
// 没有头像URL,不需要加载
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && !isInQueue) {
|
||||
// 加入加载队列,而不是立即加载
|
||||
setIsInQueue(true)
|
||||
avatarLoadQueue.enqueue(session.avatarUrl!).then(() => {
|
||||
setShouldLoad(true)
|
||||
}).catch(() => {
|
||||
setImageError(true)
|
||||
}).finally(() => {
|
||||
setIsInQueue(false)
|
||||
})
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
rootMargin: '50px' // 减少预加载距离,只提前50px
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(containerRef.current)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [session.avatarUrl, shouldLoad, isInQueue])
|
||||
|
||||
// 当 avatarUrl 变化时重置状态
|
||||
useEffect(() => {
|
||||
setImageLoaded(false)
|
||||
setImageError(false)
|
||||
setShouldLoad(false)
|
||||
setIsInQueue(false)
|
||||
setShouldLoad(Boolean(session.avatarUrl))
|
||||
}, [session.avatarUrl])
|
||||
|
||||
// 检查图片是否已经从缓存加载完成
|
||||
useEffect(() => {
|
||||
if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
||||
setImageLoaded(true)
|
||||
}
|
||||
}, [session.avatarUrl, shouldLoad])
|
||||
|
||||
const hasValidUrl = session.avatarUrl && !imageError && shouldLoad
|
||||
const hasValidUrl = Boolean(session.avatarUrl && !imageError && shouldLoad)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -380,10 +292,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
// 确保 nextSessions 也是数组
|
||||
if (Array.isArray(nextSessions)) {
|
||||
setSessions(nextSessions)
|
||||
// 延迟启动联系人信息加载,确保UI先渲染完成
|
||||
// 启动联系人信息加载,UI 已经渲染完成
|
||||
setTimeout(() => {
|
||||
void enrichSessionsContactInfo(nextSessions)
|
||||
}, 500)
|
||||
}, 0)
|
||||
} else {
|
||||
console.error('mergeSessions returned non-array:', nextSessions)
|
||||
setSessions(sessionsArray)
|
||||
@@ -420,9 +332,6 @@ function ChatPage(_props: ChatPageProps) {
|
||||
console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`)
|
||||
const totalStart = performance.now()
|
||||
|
||||
// 延迟启动,等待UI渲染完成
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// 检查是否被取消
|
||||
if (enrichCancelledRef.current) {
|
||||
isEnrichingRef.current = false
|
||||
@@ -440,8 +349,8 @@ function ChatPage(_props: ChatPageProps) {
|
||||
|
||||
console.log(`[性能监控] 需要加载的联系人信息: ${needEnrich.length} 个`)
|
||||
|
||||
// 进一步减少批次大小,每批3个,避免DLL调用阻塞
|
||||
const batchSize = 3
|
||||
// 每次最多查询更多联系人,减少批次数
|
||||
const batchSize = 20
|
||||
let loadedCount = 0
|
||||
|
||||
for (let i = 0; i < needEnrich.length; i += batchSize) {
|
||||
@@ -462,31 +371,15 @@ function ChatPage(_props: ChatPageProps) {
|
||||
const batch = needEnrich.slice(i, i + batchSize)
|
||||
const usernames = batch.map(s => s.username)
|
||||
|
||||
// 使用 requestIdleCallback 延迟执行,避免阻塞UI
|
||||
await new Promise<void>((resolve) => {
|
||||
if ('requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(() => {
|
||||
void loadContactInfoBatch(usernames).then(() => resolve())
|
||||
}, { timeout: 2000 })
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
void loadContactInfoBatch(usernames).then(() => resolve())
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
// 在执行 DLL 请求前让出控制权以保持响应
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await loadContactInfoBatch(usernames)
|
||||
|
||||
loadedCount += batch.length
|
||||
const batchTime = performance.now() - batchStart
|
||||
if (batchTime > 200) {
|
||||
console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`)
|
||||
}
|
||||
|
||||
// 批次间延迟,给UI更多时间(DLL调用可能阻塞,需要更长的延迟)
|
||||
if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) {
|
||||
// 如果不在滚动,可以延迟短一点
|
||||
const delay = isScrollingRef.current ? 1000 : 800
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = performance.now() - totalStart
|
||||
@@ -638,6 +531,32 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const loadCachedMessagesForSession = async (sessionId: string) => {
|
||||
if (!sessionId) return
|
||||
const cached = sessionMessageCache.get(sessionId)
|
||||
if (cached && cached.length > 0) {
|
||||
setMessages(cached)
|
||||
setHasInitialMessages(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getCachedMessages(sessionId)
|
||||
if (result.success && Array.isArray(result.messages) && result.messages.length > 0) {
|
||||
const trimmed = result.messages.length > SESSION_MESSAGE_CACHE_LIMIT
|
||||
? result.messages.slice(-SESSION_MESSAGE_CACHE_LIMIT)
|
||||
: result.messages
|
||||
sessionMessageCache.set(sessionId, trimmed)
|
||||
setMessages(trimmed)
|
||||
setHasInitialMessages(true)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载缓存消息失败:', error)
|
||||
}
|
||||
setMessages([])
|
||||
setHasInitialMessages(false)
|
||||
}
|
||||
|
||||
// 加载消息
|
||||
const loadMessages = async (sessionId: string, offset = 0) => {
|
||||
const listEl = messageListRef.current
|
||||
@@ -647,7 +566,6 @@ function ChatPage(_props: ChatPageProps) {
|
||||
|
||||
if (offset === 0) {
|
||||
setLoadingMessages(true)
|
||||
setMessages([])
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
@@ -660,6 +578,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
if (result.success && result.messages) {
|
||||
if (offset === 0) {
|
||||
setMessages(result.messages)
|
||||
cacheSessionMessages(sessionId, result.messages)
|
||||
// 首次加载滚动到底部
|
||||
requestAnimationFrame(() => {
|
||||
if (messageListRef.current) {
|
||||
@@ -694,14 +613,18 @@ function ChatPage(_props: ChatPageProps) {
|
||||
// 选择会话
|
||||
const handleSelectSession = (session: ChatSession) => {
|
||||
if (session.username === currentSessionId) return
|
||||
setCurrentSession(session.username)
|
||||
const sessionId = session.username
|
||||
setCurrentSession(sessionId)
|
||||
setCurrentOffset(0)
|
||||
loadMessages(session.username, 0)
|
||||
// 重置详情面板
|
||||
setSessionDetail(null)
|
||||
if (showDetailPanel) {
|
||||
loadSessionDetail(session.username)
|
||||
loadSessionDetail(sessionId)
|
||||
}
|
||||
void (async () => {
|
||||
await loadCachedMessagesForSession(sessionId)
|
||||
loadMessages(sessionId, 0)
|
||||
})()
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
@@ -845,7 +768,6 @@ function ChatPage(_props: ChatPageProps) {
|
||||
|
||||
// 组件卸载时清理
|
||||
return () => {
|
||||
avatarLoadQueue.clear()
|
||||
if (contactUpdateTimerRef.current) {
|
||||
clearTimeout(contactUpdateTimerRef.current)
|
||||
}
|
||||
|
||||
404
src/pages/ClonePage.scss
Normal file
404
src/pages/ClonePage.scss
Normal file
@@ -0,0 +1,404 @@
|
||||
.clone-page {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.clone-hero {
|
||||
background: linear-gradient(135deg, rgba(39, 189, 149, 0.18), rgba(14, 116, 144, 0.14));
|
||||
border: 1px solid rgba(47, 198, 156, 0.2);
|
||||
}
|
||||
|
||||
.clone-hero-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.clone-hero-title {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clone-hero-title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.clone-hero-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.clone-hero-badges span {
|
||||
padding: 6px 12px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(10, 70, 63, 0.18);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.clone-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.clone-config-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 340px) minmax(320px, 1fr);
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.clone-session-panel,
|
||||
.clone-model-panel {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.clone-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.clone-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 6px 10px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.clone-search input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
width: 140px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.clone-session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.clone-session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.clone-session-item:hover {
|
||||
border-color: rgba(45, 212, 191, 0.4);
|
||||
background: rgba(45, 212, 191, 0.08);
|
||||
}
|
||||
|
||||
.clone-session-item.active {
|
||||
border-color: rgba(14, 116, 144, 0.6);
|
||||
background: rgba(14, 116, 144, 0.16);
|
||||
}
|
||||
|
||||
.clone-session-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(14, 116, 144, 0.2));
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clone-session-avatar img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.clone-session-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.clone-session-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.clone-session-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.clone-model-tip {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.clone-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.clone-label select,
|
||||
.clone-label input {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.clone-input-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clone-input-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.clone-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.clone-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.clone-options label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.clone-options input[type='number'] {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.clone-checkbox {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.clone-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clone-progress {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.clone-alert {
|
||||
margin-top: 12px;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #b91c1c;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.clone-tone {
|
||||
margin-top: 12px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.clone-tone pre {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.clone-query {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.clone-query input {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.clone-query-results {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.clone-card {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.clone-card-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.clone-card-content {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.clone-empty {
|
||||
padding: 14px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--border-color);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.clone-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.clone-chat-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.clone-bubble {
|
||||
max-width: 80%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.clone-bubble.user {
|
||||
align-self: flex-end;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
border: 1px solid rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.clone-bubble.assistant {
|
||||
align-self: flex-start;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.clone-chat-input {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clone-chat-input input {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.clone-hero-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.clone-input-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.clone-config-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.clone-search input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
481
src/pages/ClonePage.tsx
Normal file
481
src/pages/ClonePage.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Bot, Search, Wand2, Database, Play, RefreshCw, FileSearch } from 'lucide-react'
|
||||
import type { ChatSession } from '../types/models'
|
||||
import * as configService from '../services/config'
|
||||
import './ClonePage.scss'
|
||||
import './DataManagementPage.scss'
|
||||
|
||||
type ToneGuide = {
|
||||
summary?: string
|
||||
details?: Record<string, any>
|
||||
}
|
||||
|
||||
type ChatEntry = {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
function ClonePage() {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||
const [selectedSession, setSelectedSession] = useState('')
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
|
||||
const [modelPath, setModelPath] = useState('')
|
||||
const [modelSaving, setModelSaving] = useState(false)
|
||||
|
||||
const [resetIndex, setResetIndex] = useState(false)
|
||||
const [batchSize, setBatchSize] = useState(200)
|
||||
const [chunkGapMinutes, setChunkGapMinutes] = useState(10)
|
||||
const [maxChunkChars, setMaxChunkChars] = useState(400)
|
||||
const [maxChunkMessages, setMaxChunkMessages] = useState(20)
|
||||
const [indexing, setIndexing] = useState(false)
|
||||
const [indexStatus, setIndexStatus] = useState<{ totalMessages: number; totalChunks: number; hasMore: boolean } | null>(null)
|
||||
|
||||
const [toneGuide, setToneGuide] = useState<ToneGuide | null>(null)
|
||||
const [toneLoading, setToneLoading] = useState(false)
|
||||
const [toneSampleSize, setToneSampleSize] = useState(500)
|
||||
const [toneError, setToneError] = useState<string | null>(null)
|
||||
|
||||
const [queryKeyword, setQueryKeyword] = useState('')
|
||||
const [queryResults, setQueryResults] = useState<any[]>([])
|
||||
const [queryLoading, setQueryLoading] = useState(false)
|
||||
|
||||
const [chatInput, setChatInput] = useState('')
|
||||
const [chatHistory, setChatHistory] = useState<ChatEntry[]>([])
|
||||
const [chatLoading, setChatLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getSessions()
|
||||
if (!result.success || !result.sessions) {
|
||||
setLoadError(result.error || '加载会话失败')
|
||||
return
|
||||
}
|
||||
const privateSessions = result.sessions.filter((s) => !s.username.includes('@chatroom'))
|
||||
setSessions(privateSessions)
|
||||
if (privateSessions.length > 0) {
|
||||
setSelectedSession((prev) => prev || privateSessions[0].username)
|
||||
}
|
||||
} catch (err) {
|
||||
setLoadError(String(err))
|
||||
}
|
||||
}
|
||||
loadSessions()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadModelPath = async () => {
|
||||
const saved = await configService.getLlmModelPath()
|
||||
if (saved) setModelPath(saved)
|
||||
}
|
||||
loadModelPath()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electronAPI.clone.onIndexProgress?.((payload) => {
|
||||
setIndexStatus({
|
||||
totalMessages: payload.totalMessages,
|
||||
totalChunks: payload.totalChunks,
|
||||
hasMore: payload.hasMore
|
||||
})
|
||||
})
|
||||
return () => removeListener?.()
|
||||
}, [])
|
||||
|
||||
const sessionLabelMap = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const session of sessions) {
|
||||
map.set(session.username, session.displayName || session.username)
|
||||
}
|
||||
return map
|
||||
}, [sessions])
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
const keyword = searchKeyword.trim().toLowerCase()
|
||||
if (!keyword) return sessions
|
||||
return sessions.filter((session) => {
|
||||
const name = session.displayName || ''
|
||||
return (
|
||||
name.toLowerCase().includes(keyword) ||
|
||||
session.username.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
}, [sessions, searchKeyword])
|
||||
|
||||
const getAvatarLetter = (session: ChatSession) => {
|
||||
const name = session.displayName || session.username
|
||||
if (!name) return '?'
|
||||
return [...name][0] || '?'
|
||||
}
|
||||
|
||||
const handlePickModel = async () => {
|
||||
const result = await window.electronAPI.dialog.openFile({
|
||||
title: '选择本地 LLM 模型 (.gguf)',
|
||||
filters: [{ name: 'GGUF', extensions: ['gguf'] }]
|
||||
})
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setModelPath(result.filePaths[0])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveModel = async () => {
|
||||
setModelSaving(true)
|
||||
try {
|
||||
await configService.setLlmModelPath(modelPath)
|
||||
} finally {
|
||||
setModelSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIndex = async () => {
|
||||
if (!selectedSession) return
|
||||
setIndexing(true)
|
||||
setIndexStatus(null)
|
||||
try {
|
||||
await window.electronAPI.clone.indexSession(selectedSession, {
|
||||
reset: resetIndex,
|
||||
batchSize,
|
||||
chunkGapSeconds: Math.max(1, Math.round(chunkGapMinutes * 60)),
|
||||
maxChunkChars,
|
||||
maxChunkMessages
|
||||
})
|
||||
} catch (err) {
|
||||
setLoadError(String(err))
|
||||
} finally {
|
||||
setIndexing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToneGuide = async () => {
|
||||
if (!selectedSession) return
|
||||
setToneLoading(true)
|
||||
setToneError(null)
|
||||
try {
|
||||
const result = await window.electronAPI.clone.generateToneGuide(selectedSession, toneSampleSize)
|
||||
if (result.success) {
|
||||
setToneGuide(result.data || null)
|
||||
} else {
|
||||
setToneError(result.error || '生成失败')
|
||||
}
|
||||
} finally {
|
||||
setToneLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadToneGuide = async () => {
|
||||
if (!selectedSession) return
|
||||
setToneLoading(true)
|
||||
setToneError(null)
|
||||
try {
|
||||
const result = await window.electronAPI.clone.getToneGuide(selectedSession)
|
||||
if (result.success) {
|
||||
setToneGuide(result.data || null)
|
||||
} else {
|
||||
setToneError(result.error || '未找到说明书')
|
||||
}
|
||||
} finally {
|
||||
setToneLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuery = async () => {
|
||||
if (!selectedSession || !queryKeyword.trim()) return
|
||||
setQueryLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.clone.query({
|
||||
sessionId: selectedSession,
|
||||
keyword: queryKeyword.trim(),
|
||||
options: { topK: 5, roleFilter: 'target' }
|
||||
})
|
||||
if (result.success) {
|
||||
setQueryResults(result.results || [])
|
||||
} else {
|
||||
setQueryResults([])
|
||||
}
|
||||
} finally {
|
||||
setQueryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChat = async () => {
|
||||
if (!selectedSession || !chatInput.trim()) return
|
||||
const message = chatInput.trim()
|
||||
setChatInput('')
|
||||
setChatHistory((prev) => [...prev, { role: 'user', content: message }])
|
||||
setChatLoading(true)
|
||||
try {
|
||||
const result = await window.electronAPI.clone.chat({ sessionId: selectedSession, message })
|
||||
const reply = result.success ? (result.response || '') : result.error || '生成失败'
|
||||
setChatHistory((prev) => [...prev, { role: 'assistant', content: reply }])
|
||||
} finally {
|
||||
setChatLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h1>好友克隆</h1>
|
||||
</div>
|
||||
|
||||
<div className="page-scroll clone-page">
|
||||
<section className="page-section clone-hero">
|
||||
<div className="clone-hero-content">
|
||||
<div className="clone-hero-title">
|
||||
<Bot size={28} />
|
||||
<div>
|
||||
<h2>私聊分身实验室</h2>
|
||||
<p className="section-desc">建立长期记忆、生成性格说明书、通过工具调用检索旧对话。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="clone-hero-badges">
|
||||
<span>私聊限定</span>
|
||||
<span>本地推理</span>
|
||||
<span>可解释检索</span>
|
||||
</div>
|
||||
</div>
|
||||
{loadError && <div className="clone-alert">{loadError}</div>}
|
||||
</section>
|
||||
|
||||
<section className="page-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>基础配置</h2>
|
||||
<p className="section-desc">选择要克隆的好友,并配置本地 LLM 模型。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="clone-config clone-config-split">
|
||||
<div className="clone-session-panel">
|
||||
<div className="clone-panel-header">
|
||||
<span>目标好友</span>
|
||||
<div className="clone-search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索好友"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="clone-session-list">
|
||||
{filteredSessions.length === 0 ? (
|
||||
<div className="clone-empty">暂无可用会话</div>
|
||||
) : (
|
||||
filteredSessions.map((session) => (
|
||||
<button
|
||||
key={session.username}
|
||||
className={`clone-session-item ${selectedSession === session.username ? 'active' : ''}`}
|
||||
onClick={() => setSelectedSession(session.username)}
|
||||
>
|
||||
<div className="clone-session-avatar">
|
||||
<span>{getAvatarLetter(session)}</span>
|
||||
{session.avatarUrl && (
|
||||
<img
|
||||
src={session.avatarUrl}
|
||||
alt={session.displayName || session.username}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="clone-session-info">
|
||||
<div className="clone-session-name">{sessionLabelMap.get(session.username)}</div>
|
||||
<div className="clone-session-meta">{session.username}</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="clone-model-panel">
|
||||
<label className="clone-label">
|
||||
LLM 模型路径 (.gguf)
|
||||
<div className="clone-input-row">
|
||||
<input
|
||||
type="text"
|
||||
value={modelPath}
|
||||
onChange={(e) => setModelPath(e.target.value)}
|
||||
placeholder="请选择本地模型路径"
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={handlePickModel}>
|
||||
<FileSearch size={16} />
|
||||
选择
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSaveModel} disabled={modelSaving}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<div className="clone-model-tip">
|
||||
建议使用 1.5B 级别 GGUF 模型,首次加载可能需要一些时间。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="clone-grid">
|
||||
<section className="page-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>长期记忆索引</h2>
|
||||
<p className="section-desc">将私聊消息切片并向量化,建立可检索记忆库。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="clone-options">
|
||||
<label>
|
||||
<span>批大小</span>
|
||||
<input type="number" min={50} max={1000} value={batchSize} onChange={(e) => setBatchSize(Number(e.target.value))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>时间间隔 (分钟)</span>
|
||||
<input type="number" min={1} max={60} value={chunkGapMinutes} onChange={(e) => setChunkGapMinutes(Number(e.target.value))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>最大字数</span>
|
||||
<input type="number" min={100} max={1200} value={maxChunkChars} onChange={(e) => setMaxChunkChars(Number(e.target.value))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>最大条数</span>
|
||||
<input type="number" min={5} max={50} value={maxChunkMessages} onChange={(e) => setMaxChunkMessages(Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="clone-checkbox">
|
||||
<input type="checkbox" checked={resetIndex} onChange={(e) => setResetIndex(e.target.checked)} />
|
||||
重建索引
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="clone-actions">
|
||||
<button className="btn btn-primary" onClick={handleIndex} disabled={indexing || !selectedSession}>
|
||||
{indexing ? <RefreshCw size={16} className="spin" /> : <Database size={16} />}
|
||||
开始索引
|
||||
</button>
|
||||
{indexStatus && (
|
||||
<div className="clone-progress">
|
||||
<span>消息 {indexStatus.totalMessages}</span>
|
||||
<span>分片 {indexStatus.totalChunks}</span>
|
||||
<span>{indexStatus.hasMore ? '索引中' : '已完成'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="page-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>性格说明书</h2>
|
||||
<p className="section-desc">抽样目标发言,生成可长期驻留的说话风格。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="clone-options">
|
||||
<label>
|
||||
<span>抽样条数</span>
|
||||
<input type="number" min={100} max={2000} value={toneSampleSize} onChange={(e) => setToneSampleSize(Number(e.target.value))} />
|
||||
</label>
|
||||
<div className="clone-actions">
|
||||
<button className="btn btn-primary" onClick={handleToneGuide} disabled={toneLoading || !selectedSession}>
|
||||
{toneLoading ? <RefreshCw size={16} className="spin" /> : <Wand2 size={16} />}
|
||||
生成说明书
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleLoadToneGuide} disabled={toneLoading || !selectedSession}>
|
||||
读取已有
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toneError && <div className="clone-alert">{toneError}</div>}
|
||||
{toneGuide && (
|
||||
<div className="clone-tone">
|
||||
<strong>{toneGuide.summary || '未生成摘要'}</strong>
|
||||
{toneGuide.details && (
|
||||
<pre>{JSON.stringify(toneGuide.details, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="page-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>记忆检索测试</h2>
|
||||
<p className="section-desc">输入关键词测试向量检索效果。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="clone-query">
|
||||
<input
|
||||
type="text"
|
||||
value={queryKeyword}
|
||||
onChange={(e) => setQueryKeyword(e.target.value)}
|
||||
placeholder="比如:上海、火锅、雨天"
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={handleQuery} disabled={queryLoading || !selectedSession}>
|
||||
{queryLoading ? <RefreshCw size={16} className="spin" /> : <Search size={16} />}
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
<div className="clone-query-results">
|
||||
{queryResults.length === 0 ? (
|
||||
<div className="clone-empty">暂无结果</div>
|
||||
) : (
|
||||
queryResults.map((item, idx) => (
|
||||
<div key={`${item.id || idx}`} className="clone-card">
|
||||
<div className="clone-card-meta">
|
||||
<span>{item.role === 'target' ? '对方' : '我'}</span>
|
||||
<span>消息 {item.messageCount}</span>
|
||||
</div>
|
||||
<div className="clone-card-content">{item.content}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="page-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>分身对话</h2>
|
||||
<p className="section-desc">模型会按需调用记忆检索,再用目标口吻回应。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="clone-chat">
|
||||
<div className="clone-chat-history">
|
||||
{chatHistory.length === 0 ? (
|
||||
<div className="clone-empty">暂无对话</div>
|
||||
) : (
|
||||
chatHistory.map((entry, idx) => (
|
||||
<div key={`${entry.role}-${idx}`} className={`clone-bubble ${entry.role}`}>
|
||||
{entry.content}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="clone-chat-input">
|
||||
<input
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
placeholder="对分身说点什么..."
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleChat} disabled={chatLoading || !selectedSession}>
|
||||
{chatLoading ? <RefreshCw size={16} className="spin" /> : <Play size={16} />}
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClonePage
|
||||
@@ -15,6 +15,7 @@ export const CONFIG_KEYS = {
|
||||
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
||||
LOG_ENABLED: 'logEnabled',
|
||||
ONBOARDING_DONE: 'onboardingDone',
|
||||
LLM_MODEL_PATH: 'llmModelPath',
|
||||
IMAGE_XOR_KEY: 'imageXorKey',
|
||||
IMAGE_AES_KEY: 'imageAesKey'
|
||||
} as const
|
||||
@@ -132,6 +133,17 @@ export async function setLogEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
|
||||
}
|
||||
|
||||
// 获取 LLM 模型路径
|
||||
export async function getLlmModelPath(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
|
||||
return (value as string) || null
|
||||
}
|
||||
|
||||
// 设置 LLM 模型路径
|
||||
export async function setLlmModelPath(path: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
|
||||
}
|
||||
|
||||
// 清除所有配置
|
||||
export async function clearConfig(): Promise<void> {
|
||||
await config.clear()
|
||||
|
||||
18
src/types/electron.d.ts
vendored
18
src/types/electron.d.ts
vendored
@@ -95,6 +95,24 @@ export interface ElectronAPI {
|
||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
}
|
||||
clone: {
|
||||
indexSession: (sessionId: string, options?: {
|
||||
reset?: boolean
|
||||
batchSize?: number
|
||||
chunkGapSeconds?: number
|
||||
maxChunkChars?: number
|
||||
maxChunkMessages?: number
|
||||
}) => Promise<{ success: boolean; totalMessages?: number; totalChunks?: number; debug?: any; error?: string }>
|
||||
query: (payload: {
|
||||
sessionId: string
|
||||
keyword: string
|
||||
options?: { topK?: number; roleFilter?: 'target' | 'me' }
|
||||
}) => Promise<{ success: boolean; results?: any[]; debug?: any; error?: string }>
|
||||
getToneGuide: (sessionId: string) => Promise<{ success: boolean; data?: any; error?: string }>
|
||||
generateToneGuide: (sessionId: string, sampleSize?: number) => Promise<{ success: boolean; data?: any; error?: string }>
|
||||
chat: (payload: { sessionId: string; message: string; topK?: number }) => Promise<{ success: boolean; response?: string; error?: string }>
|
||||
onIndexProgress: (callback: (payload: { requestId: string; totalMessages: number; totalChunks: number; hasMore: boolean }) => void) => () => void
|
||||
}
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
||||
|
||||
@@ -10,6 +10,21 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
strictPort: false // 如果3000被占用,自动尝试下一个
|
||||
},
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
ignoreDynamicRequires: true
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
'@lancedb/lancedb',
|
||||
'@lancedb/lancedb-win32-x64-msvc',
|
||||
'node-llama-cpp',
|
||||
'onnxruntime-node',
|
||||
'@xenova/transformers',
|
||||
'@huggingface/transformers'
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
electron([
|
||||
@@ -19,7 +34,17 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
external: ['better-sqlite3', 'koffi']
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'koffi',
|
||||
'node-llama-cpp',
|
||||
'@lancedb/lancedb',
|
||||
'@lancedb/lancedb-win32-x64-msvc',
|
||||
'onnxruntime-node',
|
||||
'@xenova/transformers',
|
||||
'@huggingface/transformers',
|
||||
'fsevents'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +55,16 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
external: ['koffi'],
|
||||
external: [
|
||||
'koffi',
|
||||
'node-llama-cpp',
|
||||
'@lancedb/lancedb',
|
||||
'@lancedb/lancedb-win32-x64-msvc',
|
||||
'onnxruntime-node',
|
||||
'@xenova/transformers',
|
||||
'@huggingface/transformers',
|
||||
'fsevents'
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'annualReportWorker.js',
|
||||
inlineDynamicImports: true
|
||||
@@ -39,6 +73,30 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/cloneEmbeddingWorker.ts',
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'koffi',
|
||||
'node-llama-cpp',
|
||||
'@lancedb/lancedb',
|
||||
'@lancedb/lancedb-win32-x64-msvc',
|
||||
'onnxruntime-node',
|
||||
'@xenova/transformers',
|
||||
'@huggingface/transformers',
|
||||
'fsevents'
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'cloneEmbeddingWorker.js',
|
||||
inlineDynamicImports: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/imageSearchWorker.ts',
|
||||
vite: {
|
||||
|
||||
Reference in New Issue
Block a user