| 1 | import log from 'electron-log/main.js' |
| 2 | import path from 'path' |
| 3 | import { customAlphabet, nanoid } from 'nanoid' |
| 4 | import type { GenerationContext } from './context' |
| 5 | import type { FinalizeContext, FinalizeGenerationArgs } from './types' |
| 6 | import type { SessionPageRecord } from '../db/database' |
| 7 | import { isCancellationMessage, normalizeRestoredSessionStatus } from './status-utils' |
| 8 | |
| 9 | const pageSlugId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10) |
| 10 | |
| 11 | export const resolveGenerationFailureSessionStatus = ( |
| 12 | context: FinalizeContext, |
| 13 | cancelled: boolean |
| 14 | ): 'active' | 'completed' | 'failed' | 'archived' => { |
| 15 | if (cancelled) return normalizeRestoredSessionStatus(context.previousSessionStatus) |
| 16 | if ( |
| 17 | (context.effectiveMode === 'edit' || |
| 18 | context.effectiveMode === 'retry' || |
| 19 | context.effectiveMode === 'addPage' || |
| 20 | context.effectiveMode === 'retrySinglePage') && |
| 21 | context.previousSessionStatus !== 'active' |
| 22 | ) { |
| 23 | return normalizeRestoredSessionStatus(context.previousSessionStatus) |
| 24 | } |
| 25 | return 'failed' |
| 26 | } |
| 27 | |
| 28 | const syncGeneratedPagesToSessionPages = async ( |
| 29 | ctx: GenerationContext, |
| 30 | args: { |
| 31 | sessionId: string |
| 32 | generatedPages: Array<{ |
| 33 | id?: string |
| 34 | pageNumber: number |
| 35 | title: string |
| 36 | pageId?: string |
| 37 | htmlPath?: string |
| 38 | }> |
| 39 | } |
| 40 | ): Promise<void> => { |
| 41 | const existingPages = await ctx.db.listSessionPages(args.sessionId, { includeDeleted: true }) |
| 42 | const existingBySlug = new Map<string, SessionPageRecord>() |
| 43 | for (const row of existingPages) { |
| 44 | existingBySlug.set(row.file_slug, row) |
| 45 | if (row.legacy_page_id) existingBySlug.set(row.legacy_page_id, row) |
| 46 | } |
| 47 | |
| 48 | for (const page of args.generatedPages) { |
| 49 | const fileSlug = page.pageId || `page-${pageSlugId()}` |
| 50 | const existing = existingBySlug.get(fileSlug) |
| 51 | await ctx.db.upsertSessionPage({ |
| 52 | id: page.id || existing?.id || nanoid(), |
| 53 | sessionId: args.sessionId, |
| 54 | legacyPageId: existing?.legacy_page_id || (fileSlug.match(/^page-\d+$/) ? fileSlug : null), |
| 55 | fileSlug, |
| 56 | pageNumber: page.pageNumber, |
| 57 | title: page.title || `第 ${page.pageNumber} 页`, |
| 58 | htmlPath: page.htmlPath || '', |
| 59 | status: 'completed', |
| 60 | error: null |
| 61 | }) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | export async function finalizeGenerationSuccess( |
| 66 | ctx: GenerationContext, |
| 67 | args: FinalizeGenerationArgs |
| 68 | ): Promise<void> { |
| 69 | const { db } = ctx |
| 70 | const { context, indexPath, totalPages, generatedPages } = args |
| 71 | const contextWithPrompt = context as FinalizeContext & { userMessage?: unknown } |
| 72 | await syncGeneratedPagesToSessionPages(ctx, { |
| 73 | sessionId: context.sessionId, |
| 74 | generatedPages |
| 75 | }) |
| 76 | await db.updateSessionMetadata(context.sessionId, { |
| 77 | lastRunId: context.runId, |
| 78 | entryMode: 'multi_page', |
| 79 | indexPath, |
| 80 | projectId: context.projectId |
| 81 | }) |
| 82 | if (args.designContract) { |
| 83 | await db.updateSessionDesignContract(context.sessionId, args.designContract) |
| 84 | } |
| 85 | await db.updateProjectStatus(context.projectId, 'draft') |
| 86 | await db.updateSessionStatus(context.sessionId, 'completed') |
| 87 | await ctx.history.recordOperation({ |
| 88 | sessionId: context.sessionId, |
| 89 | projectDir: path.dirname(indexPath), |
| 90 | type: |
| 91 | context.effectiveMode === 'addPage' |
| 92 | ? 'addPage' |
| 93 | : context.effectiveMode === 'retry' |
| 94 | ? 'retry' |
| 95 | : context.effectiveMode === 'retrySinglePage' |
| 96 | ? 'retry' |
| 97 | : 'generate', |
| 98 | scope: context.effectiveMode === 'retrySinglePage' ? 'page' : 'session', |
| 99 | prompt: typeof contextWithPrompt.userMessage === 'string' ? contextWithPrompt.userMessage : null, |
| 100 | metadata: { |
| 101 | runId: context.runId, |
| 102 | effectiveMode: context.effectiveMode, |
| 103 | totalPages |
| 104 | } |
| 105 | }) |
| 106 | log.info('[generate:start] completed', { |
| 107 | sessionId: context.sessionId, |
| 108 | styleId: context.styleId, |
| 109 | totalPages |
| 110 | }) |
| 111 | ctx.runtimeEmitters.emitGenerateChunk(context.sessionId, { |
| 112 | type: 'run_completed', |
| 113 | payload: { |
| 114 | runId: context.runId, |
| 115 | totalPages |
| 116 | } |
| 117 | }) |
| 118 | } |
| 119 | |
| 120 | export async function finalizeGenerationFailure( |
| 121 | ctx: GenerationContext, |
| 122 | context: FinalizeContext, |
| 123 | error: unknown |
| 124 | ): Promise<void> { |
| 125 | const { db } = ctx |
| 126 | const message = |
| 127 | error instanceof Error && error.message.length > 0 ? error.message : 'Generation failed' |
| 128 | const cancelled = isCancellationMessage(message) |
| 129 | log.error('[generate:start] failed', { |
| 130 | sessionId: context.sessionId, |
| 131 | styleId: context.styleId, |
| 132 | message |
| 133 | }) |
| 134 | const generationRun = await db.getGenerationRun(context.runId) |
| 135 | if (generationRun && generationRun.status === 'running') { |
| 136 | await db.updateGenerationRunStatus(context.runId, 'failed', message) |
| 137 | } |
| 138 | if (context.effectiveMode === 'addPage' && context.targetPageId) { |
| 139 | const targetPage = (await db.listSessionPages(context.sessionId)).find( |
| 140 | (page) => page.id === context.targetPageId || page.file_slug === context.targetPageId |
| 141 | ) |
| 142 | if (targetPage) { |
| 143 | await db.upsertSessionPage({ |
| 144 | id: targetPage.id, |
| 145 | sessionId: targetPage.session_id, |
| 146 | legacyPageId: targetPage.legacy_page_id, |
| 147 | fileSlug: targetPage.file_slug, |
| 148 | pageNumber: targetPage.page_number, |
| 149 | title: targetPage.title, |
| 150 | htmlPath: targetPage.html_path, |
| 151 | status: 'failed', |
| 152 | error: message |
| 153 | }) |
| 154 | } |
| 155 | } |
| 156 | await db.updateSessionStatus( |
| 157 | context.sessionId, |
| 158 | resolveGenerationFailureSessionStatus(context, cancelled) |
| 159 | ) |
| 160 | await db.addMessage(context.sessionId, { |
| 161 | role: 'system', |
| 162 | content: message, |
| 163 | type: 'stream_chunk', |
| 164 | chat_scope: context.messageScope, |
| 165 | page_id: context.messagePageId, |
| 166 | run_model: context.runModel |
| 167 | }) |
| 168 | ctx.runtimeEmitters.emitGenerateChunk(context.sessionId, { |
| 169 | type: 'run_error', |
| 170 | payload: { runId: context.runId, message, cancelled } |
| 171 | }) |
| 172 | } |
| 173 |