From 4b820f8ff419a711e622793c4c8f7db70bc1d812 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 10 Mar 2026 19:56:25 +0800 Subject: [PATCH] Add canvas agent mode and management --- .../settings/admin-settings-client.tsx | 19 + app/(chat)/api/chat/route.ts | 585 +++++++- app/(chat)/api/chat/schema.ts | 1 + app/(chat)/canvas/page.tsx | 11 +- app/(chat)/layout.tsx | 1 + app/api/agent-categories/route.ts | 81 + .../agent-execution-snapshots/[id]/route.ts | 36 + app/api/agents/[id]/fork/route.ts | 47 + app/api/agents/[id]/route.ts | 154 ++ app/api/agents/route.ts | 154 ++ app/api/site-settings/route.ts | 4 + app/canvas/[id]/page.tsx | 120 +- components/agents/AgentCategoryTabs.tsx | 54 + .../agents/AgentExecutionSnapshotDialog.tsx | 155 ++ components/agents/AgentPresetCard.tsx | 111 ++ components/agents/AgentPresetDialog.tsx | 632 ++++++++ components/agents/AgentPresetPanel.tsx | 208 +++ components/canvas/CanvasChatPanel.tsx | 480 +++++- components/canvas/nodes/ImageNode.tsx | 27 + components/canvas/nodes/VideoNode.tsx | 25 + components/chat-shell.tsx | 3 + components/top-navbar.tsx | 20 +- docs/智能体页面开发文档.md | 554 +++++-- lib/db/migrations/0053_add_agent_presets.sql | 179 +++ .../0054_add_agent_preset_slots.sql | 52 + .../0055_add_agent_enabled_site_setting.sql | 2 + lib/db/migrations/meta/_journal.json | 21 + lib/db/queries.ts | 1314 +++++++++++++++++ lib/db/schema.ts | 152 ++ services/agents/index.ts | 170 +++ types/agent.ts | 159 ++ types/canvas/index.ts | 1 + 32 files changed, 5382 insertions(+), 150 deletions(-) create mode 100644 app/api/agent-categories/route.ts create mode 100644 app/api/agent-execution-snapshots/[id]/route.ts create mode 100644 app/api/agents/[id]/fork/route.ts create mode 100644 app/api/agents/[id]/route.ts create mode 100644 app/api/agents/route.ts create mode 100644 components/agents/AgentCategoryTabs.tsx create mode 100644 components/agents/AgentExecutionSnapshotDialog.tsx create mode 100644 components/agents/AgentPresetCard.tsx create mode 100644 components/agents/AgentPresetDialog.tsx create mode 100644 components/agents/AgentPresetPanel.tsx create mode 100644 lib/db/migrations/0053_add_agent_presets.sql create mode 100644 lib/db/migrations/0054_add_agent_preset_slots.sql create mode 100644 lib/db/migrations/0055_add_agent_enabled_site_setting.sql create mode 100644 services/agents/index.ts create mode 100644 types/agent.ts 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 && ( <> +
+
+ +
+ 开启后,首页侧边栏显示“智能体”入口,用户可进入画布智能体模式;关闭后隐藏入口。 +
+
+ + setSiteSettings({ ...siteSettings, agentEnabled: checked }) + } + /> +
+
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) + + + + + 智能体模式 + + + {categories.map((category) => ( + + ))} +
+ ); +} diff --git a/components/agents/AgentExecutionSnapshotDialog.tsx b/components/agents/AgentExecutionSnapshotDialog.tsx new file mode 100644 index 000000000..d326875d5 --- /dev/null +++ b/components/agents/AgentExecutionSnapshotDialog.tsx @@ -0,0 +1,155 @@ +"use client"; + +import useSWR from "swr"; +import { Loader2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { fetcher } from "@/lib/utils"; +import type { AgentExecutionSnapshot } from "@/types/agent"; + +interface AgentExecutionSnapshotDialogProps { + snapshotId?: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AgentExecutionSnapshotDialog({ + snapshotId, + open, + onOpenChange, +}: AgentExecutionSnapshotDialogProps) { + const { data, isLoading } = useSWR<{ success?: boolean; data?: AgentExecutionSnapshot }>( + open && snapshotId ? `/api/agent-execution-snapshots/${snapshotId}` : null, + fetcher + ); + + const snapshot = data?.success ? data.data : null; + const uploadedImageUrls = Array.isArray(snapshot?.requestPayload?.uploadedImageUrls) + ? snapshot?.requestPayload?.uploadedImageUrls + .map((item) => String(item || "").trim()) + .filter(Boolean) + : []; + + return ( + + + + 执行快照 + + 查看本次智能体执行时使用的素材图、槽位提示词、参考图与模型。 + + + + + {isLoading ? ( +
+ +
+ ) : !snapshot ? ( +
+ 暂无快照详情 +
+ ) : ( +
+
+
+
智能体
+
{snapshot.presetName}
+
+ 类型:{snapshot.presetType === "video" ? "视频" : "生图"} +
+
+ 分类:{snapshot.categoryName || "未分类"} +
+
+
+
模型
+
+ {snapshot.resolvedModel?.displayName || + snapshot.defaultModel?.displayName || + "未记录"} +
+
+ 槽位:{snapshot.slotCount} 个 +
+
+ 素材图:{snapshot.requiredUploadCount} 张 +
+
+
+ +
+
本次使用的 3 张素材图
+ {uploadedImageUrls.length > 0 ? ( +
+ {uploadedImageUrls.map((url, index) => ( +
+ {`素材图 +
+ {index === 0 ? "产品图1" : index === 1 ? "产品图2" : "模特图"} +
+
+ ))} +
+ ) : ( +
未记录素材图
+ )} +
+ + {snapshot.userInputText ? ( +
+
用户补充提示
+
+ {snapshot.userInputText} +
+
+ ) : null} + +
+
槽位详情
+ {snapshot.slots.map((slot, index) => ( +
+
+
+ {slot.title || slot.uploadLabel || `槽位 ${slot.slotIndex + 1}`} +
+
序号 {slot.slotIndex + 1}
+
+
+ {slot.promptTemplate} +
+ {slot.referenceImageUrls.length > 0 ? ( +
+
参考图
+
+ {slot.referenceImageUrls.map((url, imageIndex) => ( + {`参考图 + ))} +
+
+ ) : null} +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/components/agents/AgentPresetCard.tsx b/components/agents/AgentPresetCard.tsx new file mode 100644 index 000000000..69586ecdb --- /dev/null +++ b/components/agents/AgentPresetCard.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { Copy, Pencil, Sparkles, Trash2, Video } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { AgentPreset } from "@/types/agent"; + +interface AgentPresetCardProps { + preset: AgentPreset; + selected?: boolean; + onSelect: (presetId: string) => void; + onFork?: (preset: AgentPreset) => void; + onEdit?: (preset: AgentPreset) => void; + onDelete?: (preset: AgentPreset) => void; +} + +export function AgentPresetCard({ + preset, + selected = false, + onSelect, + onFork, + onEdit, + onDelete, +}: AgentPresetCardProps) { + return ( +
+
+
+
+ {preset.type === "video" ? ( +
+
+ {preset.type === "video" ? "视频" : "生图"} + {preset.visibility === "public" ? "公共" : "私有"} + {preset.category?.name ? ( + {preset.category.name} + ) : null} +
+
+ {preset.avatarUrl || preset.referenceAssets?.[0]?.thumbnailUrl || preset.referenceAssets?.[0]?.originalUrl ? ( + {preset.name} + ) : null} +
+ +
+
模型:{preset.defaultModel?.displayName || "未设置"}
+
槽位:{preset.slotCount} 个
+
需上传:{preset.requiredUploadCount} 张
+
+ +
+ + {preset.visibility === "private" && onEdit ? ( + + ) : null} + {preset.visibility === "private" && onDelete ? ( + + ) : null} + {preset.visibility === "public" && onFork ? ( + + ) : null} +
+
+ ); +} diff --git a/components/agents/AgentPresetDialog.tsx b/components/agents/AgentPresetDialog.tsx new file mode 100644 index 000000000..4be4ff905 --- /dev/null +++ b/components/agents/AgentPresetDialog.tsx @@ -0,0 +1,632 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import useSWR from "swr"; +import { Loader2, Plus, Trash2, Upload } from "lucide-react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { fetcher } from "@/lib/utils"; +import { imageGenerationService } from "@/services/canvas"; +import { agentService } from "@/services/agents"; +import type { AgentCategory, AgentPreset, AgentPresetSlot } from "@/types/agent"; + +type DialogSlotState = AgentPresetSlot & { + localReferenceAssets: Array<{ + assetId: string; + url: string; + name?: string | null; + }>; +}; + +interface AgentPresetDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + categories: AgentCategory[]; + initialPreset?: AgentPreset | null; + onSaved?: (preset: AgentPreset) => void; +} + +const NEW_CATEGORY_VALUE = "__new__"; + +export function AgentPresetDialog({ + open, + onOpenChange, + categories, + initialPreset, + onSaved, +}: AgentPresetDialogProps) { + const { data: modelsData } = useSWR<{ + models?: Array<{ + id: string; + displayName: string; + supportsImageGen?: boolean; + providerType?: string; + }>; + }>("/api/models", fetcher); + + const [name, setName] = useState(""); + const [type, setType] = useState<"image" | "video">("image"); + const [visibility, setVisibility] = useState<"public" | "private">("private"); + const [categoryMode, setCategoryMode] = useState(""); + const [newCategoryName, setNewCategoryName] = useState(""); + const [defaultModelId, setDefaultModelId] = useState(""); + const [description, setDescription] = useState(""); + const [avatarAssetId, setAvatarAssetId] = useState(""); + const [avatarUrl, setAvatarUrl] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [uploadingSlotIndex, setUploadingSlotIndex] = useState(null); + const [avatarUploading, setAvatarUploading] = useState(false); + const [slots, setSlots] = useState([ + { + slotIndex: 0, + title: "第1张", + promptTemplate: "", + requireUpload: true, + uploadLabel: "第1张", + defaultParams: null, + referenceAssetIds: [], + localReferenceAssets: [], + }, + ]); + const fileInputRefs = useRef>([]); + const avatarInputRef = useRef(null); + + const videoProviderTypes = useMemo( + () => new Set(["volcano-video", "doubao-video", "qianwen-video", "fal-video", "ruxa-video"]), + [] + ); + const filteredModels = useMemo(() => { + const models = modelsData?.models || []; + if (type === "video") { + return models.filter((model) => videoProviderTypes.has(String(model.providerType || ""))); + } + return models.filter( + (model) => + model.supportsImageGen && !videoProviderTypes.has(String(model.providerType || "")) + ); + }, [modelsData?.models, type, videoProviderTypes]); + + useEffect(() => { + if (!open) return; + if (filteredModels.length === 0) { + setDefaultModelId(""); + return; + } + if (!filteredModels.some((model) => model.id === defaultModelId)) { + setDefaultModelId(filteredModels[0]?.id || ""); + } + }, [defaultModelId, filteredModels, open]); + + useEffect(() => { + if (!open) return; + setSlots((current) => + current.map((slot, index) => ({ + ...slot, + slotIndex: index, + title: slot.title || `第${index + 1}张`, + uploadLabel: slot.uploadLabel || `第${index + 1}张`, + })) + ); + }, [open]); + + const resetState = () => { + setName(""); + setType("image"); + setVisibility("private"); + setCategoryMode(""); + setNewCategoryName(""); + setDefaultModelId(""); + setDescription(""); + setAvatarAssetId(""); + setAvatarUrl(""); + setSlots([ + { + slotIndex: 0, + title: "第1张", + promptTemplate: "", + requireUpload: true, + uploadLabel: "第1张", + defaultParams: null, + referenceAssetIds: [], + localReferenceAssets: [], + }, + ]); + setUploadingSlotIndex(null); + }; + + useEffect(() => { + if (!open) return; + if (!initialPreset) { + resetState(); + return; + } + + setName(initialPreset.name || ""); + setType(initialPreset.type); + setVisibility(initialPreset.visibility); + setCategoryMode(initialPreset.category?.id || initialPreset.categoryId || ""); + setNewCategoryName(""); + setDefaultModelId(initialPreset.defaultModelId || ""); + setDescription(initialPreset.description || ""); + setAvatarAssetId(initialPreset.avatarAssetId || ""); + setAvatarUrl(initialPreset.avatarUrl || ""); + setSlots( + (initialPreset.slots || []).map((slot, index) => ({ + slotIndex: index, + title: slot.title || `第${index + 1}张`, + promptTemplate: slot.promptTemplate || "", + requireUpload: slot.requireUpload !== false, + uploadLabel: slot.uploadLabel || `第${index + 1}张`, + defaultParams: slot.defaultParams || null, + referenceAssetIds: slot.referenceAssetIds || [], + localReferenceAssets: (slot.referenceAssets || []).map((asset) => ({ + assetId: asset.assetId, + url: asset.thumbnailUrl || asset.previewUrl || asset.originalUrl, + name: asset.displayName, + })), + })) + ); + }, [initialPreset, open]); + + const handleSlotChange = (slotIndex: number, updates: Partial) => { + setSlots((current) => + current.map((slot, index) => + index === slotIndex ? { ...slot, ...updates, slotIndex: index } : slot + ) + ); + }; + + const handleAddSlot = () => { + setSlots((current) => [ + ...current, + { + slotIndex: current.length, + title: `第${current.length + 1}张`, + promptTemplate: "", + requireUpload: true, + uploadLabel: `第${current.length + 1}张`, + defaultParams: null, + referenceAssetIds: [], + localReferenceAssets: [], + }, + ]); + }; + + const handleRemoveSlot = (slotIndex: number) => { + setSlots((current) => + current + .filter((_, index) => index !== slotIndex) + .map((slot, index) => ({ + ...slot, + slotIndex: index, + title: slot.title || `第${index + 1}张`, + uploadLabel: slot.uploadLabel || `第${index + 1}张`, + })) + ); + }; + + const handleAvatarUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setAvatarUploading(true); + try { + const result = await imageGenerationService.uploadImage(file); + if (!result.success || !result.data?.assetId || !result.data.url) { + throw new Error(result.error || "头像上传失败"); + } + setAvatarAssetId(result.data.assetId); + setAvatarUrl(result.data.url); + toast.success("头像上传成功"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "头像上传失败"); + } finally { + setAvatarUploading(false); + event.target.value = ""; + } + }; + + const handleUploadReferenceImages = async ( + slotIndex: number, + event: React.ChangeEvent + ) => { + const files = Array.from(event.target.files || []); + if (files.length === 0) return; + + setUploadingSlotIndex(slotIndex); + try { + const uploadedAssets: Array<{ assetId: string; url: string; name?: string | null }> = []; + + for (const file of files) { + const result = await imageGenerationService.uploadImage(file); + if (!result.success || !result.data?.assetId || !result.data.url) { + throw new Error(result.error || "参考图上传失败"); + } + uploadedAssets.push({ + assetId: result.data.assetId, + url: result.data.url, + name: file.name, + }); + } + + setSlots((current) => + current.map((slot, index) => { + if (index !== slotIndex) return slot; + const nextAssets = [...slot.localReferenceAssets, ...uploadedAssets]; + return { + ...slot, + referenceAssetIds: nextAssets.map((item) => item.assetId), + localReferenceAssets: nextAssets, + }; + }) + ); + + toast.success( + uploadedAssets.length === 1 + ? "参考图上传成功" + : `已上传 ${uploadedAssets.length} 张参考图` + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : "参考图上传失败"); + } finally { + setUploadingSlotIndex(null); + event.target.value = ""; + } + }; + + const handleCreate = async () => { + if (!name.trim()) { + toast.error("请输入智能体名称"); + return; + } + if (!defaultModelId) { + toast.error("请选择对应模型"); + return; + } + if (visibility === "public" && !categoryMode && !newCategoryName.trim()) { + toast.error("公开智能体必须选择分类"); + return; + } + if (slots.some((slot) => !slot.promptTemplate.trim())) { + toast.error("每个槽位都需要填写提示词"); + return; + } + + setIsSubmitting(true); + try { + const payload = { + name: name.trim(), + type, + visibility, + categoryId: + categoryMode && categoryMode !== NEW_CATEGORY_VALUE ? categoryMode : undefined, + newCategoryName: + categoryMode === NEW_CATEGORY_VALUE ? newCategoryName.trim() : undefined, + description: description.trim() || undefined, + defaultModelId, + slots: slots.map((slot, index) => ({ + slotIndex: index, + title: slot.title || null, + promptTemplate: slot.promptTemplate.trim(), + requireUpload: slot.requireUpload !== false, + uploadLabel: slot.uploadLabel || null, + defaultParams: slot.defaultParams || undefined, + referenceAssetIds: slot.referenceAssetIds || [], + })), + avatarAssetId: avatarAssetId || undefined, + } as const; + + const result = initialPreset + ? await agentService.updatePreset(initialPreset.id, payload) + : await agentService.createPreset(payload); + + if (!result.success || !result.data) { + throw new Error(result.error || (initialPreset ? "更新智能体失败" : "创建智能体失败")); + } + + toast.success(initialPreset ? "智能体更新成功" : "智能体创建成功"); + onSaved?.(result.data); + onOpenChange(false); + resetState(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "创建智能体失败"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + { + onOpenChange(nextOpen); + if (!nextOpen) resetState(); + }} + > + + + {initialPreset ? "编辑智能体" : "新建智能体"} + 配置模型、槽位提示词和参考图。 + + + +
+
+
+
头像
+
+ {avatarUrl ? ( + 智能体头像 + ) : ( +
+ 未上传 +
+ )} + +
+
+
+
智能体名称
+ setName(event.target.value)} /> +
+
+ +
+
+
可见性
+ +
+
+
类型
+ +
+
+
分类
+ +
+
+
对应模型
+ +
+
+ + {categoryMode === NEW_CATEGORY_VALUE ? ( +
+
新分类名称
+ setNewCategoryName(event.target.value)} + /> +
+ ) : null} + +
+
描述
+