返回 oh-my-ppt
page-beautify-job-service.ts
根目录 / src / main / edit-jobs / page-beautify-job-service.ts
1 import { ipcMain } from 'electron'
2 import crypto from 'crypto'
3 import fs from 'fs'
4 import path from 'path'
5 import log from 'electron-log/main.js'
6 import * as cheerio from 'cheerio'
7 import type { IpcContext } from '../ipc/context'
8 import { resolvePageHtmlPath } from '../generation/generation-utils'
9 import { isCancellationMessage, normalizeRestoredSessionStatus } from '../generation/status-utils'
10 import {
11 ensureHistoryBaselineSafe,
12 recordHistoryOperationStrict
13 } from '../history/git-history-service'
14 import { replacePageContentFragment } from '../presentation/html/page-writer-core'
15 import type { DesignContract } from '@shared/generation'
16 import { resolveGlobalModelTimeouts, resolveModelConfigForTask } from '../config/model-config-utils'
17 import { requireSessionSlideSize, type SlideSizePreset } from '@shared/slide-size'
18 import { resolveLayoutSkillName } from '../product-skills/contract'
19 import type { ModelTimeoutProfile } from '@shared/model-timeout'
20 import { JobCoordinator, sessionLockKey, type JobLease } from '../agent-runtime'
21 import type { ModelRuntimeConfig } from '../agent-runtime/model'
22 import { runPageBeautifyAgent } from './page-beautify-agent'
23
24 type FileSnapshot = {
25 path: string
26 exists: boolean
27 content: string
28 }
29
30 type ActivePageBeautifyJob = {
31 sessionId: string
32 runId: string
33 lease: JobLease
34 context: PageBeautifyContext
35 targetPageId: string
36 targetPageNumber: number
37 targetPagePath: string
38 }
39
40 type PageBeautifyTarget = {
41 id: string
42 legacyPageId: string | null
43 pageId: string
44 pageNumber: number
45 title: string
46 htmlPath: string
47 }
48
49 type PageBeautifyContext = {
50 sessionId: string
51 previousSessionStatus: string
52 runId: string
53 provider: string
54 apiKey: string
55 model: string
56 modelConfigId?: string
57 runModel?: string
58 providerBaseUrl: string
59 maxTokens: number
60 modelRuntime: ModelRuntimeConfig
61 modelTimeouts: Record<ModelTimeoutProfile, number>
62 projectDir: string
63 projectId: string
64 styleId: string
65 styleSkillPrompt: string
66 styleCase: string
67 styleKey: string
68 styleName: string
69 styleVersion: string
70 slideSize: SlideSizePreset
71 layoutSkillName: ReturnType<typeof resolveLayoutSkillName>
72 appLocale: 'zh' | 'en'
73 userMessage: string
74 layoutAudit?: string
75 target: PageBeautifyTarget
76 designContract?: DesignContract
77 }
78
79 type PageBeautifyJobSnapshot = {
80 sessionId: string
81 runId: string | null
82 status: 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
83 hasActiveRun: boolean
84 progress: number
85 totalPages: 1
86 completedPageCount: number
87 failedPageCount: number
88 outcome: 'changed' | 'unchanged' | null
89 error: string | null
90 startedAt: number | null
91 updatedAt: number | null
92 kind: 'page-beautify'
93 targetPageId?: string
94 targetPageNumber?: number
95 }
96
97 const BEAUTIFY_TMP_SUFFIX = '.beautify-tmp'
98
99 const buildPageBeautifyHistoryPrompt = (pageNumber: number): string => `一键美化第 ${pageNumber} 页`
100
101 const toRelativeProjectPath = (projectDir: string, filePath: string): string => {
102 const relative = path.relative(projectDir, filePath).split(path.sep).join('/')
103 if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) {
104 throw new Error(`一键美化页面路径不在项目目录内:${filePath}`)
105 }
106 return relative
107 }
108
109 const createPageBeautifyChangeSignature = (html: string): string => {
110 const $ = cheerio.load(html.replace(/<!--[\s\S]*?-->/g, ''), { scriptingEnabled: false }, false)
111 $('script, style, template, noscript').remove()
112 $('*').each((_, node) => {
113 const el = $(node)
114 for (const name of Object.keys(el.attr() || {})) {
115 if (name.startsWith('data-')) el.removeAttr(name)
116 }
117 const className = el.attr('class')
118 if (className) el.attr('class', className.split(/\s+/).filter(Boolean).sort().join(' '))
119 })
120 // Layout review deliberately ignores content edits. A text or number-only change
121 // must not masquerade as a page redesign, but any meaningful DOM/CSS re-layout
122 // remains visible in this signature.
123 $('*')
124 .contents()
125 .filter((_, node) => node.type === 'text')
126 .remove()
127 return ($.root().html() || '').replace(/>\s+</g, '><').replace(/\s+/g, ' ').trim()
128 }
129
130 export const hasMeaningfulPageBeautifyChange = (original: string, next: string): boolean =>
131 createPageBeautifyChangeSignature(original) !== createPageBeautifyChangeSignature(next)
132
133 async function captureSnapshots(paths: readonly string[]): Promise<FileSnapshot[]> {
134 return Promise.all(
135 Array.from(new Set(paths)).map(async (filePath) => ({
136 path: filePath,
137 exists: fs.existsSync(filePath),
138 content: fs.existsSync(filePath) ? await fs.promises.readFile(filePath, 'utf-8') : ''
139 }))
140 )
141 }
142
143 async function restoreSnapshots(snapshots: readonly FileSnapshot[]): Promise<void> {
144 const results = await Promise.allSettled(
145 snapshots.map((snapshot) =>
146 snapshot.exists
147 ? fs.promises.writeFile(snapshot.path, snapshot.content, 'utf-8')
148 : fs.promises.rm(snapshot.path, { force: true })
149 )
150 )
151 const failed = results.find((result) => result.status === 'rejected')
152 if (failed?.status === 'rejected') throw failed.reason
153 }
154
155 const removeTempFile = async (tmpPath: string): Promise<void> => {
156 try {
157 await fs.promises.rm(tmpPath, { force: true })
158 } catch (error) {
159 log.warn('[page-beautify:job] failed to remove temp file', {
160 tmpPath,
161 message: error instanceof Error ? error.message : String(error)
162 })
163 }
164 }
165
166 // Write to a sibling .beautify-tmp file, then atomically rename into place. If the
167 // process dies after the rename but before git commit, the working tree carries an
168 // orphan change that abortInterruptedJobs will git-restore. If it dies before the
169 // rename, the tmp file lingers and is cleaned up by abortInterruptedJobs on restart.
170 async function writeTargetHtmlAtomically(targetPath: string, html: string): Promise<void> {
171 const tmpPath = `${targetPath}${BEAUTIFY_TMP_SUFFIX}`
172 await fs.promises.writeFile(tmpPath, html, 'utf-8')
173 try {
174 await fs.promises.rename(tmpPath, targetPath)
175 } catch (error) {
176 await removeTempFile(tmpPath)
177 throw error
178 }
179 }
180
181 const parseDesignContract = (session: Record<string, unknown>): DesignContract | undefined => {
182 const raw = session.designContract
183 if (typeof raw !== 'string' || raw.trim().length === 0) return undefined
184 try {
185 return JSON.parse(raw) as DesignContract
186 } catch {
187 return undefined
188 }
189 }
190
191 const safeParseJson = (value: string): unknown => {
192 try {
193 return JSON.parse(value) as unknown
194 } catch {
195 return null
196 }
197 }
198
199 async function resolvePageBeautifyContext(
200 ctx: IpcContext,
201 args: {
202 sessionId: string
203 selectedPageId: string
204 runId: string
205 modelConfigId?: string
206 layoutAudit?: string
207 }
208 ): Promise<PageBeautifyContext> {
209 const [session, project, pages, activeModel, modelTimeouts, styleSnapshot, settings] =
210 await Promise.all([
211 ctx.db.getSession(args.sessionId),
212 ctx.db.getProject(args.sessionId),
213 ctx.db.listSessionPages(args.sessionId),
214 resolveModelConfigForTask(ctx, { modelConfigId: args.modelConfigId, purpose: 'generation' }),
215 resolveGlobalModelTimeouts(ctx),
216 ctx.db.getOrCreateSessionStyleSnapshot(args.sessionId),
217 ctx.db.getAllSettings()
218 ])
219 if (!session) throw new Error('Session not found')
220 if (!project) throw new Error('一键美化的页面项目不存在')
221 if (!activeModel.apiKey) {
222 throw new Error(`当前 provider "${activeModel.provider}" 缺少 API Key,请先到设置页配置。`)
223 }
224
225 const page = pages.find(
226 (item) => item.id === args.selectedPageId || item.file_slug === args.selectedPageId
227 )
228 if (!page) throw new Error('一键美化的目标页面不存在')
229 const projectDir = await ctx.resolveSessionProjectDir(args.sessionId)
230 const htmlPath = resolvePageHtmlPath({
231 projectDir,
232 fileSlug: page.file_slug,
233 candidates: [page.html_path]
234 })
235 if (!fs.existsSync(htmlPath)) throw new Error('一键美化的目标页面文件不存在')
236
237 const sessionRecord = session as unknown as Record<string, unknown>
238 const styleSkillPrompt =
239 styleSnapshot.styleSkill?.trim() ||
240 (styleSnapshot.description
241 ? `Use ${styleSnapshot.styleKey} style: ${styleSnapshot.description}`
242 : `Use ${styleSnapshot.styleKey} style.`)
243 const slideSize = requireSessionSlideSize(sessionRecord)
244
245 return {
246 sessionId: args.sessionId,
247 previousSessionStatus: String(sessionRecord.status || 'active'),
248 runId: args.runId,
249 provider: activeModel.provider,
250 apiKey: activeModel.apiKey,
251 model: activeModel.model,
252 modelConfigId: activeModel.id,
253 runModel: JSON.stringify({
254 modelConfigId: activeModel.id,
255 name: activeModel.name,
256 provider: activeModel.provider,
257 model: activeModel.model,
258 baseUrl: activeModel.baseUrl || undefined,
259 maxTokens: activeModel.maxTokens
260 }),
261 providerBaseUrl: activeModel.baseUrl,
262 maxTokens: activeModel.maxTokens,
263 modelRuntime: ctx.modelRuntime,
264 modelTimeouts,
265 projectDir,
266 projectId: project.id,
267 styleId: styleSnapshot.styleId,
268 styleSkillPrompt,
269 styleCase: styleSnapshot.styleCase || '',
270 styleKey: styleSnapshot.styleKey,
271 styleName: styleSnapshot.styleName,
272 styleVersion: styleSnapshot.version,
273 // The current session is the authoritative source for the canvas. The agent
274 // receives these persisted dimensions in both its system prompt and task message.
275 slideSize,
276 layoutSkillName: resolveLayoutSkillName(slideSize),
277 appLocale: settings.locale === 'en' ? 'en' : 'zh',
278 userMessage: buildPageBeautifyHistoryPrompt(page.page_number),
279 layoutAudit: args.layoutAudit,
280 target: {
281 id: page.id,
282 legacyPageId: page.legacy_page_id,
283 pageId: page.file_slug,
284 pageNumber: page.page_number,
285 title: page.title || `第${page.page_number}页`,
286 htmlPath
287 },
288 designContract: parseDesignContract(sessionRecord)
289 }
290 }
291
292 export const extractPageBeautifyContent = (html: string): string => {
293 const $ = cheerio.load(html, { scriptingEnabled: false })
294 const content = $('.ppt-page-root[data-ppt-guard-root="1"] .ppt-page-content').first()
295 if (!content.length) throw new Error('一键美化无法读取当前页主体')
296 const fragment = (content.html() || '').trim()
297 if (!fragment) throw new Error('一键美化当前页主体为空')
298 return fragment
299 }
300
301 export class PageBeautifyJobService {
302 private activeJobs = new Map<string, ActivePageBeautifyJob>()
303 private reservedJobIds = new Map<string, string>()
304
305 constructor(private ctx: IpcContext, private coordinator: JobCoordinator) {}
306
307 async start(
308 _event: Electron.IpcMainInvokeEvent,
309 payload: unknown
310 ): Promise<{ success: boolean; runId?: string; alreadyRunning?: boolean }> {
311 const input = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
312 const sessionId = typeof input.sessionId === 'string' ? input.sessionId.trim() : ''
313 const selectedPageId =
314 typeof input.selectedPageId === 'string' ? input.selectedPageId.trim() : ''
315 const modelConfigId =
316 typeof input.modelConfigId === 'string' ? input.modelConfigId.trim() || undefined : undefined
317 const layoutAudit =
318 typeof input.layoutAudit === 'string' ? input.layoutAudit.trim().slice(0, 6000) || undefined : undefined
319 if (!sessionId) throw new Error('sessionId 不能为空')
320 if (!selectedPageId) throw new Error('一键美化缺少当前页面')
321
322 const reservation = await this.coordinator.reserve({
323 jobId: crypto.randomUUID(),
324 domain: 'edit',
325 owner: { kind: 'session', id: sessionId },
326 claims: { write: [sessionLockKey(sessionId)] },
327 wait: 'fail'
328 })
329 if (reservation.status === 'busy') {
330 return { success: true, runId: reservation.conflictingJobId, alreadyRunning: true }
331 }
332
333 const lease = reservation.lease
334 this.reservedJobIds.set(sessionId, lease.jobId)
335 let context: PageBeautifyContext | null = null
336 let jobCreated = false
337
338 try {
339 context = await resolvePageBeautifyContext(this.ctx, {
340 sessionId,
341 selectedPageId,
342 runId: lease.jobId,
343 modelConfigId,
344 layoutAudit
345 })
346 if (lease.signal.aborted) throw new Error('生成已取消')
347 if (context.runId !== lease.jobId) {
348 throw new Error('页面美化 runId 与 JobCoordinator lease 不一致')
349 }
350 await this.ctx.db.updateSessionStatus(sessionId, 'active')
351 await this.ctx.db.createGenerationRunWithSessionJob({
352 run: {
353 id: context.runId,
354 sessionId,
355 mode: 'page-beautify',
356 totalPages: 1,
357 modelConfigId: context.modelConfigId,
358 metadata: {
359 jobType: 'page-beautify',
360 targetPageId: context.target.pageId,
361 targetPageNumber: context.target.pageNumber,
362 targetPageTitle: context.target.title,
363 styleId: context.styleId,
364 designContract: context.designContract || null
365 }
366 },
367 job: {
368 id: context.runId,
369 sessionId,
370 kind: 'page-beautify',
371 status: 'active',
372 targetPageId: context.target.pageId,
373 targetPageNumber: context.target.pageNumber,
374 totalPages: 1,
375 previousSessionStatus: normalizeRestoredSessionStatus(context.previousSessionStatus)
376 }
377 })
378 jobCreated = true
379 this.ctx.beginSessionRunState({
380 sessionId,
381 runId: context.runId,
382 mode: 'page-beautify',
383 kind: 'page-beautify',
384 activityKind: 'page-beautify',
385 targetPageId: context.target.pageId,
386 targetPageNumber: context.target.pageNumber,
387 totalPages: 1,
388 previousSessionStatus: context.previousSessionStatus,
389 status: 'running'
390 })
391
392 const job: ActivePageBeautifyJob = {
393 sessionId,
394 runId: context.runId,
395 lease,
396 context,
397 targetPageId: context.target.pageId,
398 targetPageNumber: context.target.pageNumber,
399 targetPagePath: context.target.htmlPath
400 }
401 this.activeJobs.set(sessionId, job)
402 void this.run(job)
403 return { success: true, runId: context.runId }
404 } catch (error) {
405 if (context && jobCreated)
406 await this.settleFailure(context, error, lease.signal.aborted)
407 if (context && !jobCreated) {
408 await this.ctx.db.updateSessionStatus(
409 context.sessionId,
410 normalizeRestoredSessionStatus(context.previousSessionStatus)
411 )
412 }
413 this.reservedJobIds.delete(sessionId)
414 lease.release()
415 throw error
416 }
417 }
418
419 async cancel(sessionId: string): Promise<boolean> {
420 const job = this.activeJobs.get(sessionId)
421 if (job) {
422 return this.coordinator.cancel(job.lease.jobId)
423 }
424 const jobId = this.reservedJobIds.get(sessionId)
425 if (!jobId) return false
426 const cancelled = this.coordinator.cancel(jobId)
427 if (!cancelled) return false
428 try {
429 const latest = await this.ctx.db.getLatestSessionJob(sessionId, ['page-beautify'])
430 if (latest?.status === 'active') {
431 await this.cleanupInterruptedJobFiles(sessionId, latest.target_page_id || null)
432 await this.ctx.db.updateSessionJobStatus(latest.id, 'aborted', {
433 abortReason: 'cancelled'
434 })
435 await this.ctx.db.updateGenerationRunStatus(latest.id, 'failed', '生成已取消')
436 await this.ctx.db.updateSessionStatus(
437 sessionId,
438 normalizeRestoredSessionStatus(latest.previous_session_status)
439 )
440 this.ctx.emitGenerateChunk(sessionId, {
441 type: 'run_error',
442 payload: {
443 runId: latest.id,
444 message: '生成已取消',
445 cancelled: true,
446 activityKind: 'page-beautify'
447 }
448 })
449 this.ctx.emitRuntimeJobTerminal({
450 sessionId,
451 jobId: latest.id,
452 domain: 'edit',
453 status: 'cancelled'
454 })
455 }
456 } catch (error) {
457 log.warn('[page-beautify:job] cancel cleanup failed', {
458 sessionId,
459 message: error instanceof Error ? error.message : String(error)
460 })
461 }
462 this.reservedJobIds.delete(sessionId)
463 return true
464 }
465
466 async getState(sessionId: string): Promise<PageBeautifyJobSnapshot> {
467 const activeState = this.ctx.sessionRunStates.get(sessionId)
468 if (activeState?.activityKind === 'page-beautify') {
469 return {
470 sessionId,
471 runId: activeState.runId,
472 status: activeState.status,
473 hasActiveRun: activeState.status === 'queued' || activeState.status === 'running',
474 progress: activeState.progress,
475 totalPages: 1,
476 completedPageCount: activeState.completedPageKeys.length,
477 failedPageCount: activeState.failedPageKeys.length,
478 outcome: null,
479 error: activeState.error,
480 startedAt: activeState.startedAt,
481 updatedAt: activeState.updatedAt,
482 kind: 'page-beautify',
483 targetPageId: activeState.targetPageId,
484 targetPageNumber: activeState.targetPageNumber
485 }
486 }
487
488 const job = await this.ctx.db.getLatestSessionJob(sessionId, ['page-beautify'])
489 if (!job) return this.idleState(sessionId)
490 const run = await this.ctx.db.getGenerationRun(job.id)
491 const status =
492 job.status === 'active'
493 ? 'running'
494 : job.status === 'aborted'
495 ? 'cancelled'
496 : run?.status === 'completed'
497 ? 'completed'
498 : run?.status === 'failed' || run?.status === 'partial'
499 ? 'failed'
500 : 'idle'
501 const runMetadata =
502 typeof run?.metadata === 'string'
503 ? (safeParseJson(run.metadata) as { outcome?: 'changed' | 'unchanged' } | null)
504 : null
505 const outcome =
506 status === 'completed' ? (runMetadata?.outcome === 'unchanged' ? 'unchanged' : 'changed') : null
507 return {
508 sessionId,
509 runId: job.id,
510 status,
511 hasActiveRun: job.status === 'active',
512 progress: status === 'completed' ? 100 : 0,
513 totalPages: 1,
514 completedPageCount: status === 'completed' ? 1 : 0,
515 failedPageCount: status === 'failed' ? 1 : 0,
516 outcome,
517 error: run?.error || job.abort_reason || null,
518 startedAt: job.activated_at || job.created_at,
519 updatedAt: job.updated_at,
520 kind: 'page-beautify',
521 targetPageId: job.target_page_id || undefined,
522 targetPageNumber: job.target_page_number || undefined
523 }
524 }
525
526 async listActive(): Promise<PageBeautifyJobSnapshot[]> {
527 const jobs = await this.ctx.db.listActiveSessionJobs(['page-beautify'])
528 return Promise.all(jobs.map((job) => this.getState(job.session_id)))
529 }
530
531 async abortInterruptedJobs(reason: string): Promise<void> {
532 const jobs = await this.ctx.db.listActiveSessionJobs(['page-beautify'])
533 for (const job of jobs) {
534 if (this.activeJobs.has(job.session_id)) continue
535 await this.cleanupInterruptedJobFiles(job.session_id, job.target_page_id || null)
536 await this.ctx.db.updateSessionJobStatus(job.id, 'aborted', { abortReason: reason })
537 await this.ctx.db.updateGenerationRunStatus(job.id, 'failed', reason)
538 await this.ctx.db.updateSessionStatus(
539 job.session_id,
540 normalizeRestoredSessionStatus(job.previous_session_status)
541 )
542 }
543 }
544
545 // Best-effort: remove any lingering .beautify-tmp file for the target page.
546 // Atomic rename in run() means a tmp file only exists if we crashed before the
547 // rename; the real page file is untouched in that case.
548 private async cleanupInterruptedJobFiles(
549 sessionId: string,
550 targetPageId: string | null
551 ): Promise<void> {
552 if (!targetPageId) return
553 try {
554 const projectDir = await this.ctx.resolveSessionProjectDir(sessionId).catch(() => null)
555 if (!projectDir) return
556 const pages = await this.ctx.db.listSessionPages(sessionId).catch(() => [])
557 const page = pages.find((item) => item.file_slug === targetPageId)
558 if (!page) return
559 const htmlPath = resolvePageHtmlPath({
560 projectDir,
561 fileSlug: page.file_slug,
562 candidates: [page.html_path]
563 })
564 await removeTempFile(`${htmlPath}${BEAUTIFY_TMP_SUFFIX}`)
565 } catch (error) {
566 log.warn('[page-beautify:job] cleanupInterruptedJobFiles failed', {
567 sessionId,
568 targetPageId,
569 message: error instanceof Error ? error.message : String(error)
570 })
571 }
572 }
573
574 private idleState(sessionId: string): PageBeautifyJobSnapshot {
575 return {
576 sessionId,
577 runId: null,
578 status: 'idle',
579 hasActiveRun: false,
580 progress: 0,
581 totalPages: 1,
582 completedPageCount: 0,
583 failedPageCount: 0,
584 outcome: null,
585 error: null,
586 startedAt: null,
587 updatedAt: null,
588 kind: 'page-beautify'
589 }
590 }
591
592 private async run(job: ActivePageBeautifyJob): Promise<void> {
593 let snapshots: FileSnapshot[] = []
594 let tmpPath: string | null = null
595 const startedAt = Date.now()
596 const logPayload = {
597 sessionId: job.sessionId,
598 runId: job.runId,
599 pageId: job.targetPageId,
600 pageNumber: job.targetPageNumber
601 }
602 log.info('[page-beautify:job] start', logPayload)
603 try {
604 snapshots = await captureSnapshots([job.targetPagePath])
605 const emit = this.ctx.createDeckProgressEmitter(job.sessionId, job.context.appLocale)
606 const isEn = job.context.appLocale === 'en'
607 emit({
608 type: 'stage_started',
609 payload: {
610 runId: job.runId,
611 stage: 'editing',
612 label: isEn
613 ? `Preparing to beautify page ${job.targetPageNumber}`
614 : `正在准备美化第 ${job.targetPageNumber} 页`,
615 progress: 5,
616 totalPages: 1
617 }
618 })
619
620 const originalHtml = snapshots[0]?.content
621 if (originalHtml === undefined) throw new Error('一键美化无法读取当前页面')
622 const originalFragment = extractPageBeautifyContent(originalHtml)
623 emit({
624 type: 'llm_status',
625 payload: {
626 runId: job.runId,
627 stage: 'editing',
628 label: isEn ? 'Preparing page history baseline' : '正在确认页面历史基线',
629 progress: 12,
630 totalPages: 1
631 }
632 })
633 await ensureHistoryBaselineSafe(this.ctx.db, job.sessionId, job.context.projectDir)
634 emit({
635 type: 'llm_status',
636 payload: {
637 runId: job.runId,
638 stage: 'editing',
639 label: isEn ? 'Beautifying current page' : '正在美化当前页',
640 progress: 20,
641 totalPages: 1,
642 provider: job.context.provider,
643 model: job.context.model
644 }
645 })
646 let persisted: ReturnType<typeof replacePageContentFragment> | null = null
647 let retryFeedback: string | undefined
648 for (let attempt = 0; attempt < 2; attempt += 1) {
649 log.info('[page-beautify:job] agent started', {
650 ...logPayload,
651 attempt: attempt + 1,
652 provider: job.context.provider,
653 model: job.context.model,
654 timeoutMs: job.context.modelTimeouts.agent
655 })
656 const agentStartedAt = Date.now()
657 const fragment = await runPageBeautifyAgent({
658 provider: job.context.provider,
659 apiKey: job.context.apiKey,
660 model: job.context.model,
661 baseUrl: job.context.providerBaseUrl,
662 maxTokens: job.context.maxTokens,
663 modelRuntime: job.context.modelRuntime,
664 modelTimeoutMs: job.context.modelTimeouts,
665 signal: job.lease.signal,
666 styleName: job.context.styleName,
667 styleKey: job.context.styleKey,
668 styleSkillPrompt: job.context.styleSkillPrompt,
669 styleCase: job.context.styleCase,
670 slideSize: job.context.slideSize,
671 layoutSkillName: job.context.layoutSkillName,
672 designContract: job.context.designContract,
673 layoutAudit: job.context.layoutAudit,
674 targetPageId: job.targetPageId,
675 targetPageNumber: job.targetPageNumber,
676 targetHtmlPath: job.targetPagePath,
677 retryFeedback,
678 onProgress: (ratio) => {
679 // Map agent's 0..0.82 ratio onto the 20..80 UI range so the bar keeps
680 // moving during long model streams without promising completion.
681 const progress = Math.max(20, Math.min(80, Math.round(20 + ratio * 73)))
682 emit({
683 type: 'llm_status',
684 payload: {
685 runId: job.runId,
686 stage: 'editing',
687 label: retryFeedback
688 ? isEn
689 ? 'Correcting page from validation feedback'
690 : '正在根据审核反馈修正页面'
691 : isEn
692 ? 'Beautifying current page'
693 : '正在美化当前页',
694 progress,
695 totalPages: 1,
696 provider: job.context.provider,
697 model: job.context.model
698 }
699 })
700 }
701 })
702 log.info('[page-beautify:job] agent returned', {
703 ...logPayload,
704 attempt: attempt + 1,
705 fragmentBytes: fragment.length,
706 elapsedMs: Date.now() - agentStartedAt
707 })
708 emit({
709 type: 'llm_status',
710 payload: {
711 runId: job.runId,
712 stage: 'finalizing',
713 label: isEn ? 'Validating beautified fragment' : '正在校验美化结果',
714 progress: 83,
715 totalPages: 1
716 }
717 })
718 try {
719 const candidate = replacePageContentFragment({
720 originalHtml,
721 content: fragment,
722 pageId: job.targetPageId
723 })
724 if (
725 candidate.content.trim() !== originalFragment.trim() &&
726 !hasMeaningfulPageBeautifyChange(originalFragment, extractPageBeautifyContent(candidate.html))
727 ) {
728 throw new Error(
729 '未检测到有效的布局改版。不要只修改文字、数字、注释、动画或 data 属性;请重构排版并自行审查版式。'
730 )
731 }
732 persisted = candidate
733 break
734 } catch (error) {
735 if (job.lease.signal.aborted || attempt === 1) throw error
736 retryFeedback = error instanceof Error ? error.message : String(error || '')
737 log.warn('[page-beautify:job] candidate rejected, retrying with feedback', {
738 ...logPayload,
739 message: retryFeedback
740 })
741 emit({
742 type: 'llm_status',
743 payload: {
744 runId: job.runId,
745 stage: 'editing',
746 label: isEn
747 ? 'Correcting page from validation feedback'
748 : '正在根据审核反馈修正页面',
749 progress: 45,
750 totalPages: 1,
751 provider: job.context.provider,
752 model: job.context.model
753 }
754 })
755 }
756 }
757 if (!persisted) throw new Error('一键美化未通过页面审核,请重试。')
758 const html = persisted.html
759
760 // Detect "agent returned the same fragment" before touching disk. This is a
761 // completed outcome per design: no rollback, no fake page_updated, but the
762 // run is marked completed with outcome='unchanged' so the UI can surface it.
763 const isUnchanged = persisted.content.trim() === originalFragment.trim()
764 if (isUnchanged) {
765 await this.settleUnchanged(job)
766 log.info('[page-beautify:job] completed unchanged', {
767 ...logPayload,
768 elapsedMs: Date.now() - startedAt
769 })
770 emit({
771 type: 'llm_status',
772 payload: {
773 runId: job.runId,
774 stage: 'finalizing',
775 label: isEn ? 'Page already looks good' : '当前页已是最优版本',
776 progress: 100,
777 totalPages: 1,
778 provider: job.context.provider,
779 model: job.context.model
780 }
781 })
782 emit({
783 type: 'run_completed',
784 payload: {
785 runId: job.runId,
786 totalPages: 1,
787 outcome: 'unchanged',
788 activityKind: 'page-beautify',
789 sessionId: job.sessionId
790 }
791 })
792 this.ctx.emitRuntimeJobTerminal({
793 sessionId: job.sessionId,
794 jobId: job.runId,
795 domain: 'edit',
796 status: 'completed'
797 })
798 return
799 }
800
801 emit({
802 type: 'llm_status',
803 payload: {
804 runId: job.runId,
805 stage: 'finalizing',
806 label: isEn ? 'Writing page to disk' : '正在写入页面',
807 progress: 87,
808 totalPages: 1
809 }
810 })
811 tmpPath = `${job.targetPagePath}${BEAUTIFY_TMP_SUFFIX}`
812 await writeTargetHtmlAtomically(job.targetPagePath, html)
813 tmpPath = null
814 log.info('[page-beautify:job] persisted', {
815 ...logPayload,
816 htmlBytes: html.length
817 })
818
819 emit({
820 type: 'llm_status',
821 payload: {
822 runId: job.runId,
823 stage: 'finalizing',
824 label: isEn ? 'Committing page history' : '正在提交页面历史',
825 progress: 91,
826 totalPages: 1
827 }
828 })
829 const allowedPath = toRelativeProjectPath(job.context.projectDir, job.targetPagePath)
830 await recordHistoryOperationStrict(this.ctx.db, {
831 sessionId: job.sessionId,
832 projectDir: job.context.projectDir,
833 type: 'edit',
834 scope: 'page',
835 prompt: job.context.userMessage,
836 allowedPaths: [allowedPath],
837 metadata: { runId: job.runId, jobType: 'page-beautify', pageId: job.targetPageId }
838 })
839 log.info('[page-beautify:job] committed', { ...logPayload, allowedPath })
840
841 emit({
842 type: 'llm_status',
843 payload: {
844 runId: job.runId,
845 stage: 'finalizing',
846 label: isEn ? 'Finalizing session records' : '正在更新会话记录',
847 progress: 95,
848 totalPages: 1
849 }
850 })
851 await this.ctx.db.upsertGenerationPage({
852 runId: job.runId,
853 sessionId: job.sessionId,
854 pageId: job.targetPageId,
855 pageNumber: job.targetPageNumber,
856 title: job.context.target.title,
857 contentOutline: '',
858 htmlPath: job.targetPagePath,
859 status: 'completed'
860 })
861 await this.ctx.db.upsertSessionPage({
862 id: job.context.target.id,
863 sessionId: job.sessionId,
864 legacyPageId:
865 job.context.target.legacyPageId ||
866 (job.targetPageId.match(/^page-\d+$/) ? job.targetPageId : null),
867 fileSlug: job.targetPageId,
868 pageNumber: job.targetPageNumber,
869 title: job.context.target.title,
870 htmlPath: job.targetPagePath,
871 status: 'completed',
872 error: null
873 })
874 await this.ctx.db.updateSessionMetadata(job.sessionId, {
875 lastRunId: job.runId,
876 entryMode: 'multi_page',
877 projectId: job.context.projectId
878 })
879 await this.ctx.db.updateProjectStatus(job.context.projectId, 'draft')
880 await this.ctx.db.updateSessionStatus(
881 job.sessionId,
882 normalizeRestoredSessionStatus(job.context.previousSessionStatus)
883 )
884 await this.ctx.db.updateGenerationRunStatus(job.runId, 'completed', null)
885 await this.ctx.db.updateSessionJobStatus(job.runId, 'finished')
886 log.info('[page-beautify:job] completed', {
887 ...logPayload,
888 outcome: 'changed',
889 elapsedMs: Date.now() - startedAt
890 })
891 emit({
892 type: 'page_updated',
893 payload: {
894 runId: job.runId,
895 stage: 'finalizing',
896 label: isEn ? 'Beautified' : '美化完成',
897 progress: 100,
898 currentPage: job.targetPageNumber,
899 totalPages: 1,
900 id: job.context.target.id,
901 pageNumber: job.targetPageNumber,
902 title: job.context.target.title,
903 html,
904 pageId: job.targetPageId,
905 htmlPath: job.targetPagePath,
906 sourceUrl: this.ctx.getPageSourceUrl(job.targetPagePath)
907 }
908 })
909 emit({ type: 'run_completed', payload: { runId: job.runId, totalPages: 1 } })
910 this.ctx.emitRuntimeJobTerminal({
911 sessionId: job.sessionId,
912 jobId: job.runId,
913 domain: 'edit',
914 status: 'completed'
915 })
916 } catch (error) {
917 const cancelled =
918 job.lease.signal.aborted ||
919 isCancellationMessage(error instanceof Error ? error.message : String(error || ''))
920 log.error('[page-beautify:job] failed', {
921 ...logPayload,
922 cancelled,
923 message: error instanceof Error ? error.message : String(error || ''),
924 elapsedMs: Date.now() - startedAt
925 })
926 try {
927 await restoreSnapshots(snapshots)
928 } catch (restoreError) {
929 log.error('[page-beautify:job] failed to restore snapshots', {
930 sessionId: job.sessionId,
931 runId: job.runId,
932 message: restoreError instanceof Error ? restoreError.message : String(restoreError || '')
933 })
934 }
935 await this.settleFailure(job.context, error, job.lease.signal.aborted)
936 } finally {
937 if (tmpPath) await removeTempFile(tmpPath)
938 this.activeJobs.delete(job.sessionId)
939 this.reservedJobIds.delete(job.sessionId)
940 job.lease.release()
941 }
942 }
943
944 private async settleUnchanged(job: ActivePageBeautifyJob): Promise<void> {
945 await this.ctx.db.updateGenerationRunStatus(job.runId, 'completed', null)
946 await this.ctx.db
947 .updateGenerationRunMetadata(job.runId, { outcome: 'unchanged' })
948 .catch(() => {})
949 await this.ctx.db.updateSessionStatus(
950 job.sessionId,
951 normalizeRestoredSessionStatus(job.context.previousSessionStatus)
952 )
953 await this.ctx.db.updateSessionJobStatus(job.runId, 'finished')
954 }
955
956 private async settleFailure(
957 context: PageBeautifyContext,
958 error: unknown,
959 aborted: boolean
960 ): Promise<void> {
961 const message = error instanceof Error ? error.message : String(error || '')
962 const cancelled = aborted || isCancellationMessage(message)
963 const failureMessage = cancelled ? '生成已取消' : message || '一键美化失败'
964 try {
965 await this.ctx.db.updateGenerationRunStatus(context.runId, 'failed', failureMessage)
966 await this.ctx.db.updateSessionStatus(
967 context.sessionId,
968 cancelled || context.previousSessionStatus !== 'active'
969 ? normalizeRestoredSessionStatus(context.previousSessionStatus)
970 : 'failed'
971 )
972 // Cancellation is a user-initiated action; do not leave stray system messages
973 // in the page chat. Real failures (guard rejection, model error, etc.) still
974 // surface a message so the user can diagnose.
975 if (!cancelled) {
976 await this.ctx.db.addMessage(context.sessionId, {
977 role: 'system',
978 content: failureMessage,
979 type: 'stream_chunk',
980 chat_scope: 'page',
981 page_id: context.target.pageId,
982 run_model: context.runModel
983 })
984 }
985 } finally {
986 await this.ctx.db.updateSessionJobStatus(
987 context.runId,
988 cancelled ? 'aborted' : 'finished',
989 cancelled ? { abortReason: 'cancelled' } : undefined
990 )
991 }
992 this.ctx.emitGenerateChunk(context.sessionId, {
993 type: 'run_error',
994 payload: { runId: context.runId, message: failureMessage, cancelled }
995 })
996 this.ctx.emitRuntimeJobTerminal({
997 sessionId: context.sessionId,
998 jobId: context.runId,
999 domain: 'edit',
1000 status: cancelled ? 'cancelled' : 'failed',
1001 errorCode: cancelled ? undefined : 'page_beautify_failed',
1002 errorMessage: cancelled ? undefined : failureMessage
1003 })
1004 }
1005 }
1006
1007 export function registerPageBeautifyJobHandlers(
1008 ctx: IpcContext,
1009 coordinator: JobCoordinator
1010 ): PageBeautifyJobService {
1011 const service = new PageBeautifyJobService(ctx, coordinator)
1012 const interruptedReady = service
1013 .abortInterruptedJobs('应用退出导致页面美化中断,可重试')
1014 .catch((error) => {
1015 log.warn('[page-beautify:job] failed to abort interrupted jobs', {
1016 message: error instanceof Error ? error.message : String(error)
1017 })
1018 })
1019 ipcMain.handle('page-beautify:start', async (event, payload) => {
1020 await interruptedReady
1021 return service.start(event, payload)
1022 })
1023 ipcMain.handle('page-beautify:cancel', async (_event, rawSessionId) => {
1024 await interruptedReady
1025 const sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : ''
1026 return { success: sessionId ? await service.cancel(sessionId) : true }
1027 })
1028 ipcMain.handle('page-beautify:state', async (_event, rawSessionId) => {
1029 await interruptedReady
1030 const sessionId = typeof rawSessionId === 'string' ? rawSessionId.trim() : ''
1031 if (!sessionId) throw new Error('sessionId 不能为空')
1032 return service.getState(sessionId)
1033 })
1034 ipcMain.handle('page-beautify:listActive', async () => {
1035 await interruptedReady
1036 return service.listActive()
1037 })
1038 return service
1039 }
1040
1040 lines TYPESCRIPT