markpdfdown 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +128 -0
- package/bin/cli.js +130 -0
- package/dist/main/AnthropicClient-CTbHYiqm.js +193 -0
- package/dist/main/GeminiClient-CrtYbwaF.js +196 -0
- package/dist/main/OllamaClient-DKJsnvIt.js +197 -0
- package/dist/main/OpenAIClient-gyy2nFkw.js +214 -0
- package/dist/main/OpenAIResponsesClient-DETYz2nL.js +297 -0
- package/dist/main/index.js +3523 -0
- package/dist/preload/index.js +102 -0
- package/dist/renderer/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/renderer/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/renderer/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/renderer/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/renderer/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/renderer/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/renderer/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/renderer/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/renderer/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/renderer/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/renderer/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/renderer/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/renderer/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/renderer/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/renderer/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/renderer/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/renderer/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/renderer/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/renderer/assets/MarkPDFdown-C6Sb1T4M.png +0 -0
- package/dist/renderer/assets/index-CbMlWqbh.css +327 -0
- package/dist/renderer/assets/index-DeDe7lry.js +123956 -0
- package/dist/renderer/index.html +14 -0
- package/package.json +156 -0
- package/src/core/infrastructure/db/migrations/20250414154412_/migration.sql +24 -0
- package/src/core/infrastructure/db/migrations/20250419090345_/migration.sql +29 -0
- package/src/core/infrastructure/db/migrations/20250419104636_/migration.sql +47 -0
- package/src/core/infrastructure/db/migrations/20260121154536_add_worker_fields/migration.sql +50 -0
- package/src/core/infrastructure/db/migrations/20260124014806_/migration.sql +55 -0
- package/src/core/infrastructure/db/migrations/migration_lock.toml +3 -0
- package/src/core/infrastructure/db/schema.prisma +104 -0
|
@@ -0,0 +1,3523 @@
|
|
|
1
|
+
import { app, ipcMain, dialog, protocol, BrowserWindow, shell } from "electron";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs, { promises } from "fs";
|
|
4
|
+
import isDev from "electron-is-dev";
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
import pkg from "@prisma/client";
|
|
7
|
+
import { EventEmitter } from "events";
|
|
8
|
+
import { pdfToPng } from "pdf-to-png-converter";
|
|
9
|
+
import { PDFDocument } from "pdf-lib";
|
|
10
|
+
import sharp from "sharp";
|
|
11
|
+
import fs$1 from "fs/promises";
|
|
12
|
+
import { v4 } from "uuid";
|
|
13
|
+
import __cjs_mod__ from "node:module";
|
|
14
|
+
const __filename = import.meta.filename;
|
|
15
|
+
const __dirname = import.meta.dirname;
|
|
16
|
+
const require2 = __cjs_mod__.createRequire(import.meta.url);
|
|
17
|
+
const getMigrationsDir = () => {
|
|
18
|
+
if (isDev) {
|
|
19
|
+
return path.join(
|
|
20
|
+
process.cwd(),
|
|
21
|
+
"src",
|
|
22
|
+
"core",
|
|
23
|
+
"infrastructure",
|
|
24
|
+
"db",
|
|
25
|
+
"migrations"
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (app) {
|
|
29
|
+
return path.join(app.getAppPath(), "..", "migrations");
|
|
30
|
+
}
|
|
31
|
+
return path.join(__dirname, "migrations");
|
|
32
|
+
};
|
|
33
|
+
const createMigrationsTableSQL = `
|
|
34
|
+
CREATE TABLE IF NOT EXISTS _prisma_migrations (
|
|
35
|
+
id VARCHAR(36) PRIMARY KEY NOT NULL,
|
|
36
|
+
checksum VARCHAR(64) NOT NULL,
|
|
37
|
+
finished_at DATETIME,
|
|
38
|
+
migration_name VARCHAR(255) NOT NULL,
|
|
39
|
+
logs TEXT,
|
|
40
|
+
rolled_back_at DATETIME,
|
|
41
|
+
started_at DATETIME NOT NULL DEFAULT current_timestamp,
|
|
42
|
+
applied_steps_count INTEGER UNSIGNED NOT NULL DEFAULT 0
|
|
43
|
+
);
|
|
44
|
+
`;
|
|
45
|
+
const recordMigration = async (prisma2, migrationName, checksum) => {
|
|
46
|
+
const id = generateUUID();
|
|
47
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
48
|
+
try {
|
|
49
|
+
await prisma2.$executeRaw`
|
|
50
|
+
INSERT INTO _prisma_migrations (
|
|
51
|
+
id, checksum, migration_name, started_at, finished_at, applied_steps_count
|
|
52
|
+
) VALUES (
|
|
53
|
+
${id}, ${checksum}, ${migrationName}, ${now}, ${now}, 1
|
|
54
|
+
);
|
|
55
|
+
`;
|
|
56
|
+
console.log(`Recorded migration: ${migrationName}`);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(`Failed to record migration ${migrationName}:`, error);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const generateUUID = () => {
|
|
62
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
63
|
+
const r = Math.random() * 16 | 0;
|
|
64
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
65
|
+
return v.toString(16);
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
const calculateChecksum = async (content) => {
|
|
69
|
+
const crypto = await import("crypto");
|
|
70
|
+
return crypto.default.createHash("sha256").update(content).digest("hex");
|
|
71
|
+
};
|
|
72
|
+
const applyMigration = async (prisma2, migrationDir, migrationName) => {
|
|
73
|
+
const sqlFilePath = path.join(migrationDir, migrationName, "migration.sql");
|
|
74
|
+
try {
|
|
75
|
+
if (!fs.existsSync(sqlFilePath)) {
|
|
76
|
+
console.error(`Migration SQL file not found: ${sqlFilePath}`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
|
80
|
+
const sqlStatements = sqlContent.split(";").filter((stmt) => stmt.trim());
|
|
81
|
+
for (const statement of sqlStatements) {
|
|
82
|
+
if (statement.trim()) {
|
|
83
|
+
await prisma2.$executeRawUnsafe(statement.trim());
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const checksum = await calculateChecksum(sqlContent);
|
|
87
|
+
await recordMigration(prisma2, migrationName, checksum);
|
|
88
|
+
console.log(`Successfully applied migration: ${migrationName}`);
|
|
89
|
+
return true;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(`Failed to apply migration ${migrationName}:`, error);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const getAppliedMigrations = async (prisma2) => {
|
|
96
|
+
try {
|
|
97
|
+
const tableExists = await prisma2.$queryRaw`
|
|
98
|
+
SELECT name FROM sqlite_master
|
|
99
|
+
WHERE type='table' AND name='_prisma_migrations';
|
|
100
|
+
`;
|
|
101
|
+
if (!tableExists || tableExists.length === 0) {
|
|
102
|
+
return /* @__PURE__ */ new Set();
|
|
103
|
+
}
|
|
104
|
+
const migrations = await prisma2.$queryRaw`
|
|
105
|
+
SELECT migration_name FROM _prisma_migrations
|
|
106
|
+
WHERE finished_at IS NOT NULL;
|
|
107
|
+
`;
|
|
108
|
+
return new Set(migrations.map((m) => m.migration_name));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error("Error getting applied migrations:", error);
|
|
111
|
+
return /* @__PURE__ */ new Set();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const runMigrations = async (prisma2 = null) => {
|
|
115
|
+
const startTime = Date.now();
|
|
116
|
+
console.log("Running database migrations...");
|
|
117
|
+
try {
|
|
118
|
+
await prisma2.$executeRawUnsafe(createMigrationsTableSQL);
|
|
119
|
+
const migrationsDir = getMigrationsDir();
|
|
120
|
+
console.log("Migrations directory:", migrationsDir);
|
|
121
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
122
|
+
console.error(`Migrations directory not found: ${migrationsDir}`);
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const appliedMigrations = await getAppliedMigrations(prisma2);
|
|
126
|
+
const migrationDirs = fs.readdirSync(migrationsDir, { withFileTypes: true }).filter(
|
|
127
|
+
(dirent) => dirent.isDirectory() && dirent.name !== "migration_lock.toml"
|
|
128
|
+
).map((dirent) => dirent.name).sort();
|
|
129
|
+
let migrationsApplied = 0;
|
|
130
|
+
for (const migrationName of migrationDirs) {
|
|
131
|
+
const isApplied = appliedMigrations.has(migrationName);
|
|
132
|
+
if (!isApplied) {
|
|
133
|
+
console.log(`Applying migration: ${migrationName}`);
|
|
134
|
+
const success = await applyMigration(
|
|
135
|
+
prisma2,
|
|
136
|
+
migrationsDir,
|
|
137
|
+
migrationName
|
|
138
|
+
);
|
|
139
|
+
if (success) migrationsApplied++;
|
|
140
|
+
} else {
|
|
141
|
+
console.log(`Migration already applied: ${migrationName}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const elapsed = Date.now() - startTime;
|
|
145
|
+
console.log(
|
|
146
|
+
`Database migration complete. Applied ${migrationsApplied} migrations in ${elapsed}ms`
|
|
147
|
+
);
|
|
148
|
+
return migrationsApplied > 0;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error("Migration error:", error);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const { PrismaClient } = pkg;
|
|
155
|
+
function getDatabaseUrl() {
|
|
156
|
+
if (!isDev) {
|
|
157
|
+
const userDataPath = app.getPath("userData");
|
|
158
|
+
const dbDir = path.join(userDataPath, "db");
|
|
159
|
+
if (!fs.existsSync(dbDir)) {
|
|
160
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
161
|
+
}
|
|
162
|
+
console.log(
|
|
163
|
+
"Using userData database path:",
|
|
164
|
+
`file:${path.join(dbDir, "app.db")}`
|
|
165
|
+
);
|
|
166
|
+
return `file:${path.join(dbDir, "app.db")}`;
|
|
167
|
+
}
|
|
168
|
+
console.log(
|
|
169
|
+
"Using default development database path:",
|
|
170
|
+
`file:${path.join(process.cwd(), "src", "core", "db", "dev.db")}`
|
|
171
|
+
);
|
|
172
|
+
return `file:${path.join(process.cwd(), "src", "core", "infrastructure", "db", "dev.db")}`;
|
|
173
|
+
}
|
|
174
|
+
const dbUrl = getDatabaseUrl();
|
|
175
|
+
const prisma = new PrismaClient({
|
|
176
|
+
datasources: {
|
|
177
|
+
db: {
|
|
178
|
+
url: dbUrl
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
const initDatabase = async () => {
|
|
183
|
+
try {
|
|
184
|
+
console.log(`Initializing database(url:${dbUrl})...`);
|
|
185
|
+
await prisma.$queryRaw`SELECT 1`;
|
|
186
|
+
console.log("Database connection established successfully.");
|
|
187
|
+
await runMigrations(prisma);
|
|
188
|
+
return true;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error("Database initialization error:", error);
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
const disconnect = async () => {
|
|
195
|
+
await prisma.$disconnect();
|
|
196
|
+
console.log("Database connection has been closed");
|
|
197
|
+
};
|
|
198
|
+
var TaskStatus = /* @__PURE__ */ ((TaskStatus2) => {
|
|
199
|
+
TaskStatus2[TaskStatus2["CREATED"] = -1] = "CREATED";
|
|
200
|
+
TaskStatus2[TaskStatus2["FAILED"] = 0] = "FAILED";
|
|
201
|
+
TaskStatus2[TaskStatus2["PENDING"] = 1] = "PENDING";
|
|
202
|
+
TaskStatus2[TaskStatus2["SPLITTING"] = 2] = "SPLITTING";
|
|
203
|
+
TaskStatus2[TaskStatus2["PROCESSING"] = 3] = "PROCESSING";
|
|
204
|
+
TaskStatus2[TaskStatus2["READY_TO_MERGE"] = 4] = "READY_TO_MERGE";
|
|
205
|
+
TaskStatus2[TaskStatus2["MERGING"] = 5] = "MERGING";
|
|
206
|
+
TaskStatus2[TaskStatus2["COMPLETED"] = 6] = "COMPLETED";
|
|
207
|
+
TaskStatus2[TaskStatus2["CANCELLED"] = 7] = "CANCELLED";
|
|
208
|
+
TaskStatus2[TaskStatus2["PARTIAL_FAILED"] = 8] = "PARTIAL_FAILED";
|
|
209
|
+
return TaskStatus2;
|
|
210
|
+
})(TaskStatus || {});
|
|
211
|
+
var PageStatus = /* @__PURE__ */ ((PageStatus2) => {
|
|
212
|
+
PageStatus2[PageStatus2["FAILED"] = -1] = "FAILED";
|
|
213
|
+
PageStatus2[PageStatus2["PENDING"] = 0] = "PENDING";
|
|
214
|
+
PageStatus2[PageStatus2["PROCESSING"] = 1] = "PROCESSING";
|
|
215
|
+
PageStatus2[PageStatus2["COMPLETED"] = 2] = "COMPLETED";
|
|
216
|
+
PageStatus2[PageStatus2["RETRYING"] = 3] = "RETRYING";
|
|
217
|
+
return PageStatus2;
|
|
218
|
+
})(PageStatus || {});
|
|
219
|
+
var TaskEventType = /* @__PURE__ */ ((TaskEventType2) => {
|
|
220
|
+
TaskEventType2["TASK_UPDATED"] = "task:updated";
|
|
221
|
+
TaskEventType2["TASK_STATUS_CHANGED"] = "task:status_changed";
|
|
222
|
+
TaskEventType2["TASK_PROGRESS_CHANGED"] = "task:progress_changed";
|
|
223
|
+
TaskEventType2["TASK_DELETED"] = "task:deleted";
|
|
224
|
+
TaskEventType2["TASK_DETAIL_UPDATED"] = "taskDetail:updated";
|
|
225
|
+
return TaskEventType2;
|
|
226
|
+
})(TaskEventType || {});
|
|
227
|
+
class EventBus extends EventEmitter {
|
|
228
|
+
static instance;
|
|
229
|
+
constructor() {
|
|
230
|
+
super();
|
|
231
|
+
this.setMaxListeners(100);
|
|
232
|
+
}
|
|
233
|
+
static getInstance() {
|
|
234
|
+
if (!EventBus.instance) {
|
|
235
|
+
EventBus.instance = new EventBus();
|
|
236
|
+
}
|
|
237
|
+
return EventBus.instance;
|
|
238
|
+
}
|
|
239
|
+
emitTaskEvent(type, data) {
|
|
240
|
+
this.emit(type, data);
|
|
241
|
+
this.emit("task:*", { type, ...data });
|
|
242
|
+
}
|
|
243
|
+
emitTaskDetailEvent(type, data) {
|
|
244
|
+
this.emit(type, data);
|
|
245
|
+
this.emit("taskDetail:*", { type, ...data });
|
|
246
|
+
}
|
|
247
|
+
onTaskEvent(type, handler) {
|
|
248
|
+
this.on(type, handler);
|
|
249
|
+
}
|
|
250
|
+
onTaskDetailEvent(type, handler) {
|
|
251
|
+
this.on(type, handler);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const eventBus = EventBus.getInstance();
|
|
255
|
+
class WorkerBase {
|
|
256
|
+
/** Unique worker identifier */
|
|
257
|
+
workerId;
|
|
258
|
+
/** Flag to control worker execution */
|
|
259
|
+
isRunning = false;
|
|
260
|
+
constructor() {
|
|
261
|
+
this.workerId = randomUUID();
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Stop the worker gracefully.
|
|
265
|
+
* Sets isRunning flag to false, causing run() loop to exit.
|
|
266
|
+
*/
|
|
267
|
+
stop() {
|
|
268
|
+
this.isRunning = false;
|
|
269
|
+
console.log(`[Worker-${this.workerId}] Stopping...`);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Atomically claim a task for processing.
|
|
273
|
+
*
|
|
274
|
+
* Uses Prisma transaction to ensure only one worker claims a task.
|
|
275
|
+
* The transaction:
|
|
276
|
+
* 1. Finds first available task matching fromStatus
|
|
277
|
+
* 2. Updates it to toStatus with this worker's ID
|
|
278
|
+
* 3. Returns the claimed task or null if none available
|
|
279
|
+
*
|
|
280
|
+
* @param fromStatus - Source status to claim from
|
|
281
|
+
* @param toStatus - Target status to set
|
|
282
|
+
* @returns Claimed task or null if no tasks available
|
|
283
|
+
*/
|
|
284
|
+
async claimTask(fromStatus, toStatus) {
|
|
285
|
+
try {
|
|
286
|
+
const claimed = await prisma.$transaction(async (tx) => {
|
|
287
|
+
const task = await tx.task.findFirst({
|
|
288
|
+
where: {
|
|
289
|
+
status: fromStatus,
|
|
290
|
+
worker_id: null
|
|
291
|
+
},
|
|
292
|
+
orderBy: {
|
|
293
|
+
createdAt: "asc"
|
|
294
|
+
// FIFO order
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
if (!task) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
const updated = await tx.task.update({
|
|
301
|
+
where: {
|
|
302
|
+
id: task.id
|
|
303
|
+
},
|
|
304
|
+
data: {
|
|
305
|
+
status: toStatus,
|
|
306
|
+
worker_id: this.workerId,
|
|
307
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
return updated;
|
|
311
|
+
});
|
|
312
|
+
if (claimed) {
|
|
313
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_UPDATED, {
|
|
314
|
+
taskId: claimed.id,
|
|
315
|
+
task: claimed,
|
|
316
|
+
timestamp: Date.now()
|
|
317
|
+
});
|
|
318
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_STATUS_CHANGED, {
|
|
319
|
+
taskId: claimed.id,
|
|
320
|
+
task: { status: toStatus },
|
|
321
|
+
timestamp: Date.now()
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return claimed;
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.error(`[Worker-${this.workerId}] Failed to claim task:`, error);
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Update task status.
|
|
332
|
+
*
|
|
333
|
+
* @param taskId - Task ID to update
|
|
334
|
+
* @param status - New status
|
|
335
|
+
* @param data - Optional additional data to update
|
|
336
|
+
*/
|
|
337
|
+
async updateTaskStatus(taskId, status, data) {
|
|
338
|
+
try {
|
|
339
|
+
const updated = await prisma.task.update({
|
|
340
|
+
where: {
|
|
341
|
+
id: taskId
|
|
342
|
+
},
|
|
343
|
+
data: {
|
|
344
|
+
status,
|
|
345
|
+
...data,
|
|
346
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_UPDATED, {
|
|
350
|
+
taskId,
|
|
351
|
+
task: updated,
|
|
352
|
+
timestamp: Date.now()
|
|
353
|
+
});
|
|
354
|
+
if (status !== void 0) {
|
|
355
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_STATUS_CHANGED, {
|
|
356
|
+
taskId,
|
|
357
|
+
task: { status },
|
|
358
|
+
timestamp: Date.now()
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (data?.progress !== void 0) {
|
|
362
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_PROGRESS_CHANGED, {
|
|
363
|
+
taskId,
|
|
364
|
+
task: { progress: data.progress },
|
|
365
|
+
timestamp: Date.now()
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
} catch (error) {
|
|
369
|
+
console.error(`[Worker-${this.workerId}] Failed to update task ${taskId}:`, error);
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Handle task error.
|
|
375
|
+
* Sets task status to FAILED and stores error message.
|
|
376
|
+
*
|
|
377
|
+
* @param taskId - Task ID
|
|
378
|
+
* @param error - Error object or message
|
|
379
|
+
*/
|
|
380
|
+
async handleError(taskId, error) {
|
|
381
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
382
|
+
console.error(`[Worker-${this.workerId}] Task ${taskId} failed:`, errorMessage);
|
|
383
|
+
try {
|
|
384
|
+
const updated = await prisma.task.update({
|
|
385
|
+
where: {
|
|
386
|
+
id: taskId
|
|
387
|
+
},
|
|
388
|
+
data: {
|
|
389
|
+
status: TaskStatus.FAILED,
|
|
390
|
+
error: errorMessage,
|
|
391
|
+
worker_id: null,
|
|
392
|
+
// Release worker
|
|
393
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_UPDATED, {
|
|
397
|
+
taskId,
|
|
398
|
+
task: updated,
|
|
399
|
+
timestamp: Date.now()
|
|
400
|
+
});
|
|
401
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_STATUS_CHANGED, {
|
|
402
|
+
taskId,
|
|
403
|
+
task: { status: TaskStatus.FAILED },
|
|
404
|
+
timestamp: Date.now()
|
|
405
|
+
});
|
|
406
|
+
} catch (updateError) {
|
|
407
|
+
console.error(
|
|
408
|
+
`[Worker-${this.workerId}] Failed to update task ${taskId} error state:`,
|
|
409
|
+
updateError
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Sleep for the specified duration.
|
|
415
|
+
*
|
|
416
|
+
* @param ms - Milliseconds to sleep
|
|
417
|
+
*/
|
|
418
|
+
sleep(ms) {
|
|
419
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Get worker ID.
|
|
423
|
+
*/
|
|
424
|
+
getWorkerId() {
|
|
425
|
+
return this.workerId;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Check if worker is running.
|
|
429
|
+
*/
|
|
430
|
+
getIsRunning() {
|
|
431
|
+
return this.isRunning;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
class ImagePathUtil {
|
|
435
|
+
static uploadsDir = null;
|
|
436
|
+
/**
|
|
437
|
+
* Initialize the utility with the uploads directory path.
|
|
438
|
+
* Must be called once at application startup.
|
|
439
|
+
*
|
|
440
|
+
* @param uploadsDir - Base uploads directory path (e.g., {userData}/files)
|
|
441
|
+
*/
|
|
442
|
+
static init(uploadsDir) {
|
|
443
|
+
this.uploadsDir = uploadsDir;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Get the full path to a page image file.
|
|
447
|
+
*
|
|
448
|
+
* @param taskId - Task ID
|
|
449
|
+
* @param page - Page number (1-based)
|
|
450
|
+
* @returns Full path to the image file (e.g., {uploadsDir}/{taskId}/split/page-{page}.png)
|
|
451
|
+
* @throws Error if utility is not initialized
|
|
452
|
+
*/
|
|
453
|
+
static getPath(taskId, page) {
|
|
454
|
+
if (!this.uploadsDir) {
|
|
455
|
+
throw new Error("ImagePathUtil not initialized. Call ImagePathUtil.init() first.");
|
|
456
|
+
}
|
|
457
|
+
return path.join(this.uploadsDir, taskId, "split", `page-${page}.png`);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Get the task split directory path (contains all page images).
|
|
461
|
+
*
|
|
462
|
+
* @param taskId - Task ID
|
|
463
|
+
* @returns Full path to the split directory (e.g., {uploadsDir}/{taskId}/split)
|
|
464
|
+
* @throws Error if utility is not initialized
|
|
465
|
+
*/
|
|
466
|
+
static getTaskDir(taskId) {
|
|
467
|
+
if (!this.uploadsDir) {
|
|
468
|
+
throw new Error("ImagePathUtil not initialized. Call ImagePathUtil.init() first.");
|
|
469
|
+
}
|
|
470
|
+
return path.join(this.uploadsDir, taskId, "split");
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Get the uploads directory (for testing/debugging).
|
|
474
|
+
*
|
|
475
|
+
* @returns The uploads directory path or null if not initialized
|
|
476
|
+
*/
|
|
477
|
+
static getUploadsDir() {
|
|
478
|
+
return this.uploadsDir;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Reset the utility (for testing purposes only).
|
|
482
|
+
*/
|
|
483
|
+
static reset() {
|
|
484
|
+
this.uploadsDir = null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
class PageRangeParser {
|
|
488
|
+
/**
|
|
489
|
+
* Regular expression for validating page range format.
|
|
490
|
+
* Matches patterns like "1", "1-5", "1,3,5", "1-5,7,11-14"
|
|
491
|
+
*/
|
|
492
|
+
static RANGE_REGEX = /^(\d+(-\d+)?(,\d+(-\d+)?)*)$/;
|
|
493
|
+
/**
|
|
494
|
+
* Parse a page range string into an array of page numbers.
|
|
495
|
+
*
|
|
496
|
+
* @param rangeStr - Page range string (e.g., "1-5,7,11-14") or null/empty for all pages
|
|
497
|
+
* @param totalPages - Total number of pages in the document
|
|
498
|
+
* @returns Sorted, deduplicated array of 1-based page numbers
|
|
499
|
+
* @throws Error if format is invalid or pages are out of bounds
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* parse("1-3,5", 10) // [1, 2, 3, 5]
|
|
503
|
+
* parse("", 5) // [1, 2, 3, 4, 5]
|
|
504
|
+
* parse(null, 5) // [1, 2, 3, 4, 5]
|
|
505
|
+
* parse("3,1,2,2", 10) // [1, 2, 3] (sorted and deduplicated)
|
|
506
|
+
*/
|
|
507
|
+
static parse(rangeStr, totalPages) {
|
|
508
|
+
if (!rangeStr || rangeStr.trim() === "") {
|
|
509
|
+
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
|
510
|
+
}
|
|
511
|
+
const normalized = rangeStr.trim().replace(/\s+/g, "");
|
|
512
|
+
if (!this.validate(normalized)) {
|
|
513
|
+
throw new Error(`Invalid page range format: "${rangeStr}". Use formats like "1", "1-5", "1,3,5", or "1-5,7,11-14"`);
|
|
514
|
+
}
|
|
515
|
+
const pages = /* @__PURE__ */ new Set();
|
|
516
|
+
const parts = normalized.split(",");
|
|
517
|
+
for (const part of parts) {
|
|
518
|
+
if (part.includes("-")) {
|
|
519
|
+
const [startStr, endStr] = part.split("-");
|
|
520
|
+
const start = parseInt(startStr, 10);
|
|
521
|
+
const end = parseInt(endStr, 10);
|
|
522
|
+
if (start > end) {
|
|
523
|
+
throw new Error(`Invalid range: ${part}. Start page must be less than or equal to end page.`);
|
|
524
|
+
}
|
|
525
|
+
const clampedStart = Math.max(1, Math.min(start, totalPages));
|
|
526
|
+
const clampedEnd = Math.max(1, Math.min(end, totalPages));
|
|
527
|
+
if (start < 1 || end > totalPages || start > totalPages) {
|
|
528
|
+
console.warn(
|
|
529
|
+
`[PageRangeParser] Requested page range ${part} adjusted to fit document bounds (1-${totalPages})`
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
if (start > totalPages) {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
for (let i = clampedStart; i <= clampedEnd; i++) {
|
|
536
|
+
pages.add(i);
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
const page = parseInt(part, 10);
|
|
540
|
+
if (page < 1 || page > totalPages) {
|
|
541
|
+
console.warn(
|
|
542
|
+
`[PageRangeParser] Page ${page} is out of bounds (valid: 1-${totalPages}), skipping`
|
|
543
|
+
);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
pages.add(page);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const result = Array.from(pages).sort((a, b) => a - b);
|
|
550
|
+
if (result.length === 0) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`No valid pages found in range "${rangeStr}". Document has ${totalPages} page(s), valid range: 1-${totalPages}`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Validate a page range string format (without checking bounds).
|
|
559
|
+
*
|
|
560
|
+
* @param rangeStr - Page range string to validate
|
|
561
|
+
* @returns True if format is valid, false otherwise
|
|
562
|
+
*
|
|
563
|
+
* @example
|
|
564
|
+
* validate("1-5") // true
|
|
565
|
+
* validate("1,3,5") // true
|
|
566
|
+
* validate("abc") // false
|
|
567
|
+
* validate("1-") // false
|
|
568
|
+
*/
|
|
569
|
+
static validate(rangeStr) {
|
|
570
|
+
if (!rangeStr || rangeStr.trim() === "") {
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
return this.RANGE_REGEX.test(rangeStr.trim());
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const WORKER_CONFIG = {
|
|
577
|
+
/**
|
|
578
|
+
* Splitter worker configuration.
|
|
579
|
+
* Handles PDF and image splitting into individual pages.
|
|
580
|
+
*/
|
|
581
|
+
splitter: {
|
|
582
|
+
/** Poll interval for checking new tasks (ms) */
|
|
583
|
+
pollInterval: 2e3,
|
|
584
|
+
/** PDF to PNG viewport scale (~144 DPI at 2.0) */
|
|
585
|
+
viewportScale: 2,
|
|
586
|
+
/** Output image format */
|
|
587
|
+
imageFormat: "png",
|
|
588
|
+
/** Maximum retry attempts for transient errors */
|
|
589
|
+
maxRetries: 3,
|
|
590
|
+
/** Base retry delay in milliseconds (exponential backoff) */
|
|
591
|
+
retryDelayBase: 1e3
|
|
592
|
+
},
|
|
593
|
+
/**
|
|
594
|
+
* Converter worker configuration.
|
|
595
|
+
* Handles image to markdown conversion via LLM.
|
|
596
|
+
*/
|
|
597
|
+
converter: {
|
|
598
|
+
/** Number of parallel converter workers */
|
|
599
|
+
count: 3,
|
|
600
|
+
/** Poll interval for checking new task details (ms) */
|
|
601
|
+
pollInterval: 2e3,
|
|
602
|
+
/** Timeout for LLM conversion (ms) */
|
|
603
|
+
timeout: 12e4,
|
|
604
|
+
// 2 minutes
|
|
605
|
+
/** Maximum retry attempts for transient errors */
|
|
606
|
+
maxRetries: 3,
|
|
607
|
+
/** Base retry delay in milliseconds (exponential backoff) */
|
|
608
|
+
retryDelayBase: 1e3,
|
|
609
|
+
/** Maximum content length in characters */
|
|
610
|
+
maxContentLength: 5e5
|
|
611
|
+
},
|
|
612
|
+
/**
|
|
613
|
+
* Merger worker configuration.
|
|
614
|
+
* Handles merging markdown pages into final document.
|
|
615
|
+
*/
|
|
616
|
+
merger: {
|
|
617
|
+
/** Poll interval for checking tasks ready to merge (ms) */
|
|
618
|
+
pollInterval: 2e3
|
|
619
|
+
},
|
|
620
|
+
/**
|
|
621
|
+
* Health check configuration.
|
|
622
|
+
* Monitors and recovers stuck workers/tasks.
|
|
623
|
+
*/
|
|
624
|
+
healthCheck: {
|
|
625
|
+
/** Health check interval (ms) */
|
|
626
|
+
interval: 6e4,
|
|
627
|
+
// 1 minute
|
|
628
|
+
/** Task timeout threshold (ms) */
|
|
629
|
+
taskTimeout: 3e5
|
|
630
|
+
// 5 minutes
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
class PDFSplitter {
|
|
634
|
+
uploadsDir;
|
|
635
|
+
constructor(uploadsDir) {
|
|
636
|
+
this.uploadsDir = uploadsDir;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Split a PDF file into individual page images.
|
|
640
|
+
*
|
|
641
|
+
* @param task - Task containing PDF file information
|
|
642
|
+
* @returns Split result with page information
|
|
643
|
+
* @throws Error if splitting fails after retries
|
|
644
|
+
*/
|
|
645
|
+
async split(task) {
|
|
646
|
+
if (!task.id) {
|
|
647
|
+
throw new Error("Task ID is required");
|
|
648
|
+
}
|
|
649
|
+
if (!task.filename) {
|
|
650
|
+
throw new Error("Task filename is required");
|
|
651
|
+
}
|
|
652
|
+
const taskId = task.id;
|
|
653
|
+
const filename = task.filename;
|
|
654
|
+
const sourcePath = path.join(this.uploadsDir, taskId, filename);
|
|
655
|
+
try {
|
|
656
|
+
const totalPages = await this.getPDFPageCountWithRetry(sourcePath);
|
|
657
|
+
const pageNumbers = PageRangeParser.parse(task.page_range, totalPages);
|
|
658
|
+
const pages = await this.convertPagesWithRetry(
|
|
659
|
+
sourcePath,
|
|
660
|
+
taskId,
|
|
661
|
+
pageNumbers,
|
|
662
|
+
task.page_range
|
|
663
|
+
);
|
|
664
|
+
return {
|
|
665
|
+
pages,
|
|
666
|
+
totalPages: pages.length
|
|
667
|
+
};
|
|
668
|
+
} catch (error) {
|
|
669
|
+
throw this.wrapError(error, taskId, filename);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Get PDF page count with retry logic.
|
|
674
|
+
* Uses pdf-lib for accurate page count without converting pages.
|
|
675
|
+
*/
|
|
676
|
+
async getPDFPageCountWithRetry(pdfPath) {
|
|
677
|
+
return this.withRetry(async () => {
|
|
678
|
+
const pdfBytes = await promises.readFile(pdfPath);
|
|
679
|
+
const pdfDoc = await PDFDocument.load(pdfBytes, {
|
|
680
|
+
ignoreEncryption: false
|
|
681
|
+
// Will throw error for encrypted PDFs
|
|
682
|
+
});
|
|
683
|
+
const pageCount = pdfDoc.getPageCount();
|
|
684
|
+
if (pageCount < 1) {
|
|
685
|
+
throw new Error("PDF has no pages");
|
|
686
|
+
}
|
|
687
|
+
return pageCount;
|
|
688
|
+
}, "get PDF page count");
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Convert specified pages with retry logic.
|
|
692
|
+
*/
|
|
693
|
+
async convertPagesWithRetry(pdfPath, taskId, pageNumbers, pageRange) {
|
|
694
|
+
const taskDir = ImagePathUtil.getTaskDir(taskId);
|
|
695
|
+
await promises.mkdir(taskDir, { recursive: true });
|
|
696
|
+
return this.withRetry(async () => {
|
|
697
|
+
const relativeOutputFolder = path.relative(process.cwd(), taskDir);
|
|
698
|
+
const options = {
|
|
699
|
+
outputFolder: relativeOutputFolder,
|
|
700
|
+
viewportScale: WORKER_CONFIG.splitter.viewportScale,
|
|
701
|
+
strictPagesToProcess: false,
|
|
702
|
+
verbosityLevel: 0
|
|
703
|
+
};
|
|
704
|
+
if (pageRange && pageRange.trim() !== "") {
|
|
705
|
+
options.pagesToProcess = pageNumbers;
|
|
706
|
+
}
|
|
707
|
+
const result = await pdfToPng(pdfPath, options);
|
|
708
|
+
if (!result || result.length === 0) {
|
|
709
|
+
throw new Error("PDF conversion produced no output");
|
|
710
|
+
}
|
|
711
|
+
const pages = [];
|
|
712
|
+
for (let i = 0; i < result.length; i++) {
|
|
713
|
+
const pageNum = i + 1;
|
|
714
|
+
const sourcePageNum = pageRange && pageRange.trim() !== "" ? pageNumbers[i] : pageNum;
|
|
715
|
+
const targetPath = ImagePathUtil.getPath(taskId, pageNum);
|
|
716
|
+
if (result[i].path !== targetPath) {
|
|
717
|
+
await promises.rename(result[i].path, targetPath);
|
|
718
|
+
}
|
|
719
|
+
pages.push({
|
|
720
|
+
page: pageNum,
|
|
721
|
+
pageSource: sourcePageNum,
|
|
722
|
+
imagePath: targetPath
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
return pages;
|
|
726
|
+
}, "convert PDF pages");
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Generic retry wrapper with exponential backoff.
|
|
730
|
+
*
|
|
731
|
+
* @param operation - Async operation to retry
|
|
732
|
+
* @param operationName - Name for logging
|
|
733
|
+
* @returns Operation result
|
|
734
|
+
*/
|
|
735
|
+
async withRetry(operation, operationName) {
|
|
736
|
+
const maxRetries = WORKER_CONFIG.splitter.maxRetries;
|
|
737
|
+
const baseDelay = WORKER_CONFIG.splitter.retryDelayBase;
|
|
738
|
+
let lastError = null;
|
|
739
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
740
|
+
try {
|
|
741
|
+
return await operation();
|
|
742
|
+
} catch (error) {
|
|
743
|
+
lastError = error;
|
|
744
|
+
const errorMessage = lastError.message.toLowerCase();
|
|
745
|
+
if (errorMessage.includes("password") || errorMessage.includes("encrypted") || errorMessage.includes("invalid pdf")) {
|
|
746
|
+
throw lastError;
|
|
747
|
+
}
|
|
748
|
+
if (attempt < maxRetries - 1) {
|
|
749
|
+
const delay = baseDelay * Math.pow(2, attempt);
|
|
750
|
+
console.warn(
|
|
751
|
+
`[PDFSplitter] Failed to ${operationName} (attempt ${attempt + 1}/${maxRetries}): ${lastError.message}. Retrying in ${delay}ms...`
|
|
752
|
+
);
|
|
753
|
+
await this.sleep(delay);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
throw new Error(
|
|
758
|
+
`Failed to ${operationName} after ${maxRetries} attempts: ${lastError?.message || "Unknown error"}`
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Wrap errors with friendly, actionable messages.
|
|
763
|
+
*/
|
|
764
|
+
wrapError(error, taskId, filename) {
|
|
765
|
+
const err = error;
|
|
766
|
+
const message = err.message.toLowerCase();
|
|
767
|
+
if (message.includes("password") || message.includes("encrypted")) {
|
|
768
|
+
return new Error(
|
|
769
|
+
`Cannot process password-protected PDF: ${filename}. Please provide an unencrypted version.`
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
if (message.includes("invalid pdf") || message.includes("corrupt")) {
|
|
773
|
+
return new Error(
|
|
774
|
+
`PDF file appears to be corrupted or invalid: ${filename}. Please check the file.`
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
if (message.includes("enoent") || message.includes("file not found")) {
|
|
778
|
+
return new Error(
|
|
779
|
+
`PDF file not found: ${filename}. The file may have been moved or deleted.`
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
return new Error(`Failed to split PDF ${filename}: ${err.message}`);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Clean up temporary files for a task.
|
|
786
|
+
*
|
|
787
|
+
* @param taskId - Task ID to clean up
|
|
788
|
+
*/
|
|
789
|
+
async cleanup(taskId) {
|
|
790
|
+
const taskDir = ImagePathUtil.getTaskDir(taskId);
|
|
791
|
+
try {
|
|
792
|
+
await promises.rm(taskDir, { recursive: true, force: true });
|
|
793
|
+
} catch (error) {
|
|
794
|
+
console.warn(`[PDFSplitter] Failed to cleanup task ${taskId}:`, error);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Sleep for the specified duration.
|
|
799
|
+
*/
|
|
800
|
+
sleep(ms) {
|
|
801
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
class ImageSplitter {
|
|
805
|
+
uploadsDir;
|
|
806
|
+
constructor(uploadsDir) {
|
|
807
|
+
this.uploadsDir = uploadsDir;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* "Split" an image file (actually just copy to temp directory as page-1.png).
|
|
811
|
+
*
|
|
812
|
+
* @param task - Task containing image file information
|
|
813
|
+
* @returns Split result with single page
|
|
814
|
+
* @throws Error if file not found or copy fails
|
|
815
|
+
*/
|
|
816
|
+
async split(task) {
|
|
817
|
+
if (!task.id) {
|
|
818
|
+
throw new Error("Task ID is required");
|
|
819
|
+
}
|
|
820
|
+
if (!task.filename) {
|
|
821
|
+
throw new Error("Task filename is required");
|
|
822
|
+
}
|
|
823
|
+
const taskId = task.id;
|
|
824
|
+
const filename = task.filename;
|
|
825
|
+
const sourcePath = path.join(this.uploadsDir, taskId, filename);
|
|
826
|
+
try {
|
|
827
|
+
await promises.access(sourcePath);
|
|
828
|
+
const ext = path.extname(filename).toLowerCase();
|
|
829
|
+
if (!ext) {
|
|
830
|
+
throw new Error(`Image file has no extension: ${filename}`);
|
|
831
|
+
}
|
|
832
|
+
const taskDir = ImagePathUtil.getTaskDir(taskId);
|
|
833
|
+
await promises.mkdir(taskDir, { recursive: true });
|
|
834
|
+
const targetPath = ImagePathUtil.getPath(taskId, 1);
|
|
835
|
+
await promises.copyFile(sourcePath, targetPath);
|
|
836
|
+
const pageInfo = {
|
|
837
|
+
page: 1,
|
|
838
|
+
pageSource: 1,
|
|
839
|
+
imagePath: targetPath
|
|
840
|
+
};
|
|
841
|
+
return {
|
|
842
|
+
pages: [pageInfo],
|
|
843
|
+
totalPages: 1
|
|
844
|
+
};
|
|
845
|
+
} catch (error) {
|
|
846
|
+
throw this.wrapError(error, taskId, filename);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Wrap errors with friendly, actionable messages.
|
|
851
|
+
*/
|
|
852
|
+
wrapError(error, taskId, filename) {
|
|
853
|
+
const err = error;
|
|
854
|
+
const message = err.message.toLowerCase();
|
|
855
|
+
if (message.includes("enoent") || message.includes("no such file")) {
|
|
856
|
+
return new Error(
|
|
857
|
+
`Image file not found: ${filename}. The file may have been moved or deleted.`
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
if (message.includes("eacces") || message.includes("permission denied")) {
|
|
861
|
+
return new Error(
|
|
862
|
+
`Permission denied accessing image file: ${filename}. Check file permissions.`
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
if (message.includes("enospc") || message.includes("no space")) {
|
|
866
|
+
return new Error(
|
|
867
|
+
`Not enough disk space to process image: ${filename}. Free up space and try again.`
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
return new Error(`Failed to process image ${filename}: ${err.message}`);
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Clean up temporary files for a task.
|
|
874
|
+
*
|
|
875
|
+
* @param taskId - Task ID to clean up
|
|
876
|
+
*/
|
|
877
|
+
async cleanup(taskId) {
|
|
878
|
+
const taskDir = ImagePathUtil.getTaskDir(taskId);
|
|
879
|
+
try {
|
|
880
|
+
await promises.rm(taskDir, { recursive: true, force: true });
|
|
881
|
+
} catch (error) {
|
|
882
|
+
console.warn(`[ImageSplitter] Failed to cleanup task ${taskId}:`, error);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
class SplitterFactory {
|
|
887
|
+
uploadsDir;
|
|
888
|
+
constructor(uploadsDir) {
|
|
889
|
+
this.uploadsDir = uploadsDir;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Create a splitter for the given file type.
|
|
893
|
+
*
|
|
894
|
+
* @param fileType - File type (e.g., "pdf", "jpg", "png")
|
|
895
|
+
* @returns Appropriate splitter instance
|
|
896
|
+
* @throws Error if file type is not supported
|
|
897
|
+
*/
|
|
898
|
+
create(fileType) {
|
|
899
|
+
const normalizedType = fileType.toLowerCase().trim();
|
|
900
|
+
switch (normalizedType) {
|
|
901
|
+
case "pdf":
|
|
902
|
+
return new PDFSplitter(this.uploadsDir);
|
|
903
|
+
case "jpg":
|
|
904
|
+
case "jpeg":
|
|
905
|
+
case "png":
|
|
906
|
+
case "webp":
|
|
907
|
+
return new ImageSplitter(this.uploadsDir);
|
|
908
|
+
default:
|
|
909
|
+
throw new Error(
|
|
910
|
+
`Unsupported file type: ${fileType}. Supported types: pdf, jpg, jpeg, png, webp`
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Extract file type from filename.
|
|
916
|
+
*
|
|
917
|
+
* @param filename - File name (e.g., "document.pdf", "image.JPG")
|
|
918
|
+
* @returns File extension in lowercase without the dot (e.g., "pdf", "jpg")
|
|
919
|
+
* @throws Error if filename has no extension
|
|
920
|
+
*/
|
|
921
|
+
static getFileType(filename) {
|
|
922
|
+
const ext = path.extname(filename);
|
|
923
|
+
if (!ext || ext === ".") {
|
|
924
|
+
throw new Error(`Filename has no extension: ${filename}`);
|
|
925
|
+
}
|
|
926
|
+
return ext.slice(1).toLowerCase();
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Create a splitter for the given filename.
|
|
930
|
+
*
|
|
931
|
+
* Convenience method that combines getFileType() and create().
|
|
932
|
+
*
|
|
933
|
+
* @param filename - File name
|
|
934
|
+
* @returns Appropriate splitter instance
|
|
935
|
+
* @throws Error if file type is not supported or filename has no extension
|
|
936
|
+
*/
|
|
937
|
+
createFromFilename(filename) {
|
|
938
|
+
const fileType = SplitterFactory.getFileType(filename);
|
|
939
|
+
return this.create(fileType);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
class SplitterWorker extends WorkerBase {
|
|
943
|
+
factory;
|
|
944
|
+
pollInterval;
|
|
945
|
+
constructor(uploadsDir) {
|
|
946
|
+
super();
|
|
947
|
+
this.factory = new SplitterFactory(uploadsDir);
|
|
948
|
+
this.pollInterval = WORKER_CONFIG.splitter.pollInterval;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Main worker loop.
|
|
952
|
+
* Continuously polls for PENDING tasks and processes them.
|
|
953
|
+
*/
|
|
954
|
+
async run() {
|
|
955
|
+
this.isRunning = true;
|
|
956
|
+
console.log(`[Splitter-${this.workerId}] Started. Poll interval: ${this.pollInterval}ms`);
|
|
957
|
+
while (this.isRunning) {
|
|
958
|
+
try {
|
|
959
|
+
const task = await this.claimTask(TaskStatus.PENDING, TaskStatus.SPLITTING);
|
|
960
|
+
if (task) {
|
|
961
|
+
console.log(`[Splitter-${this.workerId}] Processing task ${task.id}: ${task.filename}`);
|
|
962
|
+
await this.splitTask(task);
|
|
963
|
+
} else {
|
|
964
|
+
await this.sleep(this.pollInterval);
|
|
965
|
+
}
|
|
966
|
+
} catch (error) {
|
|
967
|
+
console.error(`[Splitter-${this.workerId}] Unexpected error in main loop:`, error);
|
|
968
|
+
await this.sleep(this.pollInterval);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
console.log(`[Splitter-${this.workerId}] Stopped.`);
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Split a single task.
|
|
975
|
+
*
|
|
976
|
+
* @param task - Task to process
|
|
977
|
+
*/
|
|
978
|
+
async splitTask(task) {
|
|
979
|
+
if (!task.id || !task.filename) {
|
|
980
|
+
await this.handleError(task.id, new Error("Task missing required fields"));
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
const splitter = this.factory.createFromFilename(task.filename);
|
|
985
|
+
console.log(`[Splitter-${this.workerId}] Splitting ${task.filename}...`);
|
|
986
|
+
const result = await splitter.split(task);
|
|
987
|
+
console.log(
|
|
988
|
+
`[Splitter-${this.workerId}] Split complete: ${result.totalPages} pages generated`
|
|
989
|
+
);
|
|
990
|
+
await this.createTaskDetails(task, result);
|
|
991
|
+
console.log(`[Splitter-${this.workerId}] Task ${task.id} completed successfully`);
|
|
992
|
+
} catch (error) {
|
|
993
|
+
console.error(`[Splitter-${this.workerId}] Failed to split task ${task.id}:`, error);
|
|
994
|
+
await this.handleError(task.id, error);
|
|
995
|
+
try {
|
|
996
|
+
const splitter = this.factory.createFromFilename(task.filename);
|
|
997
|
+
await splitter.cleanup(task.id);
|
|
998
|
+
} catch (cleanupError) {
|
|
999
|
+
console.warn(`[Splitter-${this.workerId}] Cleanup failed for task ${task.id}:`, cleanupError);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Create TaskDetail records and update Task status atomically.
|
|
1005
|
+
*
|
|
1006
|
+
* Uses a Prisma transaction to ensure:
|
|
1007
|
+
* 1. All TaskDetail records are created
|
|
1008
|
+
* 2. Task status is updated to PROCESSING
|
|
1009
|
+
* 3. Task.pages is set to total page count
|
|
1010
|
+
* 4. worker_id is released (set to null)
|
|
1011
|
+
*
|
|
1012
|
+
* @param task - Task being processed
|
|
1013
|
+
* @param result - Split result with page information
|
|
1014
|
+
*/
|
|
1015
|
+
async createTaskDetails(task, result) {
|
|
1016
|
+
const taskId = task.id;
|
|
1017
|
+
try {
|
|
1018
|
+
const updated = await prisma.$transaction(async (tx) => {
|
|
1019
|
+
const taskDetails = result.pages.map((pageInfo) => ({
|
|
1020
|
+
task: taskId,
|
|
1021
|
+
page: pageInfo.page,
|
|
1022
|
+
page_source: pageInfo.pageSource,
|
|
1023
|
+
status: PageStatus.PENDING,
|
|
1024
|
+
// Ready for converter workers
|
|
1025
|
+
worker_id: null,
|
|
1026
|
+
provider: task.provider,
|
|
1027
|
+
model: task.model,
|
|
1028
|
+
content: "",
|
|
1029
|
+
retry_count: 0
|
|
1030
|
+
}));
|
|
1031
|
+
await tx.taskDetail.createMany({
|
|
1032
|
+
data: taskDetails
|
|
1033
|
+
});
|
|
1034
|
+
const updatedTask = await tx.task.update({
|
|
1035
|
+
where: {
|
|
1036
|
+
id: taskId
|
|
1037
|
+
},
|
|
1038
|
+
data: {
|
|
1039
|
+
status: TaskStatus.PROCESSING,
|
|
1040
|
+
// Ready for converter workers
|
|
1041
|
+
pages: result.totalPages,
|
|
1042
|
+
worker_id: null,
|
|
1043
|
+
// Release this worker
|
|
1044
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
return updatedTask;
|
|
1048
|
+
});
|
|
1049
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_UPDATED, {
|
|
1050
|
+
taskId,
|
|
1051
|
+
task: updated,
|
|
1052
|
+
timestamp: Date.now()
|
|
1053
|
+
});
|
|
1054
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_STATUS_CHANGED, {
|
|
1055
|
+
taskId,
|
|
1056
|
+
task: { status: TaskStatus.PROCESSING },
|
|
1057
|
+
timestamp: Date.now()
|
|
1058
|
+
});
|
|
1059
|
+
console.log(
|
|
1060
|
+
`[Splitter-${this.workerId}] Created ${result.totalPages} TaskDetail records for task ${taskId}`
|
|
1061
|
+
);
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
console.error(
|
|
1064
|
+
`[Splitter-${this.workerId}] Failed to create TaskDetail records for task ${taskId}:`,
|
|
1065
|
+
error
|
|
1066
|
+
);
|
|
1067
|
+
throw error;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
const findAll$2 = async () => {
|
|
1072
|
+
return await prisma.provider.findMany({
|
|
1073
|
+
where: {
|
|
1074
|
+
status: 0
|
|
1075
|
+
},
|
|
1076
|
+
select: {
|
|
1077
|
+
id: true,
|
|
1078
|
+
name: true,
|
|
1079
|
+
type: true,
|
|
1080
|
+
api_key: true,
|
|
1081
|
+
base_url: true,
|
|
1082
|
+
suffix: true,
|
|
1083
|
+
status: true,
|
|
1084
|
+
createdAt: true,
|
|
1085
|
+
updatedAt: true
|
|
1086
|
+
},
|
|
1087
|
+
orderBy: [{ createdAt: "desc" }]
|
|
1088
|
+
});
|
|
1089
|
+
};
|
|
1090
|
+
const findById$1 = async (id) => {
|
|
1091
|
+
return await prisma.provider.findUnique({
|
|
1092
|
+
where: { id },
|
|
1093
|
+
select: {
|
|
1094
|
+
id: true,
|
|
1095
|
+
name: true,
|
|
1096
|
+
type: true,
|
|
1097
|
+
api_key: true,
|
|
1098
|
+
base_url: true,
|
|
1099
|
+
suffix: true,
|
|
1100
|
+
status: true,
|
|
1101
|
+
createdAt: true,
|
|
1102
|
+
updatedAt: true
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
};
|
|
1106
|
+
const create$2 = async (data) => {
|
|
1107
|
+
return await prisma.provider.create({
|
|
1108
|
+
data: {
|
|
1109
|
+
name: data?.name || "",
|
|
1110
|
+
type: data?.type || "",
|
|
1111
|
+
api_key: data?.api_key || "",
|
|
1112
|
+
base_url: data?.base_url || "",
|
|
1113
|
+
suffix: data?.suffix || "",
|
|
1114
|
+
status: data?.status || 0
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
};
|
|
1118
|
+
const update$1 = async (id, data) => {
|
|
1119
|
+
return await prisma.provider.update({
|
|
1120
|
+
where: { id },
|
|
1121
|
+
data
|
|
1122
|
+
});
|
|
1123
|
+
};
|
|
1124
|
+
const remove$2 = async (id) => {
|
|
1125
|
+
await prisma.model.deleteMany({
|
|
1126
|
+
where: { provider: id }
|
|
1127
|
+
});
|
|
1128
|
+
return await prisma.provider.delete({
|
|
1129
|
+
where: { id }
|
|
1130
|
+
});
|
|
1131
|
+
};
|
|
1132
|
+
const updateStatus = async (id, status) => {
|
|
1133
|
+
return await prisma.provider.update({
|
|
1134
|
+
where: { id },
|
|
1135
|
+
data: { status }
|
|
1136
|
+
});
|
|
1137
|
+
};
|
|
1138
|
+
const providerRepository = {
|
|
1139
|
+
findAll: findAll$2,
|
|
1140
|
+
findById: findById$1,
|
|
1141
|
+
create: create$2,
|
|
1142
|
+
update: update$1,
|
|
1143
|
+
remove: remove$2,
|
|
1144
|
+
updateStatus
|
|
1145
|
+
};
|
|
1146
|
+
class LLMClient {
|
|
1147
|
+
apiKey;
|
|
1148
|
+
baseUrl;
|
|
1149
|
+
constructor(apiKey, baseUrl = "") {
|
|
1150
|
+
this.apiKey = apiKey;
|
|
1151
|
+
this.baseUrl = baseUrl;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Normalize options for backward compatibility with single prompt
|
|
1155
|
+
* @param options Options that may contain old prompt format
|
|
1156
|
+
* @returns Normalized options
|
|
1157
|
+
*/
|
|
1158
|
+
normalizeOptions(options) {
|
|
1159
|
+
const normalizedOptions = { ...options };
|
|
1160
|
+
if ("prompt" in normalizedOptions && normalizedOptions.prompt) {
|
|
1161
|
+
if (!normalizedOptions.messages || normalizedOptions.messages.length === 0) {
|
|
1162
|
+
normalizedOptions.messages = [{
|
|
1163
|
+
role: "user",
|
|
1164
|
+
content: {
|
|
1165
|
+
type: "text",
|
|
1166
|
+
text: normalizedOptions.prompt
|
|
1167
|
+
}
|
|
1168
|
+
}];
|
|
1169
|
+
}
|
|
1170
|
+
delete normalizedOptions.prompt;
|
|
1171
|
+
}
|
|
1172
|
+
if (normalizedOptions.systemPrompt && (!normalizedOptions.messages || !normalizedOptions.messages.some((m) => m.role === "system"))) {
|
|
1173
|
+
const systemMessage = {
|
|
1174
|
+
role: "system",
|
|
1175
|
+
content: {
|
|
1176
|
+
type: "text",
|
|
1177
|
+
text: normalizedOptions.systemPrompt
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
normalizedOptions.messages = normalizedOptions.messages || [];
|
|
1181
|
+
normalizedOptions.messages.unshift(systemMessage);
|
|
1182
|
+
delete normalizedOptions.systemPrompt;
|
|
1183
|
+
}
|
|
1184
|
+
return normalizedOptions;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
class LLMClientFactory {
|
|
1188
|
+
/**
|
|
1189
|
+
* Create LLM client instance
|
|
1190
|
+
* @param type LLM client type
|
|
1191
|
+
* @param apiKey API key
|
|
1192
|
+
* @param baseUrl Base URL (some services need custom URL)
|
|
1193
|
+
*/
|
|
1194
|
+
static async createClient(type, apiKey, baseUrl) {
|
|
1195
|
+
switch (type) {
|
|
1196
|
+
case "openai": {
|
|
1197
|
+
const OpenAIModule = await import("./OpenAIClient-gyy2nFkw.js");
|
|
1198
|
+
return new OpenAIModule.OpenAIClient(apiKey, baseUrl || "");
|
|
1199
|
+
}
|
|
1200
|
+
case "openai-responses": {
|
|
1201
|
+
const OpenAIResponsesModule = await import("./OpenAIResponsesClient-DETYz2nL.js");
|
|
1202
|
+
return new OpenAIResponsesModule.OpenAIResponsesClient(apiKey, baseUrl || "");
|
|
1203
|
+
}
|
|
1204
|
+
case "gemini": {
|
|
1205
|
+
const GeminiModule = await import("./GeminiClient-CrtYbwaF.js");
|
|
1206
|
+
return new GeminiModule.GeminiClient(apiKey, baseUrl || "");
|
|
1207
|
+
}
|
|
1208
|
+
case "anthropic": {
|
|
1209
|
+
const AnthropicModule = await import("./AnthropicClient-CTbHYiqm.js");
|
|
1210
|
+
return new AnthropicModule.AnthropicClient(apiKey, baseUrl || "");
|
|
1211
|
+
}
|
|
1212
|
+
case "ollama": {
|
|
1213
|
+
const OllamaModule = await import("./OllamaClient-DKJsnvIt.js");
|
|
1214
|
+
return new OllamaModule.OllamaClient(apiKey, baseUrl || "");
|
|
1215
|
+
}
|
|
1216
|
+
default:
|
|
1217
|
+
throw new Error(`Unsupported LLM client type: ${type}`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
const getLLMClient = async (providerId) => {
|
|
1222
|
+
const provider = await providerRepository.findById(providerId);
|
|
1223
|
+
if (!provider) {
|
|
1224
|
+
throw new Error("服务商不存在");
|
|
1225
|
+
}
|
|
1226
|
+
let baseUrl = provider.base_url || "";
|
|
1227
|
+
let suffix = provider.suffix || "";
|
|
1228
|
+
if (!baseUrl) {
|
|
1229
|
+
switch (provider.type) {
|
|
1230
|
+
case "ollama":
|
|
1231
|
+
baseUrl = "http://localhost:11434/api";
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (!suffix) {
|
|
1236
|
+
switch (provider.type) {
|
|
1237
|
+
case "openai":
|
|
1238
|
+
suffix = "/chat/completions";
|
|
1239
|
+
break;
|
|
1240
|
+
case "openai-responses":
|
|
1241
|
+
suffix = "/responses";
|
|
1242
|
+
break;
|
|
1243
|
+
case "gemini":
|
|
1244
|
+
suffix = "/models";
|
|
1245
|
+
break;
|
|
1246
|
+
case "anthropic":
|
|
1247
|
+
suffix = "/messages";
|
|
1248
|
+
break;
|
|
1249
|
+
case "ollama":
|
|
1250
|
+
suffix = "/generate";
|
|
1251
|
+
break;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
return LLMClientFactory.createClient(
|
|
1255
|
+
provider.type || "",
|
|
1256
|
+
provider.api_key || "",
|
|
1257
|
+
`${baseUrl}${suffix}`
|
|
1258
|
+
);
|
|
1259
|
+
};
|
|
1260
|
+
const completion = async (providerId, options) => {
|
|
1261
|
+
const llmClient = await getLLMClient(providerId);
|
|
1262
|
+
return llmClient.completion(options);
|
|
1263
|
+
};
|
|
1264
|
+
const getImageBase64 = async (imagePath) => {
|
|
1265
|
+
const image = sharp(imagePath);
|
|
1266
|
+
const buffer = await image.toBuffer();
|
|
1267
|
+
return buffer.toString("base64");
|
|
1268
|
+
};
|
|
1269
|
+
const transformImageMessage = async (imagePath) => {
|
|
1270
|
+
const imageBase64 = await getImageBase64(imagePath);
|
|
1271
|
+
const message = [{
|
|
1272
|
+
role: "system",
|
|
1273
|
+
content: {
|
|
1274
|
+
type: "text",
|
|
1275
|
+
text: "You are a helpful assistant that can convert images to Markdown format. You are given an image, and you need to convert it to Markdown format. Please output the Markdown content only, without any other text."
|
|
1276
|
+
}
|
|
1277
|
+
}];
|
|
1278
|
+
message.push({
|
|
1279
|
+
role: "user",
|
|
1280
|
+
content: [
|
|
1281
|
+
{
|
|
1282
|
+
type: "text",
|
|
1283
|
+
text: `Below is the image of one page of a document, please read the content in the image and transcribe it into plain Markdown format. Please note:
|
|
1284
|
+
1. Identify heading levels, text styles, formulas, and the format of table rows and columns
|
|
1285
|
+
2. Mathematical formulas should be transcribed using LaTeX syntax, ensuring consistency with the original
|
|
1286
|
+
3. Please output the Markdown content only, without any other text.
|
|
1287
|
+
|
|
1288
|
+
Output Example:
|
|
1289
|
+
\`\`\`markdown
|
|
1290
|
+
{example}
|
|
1291
|
+
\`\`\``
|
|
1292
|
+
},
|
|
1293
|
+
{
|
|
1294
|
+
type: "image_url",
|
|
1295
|
+
image_url: { url: `data:image/jpeg;base64,${imageBase64}` }
|
|
1296
|
+
}
|
|
1297
|
+
]
|
|
1298
|
+
});
|
|
1299
|
+
return message;
|
|
1300
|
+
};
|
|
1301
|
+
const modelLogic = {
|
|
1302
|
+
completion,
|
|
1303
|
+
transformImageMessage
|
|
1304
|
+
};
|
|
1305
|
+
class ConverterWorker extends WorkerBase {
|
|
1306
|
+
maxRetries;
|
|
1307
|
+
maxContentLength;
|
|
1308
|
+
pollInterval;
|
|
1309
|
+
retryDelayBase;
|
|
1310
|
+
currentPageId = null;
|
|
1311
|
+
constructor() {
|
|
1312
|
+
super();
|
|
1313
|
+
this.maxRetries = WORKER_CONFIG.converter.maxRetries;
|
|
1314
|
+
this.maxContentLength = WORKER_CONFIG.converter.maxContentLength;
|
|
1315
|
+
this.pollInterval = WORKER_CONFIG.converter.pollInterval;
|
|
1316
|
+
this.retryDelayBase = WORKER_CONFIG.converter.retryDelayBase;
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Main worker loop.
|
|
1320
|
+
* Continuously polls for PENDING pages and processes them.
|
|
1321
|
+
*/
|
|
1322
|
+
async run() {
|
|
1323
|
+
this.isRunning = true;
|
|
1324
|
+
console.log(`[Converter-${this.workerId.slice(0, 8)}] Started. Poll interval: ${this.pollInterval}ms`);
|
|
1325
|
+
while (this.isRunning) {
|
|
1326
|
+
try {
|
|
1327
|
+
const page = await this.claimPage();
|
|
1328
|
+
if (page) {
|
|
1329
|
+
this.currentPageId = page.id;
|
|
1330
|
+
console.log(`[Converter-${this.workerId.slice(0, 8)}] Claimed page ${page.page} of task ${page.task} (provider: ${page.provider}, model: ${page.model})`);
|
|
1331
|
+
await this.processPageWithRetry(page);
|
|
1332
|
+
this.currentPageId = null;
|
|
1333
|
+
} else {
|
|
1334
|
+
await this.sleep(this.pollInterval);
|
|
1335
|
+
}
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
console.error(`[Converter-${this.workerId.slice(0, 8)}] Unexpected error in main loop:`, error);
|
|
1338
|
+
if (this.currentPageId !== null) {
|
|
1339
|
+
await this.releaseCurrentPage();
|
|
1340
|
+
}
|
|
1341
|
+
await this.sleep(this.pollInterval);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
console.log(`[Converter-${this.workerId.slice(0, 8)}] Stopped.`);
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Stop the worker gracefully.
|
|
1348
|
+
* Releases the current page if any.
|
|
1349
|
+
*/
|
|
1350
|
+
stop() {
|
|
1351
|
+
this.isRunning = false;
|
|
1352
|
+
console.log(`[Converter-${this.workerId.slice(0, 8)}] Stopping...`);
|
|
1353
|
+
if (this.currentPageId !== null) {
|
|
1354
|
+
this.releaseCurrentPage().catch((error) => {
|
|
1355
|
+
console.error(`[Converter-${this.workerId.slice(0, 8)}] Failed to release page on stop:`, error);
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Release the current page back to PENDING state.
|
|
1361
|
+
*/
|
|
1362
|
+
async releaseCurrentPage() {
|
|
1363
|
+
if (this.currentPageId === null) return;
|
|
1364
|
+
try {
|
|
1365
|
+
await prisma.taskDetail.update({
|
|
1366
|
+
where: { id: this.currentPageId },
|
|
1367
|
+
data: {
|
|
1368
|
+
status: PageStatus.PENDING,
|
|
1369
|
+
worker_id: null,
|
|
1370
|
+
started_at: null
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
console.log(`[Converter-${this.workerId.slice(0, 8)}] Released page ${this.currentPageId}`);
|
|
1374
|
+
this.currentPageId = null;
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
console.error(`[Converter-${this.workerId.slice(0, 8)}] Failed to release page ${this.currentPageId}:`, error);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Claim a PENDING page for processing using optimistic locking.
|
|
1381
|
+
*
|
|
1382
|
+
* Query conditions:
|
|
1383
|
+
* - task.status = PROCESSING
|
|
1384
|
+
* - task.status != CANCELLED
|
|
1385
|
+
* - page.status = PENDING
|
|
1386
|
+
* - page.worker_id = null
|
|
1387
|
+
*
|
|
1388
|
+
* Order: retry_count ASC, page ASC (prioritize fresh pages)
|
|
1389
|
+
*/
|
|
1390
|
+
async claimPage() {
|
|
1391
|
+
const maxAttempts = 5;
|
|
1392
|
+
const checkedTaskIds = [];
|
|
1393
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1394
|
+
try {
|
|
1395
|
+
const candidate = await prisma.taskDetail.findFirst({
|
|
1396
|
+
where: {
|
|
1397
|
+
status: PageStatus.PENDING,
|
|
1398
|
+
worker_id: null,
|
|
1399
|
+
// Exclude pages from tasks we've already checked and found not in PROCESSING state
|
|
1400
|
+
...checkedTaskIds.length > 0 && { task: { notIn: checkedTaskIds } }
|
|
1401
|
+
},
|
|
1402
|
+
orderBy: [
|
|
1403
|
+
{ retry_count: "asc" },
|
|
1404
|
+
{ page: "asc" }
|
|
1405
|
+
]
|
|
1406
|
+
});
|
|
1407
|
+
if (!candidate) {
|
|
1408
|
+
return null;
|
|
1409
|
+
}
|
|
1410
|
+
const task = await prisma.task.findUnique({
|
|
1411
|
+
where: { id: candidate.task },
|
|
1412
|
+
select: { status: true }
|
|
1413
|
+
});
|
|
1414
|
+
if (!task || task.status !== TaskStatus.PROCESSING && task.status !== TaskStatus.COMPLETED) {
|
|
1415
|
+
checkedTaskIds.push(candidate.task);
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
const result = await prisma.taskDetail.updateMany({
|
|
1419
|
+
where: {
|
|
1420
|
+
id: candidate.id,
|
|
1421
|
+
status: PageStatus.PENDING,
|
|
1422
|
+
worker_id: null
|
|
1423
|
+
},
|
|
1424
|
+
data: {
|
|
1425
|
+
status: PageStatus.PROCESSING,
|
|
1426
|
+
worker_id: this.workerId,
|
|
1427
|
+
started_at: /* @__PURE__ */ new Date()
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
if (result.count === 1) {
|
|
1431
|
+
const claimed = await prisma.taskDetail.findUnique({
|
|
1432
|
+
where: { id: candidate.id }
|
|
1433
|
+
});
|
|
1434
|
+
if (claimed) {
|
|
1435
|
+
this.emitPageStatusEvent(claimed.task, claimed.id, claimed.page, PageStatus.PROCESSING);
|
|
1436
|
+
}
|
|
1437
|
+
return claimed;
|
|
1438
|
+
}
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
console.error(`[Converter-${this.workerId.slice(0, 8)}] Claim attempt ${attempt + 1} failed:`, error);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Process a page with retry logic.
|
|
1447
|
+
*/
|
|
1448
|
+
async processPageWithRetry(page) {
|
|
1449
|
+
let lastError = null;
|
|
1450
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
1451
|
+
try {
|
|
1452
|
+
const result = await this.convertPage(page);
|
|
1453
|
+
await this.completePageSuccess(page, result);
|
|
1454
|
+
console.log(`[Converter-${this.workerId.slice(0, 8)}] Page ${page.page} of task ${page.task} completed (model: ${page.model})`);
|
|
1455
|
+
return;
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
1458
|
+
const errorType = this.analyzeError(lastError);
|
|
1459
|
+
console.error(
|
|
1460
|
+
`[Converter-${this.workerId.slice(0, 8)}] Page ${page.page} attempt ${attempt + 1} failed (${errorType}, model: ${page.model}):`,
|
|
1461
|
+
lastError.message
|
|
1462
|
+
);
|
|
1463
|
+
if (!this.isRetryableError(errorType)) {
|
|
1464
|
+
console.log(`[Converter-${this.workerId.slice(0, 8)}] Non-retryable error, marking as failed`);
|
|
1465
|
+
break;
|
|
1466
|
+
}
|
|
1467
|
+
if (attempt < this.maxRetries) {
|
|
1468
|
+
const taskStatus = await this.checkTaskStatus(page.task);
|
|
1469
|
+
if (taskStatus === TaskStatus.CANCELLED) {
|
|
1470
|
+
console.log(`[Converter-${this.workerId.slice(0, 8)}] Task cancelled, stopping retries`);
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
await this.incrementRetryCount(page.id);
|
|
1474
|
+
const delay = this.calculateRetryDelay(attempt, errorType);
|
|
1475
|
+
console.log(`[Converter-${this.workerId.slice(0, 8)}] Retrying in ${delay}ms...`);
|
|
1476
|
+
await this.sleep(delay);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
await this.completePageFailed(page, lastError);
|
|
1481
|
+
console.log(`[Converter-${this.workerId.slice(0, 8)}] Page ${page.page} of task ${page.task} failed (model: ${page.model})`);
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Convert a page image to Markdown using LLM.
|
|
1485
|
+
*/
|
|
1486
|
+
async convertPage(page) {
|
|
1487
|
+
const startTime = Date.now();
|
|
1488
|
+
const imagePath = ImagePathUtil.getPath(page.task, page.page);
|
|
1489
|
+
const messages = await modelLogic.transformImageMessage(imagePath);
|
|
1490
|
+
const response = await modelLogic.completion(page.provider, {
|
|
1491
|
+
model: page.model,
|
|
1492
|
+
messages,
|
|
1493
|
+
stream: false
|
|
1494
|
+
// Critical: disabled for token tracking
|
|
1495
|
+
});
|
|
1496
|
+
const inputTokens = this.extractInputTokens(response);
|
|
1497
|
+
const outputTokens = this.extractOutputTokens(response);
|
|
1498
|
+
if (!response.content || response.content.trim().length === 0) {
|
|
1499
|
+
throw new Error("LLM returned empty content");
|
|
1500
|
+
}
|
|
1501
|
+
const markdown = this.cleanMarkdownContent(response.content);
|
|
1502
|
+
if (markdown.length > this.maxContentLength) {
|
|
1503
|
+
throw new Error(`Content exceeds maximum length: ${markdown.length} > ${this.maxContentLength}`);
|
|
1504
|
+
}
|
|
1505
|
+
const conversionTime = Date.now() - startTime;
|
|
1506
|
+
return {
|
|
1507
|
+
markdown,
|
|
1508
|
+
inputTokens,
|
|
1509
|
+
outputTokens,
|
|
1510
|
+
conversionTime
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Mark page as successfully completed and update task progress.
|
|
1515
|
+
*/
|
|
1516
|
+
async completePageSuccess(page, result) {
|
|
1517
|
+
const maxAttempts = 3;
|
|
1518
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1519
|
+
try {
|
|
1520
|
+
await prisma.$transaction(
|
|
1521
|
+
async (tx) => {
|
|
1522
|
+
const currentPage = await tx.taskDetail.findUnique({
|
|
1523
|
+
where: { id: page.id },
|
|
1524
|
+
select: { worker_id: true, status: true }
|
|
1525
|
+
});
|
|
1526
|
+
if (!currentPage || currentPage.worker_id !== this.workerId) {
|
|
1527
|
+
throw new Error("Page claimed by another worker");
|
|
1528
|
+
}
|
|
1529
|
+
if (currentPage.status === PageStatus.COMPLETED) {
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
const task = await tx.task.findUnique({
|
|
1533
|
+
where: { id: page.task },
|
|
1534
|
+
select: { status: true, pages: true, completed_count: true, failed_count: true }
|
|
1535
|
+
});
|
|
1536
|
+
if (!task) {
|
|
1537
|
+
throw new Error("Task not found");
|
|
1538
|
+
}
|
|
1539
|
+
if (task.status === TaskStatus.CANCELLED) {
|
|
1540
|
+
throw new Error("Task has been cancelled");
|
|
1541
|
+
}
|
|
1542
|
+
await tx.taskDetail.update({
|
|
1543
|
+
where: { id: page.id },
|
|
1544
|
+
data: {
|
|
1545
|
+
status: PageStatus.COMPLETED,
|
|
1546
|
+
content: result.markdown,
|
|
1547
|
+
input_tokens: result.inputTokens,
|
|
1548
|
+
output_tokens: result.outputTokens,
|
|
1549
|
+
conversion_time: result.conversionTime,
|
|
1550
|
+
completed_at: /* @__PURE__ */ new Date(),
|
|
1551
|
+
worker_id: null,
|
|
1552
|
+
// Release worker
|
|
1553
|
+
error: null
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
const updatedTask = await tx.task.update({
|
|
1557
|
+
where: { id: page.task },
|
|
1558
|
+
data: {
|
|
1559
|
+
completed_count: { increment: 1 }
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
const finishedCount = updatedTask.completed_count + task.failed_count;
|
|
1563
|
+
if (finishedCount >= task.pages) {
|
|
1564
|
+
const newStatus = task.failed_count > 0 ? TaskStatus.PARTIAL_FAILED : TaskStatus.READY_TO_MERGE;
|
|
1565
|
+
await tx.task.update({
|
|
1566
|
+
where: { id: page.task },
|
|
1567
|
+
data: {
|
|
1568
|
+
status: newStatus,
|
|
1569
|
+
worker_id: null
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
const progress = Math.round(updatedTask.completed_count / task.pages * 100);
|
|
1574
|
+
await tx.task.update({
|
|
1575
|
+
where: { id: page.task },
|
|
1576
|
+
data: { progress }
|
|
1577
|
+
});
|
|
1578
|
+
},
|
|
1579
|
+
{
|
|
1580
|
+
isolationLevel: "Serializable"
|
|
1581
|
+
}
|
|
1582
|
+
);
|
|
1583
|
+
this.emitPageStatusEvent(page.task, page.id, page.page, PageStatus.COMPLETED);
|
|
1584
|
+
this.emitProgressEvent(page.task);
|
|
1585
|
+
return;
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
if (error.code === "P2034" && attempt < maxAttempts - 1) {
|
|
1588
|
+
console.warn(`[Converter-${this.workerId.slice(0, 8)}] Transaction conflict, retrying...`);
|
|
1589
|
+
await this.sleep(100 * (attempt + 1));
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
throw error;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Mark page as failed and update task progress.
|
|
1598
|
+
*/
|
|
1599
|
+
async completePageFailed(page, error) {
|
|
1600
|
+
const maxAttempts = 3;
|
|
1601
|
+
const errorMessage = this.formatError(error);
|
|
1602
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1603
|
+
try {
|
|
1604
|
+
await prisma.$transaction(
|
|
1605
|
+
async (tx) => {
|
|
1606
|
+
const currentPage = await tx.taskDetail.findUnique({
|
|
1607
|
+
where: { id: page.id },
|
|
1608
|
+
select: { worker_id: true, status: true }
|
|
1609
|
+
});
|
|
1610
|
+
if (!currentPage || currentPage.worker_id !== this.workerId) {
|
|
1611
|
+
throw new Error("Page claimed by another worker");
|
|
1612
|
+
}
|
|
1613
|
+
if (currentPage.status === PageStatus.FAILED) {
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
const task = await tx.task.findUnique({
|
|
1617
|
+
where: { id: page.task },
|
|
1618
|
+
select: { status: true, pages: true, completed_count: true, failed_count: true }
|
|
1619
|
+
});
|
|
1620
|
+
if (!task) {
|
|
1621
|
+
throw new Error("Task not found");
|
|
1622
|
+
}
|
|
1623
|
+
if (task.status === TaskStatus.CANCELLED) {
|
|
1624
|
+
throw new Error("Task has been cancelled");
|
|
1625
|
+
}
|
|
1626
|
+
await tx.taskDetail.update({
|
|
1627
|
+
where: { id: page.id },
|
|
1628
|
+
data: {
|
|
1629
|
+
status: PageStatus.FAILED,
|
|
1630
|
+
error: errorMessage,
|
|
1631
|
+
completed_at: /* @__PURE__ */ new Date(),
|
|
1632
|
+
worker_id: null
|
|
1633
|
+
// Release worker
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
const updatedTask = await tx.task.update({
|
|
1637
|
+
where: { id: page.task },
|
|
1638
|
+
data: {
|
|
1639
|
+
failed_count: { increment: 1 }
|
|
1640
|
+
}
|
|
1641
|
+
});
|
|
1642
|
+
const finishedCount = task.completed_count + updatedTask.failed_count;
|
|
1643
|
+
if (finishedCount >= task.pages) {
|
|
1644
|
+
const newStatus = task.completed_count > 0 ? TaskStatus.PARTIAL_FAILED : TaskStatus.FAILED;
|
|
1645
|
+
await tx.task.update({
|
|
1646
|
+
where: { id: page.task },
|
|
1647
|
+
data: {
|
|
1648
|
+
status: newStatus,
|
|
1649
|
+
worker_id: null,
|
|
1650
|
+
// Write page error to task when all pages failed
|
|
1651
|
+
error: newStatus === TaskStatus.FAILED ? errorMessage : void 0
|
|
1652
|
+
}
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
const progress = Math.round(task.completed_count / task.pages * 100);
|
|
1656
|
+
await tx.task.update({
|
|
1657
|
+
where: { id: page.task },
|
|
1658
|
+
data: { progress }
|
|
1659
|
+
});
|
|
1660
|
+
},
|
|
1661
|
+
{
|
|
1662
|
+
isolationLevel: "Serializable"
|
|
1663
|
+
}
|
|
1664
|
+
);
|
|
1665
|
+
this.emitPageStatusEvent(page.task, page.id, page.page, PageStatus.FAILED);
|
|
1666
|
+
this.emitProgressEvent(page.task);
|
|
1667
|
+
return;
|
|
1668
|
+
} catch (error2) {
|
|
1669
|
+
if (error2.code === "P2034" && attempt < maxAttempts - 1) {
|
|
1670
|
+
console.warn(`[Converter-${this.workerId.slice(0, 8)}] Transaction conflict, retrying...`);
|
|
1671
|
+
await this.sleep(100 * (attempt + 1));
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
throw error2;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Check task status from database.
|
|
1680
|
+
*/
|
|
1681
|
+
async checkTaskStatus(taskId) {
|
|
1682
|
+
const task = await prisma.task.findUnique({
|
|
1683
|
+
where: { id: taskId },
|
|
1684
|
+
select: { status: true }
|
|
1685
|
+
});
|
|
1686
|
+
return task?.status ?? null;
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Increment retry count for a page.
|
|
1690
|
+
*/
|
|
1691
|
+
async incrementRetryCount(pageId) {
|
|
1692
|
+
await prisma.taskDetail.update({
|
|
1693
|
+
where: { id: pageId },
|
|
1694
|
+
data: {
|
|
1695
|
+
retry_count: { increment: 1 }
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Emit page status change event.
|
|
1701
|
+
*/
|
|
1702
|
+
emitPageStatusEvent(taskId, pageId, page, status) {
|
|
1703
|
+
eventBus.emitTaskDetailEvent(TaskEventType.TASK_DETAIL_UPDATED, {
|
|
1704
|
+
taskId,
|
|
1705
|
+
pageId,
|
|
1706
|
+
page,
|
|
1707
|
+
status,
|
|
1708
|
+
timestamp: Date.now()
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Emit task progress event.
|
|
1713
|
+
*/
|
|
1714
|
+
emitProgressEvent(taskId) {
|
|
1715
|
+
prisma.task.findUnique({
|
|
1716
|
+
where: { id: taskId }
|
|
1717
|
+
}).then((task) => {
|
|
1718
|
+
if (task) {
|
|
1719
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_UPDATED, {
|
|
1720
|
+
taskId,
|
|
1721
|
+
task,
|
|
1722
|
+
timestamp: Date.now()
|
|
1723
|
+
});
|
|
1724
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_PROGRESS_CHANGED, {
|
|
1725
|
+
taskId,
|
|
1726
|
+
task: { progress: task.progress },
|
|
1727
|
+
timestamp: Date.now()
|
|
1728
|
+
});
|
|
1729
|
+
if (task.status === TaskStatus.READY_TO_MERGE || task.status === TaskStatus.PARTIAL_FAILED) {
|
|
1730
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_STATUS_CHANGED, {
|
|
1731
|
+
taskId,
|
|
1732
|
+
task: { status: task.status },
|
|
1733
|
+
timestamp: Date.now()
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}).catch((error) => {
|
|
1738
|
+
console.error(`[Converter-${this.workerId.slice(0, 8)}] Failed to emit progress event:`, error);
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Extract input tokens from LLM response.
|
|
1743
|
+
* Adapts to different provider response formats.
|
|
1744
|
+
*/
|
|
1745
|
+
extractInputTokens(response) {
|
|
1746
|
+
const raw = response.rawResponse;
|
|
1747
|
+
if (!raw) return 0;
|
|
1748
|
+
if (raw.usage?.prompt_tokens !== void 0) {
|
|
1749
|
+
return raw.usage.prompt_tokens;
|
|
1750
|
+
}
|
|
1751
|
+
if (raw.usage?.input_tokens !== void 0) {
|
|
1752
|
+
return raw.usage.input_tokens;
|
|
1753
|
+
}
|
|
1754
|
+
if (raw.usageMetadata?.promptTokenCount !== void 0) {
|
|
1755
|
+
return raw.usageMetadata.promptTokenCount;
|
|
1756
|
+
}
|
|
1757
|
+
return 0;
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Extract output tokens from LLM response.
|
|
1761
|
+
* Adapts to different provider response formats.
|
|
1762
|
+
*/
|
|
1763
|
+
extractOutputTokens(response) {
|
|
1764
|
+
const raw = response.rawResponse;
|
|
1765
|
+
if (!raw) return 0;
|
|
1766
|
+
if (raw.usage?.completion_tokens !== void 0) {
|
|
1767
|
+
return raw.usage.completion_tokens;
|
|
1768
|
+
}
|
|
1769
|
+
if (raw.usage?.output_tokens !== void 0) {
|
|
1770
|
+
return raw.usage.output_tokens;
|
|
1771
|
+
}
|
|
1772
|
+
if (raw.usageMetadata?.candidatesTokenCount !== void 0) {
|
|
1773
|
+
return raw.usageMetadata.candidatesTokenCount;
|
|
1774
|
+
}
|
|
1775
|
+
return 0;
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* Clean markdown content by removing code block markers.
|
|
1779
|
+
*/
|
|
1780
|
+
cleanMarkdownContent(content) {
|
|
1781
|
+
let cleaned = content.trim();
|
|
1782
|
+
if (cleaned.startsWith("```markdown")) {
|
|
1783
|
+
cleaned = cleaned.slice("```markdown".length);
|
|
1784
|
+
} else if (cleaned.startsWith("```md")) {
|
|
1785
|
+
cleaned = cleaned.slice("```md".length);
|
|
1786
|
+
} else if (cleaned.startsWith("```")) {
|
|
1787
|
+
cleaned = cleaned.slice("```".length);
|
|
1788
|
+
}
|
|
1789
|
+
if (cleaned.endsWith("```")) {
|
|
1790
|
+
cleaned = cleaned.slice(0, -3);
|
|
1791
|
+
}
|
|
1792
|
+
return cleaned.trim();
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Format error message for storage (truncate to 500 chars).
|
|
1796
|
+
*/
|
|
1797
|
+
formatError(error) {
|
|
1798
|
+
const message = error.message || String(error);
|
|
1799
|
+
if (message.length > 500) {
|
|
1800
|
+
return message.slice(0, 497) + "...";
|
|
1801
|
+
}
|
|
1802
|
+
return message;
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Analyze error to determine type.
|
|
1806
|
+
*/
|
|
1807
|
+
analyzeError(error) {
|
|
1808
|
+
const message = error.message.toLowerCase();
|
|
1809
|
+
if (message.includes("network") || message.includes("econnrefused") || message.includes("enotfound") || message.includes("etimedout") || message.includes("fetch failed") || message.includes("socket hang up")) {
|
|
1810
|
+
return "network_error";
|
|
1811
|
+
}
|
|
1812
|
+
if (message.includes("rate limit") || message.includes("rate_limit") || message.includes("too many requests") || message.includes("429")) {
|
|
1813
|
+
return "rate_limit_error";
|
|
1814
|
+
}
|
|
1815
|
+
if (message.includes("quota") || message.includes("insufficient_quota") || message.includes("billing")) {
|
|
1816
|
+
return "quota_exceeded_error";
|
|
1817
|
+
}
|
|
1818
|
+
if (message.includes("api key") || message.includes("apikey") || message.includes("invalid_api_key") || message.includes("unauthorized") || message.includes("authentication") || message.includes("model not found") || message.includes("not exist")) {
|
|
1819
|
+
return "config_error";
|
|
1820
|
+
}
|
|
1821
|
+
if (message.includes("enoent") || message.includes("file not found") || message.includes("no such file")) {
|
|
1822
|
+
return "file_error";
|
|
1823
|
+
}
|
|
1824
|
+
if (message.includes("timeout") || message.includes("timed out")) {
|
|
1825
|
+
return "timeout_error";
|
|
1826
|
+
}
|
|
1827
|
+
if (message.includes("llm") || message.includes("api") || message.includes("openai") || message.includes("anthropic") || message.includes("gemini")) {
|
|
1828
|
+
return "llm_error";
|
|
1829
|
+
}
|
|
1830
|
+
return "unknown_error";
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Determine if an error type is retryable.
|
|
1834
|
+
*/
|
|
1835
|
+
isRetryableError(errorType) {
|
|
1836
|
+
switch (errorType) {
|
|
1837
|
+
case "network_error":
|
|
1838
|
+
case "llm_error":
|
|
1839
|
+
case "rate_limit_error":
|
|
1840
|
+
case "timeout_error":
|
|
1841
|
+
case "unknown_error":
|
|
1842
|
+
return true;
|
|
1843
|
+
case "quota_exceeded_error":
|
|
1844
|
+
case "config_error":
|
|
1845
|
+
case "file_error":
|
|
1846
|
+
return false;
|
|
1847
|
+
default:
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Calculate retry delay with exponential backoff and jitter.
|
|
1853
|
+
*/
|
|
1854
|
+
calculateRetryDelay(attempt, errorType) {
|
|
1855
|
+
let delay = this.retryDelayBase * Math.pow(2, attempt);
|
|
1856
|
+
if (errorType === "rate_limit_error") {
|
|
1857
|
+
delay *= 2;
|
|
1858
|
+
}
|
|
1859
|
+
const jitter = Math.random() * delay * 0.25;
|
|
1860
|
+
delay += jitter;
|
|
1861
|
+
return Math.min(delay, 3e4);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
class MergerWorker extends WorkerBase {
|
|
1865
|
+
/** 上传文件根目录 */
|
|
1866
|
+
uploadsDir;
|
|
1867
|
+
/** 当前正在处理的任务 ID,用于优雅停止时释放 */
|
|
1868
|
+
currentTaskId = null;
|
|
1869
|
+
/**
|
|
1870
|
+
* 构造函数
|
|
1871
|
+
* @param uploadsDir - 上传文件根目录,合并后的文件将保存在此目录下的任务子目录中
|
|
1872
|
+
*/
|
|
1873
|
+
constructor(uploadsDir) {
|
|
1874
|
+
super();
|
|
1875
|
+
this.uploadsDir = uploadsDir;
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Worker 主循环
|
|
1879
|
+
*
|
|
1880
|
+
* 持续轮询 READY_TO_MERGE 状态的任务,抢占后进行合并处理。
|
|
1881
|
+
* 通过 isRunning 标志支持优雅停止。
|
|
1882
|
+
*/
|
|
1883
|
+
async run() {
|
|
1884
|
+
this.isRunning = true;
|
|
1885
|
+
console.log(`[Merger-${this.workerId.slice(0, 8)}] Started`);
|
|
1886
|
+
while (this.isRunning) {
|
|
1887
|
+
try {
|
|
1888
|
+
const task = await this.claimTask(
|
|
1889
|
+
TaskStatus.READY_TO_MERGE,
|
|
1890
|
+
TaskStatus.MERGING
|
|
1891
|
+
);
|
|
1892
|
+
if (!task) {
|
|
1893
|
+
await this.sleep(WORKER_CONFIG.merger.pollInterval);
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1896
|
+
this.currentTaskId = task.id;
|
|
1897
|
+
console.log(`[Merger-${this.workerId.slice(0, 8)}] Claimed task ${task.id}`);
|
|
1898
|
+
try {
|
|
1899
|
+
await this.mergeTask(task);
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
console.error(`[Merger-${this.workerId.slice(0, 8)}] Failed to merge task ${task.id}:`, error);
|
|
1902
|
+
await this.handleError(task.id, error);
|
|
1903
|
+
} finally {
|
|
1904
|
+
this.currentTaskId = null;
|
|
1905
|
+
}
|
|
1906
|
+
} catch (error) {
|
|
1907
|
+
console.error(`[Merger-${this.workerId.slice(0, 8)}] Unexpected error in main loop:`, error);
|
|
1908
|
+
if (this.currentTaskId !== null) {
|
|
1909
|
+
await this.releaseCurrentTask();
|
|
1910
|
+
}
|
|
1911
|
+
await this.sleep(WORKER_CONFIG.merger.pollInterval);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
console.log(`[Merger-${this.workerId.slice(0, 8)}] Stopped`);
|
|
1915
|
+
}
|
|
1916
|
+
/**
|
|
1917
|
+
* 优雅停止 Worker
|
|
1918
|
+
*
|
|
1919
|
+
* 覆盖基类方法,添加释放当前任务的逻辑。
|
|
1920
|
+
* 如果正在处理任务,将其释放回 READY_TO_MERGE 状态。
|
|
1921
|
+
*/
|
|
1922
|
+
stop() {
|
|
1923
|
+
this.isRunning = false;
|
|
1924
|
+
console.log(`[Merger-${this.workerId.slice(0, 8)}] Stopping...`);
|
|
1925
|
+
if (this.currentTaskId !== null) {
|
|
1926
|
+
this.releaseCurrentTask().catch((error) => {
|
|
1927
|
+
console.error(`[Merger-${this.workerId.slice(0, 8)}] Failed to release task on stop:`, error);
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* 释放当前任务回 READY_TO_MERGE 状态
|
|
1933
|
+
*
|
|
1934
|
+
* 用于优雅停止或异常恢复时释放任务。
|
|
1935
|
+
*/
|
|
1936
|
+
async releaseCurrentTask() {
|
|
1937
|
+
if (this.currentTaskId === null) return;
|
|
1938
|
+
try {
|
|
1939
|
+
await prisma.task.update({
|
|
1940
|
+
where: { id: this.currentTaskId },
|
|
1941
|
+
data: {
|
|
1942
|
+
status: TaskStatus.READY_TO_MERGE,
|
|
1943
|
+
worker_id: null
|
|
1944
|
+
}
|
|
1945
|
+
});
|
|
1946
|
+
console.log(`[Merger-${this.workerId.slice(0, 8)}] Released task ${this.currentTaskId}`);
|
|
1947
|
+
this.currentTaskId = null;
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
console.error(`[Merger-${this.workerId.slice(0, 8)}] Failed to release task ${this.currentTaskId}:`, error);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* 执行任务合并
|
|
1954
|
+
*
|
|
1955
|
+
* @param task - 待合并的任务
|
|
1956
|
+
*/
|
|
1957
|
+
async mergeTask(task) {
|
|
1958
|
+
const pages = await this.getCompletedPages(task.id);
|
|
1959
|
+
if (pages.length === 0) {
|
|
1960
|
+
throw new Error("No completed pages found for merging");
|
|
1961
|
+
}
|
|
1962
|
+
console.log(`[Merger-${this.workerId.slice(0, 8)}] Merging ${pages.length} pages for task ${task.id}`);
|
|
1963
|
+
const mergedContent = this.mergeMarkdown(pages);
|
|
1964
|
+
const outputPath = await this.saveMergedFile(task, mergedContent);
|
|
1965
|
+
await this.updateTaskStatus(task.id, TaskStatus.COMPLETED, {
|
|
1966
|
+
progress: 100,
|
|
1967
|
+
worker_id: null,
|
|
1968
|
+
// 释放 worker 占用
|
|
1969
|
+
merged_path: outputPath
|
|
1970
|
+
// 合并后的文件路径,前端需要此字段启用下载按钮
|
|
1971
|
+
});
|
|
1972
|
+
console.log(`[Merger-${this.workerId.slice(0, 8)}] Task ${task.id} merged successfully: ${outputPath}`);
|
|
1973
|
+
}
|
|
1974
|
+
/**
|
|
1975
|
+
* 获取任务的所有已完成页面
|
|
1976
|
+
*
|
|
1977
|
+
* @param taskId - 任务 ID
|
|
1978
|
+
* @returns 已完成的页面列表,按页码排序
|
|
1979
|
+
*/
|
|
1980
|
+
async getCompletedPages(taskId) {
|
|
1981
|
+
return await prisma.taskDetail.findMany({
|
|
1982
|
+
where: {
|
|
1983
|
+
task: taskId,
|
|
1984
|
+
status: PageStatus.COMPLETED
|
|
1985
|
+
},
|
|
1986
|
+
orderBy: {
|
|
1987
|
+
page: "asc"
|
|
1988
|
+
},
|
|
1989
|
+
select: {
|
|
1990
|
+
page: true,
|
|
1991
|
+
content: true
|
|
1992
|
+
}
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* 合并 Markdown 内容
|
|
1997
|
+
*
|
|
1998
|
+
* 格式:
|
|
1999
|
+
* - 每页以 <!-- Page N --> 注释开头
|
|
2000
|
+
* - 页面之间使用 --- 分隔线分隔
|
|
2001
|
+
* - 使用 LF 换行符确保跨平台兼容性
|
|
2002
|
+
*
|
|
2003
|
+
* @param pages - 已完成的页面列表
|
|
2004
|
+
* @returns 合并后的 Markdown 内容
|
|
2005
|
+
*/
|
|
2006
|
+
mergeMarkdown(pages) {
|
|
2007
|
+
return pages.map((page) => {
|
|
2008
|
+
return `<!-- Page ${page.page} -->
|
|
2009
|
+
|
|
2010
|
+
${page.content}`;
|
|
2011
|
+
}).join("\n\n---\n\n");
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* 计算输出文件路径
|
|
2015
|
+
*
|
|
2016
|
+
* 使用 path.parse() 可靠地处理各种文件名边界情况:
|
|
2017
|
+
* - 以点开头的隐藏文件(如 .hidden)
|
|
2018
|
+
* - 多个点的文件名(如 my.report.2024.pdf)
|
|
2019
|
+
* - 无扩展名的文件(如 document)
|
|
2020
|
+
*
|
|
2021
|
+
* 路径格式: {uploadsDir}/{taskId}/{filename}.md
|
|
2022
|
+
* 例如: files/abc123/document.md (原文件为 document.pdf)
|
|
2023
|
+
*
|
|
2024
|
+
* @param task - 任务对象
|
|
2025
|
+
* @returns 输出文件的完整路径
|
|
2026
|
+
*/
|
|
2027
|
+
getOutputPath(task) {
|
|
2028
|
+
const { name } = path.parse(task.filename);
|
|
2029
|
+
const outputFileName = `${name}.md`;
|
|
2030
|
+
return path.join(this.uploadsDir, task.id, outputFileName);
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* 保存合并后的文件
|
|
2034
|
+
*
|
|
2035
|
+
* @param task - 任务对象
|
|
2036
|
+
* @param content - 合并后的 Markdown 内容
|
|
2037
|
+
* @returns 保存的文件路径
|
|
2038
|
+
*/
|
|
2039
|
+
async saveMergedFile(task, content) {
|
|
2040
|
+
const outputPath = this.getOutputPath(task);
|
|
2041
|
+
const outputDir = path.dirname(outputPath);
|
|
2042
|
+
await this.ensureDirectoryExists(outputDir);
|
|
2043
|
+
await fs$1.writeFile(outputPath, content, { encoding: "utf-8" });
|
|
2044
|
+
return outputPath;
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* 确保目录存在,不存在则创建
|
|
2048
|
+
*
|
|
2049
|
+
* @param dirPath - 目录路径
|
|
2050
|
+
*/
|
|
2051
|
+
async ensureDirectoryExists(dirPath) {
|
|
2052
|
+
try {
|
|
2053
|
+
await fs$1.access(dirPath);
|
|
2054
|
+
} catch {
|
|
2055
|
+
await fs$1.mkdir(dirPath, { recursive: true });
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
const getUploadDir = () => {
|
|
2060
|
+
if (isDev) {
|
|
2061
|
+
return path.join(process.cwd(), "files");
|
|
2062
|
+
}
|
|
2063
|
+
const userDataPath = app.getPath("userData");
|
|
2064
|
+
return path.join(userDataPath, "files");
|
|
2065
|
+
};
|
|
2066
|
+
const getTempDir = () => {
|
|
2067
|
+
if (isDev) {
|
|
2068
|
+
return path.join(process.cwd(), "temp");
|
|
2069
|
+
}
|
|
2070
|
+
const userDataPath = app.getPath("userData");
|
|
2071
|
+
return path.join(userDataPath, "temp");
|
|
2072
|
+
};
|
|
2073
|
+
const deleteDirectory = (dirPath) => {
|
|
2074
|
+
if (fs.existsSync(dirPath)) {
|
|
2075
|
+
if (fs.lstatSync(dirPath).isDirectory()) {
|
|
2076
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
2077
|
+
} else {
|
|
2078
|
+
fs.unlinkSync(dirPath);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
};
|
|
2082
|
+
const getSplitDir = (taskId) => {
|
|
2083
|
+
return path.join(getUploadDir(), taskId, "split");
|
|
2084
|
+
};
|
|
2085
|
+
const deleteTaskFiles = (id) => {
|
|
2086
|
+
const uploadDir = path.join(getUploadDir(), id);
|
|
2087
|
+
deleteDirectory(uploadDir);
|
|
2088
|
+
const tempDir = path.join(getTempDir(), id);
|
|
2089
|
+
deleteDirectory(tempDir);
|
|
2090
|
+
};
|
|
2091
|
+
const fileLogic = {
|
|
2092
|
+
getUploadDir,
|
|
2093
|
+
getTempDir,
|
|
2094
|
+
getSplitDir,
|
|
2095
|
+
deleteTaskFiles
|
|
2096
|
+
};
|
|
2097
|
+
class WorkerOrchestrator {
|
|
2098
|
+
isRunning;
|
|
2099
|
+
splitterWorker;
|
|
2100
|
+
converterWorkers;
|
|
2101
|
+
mergerWorker;
|
|
2102
|
+
uploadsDir;
|
|
2103
|
+
constructor() {
|
|
2104
|
+
this.isRunning = false;
|
|
2105
|
+
this.splitterWorker = null;
|
|
2106
|
+
this.converterWorkers = [];
|
|
2107
|
+
this.mergerWorker = null;
|
|
2108
|
+
this.uploadsDir = fileLogic.getUploadDir();
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Start all workers
|
|
2112
|
+
*/
|
|
2113
|
+
async start() {
|
|
2114
|
+
if (this.isRunning) {
|
|
2115
|
+
console.warn("[WorkerOrchestrator] Workers already running");
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
try {
|
|
2119
|
+
console.log("[WorkerOrchestrator] Initializing workers...");
|
|
2120
|
+
await this.cleanupOrphanedWork();
|
|
2121
|
+
ImagePathUtil.init(this.uploadsDir);
|
|
2122
|
+
console.log(`[WorkerOrchestrator] ImagePathUtil initialized with uploadsDir: ${this.uploadsDir}`);
|
|
2123
|
+
this.splitterWorker = new SplitterWorker(this.uploadsDir);
|
|
2124
|
+
console.log(`[WorkerOrchestrator] SplitterWorker created (ID: ${this.splitterWorker.getWorkerId()})`);
|
|
2125
|
+
this.splitterWorker.run().catch((error) => {
|
|
2126
|
+
console.error("[WorkerOrchestrator] SplitterWorker error:", error);
|
|
2127
|
+
});
|
|
2128
|
+
const converterCount = WORKER_CONFIG.converter.count;
|
|
2129
|
+
for (let i = 0; i < converterCount; i++) {
|
|
2130
|
+
const worker = new ConverterWorker();
|
|
2131
|
+
this.converterWorkers.push(worker);
|
|
2132
|
+
console.log(`[WorkerOrchestrator] ConverterWorker ${i + 1}/${converterCount} created (ID: ${worker.getWorkerId().slice(0, 8)})`);
|
|
2133
|
+
worker.run().catch((error) => {
|
|
2134
|
+
console.error(`[WorkerOrchestrator] ConverterWorker ${worker.getWorkerId().slice(0, 8)} error:`, error);
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
this.mergerWorker = new MergerWorker(this.uploadsDir);
|
|
2138
|
+
console.log(`[WorkerOrchestrator] MergerWorker created (ID: ${this.mergerWorker.getWorkerId().slice(0, 8)})`);
|
|
2139
|
+
this.mergerWorker.run().catch((error) => {
|
|
2140
|
+
console.error("[WorkerOrchestrator] MergerWorker error:", error);
|
|
2141
|
+
});
|
|
2142
|
+
this.isRunning = true;
|
|
2143
|
+
console.log("[WorkerOrchestrator] All workers started successfully");
|
|
2144
|
+
} catch (error) {
|
|
2145
|
+
console.error("[WorkerOrchestrator] Failed to start workers:", error);
|
|
2146
|
+
this.isRunning = false;
|
|
2147
|
+
throw error;
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Stop all workers gracefully
|
|
2152
|
+
*/
|
|
2153
|
+
async stop() {
|
|
2154
|
+
if (!this.isRunning) {
|
|
2155
|
+
console.warn("[WorkerOrchestrator] Workers not running");
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
try {
|
|
2159
|
+
console.log("[WorkerOrchestrator] Stopping workers...");
|
|
2160
|
+
if (this.splitterWorker) {
|
|
2161
|
+
this.splitterWorker.stop();
|
|
2162
|
+
console.log("[WorkerOrchestrator] SplitterWorker stopped");
|
|
2163
|
+
this.splitterWorker = null;
|
|
2164
|
+
}
|
|
2165
|
+
for (const worker of this.converterWorkers) {
|
|
2166
|
+
worker.stop();
|
|
2167
|
+
console.log(`[WorkerOrchestrator] ConverterWorker ${worker.getWorkerId().slice(0, 8)} stopped`);
|
|
2168
|
+
}
|
|
2169
|
+
this.converterWorkers = [];
|
|
2170
|
+
if (this.mergerWorker) {
|
|
2171
|
+
this.mergerWorker.stop();
|
|
2172
|
+
console.log(`[WorkerOrchestrator] MergerWorker ${this.mergerWorker.getWorkerId().slice(0, 8)} stopped`);
|
|
2173
|
+
this.mergerWorker = null;
|
|
2174
|
+
}
|
|
2175
|
+
this.isRunning = false;
|
|
2176
|
+
console.log("[WorkerOrchestrator] All workers stopped");
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
console.error("[WorkerOrchestrator] Error stopping workers:", error);
|
|
2179
|
+
throw error;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* Get running status
|
|
2184
|
+
*/
|
|
2185
|
+
getStatus() {
|
|
2186
|
+
return this.isRunning;
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Get worker information (for debugging/monitoring)
|
|
2190
|
+
*/
|
|
2191
|
+
getWorkerInfo() {
|
|
2192
|
+
return {
|
|
2193
|
+
isRunning: this.isRunning,
|
|
2194
|
+
splitterWorker: this.splitterWorker ? {
|
|
2195
|
+
id: this.splitterWorker.getWorkerId(),
|
|
2196
|
+
running: this.splitterWorker.getIsRunning()
|
|
2197
|
+
} : null,
|
|
2198
|
+
converterWorkers: this.converterWorkers.map((worker) => ({
|
|
2199
|
+
id: worker.getWorkerId().slice(0, 8),
|
|
2200
|
+
running: worker.getIsRunning()
|
|
2201
|
+
})),
|
|
2202
|
+
mergerWorker: this.mergerWorker ? {
|
|
2203
|
+
id: this.mergerWorker.getWorkerId().slice(0, 8),
|
|
2204
|
+
running: this.mergerWorker.getIsRunning()
|
|
2205
|
+
} : null,
|
|
2206
|
+
directories: {
|
|
2207
|
+
uploads: this.uploadsDir
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Clean up orphaned tasks and pages from previous abnormal shutdown.
|
|
2213
|
+
*
|
|
2214
|
+
* This handles the case where the application was closed while tasks were in progress:
|
|
2215
|
+
* - Pages with status=PROCESSING and worker_id set (orphaned by crashed workers)
|
|
2216
|
+
* - Tasks with status=SPLITTING (orphaned splitter work)
|
|
2217
|
+
*
|
|
2218
|
+
* These are reset to their previous state so new workers can pick them up.
|
|
2219
|
+
*/
|
|
2220
|
+
async cleanupOrphanedWork() {
|
|
2221
|
+
try {
|
|
2222
|
+
console.log("[WorkerOrchestrator] Checking for orphaned work from previous session...");
|
|
2223
|
+
const orphanedPages = await prisma.taskDetail.updateMany({
|
|
2224
|
+
where: {
|
|
2225
|
+
status: PageStatus.PROCESSING,
|
|
2226
|
+
worker_id: { not: null }
|
|
2227
|
+
},
|
|
2228
|
+
data: {
|
|
2229
|
+
status: PageStatus.PENDING,
|
|
2230
|
+
worker_id: null,
|
|
2231
|
+
started_at: null
|
|
2232
|
+
}
|
|
2233
|
+
});
|
|
2234
|
+
if (orphanedPages.count > 0) {
|
|
2235
|
+
console.log(`[WorkerOrchestrator] Reset ${orphanedPages.count} orphaned pages to PENDING`);
|
|
2236
|
+
}
|
|
2237
|
+
const orphanedSplittingTasks = await prisma.task.updateMany({
|
|
2238
|
+
where: {
|
|
2239
|
+
status: TaskStatus.SPLITTING,
|
|
2240
|
+
worker_id: { not: null }
|
|
2241
|
+
},
|
|
2242
|
+
data: {
|
|
2243
|
+
status: TaskStatus.PENDING,
|
|
2244
|
+
worker_id: null
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
if (orphanedSplittingTasks.count > 0) {
|
|
2248
|
+
console.log(`[WorkerOrchestrator] Reset ${orphanedSplittingTasks.count} orphaned SPLITTING tasks to PENDING`);
|
|
2249
|
+
}
|
|
2250
|
+
const orphanedMergingTasks = await prisma.task.updateMany({
|
|
2251
|
+
where: {
|
|
2252
|
+
status: TaskStatus.MERGING,
|
|
2253
|
+
worker_id: { not: null }
|
|
2254
|
+
},
|
|
2255
|
+
data: {
|
|
2256
|
+
status: TaskStatus.READY_TO_MERGE,
|
|
2257
|
+
worker_id: null
|
|
2258
|
+
}
|
|
2259
|
+
});
|
|
2260
|
+
if (orphanedMergingTasks.count > 0) {
|
|
2261
|
+
console.log(`[WorkerOrchestrator] Reset ${orphanedMergingTasks.count} orphaned MERGING tasks to READY_TO_MERGE`);
|
|
2262
|
+
}
|
|
2263
|
+
const result = {
|
|
2264
|
+
orphanedPages: orphanedPages.count,
|
|
2265
|
+
orphanedSplittingTasks: orphanedSplittingTasks.count,
|
|
2266
|
+
orphanedMergingTasks: orphanedMergingTasks.count,
|
|
2267
|
+
total: orphanedPages.count + orphanedSplittingTasks.count + orphanedMergingTasks.count
|
|
2268
|
+
};
|
|
2269
|
+
if (result.total === 0) {
|
|
2270
|
+
console.log("[WorkerOrchestrator] No orphaned work found");
|
|
2271
|
+
} else {
|
|
2272
|
+
console.log(`[WorkerOrchestrator] Cleanup complete: ${result.total} items recovered`);
|
|
2273
|
+
}
|
|
2274
|
+
return result;
|
|
2275
|
+
} catch (error) {
|
|
2276
|
+
console.error("[WorkerOrchestrator] Failed to clean up orphaned work:", error);
|
|
2277
|
+
return {
|
|
2278
|
+
orphanedPages: 0,
|
|
2279
|
+
orphanedSplittingTasks: 0,
|
|
2280
|
+
orphanedMergingTasks: 0,
|
|
2281
|
+
total: 0
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
const workerOrchestrator = new WorkerOrchestrator();
|
|
2287
|
+
const IPC_CHANNELS = {
|
|
2288
|
+
// Provider channels
|
|
2289
|
+
PROVIDER: {
|
|
2290
|
+
GET_ALL: "provider:getAll",
|
|
2291
|
+
GET_BY_ID: "provider:getById",
|
|
2292
|
+
CREATE: "provider:create",
|
|
2293
|
+
UPDATE: "provider:update",
|
|
2294
|
+
DELETE: "provider:delete",
|
|
2295
|
+
UPDATE_STATUS: "provider:updateStatus"
|
|
2296
|
+
},
|
|
2297
|
+
// Model channels
|
|
2298
|
+
MODEL: {
|
|
2299
|
+
GET_ALL: "model:getAll",
|
|
2300
|
+
GET_BY_PROVIDER: "model:getByProvider",
|
|
2301
|
+
CREATE: "model:create",
|
|
2302
|
+
DELETE: "model:delete"
|
|
2303
|
+
},
|
|
2304
|
+
// Task channels
|
|
2305
|
+
TASK: {
|
|
2306
|
+
CREATE: "task:create",
|
|
2307
|
+
GET_ALL: "task:getAll",
|
|
2308
|
+
GET_BY_ID: "task:getById",
|
|
2309
|
+
UPDATE: "task:update",
|
|
2310
|
+
DELETE: "task:delete",
|
|
2311
|
+
HAS_RUNNING: "task:hasRunningTasks"
|
|
2312
|
+
},
|
|
2313
|
+
// Task Detail channels
|
|
2314
|
+
TASK_DETAIL: {
|
|
2315
|
+
GET_BY_PAGE: "taskDetail:getByPage",
|
|
2316
|
+
GET_ALL_BY_TASK: "taskDetail:getAllByTask",
|
|
2317
|
+
RETRY: "taskDetail:retry",
|
|
2318
|
+
RETRY_FAILED: "taskDetail:retryFailed",
|
|
2319
|
+
GET_COST_STATS: "taskDetail:getCostStats"
|
|
2320
|
+
},
|
|
2321
|
+
// File channels
|
|
2322
|
+
FILE: {
|
|
2323
|
+
GET_IMAGE_PATH: "file:getImagePath",
|
|
2324
|
+
DOWNLOAD_MARKDOWN: "file:downloadMarkdown",
|
|
2325
|
+
SELECT_DIALOG: "file:selectDialog",
|
|
2326
|
+
UPLOAD: "file:upload",
|
|
2327
|
+
UPLOAD_MULTIPLE: "file:uploadMultiple",
|
|
2328
|
+
UPLOAD_FILE_CONTENT: "file:uploadFileContent"
|
|
2329
|
+
},
|
|
2330
|
+
// Completion (LLM) channels
|
|
2331
|
+
COMPLETION: {
|
|
2332
|
+
MARK_IMAGEDOWN: "completion:markImagedown",
|
|
2333
|
+
TEST_CONNECTION: "completion:testConnection"
|
|
2334
|
+
}
|
|
2335
|
+
};
|
|
2336
|
+
function registerProviderHandlers() {
|
|
2337
|
+
ipcMain.handle(IPC_CHANNELS.PROVIDER.GET_ALL, async () => {
|
|
2338
|
+
try {
|
|
2339
|
+
const providers = await providerRepository.findAll();
|
|
2340
|
+
return { success: true, data: providers };
|
|
2341
|
+
} catch (error) {
|
|
2342
|
+
console.error("[IPC] provider:getAll error:", error);
|
|
2343
|
+
return { success: false, error: error.message };
|
|
2344
|
+
}
|
|
2345
|
+
});
|
|
2346
|
+
ipcMain.handle(
|
|
2347
|
+
IPC_CHANNELS.PROVIDER.GET_BY_ID,
|
|
2348
|
+
async (_, id) => {
|
|
2349
|
+
try {
|
|
2350
|
+
const provider = await providerRepository.findById(id);
|
|
2351
|
+
if (!provider) {
|
|
2352
|
+
return { success: false, error: "Provider not found" };
|
|
2353
|
+
}
|
|
2354
|
+
return { success: true, data: provider };
|
|
2355
|
+
} catch (error) {
|
|
2356
|
+
console.error("[IPC] provider:getById error:", error);
|
|
2357
|
+
return { success: false, error: error.message };
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
);
|
|
2361
|
+
ipcMain.handle(
|
|
2362
|
+
IPC_CHANNELS.PROVIDER.CREATE,
|
|
2363
|
+
async (_, data) => {
|
|
2364
|
+
try {
|
|
2365
|
+
const { name, type } = data;
|
|
2366
|
+
if (!name || !type) {
|
|
2367
|
+
return { success: false, error: "Name and type are required" };
|
|
2368
|
+
}
|
|
2369
|
+
const newProvider = await providerRepository.create({
|
|
2370
|
+
name,
|
|
2371
|
+
type,
|
|
2372
|
+
api_key: "",
|
|
2373
|
+
base_url: "",
|
|
2374
|
+
suffix: "",
|
|
2375
|
+
status: 0
|
|
2376
|
+
});
|
|
2377
|
+
return { success: true, data: newProvider };
|
|
2378
|
+
} catch (error) {
|
|
2379
|
+
console.error("[IPC] provider:create error:", error);
|
|
2380
|
+
return { success: false, error: error.message };
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
);
|
|
2384
|
+
ipcMain.handle(
|
|
2385
|
+
IPC_CHANNELS.PROVIDER.UPDATE,
|
|
2386
|
+
async (_, id, data) => {
|
|
2387
|
+
try {
|
|
2388
|
+
const existingProvider = await providerRepository.findById(id);
|
|
2389
|
+
if (!existingProvider) {
|
|
2390
|
+
return { success: false, error: "Provider not found" };
|
|
2391
|
+
}
|
|
2392
|
+
const updateData = {};
|
|
2393
|
+
if (data.api_key !== void 0) updateData.api_key = data.api_key;
|
|
2394
|
+
if (data.base_url !== void 0) updateData.base_url = data.base_url;
|
|
2395
|
+
if (data.suffix !== void 0) updateData.suffix = data.suffix;
|
|
2396
|
+
const updatedProvider = await providerRepository.update(id, updateData);
|
|
2397
|
+
return { success: true, data: updatedProvider };
|
|
2398
|
+
} catch (error) {
|
|
2399
|
+
console.error("[IPC] provider:update error:", error);
|
|
2400
|
+
return { success: false, error: error.message };
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
);
|
|
2404
|
+
ipcMain.handle(
|
|
2405
|
+
IPC_CHANNELS.PROVIDER.DELETE,
|
|
2406
|
+
async (_, id) => {
|
|
2407
|
+
try {
|
|
2408
|
+
const existingProvider = await providerRepository.findById(id);
|
|
2409
|
+
if (!existingProvider) {
|
|
2410
|
+
return { success: false, error: "Provider not found" };
|
|
2411
|
+
}
|
|
2412
|
+
await providerRepository.remove(id);
|
|
2413
|
+
return { success: true };
|
|
2414
|
+
} catch (error) {
|
|
2415
|
+
console.error("[IPC] provider:delete error:", error);
|
|
2416
|
+
return { success: false, error: error.message };
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
);
|
|
2420
|
+
ipcMain.handle(
|
|
2421
|
+
IPC_CHANNELS.PROVIDER.UPDATE_STATUS,
|
|
2422
|
+
async (_, id, status) => {
|
|
2423
|
+
try {
|
|
2424
|
+
if (status === void 0) {
|
|
2425
|
+
return { success: false, error: "Invalid status value" };
|
|
2426
|
+
}
|
|
2427
|
+
const existingProvider = await providerRepository.findById(id);
|
|
2428
|
+
if (!existingProvider) {
|
|
2429
|
+
return { success: false, error: "Provider not found" };
|
|
2430
|
+
}
|
|
2431
|
+
const updatedProvider = await providerRepository.updateStatus(id, status);
|
|
2432
|
+
return { success: true, data: updatedProvider };
|
|
2433
|
+
} catch (error) {
|
|
2434
|
+
console.error("[IPC] provider:updateStatus error:", error);
|
|
2435
|
+
return { success: false, error: error.message };
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
);
|
|
2439
|
+
console.log("[IPC] Provider handlers registered");
|
|
2440
|
+
}
|
|
2441
|
+
const findAll$1 = async () => {
|
|
2442
|
+
return await prisma.model.findMany({
|
|
2443
|
+
orderBy: [{ createdAt: "desc" }]
|
|
2444
|
+
});
|
|
2445
|
+
};
|
|
2446
|
+
const findByProviderId = async (provider) => {
|
|
2447
|
+
return await prisma.model.findMany({
|
|
2448
|
+
where: { provider },
|
|
2449
|
+
orderBy: [{ createdAt: "desc" }]
|
|
2450
|
+
});
|
|
2451
|
+
};
|
|
2452
|
+
const create$1 = async (modelData) => {
|
|
2453
|
+
return await prisma.model.create({
|
|
2454
|
+
data: modelData
|
|
2455
|
+
});
|
|
2456
|
+
};
|
|
2457
|
+
const remove$1 = async (id, provider) => {
|
|
2458
|
+
return await prisma.model.delete({
|
|
2459
|
+
where: {
|
|
2460
|
+
id_provider: {
|
|
2461
|
+
id,
|
|
2462
|
+
provider
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
});
|
|
2466
|
+
};
|
|
2467
|
+
const removeByProviderId = async (provider) => {
|
|
2468
|
+
return await prisma.model.deleteMany({
|
|
2469
|
+
where: { provider }
|
|
2470
|
+
});
|
|
2471
|
+
};
|
|
2472
|
+
const modelRepository = {
|
|
2473
|
+
findAll: findAll$1,
|
|
2474
|
+
findByProviderId,
|
|
2475
|
+
create: create$1,
|
|
2476
|
+
remove: remove$1,
|
|
2477
|
+
removeByProviderId
|
|
2478
|
+
};
|
|
2479
|
+
function registerModelHandlers() {
|
|
2480
|
+
ipcMain.handle(IPC_CHANNELS.MODEL.GET_ALL, async () => {
|
|
2481
|
+
try {
|
|
2482
|
+
const providers = await providerRepository.findAll();
|
|
2483
|
+
const models = await modelRepository.findAll();
|
|
2484
|
+
const groupedModels = providers.map((provider) => ({
|
|
2485
|
+
provider: provider.id,
|
|
2486
|
+
providerName: provider.name,
|
|
2487
|
+
models: models.filter((model) => model.provider === provider.id)
|
|
2488
|
+
}));
|
|
2489
|
+
return { success: true, data: groupedModels };
|
|
2490
|
+
} catch (error) {
|
|
2491
|
+
console.error("[IPC] model:getAll error:", error);
|
|
2492
|
+
return { success: false, error: error.message };
|
|
2493
|
+
}
|
|
2494
|
+
});
|
|
2495
|
+
ipcMain.handle(
|
|
2496
|
+
IPC_CHANNELS.MODEL.GET_BY_PROVIDER,
|
|
2497
|
+
async (_, providerId) => {
|
|
2498
|
+
try {
|
|
2499
|
+
const models = await modelRepository.findByProviderId(providerId);
|
|
2500
|
+
return { success: true, data: models };
|
|
2501
|
+
} catch (error) {
|
|
2502
|
+
console.error("[IPC] model:getByProvider error:", error);
|
|
2503
|
+
return { success: false, error: error.message };
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
);
|
|
2507
|
+
ipcMain.handle(IPC_CHANNELS.MODEL.CREATE, async (_, data) => {
|
|
2508
|
+
try {
|
|
2509
|
+
const { id, provider, name } = data;
|
|
2510
|
+
if (!id || !provider || !name) {
|
|
2511
|
+
return { success: false, error: "Model ID, provider ID, and name are required" };
|
|
2512
|
+
}
|
|
2513
|
+
const newModel = await modelRepository.create({ id, provider, name });
|
|
2514
|
+
return { success: true, data: newModel };
|
|
2515
|
+
} catch (error) {
|
|
2516
|
+
console.error("[IPC] model:create error:", error);
|
|
2517
|
+
return { success: false, error: error.message };
|
|
2518
|
+
}
|
|
2519
|
+
});
|
|
2520
|
+
ipcMain.handle(
|
|
2521
|
+
IPC_CHANNELS.MODEL.DELETE,
|
|
2522
|
+
async (_, id, provider) => {
|
|
2523
|
+
try {
|
|
2524
|
+
if (!id || !provider) {
|
|
2525
|
+
return { success: false, error: "Model ID and provider ID are required" };
|
|
2526
|
+
}
|
|
2527
|
+
await modelRepository.remove(id, provider);
|
|
2528
|
+
return { success: true, data: { message: "Model deleted successfully" } };
|
|
2529
|
+
} catch (error) {
|
|
2530
|
+
console.error("[IPC] model:delete error:", error);
|
|
2531
|
+
return { success: false, error: error.message };
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
);
|
|
2535
|
+
console.log("[IPC] Model handlers registered");
|
|
2536
|
+
}
|
|
2537
|
+
const findAll = async (page, pageSize) => {
|
|
2538
|
+
return await prisma.task.findMany({
|
|
2539
|
+
skip: (page - 1) * pageSize,
|
|
2540
|
+
take: pageSize,
|
|
2541
|
+
orderBy: {
|
|
2542
|
+
createdAt: "desc"
|
|
2543
|
+
}
|
|
2544
|
+
});
|
|
2545
|
+
};
|
|
2546
|
+
const getTotal = async () => {
|
|
2547
|
+
return await prisma.task.count();
|
|
2548
|
+
};
|
|
2549
|
+
const findById = async (id) => {
|
|
2550
|
+
return await prisma.task.findUnique({
|
|
2551
|
+
where: { id }
|
|
2552
|
+
});
|
|
2553
|
+
};
|
|
2554
|
+
const create = async (task) => {
|
|
2555
|
+
return await prisma.task.create({
|
|
2556
|
+
data: {
|
|
2557
|
+
id: v4(),
|
|
2558
|
+
filename: task?.filename || "",
|
|
2559
|
+
type: task?.type || "",
|
|
2560
|
+
page_range: task?.page_range || "",
|
|
2561
|
+
pages: task?.pages || 0,
|
|
2562
|
+
provider: task?.provider || 0,
|
|
2563
|
+
model: task?.model || "",
|
|
2564
|
+
model_name: task?.model_name || "",
|
|
2565
|
+
progress: 0,
|
|
2566
|
+
status: 0
|
|
2567
|
+
}
|
|
2568
|
+
});
|
|
2569
|
+
};
|
|
2570
|
+
const createTasks = async (tasks) => {
|
|
2571
|
+
const createdTasks = [];
|
|
2572
|
+
for (const task of tasks) {
|
|
2573
|
+
const createdTask = await create(task);
|
|
2574
|
+
createdTasks.push(createdTask);
|
|
2575
|
+
}
|
|
2576
|
+
return createdTasks;
|
|
2577
|
+
};
|
|
2578
|
+
const update = async (id, task) => {
|
|
2579
|
+
return await prisma.task.update({
|
|
2580
|
+
where: { id },
|
|
2581
|
+
data: task
|
|
2582
|
+
});
|
|
2583
|
+
};
|
|
2584
|
+
const remove = async (id) => {
|
|
2585
|
+
await prisma.task.delete({
|
|
2586
|
+
where: { id }
|
|
2587
|
+
});
|
|
2588
|
+
return await prisma.taskDetail.deleteMany({
|
|
2589
|
+
where: { task: id }
|
|
2590
|
+
});
|
|
2591
|
+
};
|
|
2592
|
+
const taskRepository = {
|
|
2593
|
+
findAll,
|
|
2594
|
+
findById,
|
|
2595
|
+
create,
|
|
2596
|
+
createTasks,
|
|
2597
|
+
getTotal,
|
|
2598
|
+
update,
|
|
2599
|
+
remove
|
|
2600
|
+
};
|
|
2601
|
+
function registerTaskHandlers() {
|
|
2602
|
+
ipcMain.handle(
|
|
2603
|
+
IPC_CHANNELS.TASK.CREATE,
|
|
2604
|
+
async (_, tasks) => {
|
|
2605
|
+
try {
|
|
2606
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
2607
|
+
return { success: false, error: "Task list cannot be empty" };
|
|
2608
|
+
}
|
|
2609
|
+
const tasksWithId = tasks.map((task) => ({
|
|
2610
|
+
...task,
|
|
2611
|
+
id: v4(),
|
|
2612
|
+
progress: 0,
|
|
2613
|
+
status: -1
|
|
2614
|
+
// CREATED - waiting for file upload
|
|
2615
|
+
}));
|
|
2616
|
+
const createdTasks = await taskRepository.createTasks(tasksWithId);
|
|
2617
|
+
return { success: true, data: createdTasks };
|
|
2618
|
+
} catch (error) {
|
|
2619
|
+
console.error("[IPC] task:create error:", error);
|
|
2620
|
+
return { success: false, error: error.message };
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
);
|
|
2624
|
+
ipcMain.handle(
|
|
2625
|
+
IPC_CHANNELS.TASK.GET_ALL,
|
|
2626
|
+
async (_, params) => {
|
|
2627
|
+
try {
|
|
2628
|
+
const { page = 1, pageSize = 10 } = params || {};
|
|
2629
|
+
const tasks = await taskRepository.findAll(page, pageSize);
|
|
2630
|
+
const total = await taskRepository.getTotal();
|
|
2631
|
+
return { success: true, data: { list: tasks, total } };
|
|
2632
|
+
} catch (error) {
|
|
2633
|
+
console.error("[IPC] task:getAll error:", error);
|
|
2634
|
+
return { success: false, error: error.message };
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
);
|
|
2638
|
+
ipcMain.handle(IPC_CHANNELS.TASK.GET_BY_ID, async (_, id) => {
|
|
2639
|
+
try {
|
|
2640
|
+
if (!id) {
|
|
2641
|
+
return { success: false, error: "Task ID is required" };
|
|
2642
|
+
}
|
|
2643
|
+
const task = await taskRepository.findById(id);
|
|
2644
|
+
if (!task) {
|
|
2645
|
+
return { success: false, error: "Task not found" };
|
|
2646
|
+
}
|
|
2647
|
+
return { success: true, data: task };
|
|
2648
|
+
} catch (error) {
|
|
2649
|
+
console.error("[IPC] task:getById error:", error);
|
|
2650
|
+
return { success: false, error: error.message };
|
|
2651
|
+
}
|
|
2652
|
+
});
|
|
2653
|
+
ipcMain.handle(
|
|
2654
|
+
IPC_CHANNELS.TASK.UPDATE,
|
|
2655
|
+
async (_, id, data) => {
|
|
2656
|
+
try {
|
|
2657
|
+
const updatedTask = await taskRepository.update(id, data);
|
|
2658
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_UPDATED, {
|
|
2659
|
+
taskId: id,
|
|
2660
|
+
task: updatedTask,
|
|
2661
|
+
timestamp: Date.now()
|
|
2662
|
+
});
|
|
2663
|
+
if (data.status !== void 0) {
|
|
2664
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_STATUS_CHANGED, {
|
|
2665
|
+
taskId: id,
|
|
2666
|
+
task: { status: data.status },
|
|
2667
|
+
timestamp: Date.now()
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
return { success: true, data: updatedTask };
|
|
2671
|
+
} catch (error) {
|
|
2672
|
+
console.error("[IPC] task:update error:", error);
|
|
2673
|
+
return { success: false, error: error.message };
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
);
|
|
2677
|
+
ipcMain.handle(IPC_CHANNELS.TASK.DELETE, async (_, id) => {
|
|
2678
|
+
try {
|
|
2679
|
+
await fileLogic.deleteTaskFiles(id);
|
|
2680
|
+
const deletedTask = await taskRepository.remove(id);
|
|
2681
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_DELETED, {
|
|
2682
|
+
taskId: id,
|
|
2683
|
+
timestamp: Date.now()
|
|
2684
|
+
});
|
|
2685
|
+
return { success: true, data: deletedTask };
|
|
2686
|
+
} catch (error) {
|
|
2687
|
+
console.error("[IPC] task:delete error:", error);
|
|
2688
|
+
return { success: false, error: error.message };
|
|
2689
|
+
}
|
|
2690
|
+
});
|
|
2691
|
+
ipcMain.handle(IPC_CHANNELS.TASK.HAS_RUNNING, async () => {
|
|
2692
|
+
try {
|
|
2693
|
+
const runningStatuses = [
|
|
2694
|
+
TaskStatus.PENDING,
|
|
2695
|
+
TaskStatus.SPLITTING,
|
|
2696
|
+
TaskStatus.PROCESSING,
|
|
2697
|
+
TaskStatus.READY_TO_MERGE,
|
|
2698
|
+
TaskStatus.MERGING
|
|
2699
|
+
];
|
|
2700
|
+
const count = await prisma.task.count({
|
|
2701
|
+
where: {
|
|
2702
|
+
status: {
|
|
2703
|
+
in: runningStatuses
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
});
|
|
2707
|
+
return { success: true, data: { hasRunning: count > 0, count } };
|
|
2708
|
+
} catch (error) {
|
|
2709
|
+
console.error("[IPC] task:hasRunningTasks error:", error);
|
|
2710
|
+
return { success: false, error: error.message };
|
|
2711
|
+
}
|
|
2712
|
+
});
|
|
2713
|
+
console.log("[IPC] Task handlers registered");
|
|
2714
|
+
}
|
|
2715
|
+
const findByTaskId = async (taskId) => {
|
|
2716
|
+
return await prisma.taskDetail.findMany({
|
|
2717
|
+
where: { task: taskId },
|
|
2718
|
+
orderBy: {
|
|
2719
|
+
page: "asc"
|
|
2720
|
+
}
|
|
2721
|
+
});
|
|
2722
|
+
};
|
|
2723
|
+
const findByTaskAndPage = async (taskId, page) => {
|
|
2724
|
+
return await prisma.taskDetail.findFirst({
|
|
2725
|
+
where: {
|
|
2726
|
+
task: taskId,
|
|
2727
|
+
page
|
|
2728
|
+
}
|
|
2729
|
+
});
|
|
2730
|
+
};
|
|
2731
|
+
const countByTaskId = async (taskId) => {
|
|
2732
|
+
return await prisma.taskDetail.count({
|
|
2733
|
+
where: { task: taskId }
|
|
2734
|
+
});
|
|
2735
|
+
};
|
|
2736
|
+
const taskDetailRepository = {
|
|
2737
|
+
findByTaskId,
|
|
2738
|
+
findByTaskAndPage,
|
|
2739
|
+
countByTaskId
|
|
2740
|
+
};
|
|
2741
|
+
function registerTaskDetailHandlers() {
|
|
2742
|
+
ipcMain.handle(
|
|
2743
|
+
IPC_CHANNELS.TASK_DETAIL.GET_BY_PAGE,
|
|
2744
|
+
async (_, taskId, page) => {
|
|
2745
|
+
try {
|
|
2746
|
+
if (!taskId) {
|
|
2747
|
+
return { success: false, error: "Task ID is required" };
|
|
2748
|
+
}
|
|
2749
|
+
if (!page || page < 1) {
|
|
2750
|
+
return { success: false, error: "Page number must be greater than 0" };
|
|
2751
|
+
}
|
|
2752
|
+
const taskDetail = await taskDetailRepository.findByTaskAndPage(taskId, page);
|
|
2753
|
+
if (!taskDetail) {
|
|
2754
|
+
return { success: false, error: "Page detail not found" };
|
|
2755
|
+
}
|
|
2756
|
+
const imagePath = ImagePathUtil.getPath(taskId, page);
|
|
2757
|
+
const imageExists = fs.existsSync(imagePath);
|
|
2758
|
+
const taskDetailWithImage = {
|
|
2759
|
+
...taskDetail,
|
|
2760
|
+
imagePath,
|
|
2761
|
+
imageExists
|
|
2762
|
+
};
|
|
2763
|
+
return { success: true, data: taskDetailWithImage };
|
|
2764
|
+
} catch (error) {
|
|
2765
|
+
console.error("[IPC] taskDetail:getByPage error:", error);
|
|
2766
|
+
return { success: false, error: error.message };
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
);
|
|
2770
|
+
ipcMain.handle(
|
|
2771
|
+
IPC_CHANNELS.TASK_DETAIL.GET_ALL_BY_TASK,
|
|
2772
|
+
async (_, taskId) => {
|
|
2773
|
+
try {
|
|
2774
|
+
if (!taskId) {
|
|
2775
|
+
return { success: false, error: "Task ID is required" };
|
|
2776
|
+
}
|
|
2777
|
+
const taskDetails = await taskDetailRepository.findByTaskId(taskId);
|
|
2778
|
+
return { success: true, data: taskDetails };
|
|
2779
|
+
} catch (error) {
|
|
2780
|
+
console.error("[IPC] taskDetail:getAllByTask error:", error);
|
|
2781
|
+
return { success: false, error: error.message };
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
);
|
|
2785
|
+
ipcMain.handle(
|
|
2786
|
+
IPC_CHANNELS.TASK_DETAIL.RETRY,
|
|
2787
|
+
async (_, pageId) => {
|
|
2788
|
+
try {
|
|
2789
|
+
if (!pageId) {
|
|
2790
|
+
return { success: false, error: "Page ID is required" };
|
|
2791
|
+
}
|
|
2792
|
+
const result = await prisma.$transaction(async (tx) => {
|
|
2793
|
+
const page = await tx.taskDetail.findUnique({
|
|
2794
|
+
where: { id: pageId }
|
|
2795
|
+
});
|
|
2796
|
+
if (!page) {
|
|
2797
|
+
throw new Error("Page not found");
|
|
2798
|
+
}
|
|
2799
|
+
if (page.status !== PageStatus.FAILED && page.status !== PageStatus.COMPLETED) {
|
|
2800
|
+
throw new Error("Can only retry failed or completed pages");
|
|
2801
|
+
}
|
|
2802
|
+
const task = await tx.task.findUnique({
|
|
2803
|
+
where: { id: page.task }
|
|
2804
|
+
});
|
|
2805
|
+
if (!task) {
|
|
2806
|
+
throw new Error("Task not found");
|
|
2807
|
+
}
|
|
2808
|
+
if (task.status === TaskStatus.CANCELLED) {
|
|
2809
|
+
throw new Error("Task is cancelled, cannot retry");
|
|
2810
|
+
}
|
|
2811
|
+
const updatedPage = await tx.taskDetail.update({
|
|
2812
|
+
where: { id: pageId },
|
|
2813
|
+
data: {
|
|
2814
|
+
status: PageStatus.PENDING,
|
|
2815
|
+
retry_count: 0,
|
|
2816
|
+
error: null,
|
|
2817
|
+
worker_id: null,
|
|
2818
|
+
started_at: null,
|
|
2819
|
+
completed_at: null,
|
|
2820
|
+
input_tokens: 0,
|
|
2821
|
+
output_tokens: 0,
|
|
2822
|
+
conversion_time: 0,
|
|
2823
|
+
content: ""
|
|
2824
|
+
}
|
|
2825
|
+
});
|
|
2826
|
+
const decrementField = page.status === PageStatus.FAILED ? "failed_count" : "completed_count";
|
|
2827
|
+
const updatedTask = await tx.task.update({
|
|
2828
|
+
where: { id: page.task },
|
|
2829
|
+
data: {
|
|
2830
|
+
[decrementField]: { decrement: 1 },
|
|
2831
|
+
status: TaskStatus.PROCESSING,
|
|
2832
|
+
progress: Math.max(0, task.progress - Math.round(100 / task.pages))
|
|
2833
|
+
}
|
|
2834
|
+
});
|
|
2835
|
+
return { page: updatedPage, task: updatedTask };
|
|
2836
|
+
}, {
|
|
2837
|
+
isolationLevel: "Serializable"
|
|
2838
|
+
});
|
|
2839
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_UPDATED, {
|
|
2840
|
+
taskId: result.task.id,
|
|
2841
|
+
task: result.task,
|
|
2842
|
+
timestamp: Date.now()
|
|
2843
|
+
});
|
|
2844
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_STATUS_CHANGED, {
|
|
2845
|
+
taskId: result.task.id,
|
|
2846
|
+
task: { status: result.task.status },
|
|
2847
|
+
timestamp: Date.now()
|
|
2848
|
+
});
|
|
2849
|
+
return { success: true, data: result.page };
|
|
2850
|
+
} catch (error) {
|
|
2851
|
+
console.error("[IPC] taskDetail:retry error:", error);
|
|
2852
|
+
return { success: false, error: error.message };
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
);
|
|
2856
|
+
ipcMain.handle(
|
|
2857
|
+
IPC_CHANNELS.TASK_DETAIL.RETRY_FAILED,
|
|
2858
|
+
async (_, taskId) => {
|
|
2859
|
+
try {
|
|
2860
|
+
if (!taskId) {
|
|
2861
|
+
return { success: false, error: "Task ID is required" };
|
|
2862
|
+
}
|
|
2863
|
+
const result = await prisma.$transaction(async (tx) => {
|
|
2864
|
+
const task = await tx.task.findUnique({
|
|
2865
|
+
where: { id: taskId }
|
|
2866
|
+
});
|
|
2867
|
+
if (!task) {
|
|
2868
|
+
throw new Error("Task not found");
|
|
2869
|
+
}
|
|
2870
|
+
if (task.status === TaskStatus.CANCELLED) {
|
|
2871
|
+
throw new Error("Task is cancelled, cannot retry");
|
|
2872
|
+
}
|
|
2873
|
+
const failedCount = await tx.taskDetail.count({
|
|
2874
|
+
where: {
|
|
2875
|
+
task: taskId,
|
|
2876
|
+
status: PageStatus.FAILED
|
|
2877
|
+
}
|
|
2878
|
+
});
|
|
2879
|
+
if (failedCount === 0) {
|
|
2880
|
+
throw new Error("No failed pages to retry");
|
|
2881
|
+
}
|
|
2882
|
+
await tx.taskDetail.updateMany({
|
|
2883
|
+
where: {
|
|
2884
|
+
task: taskId,
|
|
2885
|
+
status: PageStatus.FAILED
|
|
2886
|
+
},
|
|
2887
|
+
data: {
|
|
2888
|
+
status: PageStatus.PENDING,
|
|
2889
|
+
retry_count: 0,
|
|
2890
|
+
error: null,
|
|
2891
|
+
worker_id: null,
|
|
2892
|
+
started_at: null,
|
|
2893
|
+
completed_at: null,
|
|
2894
|
+
input_tokens: 0,
|
|
2895
|
+
output_tokens: 0,
|
|
2896
|
+
conversion_time: 0,
|
|
2897
|
+
content: ""
|
|
2898
|
+
}
|
|
2899
|
+
});
|
|
2900
|
+
const updatedTask = await tx.task.update({
|
|
2901
|
+
where: { id: taskId },
|
|
2902
|
+
data: {
|
|
2903
|
+
failed_count: 0,
|
|
2904
|
+
status: TaskStatus.PROCESSING,
|
|
2905
|
+
progress: Math.round(task.completed_count / task.pages * 100)
|
|
2906
|
+
}
|
|
2907
|
+
});
|
|
2908
|
+
return { updatedCount: failedCount, task: updatedTask };
|
|
2909
|
+
}, {
|
|
2910
|
+
isolationLevel: "Serializable"
|
|
2911
|
+
});
|
|
2912
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_UPDATED, {
|
|
2913
|
+
taskId: result.task.id,
|
|
2914
|
+
task: result.task,
|
|
2915
|
+
timestamp: Date.now()
|
|
2916
|
+
});
|
|
2917
|
+
eventBus.emitTaskEvent(TaskEventType.TASK_STATUS_CHANGED, {
|
|
2918
|
+
taskId: result.task.id,
|
|
2919
|
+
task: { status: result.task.status },
|
|
2920
|
+
timestamp: Date.now()
|
|
2921
|
+
});
|
|
2922
|
+
return { success: true, data: { retried: result.updatedCount } };
|
|
2923
|
+
} catch (error) {
|
|
2924
|
+
console.error("[IPC] taskDetail:retryFailed error:", error);
|
|
2925
|
+
return { success: false, error: error.message };
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
);
|
|
2929
|
+
ipcMain.handle(
|
|
2930
|
+
IPC_CHANNELS.TASK_DETAIL.GET_COST_STATS,
|
|
2931
|
+
async (_, taskId) => {
|
|
2932
|
+
try {
|
|
2933
|
+
if (!taskId) {
|
|
2934
|
+
return { success: false, error: "Task ID is required" };
|
|
2935
|
+
}
|
|
2936
|
+
const aggregate = await prisma.taskDetail.aggregate({
|
|
2937
|
+
where: { task: taskId },
|
|
2938
|
+
_sum: {
|
|
2939
|
+
input_tokens: true,
|
|
2940
|
+
output_tokens: true,
|
|
2941
|
+
conversion_time: true
|
|
2942
|
+
},
|
|
2943
|
+
_avg: {
|
|
2944
|
+
conversion_time: true
|
|
2945
|
+
},
|
|
2946
|
+
_count: {
|
|
2947
|
+
id: true
|
|
2948
|
+
}
|
|
2949
|
+
});
|
|
2950
|
+
const byStatus = await prisma.taskDetail.groupBy({
|
|
2951
|
+
by: ["status"],
|
|
2952
|
+
where: { task: taskId },
|
|
2953
|
+
_count: {
|
|
2954
|
+
id: true
|
|
2955
|
+
},
|
|
2956
|
+
_sum: {
|
|
2957
|
+
input_tokens: true,
|
|
2958
|
+
output_tokens: true
|
|
2959
|
+
}
|
|
2960
|
+
});
|
|
2961
|
+
const statusMap = {
|
|
2962
|
+
[-1]: "failed",
|
|
2963
|
+
[0]: "pending",
|
|
2964
|
+
[1]: "processing",
|
|
2965
|
+
[2]: "completed",
|
|
2966
|
+
[3]: "retrying"
|
|
2967
|
+
};
|
|
2968
|
+
const byStatusFormatted = byStatus.reduce((acc, item) => {
|
|
2969
|
+
const statusName = statusMap[item.status] || `status_${item.status}`;
|
|
2970
|
+
acc[statusName] = {
|
|
2971
|
+
count: item._count.id,
|
|
2972
|
+
input_tokens: item._sum.input_tokens || 0,
|
|
2973
|
+
output_tokens: item._sum.output_tokens || 0
|
|
2974
|
+
};
|
|
2975
|
+
return acc;
|
|
2976
|
+
}, {});
|
|
2977
|
+
return {
|
|
2978
|
+
success: true,
|
|
2979
|
+
data: {
|
|
2980
|
+
total: {
|
|
2981
|
+
pages: aggregate._count.id,
|
|
2982
|
+
input_tokens: aggregate._sum.input_tokens || 0,
|
|
2983
|
+
output_tokens: aggregate._sum.output_tokens || 0,
|
|
2984
|
+
total_tokens: (aggregate._sum.input_tokens || 0) + (aggregate._sum.output_tokens || 0),
|
|
2985
|
+
total_conversion_time: aggregate._sum.conversion_time || 0,
|
|
2986
|
+
avg_conversion_time: Math.round(aggregate._avg.conversion_time || 0)
|
|
2987
|
+
},
|
|
2988
|
+
byStatus: byStatusFormatted
|
|
2989
|
+
}
|
|
2990
|
+
};
|
|
2991
|
+
} catch (error) {
|
|
2992
|
+
console.error("[IPC] taskDetail:getCostStats error:", error);
|
|
2993
|
+
return { success: false, error: error.message };
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
);
|
|
2997
|
+
console.log("[IPC] TaskDetail handlers registered");
|
|
2998
|
+
}
|
|
2999
|
+
function registerFileHandlers() {
|
|
3000
|
+
ipcMain.handle(
|
|
3001
|
+
IPC_CHANNELS.FILE.GET_IMAGE_PATH,
|
|
3002
|
+
async (_, taskId, page) => {
|
|
3003
|
+
try {
|
|
3004
|
+
if (!taskId) {
|
|
3005
|
+
return { success: false, error: "Task ID is required" };
|
|
3006
|
+
}
|
|
3007
|
+
if (!page || page < 1) {
|
|
3008
|
+
return { success: false, error: "Page number must be greater than 0" };
|
|
3009
|
+
}
|
|
3010
|
+
const imagePath = ImagePathUtil.getPath(taskId, page);
|
|
3011
|
+
const exists = fs.existsSync(imagePath);
|
|
3012
|
+
return {
|
|
3013
|
+
success: true,
|
|
3014
|
+
data: { imagePath, exists }
|
|
3015
|
+
};
|
|
3016
|
+
} catch (error) {
|
|
3017
|
+
console.error("[IPC] file:getImagePath error:", error);
|
|
3018
|
+
return { success: false, error: error.message };
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
);
|
|
3022
|
+
ipcMain.handle(
|
|
3023
|
+
IPC_CHANNELS.FILE.DOWNLOAD_MARKDOWN,
|
|
3024
|
+
async (_, taskId) => {
|
|
3025
|
+
try {
|
|
3026
|
+
if (!taskId) {
|
|
3027
|
+
return { success: false, error: "Task ID is required" };
|
|
3028
|
+
}
|
|
3029
|
+
const task = await taskRepository.findById(taskId);
|
|
3030
|
+
if (!task) {
|
|
3031
|
+
return { success: false, error: "Task not found" };
|
|
3032
|
+
}
|
|
3033
|
+
if (!task.merged_path) {
|
|
3034
|
+
return { success: false, error: "Merged file does not exist, task may not be completed" };
|
|
3035
|
+
}
|
|
3036
|
+
if (!fs.existsSync(task.merged_path)) {
|
|
3037
|
+
return { success: false, error: "Merged file is missing" };
|
|
3038
|
+
}
|
|
3039
|
+
const result = await dialog.showSaveDialog({
|
|
3040
|
+
title: "Save Markdown File",
|
|
3041
|
+
defaultPath: task.filename.replace(/\.[^/.]+$/, ".md"),
|
|
3042
|
+
filters: [
|
|
3043
|
+
{ name: "Markdown Files", extensions: ["md"] },
|
|
3044
|
+
{ name: "All Files", extensions: ["*"] }
|
|
3045
|
+
]
|
|
3046
|
+
});
|
|
3047
|
+
if (result.canceled || !result.filePath) {
|
|
3048
|
+
return { success: false, error: "User cancelled save" };
|
|
3049
|
+
}
|
|
3050
|
+
fs.copyFileSync(task.merged_path, result.filePath);
|
|
3051
|
+
return {
|
|
3052
|
+
success: true,
|
|
3053
|
+
data: { savedPath: result.filePath }
|
|
3054
|
+
};
|
|
3055
|
+
} catch (error) {
|
|
3056
|
+
console.error("[IPC] file:downloadMarkdown error:", error);
|
|
3057
|
+
return { success: false, error: error.message };
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
);
|
|
3061
|
+
ipcMain.handle(IPC_CHANNELS.FILE.SELECT_DIALOG, async () => {
|
|
3062
|
+
try {
|
|
3063
|
+
const result = await dialog.showOpenDialog({
|
|
3064
|
+
properties: ["openFile", "multiSelections"],
|
|
3065
|
+
filters: [
|
|
3066
|
+
{
|
|
3067
|
+
name: "PDF and Images",
|
|
3068
|
+
extensions: ["pdf", "jpg", "jpeg", "png", "bmp", "gif"]
|
|
3069
|
+
},
|
|
3070
|
+
{ name: "PDF Documents", extensions: ["pdf"] },
|
|
3071
|
+
{ name: "Images", extensions: ["jpg", "jpeg", "png", "bmp", "gif"] },
|
|
3072
|
+
{ name: "All Files", extensions: ["*"] }
|
|
3073
|
+
]
|
|
3074
|
+
});
|
|
3075
|
+
return {
|
|
3076
|
+
success: true,
|
|
3077
|
+
data: { filePaths: result.filePaths, canceled: result.canceled }
|
|
3078
|
+
};
|
|
3079
|
+
} catch (error) {
|
|
3080
|
+
console.error("[IPC] file:selectDialog error:", error);
|
|
3081
|
+
return { success: false, error: error.message };
|
|
3082
|
+
}
|
|
3083
|
+
});
|
|
3084
|
+
ipcMain.handle(
|
|
3085
|
+
IPC_CHANNELS.FILE.UPLOAD,
|
|
3086
|
+
async (_, taskId, filePath) => {
|
|
3087
|
+
try {
|
|
3088
|
+
if (!taskId || !filePath) {
|
|
3089
|
+
return { success: false, error: "Task ID and file path are required" };
|
|
3090
|
+
}
|
|
3091
|
+
if (!fs.existsSync(filePath)) {
|
|
3092
|
+
return { success: false, error: "File does not exist" };
|
|
3093
|
+
}
|
|
3094
|
+
const baseUploadDir = fileLogic.getUploadDir();
|
|
3095
|
+
const uploadDir = path.join(baseUploadDir, taskId);
|
|
3096
|
+
if (!fs.existsSync(uploadDir)) {
|
|
3097
|
+
fs.mkdirSync(uploadDir, { recursive: true });
|
|
3098
|
+
}
|
|
3099
|
+
const fileName = path.basename(filePath);
|
|
3100
|
+
const destPath = path.join(uploadDir, fileName);
|
|
3101
|
+
fs.copyFileSync(filePath, destPath);
|
|
3102
|
+
const stats = fs.statSync(destPath);
|
|
3103
|
+
const fileInfo = {
|
|
3104
|
+
originalName: fileName,
|
|
3105
|
+
savedName: fileName,
|
|
3106
|
+
path: destPath,
|
|
3107
|
+
size: stats.size,
|
|
3108
|
+
taskId
|
|
3109
|
+
};
|
|
3110
|
+
return { success: true, data: fileInfo };
|
|
3111
|
+
} catch (error) {
|
|
3112
|
+
console.error("[IPC] file:upload error:", error);
|
|
3113
|
+
return { success: false, error: error.message };
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
);
|
|
3117
|
+
ipcMain.handle(
|
|
3118
|
+
IPC_CHANNELS.FILE.UPLOAD_MULTIPLE,
|
|
3119
|
+
async (_, taskId, filePaths) => {
|
|
3120
|
+
try {
|
|
3121
|
+
if (!taskId || !Array.isArray(filePaths) || filePaths.length === 0) {
|
|
3122
|
+
return { success: false, error: "Task ID and file path list are required" };
|
|
3123
|
+
}
|
|
3124
|
+
const uploadResults = [];
|
|
3125
|
+
for (const filePath of filePaths) {
|
|
3126
|
+
if (!fs.existsSync(filePath)) {
|
|
3127
|
+
continue;
|
|
3128
|
+
}
|
|
3129
|
+
const baseUploadDir = fileLogic.getUploadDir();
|
|
3130
|
+
const uploadDir = path.join(baseUploadDir, taskId);
|
|
3131
|
+
if (!fs.existsSync(uploadDir)) {
|
|
3132
|
+
fs.mkdirSync(uploadDir, { recursive: true });
|
|
3133
|
+
}
|
|
3134
|
+
const fileName = path.basename(filePath);
|
|
3135
|
+
const destPath = path.join(uploadDir, fileName);
|
|
3136
|
+
fs.copyFileSync(filePath, destPath);
|
|
3137
|
+
const stats = fs.statSync(destPath);
|
|
3138
|
+
uploadResults.push({
|
|
3139
|
+
originalName: fileName,
|
|
3140
|
+
savedName: fileName,
|
|
3141
|
+
path: destPath,
|
|
3142
|
+
size: stats.size,
|
|
3143
|
+
taskId
|
|
3144
|
+
});
|
|
3145
|
+
}
|
|
3146
|
+
return {
|
|
3147
|
+
success: true,
|
|
3148
|
+
data: { message: "Files uploaded successfully", files: uploadResults }
|
|
3149
|
+
};
|
|
3150
|
+
} catch (error) {
|
|
3151
|
+
console.error("[IPC] file:uploadMultiple error:", error);
|
|
3152
|
+
return { success: false, error: error.message };
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
);
|
|
3156
|
+
ipcMain.handle(
|
|
3157
|
+
IPC_CHANNELS.FILE.UPLOAD_FILE_CONTENT,
|
|
3158
|
+
async (_, taskId, fileName, fileBuffer) => {
|
|
3159
|
+
try {
|
|
3160
|
+
if (!taskId || !fileName || !fileBuffer) {
|
|
3161
|
+
return { success: false, error: "Task ID, file name, and file content are required" };
|
|
3162
|
+
}
|
|
3163
|
+
const baseUploadDir = fileLogic.getUploadDir();
|
|
3164
|
+
const uploadDir = path.join(baseUploadDir, taskId);
|
|
3165
|
+
if (!fs.existsSync(uploadDir)) {
|
|
3166
|
+
fs.mkdirSync(uploadDir, { recursive: true });
|
|
3167
|
+
}
|
|
3168
|
+
const destPath = path.join(uploadDir, fileName);
|
|
3169
|
+
const buffer = Buffer.from(fileBuffer);
|
|
3170
|
+
fs.writeFileSync(destPath, buffer);
|
|
3171
|
+
const stats = fs.statSync(destPath);
|
|
3172
|
+
const fileInfo = {
|
|
3173
|
+
originalName: fileName,
|
|
3174
|
+
savedName: fileName,
|
|
3175
|
+
path: destPath,
|
|
3176
|
+
size: stats.size,
|
|
3177
|
+
taskId
|
|
3178
|
+
};
|
|
3179
|
+
return { success: true, data: fileInfo };
|
|
3180
|
+
} catch (error) {
|
|
3181
|
+
console.error("[IPC] file:uploadFileContent error:", error);
|
|
3182
|
+
return { success: false, error: error.message };
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
);
|
|
3186
|
+
console.log("[IPC] File handlers registered");
|
|
3187
|
+
}
|
|
3188
|
+
function registerCompletionHandlers() {
|
|
3189
|
+
ipcMain.handle(
|
|
3190
|
+
IPC_CHANNELS.COMPLETION.MARK_IMAGEDOWN,
|
|
3191
|
+
async (_, providerId, modelId, url) => {
|
|
3192
|
+
try {
|
|
3193
|
+
if (!providerId || !modelId || !url) {
|
|
3194
|
+
return { success: false, error: "providerId, modelId, and url are required" };
|
|
3195
|
+
}
|
|
3196
|
+
const result = await modelLogic.completion(providerId, {
|
|
3197
|
+
model: modelId,
|
|
3198
|
+
messages: [
|
|
3199
|
+
{
|
|
3200
|
+
role: "user",
|
|
3201
|
+
content: [
|
|
3202
|
+
{
|
|
3203
|
+
type: "image_url",
|
|
3204
|
+
image_url: { url }
|
|
3205
|
+
},
|
|
3206
|
+
{
|
|
3207
|
+
type: "text",
|
|
3208
|
+
text: "Convert this image to markdown."
|
|
3209
|
+
}
|
|
3210
|
+
]
|
|
3211
|
+
}
|
|
3212
|
+
]
|
|
3213
|
+
});
|
|
3214
|
+
return { success: true, data: result };
|
|
3215
|
+
} catch (error) {
|
|
3216
|
+
console.error("[IPC] completion:markImagedown error:", error);
|
|
3217
|
+
return { success: false, error: error.message };
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
);
|
|
3221
|
+
ipcMain.handle(
|
|
3222
|
+
IPC_CHANNELS.COMPLETION.TEST_CONNECTION,
|
|
3223
|
+
async (_, providerId, modelId) => {
|
|
3224
|
+
try {
|
|
3225
|
+
if (!providerId || !modelId) {
|
|
3226
|
+
return { success: false, error: "providerId and modelId are required" };
|
|
3227
|
+
}
|
|
3228
|
+
const testImageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
|
3229
|
+
const result = await modelLogic.completion(providerId, {
|
|
3230
|
+
model: modelId,
|
|
3231
|
+
messages: [
|
|
3232
|
+
{
|
|
3233
|
+
role: "user",
|
|
3234
|
+
content: [
|
|
3235
|
+
{
|
|
3236
|
+
type: "image_url",
|
|
3237
|
+
image_url: {
|
|
3238
|
+
url: `data:image/png;base64,${testImageBase64}`
|
|
3239
|
+
}
|
|
3240
|
+
},
|
|
3241
|
+
{
|
|
3242
|
+
type: "text",
|
|
3243
|
+
text: "Test connection."
|
|
3244
|
+
}
|
|
3245
|
+
]
|
|
3246
|
+
}
|
|
3247
|
+
]
|
|
3248
|
+
});
|
|
3249
|
+
return { success: true, data: result };
|
|
3250
|
+
} catch (error) {
|
|
3251
|
+
console.error("[IPC] completion:testConnection error:", error);
|
|
3252
|
+
return { success: false, error: error.message };
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
);
|
|
3256
|
+
console.log("[IPC] Completion handlers registered");
|
|
3257
|
+
}
|
|
3258
|
+
function registerAppHandlers() {
|
|
3259
|
+
ipcMain.handle("app:getVersion", () => {
|
|
3260
|
+
return app.getVersion();
|
|
3261
|
+
});
|
|
3262
|
+
}
|
|
3263
|
+
function registerAllHandlers() {
|
|
3264
|
+
registerProviderHandlers();
|
|
3265
|
+
registerModelHandlers();
|
|
3266
|
+
registerTaskHandlers();
|
|
3267
|
+
registerTaskDetailHandlers();
|
|
3268
|
+
registerFileHandlers();
|
|
3269
|
+
registerCompletionHandlers();
|
|
3270
|
+
registerAppHandlers();
|
|
3271
|
+
console.log("[IPC] All handlers registered successfully");
|
|
3272
|
+
}
|
|
3273
|
+
function registerIpcHandlers() {
|
|
3274
|
+
registerAllHandlers();
|
|
3275
|
+
}
|
|
3276
|
+
class WindowManager {
|
|
3277
|
+
static instance;
|
|
3278
|
+
mainWindow = null;
|
|
3279
|
+
constructor() {
|
|
3280
|
+
}
|
|
3281
|
+
static getInstance() {
|
|
3282
|
+
if (!WindowManager.instance) {
|
|
3283
|
+
WindowManager.instance = new WindowManager();
|
|
3284
|
+
}
|
|
3285
|
+
return WindowManager.instance;
|
|
3286
|
+
}
|
|
3287
|
+
setMainWindow(window) {
|
|
3288
|
+
this.mainWindow = window;
|
|
3289
|
+
if (window) {
|
|
3290
|
+
window.on("closed", () => {
|
|
3291
|
+
this.mainWindow = null;
|
|
3292
|
+
});
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
getMainWindow() {
|
|
3296
|
+
return this.mainWindow;
|
|
3297
|
+
}
|
|
3298
|
+
isWindowAvailable() {
|
|
3299
|
+
return this.mainWindow !== null && !this.mainWindow.isDestroyed();
|
|
3300
|
+
}
|
|
3301
|
+
sendToRenderer(channel, ...args) {
|
|
3302
|
+
if (this.isWindowAvailable()) {
|
|
3303
|
+
this.mainWindow.webContents.send(channel, ...args);
|
|
3304
|
+
} else {
|
|
3305
|
+
console.warn(`[WindowManager] Window not available (channel: ${channel})`);
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
const windowManager = WindowManager.getInstance();
|
|
3310
|
+
class EventBridge {
|
|
3311
|
+
isInitialized = false;
|
|
3312
|
+
initialize() {
|
|
3313
|
+
if (this.isInitialized) return;
|
|
3314
|
+
eventBus.onTaskEvent("task:*", this.handleTaskEvent.bind(this));
|
|
3315
|
+
eventBus.onTaskDetailEvent("taskDetail:*", this.handleTaskDetailEvent.bind(this));
|
|
3316
|
+
this.isInitialized = true;
|
|
3317
|
+
console.log("[EventBridge] Initialized");
|
|
3318
|
+
}
|
|
3319
|
+
handleTaskEvent(data) {
|
|
3320
|
+
const { type, taskId, task, timestamp } = data;
|
|
3321
|
+
windowManager.sendToRenderer("task:event", {
|
|
3322
|
+
type,
|
|
3323
|
+
taskId,
|
|
3324
|
+
task,
|
|
3325
|
+
timestamp
|
|
3326
|
+
});
|
|
3327
|
+
}
|
|
3328
|
+
handleTaskDetailEvent(data) {
|
|
3329
|
+
const { type, taskId, pageId, page, status, timestamp } = data;
|
|
3330
|
+
windowManager.sendToRenderer("taskDetail:event", {
|
|
3331
|
+
type,
|
|
3332
|
+
taskId,
|
|
3333
|
+
pageId,
|
|
3334
|
+
page,
|
|
3335
|
+
status,
|
|
3336
|
+
timestamp
|
|
3337
|
+
});
|
|
3338
|
+
}
|
|
3339
|
+
cleanup() {
|
|
3340
|
+
eventBus.removeAllListeners();
|
|
3341
|
+
this.isInitialized = false;
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
const eventBridge = new EventBridge();
|
|
3345
|
+
protocol.registerSchemesAsPrivileged([
|
|
3346
|
+
{
|
|
3347
|
+
scheme: "local-file",
|
|
3348
|
+
privileges: {
|
|
3349
|
+
secure: true,
|
|
3350
|
+
supportFetchAPI: true,
|
|
3351
|
+
bypassCSP: false,
|
|
3352
|
+
corsEnabled: false
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
]);
|
|
3356
|
+
let mainWindow;
|
|
3357
|
+
function registerLocalFileProtocol() {
|
|
3358
|
+
protocol.registerFileProtocol("local-file", (request, callback) => {
|
|
3359
|
+
try {
|
|
3360
|
+
let url = request.url.substring("local-file://".length);
|
|
3361
|
+
if (url.startsWith("/") && /^\/[A-Za-z]:/.test(url)) {
|
|
3362
|
+
url = url.substring(1);
|
|
3363
|
+
}
|
|
3364
|
+
const decodedPath = decodeURIComponent(url);
|
|
3365
|
+
const uploadsDir = fileLogic.getUploadDir();
|
|
3366
|
+
const tempDir = fileLogic.getTempDir();
|
|
3367
|
+
const normalizedPath = path.normalize(decodedPath);
|
|
3368
|
+
const isInUploads = normalizedPath.startsWith(uploadsDir);
|
|
3369
|
+
const isInTemp = normalizedPath.startsWith(tempDir);
|
|
3370
|
+
if (!isInUploads && !isInTemp) {
|
|
3371
|
+
console.error("[Protocol] Attempted to access file outside allowed directories:", normalizedPath);
|
|
3372
|
+
callback({ error: -10 });
|
|
3373
|
+
return;
|
|
3374
|
+
}
|
|
3375
|
+
if (!fs.existsSync(normalizedPath)) {
|
|
3376
|
+
console.error("[Protocol] File not found:", normalizedPath);
|
|
3377
|
+
callback({ error: -6 });
|
|
3378
|
+
return;
|
|
3379
|
+
}
|
|
3380
|
+
callback({ path: normalizedPath });
|
|
3381
|
+
} catch (error) {
|
|
3382
|
+
console.error("[Protocol] Error handling local-file request:", error);
|
|
3383
|
+
callback({ error: -2 });
|
|
3384
|
+
}
|
|
3385
|
+
});
|
|
3386
|
+
}
|
|
3387
|
+
async function startTask() {
|
|
3388
|
+
await workerOrchestrator.start();
|
|
3389
|
+
}
|
|
3390
|
+
async function stopTask() {
|
|
3391
|
+
await workerOrchestrator.stop();
|
|
3392
|
+
}
|
|
3393
|
+
function createWindow() {
|
|
3394
|
+
mainWindow = new BrowserWindow({
|
|
3395
|
+
width: 1200,
|
|
3396
|
+
height: 800,
|
|
3397
|
+
// macOS: 使用隐藏标题栏,Windows/Linux: 使用无边框窗口
|
|
3398
|
+
...process.platform === "darwin" ? { titleBarStyle: "hidden" } : { frame: false },
|
|
3399
|
+
webPreferences: {
|
|
3400
|
+
nodeIntegration: false,
|
|
3401
|
+
contextIsolation: true,
|
|
3402
|
+
preload: path.join(__dirname, "../preload/index.js")
|
|
3403
|
+
},
|
|
3404
|
+
icon: path.join(
|
|
3405
|
+
process.env.ELECTRON_RENDERER_URL ? process.cwd() : app.getAppPath(),
|
|
3406
|
+
process.platform === "darwin" ? "public/icons/mac/icon.icns" : process.platform === "win32" ? "public/icons/win/icon.ico" : "public/icons/png/512x512.png"
|
|
3407
|
+
)
|
|
3408
|
+
});
|
|
3409
|
+
windowManager.setMainWindow(mainWindow);
|
|
3410
|
+
if (process.env.ELECTRON_RENDERER_URL) {
|
|
3411
|
+
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
|
|
3412
|
+
} else {
|
|
3413
|
+
mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
|
|
3414
|
+
}
|
|
3415
|
+
if (isDev) {
|
|
3416
|
+
mainWindow.webContents.openDevTools();
|
|
3417
|
+
}
|
|
3418
|
+
mainWindow.webContents.on("will-navigate", (event, url) => {
|
|
3419
|
+
if (url.startsWith("http") && !url.includes("localhost")) {
|
|
3420
|
+
event.preventDefault();
|
|
3421
|
+
shell.openExternal(url);
|
|
3422
|
+
}
|
|
3423
|
+
});
|
|
3424
|
+
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
3425
|
+
if (url.startsWith("http")) {
|
|
3426
|
+
shell.openExternal(url);
|
|
3427
|
+
return { action: "deny" };
|
|
3428
|
+
}
|
|
3429
|
+
return { action: "allow" };
|
|
3430
|
+
});
|
|
3431
|
+
ipcMain.on("open-external-link", (_, url) => {
|
|
3432
|
+
if (url && typeof url === "string") {
|
|
3433
|
+
shell.openExternal(url);
|
|
3434
|
+
}
|
|
3435
|
+
});
|
|
3436
|
+
ipcMain.on("window:minimize", () => {
|
|
3437
|
+
if (mainWindow) {
|
|
3438
|
+
mainWindow.minimize();
|
|
3439
|
+
}
|
|
3440
|
+
});
|
|
3441
|
+
ipcMain.on("window:maximize", () => {
|
|
3442
|
+
if (mainWindow) {
|
|
3443
|
+
if (mainWindow.isMaximized()) {
|
|
3444
|
+
mainWindow.unmaximize();
|
|
3445
|
+
} else {
|
|
3446
|
+
mainWindow.maximize();
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
});
|
|
3450
|
+
ipcMain.on("window:close", () => {
|
|
3451
|
+
if (mainWindow) {
|
|
3452
|
+
mainWindow.close();
|
|
3453
|
+
}
|
|
3454
|
+
});
|
|
3455
|
+
mainWindow.on("closed", () => {
|
|
3456
|
+
mainWindow = null;
|
|
3457
|
+
windowManager.setMainWindow(null);
|
|
3458
|
+
});
|
|
3459
|
+
}
|
|
3460
|
+
app.whenReady().then(async () => {
|
|
3461
|
+
try {
|
|
3462
|
+
const startTime = Date.now();
|
|
3463
|
+
registerLocalFileProtocol();
|
|
3464
|
+
console.log("[Main] Custom protocol 'local-file' registered");
|
|
3465
|
+
registerIpcHandlers();
|
|
3466
|
+
eventBridge.initialize();
|
|
3467
|
+
createWindow();
|
|
3468
|
+
console.log(`[Main] Window created in ${Date.now() - startTime}ms`);
|
|
3469
|
+
initializeBackgroundServices().catch((error) => {
|
|
3470
|
+
console.error("[Main] Background services initialization failed:", error);
|
|
3471
|
+
});
|
|
3472
|
+
} catch (error) {
|
|
3473
|
+
console.error("[Main] Error starting application:", error);
|
|
3474
|
+
app.quit();
|
|
3475
|
+
}
|
|
3476
|
+
});
|
|
3477
|
+
async function initializeBackgroundServices() {
|
|
3478
|
+
try {
|
|
3479
|
+
const startTime = Date.now();
|
|
3480
|
+
console.log("[Main] Initializing database in background...");
|
|
3481
|
+
await initDatabase();
|
|
3482
|
+
console.log(`[Main] Database initialized in ${Date.now() - startTime}ms`);
|
|
3483
|
+
console.log("[Main] Starting task logic in background...");
|
|
3484
|
+
const taskStartTime = Date.now();
|
|
3485
|
+
await startTask();
|
|
3486
|
+
console.log(`[Main] Task logic started in ${Date.now() - taskStartTime}ms`);
|
|
3487
|
+
console.log(
|
|
3488
|
+
`[Main] Background services initialized successfully in ${Date.now() - startTime}ms`
|
|
3489
|
+
);
|
|
3490
|
+
if (mainWindow) {
|
|
3491
|
+
mainWindow.webContents.send("app:ready");
|
|
3492
|
+
}
|
|
3493
|
+
} catch (error) {
|
|
3494
|
+
console.error("[Main] Background services initialization error:", error);
|
|
3495
|
+
throw error;
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
app.on("window-all-closed", () => {
|
|
3499
|
+
if (process.platform !== "darwin") {
|
|
3500
|
+
if (workerOrchestrator.getStatus()) {
|
|
3501
|
+
stopTask();
|
|
3502
|
+
}
|
|
3503
|
+
eventBridge.cleanup();
|
|
3504
|
+
disconnect();
|
|
3505
|
+
app.quit();
|
|
3506
|
+
}
|
|
3507
|
+
});
|
|
3508
|
+
app.on("activate", async () => {
|
|
3509
|
+
if (mainWindow === null) {
|
|
3510
|
+
try {
|
|
3511
|
+
createWindow();
|
|
3512
|
+
if (!workerOrchestrator.getStatus()) {
|
|
3513
|
+
await startTask();
|
|
3514
|
+
}
|
|
3515
|
+
} catch (error) {
|
|
3516
|
+
console.error("[Main] Error reactivating application:", error);
|
|
3517
|
+
app.quit();
|
|
3518
|
+
}
|
|
3519
|
+
}
|
|
3520
|
+
});
|
|
3521
|
+
export {
|
|
3522
|
+
LLMClient as L
|
|
3523
|
+
};
|