返回 oh-my-ppt
deck-edit-job-service.ts
根目录 / src / main / edit-jobs / deck-edit-job-service.ts
1 import { ipcMain } from 'electron'
2 import crypto from 'crypto'
3 import log from 'electron-log/main.js'
4 import type { GenerateStartPayload } from '@shared/generation'
5 import type { IpcContext } from '../ipc/context'
6 import { executeDeckAllPageEditGeneration } from '../generation/edit-deck-allpage-flow'
7 import { resolveEditContext } from '../generation/edit-flow'
8 import { createEmitAssistantMessage } from '../generation/generation-utils'
9 import { createGenerationContext, normalizeGeneratePayload } from '../generation/context'
10 import type { EditContext } from '../generation/types'
11 import { isCancellationMessage, normalizeRestoredSessionStatus } from '../generation/status-utils'
12 import { JobCoordinator, sessionLockKey, type JobLease } from '../agent-runtime'
13 import { settleEditJobFailure, settleEditJobSuccess } from './edit-job-finalization'
14
15 type ActiveDeckEditJob = {
16 sessionId: string
17 runId: string
18 lease: JobLease
19 context: EditContext
20 }
21
22 type DeckEditRunSnapshot = {
23 sessionId: string
24 runId: string | null
25 status: 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
26 hasActiveRun: boolean
27 progress: number
28 totalPages: number
29 completedPageCount: number
30 failedPageCount: number
31 events: never[]
32 error: string | null
33 startedAt: number | null
34 updatedAt: number | null
35 kind: 'deck-edit'
36 retryPayload?: GenerateStartPayload
37 }
38
39 const buildDeckEditRetryPayload = (
40 input: ReturnType<typeof normalizeGeneratePayload>,
41 modelConfigId?: string
42 ): GenerateStartPayload => ({
43 sessionId: input.sessionId,
44 modelConfigId: modelConfigId || input.modelConfigId,
45 userMessage: input.rawUserMessage,
46 type: 'page',
47 chatType: 'main',
48 selectPageIds: input.selectPageIds,
49 imagePaths: input.rawImagePaths,
50 videoPaths: input.rawVideoPaths,
51 docPaths: input.rawDocPaths
52 })
53
54 const parseDeckEditRetryPayload = (
55 metadata: string | null,
56 sessionId: string
57 ): GenerateStartPayload | undefined => {
58 if (!metadata) return undefined
59 try {
60 const parsed = JSON.parse(metadata) as { retryPayload?: unknown }
61 const input = normalizeGeneratePayload({
62 ...(parsed.retryPayload && typeof parsed.retryPayload === 'object'
63 ? parsed.retryPayload
64 : {}),
65 sessionId,
66 type: 'page',
67 chatType: 'main'
68 })
69 if (!input.rawUserMessage.trim()) return undefined
70 return buildDeckEditRetryPayload(input, input.modelConfigId)
71 } catch {
72 return undefined
73 }
74 }
75
76 export class DeckEditJobService {
77 private activeJobs = new Map<string, ActiveDeckEditJob>()
78 private reservedJobIds = new Map<string, string>()
79
80 constructor(private ctx: IpcContext, private coordinator: JobCoordinator) {}
81
82 async start(event: Electron.IpcMainInvokeEvent, payload: unknown): Promise<{
83 success: boolean
84 runId?: string
85 alreadyRunning?: boolean
86 }> {
87 const input = normalizeGeneratePayload(payload)
88 if (!input.sessionId) throw new Error('sessionId 不能为空')
89 if (input.requestedType !== 'page' || input.chatType !== 'main') {
90 throw new Error('deck-edit:start 仅支持主会话批量编辑')
91 }
92
93 const reservation = await this.coordinator.reserve({
94 jobId: crypto.randomUUID(),
95 domain: 'edit',
96 owner: { kind: 'session', id: input.sessionId },
97 claims: { write: [sessionLockKey(input.sessionId)] },
98 wait: 'fail'
99 })
100 if (reservation.status === 'busy') {
101 return { success: true, runId: reservation.conflictingJobId, alreadyRunning: true }
102 }
103 const lease = reservation.lease
104 this.reservedJobIds.set(input.sessionId, lease.jobId)
105 let context: EditContext | null = null
106 let jobCreated = false
107 try {
108 const editContext = await resolveEditContext(createGenerationContext(this.ctx), event, payload, {
109 runId: lease.jobId,
110 abortSignal: lease.signal
111 })
112 context = editContext
113 if (lease.signal.aborted) throw new Error('生成已取消')
114 if (editContext.runId !== lease.jobId) {
115 throw new Error('批量编辑 runId 与 JobCoordinator lease 不一致')
116 }
117
118 const totalPages = Math.max(1, input.selectPageIds.length || editContext.totalPages)
119 const retryPayload = buildDeckEditRetryPayload(input, editContext.modelConfigId)
120 await this.ctx.db.createGenerationRunWithSessionJob({
121 run: {
122 id: editContext.runId,
123 sessionId: editContext.sessionId,
124 mode: 'edit',
125 totalPages,
126 modelConfigId: editContext.modelConfigId,
127 metadata: {
128 jobType: 'deck-edit',
129 editScope: 'deck',
130 selectPageIds: input.selectPageIds,
131 modelConfigId: editContext.modelConfigId,
132 modelConfigName: editContext.modelConfigName,
133 provider: editContext.provider,
134 model: editContext.model,
135 retryPayload
136 }
137 },
138 job: {
139 id: editContext.runId,
140 sessionId: editContext.sessionId,
141 kind: 'deck-edit',
142 status: 'active',
143 previousSessionStatus: normalizeRestoredSessionStatus(editContext.previousSessionStatus),
144 totalPages
145 }
146 })
147 jobCreated = true
148 if (lease.signal.aborted) throw new Error('生成已取消')
149 editContext.skipGenerationRunCreation = true
150 this.ctx.beginSessionRunState({
151 sessionId: editContext.sessionId,
152 runId: editContext.runId,
153 mode: 'edit',
154 kind: 'deck-edit',
155 activityKind: 'deck-edit',
156 totalPages,
157 previousSessionStatus: editContext.previousSessionStatus,
158 status: 'running'
159 })
160
161 const job: ActiveDeckEditJob = {
162 sessionId: editContext.sessionId,
163 runId: editContext.runId,
164 lease,
165 context: editContext
166 }
167 this.activeJobs.set(editContext.sessionId, job)
168 void this.run(job)
169 return { success: true, runId: editContext.runId }
170 } catch (error) {
171 try {
172 if (context) {
173 const message = error instanceof Error ? error.message : String(error || '')
174 await settleEditJobFailure({
175 ctx: this.ctx,
176 context,
177 error,
178 cancelled: lease.signal.aborted || isCancellationMessage(message),
179 hasPersistedJob: jobCreated,
180 logPrefix: '[deck-edit:job]'
181 })
182 }
183 } finally {
184 lease.release()
185 this.reservedJobIds.delete(input.sessionId)
186 if (context) this.ctx.agentManager.removeSession(context.sessionId)
187 }
188 throw error
189 }
190 }
191
192 async retry(event: Electron.IpcMainInvokeEvent, payload: unknown): Promise<{
193 success: boolean
194 runId?: string
195 alreadyRunning?: boolean
196 failedPageCount: number
197 }> {
198 const record = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
199 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
200 const failedRunId =
201 typeof record.failedRunId === 'string' ? record.failedRunId.trim() || undefined : undefined
202 const userMessage = typeof record.userMessage === 'string' ? record.userMessage.trim() : ''
203 if (!sessionId) throw new Error('sessionId 不能为空')
204 if (!userMessage) throw new Error('重试编辑失败:缺少原始编辑指令')
205
206 const failedPages = failedRunId
207 ? await this.getFailedPagesForRun(sessionId, failedRunId)
208 : await this.ctx.db.listLatestFailedGenerationPages(sessionId)
209 let failedPageIds = Array.from(
210 new Set(failedPages.map((page) => page.page_id).filter((pageId) => pageId.length > 0))
211 )
212 if (failedPageIds.length === 0) {
213 failedPageIds = normalizeGeneratePayload(record).selectPageIds
214 }
215 if (failedPageIds.length === 0) {
216 failedPageIds = (await this.ctx.db.listSessionPages(sessionId)).map((page) => page.file_slug)
217 }
218 if (failedPageIds.length === 0) return { success: true, failedPageCount: 0 }
219
220 const result = await this.start(event, {
221 ...record,
222 sessionId,
223 userMessage,
224 type: 'page',
225 chatType: 'main',
226 selectPageIds: failedPageIds,
227 persistUserMessage: false
228 })
229 return { ...result, failedPageCount: 0 }
230 }
231
232 async cancel(sessionId: string): Promise<boolean> {
233 const job = this.activeJobs.get(sessionId)
234 if (!job) {
235 const jobId = this.reservedJobIds.get(sessionId)
236 return jobId ? this.coordinator.cancel(jobId) : false
237 }
238 return this.coordinator.cancel(job.lease.jobId)
239 }
240
241 async getState(sessionId: string): Promise<DeckEditRunSnapshot> {
242 const activeState = this.ctx.sessionRunStates.get(sessionId)
243 if (activeState?.activityKind === 'deck-edit') {
244 const run = await this.ctx.db.getGenerationRun(activeState.runId)
245 return {
246 sessionId,
247 runId: activeState.runId,
248 status: activeState.status,
249 hasActiveRun: activeState.status === 'queued' || activeState.status === 'running',
250 progress: activeState.progress,
251 totalPages: activeState.totalPages,
252 completedPageCount: activeState.completedPageKeys.length,
253 failedPageCount: activeState.failedPageKeys.length,
254 events: [],
255 error: activeState.error,
256 startedAt: activeState.startedAt,
257 updatedAt: activeState.updatedAt,
258 kind: 'deck-edit',
259 retryPayload: parseDeckEditRetryPayload(run?.metadata || null, sessionId)
260 }
261 }
262
263 const job = await this.ctx.db.getLatestSessionJob(sessionId, ['deck-edit'])
264 const run = job ? await this.ctx.db.getGenerationRun(job.id) : undefined
265 const retryPayload = parseDeckEditRetryPayload(run?.metadata || null, sessionId)
266 if (job?.status === 'active') {
267 return {
268 sessionId,
269 runId: job.id,
270 status: 'running',
271 hasActiveRun: true,
272 progress: 0,
273 totalPages: job.total_pages || 1,
274 completedPageCount: 0,
275 failedPageCount: 0,
276 events: [],
277 error: null,
278 startedAt: job.activated_at || job.created_at,
279 updatedAt: job.updated_at,
280 kind: 'deck-edit',
281 retryPayload
282 }
283 }
284
285 const generationPages = run ? await this.ctx.db.listGenerationPages(run.id) : []
286 const completedPageCount = generationPages.filter((page) => page.status === 'completed').length
287 const persistedFailedPageCount = generationPages.filter((page) => page.status === 'failed').length
288 const userCancelled = job?.status === 'aborted' && job.abort_reason === 'cancelled'
289 const interrupted = job?.status === 'aborted' && !userCancelled
290 const failed = run?.status === 'failed' || run?.status === 'partial' || interrupted
291 const failedPageCount = failed
292 ? persistedFailedPageCount || Math.max(1, job?.total_pages || run?.total_pages || 1)
293 : 0
294
295 return {
296 sessionId,
297 runId: job?.id || null,
298 status: userCancelled
299 ? 'cancelled'
300 : failed
301 ? 'failed'
302 : run?.status === 'completed'
303 ? 'completed'
304 : 'idle',
305 hasActiveRun: false,
306 progress: run?.status === 'completed' || run?.status === 'partial' ? 100 : 0,
307 totalPages: job?.total_pages || 1,
308 completedPageCount,
309 failedPageCount,
310 events: [],
311 error: run?.error || job?.abort_reason || null,
312 startedAt: job?.created_at || null,
313 updatedAt: job?.updated_at || null,
314 kind: 'deck-edit',
315 retryPayload: failed && !userCancelled ? retryPayload : undefined
316 }
317 }
318
319 async listActive(): Promise<DeckEditRunSnapshot[]> {
320 const jobs = await this.ctx.db.listActiveSessionJobs(['deck-edit'])
321 return Promise.all(jobs.map((job) => this.getState(job.session_id)))
322 }
323
324 async abortInterruptedJobs(reason: string): Promise<void> {
325 const jobs = await this.ctx.db.listActiveSessionJobs(['deck-edit'])
326 for (const job of jobs) {
327 if (this.activeJobs.has(job.session_id)) continue
328 await this.ctx.db.updateSessionJobStatus(job.id, 'aborted', { abortReason: reason })
329 await this.ctx.db.updateGenerationRunStatus(job.id, 'failed', reason)
330 await this.ctx.db.updateSessionStatus(
331 job.session_id,
332 normalizeRestoredSessionStatus(job.previous_session_status)
333 )
334 }
335 }
336
337 private async run(job: ActiveDeckEditJob): Promise<void> {
338 const emitAssistant = createEmitAssistantMessage(this.ctx.db, this.ctx.emitGenerateChunk)
339 try {
340 await executeDeckAllPageEditGeneration(
341 createGenerationContext(this.ctx),
342 emitAssistant,
343 job.context
344 )
345 await settleEditJobSuccess({ ctx: this.ctx, context: job.context })
346 } catch (error) {
347 const message = error instanceof Error ? error.message : String(error || '')
348 const cancelled = job.lease.signal.aborted || isCancellationMessage(message)
349 await settleEditJobFailure({
350 ctx: this.ctx,
351 context: job.context,
352 error,
353 cancelled,
354 hasPersistedJob: true,
355 logPrefix: '[deck-edit:job]'
356 })
357 } finally {
358 this.ctx.agentManager.removeSession(job.sessionId)
359 this.activeJobs.delete(job.sessionId)
360 this.reservedJobIds.delete(job.sessionId)
361 job.lease.release()
362 }
363 }
364
365 private async getFailedPagesForRun(sessionId: string, runId: string) {
366 const run = await this.ctx.db.getGenerationRun(runId)
367 if (!run || run.session_id !== sessionId) {
368 throw new Error('重试失败:原失败任务不存在或不属于当前 Session')
369 }
370 return (await this.ctx.db.listGenerationPages(runId)).filter((page) => page.status === 'failed')
371 }
372 }
373
374 export function registerDeckEditJobHandlers(
375 ctx: IpcContext,
376 coordinator: JobCoordinator
377 ): DeckEditJobService {
378 const service = new DeckEditJobService(ctx, coordinator)
379 const interruptedReady = service.abortInterruptedJobs('应用退出导致主会话编辑中断,可重新发起').catch((error) => {
380 log.warn('[deck-edit:job] failed to abort interrupted jobs', {
381 message: error instanceof Error ? error.message : String(error)
382 })
383 })
384
385 ipcMain.handle('deck-edit:start', async (event, payload) => {
386 await interruptedReady
387 return service.start(event, payload)
388 })
389 ipcMain.handle('deck-edit:cancel', async (_event, rawSessionId) => {
390 await interruptedReady
391 const sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : ''
392 return { success: sessionId ? await service.cancel(sessionId) : true }
393 })
394 ipcMain.handle('deck-edit:state', async (_event, rawSessionId) => {
395 await interruptedReady
396 const sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : ''
397 if (!sessionId) throw new Error('sessionId 不能为空')
398 return service.getState(sessionId)
399 })
400 ipcMain.handle('deck-edit:listActive', async () => {
401 await interruptedReady
402 return service.listActive()
403 })
404 return service
405 }
406
406 lines TYPESCRIPT