返回 oh-my-ppt
TemplateUseDialog.tsx
根目录 / src / renderer / src / components / templates / TemplateUseDialog.tsx
1 import { useEffect, useRef, useState } from 'react'
2 import { useNavigate } from 'react-router-dom'
3 import {
4 CircleAlert,
5 Eye,
6 FileText,
7 LayoutTemplate,
8 Loader2,
9 Pencil,
10 Sparkles,
11 X
12 } from 'lucide-react'
13 import { Button } from '../ui/Button'
14 import {
15 Dialog,
16 DialogContent,
17 DialogDescription,
18 DialogFooter,
19 DialogHeader,
20 DialogTitle
21 } from '../ui/Dialog'
22 import { Input, Textarea } from '../ui/Input'
23 import { ScrollArea } from '../ui/ScrollArea'
24 import { ModelSplitButton } from '../model/ModelActionButton'
25 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip'
26 import { useModelAction } from '@renderer/hooks/useModelAction'
27 import { useT } from '@renderer/i18n'
28 import { ipc, type TemplateListItem } from '@renderer/lib/ipc'
29 import { useTemplateStore, useToastStore } from '@renderer/store'
30 import type { ParsedDocumentPlanResult } from '@shared/generation'
31 import ReactMarkdown from 'react-markdown'
32 import {
33 buildSuggestionDraft,
34 formatSourceOutlineBriefText,
35 SessionCreateSuggestionDialog,
36 type DocumentPlanSuggestion,
37 type DocumentPlanSuggestionDraft
38 } from '../session-create/SessionCreateSuggestionDialog'
39
40 const MIN_PAGE_COUNT = 1
41 const MAX_PAGE_COUNT = 500
42 const MAX_DOCUMENT_SIZE_MB = 10
43 const MAX_DOCUMENT_SIZE_BYTES = MAX_DOCUMENT_SIZE_MB * 1024 * 1024
44
45 type AttachedReferenceFile = ParsedDocumentPlanResult['files'][number]
46
47 const resolvePageCount = (raw: string, fallback: number): number => {
48 const parsed = Number.parseInt(raw, 10)
49 if (!Number.isFinite(parsed)) return fallback
50 return Math.min(MAX_PAGE_COUNT, Math.max(MIN_PAGE_COUNT, parsed))
51 }
52
53 const buildTemplateInitialPrompt = (args: {
54 templateName: string
55 title: string
56 pageCount: number
57 brief: string
58 }): string =>
59 [
60 `Create a ${args.pageCount}-slide presentation titled "${args.title}".`,
61 `Use the selected template "${args.templateName}" as the fixed visual template reference.`,
62 'Regenerate every slide from the new brief/source document. Preserve the template direction for layout roles, visual rhythm, colors, typography, and component treatment, but do not reuse old slide text unless the user asks for it.',
63 'Page-count mapping: preserve the template cover/opening role for slide 1 and the closing/ending role for the final slide when possible. If the final deck has more pages than the template, add the extra pages in the middle by reusing or varying relevant middle-page roles. If it has fewer pages, merge or skip less relevant middle-page roles. Do not force one-to-one page matching.',
64 'Determine the presentation content language from the brief and source documents; do not infer it from the application UI language or this instruction language.',
65 '',
66 'Brief:',
67 args.brief
68 ].join('\n')
69
70 export function TemplateUseDialog({
71 template,
72 onOpenChange
73 }: {
74 template: TemplateListItem | null
75 onOpenChange: (open: boolean) => void
76 }): React.JSX.Element {
77 const navigate = useNavigate()
78 const t = useT()
79 const { createSessionFromTemplate } = useTemplateStore()
80 const { success, error, warning } = useToastStore()
81 const modelAction = useModelAction()
82 const { selectedModelConfigId, ensureModelActive } = modelAction
83 const [title, setTitle] = useState('')
84 const [brief, setBrief] = useState('')
85 const [briefMode, setBriefMode] = useState<'edit' | 'preview'>('edit')
86 const [pageCount, setPageCount] = useState('5')
87 const [attachedReferenceFile, setAttachedReferenceFile] = useState<AttachedReferenceFile | null>(
88 null
89 )
90 const [referenceDocumentPath, setReferenceDocumentPath] = useState<string | null>(null)
91 const [parsingDocument, setParsingDocument] = useState(false)
92 const [documentParseError, setDocumentParseError] = useState<string | null>(null)
93 const [hasParsedSource, setHasParsedSource] = useState(false)
94 const [suggestionDraft, setSuggestionDraft] = useState<DocumentPlanSuggestionDraft | null>(null)
95 const [acceptedSourcePlan, setAcceptedSourcePlan] =
96 useState<DocumentPlanSuggestion['sourcePlan']>(undefined)
97 const [suggestionDialogOpen, setSuggestionDialogOpen] = useState(false)
98 const [applyTitleSuggestion, setApplyTitleSuggestion] = useState(false)
99 const [applyPageCountSuggestion, setApplyPageCountSuggestion] = useState(false)
100 const [applyBriefSuggestion, setApplyBriefSuggestion] = useState(false)
101 const [creating, setCreating] = useState(false)
102 const documentInputRef = useRef<HTMLInputElement | null>(null)
103 const open = Boolean(template)
104
105 useEffect(() => {
106 if (!template) return
107 setTitle(template.name)
108 setBrief('')
109 setBriefMode('edit')
110 setPageCount(String(resolvePageCount(String(template.pageCount || 5), 5)))
111 setAttachedReferenceFile(null)
112 setReferenceDocumentPath(null)
113 setDocumentParseError(null)
114 setHasParsedSource(false)
115 setSuggestionDraft(null)
116 setAcceptedSourcePlan(undefined)
117 setSuggestionDialogOpen(false)
118 }, [template])
119
120 const close = (): void => {
121 if (creating || parsingDocument) return
122 onOpenChange(false)
123 }
124
125 const ensureUploadPrerequisites = async (): Promise<boolean> => {
126 const validation = await ipc.validateUploadPrerequisites()
127 if (validation.ready) return true
128 warning(t('templates.settingsRequiredTitle'), {
129 description: validation.message || t('templates.settingsRequiredDescription'),
130 action: {
131 label: t('templates.goToSettings'),
132 onClick: () => navigate('/settings')
133 }
134 })
135 return false
136 }
137
138 const handleChooseDocumentClick = async (): Promise<void> => {
139 if (parsingDocument) return
140 if (!(await ensureUploadPrerequisites())) return
141 documentInputRef.current?.click()
142 }
143
144 const handleDocumentFilesSelected = async (files: FileList | null): Promise<void> => {
145 const selectedFiles = Array.from(files || [])
146 if (documentInputRef.current) {
147 documentInputRef.current.value = ''
148 }
149 if (!template || selectedFiles.length === 0) return
150 if (selectedFiles.length > 1) {
151 const message = t('templates.documentSingleOnly')
152 setDocumentParseError(message)
153 error(t('templates.documentCountExceeded'), { description: message })
154 return
155 }
156
157 const selectedFile = selectedFiles[0]
158 if (selectedFile.size > MAX_DOCUMENT_SIZE_BYTES) {
159 const message = t('templates.documentTooLarge', { maxSize: MAX_DOCUMENT_SIZE_MB })
160 setDocumentParseError(message)
161 error(t('templates.documentTooLargeTitle'), { description: message })
162 return
163 }
164
165 const payloadFiles = selectedFiles
166 .map((file) => ({
167 path: window.electron?.getPathForFile?.(file) || '',
168 name: file.name
169 }))
170 .filter((file) => file.path)
171 if (payloadFiles.length === 0) {
172 setDocumentParseError(t('templates.documentPathFailed'))
173 error(t('templates.documentPathFailedTitle'))
174 return
175 }
176
177 setParsingDocument(true)
178 setDocumentParseError(null)
179 setHasParsedSource(false)
180 try {
181 const result = await ipc.prepareReferenceDocument({ files: payloadFiles })
182 const referenceFile = result.files[0]
183 setAttachedReferenceFile(referenceFile || null)
184 setReferenceDocumentPath(
185 referenceFile && referenceFile.type !== 'image' ? referenceFile.path : null
186 )
187 setSuggestionDraft(null)
188 setAcceptedSourcePlan(undefined)
189 setSuggestionDialogOpen(false)
190 success(t('templates.referenceAttached'), {
191 description: referenceFile?.name || selectedFile.name
192 })
193 } catch (err) {
194 const message = err instanceof Error ? err.message : t('common.retryLater')
195 setDocumentParseError(message)
196 error(t('templates.referenceAttachFailed'), { description: message })
197 } finally {
198 setParsingDocument(false)
199 }
200 }
201
202 const handleRemoveReferenceFile = (): void => {
203 setAttachedReferenceFile(null)
204 setReferenceDocumentPath(null)
205 setDocumentParseError(null)
206 setHasParsedSource(false)
207 setSuggestionDraft(null)
208 setAcceptedSourcePlan(undefined)
209 setSuggestionDialogOpen(false)
210 }
211
212 const handleAnalyzeDocument = async (modelConfigId = selectedModelConfigId): Promise<void> => {
213 if (!template || !attachedReferenceFile || parsingDocument) return
214 const resolvedModelConfigId = await ensureModelActive(modelConfigId)
215 if (!resolvedModelConfigId) return
216
217 setParsingDocument(true)
218 setDocumentParseError(null)
219 try {
220 const result = await ipc.parseDocumentPlan({
221 files: [{ path: attachedReferenceFile.path, name: attachedReferenceFile.name }],
222 topic: title.trim() || template.name,
223 existingBrief: brief.trim(),
224 modelConfigId: resolvedModelConfigId
225 })
226 const referenceFile = result.files[0] || attachedReferenceFile
227 setAttachedReferenceFile(referenceFile)
228 setReferenceDocumentPath(referenceFile.type !== 'image' ? referenceFile.path : null)
229 const nextSuggestion = {
230 topic: result.topic || title || template.name,
231 pageCount: resolvePageCount(String(result.pageCount), 5),
232 briefText: result.briefText,
233 sourcePlan: result.sourcePlan
234 }
235 setSuggestionDraft(buildSuggestionDraft(nextSuggestion))
236 setAcceptedSourcePlan(undefined)
237 setApplyTitleSuggestion(!title.trim() || title.trim() === template.name)
238 setApplyPageCountSuggestion(!result.sourcePlan?.pageSkeleton.length)
239 setApplyBriefSuggestion(Boolean(result.sourcePlan?.pageSkeleton.length) || !brief.trim())
240 setSuggestionDialogOpen(true)
241 setHasParsedSource(true)
242 success(t('templates.documentParsed'), {
243 description: t('templates.documentParsedDescription', { count: result.files.length })
244 })
245 } catch (err) {
246 const message = err instanceof Error ? err.message : t('common.retryLater')
247 setDocumentParseError(message)
248 error(t('templates.documentParseFailed'), { description: message })
249 } finally {
250 setParsingDocument(false)
251 }
252 }
253
254 const applyDocumentSuggestion = (): void => {
255 const draft = suggestionDraft
256 if (!draft) return
257 const sourceOutlinePageCount = draft.sourcePlan?.pageSkeleton.length || 0
258 const hasSourceOutline = sourceOutlinePageCount > 0
259 const shouldApplySourceOutline = hasSourceOutline && applyBriefSuggestion
260
261 if (applyTitleSuggestion) setTitle(draft.topic)
262 if (shouldApplySourceOutline) {
263 setPageCount(String(resolvePageCount(String(sourceOutlinePageCount), 5)))
264 } else if (applyPageCountSuggestion) {
265 setPageCount(String(resolvePageCount(draft.pageCount, 5)))
266 }
267 if (applyBriefSuggestion) {
268 setBrief(
269 draft.sourcePlan?.pageSkeleton.length
270 ? formatSourceOutlineBriefText(draft.sourcePlan.pageSkeleton)
271 : draft.briefText
272 )
273 }
274 setAcceptedSourcePlan(shouldApplySourceOutline ? draft.sourcePlan : undefined)
275 setSuggestionDialogOpen(false)
276 }
277
278 const handleCreate = async (modelConfigId = selectedModelConfigId): Promise<void> => {
279 if (!template || creating) return
280 const resolvedModelConfigId = await ensureModelActive(modelConfigId)
281 if (!resolvedModelConfigId) return
282 const deckTitle = title.trim() || template.name
283 const briefText = brief.trim()
284 if (!briefText) {
285 warning(t('templates.briefRequired'))
286 return
287 }
288 const safePageCount = resolvePageCount(pageCount, template.pageCount || 5)
289 setCreating(true)
290 try {
291 const sessionId = await createSessionFromTemplate({
292 templateId: template.id,
293 title: deckTitle,
294 modelConfigId: resolvedModelConfigId,
295 pageCount: safePageCount,
296 referenceDocumentPath: referenceDocumentPath || undefined,
297 sourcePlan: acceptedSourcePlan
298 })
299 const initialPrompt = buildTemplateInitialPrompt({
300 templateName: template.name,
301 title: deckTitle,
302 pageCount: safePageCount,
303 brief: briefText
304 })
305 success(t('templates.sessionCreated'), {
306 description: t('templates.sessionCreatedDescription')
307 })
308 onOpenChange(false)
309 navigate(`/sessions/${sessionId}/template-generating`, {
310 state: { initialPrompt, modelConfigId: resolvedModelConfigId }
311 })
312 } catch (err) {
313 error(t('templates.createFailed'), {
314 description: err instanceof Error ? err.message : t('common.retryLater')
315 })
316 } finally {
317 setCreating(false)
318 }
319 }
320
321 return (
322 <>
323 <Dialog open={open} onOpenChange={(next) => !next && close()}>
324 <DialogContent className="max-w-2xl">
325 <DialogHeader>
326 <DialogTitle className="flex items-center gap-2">
327 <LayoutTemplate className="h-4 w-4" />
328 {t('templates.useDialogTitle')}
329 </DialogTitle>
330 <DialogDescription className="text-xs leading-5">
331 {t('templates.useDialogDescription')}
332 </DialogDescription>
333 </DialogHeader>
334 <div className="space-y-3">
335 <div className="flex flex-col gap-3 sm:flex-row">
336 <div className="min-w-0 flex-1">
337 <label className="mb-1 block text-xs font-medium text-[#5f6b50]">
338 {t('templates.sessionTitleLabel')}
339 </label>
340 <Input value={title} onChange={(event) => setTitle(event.target.value)} />
341 </div>
342 <div className="w-full sm:w-28">
343 <label className="mb-1 block text-xs font-medium text-[#5f6b50]">
344 {t('templates.pageCountLabel')}
345 </label>
346 <Input
347 value={pageCount}
348 inputMode="numeric"
349 onChange={(event) => {
350 setAcceptedSourcePlan(undefined)
351 setPageCount(event.target.value)
352 }}
353 />
354 </div>
355 </div>
356 <div>
357 <div className="mb-2 flex items-center justify-between gap-2">
358 <label className="block text-xs font-medium text-[#5f6b50]">
359 {t('templates.briefLabel')}
360 </label>
361 <div className="flex items-center gap-2">
362 {hasParsedSource && !parsingDocument ? (
363 <span className="rounded-full bg-[#e8f0df] px-2 py-0.5 text-[11px] text-[#4f6340]">
364 {t('templates.parsed')}
365 </span>
366 ) : null}
367 <div className="flex items-center gap-1 rounded-lg border border-border p-0.5">
368 <button
369 type="button"
370 onClick={() => setBriefMode('edit')}
371 className={`flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors ${
372 briefMode === 'edit'
373 ? 'bg-foreground text-background'
374 : 'text-muted-foreground hover:text-foreground'
375 }`}
376 >
377 <Pencil className="h-3.5 w-3.5" />
378 {t('common.edit')}
379 </button>
380 <button
381 type="button"
382 onClick={() => setBriefMode('preview')}
383 className={`flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors ${
384 briefMode === 'preview'
385 ? 'bg-foreground text-background'
386 : 'text-muted-foreground hover:text-foreground'
387 }`}
388 >
389 <Eye className="h-3.5 w-3.5" />
390 {t('common.preview')}
391 </button>
392 </div>
393 </div>
394 </div>
395 {briefMode === 'edit' ? (
396 <Textarea
397 value={brief}
398 onChange={(event) => {
399 setAcceptedSourcePlan(undefined)
400 setBrief(event.target.value)
401 }}
402 className="min-h-[150px] resize-y px-3 py-2 text-xs leading-5"
403 placeholder={t('templates.briefPlaceholder')}
404 />
405 ) : (
406 <ScrollArea
407 className="h-[180px] rounded-lg border border-border/70 bg-background/70"
408 viewportClassName="p-4"
409 >
410 <ReactMarkdown
411 components={{
412 h1: ({ children }) => (
413 <h1 className="mb-2 text-lg font-semibold text-foreground">{children}</h1>
414 ),
415 h2: ({ children }) => (
416 <h2 className="mb-2 mt-3 text-base font-semibold text-foreground">
417 {children}
418 </h2>
419 ),
420 h3: ({ children }) => (
421 <h3 className="mb-1.5 mt-2.5 text-sm font-semibold text-foreground">
422 {children}
423 </h3>
424 ),
425 p: ({ children }) => (
426 <p className="mb-2 text-xs leading-5 text-muted-foreground">{children}</p>
427 ),
428 ul: ({ children }) => (
429 <ul className="mb-2 list-disc space-y-0.5 pl-5 text-xs text-muted-foreground">
430 {children}
431 </ul>
432 ),
433 ol: ({ children }) => (
434 <ol className="mb-2 list-decimal space-y-0.5 pl-5 text-xs text-muted-foreground">
435 {children}
436 </ol>
437 ),
438 li: ({ children }) => <li>{children}</li>,
439 code: ({ children }) => (
440 <code className="rounded bg-muted px-1.5 py-0.5 text-xs text-foreground">
441 {children}
442 </code>
443 ),
444 blockquote: ({ children }) => (
445 <blockquote className="mb-2 border-l-2 border-border pl-3 text-xs text-muted-foreground">
446 {children}
447 </blockquote>
448 )
449 }}
450 >
451 {brief || t('templates.briefPlaceholder')}
452 </ReactMarkdown>
453 </ScrollArea>
454 )}
455 </div>
456 {attachedReferenceFile ? (
457 <div className="flex min-w-0">
458 <span
459 className="inline-flex h-6 max-w-[260px] items-center gap-1 rounded-full border border-[#c7d9b4]/70 bg-[#e6f1dc]/80 px-2 text-[10px] text-[#405333]"
460 title={attachedReferenceFile.path}
461 >
462 <FileText className="h-3 w-3 shrink-0" />
463 <span className="min-w-0 truncate">{attachedReferenceFile.name}</span>
464 <button
465 type="button"
466 onClick={handleRemoveReferenceFile}
467 className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full text-[#657552] hover:bg-[#c8ddb2]"
468 aria-label={t('templates.removeReference')}
469 >
470 <X className="h-2.5 w-2.5" />
471 </button>
472 </span>
473 </div>
474 ) : null}
475 <input
476 ref={documentInputRef}
477 type="file"
478 accept=".md,.txt,.text,.csv,.docx"
479 multiple={false}
480 className="hidden"
481 onChange={(event) => void handleDocumentFilesSelected(event.target.files)}
482 />
483 <TooltipProvider delayDuration={180}>
484 <div className="flex flex-wrap items-center gap-2">
485 <Tooltip>
486 <TooltipTrigger asChild>
487 <span className="inline-flex">
488 <Button
489 type="button"
490 variant="ghost"
491 size="sm"
492 onClick={() => void handleChooseDocumentClick()}
493 disabled={parsingDocument || creating}
494 className="h-8 shrink-0 rounded-lg border border-[#d8ccb5]/80 bg-[#fffdf8]/76 px-2.5 text-xs font-medium text-[#405333] shadow-none hover:bg-[#f3f7ed] hover:text-[#2f3b28]"
495 >
496 {parsingDocument ? (
497 <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
498 ) : (
499 <FileText className="mr-1.5 h-3.5 w-3.5" />
500 )}
501 {parsingDocument
502 ? t('templates.processingDocument')
503 : t('templates.uploadDocument')}
504 </Button>
505 </span>
506 </TooltipTrigger>
507 <TooltipContent side="bottom" align="start">
508 {t('templates.uploadDocumentTooltip', { maxSize: MAX_DOCUMENT_SIZE_MB })}
509 </TooltipContent>
510 </Tooltip>
511 {attachedReferenceFile ? (
512 <Tooltip>
513 <TooltipTrigger asChild>
514 <span>
515 <ModelSplitButton
516 modelAction={modelAction}
517 label={t('templates.analyzeDocument')}
518 loadingLabel={t('templates.analyzingDocument')}
519 loading={parsingDocument}
520 disabled={creating || !attachedReferenceFile}
521 icon={Sparkles}
522 tone="primary"
523 dropdownAlign="start"
524 className="h-8 rounded-lg border-0 bg-gradient-to-r from-[#7f965f] to-[#5f7448] shadow-[0_8px_18px_rgba(93,107,77,0.18)]"
525 mainClassName="h-full bg-transparent px-2.5 text-xs text-white shadow-none hover:bg-white/10 hover:text-white hover:shadow-none"
526 triggerClassName="h-full w-8 px-0"
527 onRun={handleAnalyzeDocument}
528 />
529 </span>
530 </TooltipTrigger>
531 <TooltipContent side="bottom" align="start" className="max-w-xs">
532 {t('templates.analyzeDocumentTooltip')}
533 </TooltipContent>
534 </Tooltip>
535 ) : null}
536 <span className="text-xs text-muted-foreground">
537 {t('templates.supportedDocuments', { maxSize: MAX_DOCUMENT_SIZE_MB })}
538 </span>
539 </div>
540 </TooltipProvider>
541 {documentParseError ? (
542 <div className="flex items-start gap-2 rounded-md border border-[#d58b7f]/45 bg-[#fff2ef] px-3 py-2 text-xs text-[#8a3d33]">
543 <CircleAlert className="mt-0.5 h-4 w-4 shrink-0" />
544 <span>{documentParseError}</span>
545 </div>
546 ) : null}
547 </div>
548 <DialogFooter className="gap-2">
549 <Button
550 type="button"
551 variant="outline"
552 size="sm"
553 onClick={close}
554 disabled={creating || parsingDocument}
555 >
556 {t('common.cancel')}
557 </Button>
558 <ModelSplitButton
559 modelAction={modelAction}
560 label={t('templates.createAndGenerate')}
561 loadingLabel={t('templates.creating')}
562 loading={creating}
563 disabled={parsingDocument}
564 icon={Sparkles}
565 tone="primary"
566 onRun={handleCreate}
567 />
568 </DialogFooter>
569 </DialogContent>
570 </Dialog>
571
572 <SessionCreateSuggestionDialog
573 open={suggestionDialogOpen}
574 onOpenChange={setSuggestionDialogOpen}
575 attachedReferenceFile={attachedReferenceFile}
576 suggestionDraft={suggestionDraft}
577 setSuggestionDraft={setSuggestionDraft}
578 applyTopicSuggestion={applyTitleSuggestion}
579 setApplyTopicSuggestion={setApplyTitleSuggestion}
580 applyPageCountSuggestion={applyPageCountSuggestion}
581 setApplyPageCountSuggestion={setApplyPageCountSuggestion}
582 applyBriefSuggestion={applyBriefSuggestion}
583 setApplyBriefSuggestion={setApplyBriefSuggestion}
584 onApplySelected={applyDocumentSuggestion}
585 />
586 </>
587 )
588 }
589
589 lines Plain Text