| 1 | import { useEffect, useRef, useState } from 'react' |
| 2 | import { useNavigate } from 'react-router-dom' |
| 3 | import { |
| 4 | CircleAlert, |
| 5 | Eye, |
| 6 | FileText, |
| 7 | LayoutTemplate, |
| 8 | Loader2, |
| 9 | Pencil, |
| 10 | Sparkles, |
| 11 | X |
| 12 | } from 'lucide-react' |
| 13 | import { Button } from '../ui/Button' |
| 14 | import { |
| 15 | Dialog, |
| 16 | DialogContent, |
| 17 | DialogDescription, |
| 18 | DialogFooter, |
| 19 | DialogHeader, |
| 20 | DialogTitle |
| 21 | } from '../ui/Dialog' |
| 22 | import { Input, Textarea } from '../ui/Input' |
| 23 | import { ScrollArea } from '../ui/ScrollArea' |
| 24 | import { ModelSplitButton } from '../model/ModelActionButton' |
| 25 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip' |
| 26 | import { useModelAction } from '@renderer/hooks/useModelAction' |
| 27 | import { useT } from '@renderer/i18n' |
| 28 | import { ipc, type TemplateListItem } from '@renderer/lib/ipc' |
| 29 | import { useTemplateStore, useToastStore } from '@renderer/store' |
| 30 | import type { ParsedDocumentPlanResult } from '@shared/generation' |
| 31 | import ReactMarkdown from 'react-markdown' |
| 32 | import { |
| 33 | buildSuggestionDraft, |
| 34 | formatSourceOutlineBriefText, |
| 35 | SessionCreateSuggestionDialog, |
| 36 | type DocumentPlanSuggestion, |
| 37 | type DocumentPlanSuggestionDraft |
| 38 | } from '../session-create/SessionCreateSuggestionDialog' |
| 39 | |
| 40 | const MIN_PAGE_COUNT = 1 |
| 41 | const MAX_PAGE_COUNT = 500 |
| 42 | const MAX_DOCUMENT_SIZE_MB = 10 |
| 43 | const MAX_DOCUMENT_SIZE_BYTES = MAX_DOCUMENT_SIZE_MB * 1024 * 1024 |
| 44 | |
| 45 | type AttachedReferenceFile = ParsedDocumentPlanResult['files'][number] |
| 46 | |
| 47 | const resolvePageCount = (raw: string, fallback: number): number => { |
| 48 | const parsed = Number.parseInt(raw, 10) |
| 49 | if (!Number.isFinite(parsed)) return fallback |
| 50 | return Math.min(MAX_PAGE_COUNT, Math.max(MIN_PAGE_COUNT, parsed)) |
| 51 | } |
| 52 | |
| 53 | const buildTemplateInitialPrompt = (args: { |
| 54 | templateName: string |
| 55 | title: string |
| 56 | pageCount: number |
| 57 | brief: string |
| 58 | }): string => |
| 59 | [ |
| 60 | `Create a ${args.pageCount}-slide presentation titled "${args.title}".`, |
| 61 | `Use the selected template "${args.templateName}" as the fixed visual template reference.`, |
| 62 | 'Regenerate every slide from the new brief/source document. Preserve the template direction for layout roles, visual rhythm, colors, typography, and component treatment, but do not reuse old slide text unless the user asks for it.', |
| 63 | 'Page-count mapping: preserve the template cover/opening role for slide 1 and the closing/ending role for the final slide when possible. If the final deck has more pages than the template, add the extra pages in the middle by reusing or varying relevant middle-page roles. If it has fewer pages, merge or skip less relevant middle-page roles. Do not force one-to-one page matching.', |
| 64 | 'Determine the presentation content language from the brief and source documents; do not infer it from the application UI language or this instruction language.', |
| 65 | '', |
| 66 | 'Brief:', |
| 67 | args.brief |
| 68 | ].join('\n') |
| 69 | |
| 70 | export function TemplateUseDialog({ |
| 71 | template, |
| 72 | onOpenChange |
| 73 | }: { |
| 74 | template: TemplateListItem | null |
| 75 | onOpenChange: (open: boolean) => void |
| 76 | }): React.JSX.Element { |
| 77 | const navigate = useNavigate() |
| 78 | const t = useT() |
| 79 | const { createSessionFromTemplate } = useTemplateStore() |
| 80 | const { success, error, warning } = useToastStore() |
| 81 | const modelAction = useModelAction() |
| 82 | const { selectedModelConfigId, ensureModelActive } = modelAction |
| 83 | const [title, setTitle] = useState('') |
| 84 | const [brief, setBrief] = useState('') |
| 85 | const [briefMode, setBriefMode] = useState<'edit' | 'preview'>('edit') |
| 86 | const [pageCount, setPageCount] = useState('5') |
| 87 | const [attachedReferenceFile, setAttachedReferenceFile] = useState<AttachedReferenceFile | null>( |
| 88 | null |
| 89 | ) |
| 90 | const [referenceDocumentPath, setReferenceDocumentPath] = useState<string | null>(null) |
| 91 | const [parsingDocument, setParsingDocument] = useState(false) |
| 92 | const [documentParseError, setDocumentParseError] = useState<string | null>(null) |
| 93 | const [hasParsedSource, setHasParsedSource] = useState(false) |
| 94 | const [suggestionDraft, setSuggestionDraft] = useState<DocumentPlanSuggestionDraft | null>(null) |
| 95 | const [acceptedSourcePlan, setAcceptedSourcePlan] = |
| 96 | useState<DocumentPlanSuggestion['sourcePlan']>(undefined) |
| 97 | const [suggestionDialogOpen, setSuggestionDialogOpen] = useState(false) |
| 98 | const [applyTitleSuggestion, setApplyTitleSuggestion] = useState(false) |
| 99 | const [applyPageCountSuggestion, setApplyPageCountSuggestion] = useState(false) |
| 100 | const [applyBriefSuggestion, setApplyBriefSuggestion] = useState(false) |
| 101 | const [creating, setCreating] = useState(false) |
| 102 | const documentInputRef = useRef<HTMLInputElement | null>(null) |
| 103 | const open = Boolean(template) |
| 104 | |
| 105 | useEffect(() => { |
| 106 | if (!template) return |
| 107 | setTitle(template.name) |
| 108 | setBrief('') |
| 109 | setBriefMode('edit') |
| 110 | setPageCount(String(resolvePageCount(String(template.pageCount || 5), 5))) |
| 111 | setAttachedReferenceFile(null) |
| 112 | setReferenceDocumentPath(null) |
| 113 | setDocumentParseError(null) |
| 114 | setHasParsedSource(false) |
| 115 | setSuggestionDraft(null) |
| 116 | setAcceptedSourcePlan(undefined) |
| 117 | setSuggestionDialogOpen(false) |
| 118 | }, [template]) |
| 119 | |
| 120 | const close = (): void => { |
| 121 | if (creating || parsingDocument) return |
| 122 | onOpenChange(false) |
| 123 | } |
| 124 | |
| 125 | const ensureUploadPrerequisites = async (): Promise<boolean> => { |
| 126 | const validation = await ipc.validateUploadPrerequisites() |
| 127 | if (validation.ready) return true |
| 128 | warning(t('templates.settingsRequiredTitle'), { |
| 129 | description: validation.message || t('templates.settingsRequiredDescription'), |
| 130 | action: { |
| 131 | label: t('templates.goToSettings'), |
| 132 | onClick: () => navigate('/settings') |
| 133 | } |
| 134 | }) |
| 135 | return false |
| 136 | } |
| 137 | |
| 138 | const handleChooseDocumentClick = async (): Promise<void> => { |
| 139 | if (parsingDocument) return |
| 140 | if (!(await ensureUploadPrerequisites())) return |
| 141 | documentInputRef.current?.click() |
| 142 | } |
| 143 | |
| 144 | const handleDocumentFilesSelected = async (files: FileList | null): Promise<void> => { |
| 145 | const selectedFiles = Array.from(files || []) |
| 146 | if (documentInputRef.current) { |
| 147 | documentInputRef.current.value = '' |
| 148 | } |
| 149 | if (!template || selectedFiles.length === 0) return |
| 150 | if (selectedFiles.length > 1) { |
| 151 | const message = t('templates.documentSingleOnly') |
| 152 | setDocumentParseError(message) |
| 153 | error(t('templates.documentCountExceeded'), { description: message }) |
| 154 | return |
| 155 | } |
| 156 | |
| 157 | const selectedFile = selectedFiles[0] |
| 158 | if (selectedFile.size > MAX_DOCUMENT_SIZE_BYTES) { |
| 159 | const message = t('templates.documentTooLarge', { maxSize: MAX_DOCUMENT_SIZE_MB }) |
| 160 | setDocumentParseError(message) |
| 161 | error(t('templates.documentTooLargeTitle'), { description: message }) |
| 162 | return |
| 163 | } |
| 164 | |
| 165 | const payloadFiles = selectedFiles |
| 166 | .map((file) => ({ |
| 167 | path: window.electron?.getPathForFile?.(file) || '', |
| 168 | name: file.name |
| 169 | })) |
| 170 | .filter((file) => file.path) |
| 171 | if (payloadFiles.length === 0) { |
| 172 | setDocumentParseError(t('templates.documentPathFailed')) |
| 173 | error(t('templates.documentPathFailedTitle')) |
| 174 | return |
| 175 | } |
| 176 | |
| 177 | setParsingDocument(true) |
| 178 | setDocumentParseError(null) |
| 179 | setHasParsedSource(false) |
| 180 | try { |
| 181 | const result = await ipc.prepareReferenceDocument({ files: payloadFiles }) |
| 182 | const referenceFile = result.files[0] |
| 183 | setAttachedReferenceFile(referenceFile || null) |
| 184 | setReferenceDocumentPath( |
| 185 | referenceFile && referenceFile.type !== 'image' ? referenceFile.path : null |
| 186 | ) |
| 187 | setSuggestionDraft(null) |
| 188 | setAcceptedSourcePlan(undefined) |
| 189 | setSuggestionDialogOpen(false) |
| 190 | success(t('templates.referenceAttached'), { |
| 191 | description: referenceFile?.name || selectedFile.name |
| 192 | }) |
| 193 | } catch (err) { |
| 194 | const message = err instanceof Error ? err.message : t('common.retryLater') |
| 195 | setDocumentParseError(message) |
| 196 | error(t('templates.referenceAttachFailed'), { description: message }) |
| 197 | } finally { |
| 198 | setParsingDocument(false) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | const handleRemoveReferenceFile = (): void => { |
| 203 | setAttachedReferenceFile(null) |
| 204 | setReferenceDocumentPath(null) |
| 205 | setDocumentParseError(null) |
| 206 | setHasParsedSource(false) |
| 207 | setSuggestionDraft(null) |
| 208 | setAcceptedSourcePlan(undefined) |
| 209 | setSuggestionDialogOpen(false) |
| 210 | } |
| 211 | |
| 212 | const handleAnalyzeDocument = async (modelConfigId = selectedModelConfigId): Promise<void> => { |
| 213 | if (!template || !attachedReferenceFile || parsingDocument) return |
| 214 | const resolvedModelConfigId = await ensureModelActive(modelConfigId) |
| 215 | if (!resolvedModelConfigId) return |
| 216 | |
| 217 | setParsingDocument(true) |
| 218 | setDocumentParseError(null) |
| 219 | try { |
| 220 | const result = await ipc.parseDocumentPlan({ |
| 221 | files: [{ path: attachedReferenceFile.path, name: attachedReferenceFile.name }], |
| 222 | topic: title.trim() || template.name, |
| 223 | existingBrief: brief.trim(), |
| 224 | modelConfigId: resolvedModelConfigId |
| 225 | }) |
| 226 | const referenceFile = result.files[0] || attachedReferenceFile |
| 227 | setAttachedReferenceFile(referenceFile) |
| 228 | setReferenceDocumentPath(referenceFile.type !== 'image' ? referenceFile.path : null) |
| 229 | const nextSuggestion = { |
| 230 | topic: result.topic || title || template.name, |
| 231 | pageCount: resolvePageCount(String(result.pageCount), 5), |
| 232 | briefText: result.briefText, |
| 233 | sourcePlan: result.sourcePlan |
| 234 | } |
| 235 | setSuggestionDraft(buildSuggestionDraft(nextSuggestion)) |
| 236 | setAcceptedSourcePlan(undefined) |
| 237 | setApplyTitleSuggestion(!title.trim() || title.trim() === template.name) |
| 238 | setApplyPageCountSuggestion(!result.sourcePlan?.pageSkeleton.length) |
| 239 | setApplyBriefSuggestion(Boolean(result.sourcePlan?.pageSkeleton.length) || !brief.trim()) |
| 240 | setSuggestionDialogOpen(true) |
| 241 | setHasParsedSource(true) |
| 242 | success(t('templates.documentParsed'), { |
| 243 | description: t('templates.documentParsedDescription', { count: result.files.length }) |
| 244 | }) |
| 245 | } catch (err) { |
| 246 | const message = err instanceof Error ? err.message : t('common.retryLater') |
| 247 | setDocumentParseError(message) |
| 248 | error(t('templates.documentParseFailed'), { description: message }) |
| 249 | } finally { |
| 250 | setParsingDocument(false) |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | const applyDocumentSuggestion = (): void => { |
| 255 | const draft = suggestionDraft |
| 256 | if (!draft) return |
| 257 | const sourceOutlinePageCount = draft.sourcePlan?.pageSkeleton.length || 0 |
| 258 | const hasSourceOutline = sourceOutlinePageCount > 0 |
| 259 | const shouldApplySourceOutline = hasSourceOutline && applyBriefSuggestion |
| 260 | |
| 261 | if (applyTitleSuggestion) setTitle(draft.topic) |
| 262 | if (shouldApplySourceOutline) { |
| 263 | setPageCount(String(resolvePageCount(String(sourceOutlinePageCount), 5))) |
| 264 | } else if (applyPageCountSuggestion) { |
| 265 | setPageCount(String(resolvePageCount(draft.pageCount, 5))) |
| 266 | } |
| 267 | if (applyBriefSuggestion) { |
| 268 | setBrief( |
| 269 | draft.sourcePlan?.pageSkeleton.length |
| 270 | ? formatSourceOutlineBriefText(draft.sourcePlan.pageSkeleton) |
| 271 | : draft.briefText |
| 272 | ) |
| 273 | } |
| 274 | setAcceptedSourcePlan(shouldApplySourceOutline ? draft.sourcePlan : undefined) |
| 275 | setSuggestionDialogOpen(false) |
| 276 | } |
| 277 | |
| 278 | const handleCreate = async (modelConfigId = selectedModelConfigId): Promise<void> => { |
| 279 | if (!template || creating) return |
| 280 | const resolvedModelConfigId = await ensureModelActive(modelConfigId) |
| 281 | if (!resolvedModelConfigId) return |
| 282 | const deckTitle = title.trim() || template.name |
| 283 | const briefText = brief.trim() |
| 284 | if (!briefText) { |
| 285 | warning(t('templates.briefRequired')) |
| 286 | return |
| 287 | } |
| 288 | const safePageCount = resolvePageCount(pageCount, template.pageCount || 5) |
| 289 | setCreating(true) |
| 290 | try { |
| 291 | const sessionId = await createSessionFromTemplate({ |
| 292 | templateId: template.id, |
| 293 | title: deckTitle, |
| 294 | modelConfigId: resolvedModelConfigId, |
| 295 | pageCount: safePageCount, |
| 296 | referenceDocumentPath: referenceDocumentPath || undefined, |
| 297 | sourcePlan: acceptedSourcePlan |
| 298 | }) |
| 299 | const initialPrompt = buildTemplateInitialPrompt({ |
| 300 | templateName: template.name, |
| 301 | title: deckTitle, |
| 302 | pageCount: safePageCount, |
| 303 | brief: briefText |
| 304 | }) |
| 305 | success(t('templates.sessionCreated'), { |
| 306 | description: t('templates.sessionCreatedDescription') |
| 307 | }) |
| 308 | onOpenChange(false) |
| 309 | navigate(`/sessions/${sessionId}/template-generating`, { |
| 310 | state: { initialPrompt, modelConfigId: resolvedModelConfigId } |
| 311 | }) |
| 312 | } catch (err) { |
| 313 | error(t('templates.createFailed'), { |
| 314 | description: err instanceof Error ? err.message : t('common.retryLater') |
| 315 | }) |
| 316 | } finally { |
| 317 | setCreating(false) |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | return ( |
| 322 | <> |
| 323 | <Dialog open={open} onOpenChange={(next) => !next && close()}> |
| 324 | <DialogContent className="max-w-2xl"> |
| 325 | <DialogHeader> |
| 326 | <DialogTitle className="flex items-center gap-2"> |
| 327 | <LayoutTemplate className="h-4 w-4" /> |
| 328 | {t('templates.useDialogTitle')} |
| 329 | </DialogTitle> |
| 330 | <DialogDescription className="text-xs leading-5"> |
| 331 | {t('templates.useDialogDescription')} |
| 332 | </DialogDescription> |
| 333 | </DialogHeader> |
| 334 | <div className="space-y-3"> |
| 335 | <div className="flex flex-col gap-3 sm:flex-row"> |
| 336 | <div className="min-w-0 flex-1"> |
| 337 | <label className="mb-1 block text-xs font-medium text-[#5f6b50]"> |
| 338 | {t('templates.sessionTitleLabel')} |
| 339 | </label> |
| 340 | <Input value={title} onChange={(event) => setTitle(event.target.value)} /> |
| 341 | </div> |
| 342 | <div className="w-full sm:w-28"> |
| 343 | <label className="mb-1 block text-xs font-medium text-[#5f6b50]"> |
| 344 | {t('templates.pageCountLabel')} |
| 345 | </label> |
| 346 | <Input |
| 347 | value={pageCount} |
| 348 | inputMode="numeric" |
| 349 | onChange={(event) => { |
| 350 | setAcceptedSourcePlan(undefined) |
| 351 | setPageCount(event.target.value) |
| 352 | }} |
| 353 | /> |
| 354 | </div> |
| 355 | </div> |
| 356 | <div> |
| 357 | <div className="mb-2 flex items-center justify-between gap-2"> |
| 358 | <label className="block text-xs font-medium text-[#5f6b50]"> |
| 359 | {t('templates.briefLabel')} |
| 360 | </label> |
| 361 | <div className="flex items-center gap-2"> |
| 362 | {hasParsedSource && !parsingDocument ? ( |
| 363 | <span className="rounded-full bg-[#e8f0df] px-2 py-0.5 text-[11px] text-[#4f6340]"> |
| 364 | {t('templates.parsed')} |
| 365 | </span> |
| 366 | ) : null} |
| 367 | <div className="flex items-center gap-1 rounded-lg border border-border p-0.5"> |
| 368 | <button |
| 369 | type="button" |
| 370 | onClick={() => setBriefMode('edit')} |
| 371 | className={`flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors ${ |
| 372 | briefMode === 'edit' |
| 373 | ? 'bg-foreground text-background' |
| 374 | : 'text-muted-foreground hover:text-foreground' |
| 375 | }`} |
| 376 | > |
| 377 | <Pencil className="h-3.5 w-3.5" /> |
| 378 | {t('common.edit')} |
| 379 | </button> |
| 380 | <button |
| 381 | type="button" |
| 382 | onClick={() => setBriefMode('preview')} |
| 383 | className={`flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors ${ |
| 384 | briefMode === 'preview' |
| 385 | ? 'bg-foreground text-background' |
| 386 | : 'text-muted-foreground hover:text-foreground' |
| 387 | }`} |
| 388 | > |
| 389 | <Eye className="h-3.5 w-3.5" /> |
| 390 | {t('common.preview')} |
| 391 | </button> |
| 392 | </div> |
| 393 | </div> |
| 394 | </div> |
| 395 | {briefMode === 'edit' ? ( |
| 396 | <Textarea |
| 397 | value={brief} |
| 398 | onChange={(event) => { |
| 399 | setAcceptedSourcePlan(undefined) |
| 400 | setBrief(event.target.value) |
| 401 | }} |
| 402 | className="min-h-[150px] resize-y px-3 py-2 text-xs leading-5" |
| 403 | placeholder={t('templates.briefPlaceholder')} |
| 404 | /> |
| 405 | ) : ( |
| 406 | <ScrollArea |
| 407 | className="h-[180px] rounded-lg border border-border/70 bg-background/70" |
| 408 | viewportClassName="p-4" |
| 409 | > |
| 410 | <ReactMarkdown |
| 411 | components={{ |
| 412 | h1: ({ children }) => ( |
| 413 | <h1 className="mb-2 text-lg font-semibold text-foreground">{children}</h1> |
| 414 | ), |
| 415 | h2: ({ children }) => ( |
| 416 | <h2 className="mb-2 mt-3 text-base font-semibold text-foreground"> |
| 417 | {children} |
| 418 | </h2> |
| 419 | ), |
| 420 | h3: ({ children }) => ( |
| 421 | <h3 className="mb-1.5 mt-2.5 text-sm font-semibold text-foreground"> |
| 422 | {children} |
| 423 | </h3> |
| 424 | ), |
| 425 | p: ({ children }) => ( |
| 426 | <p className="mb-2 text-xs leading-5 text-muted-foreground">{children}</p> |
| 427 | ), |
| 428 | ul: ({ children }) => ( |
| 429 | <ul className="mb-2 list-disc space-y-0.5 pl-5 text-xs text-muted-foreground"> |
| 430 | {children} |
| 431 | </ul> |
| 432 | ), |
| 433 | ol: ({ children }) => ( |
| 434 | <ol className="mb-2 list-decimal space-y-0.5 pl-5 text-xs text-muted-foreground"> |
| 435 | {children} |
| 436 | </ol> |
| 437 | ), |
| 438 | li: ({ children }) => <li>{children}</li>, |
| 439 | code: ({ children }) => ( |
| 440 | <code className="rounded bg-muted px-1.5 py-0.5 text-xs text-foreground"> |
| 441 | {children} |
| 442 | </code> |
| 443 | ), |
| 444 | blockquote: ({ children }) => ( |
| 445 | <blockquote className="mb-2 border-l-2 border-border pl-3 text-xs text-muted-foreground"> |
| 446 | {children} |
| 447 | </blockquote> |
| 448 | ) |
| 449 | }} |
| 450 | > |
| 451 | {brief || t('templates.briefPlaceholder')} |
| 452 | </ReactMarkdown> |
| 453 | </ScrollArea> |
| 454 | )} |
| 455 | </div> |
| 456 | {attachedReferenceFile ? ( |
| 457 | <div className="flex min-w-0"> |
| 458 | <span |
| 459 | className="inline-flex h-6 max-w-[260px] items-center gap-1 rounded-full border border-[#c7d9b4]/70 bg-[#e6f1dc]/80 px-2 text-[10px] text-[#405333]" |
| 460 | title={attachedReferenceFile.path} |
| 461 | > |
| 462 | <FileText className="h-3 w-3 shrink-0" /> |
| 463 | <span className="min-w-0 truncate">{attachedReferenceFile.name}</span> |
| 464 | <button |
| 465 | type="button" |
| 466 | onClick={handleRemoveReferenceFile} |
| 467 | className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full text-[#657552] hover:bg-[#c8ddb2]" |
| 468 | aria-label={t('templates.removeReference')} |
| 469 | > |
| 470 | <X className="h-2.5 w-2.5" /> |
| 471 | </button> |
| 472 | </span> |
| 473 | </div> |
| 474 | ) : null} |
| 475 | <input |
| 476 | ref={documentInputRef} |
| 477 | type="file" |
| 478 | accept=".md,.txt,.text,.csv,.docx" |
| 479 | multiple={false} |
| 480 | className="hidden" |
| 481 | onChange={(event) => void handleDocumentFilesSelected(event.target.files)} |
| 482 | /> |
| 483 | <TooltipProvider delayDuration={180}> |
| 484 | <div className="flex flex-wrap items-center gap-2"> |
| 485 | <Tooltip> |
| 486 | <TooltipTrigger asChild> |
| 487 | <span className="inline-flex"> |
| 488 | <Button |
| 489 | type="button" |
| 490 | variant="ghost" |
| 491 | size="sm" |
| 492 | onClick={() => void handleChooseDocumentClick()} |
| 493 | disabled={parsingDocument || creating} |
| 494 | className="h-8 shrink-0 rounded-lg border border-[#d8ccb5]/80 bg-[#fffdf8]/76 px-2.5 text-xs font-medium text-[#405333] shadow-none hover:bg-[#f3f7ed] hover:text-[#2f3b28]" |
| 495 | > |
| 496 | {parsingDocument ? ( |
| 497 | <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> |
| 498 | ) : ( |
| 499 | <FileText className="mr-1.5 h-3.5 w-3.5" /> |
| 500 | )} |
| 501 | {parsingDocument |
| 502 | ? t('templates.processingDocument') |
| 503 | : t('templates.uploadDocument')} |
| 504 | </Button> |
| 505 | </span> |
| 506 | </TooltipTrigger> |
| 507 | <TooltipContent side="bottom" align="start"> |
| 508 | {t('templates.uploadDocumentTooltip', { maxSize: MAX_DOCUMENT_SIZE_MB })} |
| 509 | </TooltipContent> |
| 510 | </Tooltip> |
| 511 | {attachedReferenceFile ? ( |
| 512 | <Tooltip> |
| 513 | <TooltipTrigger asChild> |
| 514 | <span> |
| 515 | <ModelSplitButton |
| 516 | modelAction={modelAction} |
| 517 | label={t('templates.analyzeDocument')} |
| 518 | loadingLabel={t('templates.analyzingDocument')} |
| 519 | loading={parsingDocument} |
| 520 | disabled={creating || !attachedReferenceFile} |
| 521 | icon={Sparkles} |
| 522 | tone="primary" |
| 523 | dropdownAlign="start" |
| 524 | className="h-8 rounded-lg border-0 bg-gradient-to-r from-[#7f965f] to-[#5f7448] shadow-[0_8px_18px_rgba(93,107,77,0.18)]" |
| 525 | mainClassName="h-full bg-transparent px-2.5 text-xs text-white shadow-none hover:bg-white/10 hover:text-white hover:shadow-none" |
| 526 | triggerClassName="h-full w-8 px-0" |
| 527 | onRun={handleAnalyzeDocument} |
| 528 | /> |
| 529 | </span> |
| 530 | </TooltipTrigger> |
| 531 | <TooltipContent side="bottom" align="start" className="max-w-xs"> |
| 532 | {t('templates.analyzeDocumentTooltip')} |
| 533 | </TooltipContent> |
| 534 | </Tooltip> |
| 535 | ) : null} |
| 536 | <span className="text-xs text-muted-foreground"> |
| 537 | {t('templates.supportedDocuments', { maxSize: MAX_DOCUMENT_SIZE_MB })} |
| 538 | </span> |
| 539 | </div> |
| 540 | </TooltipProvider> |
| 541 | {documentParseError ? ( |
| 542 | <div className="flex items-start gap-2 rounded-md border border-[#d58b7f]/45 bg-[#fff2ef] px-3 py-2 text-xs text-[#8a3d33]"> |
| 543 | <CircleAlert className="mt-0.5 h-4 w-4 shrink-0" /> |
| 544 | <span>{documentParseError}</span> |
| 545 | </div> |
| 546 | ) : null} |
| 547 | </div> |
| 548 | <DialogFooter className="gap-2"> |
| 549 | <Button |
| 550 | type="button" |
| 551 | variant="outline" |
| 552 | size="sm" |
| 553 | onClick={close} |
| 554 | disabled={creating || parsingDocument} |
| 555 | > |
| 556 | {t('common.cancel')} |
| 557 | </Button> |
| 558 | <ModelSplitButton |
| 559 | modelAction={modelAction} |
| 560 | label={t('templates.createAndGenerate')} |
| 561 | loadingLabel={t('templates.creating')} |
| 562 | loading={creating} |
| 563 | disabled={parsingDocument} |
| 564 | icon={Sparkles} |
| 565 | tone="primary" |
| 566 | onRun={handleCreate} |
| 567 | /> |
| 568 | </DialogFooter> |
| 569 | </DialogContent> |
| 570 | </Dialog> |
| 571 | |
| 572 | <SessionCreateSuggestionDialog |
| 573 | open={suggestionDialogOpen} |
| 574 | onOpenChange={setSuggestionDialogOpen} |
| 575 | attachedReferenceFile={attachedReferenceFile} |
| 576 | suggestionDraft={suggestionDraft} |
| 577 | setSuggestionDraft={setSuggestionDraft} |
| 578 | applyTopicSuggestion={applyTitleSuggestion} |
| 579 | setApplyTopicSuggestion={setApplyTitleSuggestion} |
| 580 | applyPageCountSuggestion={applyPageCountSuggestion} |
| 581 | setApplyPageCountSuggestion={setApplyPageCountSuggestion} |
| 582 | applyBriefSuggestion={applyBriefSuggestion} |
| 583 | setApplyBriefSuggestion={setApplyBriefSuggestion} |
| 584 | onApplySelected={applyDocumentSuggestion} |
| 585 | /> |
| 586 | </> |
| 587 | ) |
| 588 | } |
| 589 |