feat: separate agent projects from canvas storage
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-19 13:07:39 +08:00
parent 5be4d0e9df
commit 98fe210b64
12 changed files with 1285 additions and 41 deletions

View File

@ -0,0 +1,179 @@
import { NextRequest, NextResponse } from "next/server";
import { and, eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import {
createEmptyAgentGenerationProjectPersistedState,
normalizeAgentGenerationProjectPersistedState,
} from "@/lib/agents/generation-project-state";
export const dynamic = "force-dynamic";
export const revalidate = 0;
function mapAgentProjectRow(row: typeof canvasProject.$inferSelect) {
const canvasData =
row.canvasData && typeof row.canvasData === "object" && !Array.isArray(row.canvasData)
? (row.canvasData as Record<string, unknown>)
: {};
return {
id: row.id,
userId: row.userId,
title: row.title,
description: row.description || null,
thumbnail: row.thumbnail || null,
projectState: normalizeAgentGenerationProjectPersistedState(
canvasData.agentProjectState || createEmptyAgentGenerationProjectPersistedState()
),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id } = await params;
const [project] = await db
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
)
)
.limit(1);
if (!project) {
return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 });
}
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
} catch (error) {
console.error("[AgentProjects API] 获取项目失败:", error);
return NextResponse.json({ success: false, error: "获取生图项目失败" }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id } = await params;
const body = await request.json().catch(() => ({}));
const [existingProject] = await db
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
)
)
.limit(1);
if (!existingProject) {
return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 });
}
const title = body.title !== undefined ? String(body.title || "").trim() || "未命名生图项目" : undefined;
const description =
body.description !== undefined ? String(body.description || "").trim() || null : undefined;
const thumbnail =
body.thumbnail !== undefined ? String(body.thumbnail || "").trim() || null : undefined;
const existingCanvasData =
existingProject.canvasData &&
typeof existingProject.canvasData === "object" &&
!Array.isArray(existingProject.canvasData)
? (existingProject.canvasData as Record<string, unknown>)
: {};
const nextProjectState =
body.projectState !== undefined
? normalizeAgentGenerationProjectPersistedState(body.projectState)
: normalizeAgentGenerationProjectPersistedState(
existingCanvasData.agentProjectState || createEmptyAgentGenerationProjectPersistedState()
);
const [project] = await db
.update(canvasProject)
.set({
title,
description,
thumbnail,
updatedAt: new Date(),
canvasData: {
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select",
agentProjectState: nextProjectState,
} as any,
})
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
)
)
.returning();
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
} catch (error) {
console.error("[AgentProjects API] 保存项目失败:", error);
return NextResponse.json({ success: false, error: "保存生图项目失败" }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { id } = await params;
const [project] = await db
.delete(canvasProject)
.where(
and(
eq(canvasProject.id, id),
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
)
)
.returning();
if (!project) {
return NextResponse.json({ success: false, error: "生图项目不存在" }, { status: 404 });
}
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
} catch (error) {
console.error("[AgentProjects API] 删除项目失败:", error);
return NextResponse.json({ success: false, error: "删除生图项目失败" }, { status: 500 });
}
}

View File

@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from "next/server";
import { and, desc, eq } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import {
createEmptyAgentGenerationProjectPersistedState,
normalizeAgentGenerationProjectPersistedState,
} from "@/lib/agents/generation-project-state";
export const dynamic = "force-dynamic";
export const revalidate = 0;
function mapAgentProjectRow(row: typeof canvasProject.$inferSelect) {
const canvasData =
row.canvasData && typeof row.canvasData === "object" && !Array.isArray(row.canvasData)
? (row.canvasData as Record<string, unknown>)
: {};
return {
id: row.id,
userId: row.userId,
title: row.title,
description: row.description || null,
thumbnail: row.thumbnail || null,
projectState: normalizeAgentGenerationProjectPersistedState(
canvasData.agentProjectState || createEmptyAgentGenerationProjectPersistedState()
),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const projects = await db
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "agent")
)
)
.orderBy(desc(canvasProject.updatedAt));
return NextResponse.json({
success: true,
data: projects.map(mapAgentProjectRow),
});
} catch (error) {
console.error("[AgentProjects API] 获取项目列表失败:", error);
return NextResponse.json({ success: false, error: "获取生图项目列表失败" }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const title = String(body.title || "").trim() || "未命名生图项目";
const description = String(body.description || "").trim() || null;
const thumbnail = String(body.thumbnail || "").trim() || null;
const projectState = normalizeAgentGenerationProjectPersistedState(body.projectState);
const [project] = await db
.insert(canvasProject)
.values({
userId: session.user.id,
title,
description,
thumbnail,
workspaceType: "agent",
canvasData: {
nodes: [],
edges: [],
selectedNodeIds: [],
viewport: { x: 0, y: 0, zoom: 1 },
tool: "select",
agentProjectState: projectState,
} as any,
})
.returning();
return NextResponse.json({ success: true, data: mapAgentProjectRow(project) });
} catch (error) {
console.error("[AgentProjects API] 创建项目失败:", error);
return NextResponse.json({ success: false, error: "创建生图项目失败" }, { status: 500 });
}
}

View File

