mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
测试版本,添加了模拟好友并优化了本地缓存
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user