Files
makelore/src/pages/ImageCanvas/YouthCreationCard.tsx

696 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground">{label}</span>
<span className="inline-flex h-8 items-center overflow-hidden rounded-lg border border-border/80 bg-background">
<button
type="button"
aria-label={`减少${label}`}
className="flex h-full w-8 items-center justify-center text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-35"
disabled={disabled || value <= minimum}
onClick={() => onChange(Math.max(minimum, value - 1))}
>
<Minus className="h-3.5 w-3.5" />
</button>
<span className="min-w-11 border-x border-border/70 px-2 text-center text-xs tabular-nums text-foreground">
{value}{suffix ? ` ${suffix}` : ''}
</span>
<button
type="button"
aria-label={`增加${label}`}
className="flex h-full w-8 items-center justify-center text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-35"
disabled={disabled || value >= maximum}
onClick={() => onChange(Math.min(maximum, value + 1))}
>
<Plus className="h-3.5 w-3.5" />
</button>
</span>
</div>
);
}
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<SaveState>('settled');
const [localBusy, setLocalBusy] = useState(false);
const [mentionRange, setMentionRange] = useState<{ start: number; end: number } | null>(null);
const [replaceIndex, setReplaceIndex] = useState<number | null>(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<HTMLTextAreaElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(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: <Loader2 className="h-4 w-4 animate-spin" /> };
if (saveState === 'repricing') return { text: '正在重新核价', icon: <Loader2 className="h-4 w-4 animate-spin" /> };
if (saveState === 'error') return { text: '方案需要重试', icon: <RefreshCw className="h-4 w-4" /> };
if (promptDirty) return { text: '提示词尚未保存', icon: <Sparkles className="h-4 w-4" /> };
return { text: offeredQuote ? '方案已核价' : '方案已更新', icon: <Check className="h-4 w-4" /> };
}, [offeredQuote, promptDirty, saveState]);
if (!hasActiveCreationPlan(workspace)) return null;
return (
<section
data-testid="youth-creation-card"
aria-labelledby="youth-creation-card-title"
className="overflow-hidden rounded-2xl border border-border/75 bg-background shadow-sm"
>
<header className="flex items-start justify-between gap-3 border-b border-border/60 px-4 py-3.5 sm:px-5">
<div>
<h2 id="youth-creation-card-title" className="text-base font-semibold text-foreground">制作方案</h2>
<p className="mt-0.5 text-xs text-muted-foreground">确认前可以直接修改最终内容</p>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full bg-brand/[0.055] px-2.5 py-1 text-[11px] font-medium text-brand">
<Sparkles className="h-3.5 w-3.5" />
AI 已根据对话整理
</span>
</header>
<div className="space-y-3 px-4 py-4 sm:px-5">
<section className="rounded-xl border border-border/70 bg-surface-subtle/25 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor="creator-final-prompt" className="text-xs font-semibold text-foreground">创作提示词</label>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-border/70 bg-background px-2.5 text-xs font-medium text-foreground hover:border-brand/25 hover:text-brand"
disabled={busy}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setReplaceIndex(null);
uploadInputRef.current?.click();
}}
>
<ImagePlus className="h-3.5 w-3.5" />
添加参考图
</button>
</div>
<div className="relative mt-2">
<Textarea
ref={promptRef}
id="creator-final-prompt"
aria-label="创作提示词"
value={prompt}
maxLength={1000}
rows={4}
disabled={busy}
placeholder="AI 会根据前面的对话整理成最终创作提示词,你也可以直接修改。"
className="min-h-[104px] resize-y bg-background pr-12 text-sm leading-6"
onChange={(event) => {
const value = event.currentTarget.value;
const cursor = event.currentTarget.selectionStart ?? value.length;
setPrompt(value);
setPromptDirty(value.trim() !== canonicalPrompt);
setMentionRange(findReferenceMentionRange(value, cursor));
if (saveState === 'error') setSaveState('settled');
}}
onClick={(event) => {
const cursor = event.currentTarget.selectionStart ?? prompt.length;
setMentionRange(findReferenceMentionRange(prompt, cursor));
}}
onKeyDown={(event) => {
if (event.key === 'Escape') setMentionRange(null);
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
void savePrompt(Boolean(offeredQuote));
}
}}
onBlur={() => {
if (promptDirty && !mentionRange) void savePrompt(Boolean(offeredQuote));
}}
/>
{mentionRange && (
<div
role="listbox"
aria-label="可引用的图片"
className="absolute left-2 top-full z-20 mt-1 w-[min(320px,calc(100%-1rem))] overflow-hidden rounded-xl border border-border/80 bg-background p-1.5 shadow-float"
onMouseDown={(event) => event.preventDefault()}
>
{references.map((reference, index) => {
const asset = workspace.assets.find((item) => item.assetId === reference.asset_id) ?? null;
return (
<button
key={reference.id}
type="button"
role="option"
aria-selected="false"
className="flex min-h-10 w-full items-center gap-2 rounded-lg px-2 text-left hover:bg-surface-subtle"
onClick={() => insertExistingReference(index)}
>
<DesignAssetThumbnail asset={asset} className="h-7 w-7 rounded-md" />
<span className="text-xs font-medium text-foreground">{referenceToken(index)}</span>
</button>
);
})}
<button
type="button"
role="option"
aria-selected="false"
className="flex min-h-10 w-full items-center gap-2 rounded-lg px-2 text-left text-xs font-medium text-brand hover:bg-brand/[0.05]"
onClick={() => {
setReplaceIndex(null);
uploadInputRef.current?.click();
}}
>
<Plus className="h-3.5 w-3.5" />
上传新的参考图
</button>
</div>
)}
</div>
<div className="mt-1.5 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
<span>输入 @ 可引用已添加的图片</span>
<span className="tabular-nums">{prompt.length} / 1000</span>
</div>
</section>
<section aria-label="参考图" className="border-t border-border/60 pt-3">
<h3 className="text-xs font-semibold text-foreground">参考图 {references.length > 0 ? references.length : ''}</h3>
<div className="mt-2 grid gap-2 lg:grid-cols-2">
{references.map((reference, index) => {
const asset = workspace.assets.find((item) => item.assetId === reference.asset_id) ?? null;
const referenced = promptReferencesImage(prompt, index);
return (
<article key={reference.id} className="flex min-h-14 items-center gap-2.5 rounded-xl border border-border/70 bg-background p-2">
<DesignAssetThumbnail asset={asset} className="h-11 w-11 rounded-lg" />
<button
type="button"
title={`在提示词中插入 ${referenceToken(index)}`}
className="min-w-0 flex-1 text-left"
onClick={() => insertExistingReference(index)}
>
<span className="block truncate text-xs font-semibold text-foreground">{referenceToken(index)}</span>
<span className="mt-0.5 block truncate text-[10px] text-muted-foreground">
{asset ? `参考图 ${index + 1} · ${asset.width}×${asset.height}` : '图片正在同步'}
</span>
</button>
<span className={cn(
'shrink-0 rounded-md px-1.5 py-1 text-[10px] font-medium',
referenced ? 'bg-emerald-50 text-emerald-700' : 'bg-surface-subtle text-muted-foreground',
)}>
{referenced ? '已引用' : '未引用'}
</span>
<button
type="button"
aria-label={`替换参考图 ${index + 1}`}
title="替换"
disabled={busy}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-40"
onClick={() => {
setReplaceIndex(index);
uploadInputRef.current?.click();
}}
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`删除参考图 ${index + 1}`}
title="删除"
disabled={busy}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-destructive/[0.05] hover:text-destructive disabled:opacity-40"
onClick={() => removeReference(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</article>
);
})}
<button
type="button"
className="flex min-h-14 items-center justify-center gap-2 rounded-xl border border-dashed border-border px-3 text-xs font-medium text-foreground hover:border-brand/30 hover:bg-brand/[0.025]"
disabled={busy}
onClick={() => {
setReplaceIndex(null);
uploadInputRef.current?.click();
}}
>
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
添加参考图
</button>
</div>
{unboundReferenceNumbers.length > 0 && (
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-200 bg-amber-50/65 px-3 py-2">
<p className="text-xs leading-5 text-amber-900">
{unboundReferenceNumbers.map((number) => `@图片${number}`).join('、')} 还没有绑定图片
</p>
{unboundReferenceNumbers.every((number) => number > 0) ? (
<button
type="button"
className="h-8 rounded-lg bg-background px-2.5 text-xs font-medium text-amber-900 shadow-sm hover:bg-amber-100"
disabled={busy}
onClick={() => {
setReplaceIndex(null);
uploadInputRef.current?.click();
}}
>
上传 {referenceToken(references.length)}
</button>
) : (
<span className="text-[11px] text-amber-800">图片编号需从 1 开始</span>
)}
</div>
)}
<input
ref={uploadInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
aria-label="选择参考图片"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) void uploadReference(file);
}}
/>
</section>
<section aria-label="制作参数" className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-border/60 pt-3">
<label className="flex items-center gap-2 text-xs font-medium text-foreground">
类型
<Select
aria-label="类型"
value={medium}
disabled={busy}
className="h-8 w-[82px] rounded-lg px-2 py-0 pr-7 text-xs"
onChange={(event) => {
const next = event.currentTarget.value as 'image' | 'video';
const operations: DesignUserFieldOperation[] = [{ kind: 'set', path: 'intent.media', value: next }];
if (next === 'video' && values.video.total_duration_seconds === null) {
operations.push({ kind: 'set', path: 'video.total_duration_seconds', value: duration });
}
changeParameter(operations, () => setMedium(next));
}}
>
<option value="image">图片</option>
<option value="video">视频</option>
</Select>
</label>
<label className="flex items-center gap-2 text-xs font-medium text-foreground">
画幅
<Select
aria-label="画幅"
value={aspectRatio}
disabled={busy}
className="h-8 w-[78px] rounded-lg px-2 py-0 pr-7 text-xs"
onChange={(event) => {
const next = event.currentTarget.value;
const orientation = ASPECT_RATIOS.find((item) => item.value === next)?.orientation ?? null;
changeParameter([
{ kind: 'set', path: 'output.aspect_ratio', value: next },
orientation
? { kind: 'set', path: 'output.orientation', value: orientation }
: { kind: 'clear', path: 'output.orientation' },
], () => setAspectRatio(next));
}}
>
{ASPECT_RATIOS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</Select>
</label>
{medium === 'video' && (
<Stepper
label="时长"
value={duration}
suffix="秒"
minimum={1}
maximum={30}
disabled={busy}
onChange={(next) => changeParameter(
[{ kind: 'set', path: 'video.total_duration_seconds', value: next }],
() => setDuration(next),
)}
/>
)}
<Stepper
label="数量"
value={variantCount}
minimum={1}
maximum={4}
disabled={busy}
onChange={(next) => changeParameter(
[{ kind: 'set', path: 'output.variant_count', value: next }],
() => setVariantCount(next),
)}
/>
</section>
{blockers.length > 0 && (
<div className="rounded-xl border border-destructive/15 bg-destructive/[0.035] px-3 py-2 text-xs leading-5 text-destructive">
{blockers.map((blocker) => (
<p key={`${blocker.code}-${blocker.path ?? ''}`}>{youthCompilationIssueMessage(blocker)}</p>
))}
</div>
)}
{offeredQuote?.warnings.map((warning) => (
<p key={`${warning.code}-${warning.path ?? ''}`} className="text-xs leading-5 text-amber-800">
{youthCompilationIssueMessage(warning)}
</p>
))}
</div>
<footer className="flex flex-col gap-3 border-t border-border/70 bg-surface-subtle/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className={cn(
'text-emerald-600',
(saveState === 'saving' || saveState === 'repricing') && 'text-brand',
saveState === 'error' && 'text-destructive',
)}>
{status.icon}
</span>
<span>{status.text}</span>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<span className="text-xs text-muted-foreground sm:mr-2">
{offeredQuote
? <>预计使用 <strong className="font-semibold text-foreground">{offeredQuote.quotedDesignPoints}</strong> 设计点</>
: '确认前会先核算设计点'}
</span>
{offeredQuote ? (
<Button
type="button"
className="h-10 rounded-xl px-5"
disabled={busy || confirming || promptDirty || unboundReferenceNumbers.length > 0 || !generationAvailable}
onClick={() => {
void confirmGeneration(offeredQuote.quoteId).catch(() => {
toast.error('这次没有开始制作,请稍后再试');
});
}}
>
{confirming ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <WandSparkles className="mr-2 h-4 w-4" />}
确认并开始制作
</Button>
) : (
<Button
type="button"
className="h-10 rounded-xl px-5"
disabled={busy || !canPrepareQuote}
onClick={() => void savePrompt(true)}
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <WandSparkles className="mr-2 h-4 w-4" />}
准备制作方案
</Button>
)}
</div>
</footer>
</section>
);
}