read-and-write.js 36 KB

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