返回 oh-my-ppt
thinking-detail.tsx
根目录 / src / renderer / src / pages / thinking-detail.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
2 import { useNavigate } from 'react-router-dom'
3 import { useThinkingStore } from '../store/thinkingStore'
4 import { useSessionStore, useToastStore } from '../store'
5 import { ipc } from '@renderer/lib/ipc'
6 import { ThinkingChat } from '../components/thinking/ThinkingChat'
7 import { ThinkingPageCards } from '../components/thinking/ThinkingPageCards'
8 import { GenerationConfirmDialog } from '../components/thinking/GenerationConfirmDialog'
9 import {
10 AlertDialog,
11 AlertDialogAction,
12 AlertDialogCancel,
13 AlertDialogContent,
14 AlertDialogDescription,
15 AlertDialogTitle
16 } from '../components/ui/AlertDialog'
17 import { Popover, PopoverContent, PopoverTrigger } from '../components/ui/Popover'
18 import { useLang, useT, type I18nKey } from '../i18n'
19 import { Clock3, FileText, History, Loader2, Plus, Trash2 } from 'lucide-react'
20 import type { SourceDocumentPlan } from '@shared/generation'
21 import type { SlideSizePresetId } from '@shared/slide-size'
22 import type {
23 ThinkingChatMessage,
24 ThinkingSource,
25 ThinkingPrepareGenerationResult,
26 ThinkingStage,
27 ThinkingWorkspaceListItem
28 } from '@shared/thinking'
29
30 const buildWelcomeMessage = (
31 t: (key: 'thinking.welcomeMessage') => string
32 ): ThinkingChatMessage => ({
33 role: 'assistant',
34 content: t('thinking.welcomeMessage'),
35 timestamp: Date.now()
36 })
37
38 const buildThinkingGenerationPrompt = (args: {
39 topic: string
40 pageCount: number
41 referenceDocumentPath: string
42 }): string =>
43 [
44 `Create a ${args.pageCount}-slide presentation about "${args.topic}" from the finalized thinking document.`,
45 `Use the attached source document at ${args.referenceDocumentPath} as the authoritative thinking brief.`,
46 'Follow the prepared page outline exactly. Each page outline is derived from the matching "## Page N: ..." section.',
47 'Before writing a page, inspect only the relevant source range for that page instead of reading the full document.',
48 'If the attached reference document includes image source notes, use the listed ./images/... public paths when relevant.',
49 'Determine the presentation content language from the thinking document and source notes; do not infer it from the application UI language.'
50 ].join('\n')
51
52 const stageKeyByStage: Record<ThinkingStage, I18nKey> = {
53 collect: 'thinking.stageCollect',
54 outline: 'thinking.stageOutline',
55 draft: 'thinking.stageDraft',
56 refine: 'thinking.stageRefine',
57 ready: 'thinking.stageReady'
58 }
59
60 const contextSectionOrder = [
61 'Topic',
62 'User Intent',
63 'Confirmed Decisions',
64 'Open Questions',
65 'Source Notes',
66 'Latest Direction'
67 ]
68
69 function readMarkdownSection(markdown: string, heading: string): string {
70 const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
71 const match = markdown.match(
72 new RegExp(`^##\\s*${escaped}\\s*\\n([\\s\\S]*?)(?=^##\\s|\\s*$)`, 'm')
73 )
74 return match?.[1]?.trim() || ''
75 }
76
77 function buildContextMessage(
78 contextMd: string,
79 t: (key: I18nKey) => string
80 ): ThinkingChatMessage | null {
81 const parts = contextSectionOrder
82 .map((heading) => {
83 const content = readMarkdownSection(contextMd, heading)
84 return content ? `**${heading}**\n${content}` : ''
85 })
86 .filter(Boolean)
87
88 if (parts.length === 0) return null
89
90 return {
91 role: 'assistant',
92 content: [`**${t('thinking.restoredContextTitle')}**`, ...parts].join('\n\n'),
93 timestamp: Date.now()
94 }
95 }
96
97 export function ThinkingDetailPage(): ReactElement {
98 const t = useT()
99 const { lang } = useLang()
100 const navigate = useNavigate()
101 const { success, error: toastError } = useToastStore()
102 const { createSession } = useSessionStore()
103 const {
104 thinkingId,
105 thinkingMd,
106 contextMd,
107 stage,
108 messages,
109 sources,
110 loading,
111 thinkingSteps,
112 animatingText,
113 createWorkspace,
114 loadWorkspace,
115 loadLatestWorkspace,
116 reset,
117 sendMessage
118 } = useThinkingStore()
119
120 const [confirmOpen, setConfirmOpen] = useState(false)
121 const [prepared, setPrepared] = useState<ThinkingPrepareGenerationResult | null>(null)
122 const [generating, setGenerating] = useState(false)
123 const [pendingSources, setPendingSources] = useState<ThinkingSource[]>([])
124 const [historyItems, setHistoryItems] = useState<ThinkingWorkspaceListItem[]>([])
125 const [historyLoading, setHistoryLoading] = useState(false)
126 const [historyOpen, setHistoryOpen] = useState(false)
127 const [creatingWorkspace, setCreatingWorkspace] = useState(false)
128 const [deleteTarget, setDeleteTarget] = useState<ThinkingWorkspaceListItem | null>(null)
129 const [deletingThinkingId, setDeletingThinkingId] = useState<string | null>(null)
130
131 const refreshHistoryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
132 const refreshHistory = useCallback(async (): Promise<void> => {
133 if (refreshHistoryTimerRef.current) {
134 clearTimeout(refreshHistoryTimerRef.current)
135 refreshHistoryTimerRef.current = null
136 }
137 setHistoryLoading(true)
138 try {
139 const items = await ipc.thinkingListWorkspaces({ limit: 50 })
140 setHistoryItems(items)
141 } catch (err) {
142 toastError(t('thinking.historyLoadFailed'), {
143 description: err instanceof Error ? err.message : t('common.retryLater')
144 })
145 } finally {
146 setHistoryLoading(false)
147 }
148 }, [t, toastError])
149 const debouncedRefreshHistory = useCallback(() => {
150 if (refreshHistoryTimerRef.current) clearTimeout(refreshHistoryTimerRef.current)
151 refreshHistoryTimerRef.current = setTimeout(() => void refreshHistory(), 300)
152 }, [refreshHistory])
153
154 useEffect(() => {
155 if (!thinkingId && !loading) {
156 void loadLatestWorkspace()
157 }
158 setPendingSources([])
159 }, [thinkingId, loading, loadLatestWorkspace])
160
161 useEffect(() => {
162 void refreshHistory()
163 }, [refreshHistory])
164
165 // The thinking store owns stream state globally; this page only refreshes history metadata.
166 useEffect(() => {
167 const unsubscribeEnd = ipc.onThinkingStreamEnd((payload) => {
168 if (payload.thinkingId === thinkingId) {
169 debouncedRefreshHistory()
170 }
171 })
172 return () => {
173 unsubscribeEnd()
174 }
175 }, [thinkingId, debouncedRefreshHistory])
176
177 const handleCreateWorkspace = async (): Promise<void> => {
178 if (creatingWorkspace) return
179 setCreatingWorkspace(true)
180 try {
181 await createWorkspace()
182 await refreshHistory()
183 setHistoryOpen(false)
184 } catch (err) {
185 toastError(t('thinking.createFailed'), {
186 description: err instanceof Error ? err.message : t('common.retryLater')
187 })
188 } finally {
189 setCreatingWorkspace(false)
190 }
191 }
192
193 const handleDeleteWorkspace = async (): Promise<void> => {
194 if (!deleteTarget || deletingThinkingId) return
195 const targetId = deleteTarget.thinkingId
196 setDeletingThinkingId(targetId)
197 try {
198 await ipc.thinkingDeleteWorkspace(targetId)
199 success(t('thinking.deleteWorkspaceDone'))
200 setDeleteTarget(null)
201 if (targetId === thinkingId) {
202 setHistoryOpen(false)
203 reset()
204 }
205 await refreshHistory()
206 } catch (err) {
207 toastError(t('thinking.deleteWorkspaceFailed'), {
208 description: err instanceof Error ? err.message : t('common.retryLater')
209 })
210 } finally {
211 setDeletingThinkingId(null)
212 }
213 }
214
215 const handleSend = (content: string, modelConfigId: string): void => {
216 const attachments = pendingSources.length > 0 ? pendingSources : undefined
217 setPendingSources([])
218 void sendMessage(content, attachments, modelConfigId)
219 }
220
221 const handleSourcesUploaded = (newSources: ThinkingSource[]): void => {
222 useThinkingStore.setState((state) => ({
223 sources: [...state.sources, ...newSources]
224 }))
225 setPendingSources((prev) => [...prev, ...newSources])
226 }
227
228 const handleSourceRemoved = (sourceId: string): void => {
229 useThinkingStore.setState((state) => ({
230 sources: state.sources.filter((source) => source.id !== sourceId)
231 }))
232 setPendingSources((prev) => prev.filter((source) => source.id !== sourceId))
233 }
234
235 const handleConfirmGenerate = async (): Promise<void> => {
236 if (!thinkingId) return
237 try {
238 const result = await ipc.thinkingPrepareGeneration({ thinkingId })
239 setPrepared(result)
240 setConfirmOpen(true)
241 } catch (err) {
242 toastError(t('thinking.prepareFailed'), {
243 description: err instanceof Error ? err.message : t('common.retryLater')
244 })
245 }
246 }
247
248 const handleRevealWorkspace = async (): Promise<void> => {
249 if (!thinkingId) return
250 try {
251 await ipc.thinkingRevealWorkspace(thinkingId)
252 } catch (err) {
253 toastError(t('thinking.revealWorkspace'), {
254 description: err instanceof Error ? err.message : t('common.retryLater')
255 })
256 }
257 }
258
259 const handleGenerationConfirm = async (params: {
260 topic: string
261 pageCount: number
262 styleId: string
263 fontSelection: import('@shared/generation').FontSelection
264 slideSizeId: SlideSizePresetId
265 referenceDocumentPath: string
266 sourcePlan?: SourceDocumentPlan
267 modelConfigId?: string
268 visualEnabled: boolean
269 imageModelConfigId?: string
270 }): Promise<void> => {
271 if (generating || !prepared) return
272 setGenerating(true)
273 try {
274 const sessionId = await createSession({
275 topic: params.topic,
276 styleId: params.styleId,
277 modelConfigId: params.modelConfigId,
278 pageCount: params.pageCount,
279 slideSizeId: params.slideSizeId,
280 referenceDocumentPath: params.referenceDocumentPath,
281 fontSelection: params.fontSelection,
282 sourcePlan: params.sourcePlan,
283 visualEnabled: params.visualEnabled,
284 imageModelConfigId: params.imageModelConfigId
285 })
286 success(t('home.sessionCreated'), {
287 description: t('home.generationStarted'),
288 duration: 1000
289 })
290 navigate(`/sessions/${sessionId}/generating`, {
291 state: {
292 modelConfigId: params.modelConfigId,
293 initialPrompt: buildThinkingGenerationPrompt({
294 topic: params.topic,
295 pageCount: params.pageCount,
296 referenceDocumentPath: params.referenceDocumentPath
297 })
298 }
299 })
300 } catch (err) {
301 toastError(t('home.sessionCreateFailed'), {
302 description: err instanceof Error ? err.message : t('common.retryLater')
303 })
304 } finally {
305 setGenerating(false)
306 }
307 }
308
309 const restoredContextMessage = useMemo(() => buildContextMessage(contextMd, t), [contextMd, t])
310
311 const displayMessages: ThinkingChatMessage[] = useMemo(() => {
312 if (messages.length > 0) {
313 const shouldAppendContext =
314 restoredContextMessage && !loading && !messages.some((m) => m.role === 'assistant')
315 return shouldAppendContext ? [...messages, restoredContextMessage] : messages
316 }
317 if (restoredContextMessage) return [restoredContextMessage]
318 return [buildWelcomeMessage(t)]
319 }, [messages, restoredContextMessage, loading, t])
320 const showOutlinePanel = Boolean(thinkingId) && stage !== 'collect'
321 const dateFormatter = new Intl.DateTimeFormat(lang === 'zh' ? 'zh-CN' : 'en-US', {
322 month: '2-digit',
323 day: '2-digit',
324 hour: '2-digit',
325 minute: '2-digit'
326 })
327
328 return (
329 <div className="relative flex h-full min-h-0 flex-col bg-[#f5f1e8] text-foreground">
330 <div className="relative z-50 shrink-0 border-b border-[#e0d8c8] bg-[#f5f1e8]/90 px-6 py-4 backdrop-blur">
331 <div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
332 <div className="min-w-0">
333 <p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
334 {t('thinking.eyebrow')}
335 </p>
336 <h1 className="organic-serif mt-2 flex min-w-0 items-baseline gap-3 text-[32px] font-semibold leading-none text-[#3e4a32]">
337 <span className="truncate">{t('thinking.title')}</span>
338 {thinkingId && (
339 <button
340 type="button"
341 className="min-w-0 rounded-full px-2 py-0.5 font-mono text-[11px] font-normal leading-none text-[#7a806c] transition-colors hover:bg-[#d4e4c1] hover:text-[#3e4a32]"
342 onClick={() => void handleRevealWorkspace()}
343 title={t('thinking.revealWorkspace')}
344 >
345 {thinkingId}
346 </button>
347 )}
348 </h1>
349 <p className="mt-2 max-w-3xl text-[12px] leading-relaxed text-muted-foreground">
350 {t('thinking.description')}
351 </p>
352 </div>
353 <div className="relative flex shrink-0 items-center gap-2">
354 <Popover open={historyOpen} onOpenChange={setHistoryOpen}>
355 <PopoverTrigger asChild>
356 <button
357 type="button"
358 className="inline-flex h-10 items-center justify-center gap-2 rounded-full border border-[#d9cfbd] bg-[#fffdf8]/95 px-4 text-[13px] font-semibold text-[#3e4a32] shadow-[0_10px_22px_rgba(86,73,54,0.12)] transition-colors hover:bg-[#f5f1e8]"
359 >
360 {historyLoading ? (
361 <Loader2 className="h-4 w-4 animate-spin text-[#7a806c]" />
362 ) : (
363 <History className="h-4 w-4 text-[#5d6b4d]" />
364 )}
365 {t('thinking.historyTitle')}
366 </button>
367 </PopoverTrigger>
368 <PopoverContent
369 align="end"
370 sideOffset={8}
371 className="z-[60] flex w-[320px] flex-col overflow-hidden rounded-[1.5rem] border border-[#e0d8c8] bg-[#fffdf8]/98 p-0 shadow-[0_22px_54px_rgba(86,73,54,0.22)] backdrop-blur"
372 style={{ height: 'min(420px, calc(100vh - 160px))' }}
373 >
374 <div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#eee4d4] px-4 py-3">
375 <div className="flex min-w-0 items-center gap-2">
376 <History className="h-4 w-4 shrink-0 text-[#5d6b4d]" />
377 <h2 className="truncate text-[13px] font-semibold text-[#3e4a32]">
378 {t('thinking.historyTitle')}
379 </h2>
380 </div>
381 {historyLoading && (
382 <Loader2 className="h-4 w-4 shrink-0 animate-spin text-[#7a806c]" />
383 )}
384 </div>
385 <div className="min-h-0 flex-1 overflow-y-auto p-2.5">
386 {historyItems.length > 0 ? (
387 <div className="flex flex-col gap-2">
388 {historyItems.map((item) => {
389 const active = item.thinkingId === thinkingId
390 const deleteDisabled = active && loading
391 return (
392 <div
393 key={item.thinkingId}
394 className={`group flex w-full items-start gap-1.5 rounded-[1.25rem] border p-2 transition-colors ${
395 active
396 ? 'border-[#9eb88a] bg-[#d4e4c1] text-[#2f3b28]'
397 : 'border-transparent bg-[#f5f1e8]/76 text-[#3e4a32] hover:border-[#d9cfbd] hover:bg-[#efe7d8]'
398 }`}
399 >
400 <button
401 type="button"
402 onClick={() => {
403 setHistoryOpen(false)
404 setPendingSources([])
405 void loadWorkspace(item.thinkingId)
406 }}
407 className="min-w-0 flex-1 rounded-[1rem] p-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#8fbc8f]"
408 >
409 <div className="flex min-w-0 items-start gap-2.5">
410 <FileText className="mt-0.5 h-4 w-4 shrink-0 text-[#7a806c]" />
411 <div className="min-w-0 flex-1">
412 <div className="truncate text-[13px] font-semibold">
413 {item.topic || t('thinking.untitledWorkspace')}
414 </div>
415 <div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-[#7a806c]">
416 <Clock3 className="h-3 w-3 shrink-0" />
417 <span className="truncate">
418 {dateFormatter.format(item.updatedAt)}
419 </span>
420 </div>
421 </div>
422 </div>
423 <div className="mt-2 inline-flex rounded-full bg-[#fffdf8]/72 px-2 py-0.5 text-[10px] font-semibold text-[#5d6b4d]">
424 {t(stageKeyByStage[item.stage])}
425 </div>
426 </button>
427 <button
428 type="button"
429 disabled={deleteDisabled || deletingThinkingId === item.thinkingId}
430 onClick={(event) => {
431 event.stopPropagation()
432 setDeleteTarget(item)
433 }}
434 className="mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#9a6b58] opacity-75 transition-colors hover:bg-[#ead4c8] hover:text-[#7f3b2e] disabled:cursor-not-allowed disabled:opacity-35"
435 title={t('thinking.deleteWorkspace')}
436 >
437 {deletingThinkingId === item.thinkingId ? (
438 <Loader2 className="h-3.5 w-3.5 animate-spin" />
439 ) : (
440 <Trash2 className="h-3.5 w-3.5" />
441 )}
442 </button>
443 </div>
444 )
445 })}
446 </div>
447 ) : (
448 <div className="flex h-full min-h-[180px] flex-col items-center justify-center px-4 text-center">
449 <p className="text-[13px] font-semibold text-[#3e4a32]">
450 {t('thinking.historyEmptyTitle')}
451 </p>
452 <p className="mt-2 text-[12px] leading-relaxed text-[#7a806c]">
453 {t('thinking.historyEmptyDescription')}
454 </p>
455 </div>
456 )}
457 </div>
458 </PopoverContent>
459 </Popover>
460 <button
461 type="button"
462 onClick={() => void handleCreateWorkspace()}
463 disabled={creatingWorkspace}
464 className="inline-flex h-10 items-center justify-center gap-2 rounded-full bg-[#3e4a32] px-4 text-[13px] font-semibold text-white shadow-[0_10px_22px_rgba(62,74,50,0.18)] transition-colors hover:bg-[#5d6b4d] disabled:cursor-not-allowed disabled:opacity-65"
465 >
466 {creatingWorkspace ? (
467 <Loader2 className="h-4 w-4 animate-spin" />
468 ) : (
469 <Plus className="h-4 w-4" />
470 )}
471 {t('thinking.newWorkspace')}
472 </button>
473 </div>
474 </div>
475 </div>
476
477 <div
478 className={`relative grid min-h-0 flex-1 gap-4 p-4 ${
479 showOutlinePanel ? 'lg:grid-cols-[minmax(0,1fr)_360px]' : 'grid-cols-1'
480 }`}
481 >
482 <section className="min-h-0 overflow-hidden rounded-[2rem] border border-[#e0d8c8] bg-[#fffdf8] shadow-[0_14px_34px_rgba(86,73,54,0.12)]">
483 {thinkingId ? (
484 <ThinkingChat
485 thinkingId={thinkingId}
486 messages={displayMessages}
487 sources={sources}
488 pendingSources={pendingSources}
489 loading={loading}
490 thinkingSteps={thinkingSteps}
491 animatingText={animatingText}
492 onSend={handleSend}
493 onSourcesUploaded={handleSourcesUploaded}
494 onSourceRemoved={handleSourceRemoved}
495 />
496 ) : (
497 <div className="flex h-full min-h-[360px] flex-col items-center justify-center px-8 text-center">
498 <div className="flex h-14 w-14 items-center justify-center rounded-[10%_90%_16%_84%/78%_22%_78%_22%] bg-[#d4e4c1] text-[#3e4a32]">
499 <History className="h-6 w-6" />
500 </div>
501 <h2 className="organic-serif mt-5 text-[28px] font-semibold leading-none text-[#3e4a32]">
502 {t('thinking.emptyWorkspaceTitle')}
503 </h2>
504 <p className="mt-3 max-w-md text-[13px] leading-relaxed text-[#5d6b4d]">
505 {t('thinking.emptyWorkspaceDescription')}
506 </p>
507 <button
508 type="button"
509 onClick={() => void handleCreateWorkspace()}
510 disabled={creatingWorkspace}
511 className="mt-6 inline-flex h-11 items-center justify-center gap-2 rounded-full bg-[#3e4a32] px-5 text-[13px] font-semibold text-white shadow-[0_10px_22px_rgba(62,74,50,0.18)] transition-colors hover:bg-[#5d6b4d] disabled:cursor-not-allowed disabled:opacity-65"
512 >
513 {creatingWorkspace ? (
514 <Loader2 className="h-4 w-4 animate-spin" />
515 ) : (
516 <Plus className="h-4 w-4" />
517 )}
518 {t('thinking.newWorkspace')}
519 </button>
520 </div>
521 )}
522 </section>
523 {showOutlinePanel && (
524 <aside className="min-h-0 overflow-hidden rounded-[2rem] border border-[#c8d6ba] bg-[#d4e4c1] shadow-[0_14px_34px_rgba(86,73,54,0.12)]">
525 <ThinkingPageCards
526 thinkingMd={thinkingMd}
527 stage={stage}
528 onConfirmGenerate={() => void handleConfirmGenerate()}
529 loading={loading || generating}
530 />
531 </aside>
532 )}
533 </div>
534
535 <GenerationConfirmDialog
536 open={confirmOpen}
537 onOpenChange={setConfirmOpen}
538 prepared={prepared}
539 onConfirm={(params) => void handleGenerationConfirm(params)}
540 />
541
542 <AlertDialog
543 open={Boolean(deleteTarget)}
544 onOpenChange={(open) => {
545 if (!open && !deletingThinkingId) setDeleteTarget(null)
546 }}
547 >
548 <AlertDialogContent>
549 <AlertDialogTitle>{t('thinking.deleteWorkspaceTitle')}</AlertDialogTitle>
550 <AlertDialogDescription>
551 {t('thinking.deleteWorkspaceDescription', {
552 title: deleteTarget?.topic || t('thinking.untitledWorkspace')
553 })}
554 </AlertDialogDescription>
555 <div className="flex justify-end gap-2">
556 <AlertDialogCancel disabled={Boolean(deletingThinkingId)}>
557 {t('common.cancel')}
558 </AlertDialogCancel>
559 <AlertDialogAction
560 disabled={Boolean(deletingThinkingId)}
561 onClick={(event) => {
562 event.preventDefault()
563 void handleDeleteWorkspace()
564 }}
565 className="bg-[#8f3f31] text-white hover:bg-[#743126] disabled:cursor-not-allowed disabled:opacity-65"
566 >
567 {deletingThinkingId ? (
568 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
569 ) : (
570 <Trash2 className="mr-2 h-4 w-4" />
571 )}
572 {t('common.delete')}
573 </AlertDialogAction>
574 </div>
575 </AlertDialogContent>
576 </AlertDialog>
577 </div>
578 )
579 }
580
580 lines Plain Text