| 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 |