feat(mcp): enhance MCP transport support and add server management UI
- Extend McpTransport to support "sse" in schemas. - Refactor McpToolClient to handle both "streamable_http" and "sse" transports. - Introduce McpServerDialog for managing MCP server configurations, including transport settings and tool synchronization. - Replace McpServersSection with the new dialog component for improved server management. - Add tests for MCP transport handling and server dialog functionality.
This commit is contained in:
@@ -9,10 +9,17 @@ import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
ServerCog,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { McpServersSection } from "@/components/tools/McpServersSection";
|
||||
import { McpServerDialog } from "@/components/tools/McpServerDialog";
|
||||
import {
|
||||
TOOL_DIALOG_CONTENT_CLASS,
|
||||
ToolFormField as Field,
|
||||
ToolFormSection as FieldSection,
|
||||
ToolJsonField as JsonField,
|
||||
} from "@/components/tools/tool-form-controls";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataList, type DataListColumn } from "@/components/ui/data-list";
|
||||
@@ -45,18 +52,23 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
mcpServersApi,
|
||||
toolsApi,
|
||||
type HttpToolDefinition,
|
||||
type McpServer,
|
||||
type Tool,
|
||||
type ToolParameter,
|
||||
type ToolStatus,
|
||||
type ToolUpsert,
|
||||
} from "@/lib/api";
|
||||
|
||||
type ToolKind = "end_call" | "http" | "mcp";
|
||||
type ToolKind = "end_call" | "http";
|
||||
type HttpMethod = HttpToolDefinition["config"]["method"];
|
||||
type ToolFilter = "全部" | "End Call" | "HTTP" | "MCP";
|
||||
type SortOrder = "newest" | "oldest";
|
||||
type ToolResource =
|
||||
| { kind: "tool"; id: string; tool: Tool }
|
||||
| { kind: "mcp"; id: string; server: McpServer };
|
||||
|
||||
const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP", "MCP"];
|
||||
|
||||
@@ -78,10 +90,6 @@ type ToolForm = {
|
||||
body: string;
|
||||
dynamicVariableAssignments: string;
|
||||
secretDynamicVariables: string;
|
||||
mcpServerId: string;
|
||||
remoteToolName: string;
|
||||
inputSchema: string;
|
||||
schemaHash: string;
|
||||
};
|
||||
|
||||
const EMPTY_OBJECT = "{}";
|
||||
@@ -106,10 +114,6 @@ function blankForm(): ToolForm {
|
||||
body: EMPTY_OBJECT,
|
||||
dynamicVariableAssignments: EMPTY_OBJECT,
|
||||
secretDynamicVariables: EMPTY_OBJECT,
|
||||
mcpServerId: "",
|
||||
remoteToolName: "",
|
||||
inputSchema: EMPTY_OBJECT,
|
||||
schemaHash: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +122,9 @@ function pretty(value: unknown, fallback: string): string {
|
||||
}
|
||||
|
||||
function formFromTool(tool: Tool): ToolForm {
|
||||
if (tool.type === "mcp") {
|
||||
throw new Error("MCP 服务需要使用 MCP 编辑窗口");
|
||||
}
|
||||
const base = { ...blankForm(), name: tool.name, functionName: tool.functionName };
|
||||
base.type = tool.type;
|
||||
base.description = tool.description;
|
||||
@@ -128,17 +135,7 @@ function formFromTool(tool: Tool): ToolForm {
|
||||
base.captureReason = tool.definition.config.captureReason;
|
||||
return base;
|
||||
}
|
||||
if (tool.definition.type === "mcp") {
|
||||
base.mcpServerId = tool.mcpServerId ?? "";
|
||||
base.remoteToolName = tool.definition.config.remoteToolName;
|
||||
base.inputSchema = pretty(tool.definition.config.inputSchema, EMPTY_OBJECT);
|
||||
base.schemaHash = tool.definition.config.schemaHash;
|
||||
base.dynamicVariableAssignments = pretty(
|
||||
tool.definition.config.dynamicVariableAssignments ?? {},
|
||||
EMPTY_OBJECT,
|
||||
);
|
||||
return base;
|
||||
}
|
||||
if (tool.definition.type === "mcp") return base;
|
||||
base.method = tool.definition.config.method;
|
||||
base.url = tool.definition.config.url;
|
||||
base.timeoutSeconds = String(tool.definition.config.timeoutSeconds);
|
||||
@@ -218,30 +215,6 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (form.type === "mcp") {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
functionName: form.functionName,
|
||||
description: form.description.trim(),
|
||||
status: form.status,
|
||||
mcpServerId: form.mcpServerId,
|
||||
secrets: {},
|
||||
definition: {
|
||||
schemaVersion: 1,
|
||||
type: "mcp",
|
||||
config: {
|
||||
remoteToolName: form.remoteToolName,
|
||||
inputSchema: parseObject(form.inputSchema, "MCP Input Schema"),
|
||||
schemaHash: form.schemaHash,
|
||||
dynamicVariableAssignments: parseObject(
|
||||
form.dynamicVariableAssignments,
|
||||
"变量赋值",
|
||||
) as Record<string, string>,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutSeconds = Number(form.timeoutSeconds);
|
||||
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
|
||||
throw new Error("超时时间必须是 1 到 120 秒之间的整数");
|
||||
@@ -301,6 +274,7 @@ function updatedAtValue(value?: string | null): number {
|
||||
|
||||
export function ComponentsToolsPage() {
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -309,6 +283,8 @@ export function ComponentsToolsPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Tool | null>(null);
|
||||
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
|
||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServer | null>(null);
|
||||
const [form, setForm] = useState<ToolForm>(blankForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
@@ -316,13 +292,18 @@ export function ComponentsToolsPage() {
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
|
||||
|
||||
const loadTools = useCallback(async () => {
|
||||
const loadResources = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setTools(await toolsApi.list());
|
||||
const [nextTools, nextMcpServers] = await Promise.all([
|
||||
toolsApi.list(),
|
||||
mcpServersApi.list(),
|
||||
]);
|
||||
setTools(nextTools);
|
||||
setMcpServers(nextMcpServers);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "加载工具失败");
|
||||
setError(loadError instanceof Error ? loadError.message : "加载工具资源失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -330,37 +311,60 @@ export function ComponentsToolsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadTools();
|
||||
}, [loadTools]);
|
||||
void loadResources();
|
||||
}, [loadResources]);
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return tools.filter((tool) =>
|
||||
(filter === "全部" ||
|
||||
(filter === "End Call" && tool.type === "end_call") ||
|
||||
(filter === "HTTP" && tool.type === "http") ||
|
||||
(filter === "MCP" && tool.type === "mcp")) &&
|
||||
(!query ||
|
||||
[tool.name, tool.functionName, tool.description].some((value) =>
|
||||
value.toLowerCase().includes(query),
|
||||
)),
|
||||
const resources = useMemo<ToolResource[]>(() => {
|
||||
const nativeTools = tools
|
||||
.filter((tool) => tool.type !== "mcp")
|
||||
.map((tool) => ({ kind: "tool", id: tool.id, tool }) as const);
|
||||
const servers = mcpServers.map(
|
||||
(server) => ({ kind: "mcp", id: server.id, server }) as const,
|
||||
);
|
||||
}, [filter, search, tools]);
|
||||
return [...nativeTools, ...servers];
|
||||
}, [mcpServers, tools]);
|
||||
|
||||
const sortedTools = useMemo(() => {
|
||||
return [...filteredTools].sort((a, b) => {
|
||||
const diff = updatedAtValue(b.updatedAt) - updatedAtValue(a.updatedAt);
|
||||
const filteredResources = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return resources.filter((resource) => {
|
||||
if (resource.kind === "mcp") {
|
||||
return (
|
||||
(filter === "全部" || filter === "MCP") &&
|
||||
(!query ||
|
||||
[resource.server.name, resource.server.url, resource.server.description].some(
|
||||
(value) => value.toLowerCase().includes(query),
|
||||
))
|
||||
);
|
||||
}
|
||||
const tool = resource.tool;
|
||||
return (
|
||||
(filter === "全部" ||
|
||||
(filter === "End Call" && tool.type === "end_call") ||
|
||||
(filter === "HTTP" && tool.type === "http")) &&
|
||||
(!query ||
|
||||
[tool.name, tool.functionName, tool.description].some((value) =>
|
||||
value.toLowerCase().includes(query),
|
||||
))
|
||||
);
|
||||
});
|
||||
}, [filter, resources, search]);
|
||||
|
||||
const sortedResources = useMemo(() => {
|
||||
return [...filteredResources].sort((a, b) => {
|
||||
const aUpdatedAt = a.kind === "mcp" ? a.server.updatedAt : a.tool.updatedAt;
|
||||
const bUpdatedAt = b.kind === "mcp" ? b.server.updatedAt : b.tool.updatedAt;
|
||||
const diff = updatedAtValue(bUpdatedAt) - updatedAtValue(aUpdatedAt);
|
||||
if (diff !== 0) return sortOrder === "newest" ? diff : -diff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}, [filteredTools, sortOrder]);
|
||||
}, [filteredResources, sortOrder]);
|
||||
|
||||
const pageSize = 5;
|
||||
const totalPages = Math.max(1, Math.ceil(sortedTools.length / pageSize));
|
||||
const totalPages = Math.max(1, Math.ceil(sortedResources.length / pageSize));
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||||
const pageStart = (safeCurrentPage - 1) * pageSize;
|
||||
const pageEnd = pageStart + pageSize;
|
||||
const paginatedTools = sortedTools.slice(pageStart, pageEnd);
|
||||
const paginatedResources = sortedResources.slice(pageStart, pageEnd);
|
||||
|
||||
function changeFilter(value: ToolFilter) {
|
||||
setFilter(value);
|
||||
@@ -380,6 +384,12 @@ export function ComponentsToolsPage() {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openCreateMcpServer() {
|
||||
setDialogOpen(false);
|
||||
setEditingMcpServer(null);
|
||||
setMcpDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(tool: Tool) {
|
||||
setEditing(tool);
|
||||
setForm(formFromTool(tool));
|
||||
@@ -388,6 +398,11 @@ export function ComponentsToolsPage() {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEditMcpServer(server: McpServer) {
|
||||
setEditingMcpServer(server);
|
||||
setMcpDialogOpen(true);
|
||||
}
|
||||
|
||||
async function saveTool() {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
@@ -397,7 +412,7 @@ export function ComponentsToolsPage() {
|
||||
if (editing) await toolsApi.update(editing.id, payload);
|
||||
else await toolsApi.create(payload);
|
||||
setDialogOpen(false);
|
||||
await loadTools();
|
||||
await loadResources();
|
||||
} catch (saveError) {
|
||||
setFormError(saveError instanceof Error ? saveError.message : "保存失败");
|
||||
} finally {
|
||||
@@ -410,7 +425,20 @@ export function ComponentsToolsPage() {
|
||||
setDeletingId(tool.id);
|
||||
try {
|
||||
await toolsApi.remove(tool.id);
|
||||
await loadTools();
|
||||
await loadResources();
|
||||
} catch (removeError) {
|
||||
setError(removeError instanceof Error ? removeError.message : "删除失败");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMcpServer(server: McpServer) {
|
||||
if (!window.confirm(`确认删除 MCP 工具资源“${server.name}”及其同步工具?`)) return;
|
||||
setDeletingId(server.id);
|
||||
try {
|
||||
await mcpServersApi.remove(server.id);
|
||||
await loadResources();
|
||||
} catch (removeError) {
|
||||
setError(removeError instanceof Error ? removeError.message : "删除失败");
|
||||
} finally {
|
||||
@@ -422,7 +450,7 @@ export function ComponentsToolsPage() {
|
||||
setDuplicatingId(id);
|
||||
try {
|
||||
await toolsApi.duplicate(id);
|
||||
await loadTools();
|
||||
await loadResources();
|
||||
} catch (duplicateError) {
|
||||
setError(duplicateError instanceof Error ? duplicateError.message : "复制失败");
|
||||
} finally {
|
||||
@@ -430,18 +458,22 @@ export function ComponentsToolsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataListColumn<Tool>[] = [
|
||||
const columns: DataListColumn<ToolResource>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "工具名称",
|
||||
width: "md:w-[360px]",
|
||||
cell: (tool) => (
|
||||
cell: (resource) => (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium text-foreground">{tool.name}</span>
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{resource.kind === "mcp" ? resource.server.name : resource.tool.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-xs text-muted-soft">
|
||||
{tool.functionName}
|
||||
{resource.kind === "mcp"
|
||||
? `${resource.server.url} · ${resource.server.toolCount} 个工具`
|
||||
: resource.tool.functionName}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
@@ -450,29 +482,19 @@ export function ComponentsToolsPage() {
|
||||
key: "type",
|
||||
header: "类型",
|
||||
width: "md:w-[128px]",
|
||||
cell: (tool) => (
|
||||
cell: (resource) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||||
>
|
||||
{tool.type === "end_call"
|
||||
? "End Call"
|
||||
: tool.type === "mcp"
|
||||
? "MCP"
|
||||
{resource.kind === "mcp"
|
||||
? "MCP"
|
||||
: resource.tool.type === "end_call"
|
||||
? "End Call"
|
||||
: "HTTP"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
width: "md:w-[156px]",
|
||||
cell: (tool) => (
|
||||
<Badge variant="outline" className="h-6 px-3">
|
||||
{tool.status === "active" ? "启用" : tool.status === "draft" ? "草稿" : "已归档"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
width: "md:w-[176px]",
|
||||
@@ -491,74 +513,86 @@ export function ComponentsToolsPage() {
|
||||
</button>
|
||||
),
|
||||
cellClassName: "whitespace-nowrap tabular-nums text-muted-foreground",
|
||||
cell: (tool) => formatTimestamp(tool.updatedAt),
|
||||
cell: (resource) =>
|
||||
formatTimestamp(
|
||||
resource.kind === "mcp" ? resource.server.updatedAt : resource.tool.updatedAt,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "操作",
|
||||
align: "right",
|
||||
cellClassName: "flex justify-end gap-2",
|
||||
cell: (tool) => (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openEdit(tool)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
aria-label={`${tool.name} 更多操作`}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||||
cell: (resource) => {
|
||||
const name = resource.kind === "mcp" ? resource.server.name : resource.tool.name;
|
||||
const resourceId = resource.id;
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() =>
|
||||
resource.kind === "mcp"
|
||||
? openEditMcpServer(resource.server)
|
||||
: openEdit(resource.tool)
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
disabled={duplicatingId === tool.id || tool.type === "mcp"}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
void duplicateTool(tool.id);
|
||||
}}
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
aria-label={`${name} 更多操作`}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||||
>
|
||||
{duplicatingId === tool.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
复制
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="rounded-lg"
|
||||
disabled={deletingId === tool.id}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
void removeTool(tool);
|
||||
}}
|
||||
>
|
||||
{deletingId === tool.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
),
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
disabled={resource.kind === "mcp" || duplicatingId === resourceId}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
if (resource.kind === "tool") void duplicateTool(resource.tool.id);
|
||||
}}
|
||||
>
|
||||
{duplicatingId === resourceId ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
复制
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="rounded-lg"
|
||||
disabled={deletingId === resourceId}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
if (resource.kind === "mcp") void removeMcpServer(resource.server);
|
||||
else void removeTool(resource.tool);
|
||||
}}
|
||||
>
|
||||
{deletingId === resourceId ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -568,15 +602,31 @@ export function ComponentsToolsPage() {
|
||||
title="工具资源"
|
||||
description="管理可复用的助手工具,并将启用的工具绑定到提示词助手。"
|
||||
action={
|
||||
<Button size="lg" className="w-full gap-2 sm:w-auto" onClick={openCreate}>
|
||||
<Plus size={16} />
|
||||
添加工具
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="lg" className="w-full gap-2 sm:w-auto">
|
||||
<Plus size={16} />
|
||||
添加工具
|
||||
<ChevronDown size={14} className="opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-52 rounded-xl border border-hairline bg-popover p-1"
|
||||
>
|
||||
<DropdownMenuItem className="rounded-lg" onSelect={openCreate}>
|
||||
<Plus size={15} />
|
||||
添加普通工具
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="rounded-lg" onSelect={openCreateMcpServer}>
|
||||
<ServerCog size={15} />
|
||||
添加 MCP 服务
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
|
||||
<McpServersSection onToolsChanged={loadTools} />
|
||||
|
||||
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||||
<ListToolbar
|
||||
filters={
|
||||
@@ -591,18 +641,18 @@ export function ComponentsToolsPage() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DataList<Tool>
|
||||
<DataList<ToolResource>
|
||||
columns={columns}
|
||||
rows={paginatedTools}
|
||||
rowKey={(tool) => tool.id}
|
||||
rows={paginatedResources}
|
||||
rowKey={(resource) => resource.id}
|
||||
loading={loading}
|
||||
loadingText="正在加载工具…"
|
||||
error={error}
|
||||
onRetry={() => void loadTools()}
|
||||
onRetry={() => void loadResources()}
|
||||
empty={{
|
||||
title: tools.length === 0 ? "暂无工具资源" : "未找到匹配的工具资源",
|
||||
title: resources.length === 0 ? "暂无工具资源" : "未找到匹配的工具资源",
|
||||
description:
|
||||
tools.length === 0
|
||||
resources.length === 0
|
||||
? "点击右上角「添加工具」开始。"
|
||||
: "请调整关键词或筛选条件后再试。",
|
||||
}}
|
||||
@@ -611,15 +661,15 @@ export function ComponentsToolsPage() {
|
||||
totalPages,
|
||||
onPageChange: setCurrentPage,
|
||||
summary:
|
||||
filteredTools.length === 0
|
||||
filteredResources.length === 0
|
||||
? "没有数据"
|
||||
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredTools.length)} / 共 ${filteredTools.length} 个工具资源`,
|
||||
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredResources.length)} / 共 ${filteredResources.length} 个工具资源`,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:overflow-hidden">
|
||||
<DialogContent className={TOOL_DIALOG_CONTENT_CLASS}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑工具资源" : "添加工具资源"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -629,6 +679,21 @@ export function ComponentsToolsPage() {
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<FieldSection title="基本信息" tall>
|
||||
<Field label="工具类型" required>
|
||||
<Select
|
||||
value={form.type}
|
||||
disabled={Boolean(editing)}
|
||||
onValueChange={(type: ToolKind) =>
|
||||
setForm((current) => ({ ...current, type }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="end_call">End Call</SelectItem>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="工具名称" required>
|
||||
<Input
|
||||
value={form.name}
|
||||
@@ -654,39 +719,6 @@ export function ComponentsToolsPage() {
|
||||
placeholder="query_order"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="工具类型" required>
|
||||
<Select
|
||||
value={form.type}
|
||||
disabled={form.type === "mcp"}
|
||||
onValueChange={(type: ToolKind) =>
|
||||
setForm((current) => ({ ...current, type }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="end_call">End Call</SelectItem>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
<SelectItem value="mcp" disabled={!editing || form.type !== "mcp"}>
|
||||
MCP(由 Server 同步)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="状态">
|
||||
<Select
|
||||
value={form.status}
|
||||
onValueChange={(status: ToolStatus) =>
|
||||
setForm((current) => ({ ...current, status }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">启用</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="archived">归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="描述">
|
||||
<Textarea
|
||||
value={form.description}
|
||||
@@ -701,8 +733,6 @@ export function ComponentsToolsPage() {
|
||||
<FieldSection title="参数配置" scrollable tall>
|
||||
{form.type === "end_call" ? (
|
||||
<EndCallFields form={form} setForm={setForm} />
|
||||
) : form.type === "mcp" ? (
|
||||
<McpFields form={form} setForm={setForm} />
|
||||
) : (
|
||||
<HttpFields form={form} setForm={setForm} />
|
||||
)}
|
||||
@@ -722,6 +752,13 @@ export function ComponentsToolsPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<McpServerDialog
|
||||
open={mcpDialogOpen}
|
||||
server={editingMcpServer}
|
||||
onOpenChange={setMcpDialogOpen}
|
||||
onChanged={loadResources}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -776,42 +813,6 @@ function EndCallFields({
|
||||
);
|
||||
}
|
||||
|
||||
function McpFields({
|
||||
form,
|
||||
setForm,
|
||||
}: {
|
||||
form: ToolForm;
|
||||
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-hairline bg-canvas-soft px-4 py-3 text-sm text-muted-foreground">
|
||||
连接和参数 Schema 由 MCP Server 同步维护;这里仅配置本地名称、状态和结果变量赋值。
|
||||
</div>
|
||||
<Field label="远端工具名称">
|
||||
<Input value={form.remoteToolName} disabled className="font-mono" />
|
||||
</Field>
|
||||
<Field label="MCP Server ID">
|
||||
<Input value={form.mcpServerId} disabled className="font-mono" />
|
||||
</Field>
|
||||
<JsonField
|
||||
label="Input Schema(只读)"
|
||||
value={form.inputSchema}
|
||||
onChange={() => undefined}
|
||||
rows={10}
|
||||
disabled
|
||||
/>
|
||||
<JsonField
|
||||
label="响应变量赋值"
|
||||
value={form.dynamicVariableAssignments}
|
||||
onChange={(dynamicVariableAssignments) =>
|
||||
setForm((current) => ({ ...current, dynamicVariableAssignments }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HttpFields({
|
||||
form,
|
||||
setForm,
|
||||
@@ -877,84 +878,3 @@ function HttpFields({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSection({
|
||||
title,
|
||||
scrollable,
|
||||
tall,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
scrollable?: boolean;
|
||||
tall?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
"rounded-xl border border-hairline bg-surface-strong/20",
|
||||
tall ? "lg:flex lg:h-[38rem] lg:flex-col" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
"space-y-4 p-4",
|
||||
scrollable ? "max-h-72 overflow-y-auto" : "",
|
||||
tall ? "lg:max-h-none lg:flex-1" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
rows = 4,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
className="font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user