@ -3,7 +3,7 @@ import { auth } from '@/app/(auth)/auth';
import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { eq, desc } from 'drizzle-orm';
import { and, eq, desc } from 'drizzle-orm';
// 获取画布列表
export async function GET(request: NextRequest) {
@ -16,7 +16,12 @@ export async function GET(request: NextRequest) {
const projects = await db
.select()
.from(canvasProject)
.where(eq(canvasProject.userId, session.user.id))
.where(
and(
eq(canvasProject.userId, session.user.id),
eq(canvasProject.workspaceType, "canvas")
)
)
.orderBy(desc(canvasProject.updatedAt));
return NextResponse.json({ success: true, data: projects });

View File

@ -70,6 +70,7 @@ import { AgentPresetPanelNew } from "@/components/agents/AgentPresetPanelNew";
import { AgentPresetPanelDarkV2 } from "@/components/agents/AgentPresetPanelDarkV2";
import { AgentWorkflowPanelDarkV2 } from "@/components/agents/AgentWorkflowPanelDarkV2";
import { AgentGenerationWorkspaceDarkV2 } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
import { AgentProjectListSheet } from "@/components/agents/AgentProjectListSheet";
import { CanvasWorkspace } from "@/components/canvas";
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
@ -94,7 +95,14 @@ import {
createEmptyAgentModeWorkspaceState,
normalizeAgentModeWorkspaceState,
} from "@/lib/agents/mode-workspace-state";
import {
createEmptyAgentGenerationProjectPersistedState,
createEmptyAgentGenerationProjectWorkspaceState,
normalizeAgentGenerationProjectPersistedState,
} from "@/lib/agents/generation-project-state";
import { agentProjectService } from "@/services/agent-projects";
import type {
AgentGenerationProjectWorkspaceState,
AgentModeWorkspaceState,
AgentWorkflowComposerContext,
AgentWorkflowMaterialSelections,
@ -142,6 +150,15 @@ function buildScopedCanvasChatId(projectId: string, scope: "agent-mode") {
].join("-");
}
function buildDefaultAgentProjectTitle() {
const now = new Date();
const month = `${now.getMonth() + 1}`.padStart(2, "0");
const day = `${now.getDate()}`.padStart(2, "0");
const hour = `${now.getHours()}`.padStart(2, "0");
const minute = `${now.getMinutes()}`.padStart(2, "0");
return `生图项目 ${month}-${day} ${hour}:${minute}`;
}
export default function CanvasEditorPage({ params }: PageProps) {
// 使用 useParams 确保客户端导航时能正确获取更新的 id
const routeParams = useParams();
@ -212,10 +229,25 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [agentModeState, setAgentModeState] = useState<AgentModeWorkspaceState>(
() => createEmptyAgentModeWorkspaceState()
);
const [agentWorkspaceState, setAgentWorkspaceState] =
useState<AgentGenerationProjectWorkspaceState>(
() => createEmptyAgentGenerationProjectWorkspaceState()
);
const [agentProjectTitle, setAgentProjectTitle] = useState("未命名生图项目");
const [agentProjectThumbnail, setAgentProjectThumbnail] = useState<string | null>(null);
const [agentProjectResultUrls, setAgentProjectResultUrls] = useState<string[]>([]);
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
const [isAgentProjectSaving, setIsAgentProjectSaving] = useState(false);
const [workflowHydrationSource, setWorkflowHydrationSource] = useState<
"pending" | "project" | "local" | "empty"
>("pending");
const agentModeChatId = useMemo(() => buildScopedCanvasChatId(id, "agent-mode"), [id]);
const requestedAgentProjectId = searchParams.get("agentProjectId");
const agentProjectId = requestedAgentProjectId ? requestedAgentProjectId.trim() : "";
const hasActiveAgentProject = Boolean(agentProjectId);
const agentModeChatId = useMemo(
() => buildScopedCanvasChatId(agentProjectId || id, "agent-mode"),
[agentProjectId, id]
);
const { data: siteSettingsData } = useSWR<{
siteName?: string;
@ -290,6 +322,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
updateCanvasSearchParams({
mode: null,
agentPresetId: null,
agentProjectId: null,
});
return;
}
@ -297,10 +330,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
updateCanvasSearchParams({
mode: "agent",
agentPresetId: null,
agentProjectId: agentProjectId || null,
});
setIsPanelOpen(true);
setPanelMode("chat");
}, [isAgentMode, updateCanvasSearchParams]);
}, [agentProjectId, isAgentMode, updateCanvasSearchParams]);
// 加载画布数据
useLayoutEffect(() => {
@ -320,6 +354,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
setSelectedBrandModel(null);
setSelectedAgentPresetId(null);
setAgentModeState(createEmptyAgentModeWorkspaceState());
setAgentWorkspaceState(createEmptyAgentGenerationProjectWorkspaceState());
setAgentProjectTitle("未命名生图项目");
setAgentProjectThumbnail(null);
setAgentProjectResultUrls([]);
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
useCanvasStore.getState().clearCanvas();
@ -331,7 +369,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
if (result.data.canvasData) {
useCanvasStore.getState().loadCanvas(result.data.canvasData);
const persistedWorkflow = result.data.canvasData.workflowState;
if (persistedWorkflow) {
if (!requestedAgentMode && persistedWorkflow) {
setWorkflowInputs({
primarySku: String(persistedWorkflow.inputs?.primarySku || "").trim(),
secondarySku: String(persistedWorkflow.inputs?.secondarySku || "").trim(),
@ -388,20 +426,22 @@ export default function CanvasEditorPage({ params }: PageProps) {
} else {
// 画布不存在时,创建一个新画布
console.log('[Canvas] 画布不存在,创建新画布:', id);
useCanvasStore.getState().setProject(id, '未命名画布');
useCanvasStore.getState().setProject(id, requestedAgentMode ? '智能体工作台' : '未命名画布');
setWorkflowHydrationSource("empty");
// 保存新画布到数据库
try {
await canvasService.saveCanvasState(id, { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select' });
} catch (error) {
console.error('[Canvas] 创建新画布失败:', error);
if (!requestedAgentMode) {
// 保存新画布到数据库
try {
await canvasService.saveCanvasState(id, { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select' });
} catch (error) {
console.error('[Canvas] 创建新画布失败:', error);
}
}
}
setIsLoading(false);
};
loadProject();
}, [id]); // 只依赖 id,使用 getState() 直接调用 store actions
}, [id, requestedAgentMode]);
useEffect(() => {
const updateViewportWidth = () => setViewportWidth(window.innerWidth);
@ -425,6 +465,14 @@ export default function CanvasEditorPage({ params }: PageProps) {
const workflowStorageKey = useMemo(() => `canvas-workflow-state:${id}`, [id]);
const buildPersistedCanvasData = useCallback(() => {
return {
...getCanvasState(),
};
}, [
getCanvasState,
]);
const buildPersistedAgentProjectState = useCallback(() => {
const workflowState: AgentWorkflowPersistedState = {
inputs: {
primarySku: workflowInputs.primarySku.trim(),
@ -441,21 +489,24 @@ export default function CanvasEditorPage({ params }: PageProps) {
};
return {
...getCanvasState(),
workflowState,
workspaceState: agentWorkspaceState,
resultImageUrls: agentProjectResultUrls,
};
}, [
agentModeState,
agentProjectResultUrls,
agentWorkspaceState,
currentAgentStage,
getCanvasState,
selectedAgentPresetId,
selectedBrandModel,
agentModeState,
workflowInputs,
workflowMaterials,
workflowSelections,
]);
useEffect(() => {
if (isAgentMode) return;
if (typeof window === "undefined") return;
if (workflowHydrationSource !== "empty") return;
@ -499,9 +550,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
} finally {
setWorkflowHydrationSource("local");
}
}, [workflowHydrationSource, workflowStorageKey]);
}, [isAgentMode, workflowHydrationSource, workflowStorageKey]);
useEffect(() => {
if (isAgentMode) return;
if (typeof window === "undefined" || workflowHydrationSource === "pending") return;
try {
@ -521,6 +573,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
console.warn("[Canvas] Failed to persist workflow state:", error);
}
}, [
isAgentMode,
workflowHydrationSource,
workflowInputs,
workflowMaterials,
@ -532,6 +585,247 @@ export default function CanvasEditorPage({ params }: PageProps) {
agentModeState,
]);
const persistAgentProject = useCallback(
async (reason: "autosave" | "manual" | "switch") => {
if (!isAgentMode || !agentProjectId) return false;
const result = await agentProjectService.updateProject(agentProjectId, {
title: agentProjectTitle || buildDefaultAgentProjectTitle(),
thumbnail: agentProjectThumbnail,
projectState: buildPersistedAgentProjectState(),
});
if (!result.success) {
console.error(`[Agent Project] ${reason} save failed:`, result.error);
return false;
}
if (result.data) {
setAgentProjectTitle(result.data.title || buildDefaultAgentProjectTitle());
setAgentProjectThumbnail(result.data.thumbnail || null);
}
return true;
},
[
agentProjectId,
agentProjectThumbnail,
agentProjectTitle,
buildPersistedAgentProjectState,
isAgentMode,
]
);
useEffect(() => {
if (!requestedAgentMode || !isAgentFeatureEnabled || agentProjectId) return;
let cancelled = false;
const createAgentProject = async () => {
setIsAgentProjectLoading(true);
try {
const result = await agentProjectService.createProject({
title: buildDefaultAgentProjectTitle(),
projectState: createEmptyAgentGenerationProjectPersistedState(),
});
if (!result.success || !result.data) {
throw new Error(result.error || "创建生图项目失败");
}
if (cancelled) return;
updateCanvasSearchParams({
mode: "agent",
agentProjectId: result.data.id,
});
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "创建生图项目失败");
}
} finally {
if (!cancelled) {
setIsAgentProjectLoading(false);
}
}
};
void createAgentProject();
return () => {
cancelled = true;
};
}, [agentProjectId, isAgentFeatureEnabled, requestedAgentMode, updateCanvasSearchParams]);
useEffect(() => {
if (!requestedAgentMode || !agentProjectId) return;
let cancelled = false;
const loadAgentProject = async () => {
setIsAgentProjectLoading(true);
try {
const result = await agentProjectService.getProject(agentProjectId);
if (!result.success || !result.data) {
throw new Error(result.error || "加载生图项目失败");
}
if (cancelled) return;
const persistedState = normalizeAgentGenerationProjectPersistedState(
result.data.projectState
);
const persistedWorkflow = persistedState.workflowState;
setWorkflowInputs({
primarySku: String(persistedWorkflow.inputs?.primarySku || "").trim(),
secondarySku: String(persistedWorkflow.inputs?.secondarySku || "").trim(),
brand: String(persistedWorkflow.inputs?.brand || "").trim(),
store: String(persistedWorkflow.inputs?.store || "").trim(),
});
setWorkflowMaterials(normalizeAgentWorkflowMaterialSelections(persistedWorkflow.materials));
setWorkflowSelections(persistedWorkflow.selections || {});
setSelectedBrandModel(persistedWorkflow.selectedBrandModel || null);
setSelectedAgentPresetId(String(persistedWorkflow.selectedAgentPresetId || "").trim() || null);
setCurrentAgentStage(persistedWorkflow.currentStage === "mode" ? "mode" : "workflow");
setAgentModeState(normalizeAgentModeWorkspaceState(persistedWorkflow.agentModeState));
setAgentWorkspaceState(
persistedState.workspaceState || createEmptyAgentGenerationProjectWorkspaceState()
);
setAgentProjectTitle(result.data.title || "未命名生图项目");
setAgentProjectThumbnail(result.data.thumbnail || null);
setAgentProjectResultUrls(persistedState.resultImageUrls || []);
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载生图项目失败");
}
} finally {
if (!cancelled) {
setIsAgentProjectLoading(false);
}
}
};
void loadAgentProject();
return () => {
cancelled = true;
};
}, [agentProjectId, requestedAgentMode]);
useEffect(() => {
if (!isAgentMode || !agentProjectId || isAgentProjectLoading) return;
const timer = window.setTimeout(async () => {
setIsAgentProjectSaving(true);
try {
await persistAgentProject("autosave");
} finally {
setIsAgentProjectSaving(false);
}
}, 2500);
return () => window.clearTimeout(timer);
}, [
agentModeState,
agentProjectId,
agentProjectResultUrls,
agentProjectThumbnail,
agentProjectTitle,
agentWorkspaceState,
currentAgentStage,
isAgentMode,
isAgentProjectLoading,
persistAgentProject,
selectedAgentPresetId,
selectedBrandModel,
workflowInputs,
workflowMaterials,
workflowSelections,
]);
const handleManualSaveAgentProject = useCallback(async () => {
if (!agentProjectId) {
toast.error("当前还没有可保存的生图项目");
return;
}
setIsAgentProjectSaving(true);
try {
const success = await persistAgentProject("manual");
if (success) {
toast.success("生图项目已保存");
} else {
toast.error("保存生图项目失败");
}
} finally {
setIsAgentProjectSaving(false);
}
}, [agentProjectId, persistAgentProject]);
const handleCreateAgentProject = useCallback(async () => {
if (agentProjectId) {
await persistAgentProject("switch");
}
const result = await agentProjectService.createProject({
title: buildDefaultAgentProjectTitle(),
projectState: createEmptyAgentGenerationProjectPersistedState(),
});
if (!result.success || !result.data) {
toast.error(result.error || "创建生图项目失败");
return;
}
updateCanvasSearchParams({
mode: "agent",
agentProjectId: result.data.id,
});
}, [agentProjectId, persistAgentProject, updateCanvasSearchParams]);
const handleSelectAgentProject = useCallback(
async (nextProjectId: string) => {
if (!nextProjectId || nextProjectId === agentProjectId) return;
if (agentProjectId) {
await persistAgentProject("switch");
}
updateCanvasSearchParams({
mode: "agent",
agentProjectId: nextProjectId,
});
},
[agentProjectId, persistAgentProject, updateCanvasSearchParams]
);
const handleDeleteAgentProject = useCallback(
async (targetProjectId: string) => {
const result = await agentProjectService.deleteProject(targetProjectId);
if (!result.success) {
toast.error(result.error || "删除生图项目失败");
return;
}
if (targetProjectId === agentProjectId) {
const listResult = await agentProjectService.getProjects();
const nextProject = listResult.success
? listResult.data?.find((project) => project.id !== targetProjectId) || null
: null;
if (nextProject) {
updateCanvasSearchParams({
mode: "agent",
agentProjectId: nextProject.id,
});
} else {
const createResult = await agentProjectService.createProject({
title: buildDefaultAgentProjectTitle(),
projectState: createEmptyAgentGenerationProjectPersistedState(),
});
if (createResult.success && createResult.data) {
updateCanvasSearchParams({
mode: "agent",
agentProjectId: createResult.data.id,
});
}
}
}
toast.success("生图项目已删除");
},
[agentProjectId, updateCanvasSearchParams]
);
useEffect(() => {
if (!selectedBrandModel) return;
const normalizedBrand = workflowInputs.brand.trim().toLowerCase();
@ -558,7 +852,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
// 自动保存逻辑
useEffect(() => {
if (isLoading) return;
if (isLoading || isAgentMode) return;
const saveTimer = setTimeout(async () => {
setIsSaving(true);
@ -571,11 +865,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
}, 3000); // 3秒自动保存
return () => clearTimeout(saveTimer);
}, [nodes, workflowInputs, workflowSelections, isLoading, persistCanvasState]);
}, [isAgentMode, nodes, isLoading, persistCanvasState]);
// 删除节点时立即保存(按节点ID差集检测),避免删除后快速刷新被回滚
useEffect(() => {
if (isLoading) {
if (isLoading || isAgentMode) {
previousNodeIdsRef.current = new Set(nodes.map((node) => node.id));
return;
}
@ -602,11 +896,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
};
void persistAfterDelete();
}
}, [nodes, isLoading, persistCanvasState]);
}, [isAgentMode, nodes, isLoading, persistCanvasState]);
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
useEffect(() => {
if (isLoading) return;
if (isLoading || isAgentMode) return;
const handleBeforeUnload = () => {
try {
@ -628,10 +922,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("pagehide", handleBeforeUnload);
};
}, [buildPersistedCanvasData, id, isLoading]);
}, [buildPersistedCanvasData, id, isAgentMode, isLoading]);
// 智能命名与缩略图逻辑
useEffect(() => {
if (isAgentMode) return;
const autoTitleAndThumbnail = async () => {
// 如果标题是默认值,且有图片节点
if ((projectTitle === "未命名画布" || projectTitle === "新画布") && nodes.length > 0) {
@ -664,7 +959,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
// 使用防抖,避免频繁触发
const timer = setTimeout(autoTitleAndThumbnail, 5000); // 5秒后触发,给用户一点时间操作
return () => clearTimeout(timer);
}, [nodes, projectTitle, id, setProjectTitle]);
}, [id, isAgentMode, nodes, projectTitle, setProjectTitle]);
// 保存画布 (手动触发保留作为备用,但UI上移除)
const handleSave = useCallback(async () => {
@ -1039,6 +1334,34 @@ export default function CanvasEditorPage({ params }: PageProps) {
</div>
) : null;
const agentProjectNavSlot = isAgentMode ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
void handleManualSaveAgentProject();
}}
disabled={!hasActiveAgentProject || isAgentProjectLoading || isAgentProjectSaving}
className="h-9 gap-2 border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
>
{isAgentProjectSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
保存项目
</Button>
<AgentProjectListSheet
currentProjectId={agentProjectId || null}
currentProjectTitle={agentProjectTitle}
onCreateProject={handleCreateAgentProject}
onSelectProject={handleSelectAgentProject}
onDeleteProject={handleDeleteAgentProject}
/>
</div>
) : null;
if (isAgentMode) {
// 深色主题智能体模式 V2 - 完整功能版本
@ -1057,6 +1380,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
siteLogo={siteSettingsData?.siteLogo}
siteLogoDark={siteSettingsData?.siteLogoDark}
showcaseConfig={siteSettingsData?.agentWorkflowShowcaseConfig}
topNavRightSlot={agentProjectNavSlot}
onNextStep={() => setCurrentAgentStage("mode")}
onSkipStep={() => setCurrentAgentStage("mode")}
onReset={() => {
@ -1066,6 +1390,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
setSelectedBrandModel(null);
setSelectedAgentPresetId(null);
setAgentModeState(createEmptyAgentModeWorkspaceState());
setAgentWorkspaceState(createEmptyAgentGenerationProjectWorkspaceState());
setCurrentAgentStage("workflow");
}}
/>
@ -1082,9 +1407,18 @@ export default function CanvasEditorPage({ params }: PageProps) {
workflowContext={workflowContext}
agentModeState={agentModeState}
onAgentModeStateChange={setAgentModeState}
workspaceState={agentWorkspaceState}
onWorkspaceStateChange={setAgentWorkspaceState}
onResultPreviewUrlsChange={(urls) => {
setAgentProjectResultUrls(urls);
if (urls[0]) {
setAgentProjectThumbnail(urls[0]);
}
}}
siteName={siteSettingsData?.siteName}
siteLogo={siteSettingsData?.siteLogo}
siteLogoDark={siteSettingsData?.siteLogoDark}
topNavRightSlot={agentProjectNavSlot}
syncGeneratedResultsToCanvas={false}
onBack={() => setCurrentAgentStage("workflow")}
onOpenCanvasEditor={() => {

View File

@ -42,6 +42,7 @@ import type { Attachment, ChatMessage } from "@/lib/types";
import { cn, fetchWithErrorHandlers, fetcher, generateUUID } from "@/lib/utils";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
import type {
AgentGenerationProjectWorkspaceState,
AgentModeReferenceCandidate,
AgentModeWorkspaceState,
AgentPreset,
@ -52,6 +53,10 @@ import type {
import {
createEmptyAgentModeWorkspaceState,
} from "@/lib/agents/mode-workspace-state";
import {
createEmptyAgentGenerationProjectWorkspaceState,
normalizeAgentGenerationProjectPersistedState,
} from "@/lib/agents/generation-project-state";
interface AgentGenerationWorkspaceDarkV2Props {
chatId: string;
@ -71,6 +76,10 @@ interface AgentGenerationWorkspaceDarkV2Props {
agentModeState?: AgentModeWorkspaceState | null;
onAgentModeStateChange?: (next: AgentModeWorkspaceState) => void;
onOpenCanvasEditor?: () => void;
workspaceState?: AgentGenerationProjectWorkspaceState | null;
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
onResultPreviewUrlsChange?: (urls: string[]) => void;
topNavRightSlot?: ReactNode;
}
interface PickedAttachment extends Attachment {}
@ -244,6 +253,10 @@ export function AgentGenerationWorkspaceDarkV2({
agentModeState = null,
onAgentModeStateChange,
onOpenCanvasEditor,
workspaceState = null,
onWorkspaceStateChange,
onResultPreviewUrlsChange,
topNavRightSlot,
}: AgentGenerationWorkspaceDarkV2Props) {
const rawChatId = useMemo(
() => (chatId.startsWith("canvas-") ? chatId.replace("canvas-", "") : chatId),
@ -257,15 +270,38 @@ export function AgentGenerationWorkspaceDarkV2({
const hiddenPromptRef = useRef<string | null>(null);
const generatedImageKeysRef = useRef<Set<string>>(new Set());
const lastSyncedBrandModelUrlRef = useRef<string | null>(null);
const workspaceStateSignatureRef = useRef<string>("");
const [localAgentModeState, setLocalAgentModeState] = useState<AgentModeWorkspaceState>(
() => createEmptyAgentModeWorkspaceState()
);
const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>([]);
const normalizedIncomingWorkspaceState = useMemo(
() =>
normalizeAgentGenerationProjectPersistedState({
workflowState: {},
workspaceState,
resultImageUrls: [],
}).workspaceState || createEmptyAgentGenerationProjectWorkspaceState(),
[workspaceState]
);
const [input, setInput] = useState(normalizedIncomingWorkspaceState.input);
const [attachments, setAttachments] = useState<Array<PickedAttachment | null>>(
normalizedIncomingWorkspaceState.attachments.map((item) =>
item
? {
url: item.url,
name: item.name || "参考图",
contentType: item.contentType || "image/png",
assetId: item.assetId || undefined,
}
: null
)
);
const [activeAssignmentSlotIndex, setActiveAssignmentSlotIndex] = useState<number | null>(null);
const [slotUploading, setSlotUploading] = useState<number | null>(null);
const [selectedModelId, setSelectedModelId] = useState<string>("auto");
const [selectedModelId, setSelectedModelId] = useState<string>(
normalizedIncomingWorkspaceState.selectedModelId || "auto"
);
const [selectedResultUrl, setSelectedResultUrl] = useState<string | null>(null);
const [fullscreenPreview, setFullscreenPreview] = useState<string | null>(null);
const [batchDownloadState, setBatchDownloadState] = useState<null | "scene" | "detail" | "sku">(null);
@ -284,9 +320,9 @@ export function AgentGenerationWorkspaceDarkV2({
lastQuery: null,
});
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
aspectRatio: normalizedIncomingWorkspaceState.imageParams?.aspectRatio || "auto",
imageSize: normalizedIncomingWorkspaceState.imageParams?.imageSize || "2K",
imageCount: normalizedIncomingWorkspaceState.imageParams?.imageCount || 1,
});
const { data: historyData } = useSWR<{ messages: ChatMessage[] }>(
@ -514,6 +550,10 @@ export function AgentGenerationWorkspaceDarkV2({
}, [historyMessages, messages.length, setMessages]);
useEffect(() => {
if (normalizedIncomingWorkspaceState.selectedModelId) {
setSelectedModelId(normalizedIncomingWorkspaceState.selectedModelId);
return;
}
if (selectedAgentPreset?.defaultModelId) {
setSelectedModelId(selectedAgentPreset.defaultModelId);
return;
@ -521,7 +561,11 @@ export function AgentGenerationWorkspaceDarkV2({
if (modelsData?.defaultModelId) {
setSelectedModelId(modelsData.defaultModelId);
}
}, [modelsData?.defaultModelId, selectedAgentPreset?.defaultModelId]);
}, [
modelsData?.defaultModelId,
normalizedIncomingWorkspaceState.selectedModelId,
selectedAgentPreset?.defaultModelId,
]);
// 初始化附件
useEffect(() => {
@ -761,6 +805,71 @@ export function AgentGenerationWorkspaceDarkV2({
const nextToolStep =
activeToolStepIndex < toolSteps.length - 1 ? toolSteps[activeToolStepIndex + 1] : null;
useEffect(() => {
const incomingSignature = JSON.stringify(normalizedIncomingWorkspaceState);
if (incomingSignature === workspaceStateSignatureRef.current) {
return;
}
workspaceStateSignatureRef.current = incomingSignature;
setInput(normalizedIncomingWorkspaceState.input);
setAttachments(
normalizedIncomingWorkspaceState.attachments.map((item) =>
item
? {
url: item.url,
name: item.name || "参考图",
contentType: item.contentType || "image/png",
assetId: item.assetId || undefined,
}
: null
)
);
setSelectedModelId(normalizedIncomingWorkspaceState.selectedModelId || "auto");
setImageParams({
aspectRatio: normalizedIncomingWorkspaceState.imageParams?.aspectRatio || "auto",
imageSize: normalizedIncomingWorkspaceState.imageParams?.imageSize || "2K",
imageCount: normalizedIncomingWorkspaceState.imageParams?.imageCount || 1,
});
}, [normalizedIncomingWorkspaceState]);
useEffect(() => {
const nextWorkspaceState = {
input,
attachments: attachments.map((item) =>
item
? {
url: item.url,
name: item.name || null,
contentType: item.contentType || null,
assetId: item.assetId || null,
}
: null
),
selectedModelId,
imageParams: {
aspectRatio: imageParams.aspectRatio,
imageSize: imageParams.imageSize,
imageCount: imageParams.imageCount,
},
};
workspaceStateSignatureRef.current = JSON.stringify(nextWorkspaceState);
onWorkspaceStateChange?.(nextWorkspaceState);
}, [
attachments,
imageParams.aspectRatio,
imageParams.imageCount,
imageParams.imageSize,
input,
onWorkspaceStateChange,
selectedModelId,
]);
useEffect(() => {
onResultPreviewUrlsChange?.(
Array.from(new Set(isReplacementPreset ? latestReplacementResultUrls : allGeneratedImages))
);
}, [allGeneratedImages, isReplacementPreset, latestReplacementResultUrls, onResultPreviewUrlsChange]);
useEffect(() => {
if (!selectedResultUrl && latestGeneratedGroup?.imageUrls[0]) {
setSelectedResultUrl(latestGeneratedGroup.imageUrls[0]);
@ -1323,7 +1432,7 @@ export function AgentGenerationWorkspaceDarkV2({
siteLogoDark={siteLogoDark}
onBack={onBack}
activeKey="agent"
rightSlot={<div className="w-10" />}
rightSlot={topNavRightSlot || <div className="w-10" />}
/>
{/* Main Content */}

View File

@ -0,0 +1,195 @@
"use client";
import { useEffect, useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import { FolderOpen, Loader2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { agentProjectService } from "@/services/agent-projects";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import type { AgentGenerationProject } from "@/types/agent";
interface AgentProjectListSheetProps {
currentProjectId?: string | null;
currentProjectTitle?: string | null;
onCreateProject: () => Promise<void> | void;
onSelectProject: (projectId: string) => Promise<void> | void;
onDeleteProject: (projectId: string) => Promise<void> | void;
}
export function AgentProjectListSheet({
currentProjectId,
currentProjectTitle,
onCreateProject,
onSelectProject,
onDeleteProject,
}: AgentProjectListSheetProps) {
const [open, setOpen] = useState(false);
const [projects, setProjects] = useState<AgentGenerationProject[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const loadProjects = async () => {
setIsLoading(true);
try {
const result = await agentProjectService.getProjects();
if (!result.success || !result.data) {
throw new Error(result.error || "加载失败");
}
setProjects(result.data);
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载生图项目失败");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!open) return;
void loadProjects();
}, [open, currentProjectId]);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
type="button"
variant="outline"
className="h-9 max-w-[260px] gap-2 border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.08] hover:text-white"
>
<FolderOpen className="h-4 w-4 shrink-0" />
<span className="truncate">{currentProjectTitle || "生图项目列表"}</span>
</Button>
</SheetTrigger>
<SheetContent
side="right"
className="w-[380px] border-white/10 bg-[#0b0d0f] p-0 text-white sm:max-w-[380px]"
>
<SheetHeader className="border-b border-white/10 px-5 py-4 text-left">
<SheetTitle className="text-white">生图项目列表</SheetTitle>
<SheetDescription className="text-white/45">
这里保存独立于无限画布的生图项目,可随时继续生成。
</SheetDescription>
</SheetHeader>
<div className="border-b border-white/10 px-5 py-4">
<Button
type="button"
onClick={async () => {
await onCreateProject();
await loadProjects();
setOpen(false);
}}
className="h-10 w-full gap-2 bg-emerald-500 text-white hover:bg-emerald-400"
>
<Plus className="h-4 w-4" />
新建生图项目
</Button>
</div>
<ScrollArea className="h-[calc(100vh-150px)]">
<div className="space-y-2 p-4">
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-white/50" />
</div>
) : projects.length > 0 ? (
projects.map((project) => (
<button
key={project.id}
type="button"
onClick={async () => {
await onSelectProject(project.id);
setOpen(false);
}}
className={cn(
"group flex w-full items-center gap-3 rounded-2xl border p-3 text-left transition-colors",
project.id === currentProjectId
? "border-emerald-400/30 bg-emerald-500/[0.08]"
: "border-white/10 bg-white/[0.03] hover:bg-white/[0.06]"
)}
>
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-black/30">
{project.thumbnail ? (
<img
src={buildImageProxyUrl(project.thumbnail, {
width: 160,
quality: 82,
format: "webp",
})}
alt={project.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<FolderOpen className="h-5 w-5 text-white/25" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-sm font-medium",
project.id === currentProjectId ? "text-emerald-200" : "text-white"
)}
>
{project.title || "未命名生图项目"}
</div>
<div className="mt-1 text-xs text-white/45">
{formatDistanceToNow(new Date(project.updatedAt), {
addSuffix: true,
locale: zhCN,
})}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-white/45 opacity-0 transition-opacity hover:text-rose-300 group-hover:opacity-100"
disabled={pendingDeleteId === project.id}
onClick={async (event) => {
event.stopPropagation();
if (!window.confirm("确定删除这个生图项目吗?")) return;
setPendingDeleteId(project.id);
try {
await onDeleteProject(project.id);
await loadProjects();
} finally {
setPendingDeleteId(null);
}
}}
>
{pendingDeleteId === project.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</button>
))
) : (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-12 text-center">
<div className="text-sm text-white/70">还没有生图项目</div>
<div className="mt-2 text-xs text-white/40">点击上方按钮创建一个独立项目。</div>
</div>
)}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useMemo, type ReactNode } from "react";
import { motion } from "framer-motion";
import { ArrowRight, Check, Copy, Sparkles, ChevronRight } from "lucide-react";
import { toast } from "sonner";
@ -11,6 +11,7 @@ import { cn } from "@/lib/utils";
import {
defaultAgentWorkflowUiConfig,
flattenAgentWorkflowSteps,
flattenVisibleAgentWorkflowSteps,
getEnabledAgentWorkflowStepTree,
} from "@/lib/agents/workflow-ui-config";
import { AgentCanvasTopNavDark } from "@/components/agents/AgentCanvasTopNavDark";
@ -49,6 +50,7 @@ interface AgentWorkflowPanelDarkV2Props {
siteLogo?: string | null;
siteLogoDark?: string | null;
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
topNavRightSlot?: ReactNode;
}
function buildSelectionSummary(params: {
@ -56,7 +58,7 @@ function buildSelectionSummary(params: {
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}) {
const result: AgentWorkflowComposerSelection[] = [];
for (const { step } of flattenAgentWorkflowSteps(params.config.steps)) {
for (const { step } of flattenVisibleAgentWorkflowSteps(params.config.steps, params.selections)) {
const selectedOptionKey = params.selections[step.key];
if (!selectedOptionKey) continue;
const option = step.options.find((item) => item.key === selectedOptionKey && item.enabled);
@ -173,6 +175,7 @@ export function AgentWorkflowPanelDarkV2({
siteLogo,
siteLogoDark,
showcaseConfig,
topNavRightSlot,
}: AgentWorkflowPanelDarkV2Props) {
const brandOptions = useMemo(
() => config?.inputOptions?.brands || defaultAgentWorkflowUiConfig.inputOptions?.brands || [],
@ -192,10 +195,10 @@ export function AgentWorkflowPanelDarkV2({
}, [config]);
const flattenedResolvedSteps = useMemo(
() =>
flattenAgentWorkflowSteps(resolvedConfig.steps).filter(
flattenVisibleAgentWorkflowSteps(resolvedConfig.steps, selections).filter(
({ step }) => Array.isArray(step.options) && step.options.length > 0
),
[resolvedConfig.steps]
[resolvedConfig.steps, selections]
);
const summary = useMemo(
@ -239,6 +242,7 @@ export function AgentWorkflowPanelDarkV2({
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
activeKey="agent"
rightSlot={topNavRightSlot}
/>
<div className="flex-1 overflow-y-auto overscroll-contain pt-16">

View File

@ -0,0 +1,159 @@
import {
createEmptyAgentModeWorkspaceState,
normalizeAgentModeWorkspaceState,
} from "@/lib/agents/mode-workspace-state";
import {
createEmptyAgentWorkflowMaterialSelections,
normalizeAgentWorkflowMaterialSelections,
} from "@/lib/agents/workflow-material-config";
import type {
AgentGenerationProjectPersistedState,
AgentGenerationProjectWorkspaceState,
AgentWorkflowPersistedState,
} from "@/types/agent";
export function createEmptyAgentGenerationProjectWorkspaceState(): AgentGenerationProjectWorkspaceState {
return {
input: "",
attachments: [],
selectedModelId: "auto",
imageParams: {
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
},
};
}
export function createEmptyAgentWorkflowPersistedState(): AgentWorkflowPersistedState {
return {
inputs: {
primarySku: "",
secondarySku: "",
brand: "",
store: "",
},
materials: createEmptyAgentWorkflowMaterialSelections(),
selections: {},
selectedBrandModel: null,
selectedAgentPresetId: null,
currentStage: "workflow",
agentModeState: createEmptyAgentModeWorkspaceState(),
};
}
export function createEmptyAgentGenerationProjectPersistedState(): AgentGenerationProjectPersistedState {
return {
workflowState: createEmptyAgentWorkflowPersistedState(),
workspaceState: createEmptyAgentGenerationProjectWorkspaceState(),
resultImageUrls: [],
};
}
function normalizeWorkspaceState(value: unknown): AgentGenerationProjectWorkspaceState {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const attachments = Array.isArray(raw.attachments)
? raw.attachments
.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return null;
}
const record = item as Record<string, unknown>;
const url = String(record.url || "").trim();
if (!url) return null;
return {
url,
name: String(record.name || "").trim() || null,
contentType: String(record.contentType || "").trim() || null,
assetId: String(record.assetId || "").trim() || null,
};
})
.slice(0, 16)
: [];
const rawImageParams =
raw.imageParams && typeof raw.imageParams === "object" && !Array.isArray(raw.imageParams)
? (raw.imageParams as Record<string, unknown>)
: {};
const imageCount = Number(rawImageParams.imageCount);
return {
input: String(raw.input || "").trim(),
attachments,
selectedModelId: String(raw.selectedModelId || "").trim() || "auto",
imageParams: {
aspectRatio: String(rawImageParams.aspectRatio || "").trim() || "auto",
imageSize: String(rawImageParams.imageSize || "").trim() || "2K",
imageCount: Number.isFinite(imageCount) ? Math.max(1, Math.min(8, Math.round(imageCount))) : 1,
},
};
}
function normalizeWorkflowState(value: unknown): AgentWorkflowPersistedState {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
inputs: {
primarySku: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).primarySku || "" : "").trim(),
secondarySku: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).secondarySku || "" : "").trim(),
brand: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).brand || "" : "").trim(),
store: String(raw.inputs && typeof raw.inputs === "object" ? (raw.inputs as Record<string, unknown>).store || "" : "").trim(),
},
materials: normalizeAgentWorkflowMaterialSelections(raw.materials),
selections:
raw.selections && typeof raw.selections === "object" && !Array.isArray(raw.selections)
? (raw.selections as Record<string, string>)
: {},
selectedBrandModel:
raw.selectedBrandModel && typeof raw.selectedBrandModel === "object" && !Array.isArray(raw.selectedBrandModel)
? {
...(raw.selectedBrandModel as Record<string, unknown>),
brandName: String((raw.selectedBrandModel as Record<string, unknown>).brandName || "").trim(),
id: String((raw.selectedBrandModel as Record<string, unknown>).id || "").trim(),
imageUrl: String((raw.selectedBrandModel as Record<string, unknown>).imageUrl || "").trim(),
assetId: String((raw.selectedBrandModel as Record<string, unknown>).assetId || "").trim() || null,
displayName: String((raw.selectedBrandModel as Record<string, unknown>).displayName || "").trim() || null,
mimeType: String((raw.selectedBrandModel as Record<string, unknown>).mimeType || "").trim() || null,
sortOrder: Number.isFinite(Number((raw.selectedBrandModel as Record<string, unknown>).sortOrder))
? Math.max(0, Math.min(999, Math.round(Number((raw.selectedBrandModel as Record<string, unknown>).sortOrder))))
: 0,
}
: null,
selectedAgentPresetId: String(raw.selectedAgentPresetId || "").trim() || null,
currentStage: raw.currentStage === "mode" ? "mode" : "workflow",
agentModeState: normalizeAgentModeWorkspaceState(raw.agentModeState),
};
}
export function normalizeAgentGenerationProjectPersistedState(
value: unknown
): AgentGenerationProjectPersistedState {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const resultImageUrls = Array.isArray(raw.resultImageUrls)
? Array.from(
new Set(
raw.resultImageUrls
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item))
)
).slice(0, 64)
: [];
return {
workflowState: normalizeWorkflowState(raw.workflowState),
workspaceState: normalizeWorkspaceState(raw.workspaceState),
resultImageUrls,
};
}

View File

@ -1,6 +1,6 @@
import {
defaultAgentWorkflowUiConfig,
flattenAgentWorkflowSteps,
flattenVisibleAgentWorkflowSteps,
getEnabledAgentWorkflowStepTree,
} from "@/lib/agents/workflow-ui-config";
import type {
@ -27,8 +27,9 @@ export function buildAgentWorkflowComposerContext(params: {
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}): AgentWorkflowComposerContext | null {
const config = params.config || defaultAgentWorkflowUiConfig;
const steps = flattenAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(Array.isArray(config.steps) ? config.steps : [])
const steps = flattenVisibleAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(Array.isArray(config.steps) ? config.steps : []),
params.selections
)
.map(({ step }) => step)
.filter((step) => Array.isArray(step.options) && step.options.length > 0);
@ -223,8 +224,9 @@ export function getAgentWorkflowProgress(params: {
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}) {
const config = params.config || defaultAgentWorkflowUiConfig;
const enabledSteps = flattenAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(config.steps || [])
const enabledSteps = flattenVisibleAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(config.steps || []),
params.selections
)
.map(({ step }) => step)
.filter((step) => Array.isArray(step.options) && step.options.length > 0);

View File

@ -153,6 +153,47 @@ export function flattenAgentWorkflowSteps(
]);
}
export function flattenVisibleAgentWorkflowSteps(
steps: AgentWorkflowStepUiConfig[],
selections: Partial<Record<string, string>>,
depth = 1,
parentKeys: string[] = [],
ancestorsSatisfied = true
): FlattenedAgentWorkflowStep[] {
return [...steps]
.sort((left, right) => left.sortOrder - right.sortOrder)
.flatMap((step) => {
const normalizedStep = {
...step,
options: [...(step.options || [])].sort((left, right) => left.sortOrder - right.sortOrder),
children: [...(step.children || [])].sort((left, right) => left.sortOrder - right.sortOrder),
};
if (!ancestorsSatisfied) {
return [] as FlattenedAgentWorkflowStep[];
}
const currentEntry: FlattenedAgentWorkflowStep = {
step: normalizedStep,
depth,
parentKeys,
};
const currentSatisfied =
normalizedStep.options.length === 0 || Boolean(String(selections[normalizedStep.key] || "").trim());
return [
currentEntry,
...flattenVisibleAgentWorkflowSteps(
normalizedStep.children,
selections,
depth + 1,
[...parentKeys, normalizedStep.key],
currentSatisfied
),
];
});
}
export function findAgentWorkflowStepByKey(
steps: AgentWorkflowStepUiConfig[],
stepKey: string

View File

@ -0,0 +1,80 @@
import type {
AgentGenerationProject,
AgentGenerationProjectPersistedState,
} from "@/types/agent";
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
}
export const agentProjectService = {
async getProjects(): Promise<ApiResponse<AgentGenerationProject[]>> {
try {
const response = await fetch("/api/agent-projects", { cache: "no-store" });
return await response.json();
} catch {
return { success: false, error: "获取生图项目列表失败" };
}
},
async getProject(id: string): Promise<ApiResponse<AgentGenerationProject>> {
try {
const response = await fetch(`/api/agent-projects/${id}`, { cache: "no-store" });
return await response.json();
} catch {
return { success: false, error: "获取生图项目失败" };
}
},
async createProject(payload: {
title?: string;
description?: string | null;
thumbnail?: string | null;
projectState?: AgentGenerationProjectPersistedState;
}): Promise<ApiResponse<AgentGenerationProject>> {
try {
const response = await fetch("/api/agent-projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return await response.json();
} catch {
return { success: false, error: "创建生图项目失败" };
}
},
async updateProject(
id: string,
payload: {
title?: string;
description?: string | null;
thumbnail?: string | null;
projectState?: AgentGenerationProjectPersistedState;
}
): Promise<ApiResponse<AgentGenerationProject>> {
try {
const response = await fetch(`/api/agent-projects/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return await response.json();
} catch {
return { success: false, error: "保存生图项目失败" };
}
},
async deleteProject(id: string): Promise<ApiResponse> {
try {
const response = await fetch(`/api/agent-projects/${id}`, {
method: "DELETE",
});
return await response.json();
} catch {
return { success: false, error: "删除生图项目失败" };
}
},
};

View File

@ -238,6 +238,26 @@ export interface AgentModeWorkspaceState {
skuReplacement: AgentSkuReplacementState;
}
export interface AgentGenerationProjectWorkspaceAttachment {
url: string;
name?: string | null;
contentType?: string | null;
assetId?: string | null;
}
export interface AgentGenerationProjectImageParams {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
}
export interface AgentGenerationProjectWorkspaceState {
input: string;
attachments: Array<AgentGenerationProjectWorkspaceAttachment | null>;
selectedModelId?: string | null;
imageParams?: AgentGenerationProjectImageParams;
}
export interface AgentWorkflowPersistedState {
inputs: {
primarySku: string;
@ -253,6 +273,23 @@ export interface AgentWorkflowPersistedState {
agentModeState?: AgentModeWorkspaceState;
}
export interface AgentGenerationProjectPersistedState {
workflowState: AgentWorkflowPersistedState;
workspaceState?: AgentGenerationProjectWorkspaceState;
resultImageUrls?: string[];
}
export interface AgentGenerationProject {
id: string;
userId: string;
title: string;
description?: string | null;
thumbnail?: string | null;
projectState: AgentGenerationProjectPersistedState;
createdAt: Date | string;
updatedAt: Date | string;
}
export interface AgentCategoryTreeConfig {
productCategories: string[];
audienceTags: string[];