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