read-and-write.js 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024
  1. /**
  2. * 文件读写操作模块
  3. * 负责文件系统的读写操作,包括聊天记录的保存和读取
  4. */
  5. import { ipcMain } from 'electron';
  6. import { readdir, writeFile, readFile, mkdir, rm, stat, appendFile } from 'fs/promises';
  7. import { join, dirname, isAbsolute } from 'path';
  8. /**
  9. * 递归计算目录大小
  10. * @param {string} dirPath - 目录路径
  11. * @returns {Promise<number>} 目录总大小(字节)
  12. */
  13. async function calculateDirSize(dirPath) {
  14. let totalSize = 0;
  15. try {
  16. const entries = await readdir(dirPath, { withFileTypes: true });
  17. for (const entry of entries) {
  18. const entryPath = join(dirPath, entry.name);
  19. try {
  20. if (entry.isFile()) {
  21. const stats = await stat(entryPath);
  22. totalSize += stats.size;
  23. } else if (entry.isDirectory()) {
  24. totalSize += await calculateDirSize(entryPath);
  25. }
  26. } catch (e) {
  27. // 忽略无法访问的文件/目录
  28. }
  29. }
  30. } catch (e) {
  31. // 忽略无法读取的目录
  32. }
  33. return totalSize;
  34. }
  35. import { fileURLToPath } from 'url';
  36. import { captureScreenshot } from './adb/screenshot.js';
  37. import { getDeviceResolution } from './adb/device-info.js';
  38. import { extractChatHistory as extractChatHistoryFromFunc, getLastMessage as getLastMessageFromFunc, ocrFullScreen as ocrFullScreenFromFunc } from './func/ocr-chat.js';
  39. const __filename = fileURLToPath(import.meta.url);
  40. const __dirname = dirname(__filename);
  41. /**
  42. * 获取最新的聊天记录文件
  43. * @param {string} absoluteWorkflowPath - 工作流文件夹的绝对路径
  44. * @returns {Promise<Object|null>} 最新的文件信息,如果不存在则返回 null
  45. */
  46. async function getLatestHistoryFile(absoluteWorkflowPath) {
  47. try {
  48. const historyDir = join(absoluteWorkflowPath, 'history');
  49. const files = await readdir(historyDir, { withFileTypes: true });
  50. // 过滤出 JSON 文件(chat_*.json)
  51. const jsonFiles = files
  52. .filter(file => file.isFile() && file.name.startsWith('chat_') && file.name.endsWith('.json'))
  53. .map(file => ({
  54. name: file.name,
  55. path: join(historyDir, file.name)
  56. }));
  57. if (jsonFiles.length === 0) {
  58. return null;
  59. }
  60. // 获取所有文件的统计信息(修改时间)
  61. const filesWithStats = await Promise.all(
  62. jsonFiles.map(async (file) => {
  63. const stats = await stat(file.path);
  64. return {
  65. ...file,
  66. mtime: stats.mtime
  67. };
  68. })
  69. );
  70. // 按修改时间排序(最新的在前)
  71. filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
  72. // 返回最新的文件
  73. return filesWithStats[0];
  74. } catch (error) {
  75. // 如果目录不存在或读取失败,返回 null
  76. return null;
  77. }
  78. }
  79. /**
  80. * 将相对路径转换为绝对路径
  81. * @param {string} workflowFolderPath - 工作流文件夹路径(相对或绝对)
  82. * @returns {string} 绝对路径
  83. */
  84. function getAbsoluteWorkflowPath(workflowFolderPath) {
  85. if (isAbsolute(workflowFolderPath)) {
  86. return workflowFolderPath;
  87. }
  88. if (workflowFolderPath.startsWith('static/processing/')) {
  89. const folderName = workflowFolderPath.replace('static/processing/', '');
  90. return join(__dirname, '..', 'static', 'processing', folderName);
  91. } else if (workflowFolderPath.startsWith('static\\processing\\')) {
  92. const folderName = workflowFolderPath.replace('static\\processing\\', '');
  93. return join(__dirname, '..', 'static', 'processing', folderName);
  94. } else {
  95. return join(__dirname, '..', 'static', 'processing', workflowFolderPath);
  96. }
  97. }
  98. /**
  99. * 注册文件读写相关的 IPC handlers
  100. */
  101. export function registerIpcHandlers() {
  102. // 确保目录存在
  103. ipcMain.handle('ensure-directory', async (event, dirPath) => {
  104. try {
  105. await mkdir(dirPath, { recursive: true });
  106. return { success: true };
  107. } catch (error) {
  108. return { success: false, error: error.message };
  109. }
  110. });
  111. // 写入文本文件
  112. ipcMain.handle('write-text-file', async (event, filePath, content) => {
  113. try {
  114. // 如果 filePath 是绝对路径,直接使用
  115. // 否则,相对于项目根目录(__dirname 的父目录)
  116. let absoluteFilePath = filePath;
  117. if (!isAbsolute(filePath)) {
  118. // 相对路径,相对于项目根目录
  119. absoluteFilePath = join(__dirname, '..', filePath);
  120. }
  121. // 确保目录存在
  122. const dir = dirname(absoluteFilePath);
  123. await mkdir(dir, { recursive: true });
  124. await writeFile(absoluteFilePath, content, 'utf-8');
  125. return { success: true };
  126. } catch (error) {
  127. return { success: false, error: error.message };
  128. }
  129. });
  130. // 读取文本文件
  131. ipcMain.handle('read-text-file', async (event, filePath) => {
  132. try {
  133. // 如果 filePath 是绝对路径,直接使用
  134. // 否则,相对于项目根目录(__dirname 的父目录)
  135. let absoluteFilePath = filePath;
  136. if (!isAbsolute(filePath)) {
  137. // 相对路径,相对于项目根目录
  138. absoluteFilePath = join(__dirname, '..', filePath);
  139. }
  140. const content = await readFile(absoluteFilePath, 'utf-8');
  141. return { success: true, content };
  142. } catch (error) {
  143. // 如果文件不存在,返回空字符串而不是错误
  144. if (error.code === 'ENOENT') {
  145. return { success: true, content: '' };
  146. }
  147. return { success: false, error: error.message };
  148. }
  149. });
  150. // 读取图片文件为 base64
  151. ipcMain.handle('read-image-file-as-base64', async (event, filePath) => {
  152. try {
  153. // 如果 filePath 是绝对路径,直接使用
  154. // 否则,相对于项目根目录
  155. let absoluteFilePath = filePath;
  156. if (!isAbsolute(filePath)) {
  157. absoluteFilePath = join(__dirname, '..', filePath);
  158. }
  159. // 读取文件为 Buffer
  160. const fileBuffer = await readFile(absoluteFilePath);
  161. // 转换为 base64
  162. const base64 = fileBuffer.toString('base64');
  163. return { success: true, data: base64 };
  164. } catch (error) {
  165. return { success: false, error: error.message };
  166. }
  167. });
  168. // 保存 base64 图片到文件
  169. ipcMain.handle('save-base64-image', async (event, base64Data, savePath) => {
  170. try {
  171. // 处理保存路径
  172. let absoluteSavePath = savePath;
  173. if (!isAbsolute(savePath)) {
  174. // 如果是相对路径,相对于项目根目录
  175. if (savePath.startsWith('static/processing/')) {
  176. absoluteSavePath = join(__dirname, '..', savePath);
  177. } else {
  178. absoluteSavePath = join(__dirname, '..', savePath);
  179. }
  180. }
  181. // 确保目录存在
  182. const dir = dirname(absoluteSavePath);
  183. await mkdir(dir, { recursive: true });
  184. // 将 base64 转换为 Buffer 并保存
  185. const imageBuffer = Buffer.from(base64Data, 'base64');
  186. await writeFile(absoluteSavePath, imageBuffer);
  187. // 验证文件是否成功写入(检查文件大小)
  188. const { access, constants } = await import('fs/promises');
  189. await access(absoluteSavePath, constants.F_OK);
  190. const fileStats = await stat(absoluteSavePath);
  191. if (fileStats.size === 0) {
  192. return { success: false, error: '文件保存失败:文件大小为0' };
  193. }
  194. if (fileStats.size < 100) {
  195. // JPEG/PNG 文件至少应该有几百字节
  196. return { success: false, error: `文件保存失败:文件大小异常(${fileStats.size} 字节)` };
  197. }
  198. console.log(`[save-base64-image] 文件保存成功: ${absoluteSavePath}, 大小: ${fileStats.size} 字节`);
  199. return { success: true, fileSize: fileStats.size };
  200. } catch (error) {
  201. console.error(`[save-base64-image] 保存失败: ${savePath}`, error);
  202. return { success: false, error: error.message };
  203. }
  204. });
  205. // 追加日志到文件
  206. ipcMain.handle('append-log', async (event, workflowFolderPath, logMessage) => {
  207. try {
  208. const absoluteWorkflowPath = getAbsoluteWorkflowPath(workflowFolderPath);
  209. const logFilePath = join(absoluteWorkflowPath, 'log.txt');
  210. // 添加时间戳(精确到毫秒)
  211. const now = new Date();
  212. const year = now.getFullYear();
  213. const month = String(now.getMonth() + 1).padStart(2, '0');
  214. const day = String(now.getDate()).padStart(2, '0');
  215. const hour = String(now.getHours()).padStart(2, '0');
  216. const minute = String(now.getMinutes()).padStart(2, '0');
  217. const second = String(now.getSeconds()).padStart(2, '0');
  218. const ms = String(now.getMilliseconds()).padStart(3, '0');
  219. const timestamp = `${year}-${month}-${day} ${hour}:${minute}:${second}.${ms}`;
  220. const logLine = `[${timestamp}] ${logMessage}\n`;
  221. await appendFile(logFilePath, logLine, 'utf-8');
  222. return { success: true };
  223. } catch (error) {
  224. // 日志写入失败不应该影响主流程,只记录错误
  225. console.error('追加日志失败:', error);
  226. return { success: false, error: error.message };
  227. }
  228. });
  229. // 保存聊天记录到 chat-history.txt(追加模式,所有记录保存在一个文件)
  230. // 格式:{"data":"时间戳","friend":"消息","me":"消息"},允许重复键(虽然JSON不支持,但保存为文本格式)
  231. ipcMain.handle('save-chat-history-txt', async (event, workflowFolderPath, messages) => {
  232. try {
  233. const absoluteWorkflowPath = getAbsoluteWorkflowPath(workflowFolderPath);
  234. const historyDir = join(absoluteWorkflowPath, 'history');
  235. await mkdir(historyDir, { recursive: true });
  236. const chatHistoryPath = join(historyDir, 'chat-history.txt');
  237. // 读取现有文件(如果存在)
  238. // 由于JSON不支持重复键,我们需要从文本解析每一行
  239. let existingEntries = [];
  240. try {
  241. const existingContent = await readFile(chatHistoryPath, 'utf-8');
  242. if (existingContent.trim()) {
  243. // 使用正则表达式解析每一行:\t"key":"value",
  244. const lines = existingContent.split('\n');
  245. for (const line of lines) {
  246. // 匹配格式:\t"key":"value", 或 \t"key":"value"
  247. const match = line.match(/^\s*"([^"]+)":\s*"([^"]*)"\s*,?\s*$/);
  248. if (match) {
  249. existingEntries.push({ type: match[1], value: match[2] });
  250. }
  251. }
  252. }
  253. } catch (e) {
  254. // 文件不存在或格式错误,使用空数组
  255. existingEntries = [];
  256. }
  257. // 获取当前时间戳(格式:昨天 HH:MM 或 今天 HH:MM)
  258. const now = new Date();
  259. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  260. const yesterday = new Date(today);
  261. yesterday.setDate(yesterday.getDate() - 1);
  262. let timeLabel = '';
  263. const hour = now.getHours();
  264. const minute = now.getMinutes();
  265. if (now >= today) {
  266. timeLabel = `今天 ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
  267. } else if (now >= yesterday) {
  268. timeLabel = `昨天 ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
  269. } else {
  270. timeLabel = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
  271. }
  272. // 合并现有数据和新消息
  273. const mergedEntries = [...existingEntries];
  274. // 添加时间戳
  275. mergedEntries.push({ type: 'data', value: timeLabel });
  276. // 添加新消息
  277. for (const msg of messages) {
  278. const sender = msg.sender || msg.role || '';
  279. const text = (msg.text || msg.message || '').trim();
  280. if (text && (sender === 'friend' || sender === 'me')) {
  281. mergedEntries.push({ type: sender, value: text });
  282. }
  283. }
  284. // 格式化为用户要求的格式(对象格式,允许重复键)
  285. // 由于JSON不支持重复键,我们保存为格式化的文本,模拟对象格式
  286. const formattedLines = [];
  287. formattedLines.push('{');
  288. for (let i = 0; i < mergedEntries.length; i++) {
  289. const item = mergedEntries[i];
  290. const key = JSON.stringify(item.type);
  291. const value = JSON.stringify(item.value);
  292. const comma = i < mergedEntries.length - 1 ? ',' : '';
  293. formattedLines.push(`\t${key}:${value}${comma}`);
  294. }
  295. formattedLines.push('}');
  296. // 保存为格式化的文本
  297. await writeFile(chatHistoryPath, formattedLines.join('\n'), 'utf-8');
  298. return { success: true, filePath: chatHistoryPath };
  299. } catch (error) {
  300. console.error('保存聊天记录到 chat-history.txt 失败:', error);
  301. return { success: false, error: error.message };
  302. }
  303. });
  304. // 保存聊天记录到 history 文件夹(旧格式,保持向后兼容)
  305. ipcMain.handle('save-chat-history', async (event, workflowFolderPath, historyData) => {
  306. try {
  307. const absoluteWorkflowPath = getAbsoluteWorkflowPath(workflowFolderPath);
  308. const historyDir = join(absoluteWorkflowPath, 'history');
  309. await mkdir(historyDir, { recursive: true });
  310. // 获取最新的历史文件
  311. const latestFile = await getLatestHistoryFile(absoluteWorkflowPath);
  312. let targetFilePath = null;
  313. let mergedMessages = [];
  314. // 如果存在最新文件,检查是否可以追加
  315. if (latestFile) {
  316. try {
  317. const latestStats = await stat(latestFile.path);
  318. const latestFileSize = latestStats.size; // 文件大小(字节)
  319. // 检查文件大小是否超过1MB(1MB = 1024 * 1024 字节)
  320. if (latestFileSize < 1024 * 1024) {
  321. // 文件大小未超过1MB,可以追加
  322. const latestContent = await readFile(latestFile.path, 'utf-8');
  323. const latestData = JSON.parse(latestContent);
  324. const latestMessages = latestData.messages || [];
  325. const newMessages = historyData.messages || [];
  326. // 简单合并消息(不去重,去重逻辑在 saveChatHistory 中处理)
  327. mergedMessages = [...latestMessages, ...newMessages];
  328. targetFilePath = latestFile.path;
  329. } else {
  330. // 文件大小超过1MB,创建新文件
  331. mergedMessages = historyData.messages || [];
  332. }
  333. } catch (compareError) {
  334. console.warn('读取最新文件失败,将创建新文件:', compareError);
  335. mergedMessages = historyData.messages || [];
  336. }
  337. } else {
  338. // 不存在历史文件,直接使用新消息
  339. mergedMessages = historyData.messages || [];
  340. }
  341. // 如果还没有确定目标文件路径,创建新文件
  342. if (!targetFilePath) {
  343. const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
  344. const fileName = `chat_${timestamp}.json`;
  345. targetFilePath = join(historyDir, fileName);
  346. }
  347. // 构建保存的数据结构
  348. const now = new Date();
  349. const finalData = {
  350. timestamp: now.toISOString(),
  351. messageCount: mergedMessages.length,
  352. messages: mergedMessages
  353. };
  354. // 保存到文件
  355. await writeFile(targetFilePath, JSON.stringify(finalData, null, 2), 'utf-8');
  356. // 限制 history 文件夹总大小不超过10MB,超过则删除最早的文件
  357. try {
  358. const files = await readdir(historyDir, { withFileTypes: true });
  359. // 过滤出 JSON 文件(chat_*.json)
  360. const jsonFiles = files
  361. .filter(file => file.isFile() && file.name.startsWith('chat_') && file.name.endsWith('.json'))
  362. .map(file => ({
  363. name: file.name,
  364. path: join(historyDir, file.name)
  365. }));
  366. // 获取所有文件的大小和统计信息
  367. const filesWithStats = await Promise.all(
  368. jsonFiles.map(async (file) => {
  369. const stats = await stat(file.path);
  370. return {
  371. ...file,
  372. size: stats.size,
  373. birthtime: stats.birthtime || stats.mtime,
  374. mtime: stats.mtime
  375. };
  376. })
  377. );
  378. // 计算总大小
  379. let totalSize = filesWithStats.reduce((sum, file) => sum + file.size, 0);
  380. const maxSize = 10 * 1024 * 1024; // 10MB
  381. // 按时间排序(最早的在前)
  382. filesWithStats.sort((a, b) => {
  383. const timeA = a.birthtime.getTime();
  384. const timeB = b.birthtime.getTime();
  385. return timeA - timeB;
  386. });
  387. // 如果总大小超过10MB,删除最早的文件直到总大小小于10MB
  388. const filesToDelete = [];
  389. for (const file of filesWithStats) {
  390. if (totalSize <= maxSize) {
  391. break;
  392. }
  393. filesToDelete.push(file);
  394. totalSize -= file.size;
  395. }
  396. // 删除文件
  397. for (const file of filesToDelete) {
  398. try {
  399. await rm(file.path, { force: true });
  400. // 已删除临时文件日志(不显示)
  401. } catch (deleteError) {
  402. console.warn(`删除文件失败: ${file.name}`, deleteError);
  403. }
  404. }
  405. if (filesToDelete.length > 0) {
  406. console.log(`已清理 ${filesToDelete.length} 个文件,当前总大小: ${((totalSize) / 1024 / 1024).toFixed(2)}MB`);
  407. }
  408. } catch (cleanupError) {
  409. // 清理失败不影响保存操作
  410. console.warn('清理旧聊天记录文件时出错:', cleanupError);
  411. }
  412. return { success: true, filePath: targetFilePath };
  413. } catch (error) {
  414. console.error('保存聊天记录失败:', error);
  415. return { success: false, error: error.message };
  416. }
  417. });
  418. // 保存聊天记录总结
  419. ipcMain.handle('save-chat-history-summary', async (event, workflowFolderPath, summary) => {
  420. try {
  421. const absoluteWorkflowPath = getAbsoluteWorkflowPath(workflowFolderPath);
  422. const historyDir = join(absoluteWorkflowPath, 'history');
  423. await mkdir(historyDir, { recursive: true });
  424. const summaryFilePath = join(historyDir, 'summary.txt');
  425. await writeFile(summaryFilePath, summary, 'utf-8');
  426. console.log(`聊天记录总结已保存到: ${summaryFilePath}`);
  427. return { success: true };
  428. } catch (error) {
  429. console.error('保存聊天记录总结失败:', error);
  430. return { success: false, error: error.message };
  431. }
  432. });
  433. // 读取聊天记录总结
  434. ipcMain.handle('get-chat-history-summary', async (event, workflowFolderPath) => {
  435. try {
  436. const absoluteWorkflowPath = getAbsoluteWorkflowPath(workflowFolderPath);
  437. const summaryFilePath = join(absoluteWorkflowPath, 'history', 'summary.txt');
  438. // 检查文件是否存在
  439. try {
  440. const { access, constants } = await import('fs/promises');
  441. await access(summaryFilePath, constants.F_OK);
  442. const summary = await readFile(summaryFilePath, 'utf-8');
  443. return { success: true, summary: summary.trim() };
  444. } catch (error) {
  445. // 文件不存在,返回空字符串
  446. return { success: true, summary: '' };
  447. }
  448. } catch (error) {
  449. console.error('读取聊天记录总结失败:', error);
  450. return { success: false, error: error.message };
  451. }
  452. });
  453. // 读取最新聊天记录的所有消息
  454. ipcMain.handle('read-latest-chat-history', async (event, workflowFolderPath) => {
  455. try {
  456. const absoluteWorkflowPath = getAbsoluteWorkflowPath(workflowFolderPath);
  457. // 获取最新的聊天记录文件
  458. const latestFile = await getLatestHistoryFile(absoluteWorkflowPath);
  459. if (!latestFile) {
  460. return { success: false, error: '未找到聊天记录文件' };
  461. }
  462. // 读取文件内容
  463. const fileContent = await readFile(latestFile.path, 'utf-8');
  464. const historyData = JSON.parse(fileContent);
  465. // 返回消息数组
  466. return {
  467. success: true,
  468. messages: historyData.messages || []
  469. };
  470. } catch (error) {
  471. console.error('读取最新聊天记录失败:', error);
  472. return { success: false, error: error.message };
  473. }
  474. });
  475. // 读取最新聊天记录的最后一条消息
  476. ipcMain.handle('read-last-message', async (event, workflowFolderPath) => {
  477. try {
  478. const absoluteWorkflowPath = getAbsoluteWorkflowPath(workflowFolderPath);
  479. // 获取最新的聊天记录文件
  480. const latestFile = await getLatestHistoryFile(absoluteWorkflowPath);
  481. if (!latestFile) {
  482. return { success: false, error: '未找到聊天记录文件' };
  483. }
  484. // 读取文件内容
  485. const fileContent = await readFile(latestFile.path, 'utf-8');
  486. const historyData = JSON.parse(fileContent);
  487. // 获取最后一条消息
  488. const messages = historyData.messages || [];
  489. if (messages.length === 0) {
  490. return { success: false, error: '聊天记录为空' };
  491. }
  492. const lastMessage = messages[messages.length - 1];
  493. // 返回最后一条消息的文本和发送者
  494. return {
  495. success: true,
  496. text: lastMessage.text || '',
  497. sender: lastMessage.sender || ''
  498. };
  499. } catch (error) {
  500. console.error('读取最后一条消息失败:', error);
  501. return { success: false, error: error.message };
  502. }
  503. });
  504. // 读取所有聊天记录(合并所有历史文件)- 向后兼容
  505. ipcMain.handle('read-all-chat-history', async (event, workflowFolderPath) => {
  506. // 向后兼容,重定向到 read-chat-history(使用相同的实现)
  507. try {
  508. const absoluteWorkflowPath = getAbsoluteWorkflowPath(workflowFolderPath);
  509. const historyDir = join(absoluteWorkflowPath, 'history');
  510. // 检查目录是否存在
  511. try {
  512. const files = await readdir(historyDir, { withFileTypes: true });
  513. // 过滤出 JSON 文件(chat_*.json)
  514. const jsonFiles = files
  515. .filter(file => file.isFile() && file.name.startsWith('chat_') && file.name.endsWith('.json'))
  516. .map(file => ({
  517. name: file.name,
  518. path: join(historyDir, file.name)
  519. }));
  520. if (jsonFiles.length === 0) {
  521. return { success: true, messages: [] }; // 没有文件时返回空数组
  522. }
  523. // 获取所有文件的统计信息(修改时间),用于排序
  524. const filesWithStats = await Promise.all(
  525. jsonFiles.map(async (file) => {
  526. const stats = await stat(file.path);
  527. return {
  528. ...file,
  529. mtime: stats.mtime
  530. };
  531. })
  532. );
  533. // 按修改时间排序(最早的在前,保持时间顺序)
  534. filesWithStats.sort((a, b) => a.mtime.getTime() - b.mtime.getTime());
  535. // 读取所有文件并合并消息
  536. const allMessages = [];
  537. for (const file of filesWithStats) {
  538. try {
  539. const fileContent = await readFile(file.path, 'utf-8');
  540. const historyData = JSON.parse(fileContent);
  541. const messages = historyData.messages || [];
  542. allMessages.push(...messages);
  543. } catch (error) {
  544. console.warn(`读取聊天记录文件失败: ${file.name}`, error);
  545. // 继续处理其他文件
  546. }
  547. }
  548. return {
  549. success: true,
  550. messages: allMessages,
  551. fileCount: filesWithStats.length,
  552. totalMessages: allMessages.length
  553. };
  554. } catch (error) {
  555. // 目录不存在或读取失败
  556. if (error.code === 'ENOENT') {
  557. return { success: true, messages: [] }; // 目录不存在时返回空数组
  558. }
  559. throw error;
  560. }
  561. } catch (error) {
  562. console.error('读取所有聊天记录失败:', error);
  563. return { success: false, error: error.message };
  564. }
  565. });
  566. // 读取聊天记录(合并所有历史文件)
  567. ipcMain.handle('read-chat-history', async (event, workflowFolderPath) => {
  568. try {
  569. const absoluteWorkflowPath = getAbsoluteWorkflowPath(workflowFolderPath);
  570. const historyDir = join(absoluteWorkflowPath, 'history');
  571. // 检查目录是否存在
  572. try {
  573. const files = await readdir(historyDir, { withFileTypes: true });
  574. // 过滤出 JSON 文件(chat_*.json)
  575. const jsonFiles = files
  576. .filter(file => file.isFile() && file.name.startsWith('chat_') && file.name.endsWith('.json'))
  577. .map(file => ({
  578. name: file.name,
  579. path: join(historyDir, file.name)
  580. }));
  581. if (jsonFiles.length === 0) {
  582. return { success: true, messages: [] }; // 没有文件时返回空数组
  583. }
  584. // 获取所有文件的统计信息(修改时间),用于排序
  585. const filesWithStats = await Promise.all(
  586. jsonFiles.map(async (file) => {
  587. const stats = await stat(file.path);
  588. return {
  589. ...file,
  590. mtime: stats.mtime
  591. };
  592. })
  593. );
  594. // 按修改时间排序(最早的在前,保持时间顺序)
  595. filesWithStats.sort((a, b) => a.mtime.getTime() - b.mtime.getTime());
  596. // 读取所有文件并合并消息
  597. const allMessages = [];
  598. for (const file of filesWithStats) {
  599. try {
  600. const fileContent = await readFile(file.path, 'utf-8');
  601. const historyData = JSON.parse(fileContent);
  602. const messages = historyData.messages || [];
  603. allMessages.push(...messages);
  604. } catch (error) {
  605. console.warn(`读取聊天记录文件失败: ${file.name}`, error);
  606. // 继续处理其他文件
  607. }
  608. }
  609. return {
  610. success: true,
  611. messages: allMessages,
  612. fileCount: filesWithStats.length,
  613. totalMessages: allMessages.length
  614. };
  615. } catch (error) {
  616. // 目录不存在或读取失败
  617. if (error.code === 'ENOENT') {
  618. return { success: true, messages: [] }; // 目录不存在时返回空数组
  619. }
  620. throw error;
  621. }
  622. } catch (error) {
  623. console.error('读取聊天记录失败:', error);
  624. return { success: false, error: error.message };
  625. }
  626. });
  627. // 提取聊天记录(通过OCR)
  628. ipcMain.handle('extract-chat-history', async (event, ipPort, friendAvatarPath, myAvatarPath, workflowFolderPath, region = null, friendRgb = null, myRgb = null) => {
  629. return await extractChatHistory(ipPort, friendAvatarPath, myAvatarPath, workflowFolderPath, region, friendRgb, myRgb);
  630. });
  631. // 获取最后一条消息
  632. ipcMain.handle('get-last-chat-message', async (event, ipPort, friendAvatarPath, myAvatarPath) => {
  633. return await getLastChatMessage(ipPort, friendAvatarPath, myAvatarPath);
  634. });
  635. // OCR 聊天记录(向后兼容,重定向到 extract-chat-history)
  636. ipcMain.handle('ocr-chat-history', async (event, ipPort, friendAvatarPath, myAvatarPath, workflowFolderPath) => {
  637. return await extractChatHistory(ipPort, friendAvatarPath, myAvatarPath, workflowFolderPath);
  638. });
  639. }
  640. /**
  641. * 提取聊天记录(通过OCR)
  642. * @param {string} ipPort - 设备 ID/IP:Port
  643. * @param {string} friendAvatarPath - 好友头像路径
  644. * @param {string} myAvatarPath - 我的头像路径
  645. * @param {string} workflowFolderPath - 工作流文件夹路径(可选)
  646. * @param {Object} region - 识别区域(可选,包含四个顶点坐标的 corners 对象)
  647. * @param {string} friendRgb - 好友对话框RGB颜色(格式:"(r,g,b)")
  648. * @param {string} myRgb - 我的对话框RGB颜色(格式:"(r,g,b)")
  649. * @returns {Promise<{success: boolean, error?: string, messages?: Array}>}
  650. */
  651. export async function extractChatHistory(ipPort, friendAvatarPath, myAvatarPath, workflowFolderPath = null, region = null, friendRgb = null, myRgb = null) {
  652. try {
  653. if (!ipPort) {
  654. return { success: false, error: '缺少设备 ID' };
  655. }
  656. // 1. 获取设备分辨率
  657. const resolutionResult = await getDeviceResolution(ipPort);
  658. if (!resolutionResult.success) {
  659. return { success: false, error: '获取设备分辨率失败' };
  660. }
  661. const { width, height } = resolutionResult;
  662. // 2. 获取屏幕截图
  663. const screenshotResult = await captureScreenshot(ipPort, { format: 'png' });
  664. if (!screenshotResult.success || !screenshotResult.data) {
  665. return { success: false, error: '获取屏幕截图失败' };
  666. }
  667. // 3. 保存截图到临时文件(如果提供了工作流文件夹,保存到 tmp/时间戳 目录)
  668. let screenshotPath;
  669. let tmpDir = null; // 用于跟踪需要删除的临时目录
  670. if (workflowFolderPath) {
  671. // 确保 workflowFolderPath 是绝对路径
  672. let absoluteWorkflowPath = workflowFolderPath;
  673. if (!isAbsolute(workflowFolderPath)) {
  674. // 如果已经是 static/processing/xxx 格式,直接拼接(去掉开头的 static/processing)
  675. if (workflowFolderPath.startsWith('static/processing/')) {
  676. const folderName = workflowFolderPath.replace('static/processing/', '');
  677. absoluteWorkflowPath = join(__dirname, '..', 'static', 'processing', folderName);
  678. } else if (workflowFolderPath.startsWith('static\\processing\\')) {
  679. const folderName = workflowFolderPath.replace('static\\processing\\', '');
  680. absoluteWorkflowPath = join(__dirname, '..', 'static', 'processing', folderName);
  681. } else {
  682. // 如果只是文件夹名,需要加上 static/processing
  683. absoluteWorkflowPath = join(__dirname, '..', 'static', 'processing', workflowFolderPath);
  684. }
  685. }
  686. const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19).replace('T', '_');
  687. tmpDir = join(absoluteWorkflowPath, 'tmp', timestamp);
  688. await mkdir(tmpDir, { recursive: true });
  689. screenshotPath = join(tmpDir, 'screenshot.png');
  690. } else {
  691. const tempDir = join(__dirname, '..');
  692. screenshotPath = join(tempDir, 'temp_screenshot_chat.png');
  693. }
  694. const screenshotBuffer = Buffer.from(screenshotResult.data, 'base64');
  695. await writeFile(screenshotPath, screenshotBuffer);
  696. // 验证文件是否成功写入
  697. try {
  698. const { access, constants } = await import('fs/promises');
  699. await access(screenshotPath, constants.F_OK);
  700. // 截图已保存日志(不显示)
  701. } catch (err) {
  702. console.error(`截图文件写入验证失败: ${screenshotPath}`, err);
  703. return { success: false, error: `截图文件写入失败: ${err.message}` };
  704. }
  705. try {
  706. // 4. 调用 JS 函数提取聊天记录
  707. // 转换头像路径为绝对路径
  708. let friendAvatarArg = null;
  709. if (friendAvatarPath) {
  710. if (isAbsolute(friendAvatarPath)) {
  711. friendAvatarArg = friendAvatarPath;
  712. } else {
  713. friendAvatarArg = join(__dirname, '..', 'static', 'processing', friendAvatarPath);
  714. }
  715. }
  716. let myAvatarArg = null;
  717. if (myAvatarPath) {
  718. if (isAbsolute(myAvatarPath)) {
  719. myAvatarArg = myAvatarPath;
  720. } else {
  721. myAvatarArg = join(__dirname, '..', 'static', 'processing', myAvatarPath);
  722. }
  723. }
  724. // 如果提供了工作流文件夹路径,转换为绝对路径
  725. let workflowFolderArg = null;
  726. if (workflowFolderPath) {
  727. if (isAbsolute(workflowFolderPath)) {
  728. workflowFolderArg = workflowFolderPath;
  729. } else {
  730. if (workflowFolderPath.startsWith('static/processing/')) {
  731. const folderName = workflowFolderPath.replace('static/processing/', '');
  732. workflowFolderArg = join(__dirname, '..', 'static', 'processing', folderName);
  733. } else if (workflowFolderPath.startsWith('static\\processing\\')) {
  734. const folderName = workflowFolderPath.replace('static\\processing\\', '');
  735. workflowFolderArg = join(__dirname, '..', 'static', 'processing', folderName);
  736. } else {
  737. workflowFolderArg = join(__dirname, '..', 'static', 'processing', workflowFolderPath);
  738. }
  739. }
  740. }
  741. // 传递区域参数(如果提供)
  742. const regionArg = region ? JSON.stringify(region) : 'None';
  743. const result = await extractChatHistoryFromFunc(screenshotPath, friendAvatarArg, myAvatarArg, width, height, workflowFolderArg, regionArg, friendRgb, myRgb);
  744. return result;
  745. } finally {
  746. // 5. 使用完后,先判断 tmp 目录总大小是否超过 20MB,再决定是否删除临时目录
  747. if (tmpDir) {
  748. try {
  749. // 获取 tmp 目录的父目录(工作流目录下的 tmp 文件夹)
  750. const tmpParentDir = dirname(tmpDir);
  751. // 检查 tmp 目录下所有文件和子目录的总大小
  752. let totalSize = 0;
  753. const filesToDelete = [];
  754. try {
  755. const entries = await readdir(tmpParentDir, { withFileTypes: true });
  756. for (const entry of entries) {
  757. const entryPath = join(tmpParentDir, entry.name);
  758. try {
  759. if (entry.isFile()) {
  760. const stats = await stat(entryPath);
  761. totalSize += stats.size;
  762. } else if (entry.isDirectory()) {
  763. // 递归计算子目录大小
  764. const dirSize = await calculateDirSize(entryPath);
  765. totalSize += dirSize;
  766. filesToDelete.push({ path: entryPath, size: dirSize, isDir: true });
  767. }
  768. } catch (e) {
  769. // 忽略无法访问的文件
  770. }
  771. }
  772. } catch (e) {
  773. // 如果无法读取目录,直接删除临时目录
  774. await rm(tmpDir, { recursive: true, force: true });
  775. return;
  776. }
  777. const maxSize = 20 * 1024 * 1024; // 20MB
  778. // 如果总大小超过 20MB,删除时间最早的目录/文件
  779. if (totalSize > maxSize) {
  780. // 获取所有目录的修改时间并排序
  781. const dirsWithTime = [];
  782. for (const item of filesToDelete) {
  783. if (item.isDir) {
  784. try {
  785. const stats = await stat(item.path);
  786. dirsWithTime.push({ ...item, mtime: stats.mtime });
  787. } catch (e) {
  788. // 忽略无法访问的目录
  789. }
  790. }
  791. }
  792. // 按修改时间排序(最早的在前)
  793. dirsWithTime.sort((a, b) => a.mtime - b.mtime);
  794. // 删除最早的目录直到总大小小于 20MB
  795. for (const dirInfo of dirsWithTime) {
  796. if (totalSize <= maxSize) {
  797. break;
  798. }
  799. try {
  800. await rm(dirInfo.path, { recursive: true, force: true });
  801. totalSize -= dirInfo.size;
  802. } catch (e) {
  803. // 忽略删除失败
  804. }
  805. }
  806. }
  807. // 如果临时目录仍然存在且总大小未超过限制,也删除它(保持原有行为)
  808. try {
  809. const tmpDirExists = await stat(tmpDir).then(() => true).catch(() => false);
  810. if (tmpDirExists) {
  811. await rm(tmpDir, { recursive: true, force: true });
  812. }
  813. } catch (rmError) {
  814. // 忽略删除失败
  815. }
  816. } catch (error) {
  817. // 清理失败不影响主流程
  818. }
  819. }
  820. }
  821. } catch (error) {
  822. console.error('提取聊天记录失败:', error);
  823. if (error.message && error.message.includes('timeout')) {
  824. return { success: false, error: '提取聊天记录超时,请检查网络连接或稍后重试' };
  825. }
  826. return { success: false, error: error.message };
  827. }
  828. }
  829. /**
  830. * 获取最后一条消息(带发送者信息)
  831. * @param {string} ipPort - 设备 ID/IP:Port
  832. * @param {string} friendAvatarPath - 好友头像路径
  833. * @param {string} myAvatarPath - 我的头像路径
  834. * @returns {Promise<{success: boolean, error?: string, text?: string, sender?: string}>}
  835. */
  836. export async function getLastChatMessage(ipPort, friendAvatarPath, myAvatarPath) {
  837. try {
  838. if (!ipPort) {
  839. return { success: false, error: '缺少设备 ID' };
  840. }
  841. // 1. 获取设备分辨率
  842. const resolutionResult = await getDeviceResolution(ipPort);
  843. if (!resolutionResult.success) {
  844. return { success: false, error: '获取设备分辨率失败' };
  845. }
  846. const { width, height } = resolutionResult;
  847. // 2. 获取屏幕截图
  848. const screenshotResult = await captureScreenshot(ipPort, { format: 'png' });
  849. if (!screenshotResult.success || !screenshotResult.data) {
  850. return { success: false, error: '获取屏幕截图失败' };
  851. }
  852. // 3. 保存截图到临时文件
  853. const tempDir = join(__dirname, '..');
  854. const screenshotPath = join(tempDir, 'temp_screenshot_chat.png');
  855. const screenshotBuffer = Buffer.from(screenshotResult.data, 'base64');
  856. await writeFile(screenshotPath, screenshotBuffer);
  857. // 4. 调用 JS 实现获取最后一条消息
  858. // 转换头像路径为绝对路径
  859. let friendAvatarArg = null;
  860. if (friendAvatarPath) {
  861. if (isAbsolute(friendAvatarPath)) {
  862. friendAvatarArg = friendAvatarPath;
  863. } else {
  864. friendAvatarArg = join(__dirname, '..', 'static', 'processing', friendAvatarPath);
  865. }
  866. }
  867. let myAvatarArg = null;
  868. if (myAvatarPath) {
  869. if (isAbsolute(myAvatarPath)) {
  870. myAvatarArg = myAvatarPath;
  871. } else {
  872. myAvatarArg = join(__dirname, '..', 'static', 'processing', myAvatarPath);
  873. }
  874. }
  875. const normalizedScreenshotPath = screenshotPath.replace(/\\/g, '/');
  876. const normalizedFriendAvatar = friendAvatarArg ? friendAvatarArg.replace(/\\/g, '/') : null;
  877. const normalizedMyAvatar = myAvatarArg ? myAvatarArg.replace(/\\/g, '/') : null;
  878. const result = await getLastMessageFromFunc(normalizedScreenshotPath, normalizedFriendAvatar, normalizedMyAvatar, width, height);
  879. if (result.success) {
  880. // 确保正确显示UTF-8编码的中文
  881. const displayText = result.text || '';
  882. try {
  883. const textStr = Buffer.isBuffer(displayText)
  884. ? displayText.toString('utf8')
  885. : String(displayText);
  886. console.log(`最后一条消息 [${result.sender || 'unknown'}]:`, textStr);
  887. } catch (e) {
  888. console.log(`最后一条消息 [${result.sender || 'unknown'}]:`, displayText);
  889. }
  890. }
  891. return result;
  892. } catch (error) {
  893. console.error('获取最后一条消息失败:', error);
  894. if (error.message && error.message.includes('timeout')) {
  895. return { success: false, error: '获取最后一条消息超时,请检查网络连接或稍后重试' };
  896. }
  897. return { success: false, error: error.message };
  898. }
  899. }