Refactor skill and agent editing to integrate MCP tool catalog, enhancing tool selection and management

This commit is contained in:
mlogclub
2026-04-09 15:36:20 +08:00
parent 92490d7faf
commit 399c29002b
3 changed files with 202 additions and 55 deletions
@@ -37,14 +37,14 @@ import {
type AdminAgentTeam, type AdminAgentTeam,
type CreateAIAgentPayload, type CreateAIAgentPayload,
type KnowledgeBase, type KnowledgeBase,
type MCPToolCatalogItem,
type SkillDefinition, type SkillDefinition,
fetchAIAgent, fetchAIAgent,
fetchAIConfigsAll, fetchAIConfigsAll,
fetchAgentTeamsAll, fetchAgentTeamsAll,
fetchMCPCatalog,
fetchKnowledgeBasesAll, fetchKnowledgeBasesAll,
fetchSkillDefinitionsAll, fetchSkillDefinitionsAll,
listMCPServers,
listMCPTools,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { import {
AIAgentFallbackMode, AIAgentFallbackMode,
@@ -243,6 +243,7 @@ function EditDialogBody({
const [directToolOptions, setDirectToolOptions] = useState< const [directToolOptions, setDirectToolOptions] = useState<
{ value: string; label: string; meta: CreateAIAgentPayload["directTools"][number] }[] { value: string; label: string; meta: CreateAIAgentPayload["directTools"][number] }[]
>([]); >([]);
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]);
useEffect(() => { useEffect(() => {
async function loadDetail() { async function loadDetail() {
@@ -342,25 +343,22 @@ function EditDialogBody({
useEffect(() => { useEffect(() => {
async function loadDirectToolOptions() { async function loadDirectToolOptions() {
try { try {
const servers = await listMCPServers(); const catalog = await fetchMCPCatalog();
const enabledServers = servers.filter((item) => item.enabled); setToolCatalog(catalog);
const toolGroups = await Promise.all( setDirectToolOptions(
enabledServers.map(async (server) => { catalog.map((tool) => ({
const tools = await listMCPTools(server.code); value: tool.toolCode,
return tools.map((tool) => ({ label: `${tool.title || tool.toolName} · ${tool.toolCode}`,
value: `${server.code}/${tool.name}`, meta: {
label: `${server.code} / ${tool.title || tool.name}`, toolCode: tool.toolCode,
meta: { serverCode: tool.serverCode,
serverCode: server.code, toolName: tool.toolName,
toolName: tool.name, title: tool.title || tool.toolName,
title: tool.title || tool.name, description: tool.description || "",
description: tool.description || "", arguments: undefined,
arguments: undefined, },
}, })),
}));
}),
); );
setDirectToolOptions(toolGroups.flat());
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "加载 Direct Tools 失败", error instanceof Error ? error.message : "加载 Direct Tools 失败",
@@ -449,10 +447,7 @@ function EditDialogBody({
directToolOptions.filter( directToolOptions.filter(
(option) => (option) =>
option.meta.serverCode === directToolServerCodeToAdd && option.meta.serverCode === directToolServerCodeToAdd &&
!directTools.some( !directTools.some((tool) => tool.toolCode === option.value),
(tool) =>
`${tool.serverCode}/${tool.toolName}` === option.value,
),
), ),
[directToolOptions, directToolServerCodeToAdd, directTools], [directToolOptions, directToolServerCodeToAdd, directTools],
); );
@@ -583,8 +578,7 @@ function EditDialogBody({
if ( if (
prev.some( prev.some(
(item) => (item) =>
item.serverCode === option.meta.serverCode && item.toolCode === option.meta.toolCode,
item.toolName === option.meta.toolName,
) )
) { ) {
return prev; return prev;
@@ -596,9 +590,7 @@ function EditDialogBody({
} }
function handleRemoveDirectTool(value: string) { function handleRemoveDirectTool(value: string) {
setDirectTools((prev) => setDirectTools((prev) => prev.filter((item) => item.toolCode !== value));
prev.filter((item) => `${item.serverCode}/${item.toolName}` !== value),
);
} }
return ( return (
@@ -862,8 +854,7 @@ function EditDialogBody({
<div className="rounded-xl border bg-muted/10 p-4"> <div className="rounded-xl border bg-muted/10 p-4">
<div className="mb-1 text-sm font-medium">Direct MCP Tools</div> <div className="mb-1 text-sm font-medium">Direct MCP Tools</div>
<div className="mb-4 text-xs text-muted-foreground"> <div className="mb-4 text-xs text-muted-foreground">
MCP Server MCP Tool Catalog
Server
</div> </div>
<Field> <Field>
<FieldContent className="space-y-3"> <FieldContent className="space-y-3">
@@ -919,14 +910,17 @@ function EditDialogBody({
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{tools.map((tool) => { {tools.map((tool) => {
const value = `${tool.serverCode}/${tool.toolName}`; const value = tool.toolCode;
const catalogItem = toolCatalog.find(
(item) => item.toolCode === tool.toolCode,
);
return ( return (
<Badge <Badge
key={value} key={value}
variant="secondary" variant="secondary"
className="gap-1 pr-1" className="gap-1 pr-1"
> >
{tool.title || value} {tool.title || catalogItem?.title || value}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -1,10 +1,11 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Resolver, useForm } from "react-hook-form"; import { Resolver, useForm } from "react-hook-form";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { OptionCombobox } from "@/components/option-combobox";
import { ProjectDialog } from "@/components/project-dialog"; import { ProjectDialog } from "@/components/project-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -16,8 +17,10 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
fetchMCPCatalog,
fetchSkillDefinition, fetchSkillDefinition,
type CreateSkillDefinitionPayload, type CreateSkillDefinitionPayload,
type MCPToolCatalogItem,
type SkillDefinition, type SkillDefinition,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
@@ -34,7 +37,8 @@ const emptyForm: EditForm = {
code: "", code: "",
name: "", name: "",
description: "", description: "",
prompt: "", content: "",
examplesText: "",
remark: "", remark: "",
}; };
@@ -46,7 +50,8 @@ const skillFormSchema = z.object({
.regex(/^[a-zA-Z0-9_-]+$/, "Skill 编码仅支持字母、数字、下划线和中划线"), .regex(/^[a-zA-Z0-9_-]+$/, "Skill 编码仅支持字母、数字、下划线和中划线"),
name: z.string().trim().min(1, "Skill 名称不能为空"), name: z.string().trim().min(1, "Skill 名称不能为空"),
description: z.string().trim(), description: z.string().trim(),
prompt: z.string().trim().min(1, "Prompt 不能为空"), content: z.string().trim().min(1, "Content 不能为空"),
examplesText: z.string().trim(),
remark: z.string().trim(), remark: z.string().trim(),
}); });
@@ -66,17 +71,26 @@ function buildForm(item: SkillDefinition | null): EditForm {
code: item.code, code: item.code,
name: item.name, name: item.name,
description: item.description ?? "", description: item.description ?? "",
prompt: item.prompt ?? "", content: item.content ?? "",
examplesText: (item.examples ?? []).join("\n"),
remark: item.remark ?? "", remark: item.remark ?? "",
}; };
} }
function buildPayload(form: EditForm): CreateSkillDefinitionPayload { function buildPayload(
form: EditForm,
allowedToolCodes: string[],
): CreateSkillDefinitionPayload {
return { return {
code: form.code.trim(), code: form.code.trim(),
name: form.name.trim(), name: form.name.trim(),
description: form.description.trim(), description: form.description.trim(),
prompt: form.prompt.trim(), content: form.content.trim(),
examples: form.examplesText
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
allowedToolCodes,
remark: form.remark.trim(), remark: form.remark.trim(),
}; };
} }
@@ -115,6 +129,11 @@ function SkillEditDialogBody({
}: SkillEditDialogBodyProps) { }: SkillEditDialogBodyProps) {
const formId = "skill-definition-edit-form"; const formId = "skill-definition-edit-form";
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([]);
const [selectedAllowedToolCodes, setSelectedAllowedToolCodes] = useState<
string[]
>([]);
const [toolCodeToAdd, setToolCodeToAdd] = useState("");
const form = useForm< const form = useForm<
z.input<typeof skillFormSchema>, z.input<typeof skillFormSchema>,
undefined, undefined,
@@ -135,6 +154,8 @@ function SkillEditDialogBody({
async function loadDetail() { async function loadDetail() {
if (!itemId) { if (!itemId) {
reset(emptyForm); reset(emptyForm);
setSelectedAllowedToolCodes([]);
setToolCodeToAdd("");
return; return;
} }
@@ -142,6 +163,8 @@ function SkillEditDialogBody({
try { try {
const data = await fetchSkillDefinition(itemId); const data = await fetchSkillDefinition(itemId);
reset(buildForm(data)); reset(buildForm(data));
setSelectedAllowedToolCodes(data.allowedToolCodes ?? []);
setToolCodeToAdd("");
} catch (error) { } catch (error) {
console.error("Failed to load skill definition:", error); console.error("Failed to load skill definition:", error);
} finally { } finally {
@@ -152,8 +175,62 @@ function SkillEditDialogBody({
void loadDetail(); void loadDetail();
}, [itemId, reset]); }, [itemId, reset]);
useEffect(() => {
async function loadToolCatalog() {
try {
const data = await fetchMCPCatalog();
setToolCatalog(data);
} catch (error) {
console.error("Failed to load MCP tool catalog:", error);
}
}
void loadToolCatalog();
}, []);
async function onFormSubmit(values: EditForm) { async function onFormSubmit(values: EditForm) {
await onSubmit(buildPayload(values)); await onSubmit(buildPayload(values, selectedAllowedToolCodes));
}
const toolOptions = useMemo(
() =>
toolCatalog.map((item) => ({
value: item.toolCode,
label: `${item.title || item.toolName} · ${item.toolCode}`,
})),
[toolCatalog],
);
const addableToolOptions = useMemo(
() =>
toolOptions.filter(
(option) => !selectedAllowedToolCodes.includes(option.value),
),
[selectedAllowedToolCodes, toolOptions],
);
const selectedToolOptions = useMemo(
() =>
selectedAllowedToolCodes
.map((toolCode) => toolOptions.find((option) => option.value === toolCode))
.filter(
(option): option is { value: string; label: string } => !!option,
),
[selectedAllowedToolCodes, toolOptions],
);
function handleAddAllowedTool(toolCode: string) {
if (!toolCode || selectedAllowedToolCodes.includes(toolCode)) {
return;
}
setSelectedAllowedToolCodes((prev) => [...prev, toolCode]);
setToolCodeToAdd("");
}
function handleRemoveAllowedTool(toolCode: string) {
setSelectedAllowedToolCodes((prev) =>
prev.filter((item) => item !== toolCode),
);
} }
return ( return (
@@ -230,17 +307,77 @@ function SkillEditDialogBody({
</FieldContent> </FieldContent>
</Field> </Field>
<Field data-invalid={!!errors.prompt}> <Field data-invalid={!!errors.content}>
<FieldLabel htmlFor="skill-prompt">Prompt</FieldLabel> <FieldLabel htmlFor="skill-content">Content</FieldLabel>
<FieldContent> <FieldContent>
<Textarea <Textarea
id="skill-prompt" id="skill-content"
rows={10} rows={12}
placeholder="请输入 Skill 的核心提示词模板" placeholder="请输入 Skill 文档内容,描述目标、步骤、工具使用规则和边界。"
aria-invalid={!!errors.prompt} aria-invalid={!!errors.content}
{...register("prompt")} {...register("content")}
/> />
<FieldError errors={[errors.prompt]} /> <FieldError errors={[errors.content]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.examplesText}>
<FieldLabel htmlFor="skill-examples">Examples</FieldLabel>
<FieldContent>
<Textarea
id="skill-examples"
rows={5}
placeholder={"每行一个典型用户问法,例如:\n我要申请退款\n帮我查下订单"}
aria-invalid={!!errors.examplesText}
{...register("examplesText")}
/>
<FieldError errors={[errors.examplesText]} />
</FieldContent>
</Field>
<Field>
<FieldLabel>Allowed Tools</FieldLabel>
<FieldContent className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1">
<OptionCombobox
value={toolCodeToAdd}
options={addableToolOptions}
placeholder="选择允许该 Skill 使用的工具"
searchPlaceholder="搜索 toolCode 或工具名"
emptyText="没有可添加的工具"
onChange={handleAddAllowedTool}
/>
</div>
<Button
type="button"
variant="outline"
disabled={!toolCodeToAdd}
onClick={() => handleAddAllowedTool(toolCodeToAdd)}
>
</Button>
</div>
<div className="flex flex-wrap gap-2">
{selectedToolOptions.length === 0 ? (
<span className="text-sm text-muted-foreground">
Skill Agent
</span>
) : (
selectedToolOptions.map((option) => (
<Button
key={option.value}
type="button"
variant="outline"
size="sm"
onClick={() => handleRemoveAllowedTool(option.value)}
className="justify-start"
>
{option.label}
</Button>
))
)}
</div>
</FieldContent> </FieldContent>
</Field> </Field>
+23 -7
View File
@@ -216,6 +216,7 @@ export type AIAgent = {
skillIds: number[] skillIds: number[]
skills: { id: number; code: string; name: string }[] skills: { id: number; code: string; name: string }[]
directTools: { directTools: {
toolCode: string
serverCode: string serverCode: string
toolName: string toolName: string
title: string title: string
@@ -246,6 +247,7 @@ export type CreateAIAgentPayload = {
knowledgeIds: number[] knowledgeIds: number[]
skillIds: number[] skillIds: number[]
directTools: { directTools: {
toolCode: string
serverCode: string serverCode: string
toolName: string toolName: string
title: string title: string
@@ -276,10 +278,9 @@ export type SkillDefinition = {
code: string code: string
name: string name: string
description: string description: string
prompt: string content: string
executionMode?: string examples: string[]
executionModeName?: string allowedToolCodes: string[]
executionConfig?: string
priority: number priority: number
status: number status: number
statusName: string statusName: string
@@ -294,9 +295,10 @@ export type CreateSkillDefinitionPayload = {
code: string code: string
name: string name: string
description: string description: string
prompt: string content: string
executionMode?: string examples: string[]
executionConfig?: string allowedToolCodes: string[]
priority?: number
remark: string remark: string
} }
@@ -327,6 +329,16 @@ export type MCPToolInfo = {
outputSchema?: unknown outputSchema?: unknown
} }
export type MCPToolCatalogItem = {
toolCode: string
serverCode: string
toolName: string
title: string
description: string
inputSchema: unknown
outputSchema?: unknown
}
export type MCPToolResultContent = { export type MCPToolResultContent = {
type: string type: string
text?: string text?: string
@@ -878,6 +890,10 @@ export function listMCPTools(serverCode: string) {
}) })
} }
export function fetchMCPCatalog() {
return request<MCPToolCatalogItem[]>("/api/console/mcp/catalog")
}
export function callMCPTool(payload: { export function callMCPTool(payload: {
serverCode: string serverCode: string
toolName: string toolName: string