返回 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 }): Promise<void> => {
269 if (generating || !prepared) return
270 setGenerating(true)
271 try {
272 const sessionId = await createSession({
273 topic: params.topic,
274 styleId: params.styleId,
275 modelConfigId: params.modelConfigId,
276 pageCount: params.pageCount,
277 slideSizeId: params.slideSizeId,
278 referenceDocumentPath: params.referenceDocumentPath,
279 fontSelection: params.fontSelection,
280 sourcePlan: params.sourcePlan
281 })
282 success(t('home.sessionCreated'), {
283 description: t('home.generationStarted'),
284 duration: 1000
285 })
286 navigate(`/sessions/${sessionId}/generating`, {
287 state: {
288 modelConfigId: params.modelConfigId,
289 initialPrompt: buildThinkingGenerationPrompt({
290 topic: params.topic,
291 pageCount: params.pageCount,
292 referenceDocumentPath: params.referenceDocumentPath
293 })
294 }
295 })
296 } catch (err) {
297 toastError(t('home.sessionCreateFailed'), {
298 description: err instanceof Error ? err.message : t('common.retryLater')
299 })
300 } finally {
301 setGenerating(false)
302 }
303 }
304
305 const restoredContextMessage = useMemo(() => buildContextMessage(contextMd, t), [contextMd, t])
306
307 const displayMessages: ThinkingChatMessage[] = useMemo(() => {
308 if (messages.length > 0) {
309 const shouldAppendContext =
310 restoredContextMessage && !loading && !messages.some((m) => m.role === 'assistant')
311 return shouldAppendContext ? [...messages, restoredContextMessage] : messages
312 }
313 if (restoredContextMessage) return [restoredContextMessage]
314 return [buildWelcomeMessage(t)]
315 }, [messages, restoredContextMessage, loading, t])
316 const showOutlinePanel = Boolean(thinkingId) && stage !== 'collect'
317 const dateFormatter = new Intl.DateTimeFormat(lang === 'zh' ? 'zh-CN' : 'en-US', {
318 month: '2-digit',
319 day: '2-digit',
320 hour: '2-digit',
321 minute: '2-digit'
322 })
323
324 return (
325 <div className="relative flex h-full min-h-0 flex-col bg-[#f5f1e8] text-foreground">
326 <div className="relative z-50 shrink-0 border-b border-[#e0d8c8] bg-[#f5f1e8]/90 px-6 py-4 backdrop-blur">
327 <div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
328 <div className="min-w-0">
329 <p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
330 {t('thinking.eyebrow')}
331 </p>
332 <h1 className="organic-serif mt-2 flex min-w-0 items-baseline gap-3 text-[32px] font-semibold leading-none text-[#3e4a32]">
333 <span className="truncate">{t('thinking.title')}</span>
334 {thinkingId && (
335 <button
336 type="button"
337 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]"
338 onClick={() => void handleRevealWorkspace()}
339 title={t('thinking.revealWorkspace')}
340 >
341 {thinkingId}
342 </button>
343 )}
344 </h1>
345 <p className="mt-2 max-w-3xl text-[12px] leading-relaxed text-muted-foreground">
346 {t('thinking.description')}
347 </p>
348 </div>
349 <div className="relative flex shrink-0 items-center gap-2">
350 <Popover open={historyOpen} onOpenChange={setHistoryOpen}>
351 <PopoverTrigger asChild>
352 <button
353 type="button"
354 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]"
355 >
356 {historyLoading ? (
357 <Loader2 className="h-4 w-4 animate-spin text-[#7a806c]" />
358 ) : (
359 <History className="h-4 w-4 text-[#5d6b4d]" />
360 )}
361 {t('thinking.historyTitle')}
362 </button>
363 </PopoverTrigger>
364 <PopoverContent
365 align="end"
366 sideOffset={8}
367 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"
368 style={{ height: 'min(420px, calc(100vh - 160px))' }}
369 >
370 <div className="flex shrink-0 items-center justify-between gap-3 border-b border-[#eee4d4] px-4 py-3">
371 <div className="flex min-w-0 items-center gap-2">
372 <History className="h-4 w-4 shrink-0 text-[#5d6b4d]" />
373 <h2 className="truncate text-[13px] font-semibold text-[#3e4a32]">
374 {t('thinking.historyTitle')}
375 </h2>
376 </div>
377 {historyLoading && (
378 <Loader2 className="h-4 w-4 shrink-0 animate-spin text-[#7a806c]" />
379 )}
380 </div>
381 <div className="min-h-0 flex-1 overflow-y-auto p-2.5">
382 {historyItems.length > 0 ? (
383 <div className="flex flex-col gap-2">
384 {historyItems.map((item) => {
385 const active = item.thinkingId === thinkingId
386 const deleteDisabled = active && loading
387 return (
388 <div
389 key={item.thinkingId}
390 className={`group flex w-full items-start gap-1.5 rounded-[1.25rem] border p-2 transition-colors ${
391 active
392 ? 'border-[#9eb88a] bg-[#d4e4c1] text-[#2f3b28]'
393 : 'border-transparent bg-[#f5f1e8]/76 text-[#3e4a32] hover:border-[#d9cfbd] hover:bg-[#efe7d8]'
394 }`}
395 >
396 <button
397 type="button"
398 onClick={() => {
399 setHistoryOpen(false)
400 setPendingSources([])
401 void loadWorkspace(item.thinkingId)
402 }}
403 className="min-w-0 flex-1 rounded-[1rem] p-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#8fbc8f]"
404 >
405 <div className="flex min-w-0 items-start gap-2.5">
406 <FileText className="mt-0.5 h-4 w-4 shrink-0 text-[#7a806c]" />
407 <div className="min-w-0 flex-1">
408 <div className="truncate text-[13px] font-semibold">
409 {item.topic || t('thinking.untitledWorkspace')}
410 </div>
411 <div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-[#7a806c]">
412 <Clock3 className="h-3 w-3 shrink-0" />
413 <span className="truncate">
414 {dateFormatter.format(item.updatedAt)}
415 </span>
416 </div>
417 </div>
418 </div>
419 <div className="mt-2 inline-flex rounded-full bg-[#fffdf8]/72 px-2 py-0.5 text-[10px] font-semibold text-[#5d6b4d]">
420 {t(stageKeyByStage[item.stage])}
421 </div>
422 </button>
423 <button
424 type="button"
425 disabled={deleteDisabled || deletingThinkingId === item.thinkingId}
426 onClick={(event) => {
427 event.stopPropagation()
428 setDeleteTarget(item)
429 }}
430 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"
431 title={t('thinking.deleteWorkspace')}
432 >
433 {deletingThinkingId === item.thinkingId ? (
434 <Loader2 className="h-3.5 w-3.5 animate-spin" />
435 ) : (
436 <Trash2 className="h-3.5 w-3.5" />
437 )}
438 </button>
439 </div>
440 )
441 })}
442 </div>
443 ) : (
444 <div className="flex h-full min-h-[180px] flex-col items-center justify-center px-4 text-center">
445 <p className="text-[13px] font-semibold text-[#3e4a32]">
446 {t('thinking.historyEmptyTitle')}
447 </p>
448 <p className="mt-2 text-[12px] leading-relaxed text-[#7a806c]">
449 {t('thinking.historyEmptyDescription')}
450 </p>
451 </div>
452 )}
453 </div>
454 </PopoverContent>
455 </Popover>
456 <button
457 type="button"
458 onClick={() => void handleCreateWorkspace()}
459 disabled={creatingWorkspace}
460 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"
461 >
462 {creatingWorkspace ? (
463 <Loader2 className="h-4 w-4 animate-spin" />
464 ) : (
465 <Plus className="h-4 w-4" />
466 )}
467 {t('thinking.newWorkspace')}
468 </button>
469 </div>
470 </div>
471 </div>
472
473 <div
474 className={`relative grid min-h-0 flex-1 gap-4 p-4 ${
475 showOutlinePanel ? 'lg:grid-cols-[minmax(0,1fr)_360px]' : 'grid-cols-1'
476 }`}
477 >
478 <section className="min-h-0 overflow-hidden rounded-[2rem] border border-[#e0d8c8] bg-[#fffdf8] shadow-[0_14px_34px_rgba(86,73,54,0.12)]">
479 {thinkingId ? (
480 <ThinkingChat
481 thinkingId={thinkingId}
482 messages={displayMessages}
483 sources={sources}
484 pendingSources={pendingSources}
485 loading={loading}
486 thinkingSteps={thinkingSteps}
487 animatingText={animatingText}
488 onSend={handleSend}
489 onSourcesUploaded={handleSourcesUploaded}
490 onSourceRemoved={handleSourceRemoved}
491 />
492 ) : (
493 <div className="flex h-full min-h-[360px] flex-col items-center justify-center px-8 text-center">
494 <div className="flex h-14 w-14 items-center justify-center rounded-[10%_90%_16%_84%/78%_22%_78%_22%] bg-[#d4e4c1] text-[#3e4a32]">
495 <History className="h-6 w-6" />
496 </div>
497 <h2 className="organic-serif mt-5 text-[28px] font-semibold leading-none text-[#3e4a32]">
498 {t('thinking.emptyWorkspaceTitle')}
499 </h2>
500 <p className="mt-3 max-w-md text-[13px] leading-relaxed text-[#5d6b4d]">
501 {t('thinking.emptyWorkspaceDescription')}
502 </p>
503 <button
504 type="button"
505 onClick={() => void handleCreateWorkspace()}
506 disabled={creatingWorkspace}
507 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"
508 >
509 {creatingWorkspace ? (
510 <Loader2 className="h-4 w-4 animate-spin" />
511 ) : (
512 <Plus className="h-4 w-4" />
513 )}
514 {t('thinking.newWorkspace')}
515 </button>
516 </div>
517 )}
518 </section>
519 {showOutlinePanel && (
520 <aside className="min-h-0 overflow-hidden rounded-[2rem] border border-[#c8d6ba] bg-[#d4e4c1] shadow-[0_14px_34px_rgba(86,73,54,0.12)]">
521 <ThinkingPageCards
522 thinkingMd={thinkingMd}
523 stage={stage}
524 onConfirmGenerate={() => void handleConfirmGenerate()}
525 loading={loading || generating}
526 />
527 </aside>
528 )}
529 </div>
530
531 <GenerationConfirmDialog
532 open={confirmOpen}
533 onOpenChange={setConfirmOpen}
534 prepared={prepared}
535 onConfirm={(params) => void handleGenerationConfirm(params)}
536 />
537
538 <AlertDialog
539 open={Boolean(deleteTarget)}
540 onOpenChange={(open) => {
541 if (!open && !deletingThinkingId) setDeleteTarget(null)
542 }}
543 >
544 <AlertDialogContent>
545 <AlertDialogTitle>{t('thinking.deleteWorkspaceTitle')}</AlertDialogTitle>
546 <AlertDialogDescription>
547 {t('thinking.deleteWorkspaceDescription', {
548 title: deleteTarget?.topic || t('thinking.untitledWorkspace')
549 })}
550 </AlertDialogDescription>
551 <div className="flex justify-end gap-2">
552 <AlertDialogCancel disabled={Boolean(deletingThinkingId)}>
553 {t('common.cancel')}
554 </AlertDialogCancel>
555 <AlertDialogAction
556 disabled={Boolean(deletingThinkingId)}
557 onClick={(event) => {
558 event.preventDefault()
559 void handleDeleteWorkspace()
560 }}
561 className="bg-[#8f3f31] text-white hover:bg-[#743126] disabled:cursor-not-allowed disabled:opacity-65"
562 >
563 {deletingThinkingId ? (
564 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
565 ) : (
566 <Trash2 className="mr-2 h-4 w-4" />
567 )}
568 {t('common.delete')}
569 </AlertDialogAction>
570 </div>
571 </AlertDialogContent>
572 </AlertDialog>
573 </div>
574 )
575 }
576
576 lines Plain Text