fix: 修复一些代码报错; 移除了好友复刻的功能

This commit is contained in:
cc
2026-01-14 19:44:09 +08:00
parent bd94ba7b1a
commit e7c93ea2f7
12 changed files with 27 additions and 1757 deletions

View File

@@ -1,306 +0,0 @@
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) })
}
})

View File

@@ -15,7 +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
@@ -445,27 +445,7 @@ function registerIpcHandlers() {
})
// 私聊克隆
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)

View File

@@ -108,19 +108,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
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: {

View File

@@ -58,6 +58,7 @@ export interface Message {
aesKey?: string
encrypVer?: number
cdnThumbUrl?: string
voiceDurationSeconds?: number
}
export interface Contact {
@@ -2495,12 +2496,12 @@ class ChatService {
}
const aesData = payload.subarray(0, alignedAesSize)
let unpadded = Buffer.alloc(0)
let unpadded: Buffer = Buffer.alloc(0)
if (aesData.length > 0) {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
unpadded = this.strictRemovePadding(decrypted)
unpadded = this.strictRemovePadding(decrypted) as Buffer
}
const remaining = payload.subarray(alignedAesSize)
@@ -2508,21 +2509,21 @@ class ChatService {
throw new Error('文件格式异常XOR 数据长度不合法')
}
let rawData = Buffer.alloc(0)
let xoredData = Buffer.alloc(0)
let rawData: Buffer = Buffer.alloc(0)
let xoredData: Buffer = Buffer.alloc(0)
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) {
throw new Error('文件格式异常原始数据长度小于XOR长度')
}
rawData = remaining.subarray(0, rawLength)
rawData = remaining.subarray(0, rawLength) as Buffer
const xorData = remaining.subarray(rawLength)
xoredData = Buffer.alloc(xorData.length)
for (let i = 0; i < xorData.length; i++) {
xoredData[i] = xorData[i] ^ xorKey
}
} else {
rawData = remaining
rawData = remaining as Buffer
xoredData = Buffer.alloc(0)
}

View File

@@ -1,123 +0,0 @@
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
}

View File

@@ -1,356 +0,0 @@
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()

View File

@@ -5,8 +5,8 @@
"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",
"postinstall": "electron-rebuild -f -w @lancedb/lancedb,onnxruntime-node",
"rebuild": "electron-rebuild -f -w @lancedb/lancedb,onnxruntime-node",
"dev": "vite",
"build": "vue-tsc && vite build && electron-builder",
"preview": "vite preview",
@@ -16,7 +16,6 @@
"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",
@@ -28,7 +27,6 @@
"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",
@@ -54,7 +52,6 @@
"build": {
"appId": "com.WeFlow.app",
"asarUnpack": [
"**/node_modules/node-llama-cpp/**/*",
"**/node_modules/@lancedb/lancedb/**/*",
"**/node_modules/onnxruntime-node/**/*"
],
@@ -109,4 +106,4 @@
"dist-electron/**/*"
]
}
}
}

View File

@@ -14,7 +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'
@@ -189,7 +189,7 @@ function App() {
}
console.log('检测到已保存的配置,正在自动连接...')
const result = await window.electronAPI.chat.connect()
if (result.success) {
console.log('自动连接成功')
setDbConnected(true, dbPath)
@@ -312,7 +312,6 @@ 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 />} />

View File

@@ -34,15 +34,7 @@ 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
@@ -94,10 +86,10 @@ function Sidebar() {
<span className="nav-label"></span>
</NavLink>
</nav>
<div className="sidebar-footer">
<NavLink
to="/settings"
<NavLink
to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
title={collapsed ? '设置' : undefined}
>
@@ -106,8 +98,8 @@ function Sidebar() {
</span>
<span className="nav-label"></span>
</NavLink>
<button
<button
className="collapse-btn"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? '展开菜单' : '收起菜单'}

View File

@@ -1,404 +0,0 @@
.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%;
}
}

View File

@@ -1,481 +0,0 @@
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

View File

@@ -95,24 +95,7 @@ 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 }>
@@ -280,12 +263,12 @@ export interface ElectronAPI {
fastestFriend: string
fastestTime: number
} | null
topPhrases: Array<{
phrase: string
count: number
}>
}
error?: string
topPhrases: Array<{
phrase: string
count: number
}>
}
error?: string
}>
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => Promise<{
success: boolean