feat: polish agent workflow showcase and layout
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-18 01:18:58 +08:00
parent f764767ba4
commit 1f078d5b37
14 changed files with 1472 additions and 424 deletions

View File

@ -22,6 +22,10 @@ import {
defaultAgentWorkflowBrandModelConfig,
normalizeAgentWorkflowBrandModelConfig,
} from "@/lib/agents/workflow-brand-model-config";
import {
defaultAgentWorkflowShowcaseConfig,
normalizeAgentWorkflowShowcaseConfig,
} from "@/lib/agents/workflow-showcase-config";
import {
defaultAgentWorkflowMaterialApiConfig,
normalizeAgentWorkflowMaterialApiConfig,
@ -37,6 +41,7 @@ import type {
AgentWorkflowBrandModelConfig,
AgentWorkflowMaterialApiConfig,
AgentWorkflowOptionUiConfig,
AgentWorkflowShowcaseConfig,
AgentWorkflowStepUiConfig,
AgentWorkflowUiConfig,
} from "@/types/agent";
@ -132,11 +137,15 @@ export function AgentWorkflowSettingsClient({
agentWorkflowUiConfig?: AgentWorkflowUiConfig;
agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig;
agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
}>("/api/site-settings", fetcher);
const [config, setConfig] = useState<AgentWorkflowUiConfig>(defaultAgentWorkflowUiConfig);
const [materialApiConfig, setMaterialApiConfig] = useState<AgentWorkflowMaterialApiConfig>(
defaultAgentWorkflowMaterialApiConfig
);
const [showcaseConfig, setShowcaseConfig] = useState<AgentWorkflowShowcaseConfig>(
defaultAgentWorkflowShowcaseConfig
);
const [brandModelConfig, setBrandModelConfig] = useState<AgentWorkflowBrandModelConfig>(
defaultAgentWorkflowBrandModelConfig
);
@ -150,6 +159,9 @@ export function AgentWorkflowSettingsClient({
setMaterialApiConfig(
normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig)
);
setShowcaseConfig(
normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig)
);
setBrandModelConfig(
normalizeAgentWorkflowBrandModelConfig(data.agentWorkflowBrandModelConfig)
);
@ -279,6 +291,7 @@ export function AgentWorkflowSettingsClient({
agentWorkflowUiConfig: config,
agentWorkflowMaterialApiConfig: materialApiConfig,
agentWorkflowBrandModelConfig: brandModelConfig,
agentWorkflowShowcaseConfig: showcaseConfig,
}),
});
@ -395,6 +408,43 @@ export function AgentWorkflowSettingsClient({
}));
};
const handleUploadShowcaseAsset = async (
target: "video" | "poster",
file: File
) => {
const isVideo = target === "video";
const expectedPrefix = isVideo ? "video/" : "image/";
if (!file.type.startsWith(expectedPrefix)) {
toast.error(isVideo ? "请上传视频文件" : "请上传图片文件");
return;
}
const refKey = `showcase:${target}`;
try {
setUploadingKey(refKey);
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
setShowcaseConfig((prev) => ({
...prev,
[target === "video" ? "videoUrl" : "posterUrl"]: result.url,
}));
toast.success(isVideo ? "视频海报上传成功" : "视频封面上传成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setUploadingKey(null);
}
};
return (
<div className="container mx-auto space-y-6 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
@ -440,6 +490,251 @@ export function AgentWorkflowSettingsClient({
</Card>
) : (
<div className="space-y-5">
<Card>
<CardHeader>
<CardTitle>顶部视频海报</CardTitle>
<CardDescription>
配置第一步顶部展示区的视频海报。前台会按固定 21:9 比例自动循环播放,并同步显示可自定义标题、按钮文案和海报叠加文字。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-xl border bg-muted/20 px-4 py-3">
<div className="space-y-1">
<div className="text-sm font-medium">启用顶部视频海报</div>
<div className="text-xs text-muted-foreground">
关闭后前台会隐藏视频海报展示区,但保留下方工作流内容。
</div>
</div>
<Switch
checked={showcaseConfig.enabled}
onCheckedChange={(checked) =>
setShowcaseConfig((prev) => ({
...prev,
enabled: checked,
}))
}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>顶部主标题</Label>
<Input
value={showcaseConfig.title}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
title: event.target.value,
}))
}
placeholder="三倍速出片,电影级质感"
/>
</div>
<div className="space-y-2">
<Label>顶部副标题</Label>
<Input
value={showcaseConfig.subtitle}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
subtitle: event.target.value,
}))
}
placeholder="第一步:风格选择 第二步:生图模式"
/>
</div>
<div className="space-y-2">
<Label>按钮文案</Label>
<Input
value={showcaseConfig.actionText}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
actionText: event.target.value,
}))
}
placeholder="我要创作"
/>
</div>
<div className="space-y-2">
<Label>海报底部说明</Label>
<Input
value={showcaseConfig.footerText || ""}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
footerText: event.target.value,
}))
}
placeholder="用户作品"
/>
</div>
</div>
<div className="space-y-2">
<Label>海报叠加文字</Label>
<Textarea
value={showcaseConfig.overlayText || ""}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
overlayText: event.target.value,
}))
}
rows={2}
placeholder="例如:让每一步选择,都能直达高质感成片"
/>
</div>
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-4">
<div className="space-y-2">
<Label>视频链接</Label>
<Input
value={showcaseConfig.videoUrl}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
videoUrl: event.target.value,
}))
}
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label>视频封面链接</Label>
<Input
value={showcaseConfig.posterUrl || ""}
onChange={(event) =>
setShowcaseConfig((prev) => ({
...prev,
posterUrl: event.target.value,
}))
}
placeholder="https://..."
/>
</div>
<div className="flex flex-wrap gap-2">
<input
ref={(element) => {
fileInputRefs.current["showcase:video"] = element;
}}
type="file"
accept="video/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
void handleUploadShowcaseAsset("video", file);
event.target.value = "";
}}
/>
<input
ref={(element) => {
fileInputRefs.current["showcase:poster"] = element;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
void handleUploadShowcaseAsset("poster", file);
event.target.value = "";
}}
/>
<Button
type="button"
variant="outline"
disabled={uploadingKey === "showcase:video"}
onClick={() => fileInputRefs.current["showcase:video"]?.click()}
>
{uploadingKey === "showcase:video" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传视频
</Button>
<Button
type="button"
variant="outline"
disabled={uploadingKey === "showcase:poster"}
onClick={() => fileInputRefs.current["showcase:poster"]?.click()}
>
{uploadingKey === "showcase:poster" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
上传封面图
</Button>
<Button
type="button"
variant="ghost"
onClick={() =>
setShowcaseConfig((prev) => ({
...prev,
videoUrl: "",
posterUrl: "",
}))
}
>
清空媒体
</Button>
</div>
<div className="text-xs text-muted-foreground">
建议优先上传静音短视频,前台会自动以 `muted + loop + playsInline` 形式循环播放;未配置视频时会回退到封面图展示。
</div>
</div>
<div className="space-y-2">
<Label>前台预览</Label>
<div className="overflow-hidden rounded-2xl border bg-slate-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]">
<div className="relative aspect-[21/9] w-full overflow-hidden bg-black">
{showcaseConfig.videoUrl ? (
<video
key={showcaseConfig.videoUrl}
src={showcaseConfig.videoUrl}
poster={showcaseConfig.posterUrl || undefined}
className="h-full w-full object-cover"
autoPlay
muted
loop
playsInline
/>
) : showcaseConfig.posterUrl ? (
<img
src={showcaseConfig.posterUrl}
alt={showcaseConfig.overlayText || showcaseConfig.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-white/45">
暂无视频海报
</div>
)}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/65 via-black/10 to-black/25" />
{showcaseConfig.overlayText ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-5 text-center text-lg font-semibold tracking-[0.18em] text-white/22">
{showcaseConfig.overlayText}
</div>
) : null}
{showcaseConfig.footerText ? (
<div className="pointer-events-none absolute bottom-3 left-3 rounded-full border border-white/12 bg-black/35 px-2.5 py-1 text-[11px] text-white/75">
{showcaseConfig.footerText}
</div>
) : null}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>素材搜索接口配置</CardTitle>

View File

@ -46,6 +46,11 @@ import {
normalizeAgentWorkflowMaterialApiConfig,
normalizeAgentWorkflowMaterialApiConfigForPublic,
} from "@/lib/agents/workflow-material-config";
import {
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
defaultAgentWorkflowShowcaseConfig,
normalizeAgentWorkflowShowcaseConfig,
} from "@/lib/agents/workflow-showcase-config";
import {
defaultOssImageMigrationConfig,
normalizeOssImageMigrationConfig,
@ -235,6 +240,7 @@ export async function GET() {
agentWorkflowUiConfigSetting,
agentWorkflowMaterialApiConfigSetting,
agentWorkflowBrandModelConfigSetting,
agentWorkflowShowcaseConfigSetting,
] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db
@ -363,6 +369,12 @@ export async function GET() {
.where(eq(systemSettings.key, AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
]);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
@ -415,6 +427,9 @@ export async function GET() {
const agentWorkflowBrandModelConfig = normalizeAgentWorkflowBrandModelConfig(
agentWorkflowBrandModelConfigSetting?.value
);
const agentWorkflowShowcaseConfig = normalizeAgentWorkflowShowcaseConfig(
agentWorkflowShowcaseConfigSetting?.value
);
// 如果没有设置记录,返回默认值
const baseSettings = settings || {
@ -445,6 +460,7 @@ export async function GET() {
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
agentWorkflowMaterialApiConfig: defaultAgentWorkflowMaterialApiConfig,
agentWorkflowBrandModelConfig: defaultAgentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig: defaultAgentWorkflowShowcaseConfig,
realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true,
storageFailoverConfig,
@ -491,6 +507,7 @@ export async function GET() {
agentWorkflowUiConfig,
agentWorkflowMaterialApiConfig,
agentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@ -618,6 +635,18 @@ const updateSchema = z.object({
.max(300),
})
.optional(),
agentWorkflowShowcaseConfig: z
.object({
enabled: z.boolean().optional(),
title: z.string().max(200).optional(),
subtitle: z.string().max(300).optional(),
actionText: z.string().max(48).optional(),
videoUrl: z.string().max(2000).optional(),
posterUrl: z.string().max(2000).optional(),
overlayText: z.string().max(200).optional(),
footerText: z.string().max(64).optional(),
})
.optional(),
realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
@ -693,6 +722,7 @@ export async function PUT(request: NextRequest) {
"agentWorkflowUiConfig",
"agentWorkflowMaterialApiConfig",
"agentWorkflowBrandModelConfig",
"agentWorkflowShowcaseConfig",
]);
const hasRestrictedKeys = Object.keys(body || {}).some((key) => !allowedKeys.has(key));
if (hasRestrictedKeys) {
@ -713,6 +743,7 @@ export async function PUT(request: NextRequest) {
agentWorkflowUiConfig,
agentWorkflowMaterialApiConfig,
agentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@ -981,6 +1012,26 @@ export async function PUT(request: NextRequest) {
});
}
if (agentWorkflowShowcaseConfig !== undefined) {
const normalized = normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig);
await db
.insert(systemSettings)
.values({
key: AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
@ -1165,6 +1216,10 @@ export async function PUT(request: NextRequest) {
agentWorkflowBrandModelConfig !== undefined
? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig)
: defaultAgentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig:
agentWorkflowShowcaseConfig !== undefined
? normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig)
: defaultAgentWorkflowShowcaseConfig,
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)
@ -1477,6 +1532,26 @@ export async function PUT(request: NextRequest) {
});
}
if (agentWorkflowShowcaseConfig !== undefined) {
const normalized = normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig);
await db
.insert(systemSettings)
.values({
key: AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
@ -1661,6 +1736,10 @@ export async function PUT(request: NextRequest) {
agentWorkflowBrandModelConfig !== undefined
? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig)
: defaultAgentWorkflowBrandModelConfig,
agentWorkflowShowcaseConfig:
agentWorkflowShowcaseConfig !== undefined
? normalizeAgentWorkflowShowcaseConfig(agentWorkflowShowcaseConfig)
: defaultAgentWorkflowShowcaseConfig,
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)

View File

@ -95,6 +95,7 @@ import type {
AgentWorkflowMaterialSelections,
AgentWorkflowPersistedState,
AgentWorkflowSelectedBrandModel,
AgentWorkflowShowcaseConfig,
AgentWorkflowStepKey,
} from "@/types/agent";
@ -196,6 +197,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
agentEnabled?: boolean;
realtimeCanvasEnabled?: boolean;
realtimeCanvasServiceEnabled?: boolean;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
agentWorkflowUiConfig?: {
steps?: Array<{
key: AgentWorkflowStepKey;
@ -1009,6 +1011,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
onMaterialsChange={setWorkflowMaterials}
selections={workflowSelections}
onSelectionsChange={setWorkflowSelections}
siteName={siteSettingsData?.siteName}
siteLogo={siteSettingsData?.siteLogo}
siteLogoDark={siteSettingsData?.siteLogoDark}
showcaseConfig={siteSettingsData?.agentWorkflowShowcaseConfig}
onNextStep={() => setCurrentAgentStage("mode")}
onSkipStep={() => setCurrentAgentStage("mode")}
onReset={() => {
@ -1031,16 +1037,38 @@ export default function CanvasEditorPage({ params }: PageProps) {
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
selectedBrandModel={selectedBrandModel}
workflowContext={workflowContext}
siteName={siteSettingsData?.siteName}
siteLogo={siteSettingsData?.siteLogo}
siteLogoDark={siteSettingsData?.siteLogoDark}
onBack={() => setCurrentAgentStage("workflow")}
onClearPreset={() => setSelectedAgentPresetId(null)}
// 嵌入的智能体选择面板
presetPanel={
<AgentPresetPanelDarkV2
embedded
embeddedEntityLabel="生图模式"
selectedPresetId={selectedAgentPresetId}
onSelectPreset={setSelectedAgentPresetId}
/>
}
brandModelPanel={
<AgentWorkflowBrandModelPicker
brand={workflowInputs.brand}
selectedBrandModel={selectedBrandModel}
onSelectBrandModel={setSelectedBrandModel}
theme="dark"
/>
}
materialSelectionPanel={
<AgentWorkflowMaterialSelectionPanel
inputs={workflowInputs}
onInputsChange={setWorkflowInputs}
materials={workflowMaterials}
onMaterialsChange={setWorkflowMaterials}
disabled={!selectedAgentPresetId}
theme="dark"
/>
}
/>
);
}

View File

@ -0,0 +1,123 @@
"use client";
import type { ReactNode } from "react";
import { ArrowLeft, Images, LayoutGrid, Sparkles, Wand2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface AgentCanvasTopNavDarkProps {
siteName?: string | null;
siteLogo?: string | null;
siteLogoDark?: string | null;
onBack?: () => void;
rightSlot?: ReactNode;
activeKey?: "featured" | "agent" | "canvas" | "gallery";
}
const NAV_ITEMS = [
{
key: "featured" as const,
label: "AI精选",
icon: Sparkles,
onClick: () => window.location.assign("/featured"),
},
{
key: "agent" as const,
label: "智能体",
icon: Wand2,
onClick: () => window.location.assign("/canvas?mode=agent"),
},
{
key: "canvas" as const,
label: "无限画布",
icon: LayoutGrid,
onClick: () => window.location.assign(`/canvas/${crypto.randomUUID()}`),
},
{
key: "gallery" as const,
label: "我的图册",
icon: Images,
onClick: () => window.location.assign("/gallery"),
},
];
export function AgentCanvasTopNavDark({
siteName = "AI Chatbot",
siteLogo,
siteLogoDark,
onBack,
rightSlot = null,
activeKey = "agent",
}: AgentCanvasTopNavDarkProps) {
const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot";
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
const shouldInvertLogo = !siteLogoDark?.trim() && Boolean(siteLogo?.trim());
return (
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex h-16 items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
{onBack ? (
<button
type="button"
onClick={onBack}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/60 transition-colors hover:bg-white/10 hover:text-white"
aria-label="返回上一步"
>
<ArrowLeft className="h-5 w-5" />
</button>
) : null}
<button
type="button"
onClick={() => window.location.assign("/")}
className="flex min-w-0 items-center gap-2.5 transition-opacity hover:opacity-80"
>
{displayLogo ? (
<img
src={displayLogo}
alt={resolvedSiteName}
className={cn(
"h-8 w-8 shrink-0 object-contain",
shouldInvertLogo && "brightness-0 invert"
)}
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/10">
<Wand2 className="h-4 w-4 text-white" />
</div>
)}
<span className="truncate text-base font-semibold text-white">{resolvedSiteName}</span>
</button>
</div>
<nav className="hidden items-center gap-2 md:flex">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = item.key === activeKey;
return (
<button
key={item.key}
type="button"
onClick={item.onClick}
className={cn(
"flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all duration-300",
isActive
? "bg-white text-black"
: "bg-white/5 text-white/60 hover:bg-white/10 hover:text-white"
)}
>
<Icon className="h-4 w-4" />
{item.label}
</button>
);
})}
</nav>
<div className="flex shrink-0 items-center justify-end">{rightSlot}</div>
</div>
</div>
</header>
);
}

View File

@ -2,7 +2,7 @@
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import useSWR from "swr";
import { motion, AnimatePresence } from "framer-motion";
import {
@ -15,12 +15,6 @@ import {
X,
Wand2,
Settings2,
ArrowLeft,
LayoutGrid,
FolderOpen,
User,
Building2,
Menu
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@ -40,15 +34,13 @@ import { buildImageProxyUrl } from "@/lib/image-delivery";
import { extractImageUrlsFromText } from "@/lib/image-url";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import type {
AgentPreset,
AgentWorkflowComposerContext,
AgentWorkflowSelectedBrandModel,
} from "@/types/agent";
// 导航标签类型
type NavTab = "cases" | "my-works" | "personal-assets" | "company-assets";
interface AgentGenerationWorkspaceDarkV2Props {
chatId: string;
projectId: string;
@ -56,16 +48,87 @@ interface AgentGenerationWorkspaceDarkV2Props {
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
workflowContext?: AgentWorkflowComposerContext | null;
onBack?: () => void;
presetPanel?: React.ReactNode;
presetPanel?: ReactNode;
materialSelectionPanel?: ReactNode;
brandModelPanel?: ReactNode;
onClearPreset?: () => void;
siteName?: string | null;
siteLogo?: string | null;
siteLogoDark?: string | null;
}
interface PickedAttachment extends Attachment {}
interface GeneratedImageGroup {
messageId: string;
imageUrls: string[];
text: string;
}
function buildPreviewImageUrl(url: string, width: number) {
return buildImageProxyUrl(url, { width, quality: 86, format: "webp" });
}
function buildDownloadFilename(url: string): string {
const fallback = `canvas-image-${Date.now()}.png`;
const raw = String(url || "").trim();
if (!raw) return fallback;
try {
const pathname = new URL(raw).pathname || "";
const candidate = pathname.split("/").pop() || "";
return candidate || fallback;
} catch {
const candidate = raw.split("/").pop()?.split("?")[0] || "";
return candidate || fallback;
}
}
function normalizeAssistantText(value: string) {
return String(value || "")
.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_START -->\n*/g, "")
.replace(/\n*<!-- IMAGE_GENERATION_END -->\n*/g, "")
.replace(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g, "")
.trim();
}
function getImageDimensions(url: string): Promise<{
width: number;
height: number;
originalWidth: number;
originalHeight: number;
}> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const maxSize = 500;
const originalWidth = img.naturalWidth;
const originalHeight = img.naturalHeight;
let width = originalWidth;
let height = originalHeight;
if (width > maxSize || height > maxSize) {
const scale = Math.min(maxSize / width, maxSize / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const minSize = 150;
if (width < minSize && height < minSize) {
const scale = minSize / Math.min(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
resolve({ width, height, originalWidth, originalHeight });
};
img.onerror = () => {
resolve({ width: 300, height: 300, originalWidth: 0, originalHeight: 0 });
};
img.src = url;
});
}
export function AgentGenerationWorkspaceDarkV2({
chatId,
projectId,
@ -74,7 +137,12 @@ export function AgentGenerationWorkspaceDarkV2({
workflowContext = null,
onBack,
presetPanel,
materialSelectionPanel,
brandModelPanel,
onClearPreset,
siteName,
siteLogo,
siteLogoDark,
}: AgentGenerationWorkspaceDarkV2Props) {
const rawChatId = useMemo(
() => (chatId.startsWith("canvas-") ? chatId.replace("canvas-", "") : chatId),
@ -85,6 +153,7 @@ export function AgentGenerationWorkspaceDarkV2({
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const hiddenPromptRef = useRef<string | null>(null);
const generatedImageKeysRef = useRef<Set<string>>(new Set());
const lastSyncedBrandModelUrlRef = useRef<string | null>(null);
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>([]);
@ -184,6 +253,22 @@ export function AgentGenerationWorkspaceDarkV2({
}),
});
useEffect(() => {
if (!messages.length && historyMessages.length) {
setMessages(historyMessages);
}
}, [historyMessages, messages.length, setMessages]);
useEffect(() => {
if (selectedAgentPreset?.defaultModelId) {
setSelectedModelId(selectedAgentPreset.defaultModelId);
return;
}
if (modelsData?.defaultModelId) {
setSelectedModelId(modelsData.defaultModelId);
}
}, [modelsData?.defaultModelId, selectedAgentPreset?.defaultModelId]);
// 初始化附件
useEffect(() => {
if (!hasAgentUploadSlots) {
@ -220,7 +305,51 @@ export function AgentGenerationWorkspaceDarkV2({
});
}, [agentUploadSlots.length, hasAgentUploadSlots, referenceImages]);
const generatedGroups = useMemo(() => {
useEffect(() => {
if (!hasAgentUploadSlots) {
lastSyncedBrandModelUrlRef.current = null;
return;
}
const nextAttachment = selectedBrandModel?.imageUrl
? {
url: selectedBrandModel.imageUrl,
name: selectedBrandModel.displayName || `${selectedBrandModel.brandName} 模特图`,
contentType: selectedBrandModel.mimeType || "image/png",
assetId: selectedBrandModel.assetId || undefined,
}
: null;
setAttachments((current) => {
const slots: Array<PickedAttachment | null> = Array.from(
{ length: Math.max(agentUploadSlots.length, 3) },
(_, index) => current[index] ?? null
);
const currentSlot = slots[2] ?? null;
const previousSyncedUrl = lastSyncedBrandModelUrlRef.current;
if (nextAttachment) {
slots[2] = nextAttachment;
} else if (previousSyncedUrl && currentSlot?.url === previousSyncedUrl) {
slots[2] = null;
}
return slots;
});
lastSyncedBrandModelUrlRef.current = nextAttachment?.url || null;
}, [
agentUploadSlots.length,
hasAgentUploadSlots,
selectedBrandModel?.assetId,
selectedBrandModel?.brandName,
selectedBrandModel?.displayName,
selectedBrandModel?.id,
selectedBrandModel?.imageUrl,
selectedBrandModel?.mimeType,
]);
const generatedGroups = useMemo<GeneratedImageGroup[]>(() => {
return messages
.filter((message) => message.role === "assistant")
.map((message) => {
@ -230,7 +359,11 @@ export function AgentGenerationWorkspaceDarkV2({
const imageUrls = Array.from(
new Set(textParts.flatMap((text) => extractImageUrlsFromText(text)))
);
return { messageId: message.id, imageUrls, text: textParts.join("\n\n").replace(/!\[[^\]]*\]\([^)]+\)/g, "").trim() };
return {
messageId: message.id,
imageUrls,
text: normalizeAssistantText(textParts.join("\n\n")),
};
})
.filter((group) => group.imageUrls.length > 0);
}, [messages]);
@ -249,6 +382,43 @@ export function AgentGenerationWorkspaceDarkV2({
}
}, [latestGeneratedGroup?.imageUrls, selectedResultUrl]);
useEffect(() => {
const latestUrls = latestGeneratedGroup?.imageUrls || [];
if (latestUrls.length === 0) return;
latestUrls.forEach(async (url, index) => {
if (!url || generatedImageKeysRef.current.has(url)) return;
generatedImageKeysRef.current.add(url);
try {
const dimensions = await getImageDimensions(url);
const nodeIndex = nodes.length + index;
const spacing = 20;
const x = 120 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 120 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: "completed",
title: "生成结果",
prompt: latestGeneratedGroup.text || "生成结果",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
} catch (error) {
console.warn("[AgentGenerationWorkspaceDarkV2] Failed to add generated image to canvas:", error);
}
});
}, [addNode, latestGeneratedGroup, nodes.length]);
const updateAttachmentAtSlot = useCallback((slotIndex: number, attachment: PickedAttachment | null) => {
const slotCount = Math.max(agentUploadSlots.length, 1);
setAttachments((current) => {
@ -332,95 +502,90 @@ export function AgentGenerationWorkspaceDarkV2({
setInput("");
}, [attachments, hasAgentUploadSlots, input, requiredUploadCount, selectedAgentPreset, selectedAgentPresetId, sendMessage, workflowContext?.promptText, workflowContext?.summaryText]);
// 导航标签配置
const navTabs = [
{ id: "cases" as NavTab, label: "案例广场", icon: LayoutGrid },
{ id: "my-works" as NavTab, label: "我的作品", icon: FolderOpen },
{ id: "personal-assets" as NavTab, label: "个人资产库", icon: User },
{ id: "company-assets" as NavTab, label: "公司资产库", icon: Building2 },
];
const handleDownloadOriginalImage = useCallback(async (imageUrl: string) => {
const raw = String(imageUrl || "").trim();
if (!raw) {
toast.error("图片地址无效");
return;
}
const filename = buildDownloadFilename(raw);
try {
const response = await fetch(raw);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
}
}, []);
return (
<div className="min-h-screen bg-black">
{/* 顶部固定导航栏 - 与第一步保持一致 */}
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-6">
<div className="flex h-16 items-center justify-between">
{/* Logo & Site Name & Back */}
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600">
<Wand2 className="h-4 w-4 text-white" />
</div>
<span className="text-base font-semibold text-white">AI创作平台</span>
</div>
</div>
{/* 导航标签 */}
<nav className="flex items-center gap-1 rounded-full bg-white/5 p-1">
{navTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className="flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium text-white/60 transition-all duration-300 hover:text-white hover:bg-white/5"
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</nav>
{/* 右侧占位 */}
<div className="w-20" />
</div>
</div>
</header>
<AgentCanvasTopNavDark
siteName={siteName}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
onBack={onBack}
activeKey="agent"
rightSlot={<div className="w-10" />}
/>
{/* Main Content */}
<main className="pt-16">
<div className="mx-auto max-w-[1800px] px-6 py-6">
<div className="mx-auto flex min-h-[calc(100vh-64px)] max-w-[1480px] flex-col px-6 py-6 lg:px-8 xl:px-10">
{/* 步骤指示器 */}
<motion.div
<motion.div
className="mb-6 flex items-center justify-center gap-2 text-sm"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
<span className="text-white/40">第一步:风格选择</span>
<span className="text-white/20">→</span>
<span className="text-emerald-400 font-medium">第二步:智能体生图</span>
<span className="text-emerald-400 font-medium">第二步:生图模式</span>
</motion.div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
<div className="grid min-h-0 flex-1 grid-cols-1 gap-6 lg:h-[calc(100vh-136px)] lg:grid-cols-[400px_minmax(0,1fr)]">
{/* Left Panel - Controls */}
<div className="space-y-4">
<div className="min-h-0 space-y-4 lg:overflow-y-auto lg:pr-2">
{/* 嵌入的智能体选择面板 */}
{!selectedAgentPreset && presetPanel && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl border border-white/5 bg-white/[0.02] p-4 max-h-[400px] overflow-y-auto"
className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
>
<h3 className="mb-3 text-sm font-semibold text-white">选择智能体</h3>
{presetPanel}
<h3 className="mb-3 text-sm font-semibold text-white">选择生图模式</h3>
<div className="max-h-[480px] overflow-y-auto pr-1">
{presetPanel}
</div>
</motion.div>
)}
{/* 已选智能体信息 */}
{selectedAgentPreset && (
<motion.div
className="rounded-xl border border-white/10 bg-white/[0.03] p-4"
<motion.div
className="rounded-2xl border border-white/12 bg-white/[0.05] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
<div className="mb-2 text-xs text-white/40">当前智能体</div>
<div className="mb-2 text-xs text-white/40">当前生图模式</div>
<div className="flex items-center gap-3">
{selectedAgentPreset.avatarUrl ? (
<img
@ -443,15 +608,35 @@ export function AgentGenerationWorkspaceDarkV2({
onClick={onClearPreset}
className="mt-3 text-xs text-violet-400 hover:text-violet-300"
>
更换智能体 →
更换生图模式 →
</button>
)}
</motion.div>
)}
{selectedAgentPresetId && brandModelPanel ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
>
{brandModelPanel}
</motion.div>
) : null}
{materialSelectionPanel ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08 }}
>
{materialSelectionPanel}
</motion.div>
) : null}
{/* Image Slots */}
<motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4"
<motion.section
className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
@ -460,69 +645,78 @@ export function AgentGenerationWorkspaceDarkV2({
<ImagePlus className="h-4 w-4 text-violet-400" />
图片槽位
</h3>
<div className="grid grid-cols-3 gap-2">
{agentUploadSlots.map((slot, index) => (
<motion.button
key={slot.label}
type="button"
onClick={() => fileInputRefs.current[index]?.click()}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-lg transition-all",
attachments[index]
? "ring-2 ring-violet-500/30"
: "border-2 border-dashed border-white/10 hover:border-violet-500/30"
)}
>
<input
ref={(el) => (fileInputRefs.current[index] = el)}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleSlotFileChange(index, e)}
/>
{attachments[index] ? (
<>
<img
src={buildPreviewImageUrl(attachments[index]!.url, 256)}
alt={attachments[index]!.name || slot.label}
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1 text-[10px] text-white">
{slot.label}
{agentUploadSlots.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{agentUploadSlots.map((slot, index) => (
<motion.button
key={slot.label}
type="button"
onClick={() => fileInputRefs.current[index]?.click()}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
className={cn(
"group relative aspect-square overflow-hidden rounded-xl transition-all",
attachments[index]
? "ring-2 ring-violet-500/40"
: "border-2 border-dashed border-white/15 bg-black/25 hover:border-violet-500/35"
)}
>
<input
ref={(el) => {
fileInputRefs.current[index] = el;
}}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleSlotFileChange(index, e)}
/>
{attachments[index] ? (
<>
<img
src={buildPreviewImageUrl(attachments[index]!.url, 256)}
alt={attachments[index]!.name || slot.label}
className="h-full w-full object-cover"
/>
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-2 py-1 text-[10px] text-white">
{slot.label}
</div>
<button
type="button"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
updateAttachmentAtSlot(index, null);
}}
>
<X className="h-3 w-3" />
</button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-white/30">
{slotUploading === index ? (
<Loader2 className="h-5 w-5 animate-spin text-violet-500" />
) : (
<>
<Upload className="h-5 w-5" />
<span className="text-[10px]">{slot.label}</span>
</>
)}
</div>
<button
type="button"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
updateAttachmentAtSlot(index, null);
}}
>
<X className="h-3 w-3" />
</button>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-1 text-white/30">
{slotUploading === index ? (
<Loader2 className="h-5 w-5 animate-spin text-violet-500" />
) : (
<>
<Upload className="h-5 w-5" />
<span className="text-[10px]">{slot.label}</span>
</>
)}
</div>
)}
</motion.button>
))}
</div>
)}
</motion.button>
))}
</div>
) : (
<div className="flex min-h-[132px] flex-col items-center justify-center rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 text-center text-sm text-white/45">
<ImagePlus className="mb-2 h-5 w-5 opacity-60" />
先选择一个生图模式,系统会自动展示产品图1、产品图2和模特图槽位。
</div>
)}
</motion.section>
{/* Prompt Input */}
<motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4"
<motion.section
className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
@ -540,8 +734,8 @@ export function AgentGenerationWorkspaceDarkV2({
</motion.section>
{/* Settings */}
<motion.section
className="rounded-xl border border-white/5 bg-white/[0.02] p-4"
<motion.section
className="rounded-2xl border border-white/12 bg-white/[0.04] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
@ -552,7 +746,7 @@ export function AgentGenerationWorkspaceDarkV2({
</h3>
<div className="space-y-3">
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-10 rounded-lg border-white/5 bg-black text-white">
<SelectTrigger className="h-10 rounded-lg border-white/12 bg-black text-white">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent className="border-white/10 bg-zinc-900">
@ -601,13 +795,27 @@ export function AgentGenerationWorkspaceDarkV2({
</div>
{/* Right Panel - Results */}
<div className="flex flex-col">
<motion.div
className="relative flex-1 min-h-[500px] rounded-xl border border-white/5 bg-white/[0.02] overflow-hidden"
<div className="flex min-h-0 flex-col">
<motion.div
className="relative flex min-h-[500px] flex-1 flex-col overflow-hidden rounded-[28px] border border-white/12 bg-white/[0.04] shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center justify-between border-b border-white/12 px-5 py-4">
<div>
<div className="text-sm font-semibold text-white">结果预览</div>
<div className="text-xs text-white/40">
生成完成后会同步回到画布,并在这里保留预览与切换。
</div>
</div>
{allGeneratedImages.length > 0 ? (
<div className="rounded-full bg-white/8 px-3 py-1 text-xs text-white/60">
共 {allGeneratedImages.length} 张
</div>
) : null}
</div>
<AnimatePresence mode="wait">
{isGenerating ? (
<motion.div
@ -615,7 +823,7 @@ export function AgentGenerationWorkspaceDarkV2({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full items-center justify-center"
className="flex flex-1 items-center justify-center"
>
<ImageGenerationLoading
imageCount={imageParams.imageCount || 1}
@ -631,7 +839,7 @@ export function AgentGenerationWorkspaceDarkV2({
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="relative flex h-full items-center justify-center p-6"
className="relative flex flex-1 items-center justify-center p-6"
>
<img
src={buildPreviewImageUrl(displayedResultUrl, 1400)}
@ -640,7 +848,9 @@ export function AgentGenerationWorkspaceDarkV2({
onClick={() => setFullscreenPreview(displayedResultUrl)}
/>
<button
onClick={() => setFullscreenPreview(displayedResultUrl)}
onClick={() => {
void handleDownloadOriginalImage(displayedResultUrl);
}}
className="absolute bottom-6 right-6 flex h-10 items-center gap-2 rounded-full bg-white/10 px-4 text-sm text-white backdrop-blur-md transition-colors hover:bg-white/20"
>
<Download className="h-4 w-4" />
@ -653,7 +863,7 @@ export function AgentGenerationWorkspaceDarkV2({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col items-center justify-center text-center"
className="flex flex-1 flex-col items-center justify-center text-center"
>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
<Wand2 className="h-8 w-8 text-white/20" />
@ -664,40 +874,41 @@ export function AgentGenerationWorkspaceDarkV2({
<p className="mt-2 text-sm text-white/30">
{selectedAgentPresetId
? "配置图片槽位后点击开始生成"
: "从左侧选择或创建一个智能体"}
: "从左侧选择一个生图模式后继续"}
</p>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Thumbnails */}
{allGeneratedImages.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 flex gap-2 overflow-x-auto pb-2"
>
{allGeneratedImages.map((url) => (
<button
key={url}
onClick={() => setSelectedResultUrl(url)}
className={cn(
"relative flex-shrink-0 overflow-hidden rounded-lg transition-all",
selectedResultUrl === url
? "ring-2 ring-violet-500"
: "ring-1 ring-white/10 hover:ring-white/30"
)}
>
<img
src={buildPreviewImageUrl(url, 200)}
alt="缩略图"
className="h-16 w-16 object-cover"
/>
</button>
))}
</motion.div>
)}
{allGeneratedImages.length > 0 ? (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
className="border-t border-white/12 bg-black/20 px-4 py-3"
>
<div className="flex gap-2 overflow-x-auto pb-1">
{allGeneratedImages.map((url) => (
<button
key={url}
onClick={() => setSelectedResultUrl(url)}
className={cn(
"relative flex-shrink-0 overflow-hidden rounded-xl transition-all",
displayedResultUrl === url
? "ring-2 ring-violet-500"
: "ring-1 ring-white/12 hover:ring-white/30"
)}
>
<img
src={buildPreviewImageUrl(url, 240)}
alt="缩略图"
className="h-20 w-20 object-cover"
/>
</button>
))}
</div>
</motion.div>
) : null}
</motion.div>
</div>
</div>
</div>

View File

@ -10,11 +10,9 @@ import {
User,
Grid3X3,
Wand2,
ArrowRight,
LayoutGrid,
FolderOpen,
Building2,
Image as ImageIcon,
Menu
} from "lucide-react";
import useSWR from "swr";
@ -25,6 +23,8 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { agentService } from "@/services/agents";
import type { AgentPreset } from "@/types/agent";
import { normalizeAgentWorkflowShowcaseConfig } from "@/lib/agents/workflow-showcase-config";
import { AgentWorkflowShowcaseBanner } from "./AgentWorkflowShowcaseBanner";
import { AgentPresetCardDark } from "./AgentPresetCardDark";
import { AgentPresetDialog } from "./AgentPresetDialog";
@ -36,6 +36,7 @@ interface AgentPresetPanelDarkV2Props {
onSelectPreset: (presetId: string | null) => void;
onCreateClick?: () => void;
embedded?: boolean;
embeddedEntityLabel?: string;
}
export function AgentPresetPanelDarkV2({
@ -43,11 +44,16 @@ export function AgentPresetPanelDarkV2({
onSelectPreset,
onCreateClick,
embedded = false,
embeddedEntityLabel = "智能体",
}: AgentPresetPanelDarkV2Props) {
const [activeTab, setActiveTab] = useState<NavTab>("cases");
const [keyword, setKeyword] = useState("");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingPreset, setEditingPreset] = useState<AgentPreset | null>(null);
const { data: siteSettingsData } = useSWR<{ agentWorkflowShowcaseConfig?: unknown }>(
!embedded ? "/api/site-settings" : null,
(url: string) => fetch(url, { cache: "no-store" }).then((res) => res.json())
);
const { data, isLoading, mutate } = useSWR(
["agent-presets"],
@ -151,7 +157,7 @@ export function AgentPresetPanelDarkV2({
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" />
<Input
placeholder="搜索智能体..."
placeholder={`搜索${embeddedEntityLabel}...`}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className={cn(
@ -204,7 +210,7 @@ export function AgentPresetPanelDarkV2({
</AnimatePresence>
) : (
<div className="text-center py-8 text-white/40 text-sm">
暂无智能体
暂无{embeddedEntityLabel}
</div>
)}
</div>
@ -235,7 +241,7 @@ export function AgentPresetPanelDarkV2({
<div className="min-h-screen bg-black">
{/* 顶部固定导航栏 */}
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-6">
<div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex h-16 items-center justify-between">
{/* Logo & Site Name */}
<div className="flex items-center gap-3">
@ -294,62 +300,21 @@ export function AgentPresetPanelDarkV2({
{/* Hero Section - 视频/功能展示区 */}
<section className="relative pt-16">
<div className="mx-auto max-w-[1800px] px-6 py-8">
{/* 标题区域 */}
<motion.div
className="mb-8 text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className="mb-3 text-3xl font-bold text-white md:text-4xl">
三倍速出片,电影级质感
</h1>
<p className="mb-6 text-base text-white/50">
第一步:风格选择 &nbsp;&nbsp; 第二步:智能体生图
</p>
<Button
className={cn(
"h-12 gap-2 rounded-full px-8",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-white font-medium text-base",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
"transition-all duration-300"
)}
>
我要创作
<ArrowRight className="h-5 w-5" />
</Button>
</motion.div>
{/* 视频/功能卡片展示区域 - 预留空位 */}
<motion.div
className="relative mb-12 aspect-[21/9] w-full overflow-hidden rounded-2xl border border-white/10 bg-zinc-900/50"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
{/* 这里将来放视频或其他功能卡片 */}
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-2xl bg-white/5 mx-auto">
<ImageIcon className="h-10 w-10 text-white/20" />
</div>
<p className="text-white/40">视频展示区(预留)</p>
<p className="mt-2 text-sm text-white/20">未来可放置宣传片或功能介绍</p>
</div>
</div>
{/* 装饰性渐变 */}
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent pointer-events-none" />
</motion.div>
<div className="mx-auto max-w-[1480px] px-6 py-8 lg:px-8 xl:px-10">
<AgentWorkflowShowcaseBanner
config={normalizeAgentWorkflowShowcaseConfig(siteSettingsData?.agentWorkflowShowcaseConfig)}
onActionClick={() => {
const target = document.getElementById("agent-preset-grid");
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "start" });
}}
/>
</div>
</section>
{/* 智能体卡片网格区域 */}
<section className="pb-20">
<div className="mx-auto max-w-[1800px] px-6">
<div id="agent-preset-grid" className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
{/* 搜索栏 */}
<motion.div
className="mb-8 flex items-center gap-4"
@ -420,7 +385,7 @@ export function AgentPresetPanelDarkV2({
{/* 页脚 */}
<footer className="border-t border-white/5 py-6">
<div className="mx-auto max-w-[1800px] px-6">
<div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex items-center justify-center gap-6 text-sm text-white/30">
<a href="#" className="hover:text-white/60 transition-colors">使用协议</a>
<a href="#" className="hover:text-white/60 transition-colors">隐私条款</a>

View File

@ -15,12 +15,14 @@ interface AgentWorkflowBrandModelPickerProps {
brand: string;
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
onSelectBrandModel: (value: AgentWorkflowSelectedBrandModel | null) => void;
theme?: "light" | "dark";
}
export function AgentWorkflowBrandModelPicker({
brand,
selectedBrandModel = null,
onSelectBrandModel,
theme = "light",
}: AgentWorkflowBrandModelPickerProps) {
const { data } = useSWR<{ agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig }>(
"/api/site-settings",
@ -40,34 +42,76 @@ export function AgentWorkflowBrandModelPicker({
[config.entries, normalizedBrand]
);
const images = matchedEntry?.images || [];
const isDark = theme === "dark";
return (
<section className="space-y-3 rounded-2xl border border-[#dbe3ff] bg-[#f8faff] p-3">
<section
className={cn(
"space-y-3 rounded-2xl border p-4",
isDark ? "border-white/12 bg-white/[0.04]" : "border-[#dbe3ff] bg-[#f8faff]"
)}
>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-[#334155]">
<UserRound className="h-4 w-4 text-[#4f46e5]" />
<div
className={cn(
"flex items-center gap-2 text-sm font-medium",
isDark ? "text-white/88" : "text-[#334155]"
)}
>
<UserRound className={cn("h-4 w-4", isDark ? "text-emerald-300" : "text-[#4f46e5]")} />
<span>品牌模特选择</span>
</div>
<div className="text-xs leading-5 text-[#5b6472]">
<div
className={cn(
"text-xs leading-5",
isDark ? "text-white/55" : "text-[#5b6472]"
)}
>
根据第一步选择的品牌,展示管理员预先上传的模特图。选中后会自动带入右侧“模特图”槽位。
</div>
</div>
{!normalizedBrand ? (
<div className="flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed bg-background/60 px-4 text-center text-sm text-muted-foreground">
<div
className={cn(
"flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed px-4 text-center text-sm",
isDark
? "border-white/12 bg-black/20 text-white/45"
: "bg-background/60 text-muted-foreground"
)}
>
<ImageIcon className="mb-2 h-5 w-5 opacity-60" />
先回到第一步选择品牌,第二步才会按品牌展示可选模特图。
</div>
) : images.length > 0 ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-background px-2 py-1">品牌:{normalizedBrand}</span>
<span className="rounded-full bg-background px-2 py-1">
<div className={cn("flex flex-wrap gap-2 text-xs", isDark ? "text-white/55" : "text-muted-foreground")}>
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
品牌:{normalizedBrand}
</span>
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
共 {images.length} 张模特图
</span>
{selectedBrandModel ? (
<span className="rounded-full bg-background px-2 py-1">已选 1 张</span>
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
已选 1 张
</span>
) : null}
</div>
<Button
@ -104,11 +148,20 @@ export function AgentWorkflowBrandModelPicker({
className={cn(
"group overflow-hidden rounded-2xl border bg-background text-left transition-all",
selected
? "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: "border-border hover:border-primary/40 hover:shadow-sm"
? isDark
? "border-emerald-400/70 bg-white/[0.03] shadow-[0_0_0_1px_rgba(52,211,153,0.2)]"
: "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: isDark
? "border-white/12 bg-white/[0.02] hover:border-white/25 hover:shadow-sm"
: "border-border hover:border-primary/40 hover:shadow-sm"
)}
>
<div className="relative aspect-square overflow-hidden bg-muted/40">
<div
className={cn(
"relative aspect-square overflow-hidden",
isDark ? "bg-white/[0.04]" : "bg-muted/40"
)}
>
<img
src={image.imageUrl}
alt={image.displayName || `${normalizedBrand} 模特图`}
@ -124,7 +177,9 @@ export function AgentWorkflowBrandModelPicker({
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors",
selected
? "border-primary bg-primary"
? isDark
? "border-emerald-400 bg-emerald-400 text-black"
: "border-primary bg-primary"
: "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100"
)}
>
@ -136,10 +191,10 @@ export function AgentWorkflowBrandModelPicker({
</div>
</div>
<div className="space-y-1 px-3 py-2">
<div className="truncate text-xs font-medium">
<div className={cn("truncate text-xs font-medium", isDark ? "text-white/85" : "")}>
{image.displayName || `${normalizedBrand} 模特图`}
</div>
<div className="text-[11px] text-muted-foreground">
<div className={cn("text-[11px]", isDark ? "text-white/45" : "text-muted-foreground")}>
点击{selected ? "取消" : "选择"},自动进入模特图槽位
</div>
</div>
@ -149,7 +204,14 @@ export function AgentWorkflowBrandModelPicker({
</div>
</div>
) : (
<div className="flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed bg-background/60 px-4 text-center text-sm text-muted-foreground">
<div
className={cn(
"flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed px-4 text-center text-sm",
isDark
? "border-white/12 bg-black/20 text-white/45"
: "bg-background/60 text-muted-foreground"
)}
>
<ImageIcon className="mb-2 h-5 w-5 opacity-60" />
品牌“{normalizedBrand}”暂未配置模特图库,请到系统设置上传对应模特图。
</div>

View File

@ -23,6 +23,7 @@ interface AgentWorkflowMaterialPickerProps {
onValueChange: (value: string) => void;
onSearch: () => void;
onSelectedImageIdsChange: (next: string[]) => void;
theme?: "light" | "dark";
}
export function AgentWorkflowMaterialPicker({
@ -41,7 +42,9 @@ export function AgentWorkflowMaterialPicker({
onValueChange,
onSearch,
onSelectedImageIdsChange,
theme = "light",
}: AgentWorkflowMaterialPickerProps) {
const isDark = theme === "dark";
const selectedSet = useMemo(
() => new Set((selectedImageIds || []).map((item) => String(item || "").trim()).filter(Boolean)),
[selectedImageIds]
@ -65,10 +68,17 @@ export function AgentWorkflowMaterialPicker({
};
return (
<section className="space-y-3 rounded-2xl border bg-muted/20 p-4">
<section
className={cn(
"space-y-3 rounded-2xl border p-4",
isDark ? "border-white/12 bg-black/20" : "bg-muted/20"
)}
>
<div className="space-y-1">
<div className="text-sm font-medium">{title}</div>
<div className="text-xs text-muted-foreground">{description}</div>
<div className={cn("text-sm font-medium", isDark ? "text-white/88" : "")}>{title}</div>
<div className={cn("text-xs", isDark ? "text-white/50" : "text-muted-foreground")}>
{description}
</div>
</div>
<div className="flex flex-col gap-2 md:flex-row">
@ -77,6 +87,10 @@ export function AgentWorkflowMaterialPicker({
onChange={(event) => onValueChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
className={cn(
isDark &&
"border-white/12 bg-white/[0.04] text-white placeholder:text-white/25 focus-visible:ring-emerald-400/30"
)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
@ -89,6 +103,7 @@ export function AgentWorkflowMaterialPicker({
type="button"
onClick={onSearch}
disabled={disabled || isLoading || !value.trim()}
className={cn(isDark && "border-white/12 bg-white/8 text-white hover:bg-white/12")}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -100,10 +115,19 @@ export function AgentWorkflowMaterialPicker({
</div>
{searchNote ? (
<div className="text-xs text-muted-foreground">{searchNote}</div>
<div className={cn("text-xs", isDark ? "text-white/42" : "text-muted-foreground")}>
{searchNote}
</div>
) : null}
{disabled && disabledHint ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
<div
className={cn(
"rounded-xl border px-3 py-2 text-xs",
isDark
? "border-amber-500/20 bg-amber-500/10 text-amber-100"
: "border-amber-200 bg-amber-50 text-amber-800"
)}
>
{disabledHint}
</div>
) : null}
@ -117,19 +141,39 @@ export function AgentWorkflowMaterialPicker({
{!disabled && result ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-background px-2 py-1">
<div className={cn("flex flex-wrap gap-2 text-xs", isDark ? "text-white/52" : "text-muted-foreground")}>
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
命中:{result.matchedSku || result.query}
</span>
{result.productModel ? (
<span className="rounded-full bg-background px-2 py-1">
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
型号:{result.productModel}
</span>
) : null}
<span className="rounded-full bg-background px-2 py-1">
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
共 {result.totalCount} 张
</span>
<span className="rounded-full bg-background px-2 py-1">
<span
className={cn(
"rounded-full px-2 py-1",
isDark ? "bg-white/8 text-white/70" : "bg-background"
)}
>
已选 {selectedCount} 张
</span>
</div>
@ -166,11 +210,20 @@ export function AgentWorkflowMaterialPicker({
className={cn(
"group overflow-hidden rounded-2xl border bg-background text-left transition-all",
selected
? "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: "border-border hover:border-primary/40 hover:shadow-sm"
? isDark
? "border-emerald-400/70 bg-white/[0.03] shadow-[0_0_0_1px_rgba(52,211,153,0.2)]"
: "border-primary shadow-[0_0_0_1px_rgba(59,130,246,0.18)]"
: isDark
? "border-white/12 bg-white/[0.02] hover:border-white/25 hover:shadow-sm"
: "border-border hover:border-primary/40 hover:shadow-sm"
)}
>
<div className="relative aspect-square overflow-hidden bg-muted/40">
<div
className={cn(
"relative aspect-square overflow-hidden",
isDark ? "bg-white/[0.04]" : "bg-muted/40"
)}
>
<img
src={image.imageUrl}
alt={image.label}
@ -189,7 +242,9 @@ export function AgentWorkflowMaterialPicker({
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border text-white transition-colors",
selected
? "border-primary bg-primary"
? isDark
? "border-emerald-400 bg-emerald-400 text-black"
: "border-primary bg-primary"
: "border-white/60 bg-black/35 opacity-0 group-hover:opacity-100"
)}
>
@ -201,8 +256,10 @@ export function AgentWorkflowMaterialPicker({
</div>
</div>
<div className="space-y-1 px-3 py-2">
<div className="truncate text-xs font-medium">{image.fileName}</div>
<div className="text-[11px] text-muted-foreground">
<div className={cn("truncate text-xs font-medium", isDark ? "text-white/85" : "")}>
{image.fileName}
</div>
<div className={cn("text-[11px]", isDark ? "text-white/45" : "text-muted-foreground")}>
点击{selected ? "取消" : "选择"},可多选带入生图模式
</div>
</div>
@ -212,7 +269,14 @@ export function AgentWorkflowMaterialPicker({
</div>
</div>
) : (
<div className="flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed bg-background/60 px-4 text-center text-sm text-muted-foreground">
<div
className={cn(
"flex min-h-[124px] flex-col items-center justify-center rounded-2xl border border-dashed px-4 text-center text-sm",
isDark
? "border-white/12 bg-white/[0.03] text-white/42"
: "bg-background/60 text-muted-foreground"
)}
>
{disabled ? (
<>
<ImageIcon className="mb-2 h-5 w-5 opacity-60" />
@ -233,7 +297,12 @@ export function AgentWorkflowMaterialPicker({
)}
{result?.images?.length ? (
<div className="flex items-start gap-2 rounded-xl bg-background px-3 py-2 text-xs text-muted-foreground">
<div
className={cn(
"flex items-start gap-2 rounded-xl px-3 py-2 text-xs",
isDark ? "bg-white/[0.04] text-white/45" : "bg-background text-muted-foreground"
)}
>
<RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
已选中的图片会在下一步优先带入右侧“生图模式”槽位,你也可以进入后继续替换。
</div>

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import { Sparkles } from "lucide-react";
import { AgentWorkflowMaterialPicker } from "@/components/agents/AgentWorkflowMaterialPicker";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import { cn } from "@/lib/utils";
import type { AgentWorkflowInputs } from "@/components/agents/AgentWorkflowPanel";
import type { AgentWorkflowMaterialSelections } from "@/types/agent";
@ -16,6 +17,7 @@ interface AgentWorkflowMaterialSelectionPanelProps {
description?: string;
disabled?: boolean;
disabledHint?: string;
theme?: "light" | "dark";
}
export function AgentWorkflowMaterialSelectionPanel({
@ -27,6 +29,7 @@ export function AgentWorkflowMaterialSelectionPanel({
description = "先确定生图模式,再搜索主 SKU 或搭配 SKU 的白底图。选中的图片会自动进入对应槽位。",
disabled = false,
disabledHint = "请先在下方选择一个生图模式,再启用 SKU / SPU 搜图。",
theme = "light",
}: AgentWorkflowMaterialSelectionPanelProps) {
const [materialSearchState, setMaterialSearchState] = useState<{
primary: { isLoading: boolean; errorText: string | null };
@ -108,15 +111,33 @@ export function AgentWorkflowMaterialSelectionPanel({
updateMaterialSearchState(source, { isLoading: false });
}
};
const isDark = theme === "dark";
return (
<div className="space-y-3 rounded-2xl border border-[#dbe3ff] bg-[#f8faff] p-3">
<div
className={cn(
"space-y-3 rounded-2xl border p-4",
isDark ? "border-white/12 bg-white/[0.04]" : "border-[#dbe3ff] bg-[#f8faff]"
)}
>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-[#334155]">
<Sparkles className="h-4 w-4 text-[#4f46e5]" />
<div
className={cn(
"flex items-center gap-2 text-sm font-medium",
isDark ? "text-white/88" : "text-[#334155]"
)}
>
<Sparkles className={cn("h-4 w-4", isDark ? "text-emerald-300" : "text-[#4f46e5]")} />
<span>{title}</span>
</div>
<div className="text-xs leading-5 text-[#5b6472]">{description}</div>
<div
className={cn(
"text-xs leading-5",
isDark ? "text-white/55" : "text-[#5b6472]"
)}
>
{description}
</div>
</div>
<AgentWorkflowMaterialPicker
@ -136,6 +157,7 @@ export function AgentWorkflowMaterialSelectionPanel({
onSearch={() => {
void handleSearchMaterials("primary");
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,
@ -164,6 +186,7 @@ export function AgentWorkflowMaterialSelectionPanel({
onSearch={() => {
void handleSearchMaterials("secondary");
}}
theme={theme}
onSelectedImageIdsChange={(next) =>
onMaterialsChange({
...materials,

View File

@ -2,15 +2,16 @@
import { useMemo } from "react";
import { motion } from "framer-motion";
import { ArrowRight, Check, Copy, RotateCcw, Sparkles, ChevronRight, Wand2, LayoutGrid, FolderOpen, User, Building2, Menu } from "lucide-react";
import { ArrowRight, Check, Copy, RotateCcw, Sparkles, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { defaultAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import { createEmptyAgentWorkflowMaterialSelection } from "@/lib/agents/workflow-material-config";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import { AgentWorkflowShowcaseBanner } from "@/components/agents/AgentWorkflowShowcaseBanner";
import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
@ -18,6 +19,7 @@ import {
import type {
AgentWorkflowComposerSelection,
AgentWorkflowMaterialSelections,
AgentWorkflowShowcaseConfig,
AgentWorkflowStepKey,
AgentWorkflowUiConfig,
} from "@/types/agent";
@ -40,6 +42,10 @@ interface AgentWorkflowPanelDarkV2Props {
onReset?: () => void;
onNextStep?: () => void;
onSkipStep?: () => void;
siteName?: string | null;
siteLogo?: string | null;
siteLogoDark?: string | null;
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
}
function buildSelectionSummary(params: {
@ -161,6 +167,10 @@ export function AgentWorkflowPanelDarkV2({
onReset,
onNextStep,
onSkipStep,
siteName,
siteLogo,
siteLogoDark,
showcaseConfig,
}: AgentWorkflowPanelDarkV2Props) {
const brandOptions = useMemo(
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
@ -222,126 +232,58 @@ export function AgentWorkflowPanelDarkV2({
}
};
// 导航标签配置
const navTabs = [
{ id: "cases", label: "案例广场", icon: LayoutGrid },
{ id: "my-works", label: "我的作品", icon: FolderOpen },
{ id: "personal-assets", label: "个人资产库", icon: User },
{ id: "company-assets", label: "公司资产库", icon: Building2 },
];
return (
<div className="min-h-screen bg-black">
{/* 顶部固定导航栏 */}
<header className="fixed top-0 left-0 right-0 z-50 border-b border-white/5 bg-black/80 backdrop-blur-xl">
<div className="mx-auto max-w-[1800px] px-6">
<div className="flex h-16 items-center justify-between">
{/* Logo & Site Name */}
<div className="flex items-center gap-3">
<button className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/5 text-white/60 hover:bg-white/10 hover:text-white transition-colors">
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600">
<Wand2 className="h-4 w-4 text-white" />
</div>
<span className="text-base font-semibold text-white">AI创作平台</span>
</div>
</div>
{/* 导航标签 */}
<nav className="flex items-center gap-1 rounded-full bg-white/5 p-1">
{navTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
className="flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium text-white/60 transition-all duration-300 hover:text-white hover:bg-white/5"
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
);
})}
</nav>
{/* 右侧操作 */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => {
if (onReset) {
onReset();
return;
}
onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "" });
onMaterialsChange({
primary: createEmptyAgentWorkflowMaterialSelection(),
secondary: createEmptyAgentWorkflowMaterialSelection(),
});
onSelectionsChange({});
}}
className="h-9 gap-1.5 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
<RotateCcw className="h-3.5 w-3.5" />
重置
</Button>
</div>
</div>
</div>
</header>
<AgentCanvasTopNavDark
siteName={siteName}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
activeKey="agent"
rightSlot={
<Button
variant="ghost"
size="sm"
onClick={() => {
if (onReset) {
onReset();
return;
}
onInputsChange({ primarySku: "", secondarySku: "", brand: "", store: "" });
onMaterialsChange({
primary: createEmptyAgentWorkflowMaterialSelection(),
secondary: createEmptyAgentWorkflowMaterialSelection(),
});
onSelectionsChange({});
}}
className="h-9 gap-1.5 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
<RotateCcw className="h-3.5 w-3.5" />
重置
</Button>
}
/>
{/* Hero Section */}
<section className="relative pt-16">
<div className="mx-auto max-w-[1800px] px-6 py-8">
{/* 标题区域 */}
<motion.div
className="mb-8 text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className="mb-3 text-3xl font-bold text-white md:text-4xl">
三倍速出片,电影级质感
</h1>
<p className="mb-6 text-base text-white/50">
<span className="text-emerald-400 font-medium">第一步:风格选择</span>
<span className="mx-2 text-white/20">→</span>
<span className="text-white/40">第二步:智能体生图</span>
</p>
</motion.div>
{/* 进度指示 */}
<motion.div
className="mx-auto mb-8 max-w-md"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<div className="flex items-center justify-between text-sm text-white/50 mb-2">
<span>完成进度</span>
<span className="font-medium text-white">{progress.completed}/{progress.total}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-500"
initial={{ width: 0 }}
animate={{ width: `${progress.total > 0 ? (progress.completed / progress.total) * 100 : 0}%` }}
transition={{ duration: 0.5 }}
/>
</div>
</motion.div>
<div className="mx-auto max-w-[1480px] px-6 py-8 lg:px-8 xl:px-10">
<AgentWorkflowShowcaseBanner
config={showcaseConfig}
onActionClick={() => {
const target = document.getElementById("agent-workflow-content");
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "start" });
}}
/>
</div>
</section>
{/* Main Content */}
<main className="pb-20">
<div className="mx-auto max-w-[1800px] px-6">
<ScrollArea className="h-[calc(100vh-300px)]">
<div className="space-y-8 pr-4">
<main className="pb-40">
<div id="agent-workflow-content" className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="space-y-8">
{/* 基础配置 */}
<motion.section
className="rounded-2xl border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
@ -359,6 +301,7 @@ export function AgentWorkflowPanelDarkV2({
placeholder="选择品牌"
searchPlaceholder="输入品牌名筛选"
emptyText="暂无品牌可选"
theme="dark"
onValueChange={(value) => onInputsChange({ ...inputs, brand: value })}
/>
<SearchableSelect
@ -367,6 +310,7 @@ export function AgentWorkflowPanelDarkV2({
placeholder="选择店铺"
searchPlaceholder="输入店铺名筛选"
emptyText="暂无店铺可选"
theme="dark"
onValueChange={(value) => onInputsChange({ ...inputs, store: value })}
/>
</div>
@ -433,6 +377,7 @@ export function AgentWorkflowPanelDarkV2({
{/* Prompt 预览 */}
<motion.section
className="rounded-2xl border border-white/10 bg-white/[0.035] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
@ -456,49 +401,74 @@ export function AgentWorkflowPanelDarkV2({
className="resize-none rounded-xl border-white/5 bg-white/[0.03] text-sm text-white/80 placeholder:text-white/20 focus:border-white/10"
/>
</motion.section>
{/* 底部操作 */}
{onNextStep && (
<motion.div
className="flex justify-end gap-3 pt-4 pb-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
{onSkipStep && (
<Button
variant="ghost"
onClick={onSkipStep}
className="h-11 gap-2 rounded-full text-white/60 hover:bg-white/10 hover:text-white"
>
跳过
<ChevronRight className="h-4 w-4" />
</Button>
)}
<Button
onClick={onNextStep}
disabled={progress.total > 0 && !progress.isComplete}
className={cn(
"h-11 gap-2 rounded-full px-8",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-white font-medium",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
"disabled:opacity-30"
)}
>
下一步:选择智能体
<ArrowRight className="h-4 w-4" />
</Button>
</motion.div>
)}
</div>
</ScrollArea>
</div>
</main>
{onNextStep ? (
<motion.div
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="pointer-events-none fixed inset-x-0 bottom-4 z-40 px-4 sm:px-6"
>
<div className="pointer-events-auto mx-auto flex w-full max-w-5xl flex-col gap-4 rounded-[28px] border border-white/12 bg-black/85 px-4 py-4 shadow-[0_24px_80px_rgba(0,0,0,0.45)] backdrop-blur-2xl sm:px-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium text-white">工作流进度</div>
<div className="text-xs text-white/45">
已完成 {progress.completed} / {progress.total} 个步骤
{progress.isComplete ? ",可以直接进入下一步" : ",也可以选择跳过直接进入下一步"}
</div>
</div>
<div className="text-sm font-semibold text-white">
{progress.total > 0 ? Math.round((progress.completed / progress.total) * 100) : 0}%
</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-teal-400"
initial={{ width: 0 }}
animate={{ width: `${progress.total > 0 ? (progress.completed / progress.total) * 100 : 0}%` }}
transition={{ duration: 0.45 }}
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
{onSkipStep ? (
<Button
variant="ghost"
onClick={onSkipStep}
className="h-11 gap-2 rounded-full border border-white/10 bg-white/[0.03] px-6 text-white/75 hover:bg-white/10 hover:text-white"
>
跳过并进入下一步
<ChevronRight className="h-4 w-4" />
</Button>
) : null}
<Button
onClick={onNextStep}
disabled={progress.total > 0 && !progress.isComplete}
className={cn(
"h-11 gap-2 rounded-full px-8",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-white font-medium",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
"disabled:opacity-30"
)}
>
下一步:选择生图模式
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</motion.div>
) : null}
{/* 页脚 */}
<footer className="border-t border-white/5 py-6">
<div className="mx-auto max-w-[1800px] px-6">
<footer className="border-t border-white/5 pb-28 pt-6">
<div className="mx-auto max-w-[1480px] px-6 lg:px-8 xl:px-10">
<div className="flex items-center justify-center gap-6 text-sm text-white/30">
<a href="#" className="hover:text-white/60 transition-colors">使用协议</a>
<a href="#" className="hover:text-white/60 transition-colors">隐私条款</a>

View File

@ -0,0 +1,126 @@
"use client";
import { motion } from "framer-motion";
import { ArrowRight, ImageIcon, PlayCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { defaultAgentWorkflowShowcaseConfig } from "@/lib/agents/workflow-showcase-config";
import type { AgentWorkflowShowcaseConfig } from "@/types/agent";
interface AgentWorkflowShowcaseBannerProps {
config?: AgentWorkflowShowcaseConfig | null;
onActionClick?: () => void;
className?: string;
}
export function AgentWorkflowShowcaseBanner({
config,
onActionClick,
className,
}: AgentWorkflowShowcaseBannerProps) {
const resolved = config || defaultAgentWorkflowShowcaseConfig;
const hasVideo = Boolean(resolved.videoUrl);
const hasPoster = Boolean(resolved.posterUrl);
const shouldRenderMedia = resolved.enabled !== false;
return (
<div className={cn("space-y-8", className)}>
<motion.div
className="text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className="mb-3 text-3xl font-bold text-white md:text-4xl">{resolved.title}</h1>
<p className="mb-6 text-base text-white/50">{resolved.subtitle}</p>
{resolved.actionText ? (
<Button
type="button"
onClick={onActionClick}
className={cn(
"h-12 gap-2 rounded-full px-8",
"bg-gradient-to-r from-emerald-500 to-teal-500",
"text-base font-medium text-white",
"hover:from-emerald-400 hover:to-teal-400",
"shadow-[0_0_30px_rgba(16,185,129,0.3)]",
"transition-all duration-300"
)}
>
{resolved.actionText}
<ArrowRight className="h-5 w-5" />
</Button>
) : null}
</motion.div>
{shouldRenderMedia ? (
<motion.div
className="relative overflow-hidden rounded-[28px] border border-white/12 bg-[#101214] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.15 }}
>
<div className="aspect-[21/9] w-full">
{hasVideo ? (
<video
key={resolved.videoUrl}
className="h-full w-full object-cover"
src={resolved.videoUrl}
poster={resolved.posterUrl || undefined}
autoPlay
muted
loop
playsInline
preload="metadata"
/>
) : hasPoster ? (
<img
src={resolved.posterUrl || ""}
alt={resolved.overlayText || resolved.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center bg-[radial-gradient(circle_at_top,#1b2430_0%,#0e1013_55%,#070809_100%)] text-center">
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.04]">
<PlayCircle className="h-10 w-10 text-white/25" />
</div>
<div className="text-base font-medium text-white/70">顶部视频海报待配置</div>
<div className="mt-2 max-w-lg text-sm text-white/35">
在智能体系统设置中上传视频或封面图后,这里会自动展示循环播放海报。
</div>
</div>
)}
</div>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(16,185,129,0.12),transparent_42%)]" />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/10 to-black/35" />
{resolved.overlayText ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-8 text-center">
<div className="max-w-[78%] text-2xl font-semibold tracking-[0.18em] text-white/22 sm:text-3xl lg:text-5xl">
{resolved.overlayText}
</div>
</div>
) : null}
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-center justify-between px-5 py-5 sm:px-7">
<div className="rounded-full border border-white/14 bg-black/30 px-3 py-1 text-xs font-medium text-white/70 backdrop-blur-md">
视频海报
</div>
{hasVideo || hasPoster ? (
<div className="rounded-full border border-white/14 bg-black/30 px-3 py-1 text-xs text-white/55 backdrop-blur-md">
固定比例 21:9
</div>
) : null}
</div>
{resolved.footerText ? (
<div className="pointer-events-none absolute bottom-5 left-5 flex items-center gap-2 rounded-full border border-white/14 bg-black/35 px-3 py-1.5 text-sm text-white/75 backdrop-blur-md sm:left-7">
<ImageIcon className="h-4 w-4" />
<span>{resolved.footerText}</span>
</div>
) : null}
</motion.div>
) : null}
</div>
);
}

View File

@ -15,6 +15,7 @@ interface SearchableSelectProps {
searchPlaceholder?: string;
emptyText?: string;
onValueChange: (value: string) => void;
theme?: "light" | "dark";
}
export function SearchableSelect({
@ -24,9 +25,11 @@ export function SearchableSelect({
searchPlaceholder = "输入关键字搜索",
emptyText = "暂无可选项",
onValueChange,
theme = "light",
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const isDark = theme === "dark";
useEffect(() => {
if (!open) {
@ -46,21 +49,48 @@ export function SearchableSelect({
<Button
type="button"
variant="outline"
className="h-10 w-full justify-between px-3 text-left font-normal"
className={cn(
"h-10 w-full justify-between px-3 text-left font-normal",
isDark &&
"h-12 rounded-2xl border-white/14 bg-white/[0.05] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] hover:bg-white/[0.08] hover:text-white"
)}
>
<span className={cn("truncate", value ? "text-foreground" : "text-muted-foreground")}>
<span
className={cn(
"truncate",
value
? isDark
? "text-white"
: "text-foreground"
: isDark
? "text-white/42"
: "text-muted-foreground"
)}
>
{value || placeholder}
</span>
<ChevronsUpDown className="h-4 w-4 text-muted-foreground" />
<ChevronsUpDown
className={cn("h-4 w-4", isDark ? "text-white/38" : "text-muted-foreground")}
/>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-3">
<PopoverContent
align="start"
className={cn(
"w-[var(--radix-popover-trigger-width)] p-3",
isDark && "border-white/12 bg-[#0c0f11]/98 text-white backdrop-blur-xl"
)}
>
<div className="space-y-3">
<Input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
className={cn(
isDark &&
"border-white/12 bg-white/[0.04] text-white placeholder:text-white/28 focus-visible:ring-emerald-400/30 focus-visible:ring-offset-0"
)}
/>
<ScrollArea className="max-h-64">
<div className="space-y-1">
@ -78,8 +108,12 @@ export function SearchableSelect({
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
selected
? "bg-primary/10 text-primary"
: "hover:bg-muted"
? isDark
? "bg-emerald-500/15 text-emerald-300"
: "bg-primary/10 text-primary"
: isDark
? "text-white/72 hover:bg-white/8 hover:text-white"
: "hover:bg-muted"
)}
>
<span className="truncate">{item}</span>
@ -88,7 +122,12 @@ export function SearchableSelect({
);
})
) : (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
<div
className={cn(
"px-3 py-6 text-center text-sm",
isDark ? "text-white/35" : "text-muted-foreground"
)}
>
{emptyText}
</div>
)}

View File

@ -0,0 +1,47 @@
import type { AgentWorkflowShowcaseConfig } from "@/types/agent";
export const AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY = "agent_workflow_showcase_config";
export const defaultAgentWorkflowShowcaseConfig: AgentWorkflowShowcaseConfig = {
enabled: true,
title: "三倍速出片,电影级质感",
subtitle: "第一步:风格选择 第二步:生图模式",
actionText: "我要创作",
videoUrl: "",
posterUrl: "",
overlayText: "让每一步选择,都能直达高质感成片",
footerText: "用户作品",
};
function sanitizeText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
export function normalizeAgentWorkflowShowcaseConfig(
value: unknown
): AgentWorkflowShowcaseConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return defaultAgentWorkflowShowcaseConfig;
}
const raw = value as Record<string, unknown>;
return {
enabled:
typeof raw.enabled === "boolean"
? raw.enabled
: defaultAgentWorkflowShowcaseConfig.enabled,
title:
sanitizeText(raw.title, 200) || defaultAgentWorkflowShowcaseConfig.title,
subtitle:
sanitizeText(raw.subtitle, 300) || defaultAgentWorkflowShowcaseConfig.subtitle,
actionText:
sanitizeText(raw.actionText, 48) || defaultAgentWorkflowShowcaseConfig.actionText,
videoUrl: sanitizeText(raw.videoUrl, 2000),
posterUrl: sanitizeText(raw.posterUrl, 2000) || null,
overlayText:
sanitizeText(raw.overlayText, 200) || defaultAgentWorkflowShowcaseConfig.overlayText,
footerText:
sanitizeText(raw.footerText, 64) || defaultAgentWorkflowShowcaseConfig.footerText,
};
}

View File

@ -63,6 +63,17 @@ export interface AgentWorkflowBrandModelConfig {
entries: AgentWorkflowBrandModelEntry[];
}
export interface AgentWorkflowShowcaseConfig {
enabled: boolean;
title: string;
subtitle: string;
actionText: string;
videoUrl: string;
posterUrl?: string | null;
overlayText?: string | null;
footerText?: string | null;
}
export interface AgentWorkflowSelectedBrandModel extends AgentWorkflowBrandModelImage {
brandName: string;
}