返回 oh-my-ppt
style-switch-job-service.ts
根目录 / src / main / edit-jobs / style-switch-job-service.ts
1 import { ipcMain } from 'electron'
2 import crypto from 'crypto'
3 import path from 'path'
4 import log from 'electron-log/main.js'
5 import type { SessionStyleSnapshotRow } from '../db/database'
6 import type { IpcContext } from '../ipc/context'
7 import { resolveEditContext } from '../generation/edit-flow'
8 import { createGenerationContext } from '../generation/context'
9 import { buildStyleSwitchUserMessage } from '../generation/style-switch'
10 import { resolvePageHtmlPath } from '../generation/generation-utils'
11 import { isCancellationMessage, normalizeRestoredSessionStatus } from '../generation/status-utils'
12 import { buildDesignContractWithLLM } from '../generation/agent-runner'
13 import { ensureHistoryBaselineSafe, GitHistoryService } from '../history/git-history-service'
14 import { JobCoordinator, sessionLockKey } from '../agent-runtime'
15 import type { EditContext } from '../generation/types'
16 import type { GenerateChunkEvent } from '@shared/generation'
17 import { normalizeLayoutIntent } from '@shared/layout-intent'
18 import { runStyleSwitchPageFlow } from './style-switch-job-flow'
19 import {
20 readStyleSwitchFileSnapshot,
21 restoreStyleSwitchFileSnapshot
22 } from './style-switch-job-files'
23 import {
24 STYLE_SWITCH_CONCURRENCY,
25 type ActiveStyleSwitchJob,
26 type StyleSwitchJobSnapshot,
27 type StyleSwitchPageRef,
28 type StyleSwitchRunMetadata
29 } from './style-switch-job-types'
30
31 const parseJson = <T>(value: string | null | undefined, fallback: T): T => {
32 if (!value) return fallback
33 try {
34 return JSON.parse(value) as T
35 } catch {
36 return fallback
37 }
38 }
39
40 const errorMessage = (error: unknown): string =>
41 error instanceof Error && error.message ? error.message : String(error || '风格切换失败')
42
43 class StyleSwitchCommittedPageFinalizationError extends Error {
44 constructor(cause: unknown) {
45 super(`页面历史已写入,但页面状态更新失败:${errorMessage(cause)}`)
46 this.name = 'StyleSwitchCommittedPageFinalizationError'
47 }
48 }
49
50 class StyleSwitchHistoryCommitError extends Error {
51 constructor(cause: unknown) {
52 super(`页面版本历史写入失败:${errorMessage(cause)}`)
53 this.name = 'StyleSwitchHistoryCommitError'
54 }
55 }
56
57 const readSessionDesignContract = (value: string | null | undefined): unknown =>
58 parseJson<unknown>(value, null)
59
60 const toRelativeProjectPath = (projectDir: string, filePath: string): string => {
61 const relative = path.relative(projectDir, filePath).split(path.sep).join('/')
62 if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) {
63 throw new Error(`风格切换页面路径不在项目目录内:${filePath}`)
64 }
65 return relative
66 }
67
68 export class StyleSwitchJobService {
69 private activeJobs = new Map<string, ActiveStyleSwitchJob>()
70 private reservedJobIds = new Map<string, string>()
71
72 constructor(private ctx: IpcContext, private coordinator: JobCoordinator) {}
73
74 async start(
75 event: Electron.IpcMainInvokeEvent,
76 payload: unknown,
77 options?: { pageIds?: string[]; sourceRunId?: string; retryCounts?: Record<string, number> }
78 ): Promise<{
79 success: boolean
80 runId?: string
81 styleId: string
82 alreadyRunning?: boolean
83 unchanged?: boolean
84 }> {
85 const record =
86 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
87 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
88 const styleId = typeof record.styleId === 'string' ? record.styleId.trim() : ''
89 const modelConfigId =
90 typeof record.modelConfigId === 'string'
91 ? record.modelConfigId.trim() || undefined
92 : undefined
93 if (!sessionId) throw new Error('sessionId 不能为空')
94 if (!styleId) throw new Error('styleId 不能为空')
95
96 const reservation = await this.coordinator.reserve({
97 jobId: crypto.randomUUID(),
98 domain: 'style',
99 owner: { kind: 'session', id: sessionId },
100 claims: { write: [sessionLockKey(sessionId)] },
101 wait: 'fail'
102 })
103 if (reservation.status === 'busy') {
104 return { success: true, styleId, runId: reservation.conflictingJobId, alreadyRunning: true }
105 }
106
107 const lease = reservation.lease
108 this.reservedJobIds.set(sessionId, lease.jobId)
109 let context: EditContext | null = null
110 let jobCreated = false
111 let previousStyleId: string | null = null
112 let previousStyleSnapshot: SessionStyleSnapshotRow | null = null
113 let previousDesignContract: unknown = null
114 let targetSnapshotInstalled = false
115 try {
116 const style = this.ctx.db.getStyleRowSync(styleId)
117 if (!style || style.active === false) throw new Error('选择的风格不存在或已停用')
118 const session = await this.ctx.db.getSession(sessionId)
119 if (!session) throw new Error('Session not found')
120 previousStyleId = session.styleId
121 previousStyleSnapshot = (await this.ctx.db.getSessionStyleSnapshot(sessionId)) || null
122 previousDesignContract = readSessionDesignContract(session.designContract)
123
124 const sessionPages = await this.ctx.db.listSessionPages(sessionId)
125 const requestedPageIds = Array.from(new Set(options?.pageIds || []))
126 const selectedPages =
127 requestedPageIds.length > 0
128 ? sessionPages.filter((page) => requestedPageIds.includes(page.file_slug))
129 : sessionPages
130 if (requestedPageIds.length > 0 && selectedPages.length !== requestedPageIds.length) {
131 throw new Error('重试页面不存在或已被删除')
132 }
133 if (selectedPages.length === 0) throw new Error('没有可切换风格的页面')
134 if (requestedPageIds.length === 0 && session.styleId === styleId) {
135 return { success: true, styleId, unchanged: true }
136 }
137
138 await ensureHistoryBaselineSafe(
139 this.ctx.db,
140 sessionId,
141 await this.ctx.resolveSessionProjectDir(sessionId)
142 )
143 await new GitHistoryService(this.ctx.db).captureCurrentVersionStyleState(sessionId)
144
145 // resolveEditContext obtains its style prompt from the session snapshot. Keep the user-visible
146 // session style unchanged until the first page history commit succeeds.
147 await this.ctx.db.replaceSessionStyleSnapshot(sessionId, styleId)
148 targetSnapshotInstalled = true
149 context = await resolveEditContext(
150 createGenerationContext(this.ctx),
151 event,
152 {
153 sessionId,
154 modelConfigId,
155 userMessage: buildStyleSwitchUserMessage(style.styleName),
156 type: 'page',
157 chatType: 'main',
158 selectPageIds: selectedPages.map((page) => page.file_slug),
159 resetVisualStyle: true,
160 persistUserMessage: false
161 },
162 { runId: lease.jobId, abortSignal: lease.signal }
163 )
164 if (context.runId !== lease.jobId) {
165 throw new Error('风格切换 runId 与 JobCoordinator lease 不一致')
166 }
167
168 const latestPages = await this.ctx.db.listLatestGenerationPageSnapshot(sessionId)
169 const latestByPageId = new Map(latestPages.map((page) => [page.page_id, page]))
170 const projectDir = context.projectDir
171 const pageRefs: StyleSwitchPageRef[] = selectedPages.map((page) => {
172 const latest = latestByPageId.get(page.file_slug)
173 return {
174 id: page.id,
175 pageId: page.file_slug,
176 pageNumber: page.page_number,
177 title: page.title || `第${page.page_number}页`,
178 htmlPath: resolvePageHtmlPath({
179 projectDir,
180 fileSlug: page.file_slug,
181 candidates: [page.html_path]
182 }),
183 contentOutline: latest?.content_outline || '',
184 layoutIntent: latest?.layout_intent
185 ? normalizeLayoutIntent(latest.layout_intent)
186 : undefined,
187 retryCount: Math.max(0, Math.floor(options?.retryCounts?.[page.file_slug] || 0))
188 }
189 })
190 pageRefs.sort((left, right) => left.pageNumber - right.pageNumber)
191
192 const metadata: StyleSwitchRunMetadata = {
193 jobType: 'style-switch',
194 targetStyleId: style.id,
195 targetStyleName: style.styleName,
196 previousStyleId,
197 previousStyleSnapshot,
198 previousDesignContract,
199 designContract: null,
200 pageIds: pageRefs.map((page) => page.pageId),
201 sourceRunId: options?.sourceRunId,
202 userMessage: context.userMessage
203 }
204 await this.ctx.db.createGenerationRunWithSessionJobAndPages({
205 run: {
206 id: context.runId,
207 sessionId,
208 mode: 'style-switch',
209 totalPages: pageRefs.length,
210 modelConfigId: context.modelConfigId,
211 metadata
212 },
213 job: {
214 id: context.runId,
215 sessionId,
216 kind: 'style-switch',
217 status: 'active',
218 previousSessionStatus: normalizeRestoredSessionStatus(context.previousSessionStatus),
219 totalPages: pageRefs.length
220 },
221 pages: pageRefs.map((page) => ({
222 pageId: page.pageId,
223 pageNumber: page.pageNumber,
224 title: page.title,
225 contentOutline: page.contentOutline,
226 layoutIntent: page.layoutIntent,
227 htmlPath: page.htmlPath,
228 status: 'pending',
229 retryCount: page.retryCount
230 }))
231 })
232 jobCreated = true
233 this.ctx.beginSessionRunState({
234 sessionId,
235 runId: context.runId,
236 mode: 'style-switch',
237 kind: 'style-switch',
238 activityKind: 'style-switch',
239 totalPages: pageRefs.length,
240 previousSessionStatus: context.previousSessionStatus,
241 status: 'running'
242 })
243 this.ctx.emitGenerateChunk(sessionId, {
244 type: 'stage_started',
245 payload: {
246 runId: context.runId,
247 stage: 'style-switch',
248 label: context.appLocale === 'en' ? 'Preparing style switch' : '正在准备切换风格',
249 progress: 0,
250 totalPages: pageRefs.length
251 }
252 })
253
254 const designContract = await buildDesignContractWithLLM({
255 provider: context.provider,
256 apiKey: context.apiKey,
257 model: context.model,
258 baseUrl: context.providerBaseUrl,
259 maxTokens: context.maxTokens,
260 modelRuntime: this.ctx.modelRuntime,
261 modelTimeoutMs: context.modelTimeouts.design,
262 temperature: this.ctx.DESIGN_CONTRACT_TEMPERATURE,
263 styleId: context.styleId,
264 styleSkillPrompt: context.styleSkill.prompt,
265 styleKey: context.styleKey,
266 styleName: context.styleName,
267 styleVersion: context.styleVersion,
268 appLocale: context.appLocale,
269 totalPages: pageRefs.length,
270 slideSize: context.slideSize,
271 topic: context.topic,
272 userMessage: context.userMessage,
273 fontSelection: context.fontSelection,
274 emit: this.ctx.createDeckProgressEmitter(sessionId, context.appLocale),
275 runId: context.runId,
276 signal: context.abortSignal
277 })
278 context.designContract = designContract
279 await this.ctx.db.updateGenerationRunMetadata(context.runId, { ...metadata, designContract })
280
281 const job: ActiveStyleSwitchJob = {
282 sessionId,
283 runId: context.runId,
284 styleId,
285 lease,
286 context,
287 pageRefs,
288 previousStyleId,
289 previousStyleSnapshot,
290 previousDesignContract,
291 designContract,
292 styleStateCommitted: false,
293 commitQueue: Promise.resolve(),
294 fatalError: null
295 }
296 this.activeJobs.set(sessionId, job)
297 void this.run(job)
298 return { success: true, runId: context.runId, styleId }
299 } catch (error) {
300 const message = errorMessage(error)
301 let terminalRunId: string | null = null
302 let terminalCancelled = false
303 if (jobCreated && context) {
304 await this.markUnfinishedPagesFailed(context.runId, message)
305 await this.ctx.db.updateSessionJobStatus(context.runId, 'aborted', {
306 abortReason: lease.signal.aborted ? 'cancelled' : 'setup_failed'
307 })
308 await this.ctx.db.updateGenerationRunStatus(context.runId, 'failed', message)
309 terminalRunId = context.runId
310 terminalCancelled = lease.signal.aborted
311 }
312 if (targetSnapshotInstalled) {
313 await this.ctx.db
314 .restoreSessionStyleState(sessionId, previousStyleId, previousStyleSnapshot || undefined)
315 .catch(() => undefined)
316 await this.ctx.db
317 .updateSessionDesignContract(sessionId, previousDesignContract)
318 .catch(() => undefined)
319 }
320 if (context) {
321 await this.ctx.db
322 .updateSessionStatus(
323 sessionId,
324 normalizeRestoredSessionStatus(context.previousSessionStatus)
325 )
326 .catch(() => undefined)
327 }
328 if (terminalRunId) {
329 this.ctx.emitGenerateChunk(sessionId, {
330 type: 'run_error',
331 payload: { runId: terminalRunId, message, cancelled: terminalCancelled }
332 })
333 this.ctx.emitRuntimeJobTerminal({
334 sessionId,
335 jobId: terminalRunId,
336 domain: 'style',
337 status: terminalCancelled ? 'cancelled' : 'failed',
338 errorCode: terminalCancelled ? undefined : 'style_switch_setup_failed',
339 errorMessage: terminalCancelled ? undefined : message
340 })
341 }
342 throw error
343 } finally {
344 if (!this.activeJobs.has(sessionId)) {
345 lease.release()
346 this.reservedJobIds.delete(sessionId)
347 if (context) this.ctx.agentManager.removeSession(context.sessionId)
348 }
349 }
350 }
351
352 async retryPage(
353 event: Electron.IpcMainInvokeEvent,
354 payload: unknown
355 ): Promise<{ success: boolean; runId?: string; styleId: string; alreadyRunning?: boolean }> {
356 const record =
357 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
358 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
359 const pageId = typeof record.pageId === 'string' ? record.pageId.trim() : ''
360 const failedRunId = typeof record.failedRunId === 'string' ? record.failedRunId.trim() : ''
361 if (!sessionId || !pageId) throw new Error('重试风格切换缺少页面参数')
362 const sourceRunId =
363 failedRunId || (await this.ctx.db.getLatestSessionJob(sessionId, ['style-switch']))?.id
364 if (!sourceRunId) throw new Error('没有可重试的风格切换任务')
365 const sourceRun = await this.ctx.db.getGenerationRun(sourceRunId)
366 if (!sourceRun || sourceRun.session_id !== sessionId) throw new Error('重试来源任务不存在')
367 const failedPage = (await this.ctx.db.listGenerationPages(sourceRunId)).find(
368 (page) => page.page_id === pageId && page.status === 'failed'
369 )
370 if (!failedPage) throw new Error('该页面不是可重试的失败页面')
371 const metadata = parseJson<Partial<StyleSwitchRunMetadata>>(sourceRun.metadata, {})
372 if (!metadata.targetStyleId) throw new Error('重试来源缺少目标风格')
373 return this.start(
374 event,
375 { ...record, sessionId, styleId: metadata.targetStyleId },
376 {
377 pageIds: [pageId],
378 sourceRunId,
379 retryCounts: { [pageId]: failedPage.retry_count + 1 }
380 }
381 )
382 }
383
384 async retryFailed(
385 event: Electron.IpcMainInvokeEvent,
386 payload: unknown
387 ): Promise<{
388 success: boolean
389 runId?: string
390 styleId: string
391 alreadyRunning?: boolean
392 failedPageCount: number
393 }> {
394 const record =
395 payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
396 const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''
397 const failedRunId = typeof record.failedRunId === 'string' ? record.failedRunId.trim() : ''
398 if (!sessionId) throw new Error('sessionId 不能为空')
399 const sourceRunId =
400 failedRunId || (await this.ctx.db.getLatestSessionJob(sessionId, ['style-switch']))?.id
401 if (!sourceRunId) return { success: true, styleId: '', failedPageCount: 0 }
402 const sourceRun = await this.ctx.db.getGenerationRun(sourceRunId)
403 if (!sourceRun || sourceRun.session_id !== sessionId) throw new Error('重试来源任务不存在')
404 const failedPages = (await this.ctx.db.listGenerationPages(sourceRunId)).filter(
405 (page) => page.status === 'failed'
406 )
407 const failedPageIds = failedPages.map((page) => page.page_id)
408 const metadata = parseJson<Partial<StyleSwitchRunMetadata>>(sourceRun.metadata, {})
409 const styleId =
410 typeof record.styleId === 'string' ? record.styleId.trim() : metadata.targetStyleId || ''
411 if (!styleId) throw new Error('重试来源缺少目标风格')
412 if (failedPageIds.length === 0) return { success: true, styleId, failedPageCount: 0 }
413 const result = await this.start(
414 event,
415 { ...record, sessionId, styleId },
416 {
417 pageIds: failedPageIds,
418 sourceRunId,
419 retryCounts: Object.fromEntries(
420 failedPages.map((page) => [page.page_id, page.retry_count + 1])
421 )
422 }
423 )
424 return { ...result, failedPageCount: failedPageIds.length }
425 }
426
427 async cancel(sessionId: string): Promise<boolean> {
428 const job = this.activeJobs.get(sessionId)
429 if (job) {
430 return this.coordinator.cancel(job.lease.jobId)
431 }
432 const jobId = this.reservedJobIds.get(sessionId)
433 return jobId ? this.coordinator.cancel(jobId) : false
434 }
435
436 async getState(sessionId: string): Promise<StyleSwitchJobSnapshot> {
437 const active = this.activeJobs.get(sessionId)
438 const activeState = this.ctx.sessionRunStates.get(sessionId)
439 const job = active
440 ? await this.ctx.db.getSessionJob(active.runId)
441 : await this.ctx.db.getLatestSessionJob(sessionId, ['style-switch'])
442 const run = job ? await this.ctx.db.getGenerationRun(job.id) : undefined
443 const pages = run ? await this.ctx.db.listGenerationPages(run.id) : []
444 const metadata = parseJson<Partial<StyleSwitchRunMetadata>>(run?.metadata, {})
445 const completedPageCount = pages.filter((page) => page.status === 'completed').length
446 const failedPageCount = pages.filter((page) => page.status === 'failed').length
447 const activeJob = Boolean(active) || job?.status === 'active'
448 const cancelled = job?.status === 'aborted' && job.abort_reason === 'cancelled'
449 const status: StyleSwitchJobSnapshot['status'] = activeJob
450 ? 'running'
451 : cancelled
452 ? 'cancelled'
453 : run?.status === 'completed'
454 ? 'completed'
455 : run?.status === 'partial'
456 ? 'partial'
457 : run?.status === 'failed' || job?.status === 'aborted'
458 ? 'failed'
459 : 'idle'
460 return {
461 sessionId,
462 runId: job?.id || null,
463 status,
464 hasActiveRun: activeJob,
465 progress: activeState?.progress ?? (status === 'completed' ? 100 : 0),
466 totalPages: job?.total_pages || run?.total_pages || pages.length || 1,
467 completedPageCount,
468 failedPageCount,
469 targetStyleId: metadata.targetStyleId || null,
470 targetStyleName: metadata.targetStyleName || null,
471 pages: pages.map((page) => ({
472 pageId: page.page_id,
473 pageNumber: page.page_number,
474 title: page.title,
475 status: page.status,
476 error: page.error,
477 retryCount: page.retry_count
478 })),
479 error: run?.error || job?.abort_reason || null,
480 startedAt:
481 activeState?.startedAt ?? (job ? (job.activated_at || job.created_at) * 1000 : null),
482 updatedAt: activeState?.updatedAt ?? (job ? job.updated_at * 1000 : null),
483 kind: 'style-switch'
484 }
485 }
486
487 async listActive(): Promise<StyleSwitchJobSnapshot[]> {
488 const jobs = await this.ctx.db.listActiveSessionJobs(['style-switch'])
489 return Promise.all(jobs.map((job) => this.getState(job.session_id)))
490 }
491
492 async abortInterruptedJobs(reason: string): Promise<void> {
493 const jobs = await this.ctx.db.listActiveSessionJobs(['style-switch'])
494 for (const job of jobs) {
495 if (this.activeJobs.has(job.session_id)) continue
496 const run = await this.ctx.db.getGenerationRun(job.id)
497 const metadata = parseJson<Partial<StyleSwitchRunMetadata>>(run?.metadata, {})
498 const pages = await this.ctx.db.listGenerationPages(job.id)
499 const completed = pages.some((page) => page.status === 'completed')
500 if (!completed) {
501 await this.ctx.db
502 .restoreSessionStyleState(
503 job.session_id,
504 metadata.previousStyleId || null,
505 metadata.previousStyleSnapshot || undefined
506 )
507 .catch(() => undefined)
508 await this.ctx.db
509 .updateSessionDesignContract(job.session_id, metadata.previousDesignContract ?? null)
510 .catch(() => undefined)
511 }
512 await this.markUnfinishedPagesFailed(job.id, reason)
513 await this.ctx.db.updateSessionJobStatus(job.id, 'aborted', { abortReason: reason })
514 await this.ctx.db.updateGenerationRunStatus(job.id, completed ? 'partial' : 'failed', reason)
515 await this.ctx.db.updateSessionStatus(
516 job.session_id,
517 completed ? 'failed' : normalizeRestoredSessionStatus(job.previous_session_status)
518 )
519 }
520 }
521
522 private async run(job: ActiveStyleSwitchJob): Promise<void> {
523 try {
524 await this.runWorkers(job)
525 await job.commitQueue
526 if (job.lease.signal.aborted || job.fatalError) {
527 const message = job.fatalError?.message || '生成已取消'
528 await this.markUnfinishedPagesFailed(job.runId, message)
529 const pages = await this.ctx.db.listGenerationPages(job.runId)
530 const completedCount = pages.filter((page) => page.status === 'completed').length
531 const failedCount = pages.filter((page) => page.status === 'failed').length
532 await this.ctx.db.updateSessionJobStatus(job.runId, 'aborted', {
533 abortReason: job.lease.signal.aborted ? 'cancelled' : message
534 })
535 await this.ctx.db.updateGenerationRunStatus(
536 job.runId,
537 completedCount > 0 ? 'partial' : 'failed',
538 message
539 )
540 await this.ctx.db.updateSessionStatus(
541 job.sessionId,
542 completedCount > 0
543 ? 'failed'
544 : normalizeRestoredSessionStatus(job.context.previousSessionStatus)
545 )
546 if (!job.styleStateCommitted) await this.restoreInitialStyleState(job)
547 this.ctx.emitGenerateChunk(job.sessionId, {
548 type: 'run_error',
549 payload: {
550 runId: job.runId,
551 message,
552 cancelled: job.lease.signal.aborted,
553 completedPageCount: completedCount,
554 failedPageCount: failedCount
555 }
556 })
557 this.ctx.emitRuntimeJobTerminal({
558 sessionId: job.sessionId,
559 jobId: job.runId,
560 domain: 'style',
561 status: job.lease.signal.aborted ? 'cancelled' : 'failed',
562 errorCode: job.lease.signal.aborted ? undefined : 'style_switch_failed',
563 errorMessage: job.lease.signal.aborted ? undefined : message
564 })
565 return
566 }
567
568 const pages = await this.ctx.db.listGenerationPages(job.runId)
569 const completedCount = pages.filter((page) => page.status === 'completed').length
570 const failedCount = pages.filter((page) => page.status === 'failed').length
571 const runStatus = failedCount === 0 ? 'completed' : completedCount > 0 ? 'partial' : 'failed'
572 await this.ctx.db.updateSessionJobStatus(job.runId, 'finished')
573 await this.ctx.db.updateGenerationRunStatus(
574 job.runId,
575 runStatus,
576 failedCount > 0 ? `${failedCount} 个页面切换失败` : null
577 )
578 await this.ctx.db.updateSessionStatus(job.sessionId, failedCount > 0 ? 'failed' : 'completed')
579 await this.ctx.db.updateSessionMetadata(job.sessionId, {
580 lastRunId: job.runId,
581 entryMode: 'multi_page',
582 styleSwitchTargetStyleId: job.styleId
583 })
584 await this.ctx.db.updateProjectStatus(job.context.projectId, 'draft')
585 if (!job.styleStateCommitted) await this.restoreInitialStyleState(job)
586 this.ctx.emitGenerateChunk(job.sessionId, {
587 type: 'run_completed',
588 payload: {
589 runId: job.runId,
590 totalPages: job.pageRefs.length,
591 completedPageCount: completedCount,
592 failedPageCount: failedCount
593 }
594 })
595 this.ctx.emitRuntimeJobTerminal({
596 sessionId: job.sessionId,
597 jobId: job.runId,
598 domain: 'style',
599 status: 'completed'
600 })
601 } catch (error) {
602 const message = errorMessage(error)
603 log.error('[style-switch:job] run failed', {
604 sessionId: job.sessionId,
605 runId: job.runId,
606 message
607 })
608 await this.markUnfinishedPagesFailed(job.runId, message)
609 const pages = await this.ctx.db.listGenerationPages(job.runId)
610 const completedCount = pages.filter((page) => page.status === 'completed').length
611 await this.ctx.db.updateSessionJobStatus(job.runId, 'aborted', { abortReason: message })
612 await this.ctx.db.updateGenerationRunStatus(
613 job.runId,
614 completedCount > 0 ? 'partial' : 'failed',
615 message
616 )
617 await this.ctx.db.updateSessionStatus(
618 job.sessionId,
619 completedCount > 0
620 ? 'failed'
621 : normalizeRestoredSessionStatus(job.context.previousSessionStatus)
622 )
623 if (!job.styleStateCommitted) await this.restoreInitialStyleState(job)
624 this.ctx.emitGenerateChunk(job.sessionId, {
625 type: 'run_error',
626 payload: {
627 runId: job.runId,
628 message,
629 cancelled: job.lease.signal.aborted,
630 completedPageCount: completedCount,
631 failedPageCount: pages.filter((page) => page.status === 'failed').length
632 }
633 })
634 this.ctx.emitRuntimeJobTerminal({
635 sessionId: job.sessionId,
636 jobId: job.runId,
637 domain: 'style',
638 status: job.lease.signal.aborted ? 'cancelled' : 'failed',
639 errorCode: job.lease.signal.aborted ? undefined : 'style_switch_failed',
640 errorMessage: job.lease.signal.aborted ? undefined : message
641 })
642 } finally {
643 this.ctx.agentManager.removeSession(job.sessionId)
644 this.activeJobs.delete(job.sessionId)
645 this.reservedJobIds.delete(job.sessionId)
646 job.lease.release()
647 }
648 }
649
650 private async runWorkers(job: ActiveStyleSwitchJob): Promise<void> {
651 const pageQueue = [...job.pageRefs]
652 const worker = async (): Promise<void> => {
653 while (!job.lease.signal.aborted && !job.fatalError) {
654 const page = pageQueue.shift()
655 if (!page) return
656 await this.runPage(job, page)
657 }
658 }
659 await Promise.all(
660 Array.from({ length: Math.min(STYLE_SWITCH_CONCURRENCY, pageQueue.length) }, () => worker())
661 )
662 }
663
664 private async runPage(job: ActiveStyleSwitchJob, page: StyleSwitchPageRef): Promise<void> {
665 const pageSnapshot = await readStyleSwitchFileSnapshot(page.htmlPath)
666 const indexPath = path.join(job.context.projectDir, 'index.html')
667 const indexSnapshot = await readStyleSwitchFileSnapshot(indexPath)
668 try {
669 if (job.lease.signal.aborted) throw new Error('生成已取消')
670 await this.ctx.db.upsertGenerationPage({
671 runId: job.runId,
672 sessionId: job.sessionId,
673 pageId: page.pageId,
674 pageNumber: page.pageNumber,
675 title: page.title,
676 contentOutline: page.contentOutline,
677 layoutIntent: page.layoutIntent,
678 htmlPath: page.htmlPath,
679 status: 'running',
680 retryCount: page.retryCount
681 })
682 this.ctx.emitGenerateChunk(job.sessionId, {
683 type: 'page_started',
684 payload: {
685 runId: job.runId,
686 stage: 'style-switch',
687 label:
688 job.context.appLocale === 'en'
689 ? `Editing P${page.pageNumber}`
690 : `正在切换 P${page.pageNumber} 风格`,
691 progress: 0,
692 currentPage: page.pageNumber,
693 totalPages: job.pageRefs.length,
694 pageNumber: page.pageNumber,
695 pageId: page.pageId,
696 title: page.title,
697 htmlPath: page.htmlPath
698 }
699 })
700 const html = await runStyleSwitchPageFlow({
701 ctx: this.ctx,
702 job,
703 page,
704 indexPath,
705 indexSnapshot,
706 emitProgress: (chunk) => this.emitPageProgress(job, page, chunk)
707 })
708 if (!pageSnapshot.exists || pageSnapshot.content === html) {
709 throw new Error('当前页面编辑没有检测到落盘变化。')
710 }
711 await this.enqueueCommit(job, async () => this.commitPage(job, page, html))
712 } catch (error) {
713 const message = errorMessage(error)
714 const historyAlreadyCommitted = error instanceof StyleSwitchCommittedPageFinalizationError
715 const historyCommitFailed = error instanceof StyleSwitchHistoryCommitError
716 if (!historyAlreadyCommitted) {
717 await restoreStyleSwitchFileSnapshot(page.htmlPath, pageSnapshot).catch((restoreError) => {
718 log.error('[style-switch:job] page rollback failed', {
719 sessionId: job.sessionId,
720 runId: job.runId,
721 pageId: page.pageId,
722 message: errorMessage(restoreError)
723 })
724 })
725 }
726 if (!historyAlreadyCommitted && /index\.html/i.test(message)) {
727 await restoreStyleSwitchFileSnapshot(indexPath, indexSnapshot).catch((restoreError) => {
728 log.error('[style-switch:job] index rollback failed', {
729 sessionId: job.sessionId,
730 runId: job.runId,
731 pageId: page.pageId,
732 message: errorMessage(restoreError)
733 })
734 })
735 }
736 const cancelled = job.lease.signal.aborted || isCancellationMessage(message)
737 await this.ctx.db.upsertGenerationPage({
738 runId: job.runId,
739 sessionId: job.sessionId,
740 pageId: page.pageId,
741 pageNumber: page.pageNumber,
742 title: page.title,
743 contentOutline: page.contentOutline,
744 layoutIntent: page.layoutIntent,
745 htmlPath: page.htmlPath,
746 status: 'failed',
747 error: message,
748 retryCount: page.retryCount
749 })
750 this.ctx.emitGenerateChunk(job.sessionId, {
751 type: 'page_failed',
752 payload: {
753 runId: job.runId,
754 stage: 'style-switch',
755 label:
756 job.context.appLocale === 'en'
757 ? `P${page.pageNumber} failed`
758 : `P${page.pageNumber} 切换失败`,
759 progress: 0,
760 currentPage: page.pageNumber,
761 totalPages: job.pageRefs.length,
762 pageNumber: page.pageNumber,
763 pageId: page.pageId,
764 title: page.title,
765 htmlPath: page.htmlPath,
766 error: message
767 }
768 })
769 if (
770 !cancelled &&
771 (historyAlreadyCommitted ||
772 historyCommitFailed ||
773 /index\.html|历史记录|history/i.test(message))
774 ) {
775 job.fatalError = new Error(message)
776 this.coordinator.cancel(job.lease.jobId)
777 }
778 }
779 }
780
781 private async commitPage(
782 job: ActiveStyleSwitchJob,
783 page: StyleSwitchPageRef,
784 html: string
785 ): Promise<void> {
786 // A page may finish agent work while an earlier page is still committing. Do not let a
787 // cancelled job turn that queued result into a durable page version.
788 this.assertCommitNotCancelled(job)
789 const existingPage = (
790 await this.ctx.db.listSessionPages(job.sessionId, { includeDeleted: true })
791 ).find((candidate) => candidate.id === page.id || candidate.file_slug === page.pageId)
792 const previousPage = existingPage ? { ...existingPage } : null
793 const relativePath = toRelativeProjectPath(job.context.projectDir, page.htmlPath)
794 let appliedStyleState = false
795 try {
796 if (!job.styleStateCommitted) {
797 await this.ctx.db.updateSessionStyleId(job.sessionId, job.styleId)
798 await this.ctx.db.updateSessionDesignContract(job.sessionId, job.designContract)
799 appliedStyleState = true
800 }
801 // Git is the durability boundary. A page is not marked completed until this per-page
802 // history operation has succeeded.
803 this.assertCommitNotCancelled(job)
804 const history = new GitHistoryService(this.ctx.db)
805 let operation
806 try {
807 operation = await history.recordOperation({
808 sessionId: job.sessionId,
809 projectDir: job.context.projectDir,
810 type: 'edit',
811 scope: 'page',
812 prompt: `切换风格 · 第 ${page.pageNumber} 页`,
813 allowedPaths: [relativePath],
814 metadata: {
815 runId: job.runId,
816 jobType: 'style-switch',
817 pageId: page.pageId,
818 pageNumber: page.pageNumber,
819 styleId: job.styleId,
820 styleName: job.context.styleName || null,
821 retryCount: page.retryCount
822 }
823 })
824 } catch (error) {
825 const fatalError = new StyleSwitchHistoryCommitError(error)
826 job.fatalError = fatalError
827 this.coordinator.cancel(job.lease.jobId)
828 throw fatalError
829 }
830 if (!operation?.after_commit) {
831 const fatalError = new StyleSwitchHistoryCommitError('未生成 Git 提交')
832 job.fatalError = fatalError
833 this.coordinator.cancel(job.lease.jobId)
834 throw fatalError
835 }
836 try {
837 await this.ctx.db.upsertSessionPage({
838 id: existingPage?.id || page.id,
839 sessionId: job.sessionId,
840 legacyPageId:
841 existingPage?.legacy_page_id || (page.pageId.match(/^page-\d+$/) ? page.pageId : null),
842 fileSlug: page.pageId,
843 pageNumber: page.pageNumber,
844 title: page.title,
845 htmlPath: page.htmlPath,
846 status: 'completed',
847 error: null
848 })
849 await this.ctx.db.upsertGenerationPage({
850 runId: job.runId,
851 sessionId: job.sessionId,
852 pageId: page.pageId,
853 pageNumber: page.pageNumber,
854 title: page.title,
855 contentOutline: page.contentOutline,
856 layoutIntent: page.layoutIntent,
857 htmlPath: page.htmlPath,
858 status: 'completed',
859 retryCount: page.retryCount
860 })
861 } catch (error) {
862 try {
863 await history.rollbackCommittedOperation({
864 sessionId: job.sessionId,
865 projectDir: job.context.projectDir,
866 operation,
867 allowedPaths: [relativePath],
868 reason: errorMessage(error)
869 })
870 } catch (compensationError) {
871 const fatalError = new StyleSwitchCommittedPageFinalizationError(compensationError)
872 job.fatalError = fatalError
873 this.coordinator.cancel(job.lease.jobId)
874 throw fatalError
875 }
876 throw new StyleSwitchHistoryCommitError(error)
877 }
878 job.styleStateCommitted = true
879 // History is already durable. A renderer notification failure must not make the page look
880 // failed or roll its file back after the commit.
881 try {
882 this.ctx.emitGenerateChunk(job.sessionId, {
883 type: 'page_updated',
884 payload: {
885 runId: job.runId,
886 stage: 'style-switch',
887 label:
888 job.context.appLocale === 'en'
889 ? `P${page.pageNumber} saved`
890 : `P${page.pageNumber} 已保存到历史版本`,
891 progress: 100,
892 currentPage: page.pageNumber,
893 totalPages: job.pageRefs.length,
894 id: page.id,
895 pageNumber: page.pageNumber,
896 title: page.title,
897 html,
898 htmlPath: page.htmlPath,
899 pageId: page.pageId,
900 sourceUrl: this.ctx.getPageSourceUrl(page.htmlPath),
901 pageCommitReady: true
902 }
903 })
904 } catch (error) {
905 log.warn('[style-switch:job] page commit notification failed', {
906 sessionId: job.sessionId,
907 runId: job.runId,
908 pageId: page.pageId,
909 message: errorMessage(error)
910 })
911 }
912 } catch (error) {
913 if (previousPage) {
914 await this.ctx.db.upsertSessionPage({
915 id: previousPage.id,
916 sessionId: previousPage.session_id,
917 legacyPageId: previousPage.legacy_page_id,
918 fileSlug: previousPage.file_slug,
919 pageNumber: previousPage.page_number,
920 title: previousPage.title,
921 htmlPath: previousPage.html_path,
922 status: previousPage.status,
923 error: previousPage.error
924 })
925 }
926 if (appliedStyleState && !job.styleStateCommitted) await this.restoreInitialStyleState(job)
927 throw error
928 }
929 }
930
931 private emitPageProgress(
932 job: ActiveStyleSwitchJob,
933 page: StyleSwitchPageRef,
934 chunk: GenerateChunkEvent
935 ): void {
936 if (
937 chunk.type === 'assistant_message' ||
938 chunk.type === 'run_completed' ||
939 chunk.type === 'run_error'
940 )
941 return
942 this.ctx.emitGenerateChunk(job.sessionId, {
943 ...chunk,
944 payload: {
945 ...chunk.payload,
946 runId: job.runId,
947 currentPage: page.pageNumber,
948 totalPages: job.pageRefs.length
949 }
950 } as GenerateChunkEvent)
951 }
952
953 private async enqueueCommit(
954 job: ActiveStyleSwitchJob,
955 operation: () => Promise<void>
956 ): Promise<void> {
957 const guardedOperation = async (): Promise<void> => {
958 this.assertCommitNotCancelled(job)
959 if (job.fatalError) throw job.fatalError
960 await operation()
961 }
962 const next = job.commitQueue.then(guardedOperation, guardedOperation)
963 job.commitQueue = next.catch(() => undefined)
964 await next
965 }
966
967 private assertCommitNotCancelled(job: ActiveStyleSwitchJob): void {
968 if (job.lease.signal.aborted) throw new Error('生成已取消')
969 }
970
971 private async markUnfinishedPagesFailed(runId: string, reason: string): Promise<void> {
972 const pages = await this.ctx.db.listGenerationPages(runId)
973 await Promise.all(
974 pages
975 .filter((page) => page.status !== 'completed' && page.status !== 'failed')
976 .map((page) =>
977 this.ctx.db.upsertGenerationPage({
978 runId: page.run_id,
979 sessionId: page.session_id,
980 pageId: page.page_id,
981 pageNumber: page.page_number,
982 title: page.title,
983 contentOutline: page.content_outline,
984 layoutIntent: page.layout_intent,
985 htmlPath: page.html_path,
986 status: 'failed',
987 error: reason,
988 retryCount: page.retry_count
989 })
990 )
991 )
992 }
993
994 private async restoreInitialStyleState(job: ActiveStyleSwitchJob): Promise<void> {
995 await this.ctx.db
996 .restoreSessionStyleState(
997 job.sessionId,
998 job.previousStyleId,
999 job.previousStyleSnapshot || undefined
1000 )
1001 .catch((error) => {
1002 log.error('[style-switch:job] failed to restore style state', {
1003 sessionId: job.sessionId,
1004 runId: job.runId,
1005 message: errorMessage(error)
1006 })
1007 })
1008 await this.ctx.db
1009 .updateSessionDesignContract(job.sessionId, job.previousDesignContract)
1010 .catch(() => undefined)
1011 }
1012 }
1013
1014 export function registerStyleSwitchJobHandlers(
1015 ctx: IpcContext,
1016 coordinator: JobCoordinator
1017 ): StyleSwitchJobService {
1018 const service = new StyleSwitchJobService(ctx, coordinator)
1019 const interruptedReady = service
1020 .abortInterruptedJobs('应用退出导致风格切换中断,可重试')
1021 .catch((error) => {
1022 log.warn('[style-switch:job] failed to abort interrupted jobs', {
1023 message: errorMessage(error)
1024 })
1025 })
1026 ipcMain.handle('style-switch:start', async (event, payload) => {
1027 await interruptedReady
1028 return service.start(event, payload)
1029 })
1030 ipcMain.handle('style-switch:retryPage', async (event, payload) => {
1031 await interruptedReady
1032 return service.retryPage(event, payload)
1033 })
1034 ipcMain.handle('style-switch:retryFailed', async (event, payload) => {
1035 await interruptedReady
1036 return service.retryFailed(event, payload)
1037 })
1038 ipcMain.handle('style-switch:cancel', async (_event, rawSessionId) => {
1039 await interruptedReady
1040 const sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : ''
1041 return { success: sessionId ? await service.cancel(sessionId) : true }
1042 })
1043 ipcMain.handle('style-switch:state', async (_event, rawSessionId) => {
1044 await interruptedReady
1045 const sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : ''
1046 if (!sessionId) throw new Error('sessionId 不能为空')
1047 return service.getState(sessionId)
1048 })
1049 ipcMain.handle('style-switch:listActive', async () => {
1050 await interruptedReady
1051 return service.listActive()
1052 })
1053 return service
1054 }
1055
1055 lines TYPESCRIPT