feat(canvas): cut over design agent to living form v2
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user