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 (
输入 @ 可引用已添加的图片
{prompt.length} / 1000
参考图 {references.length > 0 ? references.length : ''}
{references.map((reference, index) => {
const asset = workspace.assets.find((item) => item.assetId === reference.asset_id) ?? null;
const referenced = promptReferencesImage(prompt, index);
return (
{referenced ? '已引用' : '未引用'}
);
})}
{unboundReferenceNumbers.length > 0 && (
{unboundReferenceNumbers.map((number) => `@图片${number}`).join('、')} 还没有绑定图片
{unboundReferenceNumbers.every((number) => number > 0) ? (
) : (
图片编号需从 1 开始
)}
)}
{
const file = event.currentTarget.files?.[0];
if (file) void uploadReference(file);
}}
/>
{medium === 'video' && (
changeParameter(
[{ kind: 'set', path: 'video.total_duration_seconds', value: next }],
() => setDuration(next),
)}
/>
)}
changeParameter(
[{ kind: 'set', path: 'output.variant_count', value: next }],
() => setVariantCount(next),
)}
/>
{blockers.length > 0 && (
{blockers.map((blocker) => (
{youthCompilationIssueMessage(blocker)}
))}
)}
{offeredQuote?.warnings.map((warning) => (
{youthCompilationIssueMessage(warning)}
))}
);
}