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