import { useEffect, useMemo, useRef, useState, } from 'react'; import { Check, ImagePlus, Loader2, Minus, Plus, RefreshCw, Sparkles, Trash2, WandSparkles, } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Select } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { uploadImageWorkspaceAsset } from '@/lib/image-workspace'; import { cn } from '@/lib/utils'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignCompilationIssue, DesignSpecificationValues, DesignUserFieldOperation, DesignWorkspace, } from '../../../shared/image-workspace'; import { DesignAssetThumbnail } from './DesignAssetThumbnail'; import { hasActiveCreationPlan } from './plan-lifecycle'; import { findUnboundReferenceNumbers, findReferenceMentionRange, insertReferenceToken, promptReferencesImage, referenceToken, removeReferenceTokenAndShift, } from './reference-tokens'; import { youthCompilationIssueMessage } from './youth-issue-copy'; type ReferenceValue = DesignSpecificationValues['references'][number]; type SaveState = 'settled' | 'saving' | 'repricing' | 'error'; export type YouthCreationCardProps = { workspace: DesignWorkspace; quoteBlockers: DesignCompilationIssue[]; generationAvailable: boolean; onOpenFineTune?: () => void; onFocusComposer?: () => void; onQuoteOffered?: () => void; }; const ASPECT_RATIOS = [ { value: '1:1', label: '1:1', orientation: 'square' }, { value: '3:4', label: '3:4', orientation: 'portrait' }, { value: '9:16', label: '9:16', orientation: 'portrait' }, { value: '4:3', label: '4:3', orientation: 'landscape' }, { value: '16:9', label: '16:9', orientation: 'landscape' }, ] as const; function createReferenceId(): string { const random = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; return `reference-${random}`.slice(0, 64); } function canonicalCreatorPrompt(workspace: DesignWorkspace): string { const values = workspace.form.specification.values; return values.content.concept?.trim() || values.content.narrative?.trim() || ''; } function Stepper({ label, value, suffix, minimum, maximum, disabled, onChange, }: { label: string; value: number; suffix?: string; minimum: number; maximum: number; disabled: boolean; onChange: (value: number) => void; }) { return (
{label} {value}{suffix ? ` ${suffix}` : ''}
); } export function YouthCreationCard({ workspace, quoteBlockers, generationAvailable, onQuoteOffered, }: YouthCreationCardProps) { const applyFieldOperations = useImageWorkspaceStore((state) => state.applyFieldOperations); const requestQuote = useImageWorkspaceStore((state) => state.requestQuote); const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration); const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations); const values = workspace.form.specification.values; const canonicalPrompt = canonicalCreatorPrompt(workspace); const offeredQuote = workspace.form.activeQuotes.find((quote) => quote.status === 'offered') ?? null; const [prompt, setPrompt] = useState(canonicalPrompt); const [promptDirty, setPromptDirty] = useState(false); const [saveState, setSaveState] = useState('settled'); const [localBusy, setLocalBusy] = useState(false); const [mentionRange, setMentionRange] = useState<{ start: number; end: number } | null>(null); const [replaceIndex, setReplaceIndex] = useState(null); const [uploading, setUploading] = useState(false); const [medium, setMedium] = useState(values.intent.media ?? 'image'); const [aspectRatio, setAspectRatio] = useState(values.output.aspect_ratio ?? '1:1'); const [duration, setDuration] = useState(values.video.total_duration_seconds ?? 6); const [variantCount, setVariantCount] = useState(values.output.variant_count ?? 1); const promptRef = useRef(null); const uploadInputRef = useRef(null); useEffect(() => { setPrompt(canonicalPrompt); setPromptDirty(false); setMentionRange(null); }, [canonicalPrompt, workspace.workspace.workspaceId]); useEffect(() => { setMedium(values.intent.media ?? 'image'); setAspectRatio(values.output.aspect_ratio ?? '1:1'); setDuration(values.video.total_duration_seconds ?? 6); setVariantCount(values.output.variant_count ?? 1); }, [ values.intent.media, values.output.aspect_ratio, values.output.variant_count, values.video.total_duration_seconds, workspace.workspace.workspaceId, ]); const references = values.references; const unboundReferenceNumbers = findUnboundReferenceNumbers(prompt, references.length); const submitting = Object.values(pendingOperations).some((operation) => ( operation.command.workspaceId === workspace.workspace.workspaceId )); const confirming = Object.values(pendingOperations).some((operation) => ( operation.status === 'submitting' && operation.command.kind === 'confirm_generation' && operation.command.workspaceId === workspace.workspace.workspaceId )); const busy = localBusy || submitting || uploading; const blockers = quoteBlockers.filter((issue) => issue.severity === 'blocker'); const canPrepareQuote = generationAvailable && prompt.trim().length > 0 && Boolean(medium) && Boolean(aspectRatio) && unboundReferenceNumbers.length === 0 && blockers.length === 0; const setPromptAndCursor = (value: string, cursor: number) => { setPrompt(value); setPromptDirty(value.trim() !== canonicalPrompt); setMentionRange(null); globalThis.requestAnimationFrame?.(() => { promptRef.current?.focus(); promptRef.current?.setSelectionRange(cursor, cursor); }); }; const runOperations = async ( operations: DesignUserFieldOperation[], options: { ensureQuote?: boolean; nextPrompt?: string } = {}, ) => { if (busy || operations.length === 0) return null; const shouldRefreshQuote = options.ensureQuote === true || Boolean(offeredQuote); setLocalBusy(true); setSaveState('saving'); let saved = false; try { const updated = await applyFieldOperations(operations); saved = true; if (options.nextPrompt !== undefined) { setPrompt(options.nextPrompt); setPromptDirty(false); } if (shouldRefreshQuote) { setSaveState('repricing'); const quoted = await requestQuote(); onQuoteOffered?.(); setSaveState('settled'); return quoted; } setSaveState('settled'); return updated; } catch { setSaveState('error'); toast.error(saved ? '修改已保存,但制作方案暂时没有重新核价' : '修改没有保存,请再试一次'); return null; } finally { setLocalBusy(false); } }; const savePrompt = async (ensureQuote = false) => { if (!promptDirty && !ensureQuote) return; const nextPrompt = prompt.trim(); const operations: DesignUserFieldOperation[] = nextPrompt ? [{ kind: 'set', path: 'content.concept', value: nextPrompt }] : [{ kind: 'clear', path: 'content.concept', resolution: 'open' }]; if (!promptDirty && ensureQuote) { setLocalBusy(true); setSaveState('repricing'); try { await requestQuote(); onQuoteOffered?.(); setSaveState('settled'); } catch { setSaveState('error'); toast.error('制作方案还没准备好,请稍后再试'); } finally { setLocalBusy(false); } return; } await runOperations(operations, { ensureQuote, nextPrompt }); }; const changeParameter = ( operations: DesignUserFieldOperation[], optimisticUpdate: () => void, ) => { if (busy) return; optimisticUpdate(); void runOperations(operations); }; const uploadReference = async (file: File) => { if (busy) return; setUploading(true); try { const asset = await uploadImageWorkspaceAsset(workspace.workspace.workspaceId, file); const replacing = replaceIndex; const nextReferences: ReferenceValue[] = replacing === null ? [...references, { id: createReferenceId(), asset_id: asset.assetId, asset_revision: null, role: 'inspiration', preserve: [], adapt: [], do_not_copy: [], reviewed_observations: [], }] : references.map((reference, index) => ( index === replacing ? { ...reference, asset_id: asset.assetId, asset_revision: null } : reference )); const inserted = replacing === null ? promptReferencesImage(prompt, nextReferences.length - 1) ? { value: prompt, cursor: prompt.length } : insertReferenceToken(prompt, nextReferences.length - 1, mentionRange) : { value: prompt, cursor: prompt.length }; const operations: DesignUserFieldOperation[] = [ { kind: 'set', path: 'references', value: nextReferences }, ]; if (inserted.value.trim() !== canonicalPrompt) { operations.push({ kind: 'set', path: 'content.concept', value: inserted.value.trim() }); } setUploading(false); await runOperations(operations, { nextPrompt: inserted.value.trim() }); setMentionRange(null); toast.success(replacing === null ? '参考图已添加并写入提示词' : '参考图已替换'); } catch { toast.error('参考图没有上传成功,请再试一次'); } finally { setUploading(false); setReplaceIndex(null); if (uploadInputRef.current) uploadInputRef.current.value = ''; } }; const removeReference = (index: number) => { if (busy) return; const nextReferences = references.filter((_, itemIndex) => itemIndex !== index); const nextPrompt = removeReferenceTokenAndShift(prompt, index); const operations: DesignUserFieldOperation[] = [ nextReferences.length > 0 ? { kind: 'set', path: 'references', value: nextReferences } : { kind: 'clear', path: 'references' }, nextPrompt ? { kind: 'set', path: 'content.concept', value: nextPrompt } : { kind: 'clear', path: 'content.concept', resolution: 'open' }, ]; void runOperations(operations, { nextPrompt }); }; const insertExistingReference = (index: number) => { if (!mentionRange && promptReferencesImage(prompt, index)) { promptRef.current?.focus(); return; } const inserted = insertReferenceToken(prompt, index, mentionRange); setPromptAndCursor(inserted.value, inserted.cursor); }; const status = useMemo(() => { if (saveState === 'saving') return { text: '正在保存方案', icon: }; if (saveState === 'repricing') return { text: '正在重新核价', icon: }; if (saveState === 'error') return { text: '方案需要重试', icon: }; if (promptDirty) return { text: '提示词尚未保存', icon: }; return { text: offeredQuote ? '方案已核价' : '方案已更新', icon: }; }, [offeredQuote, promptDirty, saveState]); if (!hasActiveCreationPlan(workspace)) return null; return (

制作方案

确认前可以直接修改最终内容

AI 已根据对话整理