Files
yuanzhipeng eecbe4172e feat(api): 将万川平台配置迁移至后端存储
- 移除前端localStorage依赖,改用后端SQLite作为唯一数据源
- 新增getWanchuanConfig和saveWanchuanConfig函数用于配置读写
- 添加getBoundKnowledgeBase函数统一获取绑定知识库信息
- 支持桌面应用端口变化时正确读取配置

refactor(settings): 重构万川平台配置管理逻辑

- 移除localStorage配置存储,改为后端API调用
- 实现配置自动恢复和防抖保存机制
- 添加token过期自动重登功能
- 优化知识库选择和连接状态管理

fix(knowledge): 修复知识库上传异步问题

- 将getBoundKnowledgeBase调用改为await异步处理
- 统一各页面的知识库信息获取方式
- 修正上传接口datasetId使用逻辑

feat(electron): 添加chatlog.exe存在性检查

- 新增ensureChatlogExe函数验证执行文件存在
- 防止杀毒软件误删导致的ENONENT错误
- 提供用户友好的错误提示和解决方案
2026-06-24 10:13:20 +08:00

1359 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { app, BrowserWindow, ipcMain } = require('electron');
const fs = require('fs');
const http = require('http');
const net = require('net');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
let mainWindow;
let isQuitting = false;
let isInViewMode = false;
let decryptPollTimer = null;
let backendPort = null;
let backendUrl = null;
let lastAccountFingerprint = '';
let activeChatlogConfig = null;
let malformedRepairTimer = null;
let malformedRepairInProgress = false;
let messageQueryRestartInProgress = false;
let startupValidationInProgress = false;
const pendingMalformedDbPaths = new Set();
const malformedRepairAttempts = new Set();
const malformedRepairNotified = new Set();
const messageQueryRestartAttempts = new Set();
const processes = {
chatlog: null,
fastapi: null,
};
function isPackaged() {
return app.isPackaged;
}
function projectRoot() {
return isPackaged() ? process.resourcesPath : path.resolve(__dirname, '..');
}
function resourcePath(...parts) {
return path.join(projectRoot(), ...parts);
}
function chatlogExePath() {
return resourcePath('chatlog.exe');
}
// 启动 chatlog.exe 前确认文件确实存在。
// 该文件常被杀毒软件(尤其 Windows Defender误判为风险程序而静默隔离/删除,
// 导致安装后“有时候”运行就报 spawn ... ENOENT。这里把底层的 ENOENT
// 提前转成一句用户能看懂、能自救的中文提示。
function ensureChatlogExe() {
const exePath = chatlogExePath();
if (!fs.existsSync(exePath)) {
throw new Error(
`未找到 chatlog.exe应在${exePath})。` +
`该文件可能被杀毒软件误删或隔离。请将安装目录加入杀毒软件白名单,` +
`从隔离区恢复 chatlog.exe或重新安装本应用后再试。`
);
}
return exePath;
}
function backendExePath() {
return resourcePath('backend', 'ChatLabBackend.exe');
}
function backendSourceDir() {
return resourcePath('chatlog_fastAPI');
}
function frontendDistDir() {
return isPackaged()
? resourcePath('frontend')
: resourcePath('chatlab-web', 'frontend', 'dist');
}
function appIconPath() {
const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png';
const candidate = path.join(__dirname, 'build', iconName);
return fs.existsSync(candidate) ? candidate : undefined;
}
function appDataDir() {
const dir = path.join(app.getPath('appData'), 'ChatLab');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function send(channel, payload) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, payload);
}
}
function log(source, text) {
send('log', { source, text: sanitizeLog(String(text || '')) });
}
function stripAnsi(text) {
return String(text || '').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
}
function sanitizeLog(text) {
return stripAnsi(text)
.replace(/("(?:data_key|dataKey|img_key|imgKey)"\s*:\s*")([^"]+)(")/gi, '$1********$3')
.replace(/("(?:APIKey|apiKey|api_key)"\s*:\s*")([^"]+)(")/g, '$1sk-****$3')
.replace(/\b(data_key|dataKey|DataKey)(\s*[:=]\s*)([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})/g, '$1$2$3****$4')
.replace(/\b(img_key|imgKey|ImgKey)(\s*[:=]\s*)([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})/g, '$1$2$3****$4')
.replace(/Data\s*Key:\s*\[?([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})\]?/gi, 'Data Key: [$1****$2]')
.replace(/Image\s*Key:\s*\[?([a-fA-F0-9]{8})[a-fA-F0-9]{48}([a-fA-F0-9]{8})\]?/gi, 'Image Key: [$1****$2]')
.replace(/sk-[A-Za-z0-9_\-]{8,}/g, 'sk-****');
}
function normalizePathForCompare(value) {
return String(value || '')
.trim()
.replace(/[\\/]+$/, '')
.replace(/\//g, '\\')
.toLowerCase();
}
function parseDetectedDataDir(text) {
const matches = [...stripAnsi(text).matchAll(/DataDir\s*[:]\s*([A-Za-z]:\\[^\r\n]+)/gi)];
if (matches.length === 0) return '';
return matches[matches.length - 1][1].trim();
}
function extractKeyFromOutput(text, label) {
const regex = new RegExp(`${label}\\s*Key\\s*:\\s*\\[?([a-fA-F0-9]{64})\\]?`, 'gi');
const matches = [...stripAnsi(text).matchAll(regex)];
if (matches.length === 0) return '';
return matches[matches.length - 1][1];
}
function hasRunningServices() {
return Object.values(processes).some(Boolean);
}
function createWindow() {
const icon = appIconPath();
mainWindow = new BrowserWindow({
width: 1360,
height: 860,
minWidth: 1180,
minHeight: 760,
...(icon ? { icon } : {}),
title: 'ChatLab 售后智能助手',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
},
});
mainWindow.loadFile('index.html');
isInViewMode = false;
mainWindow.setMenuBarVisibility(false);
mainWindow.on('close', async (e) => {
if (isQuitting) return;
e.preventDefault();
if (isInViewMode) {
isInViewMode = false;
try {
if (mainWindow.isMaximized()) mainWindow.unmaximize();
mainWindow.setSize(1360, 860);
mainWindow.center();
await mainWindow.loadFile('index.html');
} catch (err) {
log('App(Error)', `Failed to return to launcher: ${err.message}`);
}
return;
}
if (hasRunningServices()) {
send('show-close-warning');
return;
}
isQuitting = true;
app.quit();
});
}
function waitForClose(proc, timeoutMs = 5000) {
return new Promise((resolve) => {
if (!proc) return resolve();
let done = false;
const timer = setTimeout(() => {
if (!done) resolve();
}, timeoutMs);
proc.once('close', () => {
done = true;
clearTimeout(timer);
resolve();
});
});
}
function killProcessTree(proc) {
if (!proc) return;
try {
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', String(proc.pid), '/f', '/t'], { windowsHide: true });
} else {
proc.kill('SIGTERM');
}
} catch (e) {
console.error('Failed to kill process tree:', e);
}
}
async function stopProcess(key) {
const proc = processes[key];
if (!proc) return;
killProcessTree(proc);
await waitForClose(proc);
processes[key] = null;
send('process-stopped', key);
}
async function cleanupProcesses() {
stopDecryptPolling();
await Promise.all(Object.keys(processes).map((key) => stopProcess(key)));
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = address.port;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}
function httpJson(url, timeoutMs = 5000, method = 'GET') {
return new Promise((resolve, reject) => {
const req = http.request(url, { method, timeout: timeoutMs }, (res) => {
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
try {
resolve({ statusCode: res.statusCode, data: body ? JSON.parse(body) : {} });
} catch {
resolve({ statusCode: res.statusCode, data: body });
}
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy(new Error('request timeout'));
});
req.end();
});
}
function postJson(url, payload = {}, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const body = JSON.stringify(payload);
const req = http.request(url, {
method: 'POST',
timeout: timeoutMs,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
}, (res) => {
let responseBody = '';
res.on('data', (chunk) => { responseBody += chunk; });
res.on('end', () => {
try {
resolve({ statusCode: res.statusCode, data: responseBody ? JSON.parse(responseBody) : {} });
} catch {
resolve({ statusCode: res.statusCode, data: responseBody });
}
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy(new Error('request timeout'));
});
req.write(body);
req.end();
});
}
function buildUrl(base, params = {}) {
const url = new URL(base);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForHealth(timeoutMs = 30000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const res = await httpJson(`${backendUrl}/health`, 3000);
if (res.statusCode === 200 && res.data && res.data.ok) {
return res.data;
}
} catch {}
await new Promise((r) => setTimeout(r, 800));
}
throw new Error('后端服务启动超时');
}
async function waitForChatlogReady(timeoutMs = 120000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
let ready = false;
try {
const res = await httpJson('http://127.0.0.1:5030/api/v1/chatroom?format=json&limit=1', 3000);
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
ready = res.statusCode === 200 && !body.includes('decrypting');
} catch {}
if (ready) {
await sleep(1200);
const validation = await validateChatlogMessageQuery({ repair: true });
if (
validation.pending ||
malformedRepairInProgress ||
malformedRepairTimer ||
messageQueryRestartInProgress
) {
await sleep(800);
continue;
}
if (!validation.ok) {
throw new Error(validation.message || '消息索引未就绪,请回到启动界面点击“重新识别当前微信账号”。');
}
send('decrypt-ready');
try {
if (backendUrl) await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
} catch {}
return validation;
}
const elapsed = Math.floor((Date.now() - started) / 1000);
send('decrypt-status', { elapsed });
await sleep(1500);
}
throw new Error('等待微信数据解密超时,请确认 PC 微信已登录,并在微信里点击聊天窗口翻看历史消息。');
}
function responseBodyText(data) {
return typeof data === 'string' ? data : JSON.stringify(data || {});
}
function isTimeRangeNotFoundResponse(response) {
return response
&& response.statusCode === 404
&& /time range not found/i.test(responseBodyText(response.data));
}
function extractItems(data) {
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.items)) return data.items;
if (data && Array.isArray(data.data)) return data.data;
return [];
}
function getSessionTalker(item) {
return String(item.userName || item.UserName || item.name || item.Name || '');
}
function parseSessionTime(item) {
const value = item.nTime
|| item.NTime
|| item.time
|| item.Time
|| item.updateTime
|| item.UpdateTime
|| item.lastTime
|| item.LastTime
|| '';
if (!value) return 0;
if (typeof value === 'number' || /^\d+$/.test(String(value))) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return 0;
return n > 1e12 ? n : n * 1000;
}
const parsed = Date.parse(String(value).replace(' ', 'T'));
return Number.isFinite(parsed) ? parsed : 0;
}
function formatDateParam(date) {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function buildValidationTimeRange(session) {
const ts = parseSessionTime(session || {});
if (!ts) return '1970-01-01,2099-12-31';
const start = new Date(ts);
start.setHours(0, 0, 0, 0);
start.setDate(start.getDate() - 1);
const end = new Date(ts);
end.setHours(0, 0, 0, 0);
end.setDate(end.getDate() + 1);
return `${formatDateParam(start)},${formatDateParam(end)}`;
}
function buildRecentValidationTimeRange(days) {
const start = new Date();
start.setHours(0, 0, 0, 0);
start.setDate(start.getDate() - days);
const end = new Date();
end.setHours(0, 0, 0, 0);
end.setDate(end.getDate() + 1);
return `${formatDateParam(start)},${formatDateParam(end)}`;
}
async function waitForChatlogHttpReady(timeoutMs = 120000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const res = await httpJson('http://127.0.0.1:5030/api/v1/chatroom?format=json&limit=1', 3000);
const body = responseBodyText(res.data);
if (res.statusCode === 200 && !body.includes('decrypting')) return true;
} catch {}
const elapsed = Math.floor((Date.now() - started) / 1000);
send('decrypt-status', { elapsed });
await sleep(1500);
}
throw new Error('等待底层服务重建消息索引超时,请确认 PC 微信已登录,并在微信里打开聊天窗口翻看历史消息。');
}
async function checkChatlogMessageQuery() {
await sleep(1500);
const sessions = await httpJson(buildUrl('http://127.0.0.1:5030/api/v1/session', {
limit: 50,
format: 'json',
}), 5000);
if (sessions.statusCode !== 200) {
return {
ok: false,
pending: true,
message: `chatlog session HTTP ${sessions.statusCode}`,
};
}
const chatrooms = extractItems(sessions.data)
.filter((item) => getSessionTalker(item).endsWith('@chatroom'))
.sort((a, b) => parseSessionTime(b) - parseSessionTime(a));
if (chatrooms.length === 0) {
return {
ok: false,
pending: true,
message: '尚未读取到最近群聊会话,等待 chatlog 自动解密消息库。',
};
}
const ranges = [
buildRecentValidationTimeRange(7),
buildRecentValidationTimeRange(30),
];
let lastStatus = '';
for (const session of chatrooms.slice(0, 10)) {
const talker = getSessionTalker(session);
if (!talker) continue;
for (const time of ranges) {
const res = await httpJson(buildUrl('http://127.0.0.1:5030/api/v1/chatlog', {
talker,
limit: 20,
time,
format: 'json',
}), 5000);
if (res.statusCode === 200) {
return {
ok: true,
messageIndexReady: true,
talker,
time,
sampleCount: extractItems(res.data).length,
chatroomCount: chatrooms.length,
};
}
lastStatus = `talker=${talker} time=${time} HTTP ${res.statusCode}: ${responseBodyText(res.data)}`;
if (!isTimeRangeNotFoundResponse(res) && res.statusCode !== 404) {
return {
ok: false,
pending: true,
message: `消息查询暂未就绪:${lastStatus}`,
};
}
}
}
return {
ok: false,
pending: true,
timeRangeNotFound: true,
message: `最近 ${Math.min(chatrooms.length, 10)} 个群聊消息仍返回 404等待 chatlog 自动解密完成。${lastStatus}`,
chatroomCount: chatrooms.length,
};
}
async function validateChatlogMessageQuery({ repair = true } = {}) {
if (startupValidationInProgress || messageQueryRestartInProgress) {
return { ok: false, pending: true };
}
startupValidationInProgress = true;
try {
const result = await checkChatlogMessageQuery();
if (result.ok || !result.timeRangeNotFound) return result;
log(
'Chatlog(Validate)',
`${result.message || '消息抽样查询返回 time range not found。'} 会话接口已就绪,本次不再把它当作账号识别失败;如具体聊天记录为空,再在聊天页提示处理。`
);
return {
...result,
ok: true,
pending: false,
messageIndexReady: false,
message: 'chatlog 会话接口已就绪;部分聊天记录索引暂不可用,进入系统后在聊天页按具体群聊提示处理。',
};
} catch (e) {
log('Chatlog(Validate)', `消息查询自检未完成: ${e.message || e}`);
return { ok: false, pending: true, error: e.message || String(e) };
} finally {
startupValidationInProgress = false;
}
}
async function repairChatlogMessageIndex(validationResult) {
const config = activeChatlogConfig ? { ...activeChatlogConfig } : null;
const fingerprint = lastAccountFingerprint || (config ? fingerprintConfig(config) : '');
const message = validationResult.message || '消息索引未就绪chatlog 返回 time range not found。';
if (!config || !fingerprint) {
log('Chatlog(Validate)', `${message} 当前账号配置为空,无法自动重建消息缓存。`);
return {
...validationResult,
ok: false,
message: '消息索引未就绪,且当前账号配置为空。请回到启动界面点击“重新识别当前微信账号”。',
};
}
if (messageQueryRestartAttempts.has(fingerprint)) {
const failedMessage = '当前账号消息索引仍未就绪,已自动重建过一次。本次不会进入系统;请在启动界面点击“重新识别当前微信账号”后重试。';
log('Chatlog(Validate)', failedMessage);
send('process-error', { key: 'chatlog', message: failedMessage });
return {
...validationResult,
ok: false,
message: failedMessage,
removedMessageCache: 0,
};
}
messageQueryRestartAttempts.add(fingerprint);
messageQueryRestartInProgress = true;
let removed = [];
try {
log('Chatlog(Validate)', message);
log('Chatlog(Validate)', '正在清理当前账号生成的消息缓存并重启底层服务;不会删除微信原始数据。');
await stopProcess('chatlog');
removed = removeGeneratedMessageCache(config.workDir);
if (removed.length > 0) {
log('Chatlog(Repair)', `已清理 ${removed.length} 个当前账号消息缓存目录/文件,接下来会自动重建。`);
} else {
log('Chatlog(Repair)', '未找到可清理的当前账号消息缓存,继续重新识别并等待 chatlog 重建索引。');
}
send('decrypt-reset');
send('show-decrypt-dialog');
await startChatlog({ forceRefresh: false, startPolling: false });
await waitForChatlogHttpReady(120000);
const retry = await checkChatlogMessageQuery();
if (retry.ok) {
messageQueryRestartAttempts.delete(fingerprint);
return {
...retry,
repaired: true,
removedMessageCache: removed.length,
};
}
const failedMessage = '消息索引重建后仍返回 time range not found。请在启动界面点击“重新识别当前微信账号”并确认微信里已打开聊天窗口、能翻看到历史消息。';
log('Chatlog(Validate)', failedMessage);
send('process-error', { key: 'chatlog', message: failedMessage });
return {
...retry,
ok: false,
message: failedMessage,
removedMessageCache: removed.length,
};
} catch (e) {
const failedMessage = `重建底层消息索引失败: ${e.message || e}`;
log('Chatlog(Validate)', failedMessage);
send('process-error', { key: 'chatlog', message: failedMessage });
return {
...validationResult,
ok: false,
message: failedMessage,
removedMessageCache: removed.length,
};
} finally {
messageQueryRestartInProgress = false;
}
}
async function startBackend() {
if (processes.fastapi) return;
backendPort = backendPort || await getFreePort();
backendUrl = `http://127.0.0.1:${backendPort}`;
const env = {
...process.env,
CHATLAB_DATA_DIR: appDataDir(),
CHATLAB_STATIC_DIR: frontendDistDir(),
CHATLAB_BACKEND_PORT: String(backendPort),
};
let command;
let args;
let cwd;
let shell = false;
if (isPackaged()) {
command = backendExePath();
args = [];
cwd = path.dirname(command);
} else {
command = 'python';
args = ['run_backend.py'];
cwd = backendSourceDir();
shell = true;
}
log('FastAPI', `启动业务后端: ${isPackaged() ? command : `${command} ${args.join(' ')}`} (port=${backendPort})`);
const proc = spawn(command, args, { cwd, env, shell, windowsHide: true });
processes.fastapi = proc;
proc.stdout.on('data', (data) => log('FastAPI', data.toString()));
proc.stderr.on('data', (data) => log('FastAPI', data.toString()));
proc.on('close', (code) => {
processes.fastapi = null;
send('process-stopped', 'fastapi');
log('FastAPI', `进程退出,退出码 ${code}`);
});
send('process-started', 'fastapi');
await waitForHealth(45000);
}
function getWeChatProcesses() {
return new Promise((resolve) => {
if (process.platform !== 'win32') return resolve([]);
const ps = spawn('powershell.exe', [
'-NoProfile',
'-Command',
"Get-CimInstance Win32_Process | Where-Object { $_.Name -match '^(Weixin|WeChat)\\.exe$' } | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress",
], { windowsHide: true });
let output = '';
let errorOutput = '';
ps.stdout.on('data', (d) => { output += d.toString(); });
ps.stderr.on('data', (d) => { errorOutput += d.toString(); });
ps.on('close', () => {
try {
const parsed = JSON.parse(output || '[]');
const list = (Array.isArray(parsed) ? parsed : [parsed])
.map((item) => ({
pid: Number(item.ProcessId),
name: String(item.Name || ''),
commandLine: String(item.CommandLine || ''),
}))
.filter((item) => item.pid);
resolve(list);
} catch {
const ids = output.split(/\s+/).map((x) => Number(x)).filter(Boolean);
resolve(ids.map((pid) => ({ pid, name: '', commandLine: '' })));
}
});
ps.on('error', () => {
if (errorOutput) log('Chatlog(Init)', `读取微信进程失败: ${errorOutput.trim()}`);
resolve([]);
});
});
}
function selectMainWeChatProcess(processes) {
const candidates = processes
.filter((item) => /^(Weixin|WeChat)\.exe$/i.test(item.name))
.filter((item) => !/\s--type=/i.test(item.commandLine));
return candidates.find((item) => /\s--scene=desktop\b/i.test(item.commandLine))
|| candidates.find((item) => /Weixin\.exe/i.test(item.name))
|| candidates[0]
|| null;
}
function runChatlogKey(args, label) {
return new Promise((resolve, reject) => {
let exePath;
try {
exePath = ensureChatlogExe();
} catch (e) {
reject(e);
return;
}
const keyProcess = spawn(exePath, args, {
cwd: projectRoot(),
windowsHide: true,
});
let extractedDataKey = '';
let extractedImgKey = '';
let detectedDataDir = '';
let rawOutput = '';
const handleOutput = (data) => {
const output = data.toString();
rawOutput += output;
log('Chatlog(Init)', output);
extractedDataKey = extractKeyFromOutput(rawOutput, 'Data') || extractedDataKey;
extractedImgKey = extractKeyFromOutput(rawOutput, 'Image') || extractedImgKey;
detectedDataDir = parseDetectedDataDir(rawOutput) || detectedDataDir;
};
keyProcess.stdout.on('data', handleOutput);
keyProcess.stderr.on('data', handleOutput);
keyProcess.on('error', reject);
keyProcess.on('close', (code) => {
const partial = !extractedDataKey && /Data\s*Key\s*:\s*\[\s*\]/i.test(stripAnsi(rawOutput));
if (code !== 0 && !extractedDataKey && !extractedImgKey && !detectedDataDir) {
reject(new Error(`${label} 未能识别当前微信账号,请确认微信已登录,必要时用管理员权限启动。退出码 ${code}${sanitizeLog(rawOutput).slice(-500)}`));
return;
}
resolve({
dataKey: extractedDataKey,
imgKey: extractedImgKey,
detectedDataDir,
rawOutput,
partial,
exitCode: code,
label,
});
});
});
}
function runChatlogDecrypt(config, dataKey) {
return new Promise((resolve) => {
if (!config || !dataKey || !config.workDir || !config.dataDir) {
resolve(false);
return;
}
const args = ['decrypt', '-k', dataKey];
args.push('-w', config.workDir);
args.push('-d', config.dataDir);
args.push('-p', config.platform || 'windows');
args.push('-v', String(config.version || 4));
log('Chatlog(Init)', '正在同步解密消息与语音媒体库...');
const proc = spawn(chatlogExePath(), args, { cwd: projectRoot(), windowsHide: true });
let output = '';
const handleOutput = (data) => {
output += data.toString();
};
proc.stdout.on('data', handleOutput);
proc.stderr.on('data', handleOutput);
proc.on('error', (e) => {
log('Chatlog(Init)', `同步解密启动失败: ${e.message || e}`);
resolve(false);
});
proc.on('close', (code) => {
const text = sanitizeLog(output).trim();
if (code === 0) {
log('Chatlog(Init)', text || '同步解密完成');
resolve(true);
} else {
log('Chatlog(Init)', `同步解密失败,后续继续启动自动解密服务。退出码 ${code}${text.slice(-500)}`);
resolve(false);
}
});
});
}
async function runChatlogKeyForce(mainWeChatProcess = null) {
const attempts = [];
if (mainWeChatProcess?.pid) {
attempts.push({
label: `key --force --pid ${mainWeChatProcess.pid}`,
args: ['key', '--force', '--pid', String(mainWeChatProcess.pid)],
});
}
attempts.push({ label: 'key --force', args: ['key', '--force'] });
let best = null;
let lastError = null;
for (const attempt of attempts) {
log('Chatlog(Init)', `执行 ${attempt.label}`);
try {
const result = await runChatlogKey(attempt.args, attempt.label);
best = mergeKeyResults(best, result);
if (best.dataKey) return best;
if (attempts.length > 1) {
log('Chatlog(Init)', `${attempt.label} 未输出 Data Key继续尝试备用识别方式。`);
}
} catch (e) {
lastError = e;
log('Chatlog(Init)', `${attempt.label} 失败: ${e.message}`);
}
}
if (best) return best;
throw lastError || new Error('未能识别当前微信账号。');
}
function getChatlogVersionText() {
return new Promise((resolve) => {
const proc = spawn(chatlogExePath(), ['version'], { cwd: projectRoot(), windowsHide: true });
let output = '';
proc.stdout.on('data', (data) => { output += data.toString(); });
proc.stderr.on('data', (data) => { output += data.toString(); });
proc.on('error', () => resolve(''));
proc.on('close', () => resolve(sanitizeLog(output).trim()));
});
}
async function syncChatlogContextToBackend(config) {
if (!backendUrl || !config) return;
const payload = {
account: config.account || '',
workDir: config.workDir || '',
dataDir: config.dataDir || '',
platform: config.platform || 'windows',
version: config.version || 4,
chatlogExe: chatlogExePath(),
chatlogVersion: await getChatlogVersionText(),
};
try {
await postJson(`${backendUrl}/api/system/chatlog-context`, payload, 5000);
} catch (e) {
log('Chatlog(Init)', `同步 chatlog 诊断上下文失败: ${e.message || e}`);
}
}
function mergeKeyResults(prev, next) {
if (!prev) return next;
return {
...next,
dataKey: next.dataKey || prev.dataKey || '',
imgKey: next.imgKey || prev.imgKey || '',
detectedDataDir: next.detectedDataDir || prev.detectedDataDir || '',
rawOutput: `${prev.rawOutput || ''}\n${next.rawOutput || ''}`,
partial: Boolean(next.partial || prev.partial),
};
}
function singleMatchOrThrow(matches, matchedBy) {
if (matches.length <= 1) return matches[0] || null;
throw new Error(`chatlog 配置中有多个账号匹配同一个 ${matchedBy},为避免打开错误账号,请点击“重新识别当前微信账号”后重试。`);
}
function readCurrentChatlogConfig(hints = {}) {
const configPath = path.join(os.homedir(), '.chatlog', 'chatlog.json');
if (!fs.existsSync(configPath)) {
throw new Error('未找到 chatlog 配置文件,请确认当前微信账号已完成密钥识别');
}
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const history = Array.isArray(config.history) ? config.history : [];
const lastAccount = config.last_account || '';
const detectedDataDir = normalizePathForCompare(hints.detectedDataDir);
const imgKey = String(hints.imgKey || '').trim().toLowerCase();
const dataKey = String(hints.dataKey || '').trim().toLowerCase();
const hasOnlyPartialKey = !dataKey && Boolean(detectedDataDir || imgKey || hints.partial);
let matched = null;
let matchedBy = '';
if (detectedDataDir) {
matched = history.find((item) => normalizePathForCompare(item.data_dir) === detectedDataDir) || null;
if (matched) matchedBy = 'data_dir';
}
if (!matched && imgKey) {
const matches = history.filter((item) => String(item.img_key || '').trim().toLowerCase() === imgKey);
matched = singleMatchOrThrow(matches, 'img_key');
if (matched) matchedBy = 'img_key';
}
if (!matched && dataKey) {
const matches = history.filter((item) => String(item.data_key || '').trim().toLowerCase() === dataKey);
matched = singleMatchOrThrow(matches, 'data_key');
if (matched) matchedBy = 'data_key';
}
if (!matched) {
if (detectedDataDir || hasOnlyPartialKey) {
throw new Error('本次微信识别结果未能匹配 chatlog 配置中的账号。为避免启动旧账号数据,请在微信中打开聊天窗口并点击“重新识别当前微信账号”。');
}
matched = history.find((item) => item.account === lastAccount) || history[history.length - 1];
if (matched) matchedBy = matched.account === lastAccount ? 'last_account' : 'history_tail';
}
if (!matched) {
throw new Error('chatlog 配置中没有账号历史记录');
}
if (detectedDataDir && ['last_account', 'history_tail'].includes(matchedBy) && normalizePathForCompare(matched.data_dir) !== detectedDataDir) {
throw new Error('本次微信识别到的数据目录与 chatlog 配置中的账号不一致。为避免显示旧账号记录,请在微信中打开聊天窗口并点击“重新识别当前微信账号”。');
}
return {
account: matched.account || lastAccount || '',
workDir: matched.work_dir || '',
dataDir: matched.data_dir || '',
platform: matched.platform || 'windows',
version: matched.version || 4,
imgKey: matched.img_key || '',
dataKey: matched.data_key || '',
matchedBy,
lastAccount,
};
}
function hasDecryptedDb(workDir) {
try {
if (!workDir || !fs.existsSync(workDir)) return false;
const root = path.resolve(workDir);
const stack = [{ dir: root, depth: 0 }];
let visited = 0;
while (stack.length > 0 && visited < 2000) {
const current = stack.pop();
visited += 1;
for (const entry of fs.readdirSync(current.dir, { withFileTypes: true })) {
const fullPath = path.join(current.dir, entry.name);
if (entry.isFile() && entry.name.toLowerCase().endsWith('.db')) {
return true;
}
if (entry.isDirectory() && current.depth < 5) {
stack.push({ dir: fullPath, depth: current.depth + 1 });
}
}
}
return false;
} catch {
return false;
}
}
function pathStartsWith(childPath, parentPath) {
const child = path.resolve(childPath).toLowerCase();
const parent = path.resolve(parentPath).toLowerCase();
return child === parent || child.startsWith(`${parent}${path.sep}`);
}
function collectMalformedDbPaths(text) {
const clean = stripAnsi(text);
if (!/database disk image is malformed/i.test(clean)) return [];
return [...clean.matchAll(/([A-Za-z]:\\[^\r\n"]+?\.db)(?=[\s"']|$|[^\r\n]*database disk image is malformed)/gi)]
.map((match) => match[1].trim())
.filter(Boolean);
}
function removeGeneratedDbFiles(dbPaths, workDir) {
const removed = [];
const generatedDirs = new Set();
const candidates = new Set();
const normalizedWorkDir = normalizePathForCompare(workDir);
const rawWeChatMarkers = ['\\xwechat_files\\', '\\wechat files\\'];
for (const dbPath of dbPaths) {
if (!dbPath || !workDir || !pathStartsWith(dbPath, workDir)) continue;
const normalizedDbPath = normalizePathForCompare(dbPath);
if (rawWeChatMarkers.some((marker) => normalizedDbPath.includes(marker))) continue;
if (rawWeChatMarkers.some((marker) => normalizedWorkDir.includes(marker))) continue;
const parentDir = path.dirname(dbPath);
if (normalizePathForCompare(parentDir).endsWith('\\db_storage\\message')) {
generatedDirs.add(parentDir);
}
candidates.add(dbPath);
candidates.add(`${dbPath}-wal`);
candidates.add(`${dbPath}-shm`);
}
for (const dirPath of generatedDirs) {
try {
if (!pathStartsWith(dirPath, workDir)) continue;
if (fs.existsSync(dirPath)) {
fs.rmSync(dirPath, { recursive: true, force: true });
removed.push(dirPath);
}
} catch (e) {
log('Chatlog(Repair)', `清理损坏解密缓存目录失败: ${dirPath} (${e.message})`);
}
}
for (const filePath of candidates) {
try {
if (!pathStartsWith(filePath, workDir)) continue;
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { force: true });
removed.push(filePath);
}
} catch (e) {
log('Chatlog(Repair)', `清理损坏解密缓存失败: ${filePath} (${e.message})`);
}
}
return removed;
}
function removeGeneratedMessageCache(workDir) {
if (!workDir) return [];
const normalizedWorkDir = normalizePathForCompare(workDir);
const rawWeChatMarkers = ['\\xwechat_files\\', '\\wechat files\\'];
if (rawWeChatMarkers.some((marker) => normalizedWorkDir.includes(marker))) return [];
const messageDir = path.join(workDir, 'db_storage', 'message');
try {
if (!pathStartsWith(messageDir, workDir) || !fs.existsSync(messageDir)) return [];
fs.rmSync(messageDir, { recursive: true, force: true });
return [messageDir];
} catch (e) {
log('Chatlog(Repair)', `清理解密消息缓存目录失败: ${messageDir} (${e.message})`);
return [];
}
}
function scheduleMalformedCacheRepair(text) {
const paths = collectMalformedDbPaths(text);
if (paths.length === 0) return;
for (const dbPath of paths) pendingMalformedDbPaths.add(dbPath);
if (malformedRepairTimer || malformedRepairInProgress) return;
malformedRepairTimer = setTimeout(() => {
malformedRepairTimer = null;
repairMalformedCacheAndRestart().catch((e) => {
malformedRepairInProgress = false;
log('Chatlog(Repair)', e.message || String(e));
});
}, 800);
}
async function repairMalformedCacheAndRestart() {
if (malformedRepairInProgress) return;
const config = activeChatlogConfig ? { ...activeChatlogConfig } : null;
const fingerprint = lastAccountFingerprint || (config ? fingerprintConfig(config) : '');
const dbPaths = [...pendingMalformedDbPaths];
pendingMalformedDbPaths.clear();
if (!config || !config.workDir || dbPaths.length === 0) return;
if (malformedRepairAttempts.has(fingerprint)) {
if (!malformedRepairNotified.has(fingerprint)) {
malformedRepairNotified.add(fingerprint);
log('Chatlog(Repair)', '当前账号解密缓存再次损坏,已自动修复过一次。请在微信打开聊天窗口并翻看历史消息后,点击“重新识别当前微信账号”;必要时用管理员权限启动。');
send('show-decrypt-dialog');
}
return;
}
malformedRepairInProgress = true;
malformedRepairAttempts.add(fingerprint);
try {
log('Chatlog(Repair)', '检测到当前账号的解密缓存库损坏,正在自动清理本账号生成缓存并重新解密。');
await stopProcess('chatlog');
const removed = removeGeneratedDbFiles(dbPaths, config.workDir);
if (removed.length > 0) {
log('Chatlog(Repair)', `已清理 ${removed.length} 个损坏缓存文件;不会删除微信原始数据。`);
} else {
log('Chatlog(Repair)', '未找到可安全清理的缓存文件,已停止避免误删微信原始数据。');
}
send('decrypt-reset');
send('show-decrypt-dialog');
await startChatlog({ forceRefresh: true });
try {
await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
} catch {}
} finally {
malformedRepairInProgress = false;
}
}
function fingerprintConfig(config) {
return `${config.account || ''}|${config.workDir || ''}|${config.dataDir || ''}`;
}
function startDecryptPolling() {
if (decryptPollTimer) clearInterval(decryptPollTimer);
const startTime = Date.now();
let validationTriggered = false;
let tickInProgress = false;
decryptPollTimer = setInterval(async () => {
if (tickInProgress) return;
tickInProgress = true;
try {
const res = await httpJson('http://127.0.0.1:5030/api/v1/chatroom?format=json&limit=1', 3000);
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
if (res.statusCode === 200 && !body.includes('decrypting')) {
await new Promise((r) => setTimeout(r, 1200));
let validation = { ok: true };
if (!validationTriggered) {
validationTriggered = true;
validation = await validateChatlogMessageQuery({ repair: true });
}
if (validation.pending) {
validationTriggered = false;
const elapsed = Math.floor((Date.now() - startTime) / 1000);
send('decrypt-status', { elapsed });
return;
}
if (malformedRepairInProgress || malformedRepairTimer || messageQueryRestartInProgress) {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
send('decrypt-status', { elapsed });
return;
}
if (!validation.ok) {
stopDecryptPolling();
send('process-error', {
key: 'chatlog',
message: validation.message || '消息索引未就绪,请点击“重新识别当前微信账号”后重试。',
});
return;
}
stopDecryptPolling();
send('decrypt-ready');
try {
if (backendUrl) await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
} catch {}
return;
}
const elapsed = Math.floor((Date.now() - startTime) / 1000);
send('decrypt-status', { elapsed });
} catch {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
send('decrypt-status', { elapsed });
} finally {
tickInProgress = false;
}
}, 2000);
}
function stopDecryptPolling() {
if (decryptPollTimer) {
clearInterval(decryptPollTimer);
decryptPollTimer = null;
}
}
async function startChatlog({ forceRefresh = false, startPolling = true } = {}) {
if (processes.chatlog) return;
const wechatPids = await getWeChatProcesses();
if (wechatPids.length === 0) {
throw new Error('未检测到 PC 微信进程,请先登录 PC 微信后再启动。');
}
let keys = {};
if (forceRefresh) {
const mainWeChatProcess = selectMainWeChatProcess(wechatPids);
if (mainWeChatProcess) {
log('Chatlog(Init)', `已选择微信主进程用于 Data Key 识别: ${mainWeChatProcess.name || 'WeChat'} pid=${mainWeChatProcess.pid}`);
} else {
log('Chatlog(Init)', '未能确认微信主进程,将使用 chatlog 默认识别方式。');
}
log('Chatlog(Init)', '正在强制重新识别当前微信账号...');
keys = await runChatlogKeyForce(mainWeChatProcess);
} else {
log('Chatlog(Init)', '使用当前账号配置启动自动解密服务;如需切换账号,请点击“重新识别当前微信账号”。');
}
const config = readCurrentChatlogConfig(keys);
const effectiveDataKey = keys.dataKey || config.dataKey || '';
const effectiveImgKey = keys.imgKey || config.imgKey || '';
activeChatlogConfig = config;
await syncChatlogContextToBackend(config);
if (!effectiveDataKey) {
send('show-decrypt-dialog');
throw new Error('未能获取当前微信账号 Data Key且 chatlog 配置中没有同账号缓存密钥。请确认微信已登录,打开任意聊天窗口并向上翻看历史消息,必要时用管理员权限启动后点击“重新识别当前微信账号”。');
}
await runChatlogDecrypt(config, effectiveDataKey);
if (!keys.dataKey && config.dataKey) {
log('Chatlog(Init)', `本次 key --force 未输出 Data Key已使用当前账号配置缓存的 Data Key 启动自动解密服务(匹配方式: ${config.matchedBy})。后续消息库解密交给 chatlog server --auto-decrypt 持续处理。`);
}
if (keys.detectedDataDir && config.matchedBy === 'data_dir') {
log('Chatlog(Init)', '已按本次识别到的微信数据目录匹配当前账号。');
} else if (config.matchedBy) {
log('Chatlog(Init)', `已匹配当前账号配置(匹配方式: ${config.matchedBy})。`);
}
lastAccountFingerprint = fingerprintConfig(config);
if (!hasDecryptedDb(config.workDir)) {
log('Chatlog(Init)', '检测到当前账号尚未完成解密,将显示首次解密引导。');
send('show-decrypt-dialog');
} else {
log('Chatlog(Init)', `当前账号已识别,工作目录已就绪: ${config.account || '(unknown account)'}`);
}
const args = ['server', '--auto-decrypt', '-k', effectiveDataKey];
if (config.workDir) args.push('-w', config.workDir);
if (config.dataDir) args.push('-d', config.dataDir);
args.push('-p', config.platform || 'windows');
args.push('-v', String(config.version || 4));
if (effectiveImgKey) args.push('-i', effectiveImgKey);
log('Chatlog(Init)', `使用已匹配账号参数启动底层服务(匹配方式: ${config.matchedBy || 'unknown'})。`);
log('Chatlog(Init)', `启动底层服务,账号指纹: ${maskFingerprint(lastAccountFingerprint)}`);
const proc = spawn(chatlogExePath(), args, { cwd: projectRoot(), windowsHide: true });
processes.chatlog = proc;
const handleServerOutput = (data) => {
const text = data.toString();
log('Chatlog(Go)', text);
scheduleMalformedCacheRepair(text);
};
proc.stdout.on('data', handleServerOutput);
proc.stderr.on('data', handleServerOutput);
proc.on('close', (code) => {
processes.chatlog = null;
stopDecryptPolling();
send('process-stopped', 'chatlog');
if (!malformedRepairInProgress) send('decrypt-reset');
log('Chatlog(Go)', `进程退出,退出码 ${code}`);
});
send('process-started', 'chatlog');
if (startPolling) startDecryptPolling();
}
function maskFingerprint(value) {
if (!value) return '';
return value.length <= 16 ? value : `${value.slice(0, 8)}...${value.slice(-8)}`;
}
async function openAppWindow() {
await startBackend();
if (!processes.chatlog) {
await startChatlog({ forceRefresh: false, startPolling: false });
}
await waitForChatlogReady();
await waitForHealth(30000);
if (!mainWindow.isMaximized()) mainWindow.maximize();
mainWindow.loadURL(backendUrl);
isInViewMode = true;
}
async function refreshCurrentAccount() {
const fingerprint = lastAccountFingerprint || (activeChatlogConfig ? fingerprintConfig(activeChatlogConfig) : '');
let removed = [];
await stopProcess('chatlog');
if (fingerprint) {
messageQueryRestartAttempts.delete(fingerprint);
malformedRepairAttempts.delete(fingerprint);
malformedRepairNotified.delete(fingerprint);
}
if (activeChatlogConfig?.workDir) {
removed = removeGeneratedMessageCache(activeChatlogConfig.workDir);
if (removed.length > 0) {
log('Chatlog(Repair)', '已清理当前账号的消息解密缓存,接下来会重新识别并自动重建消息索引。');
}
}
send('decrypt-reset');
await startChatlog({ forceRefresh: true, startPolling: false });
const validation = await waitForChatlogReady(120000);
try {
if (backendUrl) await httpJson(`${backendUrl}/api/system/refresh-account`, 5000, 'POST');
} catch {}
return {
ok: true,
fingerprint: lastAccountFingerprint,
messageIndexReady: Boolean(validation?.ok),
removedMessageCache: removed.length + (validation?.removedMessageCache || 0),
};
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('before-quit', () => {
cleanupProcesses();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
ipcMain.handle('start-all', async () => {
await openAppWindow();
return { ok: true, backendUrl };
});
ipcMain.on('start-chatlog', async (event) => {
try {
await startChatlog({ forceRefresh: false });
} catch (e) {
log('Chatlog(Error)', e.message);
event.sender.send('process-error', { key: 'chatlog', message: e.message });
}
});
ipcMain.on('start-fastapi', async (event) => {
try {
await startBackend();
} catch (e) {
log('FastAPI(Error)', e.message);
event.sender.send('process-error', { key: 'fastapi', message: e.message });
}
});
ipcMain.on('start-frontend', async (event) => {
try {
await openAppWindow();
} catch (e) {
log('App(Error)', e.message);
event.sender.send('process-error', { key: 'frontend', message: e.message });
}
});
ipcMain.on('stop-chatlog', () => stopProcess('chatlog'));
ipcMain.on('stop-fastapi', () => stopProcess('fastapi'));
ipcMain.on('stop-frontend', () => {});
ipcMain.handle('refresh-current-account', async () => refreshCurrentAccount());
ipcMain.handle('get-process-status', () => ({
chatlog: processes.chatlog !== null,
fastapi: processes.fastapi !== null,
frontend: isInViewMode,
backendUrl,
accountFingerprint: lastAccountFingerprint,
}));
ipcMain.on('open-in-app', async (event) => {
try {
await openAppWindow();
} catch (e) {
log('App(Error)', e.message);
event.sender.send('process-error', { key: 'app', message: e.message });
}
});