返回 oh-my-ppt
GenerationConfirmDialog.tsx
根目录 / src / renderer / src / components / thinking / GenerationConfirmDialog.tsx
1 import { useState, useEffect, useCallback, useMemo, type ReactElement } from 'react'
2 import {
3 Dialog,
4 DialogContent,
5 DialogHeader,
6 DialogTitle,
7 DialogDescription,
8 DialogFooter
9 } from '../ui/Dialog'
10 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/Select'
11 import { StyleSelect } from '../style/StyleSelect'
12 import { Button } from '../ui/Button'
13 import { Input } from '../ui/Input'
14 import { Checkbox } from '../ui/Checkbox'
15 import { useT, type I18nKey } from '@renderer/i18n'
16 import { ipc, type FontListItem } from '@renderer/lib/ipc'
17 import {
18 getImageModelConfigLabel,
19 getUsableImageModelConfigs,
20 resolveDefaultImageModelConfigId
21 } from '@renderer/lib/image-model-config'
22 import type { FontSelection, SourceDocumentPlan } from '@shared/generation'
23 import {
24 DEFAULT_SLIDE_SIZE_ID,
25 SLIDE_SIZE_PRESETS,
26 type SlideSizePresetId
27 } from '@shared/slide-size'
28 import type { ThinkingPrepareGenerationResult } from '@shared/thinking'
29 import { Sparkles } from 'lucide-react'
30 import { ModelSplitButton } from '../model/ModelActionButton'
31 import { useModelAction } from '@renderer/hooks/useModelAction'
32 import { useSettingsStore } from '@renderer/store'
33
34 type FontPairRef = Extract<FontSelection, { mode: 'pair' }>['title']
35
36 const MIN_PAGE_COUNT = 1
37 const MAX_PAGE_COUNT = 500
38
39 const resolvePageCount = (value: string, fallback: number): number => {
40 const parsed = Number.parseInt(value, 10)
41 const resolved = Number.isFinite(parsed) ? parsed : fallback
42 return Math.min(MAX_PAGE_COUNT, Math.max(MIN_PAGE_COUNT, resolved))
43 }
44
45 const getSlideSizeLabelKey = (id: SlideSizePresetId): I18nKey => {
46 switch (id) {
47 case 'wide-16-9':
48 return 'home.slideSizeWide'
49 case 'vertical-9-16':
50 return 'home.slideSizeVertical'
51 case 'standard-4-3':
52 return 'home.slideSizeStandard'
53 case 'square-1-1':
54 return 'home.slideSizeSquare'
55 case 'vertical-3-4':
56 return 'home.slideSizePortrait'
57 case 'xiaohongshu-note':
58 return 'home.slideSizeXiaohongshu'
59 }
60 }
61
62 interface StyleOption {
63 id: string
64 styleKey?: string
65 label: string
66 description: string
67 aliases?: string[]
68 styleCase?: string
69 imageGenerationPrompt?: string
70 thumbnailPath?: string | null
71 previewPath?: string | null
72 favoriteAt?: number | null
73 }
74
75 const tokenizeStyleText = (value: string): string[] => {
76 const compact = value.trim().toLowerCase()
77 const baseTokens = compact
78 .split(/[\s,,、/|;;::()[\]{}"'“”‘’<>《》]+/)
79 .map((item) => item.trim())
80 .filter(Boolean)
81 const latinTokens = Array.from(compact.matchAll(/[a-z0-9-]{2,}/g), (match) => match[0])
82 const cnBigrams = Array.from(compact.matchAll(/[\u4e00-\u9fa5]{2,}/g)).flatMap((match) => {
83 const text = match[0]
84 const grams: string[] = []
85 for (let index = 0; index < text.length - 1; index += 1) {
86 grams.push(text.slice(index, index + 2))
87 }
88 return grams
89 })
90 return Array.from(new Set([...baseTokens, ...latinTokens, ...cnBigrams]))
91 }
92
93 const resolveFallbackStyleId = (fallbackStyleId: string, options: StyleOption[]): string => {
94 if (fallbackStyleId) return fallbackStyleId
95 return (
96 options.find((option) => option.styleKey === 'minimal-white')?.id ||
97 options.find((option) => option.id === 'minimal-white')?.id ||
98 options[0]?.id ||
99 ''
100 )
101 }
102
103 const resolveMatchedStyleId = (
104 styleText: string | undefined,
105 fallbackStyleId: string,
106 options: StyleOption[]
107 ): string => {
108 const normalizedStyleText = (styleText || '').trim().toLowerCase()
109 const resolvedFallbackStyleId = resolveFallbackStyleId(fallbackStyleId, options)
110 if (options.length === 0) return resolvedFallbackStyleId
111 if (!normalizedStyleText) return resolvedFallbackStyleId
112
113 const exact = options.find((option) => {
114 const candidates = [
115 option.id,
116 option.styleKey || '',
117 option.label,
118 ...(option.aliases || [])
119 ].map((value) => value.toLowerCase())
120 return candidates.includes(normalizedStyleText)
121 })
122 if (exact) return exact.id
123
124 const queryTokens = tokenizeStyleText(normalizedStyleText)
125 let best: { id: string; score: number } | null = null
126 for (const option of options) {
127 const haystack = [
128 option.id,
129 option.styleKey || '',
130 option.label,
131 ...(option.aliases || []),
132 option.description,
133 option.styleCase || ''
134 ]
135 .join(' ')
136 .toLowerCase()
137 let score = 0
138 for (const token of queryTokens) {
139 if (!token || !haystack.includes(token)) continue
140 score += token.length >= 2 ? 2 : 1
141 }
142 if (!best || score > best.score) best = { id: option.id, score }
143 }
144 return best && best.score > 0 ? best.id : resolvedFallbackStyleId
145 }
146
147 interface GenerationConfirmDialogProps {
148 open: boolean
149 onOpenChange: (open: boolean) => void
150 prepared: ThinkingPrepareGenerationResult | null
151 onConfirm: (params: {
152 topic: string
153 pageCount: number
154 styleId: string
155 fontSelection: FontSelection
156 slideSizeId: SlideSizePresetId
157 referenceDocumentPath: string
158 sourcePlan?: SourceDocumentPlan
159 modelConfigId?: string
160 visualEnabled: boolean
161 imageModelConfigId?: string
162 }) => void
163 }
164
165 export function GenerationConfirmDialog({
166 open,
167 onOpenChange,
168 prepared,
169 onConfirm
170 }: GenerationConfirmDialogProps): ReactElement {
171 const t = useT()
172 const modelAction = useModelAction()
173 const { selectedModelConfigId, ensureModelActive } = modelAction
174 const imageModelConfigs = useSettingsStore((state) => state.imageModelConfigs)
175 const [confirming, setConfirming] = useState(false)
176 const [topic, setTopic] = useState('')
177 const [pageCount, setPageCount] = useState('5')
178 const [styleId, setStyleId] = useState('')
179 const [styleOptions, setStyleOptions] = useState<StyleOption[]>([])
180 const [fontOptions, setFontOptions] = useState<FontListItem[]>([])
181 const [titleFontId, setTitleFontId] = useState('auto')
182 const [bodyFontId, setBodyFontId] = useState('auto')
183 const [slideSizeId, setSlideSizeId] = useState<SlideSizePresetId>(DEFAULT_SLIDE_SIZE_ID)
184 const [visualEnabled, setVisualEnabled] = useState(false)
185 const [selectedImageModelConfigId, setSelectedImageModelConfigId] = useState('')
186 const usableImageModelConfigs = useMemo(
187 () => getUsableImageModelConfigs(imageModelConfigs),
188 [imageModelConfigs]
189 )
190
191 useEffect(() => {
192 if (usableImageModelConfigs.length === 0) {
193 setVisualEnabled(false)
194 setSelectedImageModelConfigId('')
195 return
196 }
197 if (!visualEnabled) return
198 setSelectedImageModelConfigId((current) =>
199 usableImageModelConfigs.some((config) => config.id === current)
200 ? current
201 : resolveDefaultImageModelConfigId(usableImageModelConfigs)
202 )
203 }, [usableImageModelConfigs, visualEnabled])
204
205 useEffect(() => {
206 if (prepared) {
207 setTopic(prepared.topic)
208 setPageCount(String(prepared.pageCount))
209 if (styleOptions.length > 0) {
210 setStyleId(resolveMatchedStyleId(prepared.styleText, prepared.styleId, styleOptions))
211 }
212 }
213 }, [prepared, styleOptions])
214
215 useEffect(() => {
216 if (!prepared || prepared.fontSelection.mode !== 'pair') {
217 setTitleFontId('auto')
218 setBodyFontId('auto')
219 return
220 }
221
222 const resolveSelectId = (font: FontPairRef): string => {
223 if (font.id) return `${font.source}:${font.id}`
224 const match = fontOptions.find(
225 (option) => option.source === font.source && option.family === font.family
226 )
227 return match ? `${match.source}:${match.id}` : 'auto'
228 }
229
230 setTitleFontId(resolveSelectId(prepared.fontSelection.title))
231 setBodyFontId(resolveSelectId(prepared.fontSelection.body))
232 }, [prepared, fontOptions])
233
234 const loadOptions = useCallback(async (): Promise<void> => {
235 const [styleRes, fontRes] = await Promise.all([ipc.listStyles(), ipc.listFonts()])
236 const sorted = [...styleRes.items].sort(
237 (a, b) =>
238 (b.favoriteAt || 0) - (a.favoriteAt || 0) ||
239 (b.updatedAt || 0) - (a.updatedAt || 0) ||
240 (b.createdAt || 0) - (a.createdAt || 0) ||
241 a.id.localeCompare(b.id)
242 )
243 setStyleOptions(
244 sorted.map((item) => ({
245 id: item.id,
246 styleKey: item.styleKey,
247 label: item.label,
248 description: item.description,
249 aliases: item.aliases,
250 styleCase: item.styleCase,
251 imageGenerationPrompt: item.imageGenerationPrompt,
252 thumbnailPath: item.thumbnailPath,
253 previewPath: item.previewPath,
254 favoriteAt: item.favoriteAt
255 }))
256 )
257 const fonts = [...fontRes.userFonts, ...fontRes.googleFonts]
258 setFontOptions(fonts)
259 }, [])
260
261 useEffect(() => {
262 if (open) void loadOptions()
263 }, [open, loadOptions])
264
265 if (!prepared) return <></>
266
267 const titleFonts = fontOptions.filter((f) => f.role.includes('title'))
268 const bodyFonts = fontOptions.filter((f) => f.role.includes('body'))
269 const availableTitle = titleFonts.length > 0 ? titleFonts : fontOptions
270 const availableBody = bodyFonts.length > 0 ? bodyFonts : fontOptions
271
272 const resolveFontSelection = (): FontSelection => {
273 const find = (id: string): FontListItem | undefined =>
274 fontOptions.find((f) => `${f.source}:${f.id}` === id)
275 const tf = find(titleFontId)
276 const bf = find(bodyFontId)
277 if (tf && bf) {
278 return {
279 mode: 'pair',
280 title: { source: tf.source, family: tf.family, id: tf.id },
281 body: { source: bf.source, family: bf.family, id: bf.id }
282 }
283 }
284 if (
285 prepared?.fontSelection.mode === 'pair' &&
286 (fontOptions.length === 0 || (titleFontId !== 'auto' && bodyFontId !== 'auto'))
287 ) {
288 return prepared.fontSelection
289 }
290 return { mode: 'auto' }
291 }
292
293 const resolvedConfirmStyleId = styleId || resolveFallbackStyleId(prepared.styleId, styleOptions)
294
295 const handleConfirm = async (modelConfigId = selectedModelConfigId): Promise<void> => {
296 if (!resolvedConfirmStyleId || confirming) return
297 if (
298 visualEnabled &&
299 !usableImageModelConfigs.some((config) => config.id === selectedImageModelConfigId)
300 ) {
301 return
302 }
303 const resolvedModelConfigId = await ensureModelActive(modelConfigId)
304 if (!resolvedModelConfigId) return
305 setConfirming(true)
306 try {
307 const resolvedPageCount = resolvePageCount(pageCount, prepared.pageCount)
308 onConfirm({
309 topic: topic.trim() || prepared.topic,
310 pageCount: resolvedPageCount,
311 styleId: resolvedConfirmStyleId,
312 fontSelection: resolveFontSelection(),
313 slideSizeId,
314 referenceDocumentPath: prepared.thinkingDocumentPath,
315 sourcePlan:
316 prepared.sourcePlan?.pageSkeleton.length === resolvedPageCount
317 ? prepared.sourcePlan
318 : undefined,
319 modelConfigId: resolvedModelConfigId,
320 visualEnabled,
321 imageModelConfigId: visualEnabled ? selectedImageModelConfigId : undefined
322 })
323 onOpenChange(false)
324 } finally {
325 setConfirming(false)
326 }
327 }
328
329 return (
330 <Dialog open={open} onOpenChange={onOpenChange} modal={false}>
331 <DialogContent className="max-h-[calc(100vh-2rem)] w-[calc(100vw-2rem)] max-w-3xl overflow-y-auto">
332 <DialogHeader>
333 <DialogTitle>{t('thinking.generationDialogTitle')}</DialogTitle>
334 <DialogDescription className="text-[12px]">
335 {t('thinking.generationDialogDescription')}
336 </DialogDescription>
337 </DialogHeader>
338
339 <div className="min-w-0 space-y-3 py-2 [&_button[role=combobox]]:h-8 [&_input]:h-8 [&_label]:mb-1.5 [&_label]:text-xs">
340 <div className="min-w-0">
341 <label className="block font-medium">{t('home.topic')}</label>
342 <Input className="min-w-0" value={topic} onChange={(e) => setTopic(e.target.value)} />
343 </div>
344
345 <div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-[minmax(20rem,1fr)_6.25rem_minmax(0,12rem)]">
346 <div className="min-w-0">
347 <label className="block font-medium">{t('home.style')}</label>
348 <StyleSelect
349 value={styleId}
350 onChange={setStyleId}
351 options={styleOptions}
352 placeholder={t('home.stylePlaceholder')}
353 recommendation={{ topic, modelConfigId: selectedModelConfigId }}
354 className="h-8 min-w-0 py-0 text-xs"
355 dropdownClassName="w-[min(640px,calc(100vw-3rem))]"
356 />
357 </div>
358
359 <div className="min-w-0">
360 <label className="block font-medium">{t('home.pageCount')}</label>
361 <Input
362 className="min-w-0 text-center"
363 type="text"
364 inputMode="numeric"
365 value={pageCount}
366 onChange={(e) => {
367 const next = e.target.value
368 if (next === '' || /^\d+$/.test(next)) setPageCount(next)
369 }}
370 onBlur={() => {
371 setPageCount(String(resolvePageCount(pageCount, prepared.pageCount)))
372 }}
373 />
374 </div>
375
376 <div className="min-w-0">
377 <label className="block font-medium">{t('home.slideSize')}</label>
378 <Select
379 value={slideSizeId}
380 onValueChange={(value) => setSlideSizeId(value as SlideSizePresetId)}
381 >
382 <SelectTrigger className="min-w-0">
383 <SelectValue />
384 </SelectTrigger>
385 <SelectContent>
386 {SLIDE_SIZE_PRESETS.map((preset) => (
387 <SelectItem key={preset.id} value={preset.id}>
388 {t(getSlideSizeLabelKey(preset.id))}
389 </SelectItem>
390 ))}
391 </SelectContent>
392 </Select>
393 </div>
394 </div>
395
396 <div className="min-w-0">
397 <label className="block font-medium">{t('home.fontScheme')}</label>
398 <div className="mt-1 grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
399 <Select value={titleFontId} onValueChange={setTitleFontId}>
400 <SelectTrigger className="min-w-0">
401 <SelectValue placeholder={t('home.fontSchemeAuto')} />
402 </SelectTrigger>
403 <SelectContent>
404 <SelectItem value="auto">{t('home.fontSchemeAuto')}</SelectItem>
405 {availableTitle.map((font) => {
406 const isUploaded = font.source === 'uploaded'
407 return (
408 <SelectItem
409 key={`${font.source}:${font.id}`}
410 value={`${font.source}:${font.id}`}
411 >
412 <span className="flex items-center gap-2">
413 <span
414 className={`shrink-0 rounded px-1 py-0.5 text-[10px] font-medium ${
415 isUploaded
416 ? 'bg-[#eef9ec] text-[#4a7a46]'
417 : 'bg-[#eef6ff] text-[#3e6685]'
418 }`}
419 >
420 {isUploaded
421 ? t('home.fontSourceUploaded')
422 : t('home.fontSourceBuiltIn')}
423 </span>
424 <span className="truncate">
425 {t('home.fontPairTitle')} · {font.family}
426 </span>
427 </span>
428 </SelectItem>
429 )
430 })}
431 </SelectContent>
432 </Select>
433 <Select value={bodyFontId} onValueChange={setBodyFontId}>
434 <SelectTrigger className="min-w-0">
435 <SelectValue placeholder={t('home.fontSchemeAuto')} />
436 </SelectTrigger>
437 <SelectContent>
438 <SelectItem value="auto">{t('home.fontSchemeAuto')}</SelectItem>
439 {availableBody.map((font) => {
440 const isUploaded = font.source === 'uploaded'
441 return (
442 <SelectItem
443 key={`${font.source}:${font.id}`}
444 value={`${font.source}:${font.id}`}
445 >
446 <span className="flex items-center gap-2">
447 <span
448 className={`shrink-0 rounded px-1 py-0.5 text-[10px] font-medium ${
449 isUploaded
450 ? 'bg-[#eef9ec] text-[#4a7a46]'
451 : 'bg-[#eef6ff] text-[#3e6685]'
452 }`}
453 >
454 {isUploaded
455 ? t('home.fontSourceUploaded')
456 : t('home.fontSourceBuiltIn')}
457 </span>
458 <span className="truncate">
459 {t('home.fontPairBody')} · {font.family}
460 </span>
461 </span>
462 </SelectItem>
463 )
464 })}
465 </SelectContent>
466 </Select>
467 </div>
468 </div>
469
470 <div className="min-w-0 rounded-md border border-[#d8ccb5]/70 bg-[#fffdf8]/70 p-3">
471 <label className="flex cursor-pointer items-center gap-2 text-sm font-medium">
472 <Checkbox
473 checked={visualEnabled}
474 disabled={usableImageModelConfigs.length === 0}
475 onCheckedChange={(checked) => {
476 const enabled = checked === true
477 setVisualEnabled(enabled)
478 setSelectedImageModelConfigId(
479 enabled ? resolveDefaultImageModelConfigId(usableImageModelConfigs) : ''
480 )
481 }}
482 />
483 <span>{t('home.enableImageGeneration')}</span>
484 </label>
485 {visualEnabled ? (
486 <div className="mt-3 min-w-0">
487 <label className="block font-medium">{t('home.imageModel')}</label>
488 <Select value={selectedImageModelConfigId} onValueChange={setSelectedImageModelConfigId}>
489 <SelectTrigger className="min-w-0">
490 <SelectValue placeholder={t('home.imageModelPlaceholder')} />
491 </SelectTrigger>
492 <SelectContent>
493 {usableImageModelConfigs.map((config) => (
494 <SelectItem key={config.id} value={config.id}>
495 {getImageModelConfigLabel(config)}
496 </SelectItem>
497 ))}
498 </SelectContent>
499 </Select>
500 </div>
501 ) : null}
502 <p className="mt-2 text-xs text-muted-foreground">
503 {usableImageModelConfigs.length > 0
504 ? t('home.imageGenerationHint')
505 : t('home.imageModelUnavailable')}
506 </p>
507 </div>
508 </div>
509
510 <DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:items-center">
511 <Button
512 variant="outline"
513 size="sm"
514 onClick={() => onOpenChange(false)}
515 disabled={confirming}
516 className="w-full rounded-full sm:w-auto"
517 >
518 {t('common.cancel')}
519 </Button>
520 <ModelSplitButton
521 modelAction={modelAction}
522 label={t('home.createAndStart')}
523 loadingLabel={t('home.creating')}
524 loading={confirming}
525 disabled={
526 !resolvedConfirmStyleId ||
527 (visualEnabled &&
528 !usableImageModelConfigs.some((config) => config.id === selectedImageModelConfigId))
529 }
530 icon={Sparkles}
531 tone="primary"
532 className="w-full sm:w-auto"
533 mainClassName="min-w-0 flex-1 sm:flex-none sm:min-w-[156px]"
534 onRun={handleConfirm}
535 />
536 </DialogFooter>
537 </DialogContent>
538 </Dialog>
539 )
540 }
541
541 lines Plain Text