diff --git a/app/api/agents/[id]/route.ts b/app/api/agents/[id]/route.ts
index 6ee777a76..723be2ac9 100644
--- a/app/api/agents/[id]/route.ts
+++ b/app/api/agents/[id]/route.ts
@@ -22,23 +22,21 @@ const updateAgentSchema = z
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(),
+ categoryPath: z
+ .object({
+ productCategory: z.string().trim().max(64).optional(),
+ audienceTag: z.string().trim().max(64).optional(),
+ usageScenario: z.string().trim().max(64).optional(),
+ brand: z.string().trim().max(64).optional(),
+ })
+ .nullable()
+ .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(
@@ -93,8 +91,7 @@ export async function PUT(
name: data.name,
type: data.type,
visibility: data.visibility,
- categoryId: data.categoryId ?? undefined,
- newCategoryName: data.newCategoryName,
+ categoryPath: data.categoryPath ?? undefined,
description: data.description ?? undefined,
defaultModelId: data.defaultModelId,
defaultParams: data.defaultParams ?? undefined,
diff --git a/app/api/agents/route.ts b/app/api/agents/route.ts
index de0867bb6..21bf0b1bf 100644
--- a/app/api/agents/route.ts
+++ b/app/api/agents/route.ts
@@ -4,14 +4,16 @@ 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(),
+ productCategory: z.string().trim().max(64).optional(),
+ audienceTag: z.string().trim().max(64).optional(),
+ usageScenario: z.string().trim().max(64).optional(),
+ brand: z.string().trim().max(64).optional(),
keyword: z.string().trim().max(128).optional(),
sort: z.enum(["latest", "hottest", "most_used"]).default("latest"),
});
@@ -30,29 +32,19 @@ const createAgentSchema = z
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(),
+ categoryPath: z
+ .object({
+ productCategory: z.string().trim().max(64).optional(),
+ audienceTag: z.string().trim().max(64).optional(),
+ usageScenario: z.string().trim().max(64).optional(),
+ brand: z.string().trim().max(64).optional(),
+ })
+ .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) {
@@ -66,32 +58,38 @@ export async function GET(request: NextRequest) {
const query = listQuerySchema.parse({
scope: searchParams.get("scope") || "all",
type: searchParams.get("type") || undefined,
- categoryId: searchParams.get("categoryId") || undefined,
+ productCategory: searchParams.get("productCategory") || undefined,
+ audienceTag: searchParams.get("audienceTag") || undefined,
+ usageScenario: searchParams.get("usageScenario") || undefined,
+ brand: searchParams.get("brand") || 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",
- }),
- ]);
+ const presets = await getVisibleAgentPresets({
+ userId: session.user.id,
+ scope: query.scope,
+ type: query.type,
+ keyword: query.keyword,
+ sort: query.sort,
+ });
+
+ const filterByPath = (items: typeof presets) =>
+ items.filter((item) => {
+ const path = item.categoryPath || {};
+ if (query.productCategory && path.productCategory !== query.productCategory) return false;
+ if (query.audienceTag && path.audienceTag !== query.audienceTag) return false;
+ if (query.usageScenario && path.usageScenario !== query.usageScenario) return false;
+ if (query.brand && path.brand !== query.brand) return false;
+ return true;
+ });
return NextResponse.json({
success: true,
data: {
- categories,
- public: presets.filter((item) => item.visibility === "public"),
- private: presets.filter((item) => item.visibility === "private"),
+ categories: [],
+ public: filterByPath(presets.filter((item) => item.visibility === "public")),
+ private: filterByPath(presets.filter((item) => item.visibility === "private")),
},
});
} catch (error) {
@@ -126,8 +124,7 @@ export async function POST(request: NextRequest) {
name: data.name,
type: data.type,
visibility: data.visibility,
- categoryId: data.categoryId,
- newCategoryName: data.newCategoryName,
+ categoryPath: data.categoryPath,
description: data.description,
defaultModelId: data.defaultModelId,
defaultParams: data.defaultParams,
diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts
index bfa73d882..b9cb520dd 100644
--- a/app/api/site-settings/route.ts
+++ b/app/api/site-settings/route.ts
@@ -29,6 +29,7 @@ import {
normalizeOssImageMigrationConfig,
OSS_MIGRATION_CONFIG_KEY,
} from "@/lib/storage/migration-config";
+import type { AgentCategoryTreeConfig } from "@/types/agent";
const HOME_VARIANT_KEY = "home_variant";
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
@@ -42,6 +43,7 @@ const MEMBERSHIP_CONFIG_KEY = "membership_config";
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
const STORAGE_FAILOVER_CONFIG_KEY = "storage_failover_config";
const REALTIME_CANVAS_CONFIG_KEY = "realtime_canvas_config";
+const AGENT_CATEGORY_TREE_CONFIG_KEY = "agent_category_tree_config";
function normalizeHomeVariant(value: unknown): "classic" | "lovart" {
return value === "lovart" ? "lovart" : "classic";
@@ -156,6 +158,32 @@ function normalizeStorageFailoverConfig(value: unknown): {
};
}
+function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConfig {
+ const raw =
+ value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record
)
+ : {};
+
+ const normalizeList = (input: unknown) => {
+ if (!Array.isArray(input)) return [] as string[];
+ return Array.from(
+ new Set(
+ input
+ .map((item) => String(item || "").trim())
+ .filter(Boolean)
+ .slice(0, 200)
+ )
+ );
+ };
+
+ return {
+ productCategories: normalizeList(raw.productCategories),
+ audienceTags: normalizeList(raw.audienceTags),
+ usageScenarios: normalizeList(raw.usageScenarios),
+ brands: normalizeList(raw.brands),
+ };
+}
+
// GET /api/site-settings - 获取网站设置 (公开)
export async function GET() {
try {
@@ -179,6 +207,7 @@ export async function GET() {
realtimeCanvasConfigSetting,
storageFailoverConfigSetting,
ossImageMigrationConfigSetting,
+ agentCategoryTreeConfigSetting,
] = await Promise.all([
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
db
@@ -277,6 +306,12 @@ export async function GET() {
.where(eq(systemSettings.key, OSS_MIGRATION_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
+ db
+ .select()
+ .from(systemSettings)
+ .where(eq(systemSettings.key, AGENT_CATEGORY_TREE_CONFIG_KEY))
+ .limit(1)
+ .then(rows => rows[0]),
]);
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
@@ -312,6 +347,9 @@ export async function GET() {
const ossImageMigrationConfig = normalizeOssImageMigrationConfig(
ossImageMigrationConfigSetting?.value
);
+ const agentCategoryTreeConfig = normalizeAgentCategoryTreeConfig(
+ agentCategoryTreeConfigSetting?.value
+ );
// 如果没有设置记录,返回默认值
const baseSettings = settings || {
@@ -337,6 +375,7 @@ export async function GET() {
rankingEnabled: true,
canvasEnabled: true,
agentEnabled: true,
+ agentCategoryTreeConfig: normalizeAgentCategoryTreeConfig(undefined),
realtimeCanvasEnabled: true,
realtimeCanvasServiceEnabled: true,
storageFailoverConfig,
@@ -378,6 +417,7 @@ export async function GET() {
lovartComposerPreviewTexts,
lovartPromptTagVisibility,
contactConfig,
+ agentCategoryTreeConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@@ -426,6 +466,14 @@ const updateSchema = z.object({
rankingEnabled: z.boolean().optional(),
canvasEnabled: z.boolean().optional(),
agentEnabled: z.boolean().optional(),
+ agentCategoryTreeConfig: z
+ .object({
+ productCategories: z.array(z.string().trim().min(1).max(64)).max(200).optional(),
+ audienceTags: z.array(z.string().trim().min(1).max(64)).max(200).optional(),
+ usageScenarios: z.array(z.string().trim().min(1).max(64)).max(200).optional(),
+ brands: z.array(z.string().trim().min(1).max(64)).max(200).optional(),
+ })
+ .optional(),
realtimeCanvasEnabled: z.boolean().optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
@@ -501,6 +549,7 @@ export async function PUT(request: NextRequest) {
lovartComposerPreviewTexts,
lovartPromptTagVisibility,
contactConfig,
+ agentCategoryTreeConfig,
membershipConfig,
membershipUserOverrides,
pointsSystemConfig,
@@ -665,6 +714,26 @@ export async function PUT(request: NextRequest) {
});
}
+ if (agentCategoryTreeConfig !== undefined) {
+ const normalized = normalizeAgentCategoryTreeConfig(agentCategoryTreeConfig);
+ await db
+ .insert(systemSettings)
+ .values({
+ key: AGENT_CATEGORY_TREE_CONFIG_KEY,
+ value: normalized,
+ updatedAt: new Date(),
+ updatedBy: session.user.id,
+ })
+ .onConflictDoUpdate({
+ target: systemSettings.key,
+ set: {
+ value: normalized,
+ updatedAt: new Date(),
+ updatedBy: session.user.id,
+ },
+ });
+ }
+
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
@@ -829,6 +898,10 @@ export async function PUT(request: NextRequest) {
contactConfig !== undefined
? normalizeContactConfig(contactConfig)
: normalizeContactConfig(undefined),
+ agentCategoryTreeConfig:
+ agentCategoryTreeConfig !== undefined
+ ? normalizeAgentCategoryTreeConfig(agentCategoryTreeConfig)
+ : normalizeAgentCategoryTreeConfig(undefined),
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)
diff --git a/components/agents/AgentPresetCard.tsx b/components/agents/AgentPresetCard.tsx
index 5eee36444..e34cfe53c 100644
--- a/components/agents/AgentPresetCard.tsx
+++ b/components/agents/AgentPresetCard.tsx
@@ -40,6 +40,10 @@ export function AgentPresetCard({
day: "2-digit",
});
})();
+ const categoryBadges = [
+ preset.categoryPath?.productCategory,
+ preset.categoryPath?.audienceTag,
+ ].filter(Boolean) as string[];
return (
{preset.type === "video" ? "视频" : "生图"}
- {preset.category?.name ? (
-
- {preset.category.name}
+ {categoryBadges.map((item) => (
+
+ {item}
- ) : null}
+ ))}
{preset.visibility === "private" && onEdit ? (
diff --git a/components/agents/AgentPresetDialog.tsx b/components/agents/AgentPresetDialog.tsx
index a574c47a2..80dd6f6f3 100644
--- a/components/agents/AgentPresetDialog.tsx
+++ b/components/agents/AgentPresetDialog.tsx
@@ -26,7 +26,11 @@ 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";
+import type {
+ AgentCategoryTreeConfig,
+ AgentPreset,
+ AgentPresetSlot,
+} from "@/types/agent";
type DialogSlotState = AgentPresetSlot & {
localReferenceAssets: Array<{
@@ -39,17 +43,15 @@ type DialogSlotState = AgentPresetSlot & {
interface AgentPresetDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
- categories: AgentCategory[];
+ categoryTreeConfig: AgentCategoryTreeConfig;
initialPreset?: AgentPreset | null;
onSaved?: (preset: AgentPreset) => void;
}
-const NEW_CATEGORY_VALUE = "__new__";
-
export function AgentPresetDialog({
open,
onOpenChange,
- categories,
+ categoryTreeConfig,
initialPreset,
onSaved,
}: AgentPresetDialogProps) {
@@ -65,8 +67,17 @@ export function AgentPresetDialog({
const [name, setName] = useState("");
const [type, setType] = useState<"image" | "video">("image");
const [visibility, setVisibility] = useState<"public" | "private">("private");
- const [categoryMode, setCategoryMode] = useState("");
- const [newCategoryName, setNewCategoryName] = useState("");
+ const [categoryPath, setCategoryPath] = useState<{
+ productCategory: string;
+ audienceTag: string;
+ usageScenario: string;
+ brand: string;
+ }>({
+ productCategory: "",
+ audienceTag: "",
+ usageScenario: "",
+ brand: "",
+ });
const [defaultModelId, setDefaultModelId] = useState("");
const [description, setDescription] = useState("");
const [avatarAssetId, setAvatarAssetId] = useState("");
@@ -131,8 +142,12 @@ export function AgentPresetDialog({
setName("");
setType("image");
setVisibility("private");
- setCategoryMode("");
- setNewCategoryName("");
+ setCategoryPath({
+ productCategory: "",
+ audienceTag: "",
+ usageScenario: "",
+ brand: "",
+ });
setDefaultModelId("");
setDescription("");
setAvatarAssetId("");
@@ -162,8 +177,12 @@ export function AgentPresetDialog({
setName(initialPreset.name || "");
setType(initialPreset.type);
setVisibility(initialPreset.visibility);
- setCategoryMode(initialPreset.category?.id || initialPreset.categoryId || "");
- setNewCategoryName("");
+ setCategoryPath({
+ productCategory: String(initialPreset.categoryPath?.productCategory || ""),
+ audienceTag: String(initialPreset.categoryPath?.audienceTag || ""),
+ usageScenario: String(initialPreset.categoryPath?.usageScenario || ""),
+ brand: String(initialPreset.categoryPath?.brand || ""),
+ });
setDefaultModelId(initialPreset.defaultModelId || "");
setDescription(initialPreset.description || "");
setAvatarAssetId(initialPreset.avatarAssetId || "");
@@ -301,10 +320,6 @@ export function AgentPresetDialog({
toast.error("请选择对应模型");
return;
}
- if (visibility === "public" && !categoryMode && !newCategoryName.trim()) {
- toast.error("公开智能体必须选择分类");
- return;
- }
if (slots.some((slot) => !slot.promptTemplate.trim())) {
toast.error("每个槽位都需要填写提示词");
return;
@@ -316,10 +331,12 @@ export function AgentPresetDialog({
name: name.trim(),
type,
visibility,
- categoryId:
- categoryMode && categoryMode !== NEW_CATEGORY_VALUE ? categoryMode : undefined,
- newCategoryName:
- categoryMode === NEW_CATEGORY_VALUE ? newCategoryName.trim() : undefined,
+ categoryPath: {
+ productCategory: categoryPath.productCategory || undefined,
+ audienceTag: categoryPath.audienceTag || undefined,
+ usageScenario: categoryPath.usageScenario || undefined,
+ brand: categoryPath.brand || undefined,
+ },
description: description.trim() || undefined,
defaultModelId,
slots: slots.map((slot, index) => ({
@@ -453,23 +470,28 @@ export function AgentPresetDialog({
-
分类
-
对应模型
@@ -488,15 +510,80 @@ export function AgentPresetDialog({