feat(canvas): cut over design agent to living form v2

This commit is contained in:
brother7 committed 2026-08-30 17:00:31 +08:00
1 parent f245603923
commit b0b5a602b5
35 files changed
+5729 -13986

No files matched your search

@@ -0,0 +1,487 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
AlertTriangle,
Check,
Clock3,
Download,
Film,
ImageIcon,
Loader2,
RefreshCw,
Upload,
WandSparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
resolveImageWorkspaceAssetUrl,
saveImageWorkspaceAsset,
uploadImageWorkspaceAsset,
} from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type {
DesignAsset,
DesignGenerationQuote,
DesignGenerationTask,
DesignWorkspace,
} from '../../../shared/image-workspace';
const TASK_STATUS_LABELS: Record<DesignGenerationTask['status'], string> = {
queued: '排队中',
running: '生成中',
succeeded: '已完成',
failed: '未完成',
cancelled: '已取消',
};
const TASK_STAGE_LABELS: Record<DesignGenerationTask['progress']['stage'], string> = {
queued: '等待开始',
preparing: '准备素材',
submitting: '提交生成',
generating: '生成内容',
composing: '合成结果',
validating: '检查结果',
finalizing: '保存资产',
completed: '生成完成',
failed: '生成失败',
cancelled: '已取消',
};
const EXECUTION_HEALTH_LABELS: Record<DesignGenerationTask['executionHealth'], string> = {
normal: '正常',
recovering: '自动恢复中',
operator_required: '平台处理中',
};
function quoteTitle(quote: DesignGenerationQuote): string {
return quote.medium === 'video' ? '视频生成方案' : '图片生成方案';
}
function formatExpiry(value: string): string {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) return value;
return new Date(timestamp).toLocaleString([], {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function AssetPreview({ asset }: { asset: DesignAsset }) {
const [resolution, setResolution] = useState<{
contentPath: string;
source: string | null;
failed: boolean;
}>(() => ({ contentPath: asset.contentPath, source: null, failed: false }));
const currentResolution = resolution.contentPath === asset.contentPath
? resolution
: { contentPath: asset.contentPath, source: null, failed: false };
useEffect(() => {
let active = true;
void resolveImageWorkspaceAssetUrl(asset.contentPath)
.then((url) => {
if (active) setResolution({ contentPath: asset.contentPath, source: url, failed: false });
})
.catch(() => {
if (active) setResolution({ contentPath: asset.contentPath, source: null, failed: true });
});
return () => {
active = false;
};
}, [asset.contentPath]);
if (!currentResolution.source || currentResolution.failed) {
return (
<div className="flex aspect-square items-center justify-center bg-surface-subtle text-muted-foreground">
{asset.mediaType === 'video'
? <Film className="h-6 w-6" />
: <ImageIcon className="h-6 w-6" />}
</div>
);
}
if (asset.mediaType === 'video') {
return (
<video
src={currentResolution.source}
className="aspect-square w-full bg-surface-subtle object-cover"
controls
preload="metadata"
/>
);
}
return (
<img
src={currentResolution.source}
alt={asset.role === 'uploaded' ? '上传的设计参考素材' : 'AI 生成的设计结果'}
className="aspect-square w-full bg-surface-subtle object-cover"
loading="lazy"
/>
);
}
export function DesignQuotePanel({ workspace }: { workspace: DesignWorkspace }) {
const requestQuote = useImageWorkspaceStore((state) => state.requestQuote);
const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration);
const quoteBlockers = useImageWorkspaceStore((state) => state.quoteBlockers);
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const offeredQuotes = workspace.form.activeQuotes.filter((quote) => quote.status === 'offered');
const quote = offeredQuotes[0] ?? null;
const requesting = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting' && operation.command.kind === 'request_quote',
);
const confirming = Object.values(pendingOperations).some(
(operation) => operation.status === 'submitting'
&& operation.command.kind === 'confirm_generation',
);
if (!quote) {
return (
<section className="rounded-2xl border border-brand/20 bg-brand/[0.035] p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="max-w-xl">
<div className="flex items-center gap-2">
<WandSparkles className="h-4 w-4 text-brand" />
<h3 className="text-sm font-semibold text-foreground">生成方案与报价</h3>
</div>
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
服务端会冻结当前设计版本,编译专业提示词、输出计划与最高扣费。
</p>
</div>
<Button
type="button"
className="shrink-0 rounded-xl"
disabled={requesting}
onClick={() => {
void requestQuote().catch((error) => {
toast.error(error instanceof Error ? error.message : '生成方案编译失败');
});
}}
>
{requesting
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <WandSparkles className="mr-2 h-4 w-4" />}
编译生成方案
</Button>
</div>
{quoteBlockers.length > 0 && (
<div className="mt-4 rounded-xl border border-destructive/20 bg-destructive/[0.04] p-3">
<p className="text-xs font-medium text-destructive">还需要补充以下内容</p>
<ul className="mt-2 space-y-1.5 text-xs leading-5 text-foreground">
{quoteBlockers.map((blocker) => (
<li key={`${blocker.code}-${blocker.path ?? ''}`}>{blocker.message}</li>
))}
</ul>
</div>
)}
</section>
);
}
return (
<section className="rounded-2xl border border-brand/25 bg-background p-4 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">{quoteTitle(quote)}</h3>
<Badge variant="success">报价已冻结</Badge>
</div>
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
对应规格版本 {quote.specificationRevision},确认后不会再读取当前表单的新修改。
</p>
</div>
<div className="text-left sm:text-right">
<p className="text-2xl font-semibold tabular-nums text-foreground">
{quote.quotedDesignPoints}
</p>
<p className="text-xs text-muted-foreground">设计点</p>
</div>
</div>
<div className="mt-4 grid gap-3 rounded-xl bg-surface-subtle/75 p-3 sm:grid-cols-2 lg:grid-cols-4">
<div>
<p className="text-[11px] text-muted-foreground">画幅</p>
<p className="mt-1 text-sm font-medium text-foreground">{quote.outputSummary.aspectRatio}</p>
</div>
<div>
<p className="text-[11px] text-muted-foreground">数量</p>
<p className="mt-1 text-sm font-medium text-foreground">{quote.outputSummary.outputCount} 个输出</p>
</div>
<div>
<p className="text-[11px] text-muted-foreground">生成方式</p>
<p className="mt-1 text-sm font-medium text-foreground">
{quote.outputSummary.strategy === 'direct' ? '直接生成' : '多步骤生成'}
</p>
</div>
<div>
<p className="text-[11px] text-muted-foreground">有效期</p>
<p className="mt-1 text-sm font-medium text-foreground">{formatExpiry(quote.expiresAt)}</p>
</div>
</div>
{quote.warnings.length > 0 && (
<div className="mt-3 space-y-1.5 rounded-xl border border-amber-200 bg-amber-50/60 p-3 text-xs leading-5 text-amber-900">
{quote.warnings.map((warning) => (
<p key={`${warning.code}-${warning.path ?? ''}`} className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{warning.message}
</p>
))}
</div>
)}
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
确认即按此不可变报价创建任务,不会在客户端拼接或改写提示词。
</p>
<Button
type="button"
className="shrink-0 rounded-xl"
disabled={confirming}
onClick={() => {
void confirmGeneration(quote.quoteId).catch((error) => {
toast.error(error instanceof Error ? error.message : '确认生成失败');
});
}}
>
{confirming
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <Check className="mr-2 h-4 w-4" />}
确认生成
</Button>
</div>
</section>
);
}
export function DesignPendingOperationsPanel() {
const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations);
const retryOperation = useImageWorkspaceStore((state) => state.retryOperation);
const unknown = Object.values(pendingOperations).filter((operation) => operation.status === 'unknown');
if (unknown.length === 0) return null;
return (
<section className="rounded-2xl border border-amber-200 bg-amber-50/55 p-4">
<div className="flex items-start gap-2.5">
<Clock3 className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-amber-950">有操作结果尚未确认</h3>
<p className="mt-1 text-xs leading-5 text-amber-900/80">
重试会使用原操作标识,服务端会返回原结果,不会创建重复设计或重复任务。
</p>
<div className="mt-3 space-y-2">
{unknown.map((operation) => (
<div key={operation.id} className="flex items-center justify-between gap-3 rounded-xl bg-background/80 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-xs font-medium text-foreground">{operation.label}</p>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{operation.error}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => {
void retryOperation(operation.id).catch((error) => {
toast.error(error instanceof Error ? error.message : '操作重试失败');
});
}}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
安全重试
</Button>
</div>
))}
</div>
</div>
</div>
</section>
);
}
export function DesignTaskPanel({ tasks }: { tasks: DesignGenerationTask[] }) {
if (tasks.length === 0) return null;
return (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold text-foreground">生成任务</h3>
<p className="mt-1 text-xs text-muted-foreground">任务持续在云端执行,关闭页面不会取消。</p>
</div>
<div className="grid gap-3 lg:grid-cols-2">
{tasks.map((task) => {
const active = task.status === 'queued' || task.status === 'running';
return (
<article key={task.taskId} className="rounded-2xl border border-border/70 bg-background p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
{task.medium === 'video'
? <Film className="h-4 w-4 text-brand" />
: <ImageIcon className="h-4 w-4 text-brand" />}
<h4 className="text-sm font-medium text-foreground">
{task.medium === 'video' ? '视频任务' : '图片任务'}
</h4>
</div>
<p className="mt-1 text-[11px] text-muted-foreground">
规格版本 {task.specificationRevision} · {task.outputSummary.aspectRatio}
</p>
</div>
<Badge
variant={task.status === 'succeeded'
? 'success'
: task.status === 'failed'
? 'destructive'
: active
? 'warning'
: 'secondary'}
>
{TASK_STATUS_LABELS[task.status]}
</Badge>
</div>
<div className="mt-4 flex items-center justify-between rounded-xl bg-surface-subtle/75 px-3 py-2.5">
<span className="text-xs text-muted-foreground">{TASK_STAGE_LABELS[task.progress.stage]}</span>
<span className="text-xs tabular-nums text-foreground">
{task.progress.completedRequiredSteps}/{task.progress.totalRequiredSteps}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-xs">
<div>
<p className="text-muted-foreground">执行状态</p>
<p className="mt-1 font-medium text-foreground">
{EXECUTION_HEALTH_LABELS[task.executionHealth]}
</p>
</div>
<div>
<p className="text-muted-foreground">结果资产</p>
<p className="mt-1 font-medium tabular-nums text-foreground">{task.resultAssetIds.length}</p>
</div>
</div>
{task.issues.length > 0 && (
<div className="mt-3 space-y-1 rounded-xl bg-destructive/[0.04] p-3 text-xs leading-5 text-destructive">
{task.issues.map((issue) => <p key={issue.code}>{issue.message}</p>)}
</div>
)}
</article>
);
})}
</div>
</section>
);
}
export function DesignAssetsPanel({ workspace }: { workspace: DesignWorkspace }) {
const refreshWorkspace = useImageWorkspaceStore((state) => state.refreshWorkspace);
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const assets = useMemo(
() => [...workspace.assets].sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)),
[workspace.assets],
);
const upload = async (file: File) => {
setUploading(true);
try {
await uploadImageWorkspaceAsset(workspace.workspace.workspaceId, file);
await refreshWorkspace();
toast.success('参考素材已上传');
} catch (error) {
toast.error(error instanceof Error ? error.message : '素材上传失败');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
};
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-foreground">项目资产</h3>
<p className="mt-1 text-xs text-muted-foreground">上传素材后,可在表单中指定参考角色、源图或视频首帧。</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onClick={() => inputRef.current?.click()}
>
{uploading
? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
: <Upload className="mr-1.5 h-3.5 w-3.5" />}
上传图片
</Button>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void upload(file);
}}
/>
</div>
{assets.length === 0 ? (
<button
type="button"
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-dashed border-border px-4 py-8 text-sm text-muted-foreground transition-colors hover:border-brand/35 hover:bg-brand/[0.03]"
onClick={() => inputRef.current?.click()}
>
<Upload className="h-4 w-4" />
上传第一张参考图
</button>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
{assets.map((asset) => (
<article key={asset.assetId} className="group/asset overflow-hidden rounded-2xl border border-border/70 bg-background">
<AssetPreview asset={asset} />
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-xs font-medium text-foreground">
{asset.role === 'uploaded' ? '上传素材' : '生成作品'}
</p>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
{asset.width} × {asset.height}
</p>
</div>
<button
type="button"
aria-label="保存资产到电脑"
className={cn(
'rounded-lg p-1.5 text-muted-foreground transition-colors',
'hover:bg-surface-subtle hover:text-foreground',
)}
onClick={() => {
void saveImageWorkspaceAsset(asset).catch((error) => {
toast.error(error instanceof Error ? error.message : '资产保存失败');
});
}}
>
<Download className="h-3.5 w-3.5" />
</button>
</div>
</article>
))}
</div>
)}
</section>
);
}
export function DesignProductionPanel({ workspace }: { workspace: DesignWorkspace }) {
return (
<div className="space-y-6">
<DesignPendingOperationsPanel />
<DesignQuotePanel workspace={workspace} />
<DesignTaskPanel tasks={workspace.tasks} />
<DesignAssetsPanel workspace={workspace} />
</div>
);
}