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