返回 oh-my-ppt
session-generating.tsx
根目录 / src / renderer / src / pages / session-generating.tsx
1 import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2 import { useLocation, useNavigate, useParams } from 'react-router-dom'
3 import { ipc } from '@renderer/lib/ipc'
4 import type { AnimationPreferencesPayload, GenerateChunkEvent } from '@shared/generation.js'
5 import videoSrc from '../assets/images/video.mp4'
6 import { getEditorGate, type EditorGate } from '../lib/sessionMetadata'
7 import { useLang, type Lang } from '../i18n'
8 import {
9 GenerationPreviewGrid,
10 GenerationSidebar,
11 GenerationStatusPanel,
12 type GenerationPreviewPage,
13 type GenerationRunStatus,
14 type GenerationStageKey
15 } from '../components/session-generating'
16 import { useModelAction } from '../hooks/useModelAction'
17 import { trySessionSlideSize, type SlideSizePreset } from '@shared/slide-size'
18
19 type LocationState = {
20 initialPrompt?: string
21 modelConfigId?: string
22 retry?: boolean
23 rerunToken?: number
24 animationPreferences?: AnimationPreferencesPayload | null
25 failedRunId?: string
26 }
27
28 type GenerationKind = 'standard' | 'template'
29
30 type SessionGeneratedPage = {
31 id?: string
32 pageNumber: number
33 title: string
34 htmlPath?: string
35 pageId?: string
36 sourceUrl?: string
37 status?: string
38 error?: string | null
39 }
40
41 type GenerationSessionSnapshot = {
42 status?: string
43 title?: string | null
44 page_count?: number | null
45 metadata?: string | null
46 slideSizeId?: string | null
47 slideWidth?: number | null
48 slideHeight?: number | null
49 slide_size_id?: string | null
50 slide_width?: number | null
51 slide_height?: number | null
52 }
53
54 const NEUTRAL_GENERATION_PROMPT =
55 'Create a clear first draft that can be previewed directly. Determine the content language from the session topic, outline, detailed brief, and source documents; do not infer it from the application UI language or this instruction language.'
56
57 const isSessionFullyGenerated = (gate: EditorGate): boolean =>
58 gate.generatedCount >= gate.totalCount && gate.failedCount === 0
59
60 const LOG_AUTO_SCROLL_THRESHOLD = 48
61
62 const isNearLogBottom = (el: HTMLDivElement): boolean =>
63 el.scrollHeight - el.scrollTop - el.clientHeight <= LOG_AUTO_SCROLL_THRESHOLD
64
65 const compactWhitespace = (value: string): string => value.replace(/\s+/g, ' ').trim()
66
67 const eventDedupeKey = (value: string): string =>
68 compactWhitespace(value)
69 .replace(/\s*·\s*\d{1,3}%$/g, '')
70 .replace(/\s+\d{1,3}%$/g, '')
71
72 const hasTechnicalDetail = (message: string): boolean => {
73 const compact = compactWhitespace(message)
74 if (compact.length > 160 || message.includes('\n')) return true
75 return /Received tool input did not match expected schema|Error invoking tool|ZodError|expected schema|HTML 验证失败|HTML 落盘校验失败|页面编辑结果验证失败|ERR_FILE_NOT_FOUND|Failed to load URL|文件不存在|at\s+\S+.*:\d+:\d+|<html|<!doctype|data-ppt/i.test(
76 compact
77 ) || /HTML 末尾|未闭合标签|开闭标签数量不一致|内容可能被截断|<\/?[a-z][\w:-]*(\s|>|\/>)/i.test(compact)
78 }
79
80 const friendlyText = (lang: Lang, zh: string, en: string): string => (lang === 'en' ? en : zh)
81
82 const friendlyProgressDetail = (detail: string, lang: Lang): string => {
83 const compact = compactWhitespace(detail)
84 if (!compact) return ''
85 const pageMatch = compact.match(/(\d+)\/(\d+)\s*(页|pages?)/i)
86 if (pageMatch) {
87 return friendlyText(
88 lang,
89 `已处理 ${pageMatch[1]}/${pageMatch[2]} 页`,
90 `Processed ${pageMatch[1]}/${pageMatch[2]} pages`
91 )
92 }
93 if (/没有检测到.*变化|without any detected page changes|no page changes/i.test(compact)) {
94 return friendlyText(lang, '刚才没有写入变化,正在换一种方式重试。', 'No changes were written yet; trying another way.')
95 }
96 if (/HTML 末尾|未闭合标签|开闭标签数量不一致|内容可能被截断|<\/?[a-z][\w:-]*(\s|>|\/>)/i.test(compact)) {
97 return friendlyText(
98 lang,
99 '页面结构检查未通过,正在尝试修复。',
100 'The page structure needs a fix; trying to repair it.'
101 )
102 }
103 if (/schema|工具调用参数|tool call/i.test(compact)) {
104 return friendlyText(lang, '工具参数需要修正,正在自动重试。', 'Tool arguments need a quick fix; retrying automatically.')
105 }
106 if (/校验|验证|validat/i.test(compact)) {
107 return friendlyText(lang, '页面结构需要修正,正在自动重试。', 'The page structure needs a fix; retrying automatically.')
108 }
109 if (/重试|retry/i.test(compact)) {
110 return friendlyText(lang, '处理中遇到问题,正在自动重试。', 'Something needs another pass; retrying automatically.')
111 }
112 if (/准备完成|ready/i.test(compact)) {
113 return friendlyText(lang, '准备完成,开始生成页面。', 'Ready. Starting page generation.')
114 }
115 const pageTitleMatch = compact.match(/^page-[\w-]+\s*·\s*(.+)$/i)
116 if (pageTitleMatch?.[1]) {
117 const title = pageTitleMatch[1].trim()
118 return friendlyText(lang, `正在处理「${title}」`, `Processing "${title}"`)
119 }
120 return hasTechnicalDetail(compact) ? '' : compact
121 }
122
123 const isFailureProgress = (label: string | undefined, detail: string): boolean =>
124 /失败|failed|fail|error|错误/i.test(`${label || ''} ${detail}`)
125
126 const isCancellationMessage = (message: string | null | undefined): boolean =>
127 /^(生成已取消|Generation cancelled|Generation canceled)$/i.test((message || '').trim())
128
129 const friendlyProgressLabel = (label: string | undefined, detail: string, lang: Lang): string => {
130 const compactLabel = compactWhitespace(label || '')
131 if (isFailureProgress(label, detail)) {
132 return friendlyText(lang, '检查页面', 'Checking pages')
133 }
134 return compactLabel
135 }
136
137 const friendlyFailureProgressDetail = (lang: Lang): string =>
138 friendlyText(
139 lang,
140 '页面结构检查未通过,正在尝试修复。',
141 'The page structure needs a fix; trying to repair it.'
142 )
143
144 const friendlyFailureMessage = (message: string | null | undefined, lang: Lang): string => {
145 const compact = compactWhitespace(message || '')
146 if (!compact) {
147 return friendlyText(lang, '生成没有完成,请重试。', 'Generation did not finish. Please retry.')
148 }
149 if (/API Key|api key|provider|模型|model|timeout|timed out|ECONN|network|fetch failed/i.test(compact)) {
150 return friendlyText(
151 lang,
152 '模型服务暂时不可用,请检查设置后重试。',
153 'The model service is not available. Check settings and retry.'
154 )
155 }
156 if (/文件不存在|ERR_FILE_NOT_FOUND|Failed to load URL|ENOENT/i.test(compact)) {
157 return friendlyText(
158 lang,
159 '页面文件暂时不可用,请返回会话后重试。',
160 'The page files are not available. Return to the session and retry.'
161 )
162 }
163 if (/schema|tool call|工具调用参数/i.test(compact)) {
164 return friendlyText(
165 lang,
166 '生成工具调用失败,请重试一次。',
167 'The generation tool call failed. Please retry.'
168 )
169 }
170 if (/校验|验证|validat|HTML/i.test(compact)) {
171 return friendlyText(
172 lang,
173 '页面结果没有通过检查,请重试一次。',
174 'The page result did not pass checks. Please retry.'
175 )
176 }
177 return hasTechnicalDetail(compact)
178 ? friendlyText(lang, '生成没有完成,请重试。', 'Generation did not finish. Please retry.')
179 : compact
180 }
181
182 const progressLine = (args: {
183 label?: string
184 detail?: string
185 }): string => {
186 const label = compactWhitespace(args.label || '')
187 const detail = compactWhitespace(args.detail || '')
188 const parts = [label, detail].filter(Boolean)
189 return parts.join(' · ')
190 }
191
192 const buildPagePlaceholders = (
193 totalPages: number,
194 lang: Lang,
195 existing: GenerationPreviewPage[] = []
196 ): GenerationPreviewPage[] => {
197 const count = Math.max(1, Math.floor(totalPages || 1))
198 const byNumber = new Map(existing.map((page) => [page.pageNumber, page]))
199 return Array.from({ length: count }, (_, index) => {
200 const pageNumber = index + 1
201 const existingPage = byNumber.get(pageNumber)
202 if (existingPage) return existingPage
203 return {
204 id: `placeholder-${pageNumber}`,
205 pageNumber,
206 title: friendlyText(lang, `第 ${pageNumber} 页`, `Page ${pageNumber}`),
207 status: 'pending'
208 }
209 })
210 }
211
212 const mergePreviewPage = (
213 pages: GenerationPreviewPage[],
214 incoming: GenerationPreviewPage,
215 totalPages: number,
216 lang: Lang
217 ): GenerationPreviewPage[] => {
218 const placeholders = buildPagePlaceholders(totalPages, lang, pages)
219 const index = placeholders.findIndex((page) => page.pageNumber === incoming.pageNumber)
220 const previousPage = index >= 0 ? placeholders[index] : undefined
221 const nextPage = {
222 ...incoming,
223 id: incoming.id || incoming.pageId || `page-${incoming.pageNumber}`,
224 pageId: incoming.pageId || `page-${incoming.pageNumber}`,
225 status: incoming.status,
226 previewVersion: (previousPage?.previewVersion || 0) + 1
227 }
228 if (index >= 0) {
229 placeholders[index] = {
230 ...placeholders[index],
231 ...nextPage
232 }
233 } else {
234 placeholders.push(nextPage)
235 }
236 return placeholders.sort((a, b) => a.pageNumber - b.pageNumber)
237 }
238
239 const buildPreviewPagesFromGeneratedPages = (
240 pageCount: number,
241 pages: SessionGeneratedPage[],
242 lang: Lang
243 ): GenerationPreviewPage[] => {
244 const maxPageNumber = pages.reduce((max, page) => Math.max(max, page.pageNumber || 0), 0)
245 const totalPages = Math.max(1, pageCount, maxPageNumber, pages.length)
246 return buildPagePlaceholders(
247 totalPages,
248 lang,
249 pages.map((page) => ({
250 id: page.id || page.pageId || `page-${page.pageNumber}`,
251 pageNumber: page.pageNumber,
252 title: page.title,
253 htmlPath: page.htmlPath,
254 pageId: page.pageId || `page-${page.pageNumber}`,
255 sourceUrl: page.sourceUrl,
256 status:
257 page.status === 'failed'
258 ? 'failed'
259 : page.status === 'completed'
260 ? 'completed'
261 : page.status
262 ? 'pending'
263 : page.htmlPath || page.sourceUrl
264 ? 'completed'
265 : 'pending'
266 }))
267 )
268 }
269
270 const updatePreviewPageStatus = (
271 pages: GenerationPreviewPage[],
272 incoming: {
273 id?: string
274 pageNumber: number
275 title: string
276 pageId?: string
277 htmlPath?: string
278 sourceUrl?: string
279 status: GenerationPreviewPage['status']
280 },
281 totalPages: number,
282 lang: Lang
283 ): GenerationPreviewPage[] => {
284 const placeholders = buildPagePlaceholders(totalPages, lang, pages)
285 return placeholders
286 .map((page) => {
287 if (page.pageNumber !== incoming.pageNumber) return page
288 const nextStatus =
289 page.status === 'completed' && incoming.status === 'generating'
290 ? page.status
291 : incoming.status
292 return {
293 ...page,
294 id: incoming.id || page.id,
295 pageId: incoming.pageId || page.pageId,
296 htmlPath: incoming.htmlPath || page.htmlPath,
297 sourceUrl: incoming.sourceUrl || page.sourceUrl,
298 title: incoming.title || page.title,
299 status: nextStatus
300 }
301 })
302 .sort((a, b) => a.pageNumber - b.pageNumber)
303 }
304
305 export function SessionGeneratingPage({
306 generationKind = 'standard'
307 }: {
308 generationKind?: GenerationKind
309 } = {}): React.JSX.Element {
310 const { id } = useParams<{ id: string }>()
311 const navigate = useNavigate()
312 const location = useLocation()
313 const { lang, t } = useLang()
314 const modelAction = useModelAction()
315 const { ensureModelActive, selectedModelConfigId } = modelAction
316 const state = (location.state as LocationState | null) || null
317 const startedSessionRef = useRef<string | null>(null)
318 const activeRunIdRef = useRef<string | null>(null)
319 const terminalStatusRef = useRef<'completed' | 'failed' | null>(null)
320 const eventsContainerRef = useRef<HTMLDivElement | null>(null)
321 const stickToBottomRef = useRef(true)
322 const shouldAutoScrollRef = useRef(true)
323 const currentStageRef = useRef<string>('preflight')
324 const lastProgressLogRef = useRef<{ stage: string; progress: number; time: number } | null>(null)
325
326 const [status, setStatus] = useState<GenerationRunStatus>('running')
327 const [progress, setProgress] = useState(0)
328 const [events, setEvents] = useState<Array<{ text: string; time?: string }>>([
329 { text: t('generating.created'), time: new Date().toISOString() }
330 ])
331 const [error, setError] = useState<string | null>(null)
332 const [totalPages, setTotalPages] = useState<number>(1)
333 const [editorGate, setEditorGate] = useState<EditorGate>(() => getEditorGate(null))
334 const [currentStage, setCurrentStage] = useState<string>('preflight')
335 const [previewPages, setPreviewPages] = useState<GenerationPreviewPage[]>(() =>
336 buildPagePlaceholders(1, lang)
337 )
338 const [slideSize, setSlideSize] = useState<SlideSizePreset | null>(null)
339 const [presentationTitle, setPresentationTitle] = useState<string>('')
340 const [cancelPending, setCancelPending] = useState(false)
341 const generatingPath =
342 generationKind === 'template' && id
343 ? `/sessions/${id}/template-generating`
344 : `/sessions/${id}/generating`
345
346 const appendEvent = (line: string, timestamp?: string): void => {
347 const el = eventsContainerRef.current
348 shouldAutoScrollRef.current = !el || stickToBottomRef.current || isNearLogBottom(el)
349 setEvents((prev) => {
350 const normalized = line.replace(/\s+/g, ' ').trim()
351 if (!normalized) return prev
352 const normalizedKey = eventDedupeKey(normalized)
353 const normalizedPrev = prev.map((item) => eventDedupeKey(item.text))
354 const previousKey = normalizedPrev[normalizedPrev.length - 1]
355 if (previousKey === normalizedKey || previousKey?.startsWith(`${normalizedKey} · `)) {
356 return prev
357 }
358 if (previousKey && normalizedKey.startsWith(`${previousKey} · `)) {
359 const next = [...prev.slice(0, -1), { text: line, time: timestamp }]
360 return next.length > 300 ? next.slice(next.length - 300) : next
361 }
362 const recent = normalizedPrev.slice(-4)
363 if (
364 recent.some(
365 (item) =>
366 item === normalizedKey ||
367 item.startsWith(`${normalizedKey} · `) ||
368 normalizedKey.startsWith(`${item} · `)
369 )
370 ) {
371 return prev
372 }
373 const next = [...prev, { text: line, time: timestamp }]
374 return next.length > 300 ? next.slice(next.length - 300) : next
375 })
376 }
377
378 const scrollLogToBottom = (): void => {
379 const el = eventsContainerRef.current
380 if (!el) return
381 el.scrollTop = el.scrollHeight
382 window.requestAnimationFrame(() => {
383 const next = eventsContainerRef.current
384 if (!next) return
385 next.scrollTop = next.scrollHeight
386 stickToBottomRef.current = true
387 })
388 }
389
390 useLayoutEffect(() => {
391 if (!shouldAutoScrollRef.current) return
392 scrollLogToBottom()
393 }, [events, status])
394
395 useEffect(() => {
396 if (!id) {
397 navigate('/sessions', { replace: true })
398 return
399 }
400 let active = true
401
402 const initialPrompt = state?.initialPrompt?.trim() || NEUTRAL_GENERATION_PROMPT
403 const explicitRerun = typeof state?.rerunToken === 'number'
404 if (state?.retry || explicitRerun) {
405 startedSessionRef.current = null
406 activeRunIdRef.current = null
407 terminalStatusRef.current = null
408 currentStageRef.current = 'preflight'
409 lastProgressLogRef.current = null
410 shouldAutoScrollRef.current = true
411 stickToBottomRef.current = true
412 window.setTimeout(() => {
413 setStatus('running')
414 setProgress(0)
415 setError(null)
416 setCancelPending(false)
417 setCurrentStage('preflight')
418 setEvents([{ text: t('generating.created'), time: new Date().toISOString() }])
419 }, 0)
420 }
421
422 const applyChunk = (event: GenerateChunkEvent, options?: { replay?: boolean }): void => {
423 if (import.meta.env.DEV) {
424 console.debug('[generate:chunk] received', event)
425 }
426 if (event.payload.sessionId && event.payload.sessionId !== id) return
427 const incomingRunId = event.payload.runId
428 if (activeRunIdRef.current && incomingRunId && incomingRunId !== activeRunIdRef.current)
429 return
430 if (!options?.replay && !activeRunIdRef.current && incomingRunId) {
431 activeRunIdRef.current = incomingRunId
432 }
433 const applyProgress = (
434 next: number | undefined,
435 options?: { allowTerminal?: boolean }
436 ): void => {
437 const hardMax = options?.allowTerminal ? 100 : 90
438 const value = Math.max(0, Math.min(hardMax, Math.round(next ?? 0)))
439 setProgress((prev) => Math.max(prev, value))
440 }
441 const applyTotalPages = (next: number | undefined): void => {
442 if (!Number.isFinite(next)) return
443 const pages = Math.max(1, Math.floor(next as number))
444 setTotalPages((prev) => Math.max(prev, pages))
445 setPreviewPages((prev) => buildPagePlaceholders(Math.max(prev.length, pages), lang, prev))
446 }
447 if (event.type === 'stage_started' || event.type === 'stage_progress') {
448 applyProgress(event.payload.progress)
449 applyTotalPages(event.payload.totalPages)
450 const prevStage = currentStageRef.current
451 const stageChanged = event.payload.stage && event.payload.stage !== prevStage
452 if (event.payload.stage) {
453 currentStageRef.current = event.payload.stage
454 setCurrentStage(event.payload.stage)
455 }
456 const now = Date.now()
457 const previousLog = lastProgressLogRef.current
458 const progressValue = Math.round(event.payload.progress ?? 0)
459 const shouldLogProgress =
460 stageChanged ||
461 event.type === 'stage_started' ||
462 !previousLog ||
463 progressValue - previousLog.progress >= 6 ||
464 now - previousLog.time >= 8000
465 if (shouldLogProgress) {
466 lastProgressLogRef.current = {
467 stage: event.payload.stage || currentStageRef.current,
468 progress: progressValue,
469 time: now
470 }
471 appendEvent(
472 progressLine({
473 label: event.payload.label
474 }),
475 event.payload.timestamp
476 )
477 }
478 return
479 }
480
481 if (event.type === 'llm_status') {
482 applyProgress(event.payload.progress)
483 applyTotalPages(event.payload.totalPages)
484
485 // Track stage changes (compare before updating)
486 const prevStage = currentStageRef.current
487 const stageChanged = event.payload.stage && event.payload.stage !== prevStage
488 if (event.payload.stage) {
489 currentStageRef.current = event.payload.stage
490 setCurrentStage(event.payload.stage)
491 }
492
493 // Parse page completion count from detail
494 const detail = event.payload.detail || ''
495 const failureProgress = isFailureProgress(event.payload.label, detail)
496 const friendlyDetail = failureProgress
497 ? friendlyFailureProgressDetail(lang)
498 : friendlyProgressDetail(detail, lang)
499 const pageMatch = detail.match(/(\d+)\/(\d+)\s*(页|pages?)/)
500
501 // Filter: only append meaningful events to log
502 const hasPageCompletion = Boolean(pageMatch)
503 const now = Date.now()
504 const previousLog = lastProgressLogRef.current
505 const progressValue = Math.round(event.payload.progress ?? 0)
506 const progressMoved =
507 !previousLog ||
508 progressValue - previousLog.progress >= 6 ||
509 (event.payload.stage || currentStageRef.current) !== previousLog.stage
510 const progressTimedOut = !previousLog || now - previousLog.time >= 8000
511 const isValidationOrError =
512 Boolean(friendlyDetail) ||
513 detail.includes('校验') || detail.includes('validat') ||
514 detail.includes('失败') || detail.includes('fail') ||
515 detail.includes('重试') || detail.includes('retry') ||
516 detail.includes('准备完成') || detail.includes('ready')
517 const isRetryLabel =
518 event.payload.label?.includes('重试') || event.payload.label?.includes('retry')
519 const friendlyLabel = friendlyProgressLabel(event.payload.label, detail, lang)
520
521 if (
522 stageChanged ||
523 hasPageCompletion ||
524 isValidationOrError ||
525 isRetryLabel ||
526 progressMoved ||
527 progressTimedOut
528 ) {
529 lastProgressLogRef.current = {
530 stage: event.payload.stage || currentStageRef.current,
531 progress: progressValue,
532 time: now
533 }
534 appendEvent(
535 progressLine({
536 label: friendlyLabel,
537 detail: friendlyDetail
538 }),
539 event.payload.timestamp
540 )
541 }
542 return
543 }
544
545 if (event.type === 'page_generated' || event.type === 'page_updated') {
546 applyProgress(event.payload.progress)
547 applyTotalPages(Math.max(event.payload.totalPages ?? 0, event.payload.pageNumber))
548 setPreviewPages((prev) =>
549 mergePreviewPage(
550 prev,
551 {
552 id: event.payload.id || event.payload.pageId || `page-${event.payload.pageNumber}`,
553 pageNumber: event.payload.pageNumber,
554 title: event.payload.title,
555 htmlPath: event.payload.htmlPath,
556 pageId: event.payload.pageId || `page-${event.payload.pageNumber}`,
557 sourceUrl: event.payload.sourceUrl,
558 status: 'completed'
559 },
560 Math.max(prev.length, event.payload.totalPages || event.payload.pageNumber),
561 lang
562 )
563 )
564 appendEvent(
565 `${event.payload.label} · ${t('generating.pageDetail', { pageNumber: event.payload.pageNumber, title: event.payload.title })}`,
566 event.payload.timestamp
567 )
568 return
569 }
570
571 if (event.type === 'assistant_message') {
572 return
573 }
574
575 if (event.type === 'page_planned' || event.type === 'page_started' || event.type === 'page_failed') {
576 applyProgress(event.payload.progress)
577 applyTotalPages(Math.max(event.payload.totalPages ?? 0, event.payload.pageNumber))
578 setPreviewPages((prev) =>
579 updatePreviewPageStatus(
580 prev,
581 {
582 id: event.payload.id || event.payload.pageId || `page-${event.payload.pageNumber}`,
583 pageNumber: event.payload.pageNumber,
584 title: event.payload.title,
585 htmlPath: event.payload.htmlPath,
586 pageId: event.payload.pageId || `page-${event.payload.pageNumber}`,
587 status:
588 event.type === 'page_planned'
589 ? 'pending'
590 : event.type === 'page_started'
591 ? 'generating'
592 : 'failed'
593 },
594 Math.max(prev.length, event.payload.totalPages || event.payload.pageNumber),
595 lang
596 )
597 )
598 if (event.type === 'page_failed') {
599 appendEvent(
600 progressLine({
601 label: friendlyText(lang, '页面生成失败', 'Page generation failed'),
602 detail: event.payload.title
603 }),
604 event.payload.timestamp
605 )
606 }
607 return
608 }
609
610 if (event.type === 'run_completed') {
611 if (!active) return
612 terminalStatusRef.current = 'completed'
613 setStatus('completed')
614 applyProgress(100, { allowTerminal: true })
615 applyTotalPages(event.payload.totalPages)
616 appendEvent(t('generating.completed'), event.payload.timestamp)
617 if (options?.replay) return
618 window.setTimeout(() => {
619 if (!active) return
620 navigate(`/sessions/${id}`)
621 }, 850)
622 return
623 }
624
625 if (event.type === 'run_error') {
626 if (options?.replay && state?.retry) return
627 if (!active) return
628 const wasCancelled = isCancellationMessage(event.payload.message)
629 terminalStatusRef.current = 'failed'
630 setStatus(wasCancelled ? 'cancelled' : 'failed')
631 setError(friendlyFailureMessage(event.payload.message, lang))
632 appendEvent(t('generating.failedRetryOrBack'), event.payload.timestamp)
633 void ipc
634 .getSession(id)
635 .then(({ session, generatedPages }) => {
636 if (!active) return
637 const snapshot = session as GenerationSessionSnapshot | null
638 setPresentationTitle(String(snapshot?.title || ''))
639 setSlideSize(trySessionSlideSize(snapshot))
640 setEditorGate(
641 getEditorGate(snapshot)
642 )
643 setPreviewPages(
644 buildPreviewPagesFromGeneratedPages(
645 typeof snapshot?.page_count === 'number' ? snapshot.page_count : 0,
646 generatedPages,
647 lang
648 )
649 )
650 })
651 .catch(() => {})
652 }
653 }
654
655 const unsubscribe = ipc.onGenerateChunk((event) => applyChunk(event))
656
657 const startRun = async (): Promise<void> => {
658 const resolvedModelConfigId = await ensureModelActive(
659 state?.modelConfigId || selectedModelConfigId
660 )
661 if (!active || !resolvedModelConfigId) return
662 const runKey = `${id}:${generationKind}:${state?.retry ? 'retry' : 'generate'}:${state?.rerunToken ?? 'initial'}:${resolvedModelConfigId}`
663 if (startedSessionRef.current === runKey) return
664 startedSessionRef.current = runKey
665 setStatus('running')
666 setError(null)
667 terminalStatusRef.current = null
668 if (import.meta.env.DEV) {
669 console.info('[generate:start] request', {
670 sessionId: id,
671 generationKind,
672 retry: Boolean(state?.retry),
673 hasInitialPrompt: Boolean(initialPrompt),
674 modelConfigId: resolvedModelConfigId
675 })
676 }
677 const request = state?.retry
678 ? generationKind === 'template'
679 ? ipc.startTemplateGenerate({
680 sessionId: id,
681 modelConfigId: resolvedModelConfigId,
682 userMessage: state.initialPrompt?.trim() || '',
683 type: 'deck',
684 retry: true
685 })
686 : ipc.retryFailedPages({
687 sessionId: id,
688 modelConfigId: resolvedModelConfigId,
689 userMessage: state.initialPrompt?.trim() || undefined,
690 failedRunId: state.failedRunId
691 })
692 : generationKind === 'template'
693 ? ipc.startTemplateGenerate({
694 sessionId: id,
695 modelConfigId: resolvedModelConfigId,
696 userMessage: initialPrompt,
697 type: 'deck'
698 })
699 : ipc.startGenerate({
700 sessionId: id,
701 modelConfigId: resolvedModelConfigId,
702 userMessage: initialPrompt,
703 type: 'deck',
704 animationPreferences: state?.animationPreferences || undefined
705 })
706 void request
707 .then((result) => {
708 if (result?.runId) {
709 activeRunIdRef.current = result.runId
710 }
711 if (result?.alreadyRunning) {
712 appendEvent(t('generating.stillRunning'), new Date().toISOString())
713 return
714 }
715 if (import.meta.env.DEV) {
716 console.info('[generate:start] promise resolved', { sessionId: id })
717 }
718 if (!active || terminalStatusRef.current) return
719 if (result?.queued) {
720 setStatus('queued')
721 appendEvent(t('generating.queued'), new Date().toISOString())
722 return
723 }
724 appendEvent(t('generating.started'), new Date().toISOString())
725 })
726 .catch((e) => {
727 if (import.meta.env.DEV) {
728 console.error('[generate:start] promise rejected', {
729 sessionId: id,
730 message: e instanceof Error ? e.message : String(e)
731 })
732 }
733 if (!active) return
734 const rawMessage = e instanceof Error ? e.message : t('generating.failed')
735 const message = friendlyFailureMessage(rawMessage, lang)
736 appendEvent(t('generating.failedRetryOrBack'), new Date().toISOString())
737 setStatus('failed')
738 setError(message)
739 void ipc
740 .getSession(id)
741 .then(({ session, generatedPages }) => {
742 if (!active) return
743 const snapshot = session as GenerationSessionSnapshot | null
744 setPresentationTitle(String(snapshot?.title || ''))
745 setSlideSize(trySessionSlideSize(snapshot))
746 setEditorGate(
747 getEditorGate(snapshot)
748 )
749 setPreviewPages(
750 buildPreviewPagesFromGeneratedPages(
751 typeof snapshot?.page_count === 'number' ? snapshot.page_count : 0,
752 generatedPages,
753 lang
754 )
755 )
756 })
757 .catch(() => {})
758 })
759 }
760
761 void Promise.all([ipc.getSession(id), ipc.getGenerateState(id).catch(() => null)])
762 .then(([sessionResult, runState]) => {
763 if (!active) return
764 const { session, generatedPages } = sessionResult
765 const snapshot = (session || {}) as GenerationSessionSnapshot
766 const currentStatus = snapshot.status || 'active'
767 const snapshotGate = getEditorGate(snapshot)
768 setPresentationTitle(String(snapshot.title || ''))
769 setSlideSize(trySessionSlideSize(snapshot))
770 setEditorGate(snapshotGate)
771 if (typeof snapshot.page_count === 'number' && snapshot.page_count > 0) {
772 setTotalPages(Math.floor(snapshot.page_count))
773 }
774 setPreviewPages(
775 buildPreviewPagesFromGeneratedPages(
776 typeof snapshot.page_count === 'number' ? snapshot.page_count : 0,
777 generatedPages,
778 lang
779 )
780 )
781
782 const hasManualStartIntent = Boolean(
783 state?.retry ||
784 explicitRerun ||
785 (state?.initialPrompt && state.initialPrompt.trim().length > 0)
786 )
787
788 if (runState) {
789 const shouldHydrateFromSnapshot = !hasManualStartIntent || runState.hasActiveRun
790
791 if (runState.hasActiveRun && runState.runId) {
792 activeRunIdRef.current = runState.runId
793 }
794 if (
795 shouldHydrateFromSnapshot &&
796 typeof runState.totalPages === 'number' &&
797 runState.totalPages > 0
798 ) {
799 setTotalPages((prev) => Math.max(prev, Math.floor(runState.totalPages)))
800 }
801 if (
802 shouldHydrateFromSnapshot &&
803 typeof runState.progress === 'number' &&
804 runState.progress > 0
805 ) {
806 const safeProgress =
807 runState.status === 'completed'
808 ? Math.min(100, Math.floor(runState.progress))
809 : Math.min(90, Math.floor(runState.progress))
810 setProgress((prev) => Math.max(prev, safeProgress))
811 }
812 if (
813 shouldHydrateFromSnapshot &&
814 (runState.status === 'failed' || runState.status === 'cancelled') &&
815 runState.error
816 ) {
817 setError(friendlyFailureMessage(runState.error, lang))
818 }
819 if (
820 shouldHydrateFromSnapshot &&
821 Array.isArray(runState.events) &&
822 runState.events.length > 0
823 ) {
824 for (const event of runState.events) {
825 applyChunk(event, { replay: true })
826 }
827 }
828 if (runState.status === 'completed' && !state?.retry && !explicitRerun) {
829 navigate(`/sessions/${id}`, { replace: true })
830 return
831 }
832 if (
833 (runState.status === 'failed' || runState.status === 'cancelled') &&
834 !state?.retry &&
835 !explicitRerun
836 ) {
837 setStatus(runState.status)
838 setError(
839 runState.error
840 ? friendlyFailureMessage(runState.error, lang)
841 : t('generating.previousFailed')
842 )
843 appendEvent(t('generating.keptFailed'), new Date().toISOString())
844 return
845 }
846 if (runState.hasActiveRun) {
847 setStatus(runState.status === 'queued' ? 'queued' : 'running')
848 appendEvent(
849 runState.status === 'queued' ? t('generating.queued') : t('generating.resumed'),
850 new Date().toISOString()
851 )
852 return
853 }
854 }
855
856 const fullyGenerated = isSessionFullyGenerated(snapshotGate)
857
858 if (fullyGenerated && !state?.retry && !explicitRerun) {
859 navigate(`/sessions/${id}`, { replace: true })
860 return
861 }
862 if (currentStatus === 'completed' && !state?.retry && !explicitRerun) {
863 navigate(`/sessions/${id}`, { replace: true })
864 return
865 }
866 if (!fullyGenerated && !hasManualStartIntent) {
867 setStatus('failed')
868 if (snapshotGate.generatedCount > 0) {
869 setError(
870 t('generating.incompleteSome', {
871 generated: snapshotGate.generatedCount,
872 total: snapshotGate.totalCount
873 })
874 )
875 appendEvent(t('generating.continueRemainingEvent'), new Date().toISOString())
876 } else {
877 setError(t('generating.incompleteNone', { total: snapshotGate.totalCount }))
878 appendEvent(t('generating.noValidPagesEvent'), new Date().toISOString())
879 }
880 return
881 }
882 if (
883 currentStatus === 'failed' &&
884 !state?.retry &&
885 !explicitRerun &&
886 !hasManualStartIntent
887 ) {
888 setStatus('failed')
889 setError(t('generating.previousFailed'))
890 appendEvent(t('generating.keptFailed'), new Date().toISOString())
891 return
892 }
893 void startRun()
894 })
895 .catch(() => {
896 void startRun()
897 })
898
899 return () => {
900 active = false
901 unsubscribe?.()
902 }
903 }, [
904 id,
905 navigate,
906 location.key,
907 generationKind,
908 state?.initialPrompt,
909 state?.modelConfigId,
910 state?.retry,
911 state?.rerunToken,
912 state?.animationPreferences,
913 state?.failedRunId,
914 ensureModelActive,
915 selectedModelConfigId,
916 lang,
917 t
918 ])
919
920 useEffect(() => {
921 if (status !== 'queued' && status !== 'running') {
922 setCancelPending(false)
923 }
924 }, [status])
925
926 const displayProgress = Math.max(0, Math.min(100, Math.round(progress)))
927 const fullyGenerated = isSessionFullyGenerated(editorGate)
928 const hasGeneratedPages = editorGate.generatedCount > 0
929 const canEnterEditor = getEditorGate(
930 { page_count: editorGate.totalCount, generatedCount: editorGate.generatedCount },
931 0.68
932 ).canEdit
933 const showProgressEditorShortcut = canEnterEditor && !state?.retry
934 const completedPreviewCount = previewPages.filter((page) => page.status === 'completed').length
935 const failedPreviewLabels = previewPages
936 .filter((page) => page.status === 'failed')
937 .map((page) => `P${page.pageNumber}`)
938 const failedPageSummary =
939 failedPreviewLabels.length > 0
940 ? friendlyText(
941 lang,
942 `${failedPreviewLabels.join('、')} 失败`,
943 `${failedPreviewLabels.join(', ')} failed`
944 )
945 : null
946 const failureMessage =
947 failedPageSummary ||
948 (error && /部分页面生成失败|some pages failed|pages failed/i.test(error)
949 ? t('generating.failedRetry')
950 : error || t('generating.failedRetry'))
951 const canContinueRemaining = hasGeneratedPages && !fullyGenerated
952 const displayedTotalPages = Math.max(totalPages, previewPages.length)
953 const generationStages = [
954 'preflight',
955 'planning',
956 'rendering',
957 'validation'
958 ] as const satisfies readonly GenerationStageKey[]
959 const stageLabels: Record<GenerationStageKey, string> = {
960 preflight: t('generating.stages.preflight'),
961 planning: t('generating.stages.planning'),
962 rendering: t('generating.stages.rendering'),
963 validation: t('generating.stages.validation')
964 }
965 const handleContinueRemaining = (modelConfigId: string): void => {
966 if (!id) return
967 navigate(generatingPath, {
968 replace: true,
969 state: {
970 modelConfigId,
971 retry: true,
972 failedRunId: activeRunIdRef.current || undefined,
973 rerunToken: Date.now()
974 }
975 })
976 }
977 const handleRegenerate = (modelConfigId: string): void => {
978 if (!id) return
979 navigate(generatingPath, {
980 replace: true,
981 state: {
982 initialPrompt: state?.initialPrompt,
983 modelConfigId,
984 retry: false,
985 animationPreferences: state?.animationPreferences || undefined,
986 rerunToken: Date.now()
987 }
988 })
989 }
990 const handleCancelGeneration = (): void => {
991 if (!id || cancelPending || (status !== 'queued' && status !== 'running')) return
992 setCancelPending(true)
993 void ipc
994 .cancelGenerate(id)
995 .then((result) => {
996 if (!result?.success) {
997 setCancelPending(false)
998 }
999 })
1000 .catch(() => {
1001 setCancelPending(false)
1002 })
1003 }
1004
1005 return (
1006 <div className="relative flex h-full flex-col overflow-hidden bg-[#edf3e8]">
1007 <style>{`
1008 @keyframes gen-shimmer-move { 0% { background-position: 0% 50%; } 100% { background-position: 100% 50%; } }
1009 @keyframes gen-page-rise { from { opacity: 0; transform: translateY(14px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
1010 `}</style>
1011
1012 <div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
1013 <video
1014 src={videoSrc}
1015 controls={false}
1016 autoPlay
1017 loop
1018 muted
1019 playsInline
1020 className="h-full w-full object-cover object-bottom opacity-70"
1021 />
1022 <div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(237,243,232,0.86)_0%,rgba(237,243,232,0.72)_42%,rgba(237,243,232,0.48)_100%)]" />
1023 </div>
1024
1025 <div className="app-drag-region app-titlebar relative z-20 flex items-center bg-[#f7f0e2]/90 backdrop-blur-sm" />
1026
1027 <div className="app-no-drag relative z-10 flex min-h-0 flex-1 flex-col gap-4 px-5 pb-5 pt-4 lg:flex-row">
1028 <GenerationSidebar
1029 title={presentationTitle || t('generating.title')}
1030 backHomeLabel={t('generating.backToSessions')}
1031 logTitle={friendlyText(lang, '生成日志', 'Generation log')}
1032 pageCountLabel={`${completedPreviewCount}/${displayedTotalPages}`}
1033 growingLabel={t('generating.growing')}
1034 failedLabel={t('generating.failed')}
1035 events={events}
1036 status={status}
1037 onBackHome={() => navigate('/sessions')}
1038 viewportRef={eventsContainerRef}
1039 onViewportScroll={(e) => {
1040 const el = e.currentTarget
1041 stickToBottomRef.current = isNearLogBottom(el)
1042 if (stickToBottomRef.current) {
1043 shouldAutoScrollRef.current = true
1044 }
1045 }}
1046 />
1047
1048 <main className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
1049 <GenerationStatusPanel
1050 status={status}
1051 progress={displayProgress}
1052 stages={generationStages}
1053 stageLabels={stageLabels}
1054 currentStage={currentStage}
1055 completedPageCount={completedPreviewCount}
1056 totalPages={displayedTotalPages}
1057 error={failureMessage}
1058 interruptedLabel={t('generating.interrupted')}
1059 enterEditorLabel={t('generating.enterEditor')}
1060 continueRemainingLabel={t('generating.continueRemaining')}
1061 regenerateLabel={t('generating.regenerate')}
1062 cancelLabel={t('generating.cancelGeneration')}
1063 isCancelling={cancelPending}
1064 hasGeneratedPages={canContinueRemaining}
1065 canEnterEditor={canEnterEditor}
1066 showEditorShortcut={showProgressEditorShortcut}
1067 modelAction={modelAction}
1068 onEnterEditor={() => navigate(`/sessions/${id}`)}
1069 onContinueRemaining={handleContinueRemaining}
1070 onRegenerate={handleRegenerate}
1071 onCancel={handleCancelGeneration}
1072 />
1073
1074 <GenerationPreviewGrid pages={previewPages} slideSize={slideSize} />
1075 </main>
1076 </div>
1077 </div>
1078 )
1079 }
1080
1080 lines Plain Text