Add canvas agent mode and management
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-10 19:56:25 +08:00
parent 1fa564ad90
commit 4b820f8ff4
32 changed files with 5382 additions and 150 deletions

View File

@ -229,6 +229,7 @@ type SiteSettings = {
tutorialEnabled: boolean; tutorialEnabled: boolean;
rankingEnabled: boolean; rankingEnabled: boolean;
canvasEnabled: boolean; canvasEnabled: boolean;
agentEnabled: boolean;
realtimeCanvasEnabled: boolean; realtimeCanvasEnabled: boolean;
aiDetailGenerationEnabled: boolean; aiDetailGenerationEnabled: boolean;
canvasDefaultModelId: string | null; canvasDefaultModelId: string | null;
@ -618,6 +619,7 @@ export function AdminSettingsClient() {
tutorialEnabled: false, tutorialEnabled: false,
rankingEnabled: true, rankingEnabled: true,
canvasEnabled: true, canvasEnabled: true,
agentEnabled: true,
realtimeCanvasEnabled: true, realtimeCanvasEnabled: true,
aiDetailGenerationEnabled: false, aiDetailGenerationEnabled: false,
canvasDefaultModelId: null, canvasDefaultModelId: null,
@ -689,6 +691,7 @@ export function AdminSettingsClient() {
tutorialEnabled: siteSettingsData.tutorialEnabled ?? false, tutorialEnabled: siteSettingsData.tutorialEnabled ?? false,
rankingEnabled: siteSettingsData.rankingEnabled ?? true, rankingEnabled: siteSettingsData.rankingEnabled ?? true,
canvasEnabled: siteSettingsData.canvasEnabled ?? true, canvasEnabled: siteSettingsData.canvasEnabled ?? true,
agentEnabled: (siteSettingsData as any).agentEnabled ?? true,
realtimeCanvasEnabled: (siteSettingsData as any).realtimeCanvasEnabled ?? true, realtimeCanvasEnabled: (siteSettingsData as any).realtimeCanvasEnabled ?? true,
aiDetailGenerationEnabled: siteSettingsData.aiDetailGenerationEnabled ?? false, aiDetailGenerationEnabled: siteSettingsData.aiDetailGenerationEnabled ?? false,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null, canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
@ -5237,6 +5240,22 @@ export function AdminSettingsClient() {
</div> </div>
{siteSettings.canvasEnabled && ( {siteSettings.canvasEnabled && (
<> <>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="agentEnabled">智能体功能</Label>
<div className="text-xs text-muted-foreground">
开启后,首页侧边栏显示“智能体”入口,用户可进入画布智能体模式;关闭后隐藏入口。
</div>
</div>
<Switch
id="agentEnabled"
checked={siteSettings.agentEnabled}
onCheckedChange={(checked) =>
setSiteSettings({ ...siteSettings, agentEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3"> <div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="realtimeCanvasEnabled">实时画布功能</Label> <Label htmlFor="realtimeCanvasEnabled">实时画布功能</Label>

View File

@ -8,6 +8,7 @@ import {
stepCountIs, stepCountIs,
streamText, streamText,
} from "ai"; } from "ai";
import { eq, sql } from "drizzle-orm";
import { unstable_cache as cache } from "next/cache"; import { unstable_cache as cache } from "next/cache";
import { after, NextResponse } from "next/server"; import { after, NextResponse } from "next/server";
import { import {
@ -71,8 +72,10 @@ import {
getAiProviderById, getAiProviderById,
checkUserImageQuota, checkUserImageQuota,
consumeUserPoints, consumeUserPoints,
createAgentPresetExecutionSnapshot,
createMessageTargetResolution, createMessageTargetResolution,
createMediaAssetRelation, createMediaAssetRelation,
getAccessibleAgentPresetById,
getConversationWorkingStateByChatId, getConversationWorkingStateByChatId,
getMediaAssetsByUserAndUrls, getMediaAssetsByUserAndUrls,
upsertConversationWorkingState, upsertConversationWorkingState,
@ -80,7 +83,11 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import type { DBMessage } from "@/lib/db/schema"; 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 { generateCanvasImage } from "@/lib/canvas/generate-image";
import { resolveChatMediaInput } from "@/lib/ai/context-memory/media-input"; import { resolveChatMediaInput } from "@/lib/ai/context-memory/media-input";
import { resolveTargetAssets } from "@/lib/ai/context-memory/target-resolution"; import { resolveTargetAssets } from "@/lib/ai/context-memory/target-resolution";
@ -415,6 +422,11 @@ function writeCanvasMediaChunk(
videoUrls?: string[]; videoUrls?: string[];
prompt?: string | null; prompt?: string | null;
sourceImageUrls?: string[] | null; sourceImageUrls?: string[] | null;
agentPresetId?: string | null;
agentPresetName?: string | null;
agentCategoryId?: string | null;
agentCategoryName?: string | null;
agentExecutionSnapshotId?: string | null;
}, },
label: string, label: string,
) { ) {
@ -454,6 +466,12 @@ function writeCanvasMediaChunk(
.map((url) => String(url || "").trim()) .map((url) => String(url || "").trim())
.filter((url) => /^https?:\/\//i.test(url)) .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, label,
); );
@ -2578,6 +2596,70 @@ async function saveGeneratedImages(
} }
} }
type AgentPresetMessageImagePart = {
type: "file";
url: string;
name: string;
mediaType: string;
assetId?: string;
};
function extractCurrentMessageImageParts(
parts: Array<Record<string, any>>,
): 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 = 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; /^[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 { const {
id, id,
message, message,
agentPresetId,
selectedChatModel, selectedChatModel,
selectedVisibilityType, selectedVisibilityType,
hiddenPrompt, hiddenPrompt,
@ -2790,6 +2873,7 @@ export async function POST(request: Request) {
}: { }: {
id: string; id: string;
message: ChatMessage; message: ChatMessage;
agentPresetId?: string;
selectedChatModel: ChatModel["id"]; selectedChatModel: ChatModel["id"];
selectedVisibilityType: VisibilityType; selectedVisibilityType: VisibilityType;
hiddenPrompt?: string; hiddenPrompt?: string;
@ -4403,6 +4487,505 @@ export async function POST(request: Request) {
return createSseResponse(clarificationStream); 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,
"<!-- IMAGE_GENERATION_START -->",
"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<string, any>;
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<string, any>;
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,
"<!-- IMAGE_GENERATION_END -->",
"agent-preset:image:end",
);
}
},
});
return createSseResponse(agentSequenceStream);
}
const directAgentExecution = shouldRunAgentConversation const directAgentExecution = shouldRunAgentConversation
? resolveDirectAgentToolExecution(smartAutoExecution, smartAutoPlan) ? resolveDirectAgentToolExecution(smartAutoExecution, smartAutoPlan)
: null; : null;

View File

@ -80,6 +80,7 @@ export const postRequestBodySchema = z.object({
}), }),
hiddenPrompt: z.string().max(4000).optional(), hiddenPrompt: z.string().max(4000).optional(),
autoContext: z.string().max(4000).optional(), autoContext: z.string().max(4000).optional(),
agentPresetId: z.string().uuid().optional(),
// 支持预定义模型名称或数据库中的模型 UUID // 支持预定义模型名称或数据库中的模型 UUID
selectedChatModel: z.string().min(1).max(128), selectedChatModel: z.string().min(1).max(128),
selectedVisibilityType: z.enum(["public", "private"]), selectedVisibilityType: z.enum(["public", "private"]),

View File

@ -11,11 +11,16 @@ export default function CanvasRedirectPage() {
useEffect(() => { useEffect(() => {
const shouldCreateNew = searchParams.get("new") === "1"; 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 () => { const openCanvas = async () => {
if (shouldCreateNew) { if (shouldCreateNew) {
const newId = crypto.randomUUID(); const newId = crypto.randomUUID();
router.replace(`/canvas/${newId}`); router.replace(buildCanvasUrl(newId));
return; return;
} }
@ -32,7 +37,7 @@ export default function CanvasRedirectPage() {
}); });
const latest = sorted[0]; const latest = sorted[0];
if (latest?.id) { if (latest?.id) {
router.replace(`/canvas/${latest.id}`); router.replace(buildCanvasUrl(latest.id));
return; return;
} }
} }
@ -42,7 +47,7 @@ export default function CanvasRedirectPage() {
} }
const newId = crypto.randomUUID(); const newId = crypto.randomUUID();
router.replace(`/canvas/${newId}`); router.replace(buildCanvasUrl(newId));
}; };
void openCanvas(); void openCanvas();

View File

@ -71,6 +71,7 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) {
}))} }))}
tutorialEnabled={siteSettings.tutorialEnabled ?? false} tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true} rankingEnabled={siteSettings.rankingEnabled ?? true}
agentEnabled={(siteSettings as any).agentEnabled ?? true}
> >
{children} {children}
</ChatShell> </ChatShell>

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

154
app/api/agents/route.ts Normal file
View File

@ -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 }
);
}
}

View File

