返回 oh-my-ppt
session-create.tsx
根目录 / src / renderer / src / pages / session-create.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
2 import { useNavigate } from 'react-router-dom'
3 import { Button } from '../components/ui/Button'
4 import { Input } from '../components/ui/Input'
5 import { Textarea } from '../components/ui/Input'
6 import { Card, CardContent } from '../components/ui/Card'
7 import { ScrollArea } from '../components/ui/ScrollArea'
8 import { Checkbox } from '../components/ui/Checkbox'
9 import {
10 Select,
11 SelectContent,
12 SelectItem,
13 SelectTrigger,
14 SelectValue
15 } from '../components/ui/Select'
16 import { StyleSelect } from '../components/style/StyleSelect'
17 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../components/ui/Tooltip'
18 import { CircleAlert, Eye, FileText, Loader2, Pencil, Sparkles, X } from 'lucide-react'
19 import { useSessionStore } from '../store'
20 import { useSettingsStore } from '../store'
21 import { useToastStore } from '../store'
22 import { ModelSplitButton } from '../components/model/ModelActionButton'
23 import { useModelAction } from '../hooks/useModelAction'
24 import { ipc, type FontListItem } from '@renderer/lib/ipc'
25 import {
26 getUsableImageModelConfigs,
27 resolveDefaultImageModelConfigId
28 } from '@renderer/lib/image-model-config'
29 import {
30 normalizeAnimationPreferences,
31 type AnimationPreferenceId,
32 type FontSelection,
33 type ParsedDocumentPlanResult
34 } from '@shared/generation'
35 import { useT } from '../i18n'
36 import ReactMarkdown from 'react-markdown'
37 import { isSupportedImageMimeType } from '@shared/image-mime'
38 import {
39 buildSuggestionDraft,
40 formatSourceOutlineBriefText,
41 SessionCreateSuggestionDialog,
42 type DocumentPlanSuggestion,
43 type DocumentPlanSuggestionDraft
44 } from '../components/session-create/SessionCreateSuggestionDialog'
45 import { AnimationPreferenceChips } from '../components/session-create/AnimationPreferenceChips'
46 import {
47 DEFAULT_SLIDE_SIZE_ID,
48 SLIDE_SIZE_PRESETS,
49 type SlideSizePresetId
50 } from '@shared/slide-size'
51 const MIN_PAGE_COUNT = 1
52 const MAX_PAGE_COUNT = 500
53 const DEFAULT_PAGE_COUNT = 5
54 const MAX_DOCUMENT_SIZE_MB = 10
55 const MAX_DOCUMENT_SIZE_BYTES = MAX_DOCUMENT_SIZE_MB * 1024 * 1024
56 const MAX_IMAGE_SIZE_MB = 5
57 const MAX_IMAGE_SIZE_BYTES = MAX_IMAGE_SIZE_MB * 1024 * 1024
58 const isImageFileName = (name: string): boolean => /\.(png|jpe?g|webp)$/i.test(name.trim())
59
60 const isSupportedImageFile = (file: File): boolean =>
61 isSupportedImageMimeType(file.type) || isImageFileName(file.name || '')
62
63 type AttachedReferenceFile = ParsedDocumentPlanResult['files'][number]
64
65 const compactInputClass =
66 'h-10 border-[#d8ccb5]/70 bg-white/75 px-3 py-2 text-sm shadow-[inset_0_1px_2px_rgba(73,61,44,0.04)] placeholder:text-[#9aa18b]'
67 const settingsInputClass =
68 'h-8 border-[#d8ccb5]/70 bg-white/75 px-2.5 py-1.5 text-xs shadow-[inset_0_1px_2px_rgba(73,61,44,0.04)] placeholder:text-[#9aa18b]'
69 const settingsSelectTriggerClass =
70 'h-8 border-[#d8ccb5]/70 bg-white/75 px-2.5 py-1.5 text-xs shadow-[inset_0_1px_2px_rgba(73,61,44,0.04)]'
71 const compactSelectContentClass = 'text-xs'
72 const compactSelectItemClass = 'px-2.5 py-1.5 text-xs'
73 const delay = (ms: number): Promise<void> =>
74 new Promise((resolve) => window.setTimeout(resolve, ms))
75
76 const buildNeutralInitialPrompt = (args: {
77 topic: string
78 pageCount: number
79 styleLabel: string
80 }): string =>
81 [
82 `Create a ${args.pageCount}-slide presentation about "${args.topic}".`,
83 `Style preset: ${args.styleLabel}.`,
84 'Determine the presentation content language from the topic, detailed brief, and source documents; do not infer it from the application UI language or this instruction language.'
85 ].join('\n')
86
87 const resolvePageCount = (raw: string): number => {
88 const parsed = Number.parseInt(raw, 10)
89 if (!Number.isFinite(parsed)) return DEFAULT_PAGE_COUNT
90 return Math.min(MAX_PAGE_COUNT, Math.max(MIN_PAGE_COUNT, parsed))
91 }
92
93 export function SessionCreatePage(): ReactElement {
94 const navigate = useNavigate()
95 const { createSession, loading } = useSessionStore()
96 const { settings, imageModelConfigs } = useSettingsStore()
97 const { success, error, warning } = useToastStore()
98 const modelAction = useModelAction()
99 const { modelConfigs, selectedModelConfigId, ensureModelActive } = modelAction
100 const t = useT()
101 const [submitting, setSubmitting] = useState(false)
102 const [topic, setTopic] = useState('')
103 const [brief, setBrief] = useState('')
104 const [briefMode, setBriefMode] = useState<'edit' | 'preview'>('edit')
105 const [selectedAnimationPreferenceIds, setSelectedAnimationPreferenceIds] = useState<
106 AnimationPreferenceId[]
107 >([])
108 const [pageCount, setPageCount] = useState(String(DEFAULT_PAGE_COUNT))
109 const [slideSizeId, setSlideSizeId] = useState<SlideSizePresetId>(DEFAULT_SLIDE_SIZE_ID)
110 const [selectedStyleId, setSelectedStyleId] = useState('')
111 const [selectedTitleFontId, setSelectedTitleFontId] = useState('auto')
112 const [selectedBodyFontId, setSelectedBodyFontId] = useState('auto')
113 const [visualEnabled, setVisualEnabled] = useState(false)
114 const [selectedImageModelConfigId, setSelectedImageModelConfigId] = useState('')
115 const [styleOptions, setStyleOptions] = useState<
116 Array<{
117 id: string
118 styleKey?: string
119 label: string
120 description: string
121 styleCase?: string
122 imageGenerationPrompt?: string
123 thumbnailPath?: string | null
124 previewPath?: string | null
125 favoriteAt?: number | null
126 }>
127 >([])
128 const [fontOptions, setFontOptions] = useState<FontListItem[]>([])
129 const [attachedReferenceFile, setAttachedReferenceFile] = useState<AttachedReferenceFile | null>(
130 null
131 )
132 const [parsingDocument, setParsingDocument] = useState(false)
133 const [documentParseError, setDocumentParseError] = useState<string | null>(null)
134 const [referenceDocumentPath, setReferenceDocumentPath] = useState<string | null>(null)
135 const [suggestionDraft, setSuggestionDraft] = useState<DocumentPlanSuggestionDraft | null>(null)
136 const [acceptedSourcePlan, setAcceptedSourcePlan] =
137 useState<DocumentPlanSuggestion['sourcePlan']>(undefined)
138 const [suggestionDialogOpen, setSuggestionDialogOpen] = useState(false)
139 const [applyTopicSuggestion, setApplyTopicSuggestion] = useState(false)
140 const [applyPageCountSuggestion, setApplyPageCountSuggestion] = useState(false)
141 const [applyBriefSuggestion, setApplyBriefSuggestion] = useState(false)
142 const documentInputRef = useRef<HTMLInputElement | null>(null)
143 const pendingImageReference = attachedReferenceFile?.type === 'image'
144 const usableImageModelConfigs = useMemo(
145 () => getUsableImageModelConfigs(imageModelConfigs),
146 [imageModelConfigs]
147 )
148
149 useEffect(() => {
150 if (usableImageModelConfigs.length === 0) {
151 setVisualEnabled(false)
152 setSelectedImageModelConfigId('')
153 return
154 }
155 if (!visualEnabled) return
156 setSelectedImageModelConfigId((current) =>
157 usableImageModelConfigs.some((config) => config.id === current)
158 ? current
159 : resolveDefaultImageModelConfigId(usableImageModelConfigs)
160 )
161 }, [usableImageModelConfigs, visualEnabled])
162
163 const validateForm = (modelConfigId = selectedModelConfigId): string => {
164 const topicText = topic.trim()
165 if (!topicText) return t('home.validationTopic')
166
167 if (!styleOptions.length) return t('home.validationStylesLoading')
168 if (!selectedStyleId) return t('home.validationStyle')
169 const selectedStyle = styleOptions.find((option) => option.id === selectedStyleId)
170 if (!selectedStyle) return t('home.validationStyleMissing')
171
172 const pageCountText = pageCount.trim()
173 if (!pageCountText)
174 return t('home.validationPageCount', { min: MIN_PAGE_COUNT, max: MAX_PAGE_COUNT })
175 if (!/^\d+$/.test(pageCountText)) return t('home.validationPageCountNumber')
176 const rawPageCount = Number.parseInt(pageCountText, 10)
177 if (rawPageCount < MIN_PAGE_COUNT || rawPageCount > MAX_PAGE_COUNT) {
178 return t('home.validationPageCountRange', { min: MIN_PAGE_COUNT, max: MAX_PAGE_COUNT })
179 }
180
181 const briefText = brief.trim()
182 if (!briefText) return t('home.validationBrief')
183
184 const selectedModelConfig = modelConfigs.find((config) => config.id === modelConfigId)
185 const resolvedApiKey = (selectedModelConfig?.apiKey || '').trim()
186 const resolvedModel = (selectedModelConfig?.model || '').trim()
187 const resolvedStoragePath = (settings?.storagePath || '').trim()
188 if (!resolvedApiKey || !resolvedModel || !resolvedStoragePath) return t('home.settingsRequired')
189
190 if (
191 visualEnabled &&
192 !usableImageModelConfigs.some((config) => config.id === selectedImageModelConfigId)
193 ) {
194 return t('home.imageModelRequired')
195 }
196
197 return ''
198 }
199
200 const requiredReady = (() => {
201 const topicText = topic.trim()
202 const pageCountText = pageCount.trim()
203 const briefText = brief.trim()
204 if (!topicText || !selectedStyleId || !selectedModelConfigId || !briefText) return false
205 if (!/^\d+$/.test(pageCountText)) return false
206 const n = Number.parseInt(pageCountText, 10)
207 return n >= MIN_PAGE_COUNT && n <= MAX_PAGE_COUNT
208 })()
209
210 const loadStyleOptions = useCallback(
211 async (preferredStyleId?: string): Promise<void> => {
212 try {
213 const { items } = await ipc.listStyles()
214 const sorted = [...items].sort(
215 (a, b) =>
216 (b.favoriteAt || 0) - (a.favoriteAt || 0) ||
217 (b.updatedAt || 0) - (a.updatedAt || 0) ||
218 (b.createdAt || 0) - (a.createdAt || 0) ||
219 a.id.localeCompare(b.id)
220 )
221 const options = sorted.map((item) => ({
222 id: item.id,
223 styleKey: item.styleKey,
224 label: item.label,
225 description: item.description,
226 styleCase: item.styleCase,
227 imageGenerationPrompt: item.imageGenerationPrompt,
228 thumbnailPath: item.thumbnailPath,
229 previewPath: item.previewPath,
230 favoriteAt: item.favoriteAt
231 }))
232 setStyleOptions(options)
233 setSelectedStyleId((current) => {
234 if (preferredStyleId && options.some((option) => option.id === preferredStyleId)) {
235 return preferredStyleId
236 }
237 if (current && options.some((option) => option.id === current)) return current
238 return options.length > 0 ? options[0].id : ''
239 })
240 } catch (err) {
241 error(t('home.styleLoadFailed'), {
242 description: err instanceof Error ? err.message : t('common.retryLater')
243 })
244 }
245 },
246 [error, t]
247 )
248
249 const loadFontOptions = useCallback(async (): Promise<void> => {
250 try {
251 const { googleFonts, userFonts } = await ipc.listFonts()
252 const options = [...userFonts, ...googleFonts]
253 setFontOptions(options)
254 const ids = new Set(options.map((font) => `${font.source}:${font.id}`))
255 setSelectedTitleFontId((current) =>
256 current === 'auto' || ids.has(current) ? current : 'auto'
257 )
258 setSelectedBodyFontId((current) =>
259 current === 'auto' || ids.has(current) ? current : 'auto'
260 )
261 } catch {
262 setFontOptions([])
263 setSelectedTitleFontId('auto')
264 setSelectedBodyFontId('auto')
265 }
266 }, [])
267
268 useEffect(() => {
269 void loadStyleOptions()
270 }, [loadStyleOptions])
271
272 useEffect(() => {
273 void loadFontOptions()
274 }, [loadFontOptions])
275
276 const handleSubmit = async (modelConfigId: string): Promise<void> => {
277 if (parsingDocument) {
278 warning(t('home.referenceProcessingWait'))
279 return
280 }
281 if (pendingImageReference) {
282 warning(t('home.completeInfoTitle'), { description: t('home.imageReferenceNeedsParse') })
283 return
284 }
285 const validationError = validateForm(modelConfigId)
286 if (validationError) {
287 if (validationError === t('home.settingsRequired')) {
288 warning(t('home.settingsRequiredTitle'), {
289 description: t('home.settingsRequired'),
290 action: {
291 label: t('home.goToSettings'),
292 onClick: () => navigate('/settings')
293 }
294 })
295 return
296 }
297 warning(t('home.completeInfoTitle'), { description: validationError })
298 return
299 }
300 const selectedStyle = styleOptions.find((option) => option.id === selectedStyleId)!
301 const findFontBySelectId = (id: string): FontListItem | undefined =>
302 fontOptions.find((font) => `${font.source}:${font.id}` === id)
303 const selectedTitleFont = findFontBySelectId(selectedTitleFontId)
304 const selectedBodyFont = findFontBySelectId(selectedBodyFontId)
305 const fontSelection: FontSelection =
306 selectedTitleFont && selectedBodyFont
307 ? {
308 mode: 'pair',
309 title: {
310 source: selectedTitleFont.source,
311 family: selectedTitleFont.family,
312 id: selectedTitleFont.id
313 },
314 body: {
315 source: selectedBodyFont.source,
316 family: selectedBodyFont.family,
317 id: selectedBodyFont.id
318 }
319 }
320 : { mode: 'auto' }
321 const topicText = topic.trim()
322 const briefText = brief.trim()
323 const safePageCount = Number.parseInt(pageCount.trim(), 10)
324 const initialPrompt =
325 briefText ||
326 buildNeutralInitialPrompt({
327 topic: topicText || 'Untitled topic',
328 pageCount: safePageCount,
329 styleLabel: selectedStyle.label
330 })
331
332 setSubmitting(true)
333 try {
334 const resolvedModelConfigId = await ensureModelActive(modelConfigId)
335 if (!resolvedModelConfigId) return
336 const sessionId = await createSession({
337 topic: topicText,
338 styleId: selectedStyleId,
339 modelConfigId: resolvedModelConfigId,
340 pageCount: safePageCount,
341 slideSizeId,
342 referenceDocumentPath: referenceDocumentPath || undefined,
343 sourcePlan: acceptedSourcePlan,
344 fontSelection,
345 visualEnabled,
346 imageModelConfigId: visualEnabled ? selectedImageModelConfigId : undefined
347 })
348 success(t('home.sessionCreated'), {
349 description: t('home.generationStarted'),
350 duration: 1000
351 })
352 setPageCount(String(safePageCount))
353 await delay(500)
354 navigate(`/sessions/${sessionId}/generating`, {
355 state: {
356 initialPrompt,
357 modelConfigId: resolvedModelConfigId,
358 animationPreferences: normalizeAnimationPreferences(selectedAnimationPreferenceIds)
359 }
360 })
361 } catch (err) {
362 error(t('home.sessionCreateFailed'), {
363 description: err instanceof Error ? err.message : t('common.retryLater')
364 })
365 } finally {
366 setSubmitting(false)
367 }
368 }
369
370 const handleChooseReferenceClick = async (): Promise<void> => {
371 if (parsingDocument) return
372 documentInputRef.current?.click()
373 }
374
375 const handleDocumentFilesSelected = async (files: FileList | null): Promise<void> => {
376 const selectedFiles = Array.from(files || [])
377 if (documentInputRef.current) {
378 documentInputRef.current.value = ''
379 }
380 if (selectedFiles.length === 0) return
381 if (selectedFiles.length > 1) {
382 const message = t('home.documentSingleOnly')
383 setDocumentParseError(message)
384 error(t('home.documentCountExceeded'), {
385 description: message
386 })
387 return
388 }
389 const selectedFile = selectedFiles[0]
390 const isImage = isSupportedImageFile(selectedFile)
391 const maxSizeMb = isImage ? MAX_IMAGE_SIZE_MB : MAX_DOCUMENT_SIZE_MB
392 const maxSizeBytes = isImage ? MAX_IMAGE_SIZE_BYTES : MAX_DOCUMENT_SIZE_BYTES
393 if (selectedFile.size > maxSizeBytes) {
394 const message = isImage
395 ? t('home.imageTooLarge', { maxSize: maxSizeMb })
396 : t('home.documentTooLarge', { maxSize: maxSizeMb })
397 setDocumentParseError(message)
398 error(t('home.documentTooLargeTitle'), {
399 description: message
400 })
401 return
402 }
403
404 const payloadFiles = selectedFiles
405 .map((file) => ({
406 path: window.electron?.getPathForFile?.(file) || '',
407 name: file.name
408 }))
409 .filter((file) => file.path)
410
411 if (payloadFiles.length === 0) {
412 setDocumentParseError(t('home.documentPathFailed'))
413 error(t('home.documentPathFailedTitle'))
414 return
415 }
416
417 setParsingDocument(true)
418 setDocumentParseError(null)
419 try {
420 const result = await ipc.prepareReferenceDocument({ files: payloadFiles })
421 const referenceFile = result.files[0]
422 setAttachedReferenceFile(referenceFile || null)
423 setReferenceDocumentPath(
424 referenceFile && referenceFile.type !== 'image' ? referenceFile.path : null
425 )
426 setSuggestionDraft(null)
427 setAcceptedSourcePlan(undefined)
428 success(isImage ? t('home.imageReferenceAttachedNeedsParse') : t('home.referenceAttached'), {
429 description: referenceFile?.name || selectedFile.name
430 })
431 } catch (err) {
432 const message = err instanceof Error ? err.message : t('common.retryLater')
433 setDocumentParseError(message)
434 error(t('home.referenceAttachFailed'), {
435 description: message
436 })
437 } finally {
438 setParsingDocument(false)
439 }
440 }
441
442 const handleRemoveReferenceFile = (): void => {
443 setAttachedReferenceFile(null)
444 setReferenceDocumentPath(null)
445 setSuggestionDraft(null)
446 setAcceptedSourcePlan(undefined)
447 setDocumentParseError(null)
448 }
449
450 const handleRevealReferenceFile = async (): Promise<void> => {
451 if (!attachedReferenceFile) return
452 try {
453 await ipc.revealFile(attachedReferenceFile.path)
454 } catch (err) {
455 error(t('home.revealReferenceFileFailed'), {
456 description: err instanceof Error ? err.message : t('common.retryLater')
457 })
458 }
459 }
460
461 const handleParseImageReference = async (
462 modelConfigId = selectedModelConfigId
463 ): Promise<void> => {
464 if (!attachedReferenceFile || attachedReferenceFile.type !== 'image' || parsingDocument) return
465 const resolvedModelConfigId = await ensureModelActive(modelConfigId)
466 if (!resolvedModelConfigId) return
467
468 setParsingDocument(true)
469 setDocumentParseError(null)
470 try {
471 const result = await ipc.parseImageReferenceDocument({
472 file: { path: attachedReferenceFile.path, name: attachedReferenceFile.name },
473 modelConfigId: resolvedModelConfigId
474 })
475 const referenceFile = result.files[0]
476 if (!referenceFile) throw new Error(t('common.retryLater'))
477 setAttachedReferenceFile(referenceFile)
478 setReferenceDocumentPath(referenceFile.path)
479 setSuggestionDraft(null)
480 setAcceptedSourcePlan(undefined)
481 setSuggestionDialogOpen(false)
482 success(t('home.imageReferenceParsed'), { description: referenceFile.name })
483 } catch (err) {
484 const message = err instanceof Error ? err.message : t('common.retryLater')
485 setDocumentParseError(message)
486 error(t('home.documentParseFailed'), { description: message })
487 } finally {
488 setParsingDocument(false)
489 }
490 }
491
492 const handleAnalyzeReference = async (modelConfigId: string): Promise<void> => {
493 if (!attachedReferenceFile || parsingDocument) return
494 const resolvedModelConfigId = await ensureModelActive(modelConfigId)
495 if (!resolvedModelConfigId) return
496
497 setParsingDocument(true)
498 setDocumentParseError(null)
499 try {
500 const result = await ipc.parseDocumentPlan({
501 files: [{ path: attachedReferenceFile.path, name: attachedReferenceFile.name }],
502 topic: topic.trim(),
503 existingBrief: brief.trim(),
504 modelConfigId: resolvedModelConfigId
505 })
506 const nextSuggestion = {
507 topic: result.topic,
508 pageCount: result.pageCount,
509 briefText: result.briefText,
510 sourcePlan: result.sourcePlan
511 }
512 const referenceFile = result.files[0] || attachedReferenceFile
513 setAttachedReferenceFile(referenceFile)
514 setReferenceDocumentPath(referenceFile.type !== 'image' ? referenceFile.path : null)
515 setSuggestionDraft(buildSuggestionDraft(nextSuggestion))
516 setAcceptedSourcePlan(undefined)
517 setApplyTopicSuggestion(!topic.trim())
518 setApplyPageCountSuggestion(!result.sourcePlan?.pageSkeleton.length && !pageCount.trim())
519 setApplyBriefSuggestion(Boolean(result.sourcePlan?.pageSkeleton.length) || !brief.trim())
520 setSuggestionDialogOpen(true)
521 success(t('home.documentParsed'))
522 } catch (err) {
523 const message = err instanceof Error ? err.message : t('common.retryLater')
524 setDocumentParseError(message)
525 error(t('home.documentParseFailed'), {
526 description: message
527 })
528 } finally {
529 setParsingDocument(false)
530 }
531 }
532
533 const applyDocumentSuggestion = (): void => {
534 const draft = suggestionDraft
535 if (!draft) return
536 const sourceOutlinePageCount = draft.sourcePlan?.pageSkeleton.length || 0
537 const hasSourceOutline = sourceOutlinePageCount > 0
538 const shouldApplySourceOutline = hasSourceOutline && applyBriefSuggestion
539
540 if (applyTopicSuggestion) setTopic(draft.topic)
541 if (shouldApplySourceOutline) {
542 setPageCount(String(resolvePageCount(String(sourceOutlinePageCount))))
543 } else if (applyPageCountSuggestion) {
544 setPageCount(String(resolvePageCount(draft.pageCount)))
545 }
546 if (applyBriefSuggestion) {
547 setBrief(
548 draft.sourcePlan?.pageSkeleton.length
549 ? formatSourceOutlineBriefText(draft.sourcePlan.pageSkeleton)
550 : draft.briefText
551 )
552 }
553 setAcceptedSourcePlan(shouldApplySourceOutline ? draft.sourcePlan : undefined)
554 setSuggestionDialogOpen(false)
555 }
556
557 const titleFontOptions = fontOptions.filter((font) => font.role.includes('title'))
558 const bodyFontOptions = fontOptions.filter((font) => font.role.includes('body'))
559 const availableTitleFonts = titleFontOptions.length > 0 ? titleFontOptions : fontOptions
560 const availableBodyFonts = bodyFontOptions.length > 0 ? bodyFontOptions : fontOptions
561 const getSelectedFontLabel = (id: string): string => {
562 if (id === 'auto') return t('home.fontSchemeAuto')
563 const selectedFont = fontOptions.find((font) => `${font.source}:${font.id}` === id)
564 return selectedFont?.family || t('home.fontSchemeAuto')
565 }
566 const renderFontSelectItem = (font: FontListItem, roleLabel: string): ReactElement => {
567 const isUploaded = font.source === 'uploaded'
568 const sourceLabel = isUploaded ? t('home.fontSourceUploaded') : t('home.fontSourceBuiltIn')
569 return (
570 <SelectItem
571 key={`${font.source}:${font.id}`}
572 value={`${font.source}:${font.id}`}
573 textValue={font.family}
574 className={compactSelectItemClass}
575 >
576 <span className="flex min-w-0 items-center gap-2">
577 <span
578 className={`shrink-0 rounded px-1 py-0.5 text-[10px] font-medium ${
579 isUploaded ? 'bg-[#eef9ec] text-[#4a7a46]' : 'bg-[#eef6ff] text-[#3e6685]'
580 }`}
581 >
582 {sourceLabel}
583 </span>
584 <span className="min-w-0 truncate">{font.family}</span>
585 <span className="ml-auto shrink-0 text-[10px] text-[#8b927f]">{roleLabel}</span>
586 </span>
587 </SelectItem>
588 )
589 }
590 const fontSelectHint =
591 selectedTitleFontId === 'auto' && selectedBodyFontId === 'auto'
592 ? t('home.fontSchemeAutoHint')
593 : selectedTitleFontId !== 'auto' && selectedBodyFontId !== 'auto'
594 ? t('home.fontSchemeManualHint')
595 : t('home.fontSchemePartialHint')
596
597 return (
598 <div className="session-create-page mx-auto flex min-h-full w-full max-w-7xl flex-col gap-4 px-5 py-4 sm:px-6">
599 <div className="flex max-w-4xl flex-col items-start gap-1.5 border-b border-[#e0d8c8] px-1 pb-4">
600 <p className="rounded bg-[#d4e4c1]/78 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-[#3e4a32]">
601 {t('home.eyebrow')}
602 </p>
603 <h1 className="organic-serif text-[32px] font-semibold leading-tight text-[#3e4a32]">
604 {t('home.title')}
605 </h1>
606 <p className="text-sm leading-6 text-[#5d6b4d]">{t('home.description')}</p>
607 </div>
608
609 <div>
610 <input
611 ref={documentInputRef}
612 type="file"
613 accept=".md,.txt,.text,.csv,.docx,image/png,image/jpeg,image/webp"
614 multiple={false}
615 className="hidden"
616 onChange={(event) => void handleDocumentFilesSelected(event.target.files)}
617 />
618 {documentParseError && (
619 <div className="mb-4 flex items-start gap-2 rounded-xl bg-[#fff2ef] px-4 py-3 text-xs text-[#8a3d33]">
620 <CircleAlert className="mt-0.5 h-4 w-4 shrink-0" />
621 <span>{documentParseError}</span>
622 </div>
623 )}
624
625 <Card
626 data-session-create-workspace
627 className="session-create-workspace overflow-hidden rounded-2xl border border-[#ded8cb] shadow-[0_12px_28px_rgba(86,73,54,0.06)]"
628 >
629 <CardContent className="grid p-0 lg:grid-cols-[minmax(0,1.55fr)_minmax(320px,0.85fr)] [&_label]:text-[13px] [&_label]:font-semibold [&_label]:text-[#3e4a32]">
630 <main
631 data-session-create-main
632 className="flex min-w-0 flex-col gap-5 bg-transparent p-5 lg:p-6"
633 >
634 <div>
635 <label className="mb-2 block">{t('home.topic')}</label>
636 <Input
637 placeholder={t('home.topicPlaceholder')}
638 value={topic}
639 onChange={(e) => setTopic(e.target.value)}
640 required
641 className={compactInputClass}
642 />
643 </div>
644
645 <div>
646 <div className="mb-2 flex items-center justify-between">
647 <label className="block font-medium">{t('home.brief')}</label>
648 <div className="flex items-center gap-1 rounded-lg bg-[#fffdf8]/84 p-0.5">
649 <button
650 type="button"
651 onClick={() => setBriefMode('edit')}
652 className={`flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors ${
653 briefMode === 'edit'
654 ? 'bg-[#8fbc8f] text-[#3e4a32]'
655 : 'text-[#5d6b4d] hover:bg-[#d4e4c1]/70 hover:text-[#3e4a32]'
656 }`}
657 >
658 <Pencil className="h-3.5 w-3.5" />
659 {t('common.edit')}
660 </button>
661 <button
662 type="button"
663 onClick={() => setBriefMode('preview')}
664 className={`flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors ${
665 briefMode === 'preview'
666 ? 'bg-[#8fbc8f] text-[#3e4a32]'
667 : 'text-[#5d6b4d] hover:bg-[#d4e4c1]/70 hover:text-[#3e4a32]'
668 }`}
669 >
670 <Eye className="h-3.5 w-3.5" />
671 {t('common.preview')}
672 </button>
673 </div>
674 </div>
675 <div className="overflow-hidden rounded-xl border border-[#e0d8c8] bg-[#fffdf8]/90">
676 {briefMode === 'edit' ? (
677 <Textarea
678 placeholder={t('home.briefPlaceholder')}
679 rows={8}
680 value={brief}
681 required
682 onChange={(e) => {
683 setAcceptedSourcePlan(undefined)
684 setBrief(e.target.value)
685 }}
686 className="min-h-[300px] resize-y border-0 bg-transparent px-4 py-3 text-xs leading-5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
687 />
688 ) : (
689 <ScrollArea className="h-[300px] bg-transparent" viewportClassName="p-4">
690 <ReactMarkdown
691 components={{
692 h1: ({ children }) => (
693 <h1 className="mb-2 text-lg font-semibold text-foreground">
694 {children}
695 </h1>
696 ),
697 h2: ({ children }) => (
698 <h2 className="mb-2 mt-3 text-base font-semibold text-foreground">
699 {children}
700 </h2>
701 ),
702 h3: ({ children }) => (
703 <h3 className="mb-1.5 mt-2.5 text-sm font-semibold text-foreground">
704 {children}
705 </h3>
706 ),
707 p: ({ children }) => (
708 <p className="mb-2 text-xs leading-5 text-muted-foreground">
709 {children}
710 </p>
711 ),
712 ul: ({ children }) => (
713 <ul className="mb-2 list-disc space-y-0.5 pl-5 text-xs text-muted-foreground">
714 {children}
715 </ul>
716 ),
717 ol: ({ children }) => (
718 <ol className="mb-2 list-decimal space-y-0.5 pl-5 text-xs text-muted-foreground">
719 {children}
720 </ol>
721 ),
722 li: ({ children }) => <li>{children}</li>,
723 code: ({ children }) => (
724 <code className="rounded bg-muted px-1.5 py-0.5 text-xs text-foreground">
725 {children}
726 </code>
727 ),
728 blockquote: ({ children }) => (
729 <blockquote className="mb-2 border-l-2 border-border pl-3 text-xs text-muted-foreground">
730 {children}
731 </blockquote>
732 )
733 }}
734 >
735 {brief || t('home.briefPlaceholder')}
736 </ReactMarkdown>
737 </ScrollArea>
738 )}
739 </div>
740 <div
741 data-session-create-reference-actions
742 className="mt-2 flex flex-wrap items-center justify-end gap-2"
743 >
744 {attachedReferenceFile && (
745 <div className="flex min-w-0 max-w-full">
746 <span
747 className={`inline-flex h-8 max-w-full items-center gap-1.5 rounded-lg border px-2.5 text-[11px] ${
748 pendingImageReference
749 ? 'border-[#e7a19a]/80 bg-[#fff1ef] text-[#9a3f35]'
750 : 'border-[#c8d6ba] bg-[#fffdf8]/84 text-[#5d6b4d]'
751 }`}
752 title={
753 pendingImageReference
754 ? t('home.imageReferenceTagTooltip')
755 : attachedReferenceFile.path
756 }
757 >
758 <FileText className="h-3 w-3 shrink-0" />
759 <button
760 type="button"
761 onClick={() => void handleRevealReferenceFile()}
762 className="w-[150px] min-w-0 max-w-[150px] truncate text-left hover:underline"
763 title={t('home.revealReferenceFileTooltip')}
764 aria-label={t('home.revealReferenceFile')}
765 >
766 {attachedReferenceFile.name}
767 </button>
768 {pendingImageReference ? (
769 <>
770 <span className="shrink-0 text-[#b24d43]">
771 {t('home.imageReferenceNeedsParseShort')}
772 </span>
773 <button
774 type="button"
775 onClick={() => void handleParseImageReference(selectedModelConfigId)}
776 disabled={parsingDocument || submitting}
777 className="ml-1 inline-flex h-4 shrink-0 items-center rounded-full bg-[#c84f45] px-1.5 text-[10px] font-medium text-white hover:bg-[#ad4239] disabled:cursor-not-allowed disabled:opacity-60"
778 aria-label={t('home.parseImageReference')}
779 >
780 {parsingDocument
781 ? t('home.parsingImageReference')
782 : t('home.parseImageReference')}
783 </button>
784 </>
785 ) : null}
786 <button
787 type="button"
788 onClick={handleRemoveReferenceFile}
789 className={`inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full ${
790 pendingImageReference
791 ? 'text-[#a04940] hover:bg-[#f2c2bd]'
792 : 'text-[#657552] hover:bg-[#c8ddb2]'
793 }`}
794 aria-label={t('home.removeReference')}
795 >
796 <X className="h-2.5 w-2.5" />
797 </button>
798 </span>
799 </div>
800 )}
801 <TooltipProvider delayDuration={180}>
802 <div className="flex flex-wrap items-center justify-end gap-2">
803 {!attachedReferenceFile && (
804 <Tooltip>
805 <TooltipTrigger asChild>
806 <span className="inline-flex">
807 <Button
808 type="button"
809 variant="ghost"
810 size="sm"
811 onClick={() => {
812 void handleChooseReferenceClick()
813 }}
814 disabled={parsingDocument}
815 className="h-8 shrink-0 rounded-lg border border-[#e0d8c8] bg-[#fffdf8]/84 px-3 text-xs font-medium text-[#5d6b4d] shadow-none hover:bg-[#d4e4c1]/65 hover:text-[#3e4a32]"
816 >
817 {parsingDocument ? (
818 <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
819 ) : (
820 <FileText className="mr-1.5 h-3.5 w-3.5" />
821 )}
822 {parsingDocument
823 ? t('home.processingReference')
824 : t('home.uploadReference')}
825 </Button>
826 </span>
827 </TooltipTrigger>
828 <TooltipContent side="bottom" align="start">
829 {t('home.uploadReferenceTooltip', {
830 maxSize: MAX_DOCUMENT_SIZE_MB,
831 imageMaxSize: MAX_IMAGE_SIZE_MB
832 })}
833 </TooltipContent>
834 </Tooltip>
835 )}
836 {attachedReferenceFile && !pendingImageReference && (
837 <Tooltip>
838 <TooltipTrigger asChild>
839 <span>
840 <ModelSplitButton
841 modelAction={modelAction}
842 ariaLabel={t('home.analyzeReference')}
843 label={t('home.analyzeReference')}
844 loadingLabel={t('home.analyzingReference')}
845 loading={parsingDocument}
846 disabled={!attachedReferenceFile}
847 icon={Sparkles}
848 tone="primary"
849 dropdownAlign="end"
850 className="box-border h-8 rounded-lg border-0 bg-[#8fbc8f] shadow-[0_6px_14px_rgba(113,134,95,0.15)]"
851 mainClassName="h-full bg-transparent px-2.5 text-xs text-[#3e4a32] shadow-none hover:bg-white/10 hover:text-[#3e4a32] hover:shadow-none"
852 triggerClassName="h-full w-8 px-0 text-[#3e4a32] hover:text-[#3e4a32]"
853 onRun={handleAnalyzeReference}
854 />
855 </span>
856 </TooltipTrigger>
857 <TooltipContent side="top" align="start" className="max-w-xs">
858 {t('home.analyzeReferenceTooltip')}
859 </TooltipContent>
860 </Tooltip>
861 )}
862 </div>
863 </TooltipProvider>
864 </div>
865 </div>
866
867 <div className="mt-1 flex">
868 <ModelSplitButton
869 modelAction={modelAction}
870 ariaLabel={t('home.createAndStart')}
871 label={t('home.createAndStart')}
872 loadingLabel={t('home.creating')}
873 loading={submitting || loading}
874 disabled={!requiredReady || parsingDocument}
875 icon={Sparkles}
876 tone="primary"
877 className="w-full sm:w-auto"
878 mainClassName="min-w-0 flex-1 h-10 px-4 sm:flex-none sm:min-w-[176px]"
879 onRun={handleSubmit}
880 />
881 </div>
882 </main>
883
884 <aside
885 data-session-create-settings
886 className="min-w-0 bg-transparent p-5 lg:border-l lg:border-[#ded8cb] lg:p-6"
887 >
888 <div className="space-y-6">
889 <section>
890 <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_88px] lg:grid-cols-[minmax(0,1fr)_82px]">
891 <div>
892 <label className="mb-2 block">{t('home.style')}</label>
893 <StyleSelect
894 value={selectedStyleId}
895 onChange={setSelectedStyleId}
896 options={styleOptions}
897 placeholder={t('home.stylePlaceholder')}
898 recommendation={{ topic, brief, modelConfigId: selectedModelConfigId }}
899 compact
900 className="h-8 border-[#c8d6ba] bg-[#fffdf8]/90 px-2.5 py-1.5 text-xs shadow-none"
901 dropdownAlign="end"
902 dropdownClassName="w-[min(700px,calc(100vw-3rem))]"
903 />
904 </div>
905
906 <div>
907 <label className="mb-2 block">{t('home.pageCount')}</label>
908 <Input
909 type="text"
910 inputMode="numeric"
911 pattern="[0-9]*"
912 placeholder={`${MIN_PAGE_COUNT}-${MAX_PAGE_COUNT}`}
913 value={pageCount}
914 required
915 onChange={(e) => {
916 const next = e.target.value
917 setAcceptedSourcePlan(undefined)
918 if (next === '') {
919 setPageCount('')
920 return
921 }
922 if (!/^\d+$/.test(next)) return
923 setPageCount(next)
924 }}
925 onBlur={() => {
926 setPageCount(String(resolvePageCount(pageCount)))
927 }}
928 className={settingsInputClass}
929 />
930 </div>
931 </div>
932 <div className="mt-3">
933 <label className="mb-2 block">{t('home.slideSize')}</label>
934 <Select
935 value={slideSizeId}
936 onValueChange={(value) => setSlideSizeId(value as SlideSizePresetId)}
937 >
938 <SelectTrigger className={settingsSelectTriggerClass}>
939 <SelectValue />
940 </SelectTrigger>
941 <SelectContent className={compactSelectContentClass}>
942 {SLIDE_SIZE_PRESETS.map((preset) => (
943 <SelectItem
944 key={preset.id}
945 value={preset.id}
946 className={compactSelectItemClass}
947 >
948 {preset.id === 'wide-16-9'
949 ? t('home.slideSizeWide')
950 : preset.id === 'vertical-9-16'
951 ? t('home.slideSizeVertical')
952 : preset.id === 'standard-4-3'
953 ? t('home.slideSizeStandard')
954 : preset.id === 'square-1-1'
955 ? t('home.slideSizeSquare')
956 : preset.id === 'vertical-3-4'
957 ? t('home.slideSizePortrait')
958 : t('home.slideSizeXiaohongshu')}
959 <span className="ml-2 text-[10px] text-[#8b927f]">
960 {preset.width}×{preset.height}
961 </span>
962 </SelectItem>
963 ))}
964 </SelectContent>
965 </Select>
966 </div>
967 </section>
968
969 <section>
970 <label className="mb-2 block">{t('home.fontScheme')}</label>
971 <div className="grid min-w-0 grid-cols-2 overflow-hidden rounded-lg border border-[#d8ccb5]/70 bg-white/75 shadow-[inset_0_1px_2px_rgba(73,61,44,0.04)]">
972 <Select value={selectedTitleFontId} onValueChange={setSelectedTitleFontId}>
973 <SelectTrigger className="h-8 min-w-0 rounded-none border-0 border-r border-[#d8ccb5]/70 bg-transparent px-2.5 py-1.5 text-xs shadow-none focus:ring-1">
974 <span className="min-w-0 flex-1 truncate text-left">
975 <span className="mr-1.5 text-[10px] font-medium text-[#8b927f]">
976 {t('home.fontPairTitle')}
977 </span>
978 <SelectValue placeholder={t('home.fontSchemeAuto')}>
979 {getSelectedFontLabel(selectedTitleFontId)}
980 </SelectValue>
981 </span>
982 </SelectTrigger>
983 <SelectContent className={compactSelectContentClass}>
984 <SelectItem value="auto" className={compactSelectItemClass}>
985 <span className="flex min-w-0 items-center gap-2">
986 <span className="min-w-0 truncate">{t('home.fontSchemeAuto')}</span>
987 <span className="ml-auto shrink-0 text-[10px] text-[#8b927f]">
988 {t('home.fontPairTitle')}
989 </span>
990 </span>
991 </SelectItem>
992 {availableTitleFonts.map((font) =>
993 renderFontSelectItem(font, t('home.fontPairTitle'))
994 )}
995 </SelectContent>
996 </Select>
997 <Select value={selectedBodyFontId} onValueChange={setSelectedBodyFontId}>
998 <SelectTrigger className="h-8 min-w-0 rounded-none border-0 bg-transparent px-2.5 py-1.5 text-xs shadow-none focus:ring-1">
999 <span className="min-w-0 flex-1 truncate text-left">
1000 <span className="mr-1.5 text-[10px] font-medium text-[#8b927f]">
1001 {t('home.fontPairBody')}
1002 </span>
1003 <SelectValue placeholder={t('home.fontSchemeAuto')}>
1004 {getSelectedFontLabel(selectedBodyFontId)}
1005 </SelectValue>
1006 </span>
1007 </SelectTrigger>
1008 <SelectContent className={compactSelectContentClass}>
1009 <SelectItem value="auto" className={compactSelectItemClass}>
1010 <span className="flex min-w-0 items-center gap-2">
1011 <span className="min-w-0 truncate">{t('home.fontSchemeAuto')}</span>
1012 <span className="ml-auto shrink-0 text-[10px] text-[#8b927f]">
1013 {t('home.fontPairBody')}
1014 </span>
1015 </span>
1016 </SelectItem>
1017 {availableBodyFonts.map((font) =>
1018 renderFontSelectItem(font, t('home.fontPairBody'))
1019 )}
1020 </SelectContent>
1021 </Select>
1022 </div>
1023 <p className="mt-2 text-xs leading-5 text-[#7f8a70]">{fontSelectHint}</p>
1024 </section>
1025
1026 <section>
1027 <label className="flex cursor-pointer items-center gap-2 text-sm font-medium text-[#3e4a32]">
1028 <Checkbox
1029 checked={visualEnabled}
1030 disabled={usableImageModelConfigs.length === 0}
1031 onCheckedChange={(checked) => {
1032 const enabled = checked === true
1033 setVisualEnabled(enabled)
1034 setSelectedImageModelConfigId(
1035 enabled ? resolveDefaultImageModelConfigId(usableImageModelConfigs) : ''
1036 )
1037 }}
1038 />
1039 <span>{t('home.enableImageGeneration')}</span>
1040 </label>
1041 {visualEnabled ? (
1042 <div className="mt-3">
1043 <label className="mb-2 block">{t('home.imageModel')}</label>
1044 <Select
1045 value={selectedImageModelConfigId}
1046 onValueChange={setSelectedImageModelConfigId}
1047 >
1048 <SelectTrigger className={settingsSelectTriggerClass}>
1049 <SelectValue placeholder={t('home.imageModelPlaceholder')} />
1050 </SelectTrigger>
1051 <SelectContent className={compactSelectContentClass}>
1052 {usableImageModelConfigs.map((config) => (
1053 <SelectItem
1054 key={config.id}
1055 value={config.id}
1056 className={compactSelectItemClass}
1057 >
1058 {config.name}
1059 </SelectItem>
1060 ))}
1061 </SelectContent>
1062 </Select>
1063 </div>
1064 ) : null}
1065 <p className="mt-2 text-xs leading-5 text-[#7f8a70]">
1066 {usableImageModelConfigs.length > 0
1067 ? t('home.imageGenerationHint')
1068 : t('home.imageModelUnavailable')}
1069 </p>
1070 </section>
1071
1072 <section>
1073 <label className="mb-2 flex items-center gap-2">
1074 <span>{t('home.animationPreferences')}</span>
1075 <span className="text-[10px] font-medium text-[#8b927f]">
1076 {t('common.optional')}
1077 </span>
1078 </label>
1079 <AnimationPreferenceChips
1080 selectedIds={selectedAnimationPreferenceIds}
1081 onChange={setSelectedAnimationPreferenceIds}
1082 compact
1083 />
1084 </section>
1085 </div>
1086 </aside>
1087 </CardContent>
1088 </Card>
1089 </div>
1090
1091 <SessionCreateSuggestionDialog
1092 open={suggestionDialogOpen}
1093 onOpenChange={setSuggestionDialogOpen}
1094 attachedReferenceFile={attachedReferenceFile}
1095 suggestionDraft={suggestionDraft}
1096 setSuggestionDraft={setSuggestionDraft}
1097 applyTopicSuggestion={applyTopicSuggestion}
1098 setApplyTopicSuggestion={setApplyTopicSuggestion}
1099 applyPageCountSuggestion={applyPageCountSuggestion}
1100 setApplyPageCountSuggestion={setApplyPageCountSuggestion}
1101 applyBriefSuggestion={applyBriefSuggestion}
1102 setApplyBriefSuggestion={setApplyBriefSuggestion}
1103 onApplySelected={applyDocumentSuggestion}
1104 />
1105 </div>
1106 )
1107 }
1108
1108 lines Plain Text