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