@ -336,6 +336,7 @@ export async function GET() {
tutorialEnabled: false, tutorialEnabled: false,
rankingEnabled: true, rankingEnabled: true,
canvasEnabled: true, canvasEnabled: true,
agentEnabled: true,
realtimeCanvasEnabled: true, realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true, realtimeCanvasServiceEnabled: true,
storageFailoverConfig, storageFailoverConfig,
@ -424,6 +425,7 @@ const updateSchema = z.object({
tutorialEnabled: z.boolean().optional(), tutorialEnabled: z.boolean().optional(),
rankingEnabled: z.boolean().optional(), rankingEnabled: z.boolean().optional(),
canvasEnabled: z.boolean().optional(), canvasEnabled: z.boolean().optional(),
agentEnabled: z.boolean().optional(),
realtimeCanvasEnabled: z.boolean().optional(), realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(), aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(), canvasDefaultModelId: z.string().nullable().optional(),
@ -518,6 +520,7 @@ export async function PUT(request: NextRequest) {
.update(siteSettings) .update(siteSettings)
.set({ .set({
...siteSettingsPayload, ...siteSettingsPayload,
agentEnabled: siteSettingsPayload.agentEnabled ?? existing.agentEnabled ?? true,
updatedAt: new Date(), updatedAt: new Date(),
updatedBy: session.user.id, updatedBy: session.user.id,
}) })
@ -881,6 +884,7 @@ export async function PUT(request: NextRequest) {
tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false, tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false,
rankingEnabled: siteSettingsPayload.rankingEnabled ?? true, rankingEnabled: siteSettingsPayload.rankingEnabled ?? true,
canvasEnabled: siteSettingsPayload.canvasEnabled ?? true, canvasEnabled: siteSettingsPayload.canvasEnabled ?? true,
agentEnabled: siteSettingsPayload.agentEnabled ?? true,
realtimeCanvasEnabled: siteSettingsPayload.realtimeCanvasEnabled ?? true, realtimeCanvasEnabled: siteSettingsPayload.realtimeCanvasEnabled ?? true,
canvasDefaultModelId: siteSettingsPayload.canvasDefaultModelId, canvasDefaultModelId: siteSettingsPayload.canvasDefaultModelId,
canvasAutoModeDefaultModelId: siteSettingsPayload.canvasAutoModeDefaultModelId, canvasAutoModeDefaultModelId: siteSettingsPayload.canvasAutoModeDefaultModelId,

View File

@ -1,9 +1,10 @@
"use client"; "use client";
import { useEffect, useState, useCallback, use, useRef } from "react"; 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 useSWR from "swr";
import { import {
AlertCircle,
Save, Save,
Undo2, Undo2,
Redo2, Redo2,
@ -56,6 +57,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCanvasStore, canvasSelectors } from "@/store/canvas"; import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import { canvasService, imageGenerationService } from "@/services/canvas"; import { canvasService, imageGenerationService } from "@/services/canvas";
import { AgentPresetPanel } from "@/components/agents/AgentPresetPanel";
import { CanvasWorkspace } from "@/components/canvas"; import { CanvasWorkspace } from "@/components/canvas";
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel"; import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel"; import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
@ -88,8 +90,10 @@ const FLOATING_BOTTOM_INSET = 20;
export default function CanvasEditorPage({ params }: PageProps) { export default function CanvasEditorPage({ params }: PageProps) {
// 使用 useParams 确保客户端导航时能正确获取更新的 id // 使用 useParams 确保客户端导航时能正确获取更新的 id
const routeParams = useParams(); const routeParams = useParams();
const searchParams = useSearchParams();
const id = (routeParams?.id as string) || use(params).id; const id = (routeParams?.id as string) || use(params).id;
const router = useRouter(); const router = useRouter();
const mode = searchParams.get("mode");
const goHome = useCallback(() => { const goHome = useCallback(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.location.assign("/"); window.location.assign("/");
@ -138,6 +142,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
siteName?: string; siteName?: string;
siteLogo?: string | null; siteLogo?: string | null;
siteLogoDark?: string | null; siteLogoDark?: string | null;
agentEnabled?: boolean;
realtimeCanvasEnabled?: boolean; realtimeCanvasEnabled?: boolean;
realtimeCanvasServiceEnabled?: boolean; realtimeCanvasServiceEnabled?: boolean;
}>("/api/site-settings", fetcher); }>("/api/site-settings", fetcher);
@ -148,6 +153,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [aspectRatio, setAspectRatio] = useState("1:1"); const [aspectRatio, setAspectRatio] = useState("1:1");
const [imageCount, setImageCount] = useState(1); const [imageCount, setImageCount] = useState(1);
const previousNodeIdsRef = useRef<Set<string>>(new Set()); const previousNodeIdsRef = useRef<Set<string>>(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 const selectedImageNode = selectedNodeIds.length === 1
@ -155,12 +165,63 @@ export default function CanvasEditorPage({ params }: PageProps) {
: null; : null;
const resolvedPanelWidth = viewportWidth > 0 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; : FLOATING_PANEL_WIDTH;
const agentPanelWidth = isAgentMode ? resolvedPanelWidth : 0;
const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0; const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0;
const floatingToolbarOffset = isPanelOpen ? -(panelOcclusionRight / 2) : 0; const floatingToolbarOffset = isPanelOpen ? -(panelOcclusionRight / 2) : 0;
const compactControlsLeft = FLOATING_UI_GAP; const compactControlsLeft = FLOATING_UI_GAP;
const updateCanvasSearchParams = useCallback(
(updates: Record<string, string | null>) => {
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(() => { useEffect(() => {
console.log('[Canvas] Loading project:', id); console.log('[Canvas] Loading project:', id);
@ -202,6 +263,13 @@ export default function CanvasEditorPage({ params }: PageProps) {
return () => window.removeEventListener("resize", updateViewportWidth); return () => window.removeEventListener("resize", updateViewportWidth);
}, []); }, []);
useEffect(() => {
if (isAgentMode) {
setIsPanelOpen(true);
setPanelMode("chat");
}
}, [isAgentMode]);
const persistCanvasState = useCallback( const persistCanvasState = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => { async (reason: "autosave" | "delete" | "beforeunload") => {
const canvasData = getCanvasState(); const canvasData = getCanvasState();
@ -669,6 +737,16 @@ export default function CanvasEditorPage({ params }: PageProps) {
</Button> </Button>
</div> </div>
{/* Agent Disabled Notice */}
{requestedAgentMode && !isAgentFeatureEnabled ? (
<div className="absolute top-4 left-1/2 z-20 -translate-x-1/2">
<div className="inline-flex items-center gap-2 rounded-full border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/80 dark:text-amber-200">
<AlertCircle className="h-4 w-4" />
<span>智能体功能未开启,请联系管理员在系统设置中开启。</span>
</div>
</div>
) : null}
{/* 2. Floating Actions (Top Right) */} {/* 2. Floating Actions (Top Right) */}
{!isPanelOpen && ( {!isPanelOpen && (
<div className="absolute z-20" style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}> <div className="absolute z-20" style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}>
@ -684,6 +762,26 @@ export default function CanvasEditorPage({ params }: PageProps) {
)} )}
{/* 4. Floating Sidebar (Right) */} {/* 4. Floating Sidebar (Right) */}
{isAgentMode ? (
<div
className="absolute z-10 transition-all duration-300 ease-in-out"
style={{
top: DOCKED_PANEL_INSET,
left: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET,
width: agentPanelWidth,
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET}px)`,
}}
>
<div className="h-full w-full overflow-hidden border-r bg-white dark:bg-zinc-950">
<AgentPresetPanel
selectedPresetId={selectedAgentPresetId}
onSelectPreset={handleSelectAgentPreset}
/>
</div>
</div>
) : null}
<div className={cn( <div className={cn(
"absolute z-10 transition-all duration-300 ease-in-out", "absolute z-10 transition-all duration-300 ease-in-out",
isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none" isPanelOpen ? "translate-x-0 opacity-100" : "translate-x-[120%] opacity-0 pointer-events-none"
@ -704,6 +802,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
chatId={`canvas-${id}`} chatId={`canvas-${id}`}
onClose={() => setIsPanelOpen(false)} onClose={() => setIsPanelOpen(false)}
onOpenFileList={() => setPanelMode('fileList')} onOpenFileList={() => setPanelMode('fileList')}
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
/> />
</div> </div>
) : ( ) : (
@ -754,6 +853,23 @@ export default function CanvasEditorPage({ params }: PageProps) {
<TooltipContent side="top">平移 (H)</TooltipContent> <TooltipContent side="top">平移 (H)</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isAgentMode ? "secondary" : "ghost"}
size="icon"
onClick={handleToggleAgentMode}
className={cn(
"h-9 w-9 rounded-xl",
isAgentMode && "bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
<Wand2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">智能体模式</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button

View File

@ -0,0 +1,54 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { AgentCategory } from "@/types/agent";
interface AgentCategoryTabsProps {
categories: AgentCategory[];
selectedCategoryId?: string | null;
onSelectCategory: (categoryId: string | null) => void;
}
export function AgentCategoryTabs({
categories,
selectedCategoryId,
onSelectCategory,
}: AgentCategoryTabsProps) {
return (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => onSelectCategory(null)}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
!selectedCategoryId
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background text-muted-foreground hover:bg-muted/50"
)}
>
全部
</button>
{categories.map((category) => (
<button
key={category.id}
type="button"
onClick={() => onSelectCategory(category.id)}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
selectedCategoryId === category.id
? "border-primary bg-primary/10 text-primary"
: "border-border bg-background text-muted-foreground hover:bg-muted/50"
)}
>
<span>{category.name}</span>
{category.scope === "global" ? (
<Badge variant="outline" className="ml-2 px-1.5 py-0 text-[10px]">
全局
</Badge>
) : null}
</button>
))}
</div>
);
}

View File

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl bg-background">
<DialogHeader>
<DialogTitle>执行快照</DialogTitle>
<DialogDescription>
查看本次智能体执行时使用的素材图、槽位提示词、参考图与模型。
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh] pr-3">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : !snapshot ? (
<div className="rounded-xl border border-dashed p-6 text-sm text-muted-foreground">
暂无快照详情
</div>
) : (
<div className="space-y-5">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-xl border p-3">
<div className="text-xs text-muted-foreground">智能体</div>
<div className="mt-1 font-medium">{snapshot.presetName}</div>
<div className="mt-1 text-sm text-muted-foreground">
类型:{snapshot.presetType === "video" ? "视频" : "生图"}
</div>
<div className="text-sm text-muted-foreground">
分类:{snapshot.categoryName || "未分类"}
</div>
</div>
<div className="rounded-xl border p-3">
<div className="text-xs text-muted-foreground">模型</div>
<div className="mt-1 font-medium">
{snapshot.resolvedModel?.displayName ||
snapshot.defaultModel?.displayName ||
"未记录"}
</div>
<div className="mt-1 text-sm text-muted-foreground">
槽位:{snapshot.slotCount} 个
</div>
<div className="text-sm text-muted-foreground">
素材图:{snapshot.requiredUploadCount} 张
</div>
</div>
</div>
<div className="rounded-xl border p-3">
<div className="mb-2 text-sm font-medium">本次使用的 3 张素材图</div>
{uploadedImageUrls.length > 0 ? (
<div className="flex flex-wrap gap-3">
{uploadedImageUrls.map((url, index) => (
<div key={`${url}-${index}`} className="space-y-1">
<img
src={url}
alt={`素材图 ${index + 1}`}
className="h-24 w-24 rounded-lg border object-cover"
/>
<div className="text-center text-xs text-muted-foreground">
{index === 0 ? "产品图1" : index === 1 ? "产品图2" : "模特图"}
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground">未记录素材图</div>
)}
</div>
{snapshot.userInputText ? (
<div className="rounded-xl border p-3">
<div className="mb-2 text-sm font-medium">用户补充提示</div>
<div className="whitespace-pre-wrap text-sm text-muted-foreground">
{snapshot.userInputText}
</div>
</div>
) : null}
<div className="space-y-3">
<div className="text-sm font-medium">槽位详情</div>
{snapshot.slots.map((slot, index) => (
<div key={`snapshot-slot-${slot.slotIndex}-${index}`} className="rounded-xl border p-3">
<div className="mb-2 flex items-center justify-between">
<div className="font-medium">
{slot.title || slot.uploadLabel || `槽位 ${slot.slotIndex + 1}`}
</div>
<div className="text-xs text-muted-foreground">序号 {slot.slotIndex + 1}</div>
</div>
<div className="rounded-lg bg-muted/40 p-3 text-sm whitespace-pre-wrap">
{slot.promptTemplate}
</div>
{slot.referenceImageUrls.length > 0 ? (
<div className="mt-3">
<div className="mb-2 text-xs text-muted-foreground">参考图</div>
<div className="flex flex-wrap gap-2">
{slot.referenceImageUrls.map((url, imageIndex) => (
<img
key={`${url}-${imageIndex}`}
src={url}
alt={`参考图 ${imageIndex + 1}`}
className="h-16 w-16 rounded-lg border object-cover"
/>
))}
</div>
</div>
) : null}
</div>
))}
</div>
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}

View File

@ -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 (
<div
className={cn(
"rounded-2xl border p-3 transition-colors",
selected ? "border-primary bg-primary/5" : "border-border bg-background hover:bg-muted/30"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
{preset.type === "video" ? (
<Video className="h-4 w-4 text-violet-500" />
) : (
<Sparkles className="h-4 w-4 text-amber-500" />
)}
<h4 className="truncate text-sm font-semibold">{preset.name}</h4>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge variant="outline">{preset.type === "video" ? "视频" : "生图"}</Badge>
<Badge variant="outline">{preset.visibility === "public" ? "公共" : "私有"}</Badge>
{preset.category?.name ? (
<Badge variant="outline">{preset.category.name}</Badge>
) : null}
</div>
</div>
{preset.avatarUrl || preset.referenceAssets?.[0]?.thumbnailUrl || preset.referenceAssets?.[0]?.originalUrl ? (
<img
src={
preset.avatarUrl ||
preset.referenceAssets?.[0]?.thumbnailUrl ||
preset.referenceAssets?.[0]?.originalUrl ||
""
}
alt={preset.name}
className="h-12 w-12 rounded-xl border object-cover"
/>
) : null}
</div>
<div className="mt-3 space-y-1 text-xs text-muted-foreground">
<div>模型:{preset.defaultModel?.displayName || "未设置"}</div>
<div>槽位:{preset.slotCount} 个</div>
<div>需上传:{preset.requiredUploadCount} 张</div>
</div>
<div className="mt-3 flex items-center gap-2">
<Button size="sm" className="h-8 flex-1" onClick={() => onSelect(preset.id)}>
{selected ? "已选中" : "使用"}
</Button>
{preset.visibility === "private" && onEdit ? (
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => onEdit(preset)}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
编辑
</Button>
) : null}
{preset.visibility === "private" && onDelete ? (
<Button
size="sm"
variant="outline"
className="h-8 text-destructive hover:text-destructive"
onClick={() => onDelete(preset)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
删除
</Button>
) : null}
{preset.visibility === "public" && onFork ? (
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => onFork(preset)}
>
<Copy className="mr-1 h-3.5 w-3.5" />
另存
</Button>
) : null}
</div>
</div>
);
}

View File

@ -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<string>("");
const [newCategoryName, setNewCategoryName] = useState("");
const [defaultModelId, setDefaultModelId] = useState("");
const [description, setDescription] = useState("");
const [avatarAssetId, setAvatarAssetId] = useState<string>("");
const [avatarUrl, setAvatarUrl] = useState<string>("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [uploadingSlotIndex, setUploadingSlotIndex] = useState<number | null>(null);
const [avatarUploading, setAvatarUploading] = useState(false);
const [slots, setSlots] = useState<DialogSlotState[]>([
{
slotIndex: 0,
title: "第1张",
promptTemplate: "",
requireUpload: true,
uploadLabel: "第1张",
defaultParams: null,
referenceAssetIds: [],
localReferenceAssets: [],
},
]);
const fileInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const avatarInputRef = useRef<HTMLInputElement | null>(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<DialogSlotState>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>
) => {
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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) resetState();
}}
>
<DialogContent className="max-w-3xl bg-background">
<DialogHeader>
<DialogTitle>{initialPreset ? "编辑智能体" : "新建智能体"}</DialogTitle>
<DialogDescription>配置模型、槽位提示词和参考图。</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[70vh] pr-3">
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">头像</div>
<div className="flex items-center gap-3">
{avatarUrl ? (
<img
src={avatarUrl}
alt="智能体头像"
className="h-14 w-14 rounded-2xl border object-cover"
/>
) : (
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border bg-muted text-xs text-muted-foreground">
未上传
</div>
)}
<label className="inline-flex">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
void handleAvatarUpload(event);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => avatarInputRef.current?.click()}
disabled={avatarUploading}
>
{avatarUploading ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-1 h-4 w-4" />
)}
上传头像
</Button>
</label>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">智能体名称</div>
<Input value={name} onChange={(event) => setName(event.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="space-y-2">
<div className="text-sm font-medium">可见性</div>
<Select
value={visibility}
onValueChange={(value: "public" | "private") => setVisibility(value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">私有</SelectItem>
<SelectItem value="public">公开</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">类型</div>
<Select
value={type}
onValueChange={(value: "image" | "video") => {
setType(value);
setDefaultModelId("");
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="image">生图</SelectItem>
<SelectItem value="video">视频</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">分类</div>
<Select value={categoryMode} onValueChange={setCategoryMode}>
<SelectTrigger>
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
<SelectItem value={NEW_CATEGORY_VALUE}>新建分类</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">对应模型</div>
<Select value={defaultModelId} onValueChange={setDefaultModelId}>
<SelectTrigger>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{filteredModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{categoryMode === NEW_CATEGORY_VALUE ? (
<div className="space-y-2">
<div className="text-sm font-medium">新分类名称</div>
<Input
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
/>
</div>
) : null}
<div className="space-y-2">
<div className="text-sm font-medium">描述</div>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">槽位配置</div>
<div className="text-xs text-muted-foreground">
当前共 {slots.length} 个槽位,使用时固定上传 3 张素材图:产品图1、产品图2、模特图
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleAddSlot}>
<Plus className="mr-1 h-4 w-4" />
新增槽位
</Button>
</div>
<div className="space-y-3">
{slots.map((slot, index) => (
<div key={`slot-${index}`} className="rounded-2xl border p-3">
<div className="mb-3 flex items-center justify-between">
<div className="font-medium">槽位 {index + 1}</div>
{slots.length > 1 ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveSlot(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">槽位名称</div>
<Input
value={slot.title || ""}
onChange={(event) =>
handleSlotChange(index, { title: event.target.value })
}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">上传提示语</div>
<Input
value={slot.uploadLabel || ""}
onChange={(event) =>
handleSlotChange(index, { uploadLabel: event.target.value })
}
/>
</div>
</div>
<div className="mt-3 space-y-2">
<div className="text-sm font-medium">提示词</div>
<Textarea
rows={4}
value={slot.promptTemplate}
onChange={(event) =>
handleSlotChange(index, { promptTemplate: event.target.value })
}
/>
</div>
<div className="mt-3">
<input
ref={(element) => {
fileInputRefs.current[index] = element;
}}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void handleUploadReferenceImages(index, event);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRefs.current[index]?.click()}
disabled={uploadingSlotIndex === index}
>
{uploadingSlotIndex === index ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-1 h-4 w-4" />
)}
上传参考图
</Button>
{slot.localReferenceAssets.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{slot.localReferenceAssets.map((asset, assetIndex) => (
<div key={`${asset.assetId}-${assetIndex}`} className="relative">
<img
src={asset.url}
alt={asset.name || `参考图 ${assetIndex + 1}`}
className="h-16 w-16 rounded-lg border object-cover"
/>
</div>
))}
</div>
) : (
<div className="mt-2 text-xs text-muted-foreground">未上传参考图</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleCreate} disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{initialPreset ? "保存修改" : "创建智能体"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,208 @@
"use client";
import { useMemo, useState } from "react";
import useSWR from "swr";
import { Loader2, Plus, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { agentService } from "@/services/agents";
import type { AgentPreset } from "@/types/agent";
import { AgentCategoryTabs } from "@/components/agents/AgentCategoryTabs";
import { AgentPresetCard } from "@/components/agents/AgentPresetCard";
import { AgentPresetDialog } from "@/components/agents/AgentPresetDialog";
interface AgentPresetPanelProps {
selectedPresetId?: string | null;
onSelectPreset: (presetId: string | null) => void;
}
export function AgentPresetPanel({
selectedPresetId,
onSelectPreset,
}: AgentPresetPanelProps) {
const [keyword, setKeyword] = useState("");
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingPreset, setEditingPreset] = useState<AgentPreset | null>(null);
const { data, isLoading, mutate } = useSWR(
["agent-presets", selectedCategoryId],
async () => {
const result = await agentService.getPresets({
scope: "all",
sort: "latest",
});
if (!result.success || !result.data) {
throw new Error(result.error || "获取智能体失败");
}
return result.data;
}
);
const publicCategories = useMemo(
() => (data?.categories || []).filter((category) => category.scope !== "private"),
[data?.categories]
);
const filteredPublicPresets = useMemo(() => {
return (data?.public || []).filter((preset) => {
const matchedCategory = !selectedCategoryId || preset.category?.id === selectedCategoryId;
const matchedKeyword =
!keyword.trim() ||
preset.name.toLowerCase().includes(keyword.trim().toLowerCase()) ||
String(preset.category?.name || "")
.toLowerCase()
.includes(keyword.trim().toLowerCase());
return matchedCategory && matchedKeyword;
});
}, [data?.public, keyword, selectedCategoryId]);
const filteredPrivatePresets = useMemo(() => {
return (data?.private || []).filter((preset) => {
const matchedKeyword =
!keyword.trim() ||
preset.name.toLowerCase().includes(keyword.trim().toLowerCase()) ||
String(preset.category?.name || "")
.toLowerCase()
.includes(keyword.trim().toLowerCase());
return matchedKeyword;
});
}, [data?.private, keyword]);
const handleForkPreset = async (preset: AgentPreset) => {
const result = await agentService.forkPreset(preset.id, `${preset.name}(副本)`);
if (!result.success || !result.data) {
toast.error(result.error || "复制智能体失败");
return;
}
toast.success("已另存为我的智能体");
await mutate();
onSelectPreset(result.data.id);
};
const handleDeletePreset = async (preset: AgentPreset) => {
if (!window.confirm(`确定删除智能体「${preset.name}」吗?`)) {
return;
}
const result = await agentService.deletePreset(preset.id);
if (!result.success) {
toast.error(result.error || "删除智能体失败");
return;
}
toast.success("智能体已删除");
if (selectedPresetId === preset.id) {
onSelectPreset(null);
}
await mutate();
};
return (
<>
<div className="flex h-full flex-col border-r bg-white dark:bg-zinc-950">
<div className="border-b p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Sparkles className="h-4 w-4 text-amber-500" />
智能体
</div>
<div className="mt-1 text-xs text-muted-foreground">
选择智能体后,右侧对话框会按其要求显示上传槽位
</div>
</div>
<Button size="sm" onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-1 h-4 w-4" />
新建
</Button>
</div>
<Input
placeholder="搜索智能体"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
</div>
<ScrollArea className="flex-1">
<div className="space-y-5 p-4">
<section className="space-y-3">
<div className="space-y-2">
<div className="text-sm font-medium">公共智能体</div>
<AgentCategoryTabs
categories={publicCategories}
selectedCategoryId={selectedCategoryId}
onSelectCategory={setSelectedCategoryId}
/>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : filteredPublicPresets.length > 0 ? (
<div className="space-y-3">
{filteredPublicPresets.map((preset) => (
<AgentPresetCard
key={preset.id}
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onFork={handleForkPreset}
onEdit={setEditingPreset}
onDelete={handleDeletePreset}
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
暂无公共智能体
</div>
)}
</section>
<section className="space-y-3">
<div className="text-sm font-medium">我的智能体</div>
{isLoading ? null : filteredPrivatePresets.length > 0 ? (
<div className="space-y-3">
{filteredPrivatePresets.map((preset) => (
<AgentPresetCard
key={preset.id}
preset={preset}
selected={selectedPresetId === preset.id}
onSelect={onSelectPreset}
onEdit={setEditingPreset}
onDelete={handleDeletePreset}
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
还没有私有智能体,先新建一个吧
</div>
)}
</section>
</div>
</ScrollArea>
</div>
<AgentPresetDialog
open={showCreateDialog || Boolean(editingPreset)}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setShowCreateDialog(false);
setEditingPreset(null);
return;
}
setShowCreateDialog(true);
}}
categories={data?.categories || []}
initialPreset={editingPreset}
onSaved={async (preset) => {
await mutate();
onSelectPreset(preset.id);
setEditingPreset(null);
setShowCreateDialog(false);
}}
/>
</>
);
}

View File

@ -48,6 +48,7 @@ import {
} from "@/lib/image-url"; } from "@/lib/image-url";
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import type { Attachment, ChatMessage } from "@/lib/types"; import type { Attachment, ChatMessage } from "@/lib/types";
import type { AgentPreset } from "@/types/agent";
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch"; import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
import { import {
DEFAULT_CANVAS_SKILL_TAGS_TITLE, DEFAULT_CANVAS_SKILL_TAGS_TITLE,
@ -88,6 +89,11 @@ type CanvasMediaStreamPayload = {
videoUrls?: string[]; videoUrls?: string[];
prompt?: string; prompt?: string;
sourceImageUrls?: string[]; sourceImageUrls?: string[];
agentPresetId?: string;
agentPresetName?: string;
agentCategoryId?: string;
agentCategoryName?: string;
agentExecutionSnapshotId?: string;
}; };
type ImageGenerationStatusStreamPayload = { type ImageGenerationStatusStreamPayload = {
@ -102,6 +108,7 @@ interface CanvasChatPanelProps {
initialMessages?: ChatMessage[]; initialMessages?: ChatMessage[];
onClose?: () => void; onClose?: () => void;
onOpenFileList?: () => void; onOpenFileList?: () => void;
selectedAgentPresetId?: string | null;
} }
type VideoStoryboardStreamData = type VideoStoryboardStreamData =
@ -1579,7 +1586,13 @@ function buildDetailReviewAppendMarkdown(params: {
return lines.join("\n").trim(); return lines.join("\n").trim();
} }
export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenFileList }: CanvasChatPanelProps) { export function CanvasChatPanel({
chatId,
initialMessages = [],
onClose,
onOpenFileList,
selectedAgentPresetId,
}: CanvasChatPanelProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [attachments, setAttachments] = useState<PickedObjectAttachment[]>([]); const [attachments, setAttachments] = useState<PickedObjectAttachment[]>([]);
@ -1641,6 +1654,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]); const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]); const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const detailModeSwitchRef = useRef(false); const detailModeSwitchRef = useRef(false);
@ -1669,6 +1683,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}); });
const [videoParams, setVideoParams] = useState<VideoGenerationParams | null>(null); const [videoParams, setVideoParams] = useState<VideoGenerationParams | null>(null);
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null); const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null);
const [agentSlotUploading, setAgentSlotUploading] = useState<number | null>(null);
const [lastImageModelId, setLastImageModelId] = useState<string | null>(null); const [lastImageModelId, setLastImageModelId] = useState<string | null>(null);
const [lastVideoModelId, setLastVideoModelId] = useState<string | null>(null); const [lastVideoModelId, setLastVideoModelId] = useState<string | null>(null);
const [lastConversationModelId, setLastConversationModelId] = useState<string | null>(null); const [lastConversationModelId, setLastConversationModelId] = useState<string | null>(null);
@ -2544,6 +2559,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
return response.json(); return response.json();
} }
); );
const { data: agentPresetResponse } = useSWR<{ success?: boolean; data?: AgentPreset }>(
selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null,
fetcher
);
const greetingUserName = useMemo(() => { const greetingUserName = useMemo(() => {
const userName = String(session?.user?.name || "").trim(); const userName = String(session?.user?.name || "").trim();
if (userName) return userName; if (userName) return userName;
@ -2678,6 +2697,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
() => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null, () => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null,
[canvasSkillTags, selectedSkillTagId] [canvasSkillTags, selectedSkillTagId]
); );
const selectedAgentPreset = useMemo(
() => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null),
[agentPresetResponse]
);
const SelectedSkillTagIcon = useMemo( const SelectedSkillTagIcon = useMemo(
() => getCanvasSkillIconComponent(selectedSkillTag?.iconName), () => getCanvasSkillIconComponent(selectedSkillTag?.iconName),
[selectedSkillTag?.iconName] [selectedSkillTag?.iconName]
@ -2835,6 +2858,29 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
if (videoParams.mode === "first-frame") return ["首帧"] as string[]; if (videoParams.mode === "first-frame") return ["首帧"] as string[];
return [] as string[]; return [] as string[];
}, [isVideoModel, videoParams]); }, [isVideoModel, videoParams]);
const activeComposerMode = useMemo<"agent" | "image" | "video" | "detail">(() => {
if (isDetailGenerationMode) return "detail";
if (isVideoModel) return "video";
if (isAgentModel) return "agent";
return "image";
}, [isAgentModel, isDetailGenerationMode, isVideoModel]);
const agentUploadSlots = useMemo(() => {
if (activeComposerMode !== "agent" || !selectedAgentPreset) {
return [] as Array<{
slotIndex: number;
label: string;
}>;
}
return [
{ slotIndex: 0, label: "产品图1" },
{ slotIndex: 1, label: "产品图2" },
{ slotIndex: 2, label: "模特图" },
];
}, [activeComposerMode, selectedAgentPreset]);
const hasAgentUploadSlots =
activeComposerMode === "agent" && agentUploadSlots.length > 0;
const agentRequiredUploadCount = hasAgentUploadSlots ? 3 : 0;
// 监听 pendingChatAttachments:视频模式时填充首尾帧槽位,非视频模式插入对话标签 // 监听 pendingChatAttachments:视频模式时填充首尾帧槽位,非视频模式插入对话标签
useEffect(() => { useEffect(() => {
@ -2899,6 +2945,56 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
return; return;
} }
if (hasAgentUploadSlots) {
const slotCount = agentUploadSlots.length;
const currentSlots: Array<PickedObjectAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => attachments[index] ?? null
);
const existingUrls = new Set(currentSlots.filter(Boolean).map((item) => item!.url));
const toAdd = pendingChatAttachments
.filter((item) => item?.url && !existingUrls.has(item.url))
.map((item) => ({
url: item.url,
name: item.name || "参考图",
contentType: item.contentType || "image/jpeg",
})) as PickedObjectAttachment[];
let cursor = 0;
let inserted = 0;
for (let index = 0; index < slotCount && cursor < toAdd.length; index += 1) {
if (currentSlots[index]) continue;
currentSlots[index] = toAdd[cursor]!;
cursor += 1;
inserted += 1;
}
if (inserted > 0) {
const next = currentSlots
.slice(0, slotCount)
.filter((item): item is PickedObjectAttachment => Boolean(item));
setAttachments(next);
toast.success(
slotCount === 1
? "已添加到智能体图片槽位"
: `已添加到智能体槽位(${inserted}/${slotCount})`
);
} else if (toAdd.length > 0) {
toast.info("智能体图片槽位已满,请先替换或移除");
}
if (toAdd.length - inserted > 0) {
toast.info(`还有 ${toAdd.length - inserted} 张未添加,可继续替换智能体图片槽位`);
}
setTimeout(() => {
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
clearPendingChatAttachments();
return;
}
if (isVideoModel && videoFrameSlots.length > 0) { if (isVideoModel && videoFrameSlots.length > 0) {
const slotCount = videoFrameSlots.length; const slotCount = videoFrameSlots.length;
const currentSlots: Array<PickedObjectAttachment | null> = Array.from( const currentSlots: Array<PickedObjectAttachment | null> = Array.from(
@ -2966,6 +3062,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
pendingChatAttachments, pendingChatAttachments,
isDetailGenerationMode, isDetailGenerationMode,
detailSlotPickerIndex, detailSlotPickerIndex,
hasAgentUploadSlots,
agentUploadSlots.length,
isVideoModel, isVideoModel,
videoFrameSlots.length, videoFrameSlots.length,
attachments, attachments,
@ -2981,12 +3079,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
() => videoOnlyModels[0]?.id || null, () => videoOnlyModels[0]?.id || null,
[videoOnlyModels] [videoOnlyModels]
); );
const activeComposerMode = useMemo<"agent" | "image" | "video" | "detail">(() => {
if (isDetailGenerationMode) return "detail";
if (isVideoModel) return "video";
if (isAgentModel) return "agent";
return "image";
}, [isAgentModel, isDetailGenerationMode, isVideoModel]);
const activeModeLabel = const activeModeLabel =
activeComposerMode === "video" activeComposerMode === "video"
? "视频" ? "视频"
@ -3028,6 +3120,31 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
setSelectedModelId(modelSelectValue); setSelectedModelId(modelSelectValue);
}, [modelSelectValue, selectedModelId]); }, [modelSelectValue, selectedModelId]);
useEffect(() => {
if (!selectedAgentPresetId) return;
if (activeComposerMode === "agent") return;
const preferredAgentModelId =
allModels.find(
(model) =>
model.id === lastConversationModelId &&
model.isMultimodal &&
!VIDEO_PROVIDER_TYPES.has(model.providerType)
)?.id || "auto";
currentModelIdRef.current = preferredAgentModelId;
setSelectedModelId(preferredAgentModelId);
if (isDetailGenerationMode) {
setIsDetailGenerationMode(false);
}
}, [
activeComposerMode,
allModels,
isDetailGenerationMode,
lastConversationModelId,
selectedAgentPresetId,
]);
useEffect(() => { useEffect(() => {
if (isDetailGenerationMode) { if (isDetailGenerationMode) {
setAttachments([]); setAttachments([]);
@ -3085,6 +3202,15 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}); });
}, [isVideoModel, videoFrameSlots.length]); }, [isVideoModel, videoFrameSlots.length]);
useEffect(() => {
if (!hasAgentUploadSlots) return;
const maxAttachments = agentUploadSlots.length;
setAttachments((current) => {
if (current.length <= maxAttachments) return current;
return current.slice(0, maxAttachments);
});
}, [agentUploadSlots.length, hasAgentUploadSlots]);
useEffect(() => { useEffect(() => {
if (!currentModel) return; if (!currentModel) return;
if (currentModel.id !== "auto" && isDetailGenerationMode) { if (currentModel.id !== "auto" && isDetailGenerationMode) {
@ -3377,6 +3503,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
: []; : [];
const messageId = String(payload.messageId || "").trim(); const messageId = String(payload.messageId || "").trim();
const generationPrompt = String(payload.prompt || "").trim() || "对话生成"; const generationPrompt = String(payload.prompt || "").trim() || "对话生成";
const agentPresetId = String(payload.agentPresetId || "").trim() || undefined;
const agentPresetName = String(payload.agentPresetName || "").trim() || undefined;
const agentCategoryId = String(payload.agentCategoryId || "").trim() || undefined;
const agentCategoryName = String(payload.agentCategoryName || "").trim() || undefined;
const agentExecutionSnapshotId =
String(payload.agentExecutionSnapshotId || "").trim() || undefined;
let addedImageCount = 0; let addedImageCount = 0;
let addedVideoCount = 0; let addedVideoCount = 0;
@ -3438,6 +3570,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
sourceMessageId: messageId || undefined, sourceMessageId: messageId || undefined,
sourceImageKey, sourceImageKey,
compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined, compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined,
agentPresetId,
agentPresetName,
agentCategoryId,
agentCategoryName,
agentExecutionSnapshotId,
}, },
}, },
connections: sourceNodeId ? [sourceNodeId] : [], connections: sourceNodeId ? [sourceNodeId] : [],
@ -3465,6 +3602,13 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
src: url, src: url,
status: "completed", status: "completed",
prompt: generationPrompt, prompt: generationPrompt,
metadata: {
agentPresetId,
agentPresetName,
agentCategoryId,
agentCategoryName,
agentExecutionSnapshotId,
},
}, },
connections: [], connections: [],
}); });
@ -3529,6 +3673,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
body: { body: {
id: rawId, id: rawId,
message: request.messages.at(-1), message: request.messages.at(-1),
agentPresetId:
activeComposerMode === "agent" && selectedAgentPreset?.id
? selectedAgentPreset.id
: undefined,
selectedChatModel: currentModelIdRef.current, selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: "private", selectedVisibilityType: "private",
deepThinkingEnabled: requestIsConversationModel deepThinkingEnabled: requestIsConversationModel
@ -4480,6 +4628,24 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
[videoFrameSlots.length] [videoFrameSlots.length]
); );
const updateAttachmentAtAgentSlot = useCallback(
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
const slotCount = Math.max(agentUploadSlots.length, 1);
setAttachments((current) => {
const slots: Array<PickedObjectAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => current[index] ?? null
);
slots[slotIndex] = attachment;
while (slots.length > 0 && !slots[slots.length - 1]) {
slots.pop();
}
return slots.filter((item): item is PickedObjectAttachment => Boolean(item));
});
},
[agentUploadSlots.length]
);
const fillVideoSlotFromCanvasSelection = useCallback( const fillVideoSlotFromCanvasSelection = useCallback(
(slotIndex: number) => { (slotIndex: number) => {
const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0]; const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0];
@ -4494,6 +4660,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
[getCanvasNodeAttachment, selectedImageNodes, updateAttachmentAtVideoSlot] [getCanvasNodeAttachment, selectedImageNodes, updateAttachmentAtVideoSlot]
); );
const fillAgentSlotFromCanvasSelection = useCallback(
(slotIndex: number) => {
const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0];
const attachment = preferredNode
? getCanvasNodeAttachment(preferredNode, `智能体图片 ${slotIndex + 1}`)
: null;
if (!attachment) return false;
updateAttachmentAtAgentSlot(slotIndex, attachment);
return true;
},
[getCanvasNodeAttachment, selectedImageNodes, updateAttachmentAtAgentSlot]
);
const handleVideoSlotFileChange = useCallback( const handleVideoSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => { async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@ -4566,6 +4746,80 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
[addNode, nodes.length, updateAttachmentAtVideoSlot] [addNode, nodes.length, updateAttachmentAtVideoSlot]
); );
const handleAgentSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
event.currentTarget.value = "";
return;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("图片大小不能超过 50MB");
event.currentTarget.value = "";
return;
}
setAgentSlotUploading(slotIndex);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetchUploadWithRetry(formData);
if (!response.ok) {
throw new Error("上传失败");
}
const data = await response.json();
const imageUrl = data.url as string;
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
updateAttachmentAtAgentSlot(slotIndex, {
url: imageUrl,
name: file.name,
contentType: file.type || "image/jpeg",
assetId,
});
const dimensions = await getImageDimensions(imageUrl);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: imageUrl,
status: "completed",
title: file.name.replace(/\.[^.]+$/, ""),
prompt: file.name.replace(/\.[^.]+$/, ""),
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
},
},
connections: [],
});
const slotLabel = agentUploadSlots[slotIndex]?.label || `第${slotIndex + 1}张`;
toast.success(`${slotLabel}上传成功`);
} catch (_error) {
toast.error("图片上传失败");
} finally {
setAgentSlotUploading(null);
event.currentTarget.value = "";
}
},
[addNode, agentUploadSlots, nodes.length, updateAttachmentAtAgentSlot]
);
const updateDetailSlotAttachment = useCallback( const updateDetailSlotAttachment = useCallback(
(slotIndex: number, attachment: PickedObjectAttachment | null) => { (slotIndex: number, attachment: PickedObjectAttachment | null) => {
setDetailSlots((current) => { setDetailSlots((current) => {
@ -6354,6 +6608,16 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const parts: any[] = []; const parts: any[] = [];
let effectiveAttachments = [...attachments]; let effectiveAttachments = [...attachments];
if (hasAgentUploadSlots) {
if (effectiveAttachments.length > agentRequiredUploadCount) {
effectiveAttachments = effectiveAttachments.slice(0, agentRequiredUploadCount);
}
if (effectiveAttachments.length < agentRequiredUploadCount) {
toast.error("当前智能体需要上传 3 张图片:产品图1、产品图2、模特图");
return;
}
}
const videoImageLimit = isVideoModel const videoImageLimit = isVideoModel
? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability) ? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability)
: null; : null;
@ -6428,6 +6692,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
if (!textContent && effectiveAttachments.length > 0) { if (!textContent && effectiveAttachments.length > 0) {
textContent = "请处理这张图片"; textContent = "请处理这张图片";
} }
if (!textContent && hasAgentUploadSlots && selectedAgentPreset) {
textContent = `请按「${selectedAgentPreset.name}」智能体配置顺序处理产品图1、产品图2和模特图。`;
}
if (!textContent && selectedSkillTag) { if (!textContent && selectedSkillTag) {
textContent = `请按「${selectedSkillTag.name}」标签的要求处理。`; textContent = `请按「${selectedSkillTag.name}」标签的要求处理。`;
} }
@ -6529,9 +6796,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}, [ }, [
activeChatId, activeChatId,
activeComposerMode, activeComposerMode,
agentRequiredUploadCount,
input, input,
attachments, attachments,
hasAgentUploadSlots,
selectedSkillTag, selectedSkillTag,
selectedAgentPreset,
selectedModelId, selectedModelId,
sendMessage, sendMessage,
clearSelection, clearSelection,
@ -6674,6 +6944,47 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const baseNodeCount = nodes.length; const baseNodeCount = nodes.length;
let uploadIndex = 0; let uploadIndex = 0;
if (hasAgentUploadSlots) {
const slotCount = agentUploadSlots.length;
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
let handledCount = 0;
for (const file of files) {
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
if (targetSlotIndex < 0) break;
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
if (!uploaded) continue;
const attachment: PickedObjectAttachment = {
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
};
updateAttachmentAtAgentSlot(targetSlotIndex, attachment);
currentSlots[targetSlotIndex] = attachment;
handledCount += 1;
uploadIndex += 1;
if (!firstImageUrl) {
firstImageUrl = uploaded.imageUrl;
}
}
if (handledCount > 0) {
toast.success(
handledCount === 1
? "已粘贴图片到智能体槽位"
: `已粘贴 ${handledCount} 张图片到智能体槽位`
);
}
if (handledCount < files.length) {
toast.info("智能体图片槽位已满,剩余图片未添加");
}
void maybeAutoSetCanvasTitle(firstImageUrl);
return;
}
if (isVideoModel && videoFrameSlots.length > 0) { if (isVideoModel && videoFrameSlots.length > 0) {
const slotCount = videoFrameSlots.length; const slotCount = videoFrameSlots.length;
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null); const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
@ -6745,11 +7056,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
void maybeAutoSetCanvasTitle(firstImageUrl); void maybeAutoSetCanvasTitle(firstImageUrl);
}, [ }, [
agentUploadSlots.length,
attachments, attachments,
hasAgentUploadSlots,
insertImageTagInEditor, insertImageTagInEditor,
isVideoModel, isVideoModel,
maybeAutoSetCanvasTitle, maybeAutoSetCanvasTitle,
nodes.length, nodes.length,
updateAttachmentAtAgentSlot,
updateAttachmentAtVideoSlot, updateAttachmentAtVideoSlot,
uploadImageFileAndAddToCanvas, uploadImageFileAndAddToCanvas,
videoFrameSlots.length, videoFrameSlots.length,
@ -6764,6 +7078,54 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// 记录本次上传开始时的节点数量,用于计算新图片位置 // 记录本次上传开始时的节点数量,用于计算新图片位置
const baseNodeCount = nodes.length; const baseNodeCount = nodes.length;
let uploadIndex = 0; let uploadIndex = 0;
if (hasAgentUploadSlots) {
const slotCount = agentUploadSlots.length;
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
let handledCount = 0;
for (const file of Array.from(files)) {
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
if (targetSlotIndex < 0) break;
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
if (!uploaded) continue;
updateAttachmentAtAgentSlot(targetSlotIndex, {
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
});
currentSlots[targetSlotIndex] = {
url: uploaded.imageUrl,
name: uploaded.name,
contentType: uploaded.contentType,
assetId: uploaded.assetId,
};
handledCount += 1;
uploadIndex += 1;
if (!firstImageUrl) {
firstImageUrl = uploaded.imageUrl;
}
}
if (handledCount > 0) {
toast.success(
handledCount === 1
? "图片已添加到智能体槽位"
: `已添加 ${handledCount} 张图片到智能体槽位`
);
}
if (handledCount < files.length) {
toast.info("智能体需要的图片槽位已满,剩余图片未添加");
}
void maybeAutoSetCanvasTitle(firstImageUrl);
e.target.value = '';
return;
}
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
@ -6922,8 +7284,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const hasInputOrAttachments = Boolean( const hasInputOrAttachments = Boolean(
input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag) input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag)
); );
const hasEnoughAgentUploads = !hasAgentUploadSlots || attachments.length >= agentRequiredUploadCount;
const composerPlaceholder = isDetailGenerationMode const composerPlaceholder = isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)" ? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: hasAgentUploadSlots
? "请先上传产品图1、产品图2、模特图,再补充说明"
: selectedSkillTag?.placeholder?.trim() || : selectedSkillTag?.placeholder?.trim() ||
(selectedSkillTag (selectedSkillTag
? `请输入「${selectedSkillTag.name}」相关需求` ? `请输入「${selectedSkillTag.name}」相关需求`
@ -6937,7 +7302,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
: 0; : 0;
const canSend = isDetailGenerationMode const canSend = isDetailGenerationMode
? detailValidationIssues.length === 0 && !isDetailGenerating ? detailValidationIssues.length === 0 && !isDetailGenerating
: hasInputOrAttachments; : hasInputOrAttachments && hasEnoughAgentUploads;
const currentVideoImageLimit = isVideoModel const currentVideoImageLimit = isVideoModel
? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability) ? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability)
: null; : null;
@ -8673,6 +9038,19 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
</button> </button>
</div> </div>
)} )}
{!isDetailGenerationMode && activeComposerMode === "agent" && selectedAgentPreset && (
<div className="mb-2 flex flex-wrap items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-full bg-[#eef2ff] px-3 py-1.5 text-sm font-medium text-[#4338ca]">
<Sparkles className="h-4 w-4" />
<span>{selectedAgentPreset.name}</span>
</div>
{hasAgentUploadSlots ? (
<div className="inline-flex items-center rounded-full bg-[#f4f5f7] px-3 py-1.5 text-xs text-[#5b6472]">
请上传产品图1、产品图2、模特图
</div>
) : null}
</div>
)}
<div <div
ref={textareaRef as any} ref={textareaRef as any}
contentEditable={!isSubmitting} contentEditable={!isSubmitting}
@ -8689,7 +9067,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
setInput(textContent); setInput(textContent);
if (!isVideoModel || videoFrameSlots.length === 0) { if ((!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
syncAttachmentsWithEditor(target); syncAttachmentsWithEditor(target);
} }
}} }}
@ -8702,7 +9080,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
handleSend(); handleSend();
} }
// 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布) // 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布)
if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0)) { if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection && selection.rangeCount > 0) { if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
@ -8741,6 +9119,74 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
/> />
</div> </div>
{hasAgentUploadSlots && (
<div className="mb-11 px-4 pb-1">
<div className="flex flex-row gap-2 overflow-x-auto scrollbar-hide">
{agentUploadSlots.map((slot, index) => {
const slotAttachment = attachments[index];
const isUploading = agentSlotUploading === index;
const isDisabled = isLoading || (index > 0 && !attachments[index - 1]);
return (
<button
key={`canvas-agent-slot-${slot.slotIndex}-${index}`}
type="button"
onClick={() => {
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 ? (
<>
<img
src={buildPreviewImageUrl(slotAttachment.url, 256)}
alt={slotAttachment.name || slot.label}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = slotAttachment.url;
}
}}
/>
<div className="absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 text-center text-[11px] text-white">
{slot.label}
</div>
<div
className="absolute right-1 top-1 rounded-full bg-black/65 px-1 text-[11px] text-white opacity-0 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
updateAttachmentAtAgentSlot(index, null);
}}
>
x
</div>
</>
) : (
<div className="flex flex-col items-center gap-1 px-1">
<span className="text-xl leading-none">+</span>
<span className="text-center text-xs">{isUploading ? "上传中" : slot.label}</span>
</div>
)}
</button>
);
})}
</div>
<div className="mt-1.5 text-[11px] text-muted-foreground">
智能体需要上传 3 张图片:产品图1、产品图2、模特图;已选中画布图片时,点击槽位可直接填充,未选中则打开上传。
</div>
</div>
)}
{isVideoModel && videoFrameSlots.length > 0 && ( {isVideoModel && videoFrameSlots.length > 0 && (
<div className="mb-11 px-4 pb-1"> <div className="mb-11 px-4 pb-1">
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
@ -8834,6 +9280,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}} }}
/> />
))} ))}
{agentUploadSlots.map((_, index) => (
<input
key={`canvas-agent-slot-input-${index}`}
type="file"
accept="image/*"
className="hidden"
ref={(element) => {
agentSlotInputRefs.current[index] = element;
}}
onChange={(event) => {
void handleAgentSlotFileChange(index, event);
}}
/>
))}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button

View File

@ -34,6 +34,7 @@ import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { AngleEditDialog } from "@/components/angle-edit-dialog"; import { AngleEditDialog } from "@/components/angle-edit-dialog";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog"; import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { ImageAnnotator } from "@/components/image-annotator"; import { ImageAnnotator } from "@/components/image-annotator";
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
import type { PickedObject } from "@/store/canvas"; import type { PickedObject } from "@/store/canvas";
// 已标注的物品标记(本地状态) // 已标注的物品标记(本地状态)
@ -310,6 +311,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [textEditPanelOpen, setTextEditPanelOpen] = useState(false); const [textEditPanelOpen, setTextEditPanelOpen] = useState(false);
const [notePanelOpen, setNotePanelOpen] = useState(false); const [notePanelOpen, setNotePanelOpen] = useState(false);
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
const [textEditItems, setTextEditItems] = useState<ImageTextEditItem[]>( const [textEditItems, setTextEditItems] = useState<ImageTextEditItem[]>(
() => normalizeTextEditItems(data.textEditItems), () => normalizeTextEditItems(data.textEditItems),
); );
@ -361,6 +363,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
// 当前正在识别的点击位置 // 当前正在识别的点击位置
const [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null); const [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null);
const displayTitle = getImageNodeTitle(data, node.id); 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 isMultiSelected = isSelected && selectedNodeIds.length > 1;
const compareSource = useMemo(() => { const compareSource = useMemo(() => {
const targetIdentity = normalizeImageIdentityUrl(data.src || ""); const targetIdentity = normalizeImageIdentityUrl(data.src || "");
@ -2759,6 +2768,19 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
备注 备注
</div> </div>
)} )}
{shortAgentExecutionSnapshotId ? (
<button
type="button"
onClick={() => 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}
</button>
) : null}
</div> </div>
{/* 角度编辑对话框 */} {/* 角度编辑对话框 */}
@ -2770,6 +2792,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
onComplete={handleAngleEditComplete} onComplete={handleAngleEditComplete}
/> />
)} )}
<AgentExecutionSnapshotDialog
snapshotId={agentExecutionSnapshotId || null}
open={snapshotDialogOpen}
onOpenChange={setSnapshotDialogOpen}
/>
{/* 批量处理对话框 */} {/* 批量处理对话框 */}
<SceneFusionDialog <SceneFusionDialog

View File

@ -11,6 +11,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, VideoNodeData } from "@/types/canvas"; import type { CanvasNode, VideoNodeData } from "@/types/canvas";
@ -23,11 +24,19 @@ interface VideoNodeProps {
export function VideoNode({ node, isSelected }: VideoNodeProps) { export function VideoNode({ node, isSelected }: VideoNodeProps) {
const data = node.data as VideoNodeData; const data = node.data as VideoNodeData;
const agentExecutionSnapshotId =
typeof data.metadata?.agentExecutionSnapshotId === "string"
? data.metadata.agentExecutionSnapshotId.trim()
: "";
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
? agentExecutionSnapshotId.slice(0, 8)
: "";
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(true); const [isMuted, setIsMuted] = useState(true);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
const updateNode = useCanvasStore((state) => state.updateNode); const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode); const deleteNode = useCanvasStore((state) => state.deleteNode);
@ -262,6 +271,22 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
</div> </div>
)} )}
{shortAgentExecutionSnapshotId ? (
<button
type="button"
onClick={() => setSnapshotDialogOpen(true)}
className="absolute top-2 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"
title={`执行快照:${agentExecutionSnapshotId}`}
>
快照 {shortAgentExecutionSnapshotId}
</button>
) : null}
<AgentExecutionSnapshotDialog
snapshotId={agentExecutionSnapshotId || null}
open={snapshotDialogOpen}
onOpenChange={setSnapshotDialogOpen}
/>
{/* 提示词显示 */} {/* 提示词显示 */}
{data.prompt && isHovered && ( {data.prompt && isHovered && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3"> <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">

View File

@ -40,6 +40,7 @@ type ChatShellProps = {
tutorialVideos: TutorialVideo[]; tutorialVideos: TutorialVideo[];
tutorialEnabled: boolean; tutorialEnabled: boolean;
rankingEnabled: boolean; rankingEnabled: boolean;
agentEnabled: boolean;
}; };
export function ChatShell({ export function ChatShell({
@ -59,6 +60,7 @@ export function ChatShell({
tutorialVideos, tutorialVideos,
tutorialEnabled, tutorialEnabled,
rankingEnabled, rankingEnabled,
agentEnabled,
}: ChatShellProps) { }: ChatShellProps) {
const pathname = usePathname(); const pathname = usePathname();
const isLovartHome = pathname === "/" && homeVariant === "lovart"; const isLovartHome = pathname === "/" && homeVariant === "lovart";
@ -82,6 +84,7 @@ export function ChatShell({
tutorialVideos={tutorialVideos} tutorialVideos={tutorialVideos}
tutorialEnabled={tutorialEnabled} tutorialEnabled={tutorialEnabled}
rankingEnabled={rankingEnabled} rankingEnabled={rankingEnabled}
agentEnabled={agentEnabled}
/> />
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]"> <div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
<AppSidebar user={user} /> <AppSidebar user={user} />

View File

@ -2,7 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, X } from "lucide-react"; import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, Wand2, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
@ -56,6 +56,7 @@ type TopNavbarProps = {
tutorialVideos?: TutorialVideo[]; tutorialVideos?: TutorialVideo[];
tutorialEnabled?: boolean; tutorialEnabled?: boolean;
rankingEnabled?: boolean; rankingEnabled?: boolean;
agentEnabled?: boolean;
}; };
export function TopNavbar({ export function TopNavbar({
@ -71,6 +72,7 @@ export function TopNavbar({
tutorialVideos = [], tutorialVideos = [],
tutorialEnabled = false, tutorialEnabled = false,
rankingEnabled = true, rankingEnabled = true,
agentEnabled = true,
}: TopNavbarProps) { }: TopNavbarProps) {
const router = useRouter(); const router = useRouter();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
@ -134,6 +136,7 @@ export function TopNavbar({
if (trimmed === "功能教程") return "教程"; if (trimmed === "功能教程") return "教程";
if (trimmed === "精选榜") return "榜单"; if (trimmed === "精选榜") return "榜单";
if (trimmed === "图片编辑") return "编辑"; if (trimmed === "图片编辑") return "编辑";
if (trimmed === "智能体") return "智能";
if (trimmed === "无限画布") return "画布"; if (trimmed === "无限画布") return "画布";
if (trimmed === "我的图册") return "图册"; if (trimmed === "我的图册") return "图册";
if (trimmed === "系统设置") return "设置"; if (trimmed === "系统设置") return "设置";
@ -443,6 +446,21 @@ export function TopNavbar({
<span className={navLabelClass}>{getShortLabel("图片编辑")}</span> <span className={navLabelClass}>{getShortLabel("图片编辑")}</span>
</Button> </Button>
{/* 智能体模式 */}
{agentEnabled && (
<button
className={navItemBaseClass}
onClick={() => {
window.location.href = "/canvas?mode=agent";
}}
type="button"
title="智能体"
>
<Wand2 className="h-5 w-5" />
<span className={navLabelClass}>{getShortLabel("智能体")}</span>
</button>
)}
{/* 无限画布 */} {/* 无限画布 */}
<button <button
className={navItemBaseClass} className={navItemBaseClass}

View File

@ -1,17 +1,19 @@
# 智能体页面开发文档(基于当前 `ai-chatbot-main`) # 智能体页面开发文档(基于当前 `ai-chatbot-main`)
> 目标:在现有“无限画布 + 画布对话 + 多模型生成”体系上,新增一个独立的“智能体”工作台页面。 > 目标:在现有“无限画布 + 画布对话 + 多模型生成”体系上,直接基于现有画布页改造出“智能体模式”。
> 页面形态:左侧半屏展示生成结果画布,右侧半屏展示智能体工具栏;支持公共/私有智能体、分类筛选、用户自建智能体、自建分类、预设词和参考图注入。 > 页面形态:一侧为智能体界面,另一侧为现有对话框界面;底层仍使用画布项目、画布对话和媒体落盘能力。
--- ---
## 1. 需求目标 ## 1. 需求目标
本期不是重做一套生成系统,而是在现有项目基础上新增“智能体工作台”: 本期不是重做一套生成系统,而是在现有项目基础上,把现有画布页改造成“智能体模式”:
1. 新增一个页面,名称为“智能体” 1. 不新增独立业务形态,仍然基于现有画布界面
2. 页面左半区复用现有无限画布能力,用于展示生成的图片/视频结果 2. 画布页在“智能体模式”下采用左右双栏:
3. 页面右半区为智能体面板,分为: - 一侧为智能体面板
- 一侧为现有对话框界面
3. 智能体面板分为:
- 公共智能体区 - 公共智能体区
- 私有智能体区 - 私有智能体区
4. 公共智能体区支持按分类筛选查看 4. 公共智能体区支持按分类筛选查看
@ -22,16 +24,20 @@
- 类型:`生图模型` / `视频模型` - 类型:`生图模型` / `视频模型`
- 自定义名称 - 自定义名称
- 智能体分类 - 智能体分类
- 预设提示词 - 选择对应的模型
- 参考图 - 多个槽位配置
- 每个槽位一套提示词
- 每个槽位对应一组参考图
- 默认参数模板
- 公开 / 私有 - 公开 / 私有
7. 用户点击某个智能体后,可将其以“标签/卡片”的形式挂载到页面底部对话框 7. 用户点击某个智能体后,可将其以“标签/卡片”的形式挂载到现有对话框界面
8. 后端在发送时自动注入该智能体的预设提示词和参考图 8. 后端在发送时自动注入该智能体的预设提示词和参考图
9. 用户只需: 9. 用户只需在现有对话框中:
- 上传自己的图片 - 按槽位数量上传对应数量的图片
- 可选补充提示词 - 可选补充提示词
- 点击发送 - 点击发送
10. 后端完成生图或生视频后,结果直接落到左侧画布区显示 10. 后端会按槽位顺序一张张生成
11. 后端完成生图或生视频后,结果先展示在现有对话消息流里,并继续落盘到画布项目数据中
--- ---
@ -91,39 +97,48 @@
5. 主要依赖前端拼 `hiddenPrompt` 5. 主要依赖前端拼 `hiddenPrompt`
6. 无法形成正式智能体资产 6. 无法形成正式智能体资产
因此本功能建议升级为独立的数据模型,而不是继续放在 `SystemSettings` 或 `canvasSkillTags` 中。 因此本功能建议升级为独立的数据模型,而不是继续放在 `SystemSettings` 或 `canvasSkillTags` 中;但前端页面层面不单独新开一页,而是直接复用当前画布页。
--- ---
## 3. 产品形态建议 ## 3. 产品形态建议
### 3.1 页面路由 ### 3.1 页面入口
建议新增独立工作台路由,而不是塞进现有 `CanvasChatPanel`: 建议不新增 `/agents` 独立路由,直接基于现有画布页实现:
- 入口页:`/agents` - 继续使用:`/canvas/[id]`
- 工作页:`/agents/[id]` - 增加模式概念:
- 标准画布模式
- 智能体模式
推荐行为: 推荐行为:
1. 访问 `/agents` 时自动打开最近一次智能体工作台 1. 访问 `/canvas/[id]` 时可通过顶部入口或参数切到“智能体模式”
2. 若没有工作台,则创建一个新的工作台 2. 智能体模式底层仍复用 `CanvasProject`
3. 工作台底层仍复用 `CanvasProject` 3. 生成结果仍落到当前画布项目中
4. 后续如需恢复可视化画布编辑,可在同一项目里切回标准画布模式
### 3.2 页面布局 ### 3.2 页面布局
桌面端建议采用左右 1:1 分栏: 桌面端建议采用左右 1:1 分栏:
- 左侧 50%:结果画布区 - 左侧 50%:智能体工具区
- 复用 `CanvasWorkspace`
- 只展示当前智能体工作台结果
- 底部固定一个对话输入区
- 右侧 50%:智能体工具区
- 顶部:搜索 / 类型筛选 / 新建按钮 - 顶部:搜索 / 类型筛选 / 新建按钮
- 公共区顶部:分类筛选栏(全部 + 分类 Tab/下拉) - 公共区顶部:分类筛选栏(全部 + 分类 Tab/下拉)
- 中部:公共智能体区 - 中部:公共智能体区
- 下部:私有智能体区 - 下部:私有智能体区
- 点击卡片后变为当前激活智能体 - 点击卡片后变为当前激活智能体
- 右侧 50%:现有对话框界面
- 直接复用 `CanvasChatPanel`
- 保留原有消息流、上传、模型选择、发送能力
- 智能体只作为额外的预设注入层
说明:
- 首版不再把半屏留给 `CanvasWorkspace` 常驻展示
- 结果展示以现有对话消息流为主
- 生成结果仍继续落盘到画布项目,后续可以通过“切回标准画布模式”查看和编辑
### 3.3 交互约束 ### 3.3 交互约束
@ -136,6 +151,8 @@ MVP 建议限制:
3. 创建为公开后,立即出现在公共区 3. 创建为公开后,立即出现在公共区
4. 参考图统一绑定媒体资产 4. 参考图统一绑定媒体资产
5. 公共区默认只支持单分类筛选 5. 公共区默认只支持单分类筛选
6. 每次执行都生成一份“执行快照”,用于复盘和复现
7. 首版不改造原对话框核心交互,只在其上叠加智能体注入能力
--- ---
@ -145,48 +162,86 @@ MVP 建议限制:
1. 用户点击“新建智能体” 1. 用户点击“新建智能体”
2. 弹出表单,填写: 2. 弹出表单,填写:
- 智能体头像
- 智能体名称 - 智能体名称
- 智能体类型(生图 / 视频) - 智能体类型(生图 / 视频)
- 可见性(公开 / 私有) - 可见性(公开 / 私有)
- 智能体分类(选择已有分类 / 新建分类) - 智能体分类(选择已有分类 / 新建分类)
- 预设提示词 - 对应模型(生图智能体只能选生图模型,视频智能体只能选视频模型)
- 默认模型 - 多个槽位(支持新增/删除/排序)
- 参考图(可多张) - 每个槽位填写:
- 提示词
- 参考图(可多张)
- 可选参数覆盖
3. 前端先调用上传接口上传参考图 3. 前端先调用上传接口上传参考图
4. 后端保存智能体记录 4. 后端保存智能体记录
5. 若勾选“公开”,则直接出现在公共区,并归入对应分类 5. 若勾选“公开”,则直接出现在公共区,并归入对应分类
6. 若勾选“私有”,则出现在私有区;分类字段保留,便于后续扩展 6. 若勾选“私有”,则出现在私有区;分类字段保留,便于后续扩展
### 4.1.1 公共智能体另存为私有
建议首版就支持:
1. 用户在公共智能体卡片上点击“另存为我的智能体”
2. 系统复制一份智能体到当前用户名下
3. 默认写入:
- `visibility=private`
- `forkedFromPresetId=原公共智能体ID`
4. 用户可继续修改:
- 名称
- 提示词
- 默认模型
- 默认参数模板
- 参考图
这样更符合真实使用习惯:公共智能体用于发现,私有智能体用于个性化沉淀。
### 4.2 使用智能体 ### 4.2 使用智能体
1. 用户点击某个智能体卡片 1. 用户点击某个智能体卡片
2. 底部对话框出现智能体标签 2. 底部对话框出现智能体标签
3. 标签展示: 3. 标签展示:
- 头像
- 名称 - 名称
- 类型 - 类型
- 分类 - 分类
- 默认模型 - 默认模型
- 参考图数量 - 槽位数量
4. 用户上传图片 - 需要上传的图片数量
4. 用户统一上传 3 张素材图:
- 产品图1
- 产品图2
- 模特图
5. 用户可补充一句额外提示词 5. 用户可补充一句额外提示词
6. 点击发送 6. 点击发送
使用约束:
- 即使智能体配置了 10 个槽位,前端也只提示上传 3 张素材图
- 上传数量不足时,不允许发送
- 这 3 张素材图会被后端在所有槽位中重复使用
### 4.3 后端执行 ### 4.3 后端执行
1. 请求携带 `agentPresetId` 1. 请求携带 `agentPresetId`
2. 后端查询智能体定义 2. 后端查询智能体定义
3. 后端校验当前用户是否有权限使用 3. 后端校验当前用户是否有权限使用
4. 后端合并: 4. 后端合并:
- 智能体预设提示词 - 智能体槽位列表
- 智能体参考图 - 每个槽位的提示词
- 用户上传图片 - 每个槽位的参考图
- 智能体默认参数模板
- 用户上传的 3 张素材图
- 用户补充提示词 - 用户补充提示词
- 当前工作台上下文 - 当前工作台上下文
5. 根据智能体类型进入: 5. 根据智能体类型进入:
- 图片生成链路 - 图片生成链路
- 视频生成链路 - 视频生成链路
6. 结果通过现有流式消息返回 6. 后端按槽位顺序串行执行
7. 左侧画布自动新增图片或视频节点 7. 每完成一个槽位,先回一条结果
8. 结果通过现有流式消息返回
9. 结果先回到现有对话框消息流
10. 同时继续写入当前画布项目数据,便于后续切回标准画布查看
### 4.4 结果展示 ### 4.4 结果展示
@ -202,32 +257,35 @@ MVP 建议限制:
### 5.1 总体原则 ### 5.1 总体原则
1. 不新造一套生成后端,继续复用 `/api/chat` 1. 不新造一套生成后端,继续复用 `/api/chat`
2. 不复制一套画布,继续复用 `CanvasWorkspace` 2. 不新造独立页面,直接改造现有 `/canvas/[id]`
3. 不继续使用系统设置存智能体,改为正式数据表 3. 不复制现有对话框,继续复用 `CanvasChatPanel`
4. 智能体 prompt 必须由服务端注入 4. 不继续使用系统设置存智能体,改为正式数据表
5. 结果继续走现有流式落画布逻辑 5. 智能体 prompt 必须由服务端注入
6. 结果继续走现有消息流展示与画布项目落盘逻辑
7. 每次发送必须持久化一份不可变的执行快照
### 5.2 页面结构建议 ### 5.2 页面结构建议
建议新增文件: 建议新增文件:
- `app/agents/page.tsx`
- `app/agents/[id]/page.tsx`
- `app/agents/[id]/layout.tsx`
- `components/agents/AgentWorkbench.tsx`
- `components/agents/AgentPresetPanel.tsx` - `components/agents/AgentPresetPanel.tsx`
- `components/agents/AgentPresetCard.tsx` - `components/agents/AgentPresetCard.tsx`
- `components/agents/AgentCategoryTabs.tsx` - `components/agents/AgentCategoryTabs.tsx`
- `components/agents/AgentPresetDialog.tsx` - `components/agents/AgentPresetDialog.tsx`
- `components/agents/AgentComposer.tsx`
- `services/agents/index.ts` - `services/agents/index.ts`
- `types/agent.ts` - `types/agent.ts`
- `store/agent-workbench.ts` - `store/agent-workbench.ts`
建议修改文件: 建议修改文件:
- `app/canvas/[id]/page.tsx`
- 增加“智能体模式”布局
- 将主界面切为“智能体面板 + 原对话框界面”
- `components/top-navbar.tsx` - `components/top-navbar.tsx`
- 新增“智能体”入口 - 新增“智能体”入口
- 入口行为建议直接跳转到最近画布并以 `mode=agent` 打开
- `components/canvas/CanvasChatPanel.tsx`
- 接收 `agentPresetId` / 智能体标签注入
- `app/(chat)/api/chat/schema.ts` - `app/(chat)/api/chat/schema.ts`
- 扩展请求体,允许传入 `agentPresetId` - 扩展请求体,允许传入 `agentPresetId`
- `app/(chat)/api/chat/route.ts` - `app/(chat)/api/chat/route.ts`
@ -239,32 +297,25 @@ MVP 建议限制:
### 5.3 组件复用策略 ### 5.3 组件复用策略
#### A. 左侧结果区 #### A. 智能体面板
直接复用: 新增左侧 `AgentPresetPanel`:
- `CanvasWorkspace` - 负责公共/私有智能体列表
- `store/canvas` - 分类筛选
- `canvasService` - 新建智能体
- 选择智能体
#### B. 发送能力 #### B. 对话能力
建议从 `CanvasChatPanel.tsx` 中抽离一个可复用运行时层,例如: 直接复用 `CanvasChatPanel`:
- `hooks/useCanvasChatRuntime.ts` - 保留现有消息流
- 负责: - 保留现有上传/发送能力
- `sendMessage` - 在发送前额外注入 `agentPresetId`
- 模型选择 - 在输入区显示当前选中的智能体标签
- 图片/视频参数
- 流式结果消费
- 结果写入画布
然后: 这样可以最大化复用现有画布页和现有对话框。
- 旧页面继续使用 `CanvasChatPanel`
- 新页面使用更轻量的 `AgentComposer`
这样可以避免把 `CanvasChatPanel` 再复制一份。
--- ---
@ -282,12 +333,18 @@ MVP 建议限制:
| `type` | varchar(16) | `image` / `video` | | `type` | varchar(16) | `image` / `video` |
| `visibility` | varchar(16) | `public` / `private` | | `visibility` | varchar(16) | `public` / `private` |
| `categoryId` | uuid nullable | 智能体分类 | | `categoryId` | uuid nullable | 智能体分类 |
| `promptTemplate` | text | 预设提示词 | | `promptTemplate` | text | 首槽位提示词摘要/检索字段 |
| `description` | text | 可选描述 | | `description` | text | 可选描述 |
| `defaultModelId` | uuid nullable | 默认模型,关联 `AiModel.id` | | `defaultModelId` | uuid nullable | 默认模型,关联 `AiModel.id` |
| `coverAssetId` | uuid nullable | 封面图,可选 | | `defaultParams` | jsonb nullable | 默认参数模板 |
| `slotCount` | integer | 槽位数量 |
| `requiredUploadCount` | integer | 需要上传的图片数量 |
| `coverAssetId` | uuid nullable | 智能体头像 |
| `status` | varchar(16) | `active` / `disabled` | | `status` | varchar(16) | `active` / `disabled` |
| `sortOrder` | integer | 排序 | | `sortOrder` | integer | 排序 |
| `usageCount` | integer | 使用次数 |
| `lastUsedAt` | timestamp nullable | 最近使用时间 |
| `forkedFromPresetId` | uuid nullable | 从哪个公共智能体复制而来 |
| `createdAt` | timestamp | 创建时间 | | `createdAt` | timestamp | 创建时间 |
| `updatedAt` | timestamp | 更新时间 | | `updatedAt` | timestamp | 更新时间 |
@ -295,10 +352,35 @@ MVP 建议限制:
- `visibility=public`:所有已登录用户可见 - `visibility=public`:所有已登录用户可见
- `visibility=private`:仅创建者可见 - `visibility=private`:仅创建者可见
- `promptTemplate` 为服务端最终合成提示词的主模板 - `promptTemplate` 退化为摘要字段,真正执行以槽位配置为准
- `categoryId` 首期主要用于公共区分类筛选,也可保留给私有区未来扩展 - `categoryId` 首期主要用于公共区分类筛选,也可保留给私有区未来扩展
- `defaultParams` 用于沉淀默认比例、张数、视频模式、时长等参数,不只存默认模型
- `forkedFromPresetId` 用于支持“另存为我的智能体”和后续来源追踪
- `usageCount`、`lastUsedAt` 便于做热门排序、最近使用和运营分析
### 6.2 新增表:`AgentPresetCategory` ### 6.2 新增表:`AgentPresetSlot`
每个槽位代表“一套提示词 + 一组参考图 + 一张用户上传图”。
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | uuid | 主键 |
| `presetId` | uuid | 关联 `AgentPreset.id` |
| `slotIndex` | integer | 槽位顺序,从 0 开始 |
| `title` | varchar(128) nullable | 槽位名称 |
| `promptTemplate` | text | 该槽位提示词 |
| `defaultParams` | jsonb nullable | 槽位级参数覆盖 |
| `requireUpload` | boolean | 是否要求用户上传对应图片 |
| `uploadLabel` | varchar(128) nullable | 上传提示文案 |
| `createdAt` | timestamp | 创建时间 |
| `updatedAt` | timestamp | 更新时间 |
说明:
- 当前版本默认 `requiredUploadCount=3`
- 执行时按 `slotIndex` 顺序串行处理
- 后续若需要“部分槽位不上传图”,可通过 `requireUpload=false` 扩展
### 6.3 新增表:`AgentPresetCategory`
用于给公共智能体区提供分类筛选,也支持用户创建新分类。 用于给公共智能体区提供分类筛选,也支持用户创建新分类。
@ -320,8 +402,10 @@ MVP 建议限制:
- 若智能体为公开,分类建议归入 `public` 或 `global` - 若智能体为公开,分类建议归入 `public` 或 `global`
- 公共区默认展示 `public/global` 分类 - 公共区默认展示 `public/global` 分类
- 若后续要治理分类,可以再补后台合并/禁用能力 - 若后续要治理分类,可以再补后台合并/禁用能力
- 分类创建时应先做 `slug` 标准化,再做重名复用,避免出现多个语义相同分类
- 后续后台需支持“分类合并”,用于把重复分类归并到一个正式分类下
### 6.3 新增表:`AgentPresetReferenceAsset` ### 6.4 新增表:`AgentPresetReferenceAsset`
用于绑定参考图,避免把 URL 直接写死在智能体表。 用于绑定参考图,避免把 URL 直接写死在智能体表。
@ -329,6 +413,7 @@ MVP 建议限制:
|---|---|---| |---|---|---|
| `id` | uuid | 主键 | | `id` | uuid | 主键 |
| `presetId` | uuid | 关联 `AgentPreset.id` | | `presetId` | uuid | 关联 `AgentPreset.id` |
| `slotId` | uuid nullable | 关联 `AgentPresetSlot.id` |
| `assetId` | uuid | 关联 `MediaAsset.id` | | `assetId` | uuid | 关联 `MediaAsset.id` |
| `sortOrder` | integer | 参考图顺序 | | `sortOrder` | integer | 参考图顺序 |
| `createdAt` | timestamp | 创建时间 | | `createdAt` | timestamp | 创建时间 |
@ -336,11 +421,11 @@ MVP 建议限制:
说明: 说明:
- 参考图建议全部走 `MediaAsset` - 参考图建议全部走 `MediaAsset`
- 每张参考图应归属于某个槽位
- 后续若要做公共智能体复制、统计、缓存,会更稳定 - 后续若要做公共智能体复制、统计、缓存,会更稳定
### 6.5 扩展表:`CanvasProject`
### 6.4 扩展表:`CanvasProject` 为了让同一个画布项目支持“标准画布模式 / 智能体模式”切换,建议给 `CanvasProject` 增加:
为了让 `/agents` 能像 `/canvas` 一样恢复最近工作区,建议给 `CanvasProject` 增加:
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |---|---|---|
@ -349,9 +434,44 @@ MVP 建议限制:
默认值: 默认值:
- 旧画布默认 `canvas` - 旧画布默认 `canvas`
- 智能体工作台创建时写 `agent` - 当画布被用作智能体模式项目时写 `agent`
### 6.5 迁移建议 ### 6.6 新增表:`AgentPresetExecutionSnapshot`
用于记录每一次发送时实际生效的智能体快照,保证结果可追溯、可复盘。
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | uuid | 主键 |
| `presetId` | uuid | 关联 `AgentPreset.id` |
| `projectId` | uuid nullable | 工作台/画布项目 |
| `chatId` | uuid nullable | 对话 ID |
| `messageId` | uuid nullable | 触发消息 ID |
| `userId` | uuid | 执行用户 |
| `presetName` | varchar(128) | 执行时的智能体名称快照 |
| `presetType` | varchar(16) | `image` / `video` |
| `visibility` | varchar(16) | 执行时可见性 |
| `categoryId` | uuid nullable | 执行时分类 |
| `categoryName` | varchar(64) nullable | 执行时分类名快照 |
| `promptTemplate` | text | 执行时预设词快照 |
| `defaultModelId` | uuid nullable | 执行时默认模型 |
| `resolvedModelId` | uuid nullable | 实际执行模型 |
| `defaultParams` | jsonb nullable | 执行时默认参数模板 |
| `slotCount` | integer | 执行时槽位数量 |
| `requiredUploadCount` | integer | 执行时要求上传的图片数量 |
| `slots` | jsonb | 执行时完整槽位快照 |
| `referenceAssetIds` | jsonb | 执行时参考图资产 ID 列表 |
| `referenceImageUrls` | jsonb | 执行时参考图 URL 列表 |
| `userInputText` | text | 用户补充提示词 |
| `requestPayload` | jsonb | 最终调用载荷摘要 |
| `createdAt` | timestamp | 创建时间 |
说明:
- 该表是“不可变执行记录”,不随智能体后续编辑而改变
- 用于排查“为什么同一个智能体今天出图和昨天不一样”
- 也可用于后续做“复跑本次配置”
### 6.7 迁移建议
当前 `lib/db/migrations` 已存在到 `0052`,本期建议新增: 当前 `lib/db/migrations` 已存在到 `0052`,本期建议新增:
@ -373,6 +493,7 @@ MVP 建议限制:
- `type=image | video` - `type=image | video`
- `categoryId=xxx` - `categoryId=xxx`
- `keyword=xxx` - `keyword=xxx`
- `sort=latest | hottest | most_used`
返回建议: 返回建议:
@ -400,9 +521,23 @@ MVP 建议限制:
"visibility": "private", "visibility": "private",
"categoryId": "uuid", "categoryId": "uuid",
"newCategoryName": "电商主图", "newCategoryName": "电商主图",
"promptTemplate": "你是资深电商视觉设计师,请在保持主体一致的前提下优化主图视觉冲击力。",
"defaultModelId": "uuid", "defaultModelId": "uuid",
"referenceAssetIds": ["uuid1", "uuid2"] "defaultParams": {
"aspectRatio": "1:1",
"imageCount": 1
},
"slots": [
{
"title": "第1张主图",
"promptTemplate": "请把用户上传的商品图做成干净高级的主图视觉。",
"referenceAssetIds": ["uuid1"]
},
{
"title": "第2张卖点图",
"promptTemplate": "请基于用户上传的商品图突出核心卖点与材质质感。",
"referenceAssetIds": ["uuid2"]
}
]
} }
``` ```
@ -410,6 +545,10 @@ MVP 建议限制:
- `categoryId` 和 `newCategoryName` 二选一 - `categoryId` 和 `newCategoryName` 二选一
- 若传了 `newCategoryName`,后端先创建或复用同名分类,再回填 `categoryId` - 若传了 `newCategoryName`,后端先创建或复用同名分类,再回填 `categoryId`
- `slots` 至少 1 组,最多建议 20 组
- 每个槽位对应 1 次顺序生成
- 用户使用时统一只上传 3 张素材图:产品图1、产品图2、模特图
- 分类创建前应做名称清洗、`slug` 标准化和重名复用
#### `GET /api/agents/[id]` #### `GET /api/agents/[id]`
@ -423,6 +562,23 @@ MVP 建议限制:
用途:删除智能体 用途:删除智能体
#### `POST /api/agents/[id]/fork`
用途:把公共智能体复制为“我的智能体”
请求体建议:
```json
{
"name": "我的商品主图增强"
}
```
返回:
- 一份新的 `visibility=private` 智能体
- `forkedFromPresetId` 指向原公共智能体
#### `GET /api/agent-categories` #### `GET /api/agent-categories`
用途:获取智能体分类列表 用途:获取智能体分类列表
@ -490,21 +646,51 @@ agentPresetType?: "image" | "video";
最终执行输入建议: 最终执行输入建议:
1. 智能体预设提示词:来自 `AgentPreset.promptTemplate` 1. 智能体槽位:来自 `AgentPresetSlot`
2. 智能体参考图:来自 `AgentPresetReferenceAsset` 2. 槽位参考图:来自 `AgentPresetReferenceAsset`
3. 用户上传图:来自本轮 `message.parts` 3. 智能体默认参数模板:来自 `AgentPreset.defaultParams`
4. 用户补充提示词:来自本轮 `text part` 4. 用户上传图:按槽位顺序映射
5. 当前工作台上下文:可复用现有 `contextPayload` 5. 用户补充提示词:来自本轮 `text part`
6. 当前工作台上下文:可复用现有 `contextPayload`
合成优先级建议: 合成优先级建议:
```text ```text
系统路由/安全限制 系统路由/安全限制
-> 智能体 promptTemplate -> 按槽位逐个取出 promptTemplate
-> 智能体 defaultParams
-> 用户补充提示词 -> 用户补充提示词
-> 画布上下文 autoContext/contextPayload -> 画布上下文 autoContext/contextPayload
``` ```
#### 顺序执行规则
后端应按槽位串行执行,而不是一次并发全部发起:
1. 校验用户上传图片数量是否满足 `requiredUploadCount`
2. 用户统一上传 3 张素材图:产品图1、产品图2、模特图
3. 每个槽位都会复用这 3 张素材图
4. 依次类推,直到最后一个槽位
5. 每个槽位完成后立即回传对应结果
例如:
- 智能体配置了 `10` 个槽位
- 用户仍然只上传 `3` 张素材图
- 则发送前前端明确提示“请上传产品图1、产品图2、模特图”
- 后端按顺序一张张生成,共生成 10 次,并在每个槽位重复使用这 3 张素材图
#### 执行快照写入策略
每次使用智能体发送时,后端建议同步写入一条 `AgentPresetExecutionSnapshot`:
1. 读取智能体定义
2. 解析本轮用户上传图
3. 生成本次执行的槽位展开结果
4. 计算最终执行模型与参数
5. 在真正调用模型前写入快照
6. 将快照 ID 回写到消息或结果节点 metadata
#### 类型路由规则 #### 类型路由规则
- `image` 智能体: - `image` 智能体:
@ -543,18 +729,21 @@ agentPresetType?: "image" | "video";
- 分类名称 - 分类名称
- 可见性标签(公共 / 私有) - 可见性标签(公共 / 私有)
- 默认模型名称 - 默认模型名称
- 默认参数摘要
- 参考图数量 - 参考图数量
- 智能体头像
- 创建人(公共区建议展示) - 创建人(公共区建议展示)
- 使用次数 / 最近更新(公共区建议展示)
### 8.2 对话输入区 ### 8.2 对话输入区
底部输入区建议包含: 现有对话框界面建议包含:
1. 当前激活智能体标签 1. 当前激活智能体标签
2. 图片上传入口 2. 原有图片上传入口
3. 补充提示词输入框 3. 原有输入框
4. 模型选择器 4. 原有模型选择器
5. 发送按钮 5. 原有发送按钮
标签行为: 标签行为:
@ -563,27 +752,61 @@ agentPresetType?: "image" | "video";
- 切换到另一个智能体 -> 替换当前标签 - 切换到另一个智能体 -> 替换当前标签
MVP 不建议一次选择多个智能体。 MVP 不建议一次选择多个智能体。
首版尽量不改原有输入区主结构,只在现有输入区上增加“智能体标签”和必要的预设注入提示。
### 8.3 新建智能体弹窗建议 ### 8.3 新建智能体弹窗建议
表单字段建议: 表单字段建议:
1. 智能体名称 1. 智能体头像
2. 智能体类型 2. 智能体名称
3. 可见性 3. 智能体类型
4. 分类选择 4. 可见性
5. 分类选择
- 下拉展示已有分类 - 下拉展示已有分类
- 支持“新建分类” - 支持“新建分类”
- 选择“新建分类”后出现输入框 - 选择“新建分类”后出现输入框
5. 默认模型 6. 对应模型选择
6. 预设提示词 - 若类型为 `image`,只展示支持生图的模型
7. 参考图上传 - 若类型为 `video`,只展示视频模型
7. 默认参数模板
8. 槽位配置区
- 支持新增/删除/排序槽位
- 每个槽位填写:
- 提示词
- 参考图
- 可选参数覆盖
- 上传提示语
交互建议: 交互建议:
- 当用户选择 `visibility=public` 时,分类必填 - 当用户选择 `visibility=public` 时,分类必填
- 当用户选择 `visibility=private` 时,分类可选 - 当用户选择 `visibility=private` 时,分类可选
- 若输入的新分类名已存在,后端直接复用 - 若输入的新分类名已存在,后端直接复用
- 当用户切换智能体类型时,模型下拉列表要立即联动刷新
- 若用户已选模型与新类型不兼容,前端应清空当前模型并提示重新选择
- 增加一个“预览执行内容”区域,展示将被注入的槽位数、默认模型、默认参数和总参考图数量
- 即使槽位数为 10,也应明确展示“使用时需要上传 3 张素材图:产品图1、产品图2、模特图”
### 8.3.1 使用时的上传校验
当用户选中某个智能体后,现有对话框界面需要显示:
- 该智能体共有多少个槽位
- 本次必须上传多少张图片
- 固定上传哪几类图片
建议交互:
1. 当前版本 `requiredUploadCount = 3`
- 输入区上方明确显示:`请上传产品图1、产品图2、模特图`
2. 未达到数量时:
- 发送按钮禁用
- 或点击后给出明确报错
3. 达到数量后:
- 在附件区按顺序展示 `产品图1 / 产品图2 / 模特图`
4. 槽位生成阶段:
- 系统在每个槽位重复使用这 3 张素材图
### 8.4 模型选择策略 ### 8.4 模型选择策略
@ -592,19 +815,22 @@ MVP 不建议一次选择多个智能体。
- 生图智能体:只显示支持图片生成的模型 - 生图智能体:只显示支持图片生成的模型
- 视频智能体:只显示视频模型 - 视频智能体:只显示视频模型
创建/编辑智能体时建议直接强制要求用户选择对应模型,不建议首版留空走系统默认,否则公共智能体可复现性会变差。
默认行为: 默认行为:
1. 若智能体配置了默认模型,则优先用该模型 1. 若智能体配置了默认模型,则优先用该模型
2. 若未配置,则走系统默认模型 2. 若智能体配置了默认参数模板,则优先套用
3. 用户可手动改模型,但必须满足类型限制 3. 若未配置,则走系统默认模型和系统默认参数
4. 用户可手动改模型,但必须满足类型限制
### 8.5 结果落画布 ### 8.5 结果落画布
继续复用 `CanvasChatPanel` 现有逻辑中的结果落图机制: 继续复用 `CanvasChatPanel` 的现有结果消费机制:
- 消费 `data-canvasMedia` - 前端主展示:现有对话消息流
- 图片新增 `image` 节点 - 项目持久化:继续写入当前画布项目
- 视频新增 `video` 节点 - 后续查看:可切回标准画布模式查看图片/视频节点
建议给节点 `metadata` 增加: 建议给节点 `metadata` 增加:
@ -612,6 +838,7 @@ MVP 不建议一次选择多个智能体。
- `agentPresetName` - `agentPresetName`
- `agentCategoryId` - `agentCategoryId`
- `agentCategoryName` - `agentCategoryName`
- `agentExecutionSnapshotId`
- `workspaceType: "agent"` - `workspaceType: "agent"`
--- ---
@ -628,6 +855,7 @@ MVP 不建议一次选择多个智能体。
- `promptTemplate` - `promptTemplate`
- `reference assets` - `reference assets`
- `default model` - `default model`
- `default params`
4. 服务端统一构造最终调用参数 4. 服务端统一构造最终调用参数
价值: 价值:
@ -650,7 +878,42 @@ MVP 不建议一次选择多个智能体。
- 再拼用户上传图 - 再拼用户上传图
- 去重后按模型能力截断 - 去重后按模型能力截断
### 9.3 公开/私有权限 ### 9.3 默认参数模板
建议图片和视频智能体都支持默认参数模板:
- 图片智能体:
- `aspectRatio`
- `imageCount`
- `imageSize`
- `negativePrompt`
- 视频智能体:
- `mode`
- `duration`
- `ratio`
- `resolution`
- `cameraMove`
这样智能体真正能沉淀“风格化工作方式”,而不是只沉淀一句提示词。
### 9.3.1 对应模型选择规则
创建或编辑智能体时,模型选择必须和智能体类型严格匹配:
- `image` 智能体:
- 只能选择支持生图的模型
- 不能选择视频模型
- `video` 智能体:
- 只能选择视频模型
建议前后端都做校验:
- 前端:只展示对应类型模型
- 后端:再次校验提交的 `defaultModelId`
后端若发现模型不匹配,直接拒绝保存,避免出现“视频智能体绑定生图模型”这类脏数据
### 9.4 公开/私有权限
私有智能体: 私有智能体:
@ -665,13 +928,24 @@ MVP 不建议一次选择多个智能体。
- 默认所有登录用户可见 - 默认所有登录用户可见
- 若分类由普通用户创建,建议允许复用,但保留后续后台禁用/合并能力 - 若分类由普通用户创建,建议允许复用,但保留后续后台禁用/合并能力
- 分类创建应遵循:名称清洗 -> `slug` 标准化 -> 重名复用 -> 后台可合并
### 9.4 公共智能体是否立即发布 ### 9.5 公共智能体是否立即发布
按当前需求,本期可直接采用: 按当前需求,本期可直接采用:
- 创建时选“公开” -> 立即出现在公共区对应分类下 - 创建时选“公开” -> 立即出现在公共区对应分类下
### 9.6 公共智能体复制为私有
建议首版就支持:
1. 公共智能体允许“另存为我的智能体”
2. 复制后自动转为 `private`
3. 保留原分类或允许用户重新选择分类
4. 继承原智能体的参考图、提示词、默认模型、默认参数模板
5. 记录 `forkedFromPresetId`
--- ---
## 10. 与现有 `canvasSkillTags` 的兼容方案 ## 10. 与现有 `canvasSkillTags` 的兼容方案
@ -680,15 +954,16 @@ MVP 不建议一次选择多个智能体。
### Phase A:先并行存在 ### Phase A:先并行存在
- 旧画布页面继续使用 `canvasSkillTags` - 旧画布页的常规模式继续可用
- 新“智能体页面”使用 `AgentPreset` - 同一画布页新增“智能体模式”
- `canvasSkillTags` 暂不移除
### Phase B:后续统一 ### Phase B:后续统一
待智能体稳定后,可考虑: 待智能体稳定后,可考虑:
1. 将公共智能体映射到画布页面 1. 用 `AgentPreset` 替换 `canvasSkillTags`
2. 用 `AgentPreset` 替换 `canvasSkillTags` 2. 标准画布模式和智能体模式共享同一套智能体预设
3. 后台不再维护两套预设 3. 后台不再维护两套预设
--- ---
@ -700,26 +975,29 @@ MVP 不建议一次选择多个智能体。
1. 新增 `AgentPreset` 1. 新增 `AgentPreset`
2. 新增 `AgentPresetCategory` 2. 新增 `AgentPresetCategory`
3. 新增 `AgentPresetReferenceAsset` 3. 新增 `AgentPresetReferenceAsset`
4. 扩展 `CanvasProject.workspaceType` 4. 新增 `AgentPresetExecutionSnapshot`
5. 增加 `lib/db/queries.ts` 查询函数 5. 扩展 `CanvasProject.workspaceType`
6. 增加 `/api/agents` 与 `/api/agent-categories` 6. 增加 `lib/db/queries.ts` 查询函数
7. 增加 `/api/agents` 与 `/api/agent-categories`
### 第二阶段:页面骨架 ### 第二阶段:页面骨架
1. 新增 `/agents` 1. 改造 `app/canvas/[id]/page.tsx`
2. 新增 `/agents/[id]` 2. 增加“智能体模式”开关
3. 左侧接入 `CanvasWorkspace` 3. 左侧接入智能体列表
4. 右侧接入智能体列表 4. 右侧保留现有 `CanvasChatPanel`
5. 接入公共区分类筛选 5. 接入公共区分类筛选
6. 增加“新建智能体”弹窗 6. 增加“新建智能体”弹窗
7. 增加“另存为我的智能体”
### 第三阶段:发送链路 ### 第三阶段:发送链路
1. 抽离 `CanvasChatPanel` 发送运行时 1. `CanvasChatPanel` 支持接收 `agentPresetId`
2. 新增 `AgentComposer` 2. 发送时带 `agentPresetId`
3. 发送时带 `agentPresetId` 3. 后端 `/api/chat` 注入智能体定义
4. 后端 `/api/chat` 注入智能体定义 4. 写入执行快照
5. 结果复用 `data-canvasMedia` 落到画布 5. 结果继续走现有消息流展示
6. 同时落到当前画布项目
### 第四阶段:体验增强 ### 第四阶段:体验增强
@ -729,6 +1007,7 @@ MVP 不建议一次选择多个智能体。
4. 参考图拖拽排序 4. 参考图拖拽排序
5. 最近使用智能体 5. 最近使用智能体
6. 公共智能体卡片展示创建者/热度 6. 公共智能体卡片展示创建者/热度
7. 分类合并/禁用
--- ---
@ -736,21 +1015,31 @@ MVP 不建议一次选择多个智能体。
满足以下条件即可视为第一版完成: 满足以下条件即可视为第一版完成:
1. 可以从页面入口进入“智能体”工作台 1. 可以从现有画布页进入“智能体模式”
2. 左侧能看到画布结果区域 2. 一侧能看到智能体界面
3. 右侧能看到公共区、私有区 3. 一侧能看到现有对话框界面
4. 公共区可以按分类筛选智能体 4. 公共区可以按分类筛选智能体
5. 新建智能体时可以选择已有分类 5. 新建智能体时可以选择已有分类
6. 新建智能体时可以创建新分类 6. 新建智能体时可以创建新分类
7. 可以新建一个生图智能体 7. 创建分类时会做重名复用,不会重复创建近义同名分类
8. 可以新建一个视频智能体 8. 可以新建一个生图智能体
9. 可以上传参考图并成功保存 9. 可以新建一个视频智能体
10. 可以选择公开或私有 10. 创建智能体时必须能选择对应的生图模型或视频模型
11. 点击智能体卡片后,底部输入区出现智能体标签 11. 智能体支持多槽位配置
12. 上传用户图片并发送后,后端会自动注入智能体预设 12. 智能体支持上传头像并展示
13. 生图结果会自动落到左侧画布 13. 即使智能体有 10 个槽位,使用时也只提示上传 3 张素材图
14. 生视频结果会自动落到左侧画布 14. 上传数量不足时不允许发送
15. 其他用户不能使用当前用户的私有智能体 15. 智能体支持默认模型和默认参数模板
16. 可以上传参考图并成功保存
17. 可以选择公开或私有
18. 公共智能体支持“另存为我的智能体”
19. 点击智能体卡片后,底部输入区出现智能体标签
20. 上传用户图片并发送后,后端会自动注入智能体预设
21. 每次发送都会写入执行快照
22. 生图结果会先出现在现有对话消息流中
23. 生视频结果会先出现在现有对话消息流中
24. 生成结果会同步落到当前画布项目
25. 其他用户不能使用当前用户的私有智能体
--- ---
@ -768,7 +1057,6 @@ MVP 不建议一次选择多个智能体。
## 14. 一句话结论 ## 14. 一句话结论
这次“智能体页面”最合适的实现方式是: 这次“智能体功能”最合适的实现方式是:
**复用现有无限画布和 `/api/chat` 生成主链路,在其上新增正式的 `AgentPreset + AgentPresetCategory` 数据模型与工作台页面,而不是重新做一套生成系统。**
**不新开独立页面,而是直接改造现有 `/canvas/[id]`:一侧做智能体界面,一侧保留现有 `CanvasChatPanel`,底层继续复用 `CanvasProject + /api/chat + MediaAsset`。**

View File

@ -0,0 +1,179 @@
CREATE TABLE IF NOT EXISTS "AgentPresetCategory" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(64) NOT NULL,
"slug" varchar(64) NOT NULL,
"scope" varchar(16) DEFAULT 'public' NOT NULL,
"createdBy" uuid,
"sortOrder" integer DEFAULT 0 NOT NULL,
"isEnabled" boolean DEFAULT true NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetCategory" ADD CONSTRAINT "AgentPresetCategory_createdBy_User_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "AgentPreset" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"ownerUserId" uuid NOT NULL,
"name" varchar(128) NOT NULL,
"type" varchar(16) DEFAULT 'image' NOT NULL,
"visibility" varchar(16) DEFAULT 'private' NOT NULL,
"categoryId" uuid,
"promptTemplate" text DEFAULT '' NOT NULL,
"description" text,
"defaultModelId" uuid,
"defaultParams" jsonb,
"coverAssetId" uuid,
"status" varchar(16) DEFAULT 'active' NOT NULL,
"sortOrder" integer DEFAULT 0 NOT NULL,
"usageCount" integer DEFAULT 0 NOT NULL,
"lastUsedAt" timestamp,
"forkedFromPresetId" uuid,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_ownerUserId_User_id_fk" FOREIGN KEY ("ownerUserId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_categoryId_AgentPresetCategory_id_fk" FOREIGN KEY ("categoryId") REFERENCES "public"."AgentPresetCategory"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_defaultModelId_AiModel_id_fk" FOREIGN KEY ("defaultModelId") REFERENCES "public"."AiModel"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "CanvasProject"
ADD COLUMN IF NOT EXISTS "workspaceType" varchar(16) DEFAULT 'canvas' NOT NULL;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "AgentPresetExecutionSnapshot" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"presetId" uuid NOT NULL,
"projectId" uuid,
"chatId" uuid,
"messageId" uuid,
"userId" uuid NOT NULL,
"presetName" varchar(128) NOT NULL,
"presetType" varchar(16) NOT NULL,
"visibility" varchar(16) NOT NULL,
"categoryId" uuid,
"categoryName" varchar(64),
"promptTemplate" text DEFAULT '' NOT NULL,
"defaultModelId" uuid,
"resolvedModelId" uuid,
"defaultParams" jsonb,
"referenceAssetIds" jsonb DEFAULT '[]'::jsonb NOT NULL,
"referenceImageUrls" jsonb DEFAULT '[]'::jsonb NOT NULL,
"userInputText" text,
"requestPayload" jsonb,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetExecutionSnapshot" ADD CONSTRAINT "AgentPresetExecutionSnapshot_presetId_AgentPreset_id_fk" FOREIGN KEY ("presetId") REFERENCES "public"."AgentPreset"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetExecutionSnapshot" ADD CONSTRAINT "AgentPresetExecutionSnapshot_projectId_CanvasProject_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."CanvasProject"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetExecutionSnapshot" ADD CONSTRAINT "AgentPresetExecutionSnapshot_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetExecutionSnapshot" ADD CONSTRAINT "AgentPresetExecutionSnapshot_messageId_Message_v2_id_fk" FOREIGN KEY ("messageId") REFERENCES "public"."Message_v2"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetExecutionSnapshot" ADD CONSTRAINT "AgentPresetExecutionSnapshot_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetExecutionSnapshot" ADD CONSTRAINT "AgentPresetExecutionSnapshot_categoryId_AgentPresetCategory_id_fk" FOREIGN KEY ("categoryId") REFERENCES "public"."AgentPresetCategory"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetExecutionSnapshot" ADD CONSTRAINT "AgentPresetExecutionSnapshot_defaultModelId_AiModel_id_fk" FOREIGN KEY ("defaultModelId") REFERENCES "public"."AiModel"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetExecutionSnapshot" ADD CONSTRAINT "AgentPresetExecutionSnapshot_resolvedModelId_AiModel_id_fk" FOREIGN KEY ("resolvedModelId") REFERENCES "public"."AiModel"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "AgentPresetReferenceAsset" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"presetId" uuid NOT NULL,
"assetId" uuid NOT NULL,
"sortOrder" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetReferenceAsset" ADD CONSTRAINT "AgentPresetReferenceAsset_presetId_AgentPreset_id_fk" FOREIGN KEY ("presetId") REFERENCES "public"."AgentPreset"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetReferenceAsset" ADD CONSTRAINT "AgentPresetReferenceAsset_assetId_MediaAsset_id_fk" FOREIGN KEY ("assetId") REFERENCES "public"."MediaAsset"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "AgentPresetCategory_scope_sort_idx"
ON "AgentPresetCategory"("scope", "sortOrder", "name");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "AgentPresetCategory_slug_scope_idx"
ON "AgentPresetCategory"("slug", "scope");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "AgentPreset_owner_visibility_idx"
ON "AgentPreset"("ownerUserId", "visibility", "updatedAt");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "AgentPreset_category_visibility_idx"
ON "AgentPreset"("categoryId", "visibility", "updatedAt");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "AgentPreset_type_visibility_idx"
ON "AgentPreset"("type", "visibility", "updatedAt");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "AgentPresetExecutionSnapshot_preset_created_idx"
ON "AgentPresetExecutionSnapshot"("presetId", "createdAt");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "AgentPresetExecutionSnapshot_user_created_idx"
ON "AgentPresetExecutionSnapshot"("userId", "createdAt");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "AgentPresetReferenceAsset_preset_sort_idx"
ON "AgentPresetReferenceAsset"("presetId", "sortOrder");

View File

@ -0,0 +1,52 @@
ALTER TABLE "AgentPreset"
ADD COLUMN IF NOT EXISTS "slotCount" integer DEFAULT 1 NOT NULL;
--> statement-breakpoint
ALTER TABLE "AgentPreset"
ADD COLUMN IF NOT EXISTS "requiredUploadCount" integer DEFAULT 1 NOT NULL;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "AgentPresetSlot" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"presetId" uuid NOT NULL,
"slotIndex" integer NOT NULL,
"title" varchar(128),
"promptTemplate" text DEFAULT '' NOT NULL,
"defaultParams" jsonb,
"requireUpload" boolean DEFAULT true NOT NULL,
"uploadLabel" varchar(128),
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetSlot" ADD CONSTRAINT "AgentPresetSlot_presetId_AgentPreset_id_fk" FOREIGN KEY ("presetId") REFERENCES "public"."AgentPreset"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "AgentPresetReferenceAsset"
ADD COLUMN IF NOT EXISTS "slotId" uuid;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "AgentPresetReferenceAsset" ADD CONSTRAINT "AgentPresetReferenceAsset_slotId_AgentPresetSlot_id_fk" FOREIGN KEY ("slotId") REFERENCES "public"."AgentPresetSlot"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "AgentPresetExecutionSnapshot"
ADD COLUMN IF NOT EXISTS "slotCount" integer DEFAULT 1 NOT NULL;
--> statement-breakpoint
ALTER TABLE "AgentPresetExecutionSnapshot"
ADD COLUMN IF NOT EXISTS "requiredUploadCount" integer DEFAULT 1 NOT NULL;
--> statement-breakpoint
ALTER TABLE "AgentPresetExecutionSnapshot"
ADD COLUMN IF NOT EXISTS "slots" jsonb DEFAULT '[]'::jsonb NOT NULL;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "AgentPresetSlot_preset_slot_idx"
ON "AgentPresetSlot"("presetId", "slotIndex");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "AgentPresetReferenceAsset_slot_sort_idx"
ON "AgentPresetReferenceAsset"("slotId", "sortOrder");

View File

@ -0,0 +1,2 @@
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "agentEnabled" boolean DEFAULT true NOT NULL;

View File

@ -274,6 +274,27 @@
"when": 1772796000000, "when": 1772796000000,
"tag": "0052_add_asset_memory_snapshots", "tag": "0052_add_asset_memory_snapshots",
"breakpoints": true "breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1773146400000,
"tag": "0053_add_agent_presets",
"breakpoints": true
},
{
"idx": 40,
"version": "7",
"when": 1773148200000,
"tag": "0054_add_agent_preset_slots",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1773150000000,
"tag": "0055_add_agent_enabled_site_setting",
"breakpoints": true
} }
] ]
} }

File diff suppressed because it is too large Load Diff

View File

@ -376,6 +376,7 @@ export const siteSettings = pgTable("SiteSettings", {
rankingEnabled: boolean("rankingEnabled").notNull().default(true), // 是否显示精选榜按钮 rankingEnabled: boolean("rankingEnabled").notNull().default(true), // 是否显示精选榜按钮
// 无限画布设置 // 无限画布设置
canvasEnabled: boolean("canvasEnabled").notNull().default(true), // 是否启用无限画布功能 canvasEnabled: boolean("canvasEnabled").notNull().default(true), // 是否启用无限画布功能
agentEnabled: boolean("agentEnabled").notNull().default(true), // 是否启用智能体功能
realtimeCanvasEnabled: boolean("realtimeCanvasEnabled").notNull().default(true), // 是否显示实时画布入口 realtimeCanvasEnabled: boolean("realtimeCanvasEnabled").notNull().default(true), // 是否显示实时画布入口
canvasDefaultModelId: varchar("canvasDefaultModelId", { length: 128 }), // 画布默认选择的模型ID(null表示智能自动) canvasDefaultModelId: varchar("canvasDefaultModelId", { length: 128 }), // 画布默认选择的模型ID(null表示智能自动)
canvasAutoModeDefaultModelId: varchar("canvasAutoModeDefaultModelId", { length: 128 }), // 智能自动模式下实际使用的默认生图模型ID canvasAutoModeDefaultModelId: varchar("canvasAutoModeDefaultModelId", { length: 128 }), // 智能自动模式下实际使用的默认生图模型ID
@ -496,6 +497,79 @@ export const generatedImage = pgTable("GeneratedImage", {
export type GeneratedImage = InferSelectModel<typeof generatedImage>; export type GeneratedImage = InferSelectModel<typeof generatedImage>;
// 智能体分类表
export const agentPresetCategory = pgTable("AgentPresetCategory", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
name: varchar("name", { length: 64 }).notNull(),
slug: varchar("slug", { length: 64 }).notNull(),
scope: varchar("scope", { enum: ["public", "private", "global"] })
.notNull()
.default("public"),
createdBy: uuid("createdBy").references(() => user.id, { onDelete: "set null" }),
sortOrder: integer("sortOrder").notNull().default(0),
isEnabled: boolean("isEnabled").notNull().default(true),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type AgentPresetCategory = InferSelectModel<typeof agentPresetCategory>;
// 智能体表
export const agentPreset = pgTable("AgentPreset", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
ownerUserId: uuid("ownerUserId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: varchar("name", { length: 128 }).notNull(),
type: varchar("type", { enum: ["image", "video"] })
.notNull()
.default("image"),
visibility: varchar("visibility", { enum: ["public", "private"] })
.notNull()
.default("private"),
categoryId: uuid("categoryId").references(() => agentPresetCategory.id, {
onDelete: "set null",
}),
promptTemplate: text("promptTemplate").notNull().default(""),
description: text("description"),
defaultModelId: uuid("defaultModelId").references(() => aiModel.id, {
onDelete: "set null",
}),
defaultParams: jsonb("defaultParams").$type<Record<string, any>>(),
slotCount: integer("slotCount").notNull().default(1),
requiredUploadCount: integer("requiredUploadCount").notNull().default(1),
coverAssetId: uuid("coverAssetId"),
status: varchar("status", { enum: ["active", "disabled", "hidden", "blocked"] })
.notNull()
.default("active"),
sortOrder: integer("sortOrder").notNull().default(0),
usageCount: integer("usageCount").notNull().default(0),
lastUsedAt: timestamp("lastUsedAt"),
forkedFromPresetId: uuid("forkedFromPresetId"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type AgentPreset = InferSelectModel<typeof agentPreset>;
// 智能体槽位表(每个槽位=一套提示词 + 一组参考图 + 一张用户上传图)
export const agentPresetSlot = pgTable("AgentPresetSlot", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
presetId: uuid("presetId")
.notNull()
.references(() => agentPreset.id, { onDelete: "cascade" }),
slotIndex: integer("slotIndex").notNull(),
title: varchar("title", { length: 128 }),
promptTemplate: text("promptTemplate").notNull().default(""),
defaultParams: jsonb("defaultParams").$type<Record<string, any>>(),
requireUpload: boolean("requireUpload").notNull().default(true),
uploadLabel: varchar("uploadLabel", { length: 128 }),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type AgentPresetSlot = InferSelectModel<typeof agentPresetSlot>;
// ============================================ // ============================================
// 无限画布相关表 // 无限画布相关表
// ============================================ // ============================================
@ -509,6 +583,9 @@ export const canvasProject = pgTable("CanvasProject", {
title: varchar("title", { length: 256 }).notNull().default("未命名画布"), title: varchar("title", { length: 256 }).notNull().default("未命名画布"),
description: text("description"), description: text("description"),
thumbnail: text("thumbnail"), thumbnail: text("thumbnail"),
workspaceType: varchar("workspaceType", { enum: ["canvas", "agent"] })
.notNull()
.default("canvas"),
// 存储完整画布状态(节点、边、视口等) // 存储完整画布状态(节点、边、视口等)
canvasData: jsonb("canvasData").$type<{ canvasData: jsonb("canvasData").$type<{
nodes: any[]; nodes: any[];
@ -843,3 +920,78 @@ export const assetMemorySnapshot = pgTable("AssetMemorySnapshot", {
}); });
export type AssetMemorySnapshot = InferSelectModel<typeof assetMemorySnapshot>; export type AssetMemorySnapshot = InferSelectModel<typeof assetMemorySnapshot>;
// 智能体参考图绑定表
export const agentPresetReferenceAsset = pgTable("AgentPresetReferenceAsset", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
presetId: uuid("presetId")
.notNull()
.references(() => agentPreset.id, { onDelete: "cascade" }),
slotId: uuid("slotId").references(() => agentPresetSlot.id, {
onDelete: "cascade",
}),
assetId: uuid("assetId")
.notNull()
.references(() => mediaAsset.id, { onDelete: "cascade" }),
sortOrder: integer("sortOrder").notNull().default(0),
createdAt: timestamp("createdAt").notNull().defaultNow(),
});
export type AgentPresetReferenceAsset = InferSelectModel<typeof agentPresetReferenceAsset>;
// 智能体执行快照表
export const agentPresetExecutionSnapshot = pgTable("AgentPresetExecutionSnapshot", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
presetId: uuid("presetId")
.notNull()
.references(() => agentPreset.id, { onDelete: "cascade" }),
projectId: uuid("projectId").references(() => canvasProject.id, {
onDelete: "set null",
}),
chatId: uuid("chatId").references(() => chat.id, { onDelete: "set null" }),
messageId: uuid("messageId").references(() => message.id, { onDelete: "set null" }),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
presetName: varchar("presetName", { length: 128 }).notNull(),
presetType: varchar("presetType", { enum: ["image", "video"] }).notNull(),
visibility: varchar("visibility", { enum: ["public", "private"] }).notNull(),
categoryId: uuid("categoryId").references(() => agentPresetCategory.id, {
onDelete: "set null",
}),
categoryName: varchar("categoryName", { length: 64 }),
promptTemplate: text("promptTemplate").notNull().default(""),
defaultModelId: uuid("defaultModelId").references(() => aiModel.id, {
onDelete: "set null",
}),
resolvedModelId: uuid("resolvedModelId").references(() => aiModel.id, {
onDelete: "set null",
}),
defaultParams: jsonb("defaultParams").$type<Record<string, any>>(),
slotCount: integer("slotCount").notNull().default(1),
requiredUploadCount: integer("requiredUploadCount").notNull().default(1),
slots: jsonb("slots")
.$type<
Array<{
slotIndex: number;
title?: string | null;
promptTemplate: string;
defaultParams?: Record<string, any> | null;
requireUpload?: boolean;
uploadLabel?: string | null;
referenceAssetIds: string[];
referenceImageUrls: string[];
}>
>()
.notNull()
.default([]),
referenceAssetIds: jsonb("referenceAssetIds").$type<string[]>().notNull().default([]),
referenceImageUrls: jsonb("referenceImageUrls").$type<string[]>().notNull().default([]),
userInputText: text("userInputText"),
requestPayload: jsonb("requestPayload").$type<Record<string, any>>(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
});
export type AgentPresetExecutionSnapshot = InferSelectModel<
typeof agentPresetExecutionSnapshot
>;

170
services/agents/index.ts Normal file
View File

@ -0,0 +1,170 @@
import type {
AgentCategory,
AgentCategoryCreatePayload,
AgentCreatePayload,
AgentPreset,
AgentPresetListResponse,
AgentUpdatePayload,
} from "@/types/agent";
interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
}
async function parseJsonSafely<T>(response: Response): Promise<T | null> {
try {
return (await response.json()) as T;
} catch (_error) {
return null;
}
}
export const agentCategoryService = {
async getCategories(scope: "all" | "public" | "private" = "public"): Promise<ApiResponse<AgentCategory[]>> {
try {
const response = await fetch(`/api/agent-categories?scope=${scope}`, {
cache: "no-store",
});
const data = await parseJsonSafely<ApiResponse<AgentCategory[]>>(response);
if (!response.ok) {
return { success: false, error: data?.error || "获取智能体分类失败" };
}
return data || { success: false, error: "获取智能体分类失败" };
} catch (_error) {
return { success: false, error: "获取智能体分类失败" };
}
},
async createCategory(payload: AgentCategoryCreatePayload): Promise<ApiResponse<AgentCategory>> {
try {
const response = await fetch("/api/agent-categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await parseJsonSafely<ApiResponse<AgentCategory>>(response);
if (!response.ok) {
return { success: false, error: data?.error || "创建智能体分类失败" };
}
return data || { success: false, error: "创建智能体分类失败" };
} catch (_error) {
return { success: false, error: "创建智能体分类失败" };
}
},
};
export const agentService = {
async getPresets(params?: {
scope?: "all" | "public" | "private";
type?: "image" | "video";
categoryId?: string;
keyword?: string;
sort?: "latest" | "hottest" | "most_used";
}): Promise<ApiResponse<AgentPresetListResponse>> {
try {
const searchParams = new URLSearchParams();
if (params?.scope) searchParams.set("scope", params.scope);
if (params?.type) searchParams.set("type", params.type);
if (params?.categoryId) searchParams.set("categoryId", params.categoryId);
if (params?.keyword) searchParams.set("keyword", params.keyword);
if (params?.sort) searchParams.set("sort", params.sort);
const query = searchParams.toString();
const response = await fetch(`/api/agents${query ? `?${query}` : ""}`, {
cache: "no-store",
});
const data = await parseJsonSafely<ApiResponse<AgentPresetListResponse>>(response);
if (!response.ok) {
return { success: false, error: data?.error || "获取智能体列表失败" };
}
return data || { success: false, error: "获取智能体列表失败" };
} catch (_error) {
return { success: false, error: "获取智能体列表失败" };
}
},
async getPreset(id: string): Promise<ApiResponse<AgentPreset>> {
try {
const response = await fetch(`/api/agents/${id}`, { cache: "no-store" });
const data = await parseJsonSafely<ApiResponse<AgentPreset>>(response);
if (!response.ok) {
return { success: false, error: data?.error || "获取智能体失败" };
}
return data || { success: false, error: "获取智能体失败" };
} catch (_error) {
return { success: false, error: "获取智能体失败" };
}
},
async createPreset(payload: AgentCreatePayload): Promise<ApiResponse<AgentPreset>> {
try {
const response = await fetch("/api/agents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await parseJsonSafely<ApiResponse<AgentPreset>>(response);
if (!response.ok) {
return { success: false, error: data?.error || "创建智能体失败" };
}
return data || { success: false, error: "创建智能体失败" };
} catch (_error) {
return { success: false, error: "创建智能体失败" };
}
},
async updatePreset(id: string, payload: AgentUpdatePayload): Promise<ApiResponse<AgentPreset>> {
try {
const response = await fetch(`/api/agents/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await parseJsonSafely<ApiResponse<AgentPreset>>(response);
if (!response.ok) {
return { success: false, error: data?.error || "更新智能体失败" };
}
return data || { success: false, error: "更新智能体失败" };
} catch (_error) {
return { success: false, error: "更新智能体失败" };
}
},
async deletePreset(id: string): Promise<ApiResponse<AgentPreset>> {
try {
const response = await fetch(`/api/agents/${id}`, {
method: "DELETE",
});
const data = await parseJsonSafely<ApiResponse<AgentPreset>>(response);
if (!response.ok) {
return { success: false, error: data?.error || "删除智能体失败" };
}
return data || { success: false, error: "删除智能体失败" };
} catch (_error) {
return { success: false, error: "删除智能体失败" };
}
},
async forkPreset(id: string, name?: string): Promise<ApiResponse<AgentPreset>> {
try {
const response = await fetch(`/api/agents/${id}/fork`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(name ? { name } : {}),
});
const data = await parseJsonSafely<ApiResponse<AgentPreset>>(response);
if (!response.ok) {
return { success: false, error: data?.error || "复制智能体失败" };
}
return data || { success: false, error: "复制智能体失败" };
} catch (_error) {
return { success: false, error: "复制智能体失败" };
}
},
};
export default {
preset: agentService,
category: agentCategoryService,
};

159
types/agent.ts Normal file
View File

@ -0,0 +1,159 @@
export type AgentPresetType = "image" | "video";
export type AgentPresetVisibility = "public" | "private";
export type AgentCategoryScope = "public" | "private" | "global";
export type AgentPresetStatus = "active" | "disabled" | "hidden" | "blocked";
export interface AgentCategory {
id: string;
name: string;
slug: string;
scope: AgentCategoryScope;
createdBy?: string | null;
sortOrder: number;
isEnabled: boolean;
createdAt: string | Date;
updatedAt: string | Date;
}
export interface AgentReferenceAsset {
id: string;
assetId: string;
sortOrder: number;
originalUrl: string;
previewUrl?: string | null;
thumbnailUrl?: string | null;
mimeType?: string | null;
displayName?: string | null;
}
export interface AgentPresetSlot {
id?: string;
slotIndex: number;
title?: string | null;
promptTemplate: string;
defaultParams?: Record<string, any> | null;
requireUpload?: boolean;
uploadLabel?: string | null;
referenceAssets?: AgentReferenceAsset[];
referenceAssetIds?: string[];
}
export interface AgentModelSummary {
id: string;
modelId?: string | null;
displayName?: string | null;
avatarUrl?: string | null;
}
export interface AgentOwnerSummary {
id: string;
name?: string | null;
email?: string | null;
}
export interface AgentPreset {
id: string;
ownerUserId: string;
name: string;
type: AgentPresetType;
visibility: AgentPresetVisibility;
categoryId?: string | null;
promptTemplate: string;
description?: string | null;
defaultModelId?: string | null;
defaultParams?: Record<string, any> | null;
slotCount: number;
requiredUploadCount: number;
avatarAssetId?: string | null;
avatarUrl?: string | null;
coverAssetId?: string | null;
status: AgentPresetStatus;
sortOrder: number;
usageCount: number;
lastUsedAt?: string | Date | null;
forkedFromPresetId?: string | null;
createdAt: string | Date;
updatedAt: string | Date;
owner?: AgentOwnerSummary | null;
category?: AgentCategory | null;
defaultModel?: AgentModelSummary | null;
slots?: AgentPresetSlot[];
referenceAssets?: AgentReferenceAsset[];
referenceAssetIds?: string[];
referenceAssetCount?: number;
}
export interface AgentPresetListResponse {
categories: AgentCategory[];
public: AgentPreset[];
private: AgentPreset[];
}
export interface AgentExecutionSnapshot {
id: string;
presetId: string;
projectId?: string | null;
chatId?: string | null;
messageId?: string | null;
userId: string;
presetName: string;
presetType: AgentPresetType;
visibility: AgentPresetVisibility;
categoryId?: string | null;
categoryName?: string | null;
promptTemplate: string;
defaultModelId?: string | null;
resolvedModelId?: string | null;
defaultParams?: Record<string, any> | null;
slotCount: number;
requiredUploadCount: number;
slots: Array<{
slotIndex: number;
title?: string | null;
promptTemplate: string;
defaultParams?: Record<string, any> | null;
requireUpload?: boolean;
uploadLabel?: string | null;
referenceAssetIds: string[];
referenceImageUrls: string[];
}>;
referenceAssetIds: string[];
referenceImageUrls: string[];
userInputText?: string | null;
requestPayload?: Record<string, any> | null;
createdAt: string | Date;
defaultModel?: AgentModelSummary | null;
resolvedModel?: AgentModelSummary | null;
}
export interface AgentCreatePayload {
name: string;
type: AgentPresetType;
visibility: AgentPresetVisibility;
categoryId?: string;
newCategoryName?: string;
description?: string;
defaultModelId: string;
defaultParams?: Record<string, any>;
avatarAssetId?: string;
slots: AgentPresetSlot[];
}
export interface AgentUpdatePayload {
name?: string;
type?: AgentPresetType;
visibility?: AgentPresetVisibility;
categoryId?: string | null;
newCategoryName?: string;
description?: string | null;
defaultModelId?: string;
defaultParams?: Record<string, any> | null;
avatarAssetId?: string | null;
slots?: AgentPresetSlot[];
status?: AgentPresetStatus;
}
export interface AgentCategoryCreatePayload {
name: string;
scope?: "public" | "private";
}

View File

@ -68,6 +68,7 @@ export interface VideoNodeData {
duration?: number; duration?: number;
progress?: number; // 生成进度百分比 progress?: number; // 生成进度百分比
coverImage?: string; // 用于生成视频的封面图 coverImage?: string; // 用于生成视频的封面图
metadata?: Record<string, any>;
} }
// 文本节点数据 // 文本节点数据