diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index a03444fea..7db6f45f3 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -229,6 +229,7 @@ type SiteSettings = {
tutorialEnabled: boolean;
rankingEnabled: boolean;
canvasEnabled: boolean;
+ agentEnabled: boolean;
realtimeCanvasEnabled: boolean;
aiDetailGenerationEnabled: boolean;
canvasDefaultModelId: string | null;
@@ -618,6 +619,7 @@ export function AdminSettingsClient() {
tutorialEnabled: false,
rankingEnabled: true,
canvasEnabled: true,
+ agentEnabled: true,
realtimeCanvasEnabled: true,
aiDetailGenerationEnabled: false,
canvasDefaultModelId: null,
@@ -689,6 +691,7 @@ export function AdminSettingsClient() {
tutorialEnabled: siteSettingsData.tutorialEnabled ?? false,
rankingEnabled: siteSettingsData.rankingEnabled ?? true,
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
+ agentEnabled: (siteSettingsData as any).agentEnabled ?? true,
realtimeCanvasEnabled: (siteSettingsData as any).realtimeCanvasEnabled ?? true,
aiDetailGenerationEnabled: siteSettingsData.aiDetailGenerationEnabled ?? false,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
@@ -5237,6 +5240,22 @@ export function AdminSettingsClient() {
{siteSettings.canvasEnabled && (
<>
+
diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts
index 380443b7e..2e7ef9305 100644
--- a/app/(chat)/api/chat/route.ts
+++ b/app/(chat)/api/chat/route.ts
@@ -8,6 +8,7 @@ import {
stepCountIs,
streamText,
} from "ai";
+import { eq, sql } from "drizzle-orm";
import { unstable_cache as cache } from "next/cache";
import { after, NextResponse } from "next/server";
import {
@@ -71,8 +72,10 @@ import {
getAiProviderById,
checkUserImageQuota,
consumeUserPoints,
+ createAgentPresetExecutionSnapshot,
createMessageTargetResolution,
createMediaAssetRelation,
+ getAccessibleAgentPresetById,
getConversationWorkingStateByChatId,
getMediaAssetsByUserAndUrls,
upsertConversationWorkingState,
@@ -80,7 +83,11 @@ import {
} from "@/lib/db/queries";
import { db } from "@/lib/db";
import type { DBMessage } from "@/lib/db/schema";
-import { generatedImage, message as messageTable } from "@/lib/db/schema";
+import {
+ agentPreset as agentPresetTable,
+ generatedImage,
+ message as messageTable,
+} from "@/lib/db/schema";
import { generateCanvasImage } from "@/lib/canvas/generate-image";
import { resolveChatMediaInput } from "@/lib/ai/context-memory/media-input";
import { resolveTargetAssets } from "@/lib/ai/context-memory/target-resolution";
@@ -415,6 +422,11 @@ function writeCanvasMediaChunk(
videoUrls?: string[];
prompt?: string | null;
sourceImageUrls?: string[] | null;
+ agentPresetId?: string | null;
+ agentPresetName?: string | null;
+ agentCategoryId?: string | null;
+ agentCategoryName?: string | null;
+ agentExecutionSnapshotId?: string | null;
},
label: string,
) {
@@ -454,6 +466,12 @@ function writeCanvasMediaChunk(
.map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
: [],
+ agentPresetId: String(params.agentPresetId || "").trim() || undefined,
+ agentPresetName: String(params.agentPresetName || "").trim() || undefined,
+ agentCategoryId: String(params.agentCategoryId || "").trim() || undefined,
+ agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
+ agentExecutionSnapshotId:
+ String(params.agentExecutionSnapshotId || "").trim() || undefined,
},
label,
);
@@ -2578,6 +2596,70 @@ async function saveGeneratedImages(
}
}
+type AgentPresetMessageImagePart = {
+ type: "file";
+ url: string;
+ name: string;
+ mediaType: string;
+ assetId?: string;
+};
+
+function extractCurrentMessageImageParts(
+ parts: Array>,
+): AgentPresetMessageImagePart[] {
+ return parts
+ .filter(
+ (part): part is AgentPresetMessageImagePart =>
+ part?.type === "file" &&
+ typeof part?.url === "string" &&
+ /^https?:\/\//i.test(part.url) &&
+ typeof part?.mediaType === "string" &&
+ part.mediaType.startsWith("image/")
+ )
+ .map((part) => ({
+ type: "file",
+ url: String(part.url).trim(),
+ name: String(part.name || "图片").trim() || "图片",
+ mediaType: String(part.mediaType || "image/jpeg").trim() || "image/jpeg",
+ assetId:
+ typeof part.assetId === "string" && part.assetId.trim() ? part.assetId.trim() : undefined,
+ }));
+}
+
+function normalizeAgentSupplementPrompt(text: string, presetName?: string | null) {
+ const normalized = String(text || "").trim();
+ if (!normalized) return "";
+ const escapedPresetName = String(presetName || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const genericPattern = escapedPresetName
+ ? new RegExp(`^请按「${escapedPresetName}」智能体配置顺序处理这\\d+张图片。?$`)
+ : /^请按「.+」智能体配置顺序处理这\d+张图片。?$/;
+ if (genericPattern.test(normalized)) {
+ return "";
+ }
+ return normalized;
+}
+
+function buildAgentSlotPrompt(params: {
+ slotPrompt: string;
+ slotTitle?: string | null;
+ userSupplementPrompt?: string | null;
+}) {
+ const lines: string[] = [];
+ const slotTitle = String(params.slotTitle || "").trim();
+ if (slotTitle) {
+ lines.push(`【槽位】${slotTitle}`);
+ }
+ const slotPrompt = String(params.slotPrompt || "").trim();
+ if (slotPrompt) {
+ lines.push(slotPrompt);
+ }
+ const supplement = String(params.userSupplementPrompt || "").trim();
+ if (supplement) {
+ lines.push(`【补充要求】${supplement}`);
+ }
+ return lines.join("\n\n").trim();
+}
+
const MODEL_UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -2781,6 +2863,7 @@ export async function POST(request: Request) {
const {
id,
message,
+ agentPresetId,
selectedChatModel,
selectedVisibilityType,
hiddenPrompt,
@@ -2790,6 +2873,7 @@ export async function POST(request: Request) {
}: {
id: string;
message: ChatMessage;
+ agentPresetId?: string;
selectedChatModel: ChatModel["id"];
selectedVisibilityType: VisibilityType;
hiddenPrompt?: string;
@@ -4403,6 +4487,505 @@ export async function POST(request: Request) {
return createSseResponse(clarificationStream);
}
+ if (agentPresetId) {
+ const selectedAgentPreset = await getAccessibleAgentPresetById({
+ id: agentPresetId,
+ userId: session.user.id,
+ });
+
+ if (!selectedAgentPreset) {
+ return NextResponse.json(
+ {
+ code: "not_found:chat",
+ message: "智能体不存在或无权使用",
+ },
+ { status: 404 },
+ );
+ }
+
+ const orderedSlots = [...(selectedAgentPreset.slots || [])].sort(
+ (left, right) => left.slotIndex - right.slotIndex,
+ );
+ if (orderedSlots.length === 0) {
+ return NextResponse.json(
+ {
+ code: "bad_request:api",
+ message: "当前智能体未配置可执行槽位",
+ },
+ { status: 400 },
+ );
+ }
+
+ const uploadedImageParts = extractCurrentMessageImageParts(message.parts as any[]);
+ const requiredUploadCount = 3;
+
+ if (uploadedImageParts.length < requiredUploadCount) {
+ return NextResponse.json(
+ {
+ code: "bad_request:api",
+ message: `当前智能体需要上传 ${requiredUploadCount} 张图片`,
+ },
+ { status: 400 },
+ );
+ }
+
+ const presetModelId = String(selectedAgentPreset.defaultModelId || "").trim();
+ if (!presetModelId) {
+ return NextResponse.json(
+ {
+ code: "bad_request:api",
+ message: "当前智能体未配置默认模型",
+ },
+ { status: 400 },
+ );
+ }
+
+ const sanitizedUserSupplementPrompt = normalizeAgentSupplementPrompt(
+ requestTextContent,
+ selectedAgentPreset.name,
+ );
+
+ const presetSnapshot = await createAgentPresetExecutionSnapshot({
+ presetId: selectedAgentPreset.id,
+ userId: session.user.id,
+ projectId: contextPayload?.projectId || null,
+ chatId: id,
+ messageId: message.id,
+ presetName: selectedAgentPreset.name,
+ presetType: selectedAgentPreset.type,
+ visibility: selectedAgentPreset.visibility,
+ categoryId: selectedAgentPreset.category?.id || selectedAgentPreset.categoryId || null,
+ categoryName: selectedAgentPreset.category?.name || null,
+ promptTemplate: selectedAgentPreset.promptTemplate,
+ defaultModelId: selectedAgentPreset.defaultModelId || null,
+ resolvedModelId: presetModelId,
+ defaultParams: selectedAgentPreset.defaultParams || null,
+ slotCount: orderedSlots.length,
+ requiredUploadCount,
+ slots: orderedSlots.map((slot) => ({
+ slotIndex: slot.slotIndex,
+ title: slot.title || null,
+ promptTemplate: slot.promptTemplate,
+ defaultParams: slot.defaultParams || null,
+ requireUpload: slot.requireUpload !== false,
+ uploadLabel: slot.uploadLabel || null,
+ referenceAssetIds: slot.referenceAssetIds || [],
+ referenceImageUrls: (slot.referenceAssets || [])
+ .map((asset) => String(asset.originalUrl || "").trim())
+ .filter(Boolean),
+ })),
+ referenceAssetIds: orderedSlots.flatMap((slot) => slot.referenceAssetIds || []),
+ referenceImageUrls: orderedSlots.flatMap((slot) =>
+ (slot.referenceAssets || [])
+ .map((asset) => String(asset.originalUrl || "").trim())
+ .filter(Boolean),
+ ),
+ userInputText: sanitizedUserSupplementPrompt || null,
+ requestPayload: {
+ selectedChatModel,
+ agentPresetId,
+ requiredUploadCount,
+ uploadedImageUrls: uploadedImageParts.map((part) => part.url),
+ },
+ });
+
+ try {
+ await db
+ .update(agentPresetTable)
+ .set({
+ usageCount: sql`${agentPresetTable.usageCount} + 1`,
+ lastUsedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(agentPresetTable.id, selectedAgentPreset.id));
+ } catch (agentUsageError) {
+ console.warn("[Chat API] Failed to update agent preset usage stats:", agentUsageError);
+ }
+
+ const agentSequenceStream = createUIMessageStream({
+ execute: async ({ writer }) => {
+ if (selectedAgentPreset.type === "image") {
+ writeAssistantTextChunk(
+ writer,
+ "",
+ "agent-preset:image:start",
+ );
+ if (contextPayload?.source === "canvas") {
+ writeImageGenerationStatusChunk(
+ writer,
+ {
+ phase: "processing",
+ modelName:
+ selectedAgentPreset.defaultModel?.displayName ||
+ selectedAgentPreset.name,
+ imageCount: orderedSlots.length,
+ },
+ "agent-preset:image:status-processing",
+ );
+ }
+ }
+
+ let successCount = 0;
+ const sharedUploadedImageUrls = uploadedImageParts
+ .map((part) => part.url)
+ .filter((url) => /^https?:\/\//i.test(url));
+ const sharedUploadedAssetIds = uploadedImageParts
+ .map((part) => part.assetId)
+ .filter((assetId): assetId is string => Boolean(assetId));
+
+ for (const [slotOrder, slot] of orderedSlots.entries()) {
+ const slotLabel =
+ String(slot.title || slot.uploadLabel || "").trim() || `第${slotOrder + 1}张`;
+ const slotReferenceImageUrls = Array.from(
+ new Set(
+ (slot.referenceAssets || [])
+ .map((asset) => String(asset.originalUrl || "").trim())
+ .filter(Boolean),
+ ),
+ );
+ const sourceImageUrls = Array.from(
+ new Set(
+ [
+ ...sharedUploadedImageUrls,
+ ...slotReferenceImageUrls,
+ ].filter(Boolean),
+ ),
+ );
+ const sourceAssetIds = Array.from(
+ new Set(
+ [
+ ...sharedUploadedAssetIds,
+ ...(slot.referenceAssetIds || []),
+ ].filter(Boolean),
+ ),
+ );
+ const slotPrompt = buildAgentSlotPrompt({
+ slotPrompt: slot.promptTemplate,
+ slotTitle: slot.title || slot.uploadLabel || null,
+ userSupplementPrompt: sanitizedUserSupplementPrompt || null,
+ });
+ const assistantMessageId = generateUUID();
+
+ try {
+ if (selectedAgentPreset.type === "image") {
+ const mergedImageParams = {
+ ...(selectedAgentPreset.defaultParams || {}),
+ ...(slot.defaultParams || {}),
+ } as Record;
+ const result = await generateCanvasImage({
+ prompt: slotPrompt || `生成${slotLabel}`,
+ negativePrompt:
+ typeof mergedImageParams.negativePrompt === "string"
+ ? mergedImageParams.negativePrompt
+ : undefined,
+ model: presetModelId,
+ aspectRatio:
+ typeof mergedImageParams.aspectRatio === "string"
+ ? mergedImageParams.aspectRatio
+ : effectiveImageParams?.aspectRatio || defaultImageQualityParams.aspectRatio,
+ imageSize:
+ typeof mergedImageParams.imageSize === "string"
+ ? mergedImageParams.imageSize
+ : effectiveImageParams?.imageSize || defaultImageQualityParams.imageSize,
+ imageCount: 1,
+ referenceImages: sourceImageUrls,
+ });
+
+ const imageMarkdown = buildAgentToolImagesMarkdown(
+ result.images.map((image) => ({
+ originalUrl: image.originalUrl,
+ thumbnailUrl: image.thumbnailUrl,
+ imageSize:
+ typeof mergedImageParams.imageSize === "string"
+ ? mergedImageParams.imageSize
+ : effectiveImageParams?.imageSize || defaultImageQualityParams.imageSize,
+ })),
+ );
+ const assistantText = prependImageModelBadge(
+ [`### ${slotLabel}`, imageMarkdown].filter(Boolean).join("\n\n"),
+ result.model,
+ );
+
+ await saveMessages({
+ messages: [
+ {
+ id: assistantMessageId,
+ chatId: id,
+ role: "assistant",
+ parts: [{ type: "text", text: assistantText }],
+ attachments: [],
+ createdAt: new Date(),
+ videoStorageStatus: null,
+ temporaryVideoUrl: null,
+ permanentVideoUrl: null,
+ archivedAt: null,
+ videoMetadata: null,
+ },
+ ],
+ });
+
+ await saveGeneratedImages(
+ assistantText,
+ session.user.id,
+ id,
+ assistantMessageId,
+ slotPrompt || slotLabel,
+ result.modelRef || presetModelId,
+ {
+ sourceAssetIds,
+ sourceImageUrls,
+ relationType: "generated_from",
+ sourceType:
+ sourceImageUrls.length > 0 ? "reference_generate" : "generated",
+ projectId: contextPayload?.projectId || null,
+ },
+ );
+
+ writeAssistantTextChunk(
+ writer,
+ assistantText,
+ `agent-preset:image:slot-${slotOrder + 1}`,
+ assistantMessageId,
+ );
+ if (contextPayload?.source === "canvas") {
+ writeCanvasMediaChunk(
+ writer,
+ {
+ messageId: assistantMessageId,
+ imageUrls: result.images.map((image) => image.originalUrl),
+ prompt: slotPrompt || slotLabel,
+ sourceImageUrls,
+ agentPresetId: selectedAgentPreset.id,
+ agentPresetName: selectedAgentPreset.name,
+ agentCategoryId:
+ selectedAgentPreset.category?.id ||
+ selectedAgentPreset.categoryId ||
+ null,
+ agentCategoryName: selectedAgentPreset.category?.name || null,
+ agentExecutionSnapshotId: presetSnapshot.id,
+ },
+ `agent-preset:image:slot-${slotOrder + 1}:canvas-media`,
+ );
+ }
+ } else {
+ const mergedVideoParams = {
+ ...(selectedAgentPreset.defaultParams || {}),
+ ...(slot.defaultParams || {}),
+ } as Record;
+ const requestedMode = String(mergedVideoParams.mode || "").trim() as
+ | "text-to-video"
+ | "first-frame"
+ | "first-last-frame"
+ | "reference-images";
+ const mode =
+ requestedMode ||
+ (sourceImageUrls.length >= 2
+ ? "first-last-frame"
+ : sourceImageUrls.length >= 1
+ ? "first-frame"
+ : "text-to-video");
+ const directVideoRatio =
+ mergedVideoParams.ratio === "16:9" ||
+ mergedVideoParams.ratio === "4:3" ||
+ mergedVideoParams.ratio === "1:1" ||
+ mergedVideoParams.ratio === "3:4" ||
+ mergedVideoParams.ratio === "9:16" ||
+ mergedVideoParams.ratio === "21:9" ||
+ mergedVideoParams.ratio === "adaptive"
+ ? mergedVideoParams.ratio
+ : undefined;
+ const images =
+ mode === "first-last-frame"
+ ? sourceImageUrls.slice(0, 2)
+ : mode === "first-frame"
+ ? sourceImageUrls.slice(0, 1)
+ : mode === "reference-images"
+ ? sourceImageUrls.slice(0, 4)
+ : [];
+
+ const result = await executeDirectVideoGeneration({
+ prompt: slotPrompt || `生成${slotLabel}`,
+ mode,
+ images,
+ resolution: mergedVideoParams.resolution,
+ ratio: directVideoRatio,
+ duration: mergedVideoParams.duration,
+ preferredModelRef: presetModelId,
+ });
+
+ const videoUrl = String(result.videoUrl || "").trim();
+ if (!videoUrl) {
+ throw new Error("视频生成完成但未返回视频地址");
+ }
+
+ const assistantText = prependImageModelBadge(
+ [
+ `### ${slotLabel}`,
+ buildAgentToolVideoMarkdown(videoUrl),
+ ]
+ .filter(Boolean)
+ .join("\n\n"),
+ result.modelDisplayName,
+ );
+
+ await saveMessages({
+ messages: [
+ {
+ id: assistantMessageId,
+ chatId: id,
+ role: "assistant",
+ parts: [{ type: "text", text: assistantText }],
+ attachments: [],
+ createdAt: new Date(),
+ videoStorageStatus: "temporary",
+ temporaryVideoUrl: videoUrl,
+ permanentVideoUrl: null,
+ archivedAt: null,
+ videoMetadata: (result.metadata || {}) as any,
+ },
+ ],
+ });
+
+ try {
+ await upsertConversationWorkingState({
+ userId: session.user.id,
+ chatId: id,
+ projectId: contextPayload?.projectId || null,
+ currentIntent: "video_gen",
+ lastMessageId: assistantMessageId,
+ });
+ } catch (memoryError) {
+ console.warn(
+ "[Chat API] Failed to update working state after agent preset video generation:",
+ memoryError,
+ );
+ }
+
+ await consumeGeneratedVideoPoints({
+ userId: session.user.id,
+ chatId: id,
+ messageId: assistantMessageId,
+ modelRefs: [result.modelRef || presetModelId],
+ });
+
+ scheduleVideoArchive({
+ chatId: id,
+ messageId: assistantMessageId,
+ text: assistantText,
+ temporaryVideoUrl: videoUrl,
+ taskId: assistantMessageId,
+ metadata: result.metadata,
+ });
+
+ writeAssistantTextChunk(
+ writer,
+ assistantText,
+ `agent-preset:video:slot-${slotOrder + 1}`,
+ assistantMessageId,
+ );
+ if (contextPayload?.source === "canvas") {
+ writeCanvasMediaChunk(
+ writer,
+ {
+ messageId: assistantMessageId,
+ videoUrls: [videoUrl],
+ prompt: slotPrompt || slotLabel,
+ sourceImageUrls,
+ agentPresetId: selectedAgentPreset.id,
+ agentPresetName: selectedAgentPreset.name,
+ agentCategoryId:
+ selectedAgentPreset.category?.id ||
+ selectedAgentPreset.categoryId ||
+ null,
+ agentCategoryName: selectedAgentPreset.category?.name || null,
+ agentExecutionSnapshotId: presetSnapshot.id,
+ },
+ `agent-preset:video:slot-${slotOrder + 1}:canvas-media`,
+ );
+ }
+ }
+
+ successCount += 1;
+ } catch (slotError) {
+ console.error("[Chat API] Agent preset slot execution failed:", {
+ slotIndex: slot.slotIndex,
+ slotLabel,
+ error: slotError,
+ });
+ const errorMessage = `### ${slotLabel}\n\n执行失败:${
+ slotError instanceof Error ? slotError.message : "请稍后重试"
+ }`;
+ await saveMessages({
+ messages: [
+ {
+ id: assistantMessageId,
+ chatId: id,
+ role: "assistant",
+ parts: [{ type: "text", text: errorMessage }],
+ attachments: [],
+ createdAt: new Date(),
+ videoStorageStatus: null,
+ temporaryVideoUrl: null,
+ permanentVideoUrl: null,
+ archivedAt: null,
+ videoMetadata: null,
+ },
+ ],
+ });
+ writeAssistantTextChunk(
+ writer,
+ errorMessage,
+ `agent-preset:error:slot-${slotOrder + 1}`,
+ assistantMessageId,
+ );
+ }
+ }
+
+ try {
+ await upsertConversationWorkingState({
+ userId: session.user.id,
+ chatId: id,
+ projectId: contextPayload?.projectId || null,
+ currentIntent: selectedAgentPreset.type === "video" ? "video_gen" : "image_gen",
+ lastMessageId: message.id,
+ lastTurnSummary: {
+ text: requestTextContent.slice(0, 500),
+ agentPresetId: selectedAgentPreset.id,
+ agentPresetName: selectedAgentPreset.name,
+ slotCount: orderedSlots.length,
+ successCount,
+ },
+ });
+ } catch (agentPresetMemoryError) {
+ console.warn("[Chat API] Failed to persist agent preset working state:", agentPresetMemoryError);
+ }
+
+ if (selectedAgentPreset.type === "image") {
+ if (contextPayload?.source === "canvas") {
+ writeImageGenerationStatusChunk(
+ writer,
+ {
+ phase: successCount > 0 ? "completed" : "failed",
+ modelName:
+ selectedAgentPreset.defaultModel?.displayName ||
+ selectedAgentPreset.name,
+ imageCount: successCount,
+ },
+ "agent-preset:image:status-finished",
+ );
+ }
+ writeAssistantTextChunk(
+ writer,
+ "",
+ "agent-preset:image:end",
+ );
+ }
+ },
+ });
+
+ return createSseResponse(agentSequenceStream);
+ }
+
const directAgentExecution = shouldRunAgentConversation
? resolveDirectAgentToolExecution(smartAutoExecution, smartAutoPlan)
: null;
diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts
index 31cb5fde2..ce362429a 100644
--- a/app/(chat)/api/chat/schema.ts
+++ b/app/(chat)/api/chat/schema.ts
@@ -80,6 +80,7 @@ export const postRequestBodySchema = z.object({
}),
hiddenPrompt: z.string().max(4000).optional(),
autoContext: z.string().max(4000).optional(),
+ agentPresetId: z.string().uuid().optional(),
// 支持预定义模型名称或数据库中的模型 UUID
selectedChatModel: z.string().min(1).max(128),
selectedVisibilityType: z.enum(["public", "private"]),
diff --git a/app/(chat)/canvas/page.tsx b/app/(chat)/canvas/page.tsx
index 894202067..8bbb3e931 100644
--- a/app/(chat)/canvas/page.tsx
+++ b/app/(chat)/canvas/page.tsx
@@ -11,11 +11,16 @@ export default function CanvasRedirectPage() {
useEffect(() => {
const shouldCreateNew = searchParams.get("new") === "1";
+ const passthroughParams = new URLSearchParams(searchParams.toString());
+ passthroughParams.delete("new");
+ const passthroughQuery = passthroughParams.toString();
+ const buildCanvasUrl = (canvasId: string) =>
+ `/canvas/${canvasId}${passthroughQuery ? `?${passthroughQuery}` : ""}`;
const openCanvas = async () => {
if (shouldCreateNew) {
const newId = crypto.randomUUID();
- router.replace(`/canvas/${newId}`);
+ router.replace(buildCanvasUrl(newId));
return;
}
@@ -32,7 +37,7 @@ export default function CanvasRedirectPage() {
});
const latest = sorted[0];
if (latest?.id) {
- router.replace(`/canvas/${latest.id}`);
+ router.replace(buildCanvasUrl(latest.id));
return;
}
}
@@ -42,7 +47,7 @@ export default function CanvasRedirectPage() {
}
const newId = crypto.randomUUID();
- router.replace(`/canvas/${newId}`);
+ router.replace(buildCanvasUrl(newId));
};
void openCanvas();
diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx
index 715dc9039..c97d81e60 100644
--- a/app/(chat)/layout.tsx
+++ b/app/(chat)/layout.tsx
@@ -71,6 +71,7 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) {
}))}
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true}
+ agentEnabled={(siteSettings as any).agentEnabled ?? true}
>
{children}
diff --git a/app/api/agent-categories/route.ts b/app/api/agent-categories/route.ts
new file mode 100644
index 000000000..e93657c73
--- /dev/null
+++ b/app/api/agent-categories/route.ts
@@ -0,0 +1,81 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { auth } from "@/app/(auth)/auth";
+import { ChatSDKError } from "@/lib/errors";
+import {
+ createAgentCategory,
+ getVisibleAgentCategories,
+} from "@/lib/db/queries";
+
+const scopeSchema = z.enum(["all", "public", "private"]).default("public");
+
+const createCategorySchema = z.object({
+ name: z.string().trim().min(1).max(64),
+ scope: z.enum(["public", "private"]).default("public"),
+});
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const scope = scopeSchema.parse(searchParams.get("scope") || "public");
+ const categories = await getVisibleAgentCategories({
+ userId: session.user.id,
+ scope,
+ });
+
+ return NextResponse.json({ success: true, data: categories });
+ } catch (error) {
+ if (error instanceof ChatSDKError) {
+ return error.toResponse();
+ }
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { success: false, error: error.message },
+ { status: 400 }
+ );
+ }
+ 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();
+ const data = createCategorySchema.parse(body);
+
+ const category = await createAgentCategory({
+ userId: session.user.id,
+ name: data.name,
+ scope: data.scope,
+ });
+
+ return NextResponse.json({ success: true, data: category });
+ } catch (error) {
+ if (error instanceof ChatSDKError) {
+ return error.toResponse();
+ }
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { success: false, error: error.message },
+ { status: 400 }
+ );
+ }
+ return NextResponse.json(
+ { success: false, error: "创建智能体分类失败" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/agent-execution-snapshots/[id]/route.ts b/app/api/agent-execution-snapshots/[id]/route.ts
new file mode 100644
index 000000000..2c0215c7b
--- /dev/null
+++ b/app/api/agent-execution-snapshots/[id]/route.ts
@@ -0,0 +1,36 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/app/(auth)/auth";
+import { ChatSDKError } from "@/lib/errors";
+import { getAccessibleAgentPresetExecutionSnapshotById } from "@/lib/db/queries";
+
+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 snapshot = await getAccessibleAgentPresetExecutionSnapshotById({
+ id,
+ userId: session.user.id,
+ });
+
+ if (!snapshot) {
+ return NextResponse.json({ success: false, error: "执行快照不存在" }, { status: 404 });
+ }
+
+ return NextResponse.json({ success: true, data: snapshot });
+ } catch (error) {
+ if (error instanceof ChatSDKError) {
+ return error.toResponse();
+ }
+ return NextResponse.json(
+ { success: false, error: "获取执行快照失败" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/agents/[id]/fork/route.ts b/app/api/agents/[id]/fork/route.ts
new file mode 100644
index 000000000..1bb9550ac
--- /dev/null
+++ b/app/api/agents/[id]/fork/route.ts
@@ -0,0 +1,47 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { auth } from "@/app/(auth)/auth";
+import { ChatSDKError } from "@/lib/errors";
+import { forkAgentPreset } from "@/lib/db/queries";
+
+const forkSchema = z.object({
+ name: z.string().trim().min(1).max(128).optional(),
+});
+
+export async function POST(
+ 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 data = forkSchema.parse(body);
+
+ const preset = await forkAgentPreset({
+ id,
+ userId: session.user.id,
+ name: data.name,
+ });
+
+ return NextResponse.json({ success: true, data: preset });
+ } catch (error) {
+ if (error instanceof ChatSDKError) {
+ return error.toResponse();
+ }
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { success: false, error: error.message },
+ { status: 400 }
+ );
+ }
+ return NextResponse.json(
+ { success: false, error: "复制智能体失败" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/agents/[id]/route.ts b/app/api/agents/[id]/route.ts
new file mode 100644
index 000000000..6ee777a76
--- /dev/null
+++ b/app/api/agents/[id]/route.ts
@@ -0,0 +1,154 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { auth } from "@/app/(auth)/auth";
+import { ChatSDKError } from "@/lib/errors";
+import {
+ deleteAgentPresetById,
+ getAccessibleAgentPresetById,
+ updateAgentPreset,
+} from "@/lib/db/queries";
+
+const slotSchema = z.object({
+ title: z.string().trim().max(128).optional(),
+ promptTemplate: z.string().trim().min(1).max(8000),
+ defaultParams: z.record(z.string(), z.any()).nullable().optional(),
+ requireUpload: z.boolean().optional(),
+ uploadLabel: z.string().trim().max(128).optional(),
+ referenceAssetIds: z.array(z.string().uuid()).max(12).optional(),
+});
+
+const updateAgentSchema = z
+ .object({
+ name: z.string().trim().min(1).max(128).optional(),
+ type: z.enum(["image", "video"]).optional(),
+ visibility: z.enum(["public", "private"]).optional(),
+ categoryId: z.string().uuid().nullable().optional(),
+ newCategoryName: z.string().trim().min(1).max(64).optional(),
+ description: z.string().trim().max(2000).nullable().optional(),
+ defaultModelId: z.string().uuid().optional(),
+ defaultParams: z.record(z.string(), z.any()).nullable().optional(),
+ avatarAssetId: z.string().uuid().nullable().optional(),
+ slots: z.array(slotSchema).min(1).max(20).optional(),
+ status: z.enum(["active", "disabled", "hidden", "blocked"]).optional(),
+ })
+ .superRefine((value, ctx) => {
+ if (value.categoryId && value.newCategoryName) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "categoryId 和 newCategoryName 不能同时传入",
+ path: ["newCategoryName"],
+ });
+ }
+ });
+
+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 preset = await getAccessibleAgentPresetById({
+ id,
+ userId: session.user.id,
+ });
+
+ if (!preset) {
+ return NextResponse.json({ success: false, error: "智能体不存在" }, { status: 404 });
+ }
+
+ return NextResponse.json({ success: true, data: preset });
+ } catch (error) {
+ if (error instanceof ChatSDKError) {
+ return error.toResponse();
+ }
+ 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();
+ const data = updateAgentSchema.parse(body);
+
+ const preset = await updateAgentPreset({
+ id,
+ userId: session.user.id,
+ name: data.name,
+ type: data.type,
+ visibility: data.visibility,
+ categoryId: data.categoryId ?? undefined,
+ newCategoryName: data.newCategoryName,
+ description: data.description ?? undefined,
+ defaultModelId: data.defaultModelId,
+ defaultParams: data.defaultParams ?? undefined,
+ avatarAssetId: data.avatarAssetId ?? undefined,
+ slots: data.slots,
+ status: data.status,
+ });
+
+ return NextResponse.json({ success: true, data: preset });
+ } catch (error) {
+ if (error instanceof ChatSDKError) {
+ return error.toResponse();
+ }
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { success: false, error: error.message },
+ { status: 400 }
+ );
+ }
+ 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 deleted = await deleteAgentPresetById({
+ id,
+ userId: session.user.id,
+ });
+
+ if (!deleted) {
+ return NextResponse.json({ success: false, error: "智能体不存在" }, { status: 404 });
+ }
+
+ return NextResponse.json({ success: true, data: deleted });
+ } catch (error) {
+ if (error instanceof ChatSDKError) {
+ return error.toResponse();
+ }
+ return NextResponse.json(
+ { success: false, error: "删除智能体失败" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/agents/route.ts b/app/api/agents/route.ts
new file mode 100644
index 000000000..de0867bb6
--- /dev/null
+++ b/app/api/agents/route.ts
@@ -0,0 +1,154 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { auth } from "@/app/(auth)/auth";
+import { ChatSDKError } from "@/lib/errors";
+import {
+ createAgentPreset,
+ getVisibleAgentCategories,
+ getVisibleAgentPresets,
+} from "@/lib/db/queries";
+
+const listQuerySchema = z.object({
+ scope: z.enum(["all", "public", "private"]).default("all"),
+ type: z.enum(["image", "video"]).optional(),
+ categoryId: z.string().uuid().optional(),
+ keyword: z.string().trim().max(128).optional(),
+ sort: z.enum(["latest", "hottest", "most_used"]).default("latest"),
+});
+
+const slotSchema = z.object({
+ title: z.string().trim().max(128).optional(),
+ promptTemplate: z.string().trim().min(1).max(8000),
+ defaultParams: z.record(z.string(), z.any()).optional(),
+ requireUpload: z.boolean().optional(),
+ uploadLabel: z.string().trim().max(128).optional(),
+ referenceAssetIds: z.array(z.string().uuid()).max(12).optional(),
+});
+
+const createAgentSchema = z
+ .object({
+ name: z.string().trim().min(1).max(128),
+ type: z.enum(["image", "video"]),
+ visibility: z.enum(["public", "private"]).default("private"),
+ categoryId: z.string().uuid().optional(),
+ newCategoryName: z.string().trim().min(1).max(64).optional(),
+ description: z.string().trim().max(2000).optional(),
+ defaultModelId: z.string().uuid(),
+ defaultParams: z.record(z.string(), z.any()).optional(),
+ avatarAssetId: z.string().uuid().optional(),
+ slots: z.array(slotSchema).min(1).max(20),
+ })
+ .superRefine((value, ctx) => {
+ if (value.categoryId && value.newCategoryName) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "categoryId 和 newCategoryName 不能同时传入",
+ path: ["newCategoryName"],
+ });
+ }
+ if (value.visibility === "public" && !value.categoryId && !value.newCategoryName) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "公开智能体必须选择分类或新建分类",
+ path: ["categoryId"],
+ });
+ }
+ });
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ const query = listQuerySchema.parse({
+ scope: searchParams.get("scope") || "all",
+ type: searchParams.get("type") || undefined,
+ categoryId: searchParams.get("categoryId") || undefined,
+ keyword: searchParams.get("keyword") || undefined,
+ sort: searchParams.get("sort") || "latest",
+ });
+
+ const [presets, categories] = await Promise.all([
+ getVisibleAgentPresets({
+ userId: session.user.id,
+ scope: query.scope,
+ type: query.type,
+ categoryId: query.categoryId,
+ keyword: query.keyword,
+ sort: query.sort,
+ }),
+ getVisibleAgentCategories({
+ userId: session.user.id,
+ scope: query.scope === "private" ? "private" : "all",
+ }),
+ ]);
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ categories,
+ public: presets.filter((item) => item.visibility === "public"),
+ private: presets.filter((item) => item.visibility === "private"),
+ },
+ });
+ } catch (error) {
+ if (error instanceof ChatSDKError) {
+ return error.toResponse();
+ }
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { success: false, error: error.message },
+ { status: 400 }
+ );
+ }
+ 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();
+ const data = createAgentSchema.parse(body);
+
+ const preset = await createAgentPreset({
+ userId: session.user.id,
+ name: data.name,
+ type: data.type,
+ visibility: data.visibility,
+ categoryId: data.categoryId,
+ newCategoryName: data.newCategoryName,
+ description: data.description,
+ defaultModelId: data.defaultModelId,
+ defaultParams: data.defaultParams,
+ avatarAssetId: data.avatarAssetId,
+ slots: data.slots,
+ });
+
+ return NextResponse.json({ success: true, data: preset });
+ } catch (error) {
+ if (error instanceof ChatSDKError) {
+ return error.toResponse();
+ }
+ if (error instanceof z.ZodError) {
+ return NextResponse.json(
+ { success: false, error: error.message },
+ { status: 400 }
+ );
+ }
+ return NextResponse.json(
+ { success: false, error: "创建智能体失败" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts
index 4684813e6..bfa73d882 100644
--- a/app/api/site-settings/route.ts
+++ b/app/api/site-settings/route.ts
@@ -336,6 +336,7 @@ export async function GET() {
tutorialEnabled: false,
rankingEnabled: true,
canvasEnabled: true,
+ agentEnabled: true,
realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true,
storageFailoverConfig,
@@ -424,6 +425,7 @@ const updateSchema = z.object({
tutorialEnabled: z.boolean().optional(),
rankingEnabled: z.boolean().optional(),
canvasEnabled: z.boolean().optional(),
+ agentEnabled: z.boolean().optional(),
realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
@@ -518,6 +520,7 @@ export async function PUT(request: NextRequest) {
.update(siteSettings)
.set({
...siteSettingsPayload,
+ agentEnabled: siteSettingsPayload.agentEnabled ?? existing.agentEnabled ?? true,
updatedAt: new Date(),
updatedBy: session.user.id,
})
@@ -881,6 +884,7 @@ export async function PUT(request: NextRequest) {
tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false,
rankingEnabled: siteSettingsPayload.rankingEnabled ?? true,
canvasEnabled: siteSettingsPayload.canvasEnabled ?? true,
+ agentEnabled: siteSettingsPayload.agentEnabled ?? true,
realtimeCanvasEnabled: siteSettingsPayload.realtimeCanvasEnabled ?? true,
canvasDefaultModelId: siteSettingsPayload.canvasDefaultModelId,
canvasAutoModeDefaultModelId: siteSettingsPayload.canvasAutoModeDefaultModelId,
diff --git a/app/canvas/[id]/page.tsx b/app/canvas/[id]/page.tsx
index 221e02b08..6d58576f9 100644
--- a/app/canvas/[id]/page.tsx
+++ b/app/canvas/[id]/page.tsx
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useState, useCallback, use, useRef } from "react";
-import { useRouter, useParams } from "next/navigation";
+import { useRouter, useParams, useSearchParams } from "next/navigation";
import useSWR from "swr";
import {
+ AlertCircle,
Save,
Undo2,
Redo2,
@@ -56,6 +57,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "sonner";
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import { canvasService, imageGenerationService } from "@/services/canvas";
+import { AgentPresetPanel } from "@/components/agents/AgentPresetPanel";
import { CanvasWorkspace } from "@/components/canvas";
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
@@ -88,8 +90,10 @@ const FLOATING_BOTTOM_INSET = 20;
export default function CanvasEditorPage({ params }: PageProps) {
// 使用 useParams 确保客户端导航时能正确获取更新的 id
const routeParams = useParams();
+ const searchParams = useSearchParams();
const id = (routeParams?.id as string) || use(params).id;
const router = useRouter();
+ const mode = searchParams.get("mode");
const goHome = useCallback(() => {
if (typeof window !== "undefined") {
window.location.assign("/");
@@ -138,6 +142,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
siteName?: string;
siteLogo?: string | null;
siteLogoDark?: string | null;
+ agentEnabled?: boolean;
realtimeCanvasEnabled?: boolean;
realtimeCanvasServiceEnabled?: boolean;
}>("/api/site-settings", fetcher);
@@ -148,6 +153,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [aspectRatio, setAspectRatio] = useState("1:1");
const [imageCount, setImageCount] = useState(1);
const previousNodeIdsRef = useRef>(new Set());
+ const selectedAgentPresetId = searchParams.get("agentPresetId");
+ const requestedAgentMode = mode === "agent" || Boolean(selectedAgentPresetId);
+ const isAgentFeatureEnabled = siteSettingsData?.agentEnabled !== false;
+ const isAgentMode =
+ isAgentFeatureEnabled && requestedAgentMode;
// 获取选中的图片节点
const selectedImageNode = selectedNodeIds.length === 1
@@ -155,12 +165,63 @@ export default function CanvasEditorPage({ params }: PageProps) {
: null;
const resolvedPanelWidth = viewportWidth > 0
- ? Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0))
+ ? isAgentMode
+ ? Math.min(
+ 520,
+ Math.max(Math.floor((viewportWidth - DOCKED_PANEL_INSET * 2) / 2), 340)
+ )
+ : Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0))
: FLOATING_PANEL_WIDTH;
+ const agentPanelWidth = isAgentMode ? resolvedPanelWidth : 0;
const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0;
const floatingToolbarOffset = isPanelOpen ? -(panelOcclusionRight / 2) : 0;
const compactControlsLeft = FLOATING_UI_GAP;
+ const updateCanvasSearchParams = useCallback(
+ (updates: Record) => {
+ const nextParams = new URLSearchParams(searchParams.toString());
+ Object.entries(updates).forEach(([key, value]) => {
+ if (value === null || value === "") {
+ nextParams.delete(key);
+ } else {
+ nextParams.set(key, value);
+ }
+ });
+
+ const nextQuery = nextParams.toString();
+ router.replace(nextQuery ? `/canvas/${id}?${nextQuery}` : `/canvas/${id}`);
+ },
+ [id, router, searchParams]
+ );
+
+ const handleSelectAgentPreset = useCallback(
+ (presetId: string | null) => {
+ updateCanvasSearchParams({
+ mode: presetId ? "agent" : "agent",
+ agentPresetId: presetId,
+ });
+ setIsPanelOpen(true);
+ setPanelMode("chat");
+ },
+ [updateCanvasSearchParams]
+ );
+
+ const handleToggleAgentMode = useCallback(() => {
+ if (isAgentMode) {
+ updateCanvasSearchParams({
+ mode: null,
+ agentPresetId: null,
+ });
+ return;
+ }
+
+ updateCanvasSearchParams({
+ mode: "agent",
+ });
+ setIsPanelOpen(true);
+ setPanelMode("chat");
+ }, [isAgentMode, updateCanvasSearchParams]);
+
// 加载画布数据
useEffect(() => {
console.log('[Canvas] Loading project:', id);
@@ -202,6 +263,13 @@ export default function CanvasEditorPage({ params }: PageProps) {
return () => window.removeEventListener("resize", updateViewportWidth);
}, []);
+ useEffect(() => {
+ if (isAgentMode) {
+ setIsPanelOpen(true);
+ setPanelMode("chat");
+ }
+ }, [isAgentMode]);
+
const persistCanvasState = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => {
const canvasData = getCanvasState();
@@ -669,6 +737,16 @@ export default function CanvasEditorPage({ params }: PageProps) {
+ {/* Agent Disabled Notice */}
+ {requestedAgentMode && !isAgentFeatureEnabled ? (
+
+
+
+
智能体功能未开启,请联系管理员在系统设置中开启。
+
+
+ ) : null}
+
{/* 2. Floating Actions (Top Right) */}
{!isPanelOpen && (
@@ -684,6 +762,26 @@ export default function CanvasEditorPage({ params }: PageProps) {
)}
{/* 4. Floating Sidebar (Right) */}
+ {isAgentMode ? (
+
+ ) : null}
+
setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode('fileList')}
+ selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
/>
) : (
@@ -754,6 +853,23 @@ export default function CanvasEditorPage({ params }: PageProps) {
平移 (H)
+
+
+
+
+ 智能体模式
+
+
)}
+ {!isDetailGenerationMode && activeComposerMode === "agent" && selectedAgentPreset && (
+
+
+
+ {selectedAgentPreset.name}
+
+ {hasAgentUploadSlots ? (
+
+ 请上传产品图1、产品图2、模特图
+
+ ) : null}
+
+ )}
0) {
const range = selection.getRangeAt(0);
@@ -8741,6 +9119,74 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
/>
+ {hasAgentUploadSlots && (
+
+
+ {agentUploadSlots.map((slot, index) => {
+ const slotAttachment = attachments[index];
+ const isUploading = agentSlotUploading === index;
+ const isDisabled = isLoading || (index > 0 && !attachments[index - 1]);
+ return (
+
{
+ if (isDisabled) return;
+ if (fillAgentSlotFromCanvasSelection(index)) {
+ toast.success(`${slot.label}已从画布填充`);
+ return;
+ }
+ agentSlotInputRefs.current[index]?.click();
+ }}
+ className={cn(
+ "group relative flex h-24 min-w-24 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted/30 text-muted-foreground transition-colors",
+ !isDisabled && "cursor-pointer hover:border-primary/60 hover:bg-muted/50",
+ isDisabled && "cursor-not-allowed opacity-55"
+ )}
+ >
+ {slotAttachment ? (
+ <>
+
{
+ if (event.currentTarget.src !== slotAttachment.url) {
+ event.currentTarget.src = slotAttachment.url;
+ }
+ }}
+ />
+
+ {slot.label}
+
+ {
+ event.stopPropagation();
+ updateAttachmentAtAgentSlot(index, null);
+ }}
+ >
+ x
+
+ >
+ ) : (
+
+ +
+ {isUploading ? "上传中" : slot.label}
+
+ )}
+
+ );
+ })}
+
+
+ 智能体需要上传 3 张图片:产品图1、产品图2、模特图;已选中画布图片时,点击槽位可直接填充,未选中则打开上传。
+
+
+ )}
+
{isVideoModel && videoFrameSlots.length > 0 && (
@@ -8834,6 +9280,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}}
/>
))}
+ {agentUploadSlots.map((_, index) => (
+ {
+ agentSlotInputRefs.current[index] = element;
+ }}
+ onChange={(event) => {
+ void handleAgentSlotFileChange(index, event);
+ }}
+ />
+ ))}
(
() => normalizeTextEditItems(data.textEditItems),
);
@@ -361,6 +363,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
// 当前正在识别的点击位置
const [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null);
const displayTitle = getImageNodeTitle(data, node.id);
+ const agentExecutionSnapshotId =
+ typeof data.metadata?.agentExecutionSnapshotId === "string"
+ ? data.metadata.agentExecutionSnapshotId.trim()
+ : "";
+ const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
+ ? agentExecutionSnapshotId.slice(0, 8)
+ : "";
const isMultiSelected = isSelected && selectedNodeIds.length > 1;
const compareSource = useMemo(() => {
const targetIdentity = normalizeImageIdentityUrl(data.src || "");
@@ -2759,6 +2768,19 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
备注
)}
+ {shortAgentExecutionSnapshotId ? (
+
setSnapshotDialogOpen(true)}
+ className={cn(
+ "absolute left-2 inline-flex items-center gap-1 rounded-full bg-black/70 px-2 py-1 text-[11px] text-white shadow-sm transition-colors hover:bg-black/80",
+ data.note && !isSelected ? "top-10" : "top-2"
+ )}
+ title={`执行快照:${agentExecutionSnapshotId}`}
+ >
+ 快照 {shortAgentExecutionSnapshotId}
+
+ ) : null}
{/* 角度编辑对话框 */}
@@ -2770,6 +2792,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onComplete={handleAngleEditComplete}
/>
)}
+
{/* 批量处理对话框 */}
(null);
const [isHovered, setIsHovered] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(true);
const [progress, setProgress] = useState(0);
+ const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode);
@@ -262,6 +271,22 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
)}
+ {shortAgentExecutionSnapshotId ? (
+