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