Add canvas agent mode and management
This commit is contained in:
parent
1fa564ad90
commit
4b820f8ff4
@ -229,6 +229,7 @@ type SiteSettings = {
|
||||
tutorialEnabled: boolean;
|
||||
rankingEnabled: boolean;
|
||||
canvasEnabled: boolean;
|
||||
agentEnabled: boolean;
|
||||
realtimeCanvasEnabled: boolean;
|
||||
aiDetailGenerationEnabled: boolean;
|
||||
canvasDefaultModelId: string | null;
|
||||
@ -618,6 +619,7 @@ export function AdminSettingsClient() {
|
||||
tutorialEnabled: false,
|
||||
rankingEnabled: true,
|
||||
canvasEnabled: true,
|
||||
agentEnabled: true,
|
||||
realtimeCanvasEnabled: true,
|
||||
aiDetailGenerationEnabled: false,
|
||||
canvasDefaultModelId: null,
|
||||
@ -689,6 +691,7 @@ export function AdminSettingsClient() {
|
||||
tutorialEnabled: siteSettingsData.tutorialEnabled ?? false,
|
||||
rankingEnabled: siteSettingsData.rankingEnabled ?? true,
|
||||
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
|
||||
agentEnabled: (siteSettingsData as any).agentEnabled ?? true,
|
||||
realtimeCanvasEnabled: (siteSettingsData as any).realtimeCanvasEnabled ?? true,
|
||||
aiDetailGenerationEnabled: siteSettingsData.aiDetailGenerationEnabled ?? false,
|
||||
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
|
||||
@ -5237,6 +5240,22 @@ export function AdminSettingsClient() {
|
||||
</div>
|
||||
{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="space-y-0.5">
|
||||
<Label htmlFor="realtimeCanvasEnabled">实时画布功能</Label>
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
stepCountIs,
|
||||
streamText,
|
||||
} from "ai";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { unstable_cache as cache } from "next/cache";
|
||||
import { after, NextResponse } from "next/server";
|
||||
import {
|
||||
@ -71,8 +72,10 @@ import {
|
||||
getAiProviderById,
|
||||
checkUserImageQuota,
|
||||
consumeUserPoints,
|
||||
createAgentPresetExecutionSnapshot,
|
||||
createMessageTargetResolution,
|
||||
createMediaAssetRelation,
|
||||
getAccessibleAgentPresetById,
|
||||
getConversationWorkingStateByChatId,
|
||||
getMediaAssetsByUserAndUrls,
|
||||
upsertConversationWorkingState,
|
||||
@ -80,7 +83,11 @@ import {
|
||||
} from "@/lib/db/queries";
|
||||
import { db } from "@/lib/db";
|
||||
import type { DBMessage } from "@/lib/db/schema";
|
||||
import { generatedImage, message as messageTable } from "@/lib/db/schema";
|
||||
import {
|
||||
agentPreset as agentPresetTable,
|
||||
generatedImage,
|
||||
message as messageTable,
|
||||
} from "@/lib/db/schema";
|
||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||
import { resolveChatMediaInput } from "@/lib/ai/context-memory/media-input";
|
||||
import { resolveTargetAssets } from "@/lib/ai/context-memory/target-resolution";
|
||||
@ -415,6 +422,11 @@ function writeCanvasMediaChunk(
|
||||
videoUrls?: string[];
|
||||
prompt?: string | null;
|
||||
sourceImageUrls?: string[] | null;
|
||||
agentPresetId?: string | null;
|
||||
agentPresetName?: string | null;
|
||||
agentCategoryId?: string | null;
|
||||
agentCategoryName?: string | null;
|
||||
agentExecutionSnapshotId?: string | null;
|
||||
},
|
||||
label: string,
|
||||
) {
|
||||
@ -454,6 +466,12 @@ function writeCanvasMediaChunk(
|
||||
.map((url) => String(url || "").trim())
|
||||
.filter((url) => /^https?:\/\//i.test(url))
|
||||
: [],
|
||||
agentPresetId: String(params.agentPresetId || "").trim() || undefined,
|
||||
agentPresetName: String(params.agentPresetName || "").trim() || undefined,
|
||||
agentCategoryId: String(params.agentCategoryId || "").trim() || undefined,
|
||||
agentCategoryName: String(params.agentCategoryName || "").trim() || undefined,
|
||||
agentExecutionSnapshotId:
|
||||
String(params.agentExecutionSnapshotId || "").trim() || undefined,
|
||||
},
|
||||
label,
|
||||
);
|
||||
@ -2578,6 +2596,70 @@ async function saveGeneratedImages(
|
||||
}
|
||||
}
|
||||
|
||||
type AgentPresetMessageImagePart = {
|
||||
type: "file";
|
||||
url: string;
|
||||
name: string;
|
||||
mediaType: string;
|
||||
assetId?: string;
|
||||
};
|
||||
|
||||
function extractCurrentMessageImageParts(
|
||||
parts: Array<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 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
@ -2781,6 +2863,7 @@ export async function POST(request: Request) {
|
||||
const {
|
||||
id,
|
||||
message,
|
||||
agentPresetId,
|
||||
selectedChatModel,
|
||||
selectedVisibilityType,
|
||||
hiddenPrompt,
|
||||
@ -2790,6 +2873,7 @@ export async function POST(request: Request) {
|
||||
}: {
|
||||
id: string;
|
||||
message: ChatMessage;
|
||||
agentPresetId?: string;
|
||||
selectedChatModel: ChatModel["id"];
|
||||
selectedVisibilityType: VisibilityType;
|
||||
hiddenPrompt?: string;
|
||||
@ -4403,6 +4487,505 @@ export async function POST(request: Request) {
|
||||
return createSseResponse(clarificationStream);
|
||||
}
|
||||
|
||||
if (agentPresetId) {
|
||||
const selectedAgentPreset = await getAccessibleAgentPresetById({
|
||||
id: agentPresetId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
if (!selectedAgentPreset) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: "not_found:chat",
|
||||
message: "智能体不存在或无权使用",
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const orderedSlots = [...(selectedAgentPreset.slots || [])].sort(
|
||||
(left, right) => left.slotIndex - right.slotIndex,
|
||||
);
|
||||
if (orderedSlots.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: "bad_request:api",
|
||||
message: "当前智能体未配置可执行槽位",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const uploadedImageParts = extractCurrentMessageImageParts(message.parts as any[]);
|
||||
const requiredUploadCount = 3;
|
||||
|
||||
if (uploadedImageParts.length < requiredUploadCount) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: "bad_request:api",
|
||||
message: `当前智能体需要上传 ${requiredUploadCount} 张图片`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const presetModelId = String(selectedAgentPreset.defaultModelId || "").trim();
|
||||
if (!presetModelId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: "bad_request:api",
|
||||
message: "当前智能体未配置默认模型",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const sanitizedUserSupplementPrompt = normalizeAgentSupplementPrompt(
|
||||
requestTextContent,
|
||||
selectedAgentPreset.name,
|
||||
);
|
||||
|
||||
const presetSnapshot = await createAgentPresetExecutionSnapshot({
|
||||
presetId: selectedAgentPreset.id,
|
||||
userId: session.user.id,
|
||||
projectId: contextPayload?.projectId || null,
|
||||
chatId: id,
|
||||
messageId: message.id,
|
||||
presetName: selectedAgentPreset.name,
|
||||
presetType: selectedAgentPreset.type,
|
||||
visibility: selectedAgentPreset.visibility,
|
||||
categoryId: selectedAgentPreset.category?.id || selectedAgentPreset.categoryId || null,
|
||||
categoryName: selectedAgentPreset.category?.name || null,
|
||||
promptTemplate: selectedAgentPreset.promptTemplate,
|
||||
defaultModelId: selectedAgentPreset.defaultModelId || null,
|
||||
resolvedModelId: presetModelId,
|
||||
defaultParams: selectedAgentPreset.defaultParams || null,
|
||||
slotCount: orderedSlots.length,
|
||||
requiredUploadCount,
|
||||
slots: orderedSlots.map((slot) => ({
|
||||
slotIndex: slot.slotIndex,
|
||||
title: slot.title || null,
|
||||
promptTemplate: slot.promptTemplate,
|
||||
defaultParams: slot.defaultParams || null,
|
||||
requireUpload: slot.requireUpload !== false,
|
||||
uploadLabel: slot.uploadLabel || null,
|
||||
referenceAssetIds: slot.referenceAssetIds || [],
|
||||
referenceImageUrls: (slot.referenceAssets || [])
|
||||
.map((asset) => String(asset.originalUrl || "").trim())
|
||||
.filter(Boolean),
|
||||
})),
|
||||
referenceAssetIds: orderedSlots.flatMap((slot) => slot.referenceAssetIds || []),
|
||||
referenceImageUrls: orderedSlots.flatMap((slot) =>
|
||||
(slot.referenceAssets || [])
|
||||
.map((asset) => String(asset.originalUrl || "").trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
userInputText: sanitizedUserSupplementPrompt || null,
|
||||
requestPayload: {
|
||||
selectedChatModel,
|
||||
agentPresetId,
|
||||
requiredUploadCount,
|
||||
uploadedImageUrls: uploadedImageParts.map((part) => part.url),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(agentPresetTable)
|
||||
.set({
|
||||
usageCount: sql`${agentPresetTable.usageCount} + 1`,
|
||||
lastUsedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(agentPresetTable.id, selectedAgentPreset.id));
|
||||
} catch (agentUsageError) {
|
||||
console.warn("[Chat API] Failed to update agent preset usage stats:", agentUsageError);
|
||||
}
|
||||
|
||||
const agentSequenceStream = createUIMessageStream({
|
||||
execute: async ({ writer }) => {
|
||||
if (selectedAgentPreset.type === "image") {
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
"<!-- 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
|
||||
? resolveDirectAgentToolExecution(smartAutoExecution, smartAutoPlan)
|
||||
: null;
|
||||
|
||||
@ -80,6 +80,7 @@ export const postRequestBodySchema = z.object({
|
||||
}),
|
||||
hiddenPrompt: z.string().max(4000).optional(),
|
||||
autoContext: z.string().max(4000).optional(),
|
||||
agentPresetId: z.string().uuid().optional(),
|
||||
// 支持预定义模型名称或数据库中的模型 UUID
|
||||
selectedChatModel: z.string().min(1).max(128),
|
||||
selectedVisibilityType: z.enum(["public", "private"]),
|
||||
|
||||
@ -11,11 +11,16 @@ export default function CanvasRedirectPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const shouldCreateNew = searchParams.get("new") === "1";
|
||||
const passthroughParams = new URLSearchParams(searchParams.toString());
|
||||
passthroughParams.delete("new");
|
||||
const passthroughQuery = passthroughParams.toString();
|
||||
const buildCanvasUrl = (canvasId: string) =>
|
||||
`/canvas/${canvasId}${passthroughQuery ? `?${passthroughQuery}` : ""}`;
|
||||
|
||||
const openCanvas = async () => {
|
||||
if (shouldCreateNew) {
|
||||
const newId = crypto.randomUUID();
|
||||
router.replace(`/canvas/${newId}`);
|
||||
router.replace(buildCanvasUrl(newId));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -32,7 +37,7 @@ export default function CanvasRedirectPage() {
|
||||
});
|
||||
const latest = sorted[0];
|
||||
if (latest?.id) {
|
||||
router.replace(`/canvas/${latest.id}`);
|
||||
router.replace(buildCanvasUrl(latest.id));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -42,7 +47,7 @@ export default function CanvasRedirectPage() {
|
||||
}
|
||||
|
||||
const newId = crypto.randomUUID();
|
||||
router.replace(`/canvas/${newId}`);
|
||||
router.replace(buildCanvasUrl(newId));
|
||||
};
|
||||
|
||||
void openCanvas();
|
||||
|
||||
@ -71,6 +71,7 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) {
|
||||
}))}
|
||||
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
|
||||
rankingEnabled={siteSettings.rankingEnabled ?? true}
|
||||
agentEnabled={(siteSettings as any).agentEnabled ?? true}
|
||||
>
|
||||
{children}
|
||||
</ChatShell>
|
||||
|
||||
81
app/api/agent-categories/route.ts
Normal file
81
app/api/agent-categories/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/api/agent-execution-snapshots/[id]/route.ts
Normal file
36
app/api/agent-execution-snapshots/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
47
app/api/agents/[id]/fork/route.ts
Normal file
47
app/api/agents/[id]/fork/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
154
app/api/agents/[id]/route.ts
Normal file
154
app/api/agents/[id]/route.ts
Normal 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
154
app/api/agents/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -336,6 +336,7 @@ export async function GET() {
|
||||
tutorialEnabled: false,
|
||||
rankingEnabled: true,
|
||||
canvasEnabled: true,
|
||||
agentEnabled: true,
|
||||
realtimeCanvasEnabled: true,
|
||||
realtimeCanvasServiceEnabled: true,
|
||||
storageFailoverConfig,
|
||||
@ -424,6 +425,7 @@ const updateSchema = z.object({
|
||||
tutorialEnabled: z.boolean().optional(),
|
||||
rankingEnabled: z.boolean().optional(),
|
||||
canvasEnabled: z.boolean().optional(),
|
||||
agentEnabled: z.boolean().optional(),
|
||||
realtimeCanvasEnabled: z.boolean().optional(),
|
||||
aiDetailGenerationEnabled: z.boolean().optional(),
|
||||
canvasDefaultModelId: z.string().nullable().optional(),
|
||||
@ -518,6 +520,7 @@ export async function PUT(request: NextRequest) {
|
||||
.update(siteSettings)
|
||||
.set({
|
||||
...siteSettingsPayload,
|
||||
agentEnabled: siteSettingsPayload.agentEnabled ?? existing.agentEnabled ?? true,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
@ -881,6 +884,7 @@ export async function PUT(request: NextRequest) {
|
||||
tutorialEnabled: siteSettingsPayload.tutorialEnabled ?? false,
|
||||
rankingEnabled: siteSettingsPayload.rankingEnabled ?? true,
|
||||
canvasEnabled: siteSettingsPayload.canvasEnabled ?? true,
|
||||
agentEnabled: siteSettingsPayload.agentEnabled ?? true,
|
||||
realtimeCanvasEnabled: siteSettingsPayload.realtimeCanvasEnabled ?? true,
|
||||
canvasDefaultModelId: siteSettingsPayload.canvasDefaultModelId,
|
||||
canvasAutoModeDefaultModelId: siteSettingsPayload.canvasAutoModeDefaultModelId,
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, use, useRef } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
AlertCircle,
|
||||
Save,
|
||||
Undo2,
|
||||
Redo2,
|
||||
@ -56,6 +57,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { toast } from "sonner";
|
||||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||||
import { canvasService, imageGenerationService } from "@/services/canvas";
|
||||
import { AgentPresetPanel } from "@/components/agents/AgentPresetPanel";
|
||||
import { CanvasWorkspace } from "@/components/canvas";
|
||||
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
|
||||
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
|
||||
@ -88,8 +90,10 @@ const FLOATING_BOTTOM_INSET = 20;
|
||||
export default function CanvasEditorPage({ params }: PageProps) {
|
||||
// 使用 useParams 确保客户端导航时能正确获取更新的 id
|
||||
const routeParams = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const id = (routeParams?.id as string) || use(params).id;
|
||||
const router = useRouter();
|
||||
const mode = searchParams.get("mode");
|
||||
const goHome = useCallback(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign("/");
|
||||
@ -138,6 +142,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
siteName?: string;
|
||||
siteLogo?: string | null;
|
||||
siteLogoDark?: string | null;
|
||||
agentEnabled?: boolean;
|
||||
realtimeCanvasEnabled?: boolean;
|
||||
realtimeCanvasServiceEnabled?: boolean;
|
||||
}>("/api/site-settings", fetcher);
|
||||
@ -148,6 +153,11 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
const [aspectRatio, setAspectRatio] = useState("1:1");
|
||||
const [imageCount, setImageCount] = useState(1);
|
||||
const previousNodeIdsRef = useRef<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
|
||||
@ -155,12 +165,63 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
: null;
|
||||
|
||||
const resolvedPanelWidth = viewportWidth > 0
|
||||
? Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0))
|
||||
? isAgentMode
|
||||
? Math.min(
|
||||
520,
|
||||
Math.max(Math.floor((viewportWidth - DOCKED_PANEL_INSET * 2) / 2), 340)
|
||||
)
|
||||
: Math.min(FLOATING_PANEL_WIDTH, Math.max(viewportWidth - DOCKED_PANEL_INSET, 0))
|
||||
: FLOATING_PANEL_WIDTH;
|
||||
const agentPanelWidth = isAgentMode ? resolvedPanelWidth : 0;
|
||||
const panelOcclusionRight = isPanelOpen ? resolvedPanelWidth + DOCKED_PANEL_INSET : 0;
|
||||
const floatingToolbarOffset = isPanelOpen ? -(panelOcclusionRight / 2) : 0;
|
||||
const compactControlsLeft = FLOATING_UI_GAP;
|
||||
|
||||
const updateCanvasSearchParams = useCallback(
|
||||
(updates: Record<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(() => {
|
||||
console.log('[Canvas] Loading project:', id);
|
||||
@ -202,6 +263,13 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
return () => window.removeEventListener("resize", updateViewportWidth);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAgentMode) {
|
||||
setIsPanelOpen(true);
|
||||
setPanelMode("chat");
|
||||
}
|
||||
}, [isAgentMode]);
|
||||
|
||||
const persistCanvasState = useCallback(
|
||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||
const canvasData = getCanvasState();
|
||||
@ -669,6 +737,16 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
</Button>
|
||||
</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) */}
|
||||
{!isPanelOpen && (
|
||||
<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) */}
|
||||
{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(
|
||||
"absolute z-10 transition-all duration-300 ease-in-out",
|
||||
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}`}
|
||||
onClose={() => setIsPanelOpen(false)}
|
||||
onOpenFileList={() => setPanelMode('fileList')}
|
||||
selectedAgentPresetId={isAgentFeatureEnabled ? selectedAgentPresetId : null}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@ -754,6 +853,23 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
||||
<TooltipContent side="top">平移 (H)</TooltipContent>
|
||||
</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>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
54
components/agents/AgentCategoryTabs.tsx
Normal file
54
components/agents/AgentCategoryTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
components/agents/AgentExecutionSnapshotDialog.tsx
Normal file
155
components/agents/AgentExecutionSnapshotDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
components/agents/AgentPresetCard.tsx
Normal file
111
components/agents/AgentPresetCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
632
components/agents/AgentPresetDialog.tsx
Normal file
632
components/agents/AgentPresetDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
components/agents/AgentPresetPanel.tsx
Normal file
208
components/agents/AgentPresetPanel.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -48,6 +48,7 @@ import {
|
||||
} from "@/lib/image-url";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import type { AgentPreset } from "@/types/agent";
|
||||
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
|
||||
import {
|
||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
||||
@ -88,6 +89,11 @@ type CanvasMediaStreamPayload = {
|
||||
videoUrls?: string[];
|
||||
prompt?: string;
|
||||
sourceImageUrls?: string[];
|
||||
agentPresetId?: string;
|
||||
agentPresetName?: string;
|
||||
agentCategoryId?: string;
|
||||
agentCategoryName?: string;
|
||||
agentExecutionSnapshotId?: string;
|
||||
};
|
||||
|
||||
type ImageGenerationStatusStreamPayload = {
|
||||
@ -102,6 +108,7 @@ interface CanvasChatPanelProps {
|
||||
initialMessages?: ChatMessage[];
|
||||
onClose?: () => void;
|
||||
onOpenFileList?: () => void;
|
||||
selectedAgentPresetId?: string | null;
|
||||
}
|
||||
|
||||
type VideoStoryboardStreamData =
|
||||
@ -1579,7 +1586,13 @@ function buildDetailReviewAppendMarkdown(params: {
|
||||
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 [input, setInput] = useState("");
|
||||
const [attachments, setAttachments] = useState<PickedObjectAttachment[]>([]);
|
||||
@ -1641,6 +1654,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const detailModeSwitchRef = useRef(false);
|
||||
@ -1669,6 +1683,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
});
|
||||
const [videoParams, setVideoParams] = useState<VideoGenerationParams | null>(null);
|
||||
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null);
|
||||
const [agentSlotUploading, setAgentSlotUploading] = useState<number | null>(null);
|
||||
const [lastImageModelId, setLastImageModelId] = useState<string | null>(null);
|
||||
const [lastVideoModelId, setLastVideoModelId] = 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();
|
||||
}
|
||||
);
|
||||
const { data: agentPresetResponse } = useSWR<{ success?: boolean; data?: AgentPreset }>(
|
||||
selectedAgentPresetId ? `/api/agents/${selectedAgentPresetId}` : null,
|
||||
fetcher
|
||||
);
|
||||
const greetingUserName = useMemo(() => {
|
||||
const userName = String(session?.user?.name || "").trim();
|
||||
if (userName) return userName;
|
||||
@ -2678,6 +2697,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
() => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null,
|
||||
[canvasSkillTags, selectedSkillTagId]
|
||||
);
|
||||
const selectedAgentPreset = useMemo(
|
||||
() => (agentPresetResponse?.success && agentPresetResponse.data ? agentPresetResponse.data : null),
|
||||
[agentPresetResponse]
|
||||
);
|
||||
const SelectedSkillTagIcon = useMemo(
|
||||
() => getCanvasSkillIconComponent(selectedSkillTag?.iconName),
|
||||
[selectedSkillTag?.iconName]
|
||||
@ -2835,6 +2858,29 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
if (videoParams.mode === "first-frame") return ["首帧"] as string[];
|
||||
return [] as string[];
|
||||
}, [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:视频模式时填充首尾帧槽位,非视频模式插入对话标签
|
||||
useEffect(() => {
|
||||
@ -2899,6 +2945,56 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
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) {
|
||||
const slotCount = videoFrameSlots.length;
|
||||
const currentSlots: Array<PickedObjectAttachment | null> = Array.from(
|
||||
@ -2966,6 +3062,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
pendingChatAttachments,
|
||||
isDetailGenerationMode,
|
||||
detailSlotPickerIndex,
|
||||
hasAgentUploadSlots,
|
||||
agentUploadSlots.length,
|
||||
isVideoModel,
|
||||
videoFrameSlots.length,
|
||||
attachments,
|
||||
@ -2981,12 +3079,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
() => videoOnlyModels[0]?.id || null,
|
||||
[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 =
|
||||
activeComposerMode === "video"
|
||||
? "视频"
|
||||
@ -3028,6 +3120,31 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
setSelectedModelId(modelSelectValue);
|
||||
}, [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(() => {
|
||||
if (isDetailGenerationMode) {
|
||||
setAttachments([]);
|
||||
@ -3085,6 +3202,15 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
});
|
||||
}, [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(() => {
|
||||
if (!currentModel) return;
|
||||
if (currentModel.id !== "auto" && isDetailGenerationMode) {
|
||||
@ -3377,6 +3503,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
: [];
|
||||
const messageId = String(payload.messageId || "").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 addedVideoCount = 0;
|
||||
|
||||
@ -3438,6 +3570,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
sourceMessageId: messageId || undefined,
|
||||
sourceImageKey,
|
||||
compareSourceUrl: canUseCompareSource ? compareSourceUrl : undefined,
|
||||
agentPresetId,
|
||||
agentPresetName,
|
||||
agentCategoryId,
|
||||
agentCategoryName,
|
||||
agentExecutionSnapshotId,
|
||||
},
|
||||
},
|
||||
connections: sourceNodeId ? [sourceNodeId] : [],
|
||||
@ -3465,6 +3602,13 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
src: url,
|
||||
status: "completed",
|
||||
prompt: generationPrompt,
|
||||
metadata: {
|
||||
agentPresetId,
|
||||
agentPresetName,
|
||||
agentCategoryId,
|
||||
agentCategoryName,
|
||||
agentExecutionSnapshotId,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
@ -3529,6 +3673,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
body: {
|
||||
id: rawId,
|
||||
message: request.messages.at(-1),
|
||||
agentPresetId:
|
||||
activeComposerMode === "agent" && selectedAgentPreset?.id
|
||||
? selectedAgentPreset.id
|
||||
: undefined,
|
||||
selectedChatModel: currentModelIdRef.current,
|
||||
selectedVisibilityType: "private",
|
||||
deepThinkingEnabled: requestIsConversationModel
|
||||
@ -4480,6 +4628,24 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
[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(
|
||||
(slotIndex: number) => {
|
||||
const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0];
|
||||
@ -4494,6 +4660,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
[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(
|
||||
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
@ -4566,6 +4746,80 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
[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(
|
||||
(slotIndex: number, attachment: PickedObjectAttachment | null) => {
|
||||
setDetailSlots((current) => {
|
||||
@ -6354,6 +6608,16 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
const parts: any[] = [];
|
||||
|
||||
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
|
||||
? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability)
|
||||
: null;
|
||||
@ -6428,6 +6692,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
if (!textContent && effectiveAttachments.length > 0) {
|
||||
textContent = "请处理这张图片";
|
||||
}
|
||||
if (!textContent && hasAgentUploadSlots && selectedAgentPreset) {
|
||||
textContent = `请按「${selectedAgentPreset.name}」智能体配置顺序处理产品图1、产品图2和模特图。`;
|
||||
}
|
||||
if (!textContent && selectedSkillTag) {
|
||||
textContent = `请按「${selectedSkillTag.name}」标签的要求处理。`;
|
||||
}
|
||||
@ -6529,9 +6796,12 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
}, [
|
||||
activeChatId,
|
||||
activeComposerMode,
|
||||
agentRequiredUploadCount,
|
||||
input,
|
||||
attachments,
|
||||
hasAgentUploadSlots,
|
||||
selectedSkillTag,
|
||||
selectedAgentPreset,
|
||||
selectedModelId,
|
||||
sendMessage,
|
||||
clearSelection,
|
||||
@ -6674,6 +6944,47 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
const baseNodeCount = nodes.length;
|
||||
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) {
|
||||
const slotCount = videoFrameSlots.length;
|
||||
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
|
||||
@ -6745,11 +7056,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
|
||||
void maybeAutoSetCanvasTitle(firstImageUrl);
|
||||
}, [
|
||||
agentUploadSlots.length,
|
||||
attachments,
|
||||
hasAgentUploadSlots,
|
||||
insertImageTagInEditor,
|
||||
isVideoModel,
|
||||
maybeAutoSetCanvasTitle,
|
||||
nodes.length,
|
||||
updateAttachmentAtAgentSlot,
|
||||
updateAttachmentAtVideoSlot,
|
||||
uploadImageFileAndAddToCanvas,
|
||||
videoFrameSlots.length,
|
||||
@ -6764,6 +7078,54 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
// 记录本次上传开始时的节点数量,用于计算新图片位置
|
||||
const baseNodeCount = nodes.length;
|
||||
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)) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
@ -6922,8 +7284,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
const hasInputOrAttachments = Boolean(
|
||||
input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag)
|
||||
);
|
||||
const hasEnoughAgentUploads = !hasAgentUploadSlots || attachments.length >= agentRequiredUploadCount;
|
||||
const composerPlaceholder = isDetailGenerationMode
|
||||
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
|
||||
: hasAgentUploadSlots
|
||||
? "请先上传产品图1、产品图2、模特图,再补充说明"
|
||||
: selectedSkillTag?.placeholder?.trim() ||
|
||||
(selectedSkillTag
|
||||
? `请输入「${selectedSkillTag.name}」相关需求`
|
||||
@ -6937,7 +7302,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
: 0;
|
||||
const canSend = isDetailGenerationMode
|
||||
? detailValidationIssues.length === 0 && !isDetailGenerating
|
||||
: hasInputOrAttachments;
|
||||
: hasInputOrAttachments && hasEnoughAgentUploads;
|
||||
const currentVideoImageLimit = isVideoModel
|
||||
? getVideoImageLimit(videoParams, currentModel?.providerType, currentModel?.videoToolCapability)
|
||||
: null;
|
||||
@ -8673,6 +9038,19 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
</button>
|
||||
</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
|
||||
ref={textareaRef as any}
|
||||
contentEditable={!isSubmitting}
|
||||
@ -8689,7 +9067,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
setInput(textContent);
|
||||
if (!isVideoModel || videoFrameSlots.length === 0) {
|
||||
if ((!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
|
||||
syncAttachmentsWithEditor(target);
|
||||
}
|
||||
}}
|
||||
@ -8702,7 +9080,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
handleSend();
|
||||
}
|
||||
// 处理 Backspace 删除图片标签(只删除聊天框中的标签,不影响画布)
|
||||
if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0)) {
|
||||
if (e.key === 'Backspace' && (!isVideoModel || videoFrameSlots.length === 0) && !hasAgentUploadSlots) {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
@ -8741,6 +9119,74 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
|
||||
/>
|
||||
</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 && (
|
||||
<div className="mb-11 px-4 pb-1">
|
||||
<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>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
||||
@ -34,6 +34,7 @@ import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { AngleEditDialog } from "@/components/angle-edit-dialog";
|
||||
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
||||
import { ImageAnnotator } from "@/components/image-annotator";
|
||||
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
|
||||
import type { PickedObject } from "@/store/canvas";
|
||||
|
||||
// 已标注的物品标记(本地状态)
|
||||
@ -310,6 +311,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||
const [textEditPanelOpen, setTextEditPanelOpen] = useState(false);
|
||||
const [notePanelOpen, setNotePanelOpen] = useState(false);
|
||||
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
|
||||
const [textEditItems, setTextEditItems] = useState<ImageTextEditItem[]>(
|
||||
() => normalizeTextEditItems(data.textEditItems),
|
||||
);
|
||||
@ -361,6 +363,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
// 当前正在识别的点击位置
|
||||
const [currentClick, setCurrentClick] = useState<{ x: number; y: number } | null>(null);
|
||||
const displayTitle = getImageNodeTitle(data, node.id);
|
||||
const agentExecutionSnapshotId =
|
||||
typeof data.metadata?.agentExecutionSnapshotId === "string"
|
||||
? data.metadata.agentExecutionSnapshotId.trim()
|
||||
: "";
|
||||
const shortAgentExecutionSnapshotId = agentExecutionSnapshotId
|
||||
? agentExecutionSnapshotId.slice(0, 8)
|
||||
: "";
|
||||
const isMultiSelected = isSelected && selectedNodeIds.length > 1;
|
||||
const compareSource = useMemo(() => {
|
||||
const targetIdentity = normalizeImageIdentityUrl(data.src || "");
|
||||
@ -2759,6 +2768,19 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
备注
|
||||
</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>
|
||||
|
||||
{/* 角度编辑对话框 */}
|
||||
@ -2770,6 +2792,11 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
onComplete={handleAngleEditComplete}
|
||||
/>
|
||||
)}
|
||||
<AgentExecutionSnapshotDialog
|
||||
snapshotId={agentExecutionSnapshotId || null}
|
||||
open={snapshotDialogOpen}
|
||||
onOpenChange={setSnapshotDialogOpen}
|
||||
/>
|
||||
|
||||
{/* 批量处理对话框 */}
|
||||
<SceneFusionDialog
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog";
|
||||
import { toast } from "sonner";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import type { CanvasNode, VideoNodeData } from "@/types/canvas";
|
||||
@ -23,11 +24,19 @@ interface VideoNodeProps {
|
||||
|
||||
export function VideoNode({ node, isSelected }: VideoNodeProps) {
|
||||
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 [isHovered, setIsHovered] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(true);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
|
||||
|
||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||
@ -262,6 +271,22 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
|
||||
</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 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
|
||||
|
||||
@ -40,6 +40,7 @@ type ChatShellProps = {
|
||||
tutorialVideos: TutorialVideo[];
|
||||
tutorialEnabled: boolean;
|
||||
rankingEnabled: boolean;
|
||||
agentEnabled: boolean;
|
||||
};
|
||||
|
||||
export function ChatShell({
|
||||
@ -59,6 +60,7 @@ export function ChatShell({
|
||||
tutorialVideos,
|
||||
tutorialEnabled,
|
||||
rankingEnabled,
|
||||
agentEnabled,
|
||||
}: ChatShellProps) {
|
||||
const pathname = usePathname();
|
||||
const isLovartHome = pathname === "/" && homeVariant === "lovart";
|
||||
@ -82,6 +84,7 @@ export function ChatShell({
|
||||
tutorialVideos={tutorialVideos}
|
||||
tutorialEnabled={tutorialEnabled}
|
||||
rankingEnabled={rankingEnabled}
|
||||
agentEnabled={agentEnabled}
|
||||
/>
|
||||
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
|
||||
<AppSidebar user={user} />
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { useTheme } from "next-themes";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
@ -56,6 +56,7 @@ type TopNavbarProps = {
|
||||
tutorialVideos?: TutorialVideo[];
|
||||
tutorialEnabled?: boolean;
|
||||
rankingEnabled?: boolean;
|
||||
agentEnabled?: boolean;
|
||||
};
|
||||
|
||||
export function TopNavbar({
|
||||
@ -71,6 +72,7 @@ export function TopNavbar({
|
||||
tutorialVideos = [],
|
||||
tutorialEnabled = false,
|
||||
rankingEnabled = true,
|
||||
agentEnabled = true,
|
||||
}: TopNavbarProps) {
|
||||
const router = useRouter();
|
||||
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 "设置";
|
||||
@ -443,6 +446,21 @@ export function TopNavbar({
|
||||
<span className={navLabelClass}>{getShortLabel("图片编辑")}</span>
|
||||
</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
|
||||
className={navItemBaseClass}
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
# 智能体页面开发文档(基于当前 `ai-chatbot-main`)
|
||||
|
||||
> 目标:在现有“无限画布 + 画布对话 + 多模型生成”体系上,新增一个独立的“智能体”工作台页面。
|
||||
> 页面形态:左侧半屏展示生成结果画布,右侧半屏展示智能体工具栏;支持公共/私有智能体、分类筛选、用户自建智能体、自建分类、预设词和参考图注入。
|
||||
> 目标:在现有“无限画布 + 画布对话 + 多模型生成”体系上,直接基于现有画布页改造出“智能体模式”。
|
||||
> 页面形态:一侧为智能体界面,另一侧为现有对话框界面;底层仍使用画布项目、画布对话和媒体落盘能力。
|
||||
|
||||
---
|
||||
|
||||
## 1. 需求目标
|
||||
|
||||
本期不是重做一套生成系统,而是在现有项目基础上新增“智能体工作台”:
|
||||
本期不是重做一套生成系统,而是在现有项目基础上,把现有画布页改造成“智能体模式”:
|
||||
|
||||
1. 新增一个页面,名称为“智能体”
|
||||
2. 页面左半区复用现有无限画布能力,用于展示生成的图片/视频结果
|
||||
3. 页面右半区为智能体面板,分为:
|
||||
1. 不新增独立业务形态,仍然基于现有画布界面
|
||||
2. 画布页在“智能体模式”下采用左右双栏:
|
||||
- 一侧为智能体面板
|
||||
- 一侧为现有对话框界面
|
||||
3. 智能体面板分为:
|
||||
- 公共智能体区
|
||||
- 私有智能体区
|
||||
4. 公共智能体区支持按分类筛选查看
|
||||
@ -22,16 +24,20 @@
|
||||
- 类型:`生图模型` / `视频模型`
|
||||
- 自定义名称
|
||||
- 智能体分类
|
||||
- 预设提示词
|
||||
- 参考图
|
||||
- 选择对应的模型
|
||||
- 多个槽位配置
|
||||
- 每个槽位一套提示词
|
||||
- 每个槽位对应一组参考图
|
||||
- 默认参数模板
|
||||
- 公开 / 私有
|
||||
7. 用户点击某个智能体后,可将其以“标签/卡片”的形式挂载到页面底部对话框
|
||||
7. 用户点击某个智能体后,可将其以“标签/卡片”的形式挂载到现有对话框界面
|
||||
8. 后端在发送时自动注入该智能体的预设提示词和参考图
|
||||
9. 用户只需:
|
||||
- 上传自己的图片
|
||||
9. 用户只需在现有对话框中:
|
||||
- 按槽位数量上传对应数量的图片
|
||||
- 可选补充提示词
|
||||
- 点击发送
|
||||
10. 后端完成生图或生视频后,结果直接落到左侧画布区显示
|
||||
10. 后端会按槽位顺序一张张生成
|
||||
11. 后端完成生图或生视频后,结果先展示在现有对话消息流里,并继续落盘到画布项目数据中
|
||||
|
||||
---
|
||||
|
||||
@ -91,39 +97,48 @@
|
||||
5. 主要依赖前端拼 `hiddenPrompt`
|
||||
6. 无法形成正式智能体资产
|
||||
|
||||
因此本功能建议升级为独立的数据模型,而不是继续放在 `SystemSettings` 或 `canvasSkillTags` 中。
|
||||
因此本功能建议升级为独立的数据模型,而不是继续放在 `SystemSettings` 或 `canvasSkillTags` 中;但前端页面层面不单独新开一页,而是直接复用当前画布页。
|
||||
|
||||
---
|
||||
|
||||
## 3. 产品形态建议
|
||||
|
||||
### 3.1 页面路由
|
||||
### 3.1 页面入口
|
||||
|
||||
建议新增独立工作台路由,而不是塞进现有 `CanvasChatPanel`:
|
||||
建议不新增 `/agents` 独立路由,直接基于现有画布页实现:
|
||||
|
||||
- 入口页:`/agents`
|
||||
- 工作页:`/agents/[id]`
|
||||
- 继续使用:`/canvas/[id]`
|
||||
- 增加模式概念:
|
||||
- 标准画布模式
|
||||
- 智能体模式
|
||||
|
||||
推荐行为:
|
||||
|
||||
1. 访问 `/agents` 时自动打开最近一次智能体工作台
|
||||
2. 若没有工作台,则创建一个新的工作台
|
||||
3. 工作台底层仍复用 `CanvasProject`
|
||||
1. 访问 `/canvas/[id]` 时可通过顶部入口或参数切到“智能体模式”
|
||||
2. 智能体模式底层仍复用 `CanvasProject`
|
||||
3. 生成结果仍落到当前画布项目中
|
||||
4. 后续如需恢复可视化画布编辑,可在同一项目里切回标准画布模式
|
||||
|
||||
### 3.2 页面布局
|
||||
|
||||
桌面端建议采用左右 1:1 分栏:
|
||||
|
||||
- 左侧 50%:结果画布区
|
||||
- 复用 `CanvasWorkspace`
|
||||
- 只展示当前智能体工作台结果
|
||||
- 底部固定一个对话输入区
|
||||
- 右侧 50%:智能体工具区
|
||||
- 左侧 50%:智能体工具区
|
||||
- 顶部:搜索 / 类型筛选 / 新建按钮
|
||||
- 公共区顶部:分类筛选栏(全部 + 分类 Tab/下拉)
|
||||
- 中部:公共智能体区
|
||||
- 下部:私有智能体区
|
||||
- 点击卡片后变为当前激活智能体
|
||||
- 右侧 50%:现有对话框界面
|
||||
- 直接复用 `CanvasChatPanel`
|
||||
- 保留原有消息流、上传、模型选择、发送能力
|
||||
- 智能体只作为额外的预设注入层
|
||||
|
||||
说明:
|
||||
|
||||
- 首版不再把半屏留给 `CanvasWorkspace` 常驻展示
|
||||
- 结果展示以现有对话消息流为主
|
||||
- 生成结果仍继续落盘到画布项目,后续可以通过“切回标准画布模式”查看和编辑
|
||||
|
||||
### 3.3 交互约束
|
||||
|
||||
@ -136,6 +151,8 @@ MVP 建议限制:
|
||||
3. 创建为公开后,立即出现在公共区
|
||||
4. 参考图统一绑定媒体资产
|
||||
5. 公共区默认只支持单分类筛选
|
||||
6. 每次执行都生成一份“执行快照”,用于复盘和复现
|
||||
7. 首版不改造原对话框核心交互,只在其上叠加智能体注入能力
|
||||
|
||||
---
|
||||
|
||||
@ -145,48 +162,86 @@ MVP 建议限制:
|
||||
|
||||
1. 用户点击“新建智能体”
|
||||
2. 弹出表单,填写:
|
||||
- 智能体头像
|
||||
- 智能体名称
|
||||
- 智能体类型(生图 / 视频)
|
||||
- 可见性(公开 / 私有)
|
||||
- 智能体分类(选择已有分类 / 新建分类)
|
||||
- 预设提示词
|
||||
- 默认模型
|
||||
- 参考图(可多张)
|
||||
- 对应模型(生图智能体只能选生图模型,视频智能体只能选视频模型)
|
||||
- 多个槽位(支持新增/删除/排序)
|
||||
- 每个槽位填写:
|
||||
- 提示词
|
||||
- 参考图(可多张)
|
||||
- 可选参数覆盖
|
||||
3. 前端先调用上传接口上传参考图
|
||||
4. 后端保存智能体记录
|
||||
5. 若勾选“公开”,则直接出现在公共区,并归入对应分类
|
||||
6. 若勾选“私有”,则出现在私有区;分类字段保留,便于后续扩展
|
||||
|
||||
### 4.1.1 公共智能体另存为私有
|
||||
|
||||
建议首版就支持:
|
||||
|
||||
1. 用户在公共智能体卡片上点击“另存为我的智能体”
|
||||
2. 系统复制一份智能体到当前用户名下
|
||||
3. 默认写入:
|
||||
- `visibility=private`
|
||||
- `forkedFromPresetId=原公共智能体ID`
|
||||
4. 用户可继续修改:
|
||||
- 名称
|
||||
- 提示词
|
||||
- 默认模型
|
||||
- 默认参数模板
|
||||
- 参考图
|
||||
|
||||
这样更符合真实使用习惯:公共智能体用于发现,私有智能体用于个性化沉淀。
|
||||
|
||||
### 4.2 使用智能体
|
||||
|
||||
1. 用户点击某个智能体卡片
|
||||
2. 底部对话框出现智能体标签
|
||||
3. 标签展示:
|
||||
- 头像
|
||||
- 名称
|
||||
- 类型
|
||||
- 分类
|
||||
- 默认模型
|
||||
- 参考图数量
|
||||
4. 用户上传图片
|
||||
- 槽位数量
|
||||
- 需要上传的图片数量
|
||||
4. 用户统一上传 3 张素材图:
|
||||
- 产品图1
|
||||
- 产品图2
|
||||
- 模特图
|
||||
5. 用户可补充一句额外提示词
|
||||
6. 点击发送
|
||||
|
||||
使用约束:
|
||||
|
||||
- 即使智能体配置了 10 个槽位,前端也只提示上传 3 张素材图
|
||||
- 上传数量不足时,不允许发送
|
||||
- 这 3 张素材图会被后端在所有槽位中重复使用
|
||||
|
||||
### 4.3 后端执行
|
||||
|
||||
1. 请求携带 `agentPresetId`
|
||||
2. 后端查询智能体定义
|
||||
3. 后端校验当前用户是否有权限使用
|
||||
4. 后端合并:
|
||||
- 智能体预设提示词
|
||||
- 智能体参考图
|
||||
- 用户上传图片
|
||||
- 智能体槽位列表
|
||||
- 每个槽位的提示词
|
||||
- 每个槽位的参考图
|
||||
- 智能体默认参数模板
|
||||
- 用户上传的 3 张素材图
|
||||
- 用户补充提示词
|
||||
- 当前工作台上下文
|
||||
5. 根据智能体类型进入:
|
||||
- 图片生成链路
|
||||
- 视频生成链路
|
||||
6. 结果通过现有流式消息返回
|
||||
7. 左侧画布自动新增图片或视频节点
|
||||
6. 后端按槽位顺序串行执行
|
||||
7. 每完成一个槽位,先回一条结果
|
||||
8. 结果通过现有流式消息返回
|
||||
9. 结果先回到现有对话框消息流
|
||||
10. 同时继续写入当前画布项目数据,便于后续切回标准画布查看
|
||||
|
||||
### 4.4 结果展示
|
||||
|
||||
@ -202,32 +257,35 @@ MVP 建议限制:
|
||||
### 5.1 总体原则
|
||||
|
||||
1. 不新造一套生成后端,继续复用 `/api/chat`
|
||||
2. 不复制一套画布,继续复用 `CanvasWorkspace`
|
||||
3. 不继续使用系统设置存智能体,改为正式数据表
|
||||
4. 智能体 prompt 必须由服务端注入
|
||||
5. 结果继续走现有流式落画布逻辑
|
||||
2. 不新造独立页面,直接改造现有 `/canvas/[id]`
|
||||
3. 不复制现有对话框,继续复用 `CanvasChatPanel`
|
||||
4. 不继续使用系统设置存智能体,改为正式数据表
|
||||
5. 智能体 prompt 必须由服务端注入
|
||||
6. 结果继续走现有消息流展示与画布项目落盘逻辑
|
||||
7. 每次发送必须持久化一份不可变的执行快照
|
||||
|
||||
### 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/AgentPresetCard.tsx`
|
||||
- `components/agents/AgentCategoryTabs.tsx`
|
||||
- `components/agents/AgentPresetDialog.tsx`
|
||||
- `components/agents/AgentComposer.tsx`
|
||||
- `services/agents/index.ts`
|
||||
- `types/agent.ts`
|
||||
- `store/agent-workbench.ts`
|
||||
|
||||
建议修改文件:
|
||||
|
||||
- `app/canvas/[id]/page.tsx`
|
||||
- 增加“智能体模式”布局
|
||||
- 将主界面切为“智能体面板 + 原对话框界面”
|
||||
- `components/top-navbar.tsx`
|
||||
- 新增“智能体”入口
|
||||
- 入口行为建议直接跳转到最近画布并以 `mode=agent` 打开
|
||||
- `components/canvas/CanvasChatPanel.tsx`
|
||||
- 接收 `agentPresetId` / 智能体标签注入
|
||||
- `app/(chat)/api/chat/schema.ts`
|
||||
- 扩展请求体,允许传入 `agentPresetId`
|
||||
- `app/(chat)/api/chat/route.ts`
|
||||
@ -239,32 +297,25 @@ MVP 建议限制:
|
||||
|
||||
### 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` |
|
||||
| `visibility` | varchar(16) | `public` / `private` |
|
||||
| `categoryId` | uuid nullable | 智能体分类 |
|
||||
| `promptTemplate` | text | 预设提示词 |
|
||||
| `promptTemplate` | text | 首槽位提示词摘要/检索字段 |
|
||||
| `description` | text | 可选描述 |
|
||||
| `defaultModelId` | uuid nullable | 默认模型,关联 `AiModel.id` |
|
||||
| `coverAssetId` | uuid nullable | 封面图,可选 |
|
||||
| `defaultParams` | jsonb nullable | 默认参数模板 |
|
||||
| `slotCount` | integer | 槽位数量 |
|
||||
| `requiredUploadCount` | integer | 需要上传的图片数量 |
|
||||
| `coverAssetId` | uuid nullable | 智能体头像 |
|
||||
| `status` | varchar(16) | `active` / `disabled` |
|
||||
| `sortOrder` | integer | 排序 |
|
||||
| `usageCount` | integer | 使用次数 |
|
||||
| `lastUsedAt` | timestamp nullable | 最近使用时间 |
|
||||
| `forkedFromPresetId` | uuid nullable | 从哪个公共智能体复制而来 |
|
||||
| `createdAt` | timestamp | 创建时间 |
|
||||
| `updatedAt` | timestamp | 更新时间 |
|
||||
|
||||
@ -295,10 +352,35 @@ MVP 建议限制:
|
||||
|
||||
- `visibility=public`:所有已登录用户可见
|
||||
- `visibility=private`:仅创建者可见
|
||||
- `promptTemplate` 为服务端最终合成提示词的主模板
|
||||
- `promptTemplate` 退化为摘要字段,真正执行以槽位配置为准
|
||||
- `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` 分类
|
||||
- 若后续要治理分类,可以再补后台合并/禁用能力
|
||||
- 分类创建时应先做 `slug` 标准化,再做重名复用,避免出现多个语义相同分类
|
||||
- 后续后台需支持“分类合并”,用于把重复分类归并到一个正式分类下
|
||||
|
||||
### 6.3 新增表:`AgentPresetReferenceAsset`
|
||||
### 6.4 新增表:`AgentPresetReferenceAsset`
|
||||
|
||||
用于绑定参考图,避免把 URL 直接写死在智能体表。
|
||||
|
||||
@ -329,6 +413,7 @@ MVP 建议限制:
|
||||
|---|---|---|
|
||||
| `id` | uuid | 主键 |
|
||||
| `presetId` | uuid | 关联 `AgentPreset.id` |
|
||||
| `slotId` | uuid nullable | 关联 `AgentPresetSlot.id` |
|
||||
| `assetId` | uuid | 关联 `MediaAsset.id` |
|
||||
| `sortOrder` | integer | 参考图顺序 |
|
||||
| `createdAt` | timestamp | 创建时间 |
|
||||
@ -336,11 +421,11 @@ MVP 建议限制:
|
||||
说明:
|
||||
|
||||
- 参考图建议全部走 `MediaAsset`
|
||||
- 每张参考图应归属于某个槽位
|
||||
- 后续若要做公共智能体复制、统计、缓存,会更稳定
|
||||
### 6.5 扩展表:`CanvasProject`
|
||||
|
||||
### 6.4 扩展表:`CanvasProject`
|
||||
|
||||
为了让 `/agents` 能像 `/canvas` 一样恢复最近工作区,建议给 `CanvasProject` 增加:
|
||||
为了让同一个画布项目支持“标准画布模式 / 智能体模式”切换,建议给 `CanvasProject` 增加:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
@ -349,9 +434,44 @@ MVP 建议限制:
|
||||
默认值:
|
||||
|
||||
- 旧画布默认 `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`,本期建议新增:
|
||||
|
||||
@ -373,6 +493,7 @@ MVP 建议限制:
|
||||
- `type=image | video`
|
||||
- `categoryId=xxx`
|
||||
- `keyword=xxx`
|
||||
- `sort=latest | hottest | most_used`
|
||||
|
||||
返回建议:
|
||||
|
||||
@ -400,9 +521,23 @@ MVP 建议限制:
|
||||
"visibility": "private",
|
||||
"categoryId": "uuid",
|
||||
"newCategoryName": "电商主图",
|
||||
"promptTemplate": "你是资深电商视觉设计师,请在保持主体一致的前提下优化主图视觉冲击力。",
|
||||
"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` 二选一
|
||||
- 若传了 `newCategoryName`,后端先创建或复用同名分类,再回填 `categoryId`
|
||||
- `slots` 至少 1 组,最多建议 20 组
|
||||
- 每个槽位对应 1 次顺序生成
|
||||
- 用户使用时统一只上传 3 张素材图:产品图1、产品图2、模特图
|
||||
- 分类创建前应做名称清洗、`slug` 标准化和重名复用
|
||||
|
||||
#### `GET /api/agents/[id]`
|
||||
|
||||
@ -423,6 +562,23 @@ MVP 建议限制:
|
||||
|
||||
用途:删除智能体
|
||||
|
||||
#### `POST /api/agents/[id]/fork`
|
||||
|
||||
用途:把公共智能体复制为“我的智能体”
|
||||
|
||||
请求体建议:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "我的商品主图增强"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
- 一份新的 `visibility=private` 智能体
|
||||
- `forkedFromPresetId` 指向原公共智能体
|
||||
|
||||
#### `GET /api/agent-categories`
|
||||
|
||||
用途:获取智能体分类列表
|
||||
@ -490,21 +646,51 @@ agentPresetType?: "image" | "video";
|
||||
|
||||
最终执行输入建议:
|
||||
|
||||
1. 智能体预设提示词:来自 `AgentPreset.promptTemplate`
|
||||
2. 智能体参考图:来自 `AgentPresetReferenceAsset`
|
||||
3. 用户上传图:来自本轮 `message.parts`
|
||||
4. 用户补充提示词:来自本轮 `text part`
|
||||
5. 当前工作台上下文:可复用现有 `contextPayload`
|
||||
1. 智能体槽位:来自 `AgentPresetSlot`
|
||||
2. 槽位参考图:来自 `AgentPresetReferenceAsset`
|
||||
3. 智能体默认参数模板:来自 `AgentPreset.defaultParams`
|
||||
4. 用户上传图:按槽位顺序映射
|
||||
5. 用户补充提示词:来自本轮 `text part`
|
||||
6. 当前工作台上下文:可复用现有 `contextPayload`
|
||||
|
||||
合成优先级建议:
|
||||
|
||||
```text
|
||||
系统路由/安全限制
|
||||
-> 智能体 promptTemplate
|
||||
-> 按槽位逐个取出 promptTemplate
|
||||
-> 智能体 defaultParams
|
||||
-> 用户补充提示词
|
||||
-> 画布上下文 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` 智能体:
|
||||
@ -543,18 +729,21 @@ agentPresetType?: "image" | "video";
|
||||
- 分类名称
|
||||
- 可见性标签(公共 / 私有)
|
||||
- 默认模型名称
|
||||
- 默认参数摘要
|
||||
- 参考图数量
|
||||
- 智能体头像
|
||||
- 创建人(公共区建议展示)
|
||||
- 使用次数 / 最近更新(公共区建议展示)
|
||||
|
||||
### 8.2 对话输入区
|
||||
|
||||
底部输入区建议包含:
|
||||
现有对话框界面建议包含:
|
||||
|
||||
1. 当前激活智能体标签
|
||||
2. 图片上传入口
|
||||
3. 补充提示词输入框
|
||||
4. 模型选择器
|
||||
5. 发送按钮
|
||||
2. 原有图片上传入口
|
||||
3. 原有输入框
|
||||
4. 原有模型选择器
|
||||
5. 原有发送按钮
|
||||
|
||||
标签行为:
|
||||
|
||||
@ -563,27 +752,61 @@ agentPresetType?: "image" | "video";
|
||||
- 切换到另一个智能体 -> 替换当前标签
|
||||
|
||||
MVP 不建议一次选择多个智能体。
|
||||
首版尽量不改原有输入区主结构,只在现有输入区上增加“智能体标签”和必要的预设注入提示。
|
||||
|
||||
### 8.3 新建智能体弹窗建议
|
||||
|
||||
表单字段建议:
|
||||
|
||||
1. 智能体名称
|
||||
2. 智能体类型
|
||||
3. 可见性
|
||||
4. 分类选择
|
||||
1. 智能体头像
|
||||
2. 智能体名称
|
||||
3. 智能体类型
|
||||
4. 可见性
|
||||
5. 分类选择
|
||||
- 下拉展示已有分类
|
||||
- 支持“新建分类”
|
||||
- 选择“新建分类”后出现输入框
|
||||
5. 默认模型
|
||||
6. 预设提示词
|
||||
7. 参考图上传
|
||||
6. 对应模型选择
|
||||
- 若类型为 `image`,只展示支持生图的模型
|
||||
- 若类型为 `video`,只展示视频模型
|
||||
7. 默认参数模板
|
||||
8. 槽位配置区
|
||||
- 支持新增/删除/排序槽位
|
||||
- 每个槽位填写:
|
||||
- 提示词
|
||||
- 参考图
|
||||
- 可选参数覆盖
|
||||
- 上传提示语
|
||||
|
||||
交互建议:
|
||||
|
||||
- 当用户选择 `visibility=public` 时,分类必填
|
||||
- 当用户选择 `visibility=private` 时,分类可选
|
||||
- 若输入的新分类名已存在,后端直接复用
|
||||
- 当用户切换智能体类型时,模型下拉列表要立即联动刷新
|
||||
- 若用户已选模型与新类型不兼容,前端应清空当前模型并提示重新选择
|
||||
- 增加一个“预览执行内容”区域,展示将被注入的槽位数、默认模型、默认参数和总参考图数量
|
||||
- 即使槽位数为 10,也应明确展示“使用时需要上传 3 张素材图:产品图1、产品图2、模特图”
|
||||
|
||||
### 8.3.1 使用时的上传校验
|
||||
|
||||
当用户选中某个智能体后,现有对话框界面需要显示:
|
||||
|
||||
- 该智能体共有多少个槽位
|
||||
- 本次必须上传多少张图片
|
||||
- 固定上传哪几类图片
|
||||
|
||||
建议交互:
|
||||
|
||||
1. 当前版本 `requiredUploadCount = 3`
|
||||
- 输入区上方明确显示:`请上传产品图1、产品图2、模特图`
|
||||
2. 未达到数量时:
|
||||
- 发送按钮禁用
|
||||
- 或点击后给出明确报错
|
||||
3. 达到数量后:
|
||||
- 在附件区按顺序展示 `产品图1 / 产品图2 / 模特图`
|
||||
4. 槽位生成阶段:
|
||||
- 系统在每个槽位重复使用这 3 张素材图
|
||||
|
||||
### 8.4 模型选择策略
|
||||
|
||||
@ -592,19 +815,22 @@ MVP 不建议一次选择多个智能体。
|
||||
- 生图智能体:只显示支持图片生成的模型
|
||||
- 视频智能体:只显示视频模型
|
||||
|
||||
创建/编辑智能体时建议直接强制要求用户选择对应模型,不建议首版留空走系统默认,否则公共智能体可复现性会变差。
|
||||
|
||||
默认行为:
|
||||
|
||||
1. 若智能体配置了默认模型,则优先用该模型
|
||||
2. 若未配置,则走系统默认模型
|
||||
3. 用户可手动改模型,但必须满足类型限制
|
||||
2. 若智能体配置了默认参数模板,则优先套用
|
||||
3. 若未配置,则走系统默认模型和系统默认参数
|
||||
4. 用户可手动改模型,但必须满足类型限制
|
||||
|
||||
### 8.5 结果落画布
|
||||
|
||||
继续复用 `CanvasChatPanel` 现有逻辑中的结果落图机制:
|
||||
继续复用 `CanvasChatPanel` 的现有结果消费机制:
|
||||
|
||||
- 消费 `data-canvasMedia`
|
||||
- 图片新增 `image` 节点
|
||||
- 视频新增 `video` 节点
|
||||
- 前端主展示:现有对话消息流
|
||||
- 项目持久化:继续写入当前画布项目
|
||||
- 后续查看:可切回标准画布模式查看图片/视频节点
|
||||
|
||||
建议给节点 `metadata` 增加:
|
||||
|
||||
@ -612,6 +838,7 @@ MVP 不建议一次选择多个智能体。
|
||||
- `agentPresetName`
|
||||
- `agentCategoryId`
|
||||
- `agentCategoryName`
|
||||
- `agentExecutionSnapshotId`
|
||||
- `workspaceType: "agent"`
|
||||
|
||||
---
|
||||
@ -628,6 +855,7 @@ MVP 不建议一次选择多个智能体。
|
||||
- `promptTemplate`
|
||||
- `reference assets`
|
||||
- `default model`
|
||||
- `default params`
|
||||
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` 的兼容方案
|
||||
@ -680,15 +954,16 @@ MVP 不建议一次选择多个智能体。
|
||||
|
||||
### Phase A:先并行存在
|
||||
|
||||
- 旧画布页面继续使用 `canvasSkillTags`
|
||||
- 新“智能体页面”使用 `AgentPreset`
|
||||
- 旧画布页的常规模式继续可用
|
||||
- 同一画布页新增“智能体模式”
|
||||
- `canvasSkillTags` 暂不移除
|
||||
|
||||
### Phase B:后续统一
|
||||
|
||||
待智能体稳定后,可考虑:
|
||||
|
||||
1. 将公共智能体映射到画布页面
|
||||
2. 用 `AgentPreset` 替换 `canvasSkillTags`
|
||||
1. 用 `AgentPreset` 替换 `canvasSkillTags`
|
||||
2. 标准画布模式和智能体模式共享同一套智能体预设
|
||||
3. 后台不再维护两套预设
|
||||
|
||||
---
|
||||
@ -700,26 +975,29 @@ MVP 不建议一次选择多个智能体。
|
||||
1. 新增 `AgentPreset`
|
||||
2. 新增 `AgentPresetCategory`
|
||||
3. 新增 `AgentPresetReferenceAsset`
|
||||
4. 扩展 `CanvasProject.workspaceType`
|
||||
5. 增加 `lib/db/queries.ts` 查询函数
|
||||
6. 增加 `/api/agents` 与 `/api/agent-categories`
|
||||
4. 新增 `AgentPresetExecutionSnapshot`
|
||||
5. 扩展 `CanvasProject.workspaceType`
|
||||
6. 增加 `lib/db/queries.ts` 查询函数
|
||||
7. 增加 `/api/agents` 与 `/api/agent-categories`
|
||||
|
||||
### 第二阶段:页面骨架
|
||||
|
||||
1. 新增 `/agents`
|
||||
2. 新增 `/agents/[id]`
|
||||
3. 左侧接入 `CanvasWorkspace`
|
||||
4. 右侧接入智能体列表
|
||||
1. 改造 `app/canvas/[id]/page.tsx`
|
||||
2. 增加“智能体模式”开关
|
||||
3. 左侧接入智能体列表
|
||||
4. 右侧保留现有 `CanvasChatPanel`
|
||||
5. 接入公共区分类筛选
|
||||
6. 增加“新建智能体”弹窗
|
||||
7. 增加“另存为我的智能体”
|
||||
|
||||
### 第三阶段:发送链路
|
||||
|
||||
1. 抽离 `CanvasChatPanel` 发送运行时
|
||||
2. 新增 `AgentComposer`
|
||||
3. 发送时带 `agentPresetId`
|
||||
4. 后端 `/api/chat` 注入智能体定义
|
||||
5. 结果复用 `data-canvasMedia` 落到画布
|
||||
1. `CanvasChatPanel` 支持接收 `agentPresetId`
|
||||
2. 发送时带 `agentPresetId`
|
||||
3. 后端 `/api/chat` 注入智能体定义
|
||||
4. 写入执行快照
|
||||
5. 结果继续走现有消息流展示
|
||||
6. 同时落到当前画布项目
|
||||
|
||||
### 第四阶段:体验增强
|
||||
|
||||
@ -729,6 +1007,7 @@ MVP 不建议一次选择多个智能体。
|
||||
4. 参考图拖拽排序
|
||||
5. 最近使用智能体
|
||||
6. 公共智能体卡片展示创建者/热度
|
||||
7. 分类合并/禁用
|
||||
|
||||
---
|
||||
|
||||
@ -736,21 +1015,31 @@ MVP 不建议一次选择多个智能体。
|
||||
|
||||
满足以下条件即可视为第一版完成:
|
||||
|
||||
1. 可以从页面入口进入“智能体”工作台
|
||||
2. 左侧能看到画布结果区域
|
||||
3. 右侧能看到公共区、私有区
|
||||
1. 可以从现有画布页进入“智能体模式”
|
||||
2. 一侧能看到智能体界面
|
||||
3. 一侧能看到现有对话框界面
|
||||
4. 公共区可以按分类筛选智能体
|
||||
5. 新建智能体时可以选择已有分类
|
||||
6. 新建智能体时可以创建新分类
|
||||
7. 可以新建一个生图智能体
|
||||
8. 可以新建一个视频智能体
|
||||
9. 可以上传参考图并成功保存
|
||||
10. 可以选择公开或私有
|
||||
11. 点击智能体卡片后,底部输入区出现智能体标签
|
||||
12. 上传用户图片并发送后,后端会自动注入智能体预设
|
||||
13. 生图结果会自动落到左侧画布
|
||||
14. 生视频结果会自动落到左侧画布
|
||||
15. 其他用户不能使用当前用户的私有智能体
|
||||
7. 创建分类时会做重名复用,不会重复创建近义同名分类
|
||||
8. 可以新建一个生图智能体
|
||||
9. 可以新建一个视频智能体
|
||||
10. 创建智能体时必须能选择对应的生图模型或视频模型
|
||||
11. 智能体支持多槽位配置
|
||||
12. 智能体支持上传头像并展示
|
||||
13. 即使智能体有 10 个槽位,使用时也只提示上传 3 张素材图
|
||||
14. 上传数量不足时不允许发送
|
||||
15. 智能体支持默认模型和默认参数模板
|
||||
16. 可以上传参考图并成功保存
|
||||
17. 可以选择公开或私有
|
||||
18. 公共智能体支持“另存为我的智能体”
|
||||
19. 点击智能体卡片后,底部输入区出现智能体标签
|
||||
20. 上传用户图片并发送后,后端会自动注入智能体预设
|
||||
21. 每次发送都会写入执行快照
|
||||
22. 生图结果会先出现在现有对话消息流中
|
||||
23. 生视频结果会先出现在现有对话消息流中
|
||||
24. 生成结果会同步落到当前画布项目
|
||||
25. 其他用户不能使用当前用户的私有智能体
|
||||
|
||||
---
|
||||
|
||||
@ -768,7 +1057,6 @@ MVP 不建议一次选择多个智能体。
|
||||
|
||||
## 14. 一句话结论
|
||||
|
||||
这次“智能体页面”最合适的实现方式是:
|
||||
|
||||
**复用现有无限画布和 `/api/chat` 生成主链路,在其上新增正式的 `AgentPreset + AgentPresetCategory` 数据模型与工作台页面,而不是重新做一套生成系统。**
|
||||
这次“智能体功能”最合适的实现方式是:
|
||||
|
||||
**不新开独立页面,而是直接改造现有 `/canvas/[id]`:一侧做智能体界面,一侧保留现有 `CanvasChatPanel`,底层继续复用 `CanvasProject + /api/chat + MediaAsset`。**
|
||||
|
||||
179
lib/db/migrations/0053_add_agent_presets.sql
Normal file
179
lib/db/migrations/0053_add_agent_presets.sql
Normal 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");
|
||||
52
lib/db/migrations/0054_add_agent_preset_slots.sql
Normal file
52
lib/db/migrations/0054_add_agent_preset_slots.sql
Normal 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");
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "agentEnabled" boolean DEFAULT true NOT NULL;
|
||||
@ -274,6 +274,27 @@
|
||||
"when": 1772796000000,
|
||||
"tag": "0052_add_asset_memory_snapshots",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1314
lib/db/queries.ts
1314
lib/db/queries.ts
File diff suppressed because it is too large
Load Diff
152
lib/db/schema.ts
152
lib/db/schema.ts
@ -376,6 +376,7 @@ export const siteSettings = pgTable("SiteSettings", {
|
||||
rankingEnabled: boolean("rankingEnabled").notNull().default(true), // 是否显示精选榜按钮
|
||||
// 无限画布设置
|
||||
canvasEnabled: boolean("canvasEnabled").notNull().default(true), // 是否启用无限画布功能
|
||||
agentEnabled: boolean("agentEnabled").notNull().default(true), // 是否启用智能体功能
|
||||
realtimeCanvasEnabled: boolean("realtimeCanvasEnabled").notNull().default(true), // 是否显示实时画布入口
|
||||
canvasDefaultModelId: varchar("canvasDefaultModelId", { length: 128 }), // 画布默认选择的模型ID(null表示智能自动)
|
||||
canvasAutoModeDefaultModelId: varchar("canvasAutoModeDefaultModelId", { length: 128 }), // 智能自动模式下实际使用的默认生图模型ID
|
||||
@ -496,6 +497,79 @@ export const generatedImage = pgTable("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("未命名画布"),
|
||||
description: text("description"),
|
||||
thumbnail: text("thumbnail"),
|
||||
workspaceType: varchar("workspaceType", { enum: ["canvas", "agent"] })
|
||||
.notNull()
|
||||
.default("canvas"),
|
||||
// 存储完整画布状态(节点、边、视口等)
|
||||
canvasData: jsonb("canvasData").$type<{
|
||||
nodes: any[];
|
||||
@ -843,3 +920,78 @@ export const assetMemorySnapshot = pgTable("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
170
services/agents/index.ts
Normal 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
159
types/agent.ts
Normal 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";
|
||||
}
|
||||
@ -68,6 +68,7 @@ export interface VideoNodeData {
|
||||
duration?: number;
|
||||
progress?: number; // 生成进度百分比
|
||||
coverImage?: string; // 用于生成视频的封面图
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
// 文本节点数据
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user