feat: add MCP tool integration
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
Pencil,
|
||||
PhoneOff,
|
||||
Plus,
|
||||
ServerCog,
|
||||
Settings2,
|
||||
Waypoints,
|
||||
Wrench,
|
||||
@@ -472,7 +473,13 @@ export function ToolPicker({
|
||||
key={tool.id}
|
||||
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
|
||||
>
|
||||
{tool.type === "end_call" ? <PhoneOff size={14} /> : <Wrench size={14} />}
|
||||
{tool.type === "end_call" ? (
|
||||
<PhoneOff size={14} />
|
||||
) : tool.type === "mcp" ? (
|
||||
<ServerCog size={14} />
|
||||
) : (
|
||||
<Wrench size={14} />
|
||||
)}
|
||||
<span className="max-w-48 truncate">{tool.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -535,7 +542,11 @@ export function ToolPicker({
|
||||
{tool.name}
|
||||
</span>
|
||||
<Badge variant="secondary">
|
||||
{tool.type === "end_call" ? "End Call" : "HTTP"}
|
||||
{tool.type === "end_call"
|
||||
? "End Call"
|
||||
: tool.type === "mcp"
|
||||
? "MCP"
|
||||
: "HTTP"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { McpServersSection } from "@/components/tools/McpServersSection";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataList, type DataListColumn } from "@/components/ui/data-list";
|
||||
@@ -52,12 +53,12 @@ import {
|
||||
type ToolUpsert,
|
||||
} from "@/lib/api";
|
||||
|
||||
type ToolKind = "end_call" | "http";
|
||||
type ToolKind = "end_call" | "http" | "mcp";
|
||||
type HttpMethod = HttpToolDefinition["config"]["method"];
|
||||
type ToolFilter = "全部" | "End Call" | "HTTP";
|
||||
type ToolFilter = "全部" | "End Call" | "HTTP" | "MCP";
|
||||
type SortOrder = "newest" | "oldest";
|
||||
|
||||
const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP"];
|
||||
const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP", "MCP"];
|
||||
|
||||
type ToolForm = {
|
||||
name: string;
|
||||
@@ -77,6 +78,10 @@ type ToolForm = {
|
||||
body: string;
|
||||
dynamicVariableAssignments: string;
|
||||
secretDynamicVariables: string;
|
||||
mcpServerId: string;
|
||||
remoteToolName: string;
|
||||
inputSchema: string;
|
||||
schemaHash: string;
|
||||
};
|
||||
|
||||
const EMPTY_OBJECT = "{}";
|
||||
@@ -101,6 +106,10 @@ function blankForm(): ToolForm {
|
||||
body: EMPTY_OBJECT,
|
||||
dynamicVariableAssignments: EMPTY_OBJECT,
|
||||
secretDynamicVariables: EMPTY_OBJECT,
|
||||
mcpServerId: "",
|
||||
remoteToolName: "",
|
||||
inputSchema: EMPTY_OBJECT,
|
||||
schemaHash: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,6 +128,17 @@ 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;
|
||||
}
|
||||
base.method = tool.definition.config.method;
|
||||
base.url = tool.definition.config.url;
|
||||
base.timeoutSeconds = String(tool.definition.config.timeoutSeconds);
|
||||
@@ -198,6 +218,29 @@ 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) {
|
||||
@@ -295,7 +338,8 @@ export function ComponentsToolsPage() {
|
||||
return tools.filter((tool) =>
|
||||
(filter === "全部" ||
|
||||
(filter === "End Call" && tool.type === "end_call") ||
|
||||
(filter === "HTTP" && tool.type === "http")) &&
|
||||
(filter === "HTTP" && tool.type === "http") ||
|
||||
(filter === "MCP" && tool.type === "mcp")) &&
|
||||
(!query ||
|
||||
[tool.name, tool.functionName, tool.description].some((value) =>
|
||||
value.toLowerCase().includes(query),
|
||||
@@ -411,7 +455,11 @@ export function ComponentsToolsPage() {
|
||||
variant="secondary"
|
||||
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||||
>
|
||||
{tool.type === "end_call" ? "End Call" : "HTTP"}
|
||||
{tool.type === "end_call"
|
||||
? "End Call"
|
||||
: tool.type === "mcp"
|
||||
? "MCP"
|
||||
: "HTTP"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -478,7 +526,7 @@ export function ComponentsToolsPage() {
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
disabled={duplicatingId === tool.id}
|
||||
disabled={duplicatingId === tool.id || tool.type === "mcp"}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
void duplicateTool(tool.id);
|
||||
@@ -527,6 +575,8 @@ export function ComponentsToolsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<McpServersSection onToolsChanged={loadTools} />
|
||||
|
||||
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||||
<ListToolbar
|
||||
filters={
|
||||
@@ -607,6 +657,7 @@ export function ComponentsToolsPage() {
|
||||
<Field label="工具类型" required>
|
||||
<Select
|
||||
value={form.type}
|
||||
disabled={form.type === "mcp"}
|
||||
onValueChange={(type: ToolKind) =>
|
||||
setForm((current) => ({ ...current, type }))
|
||||
}
|
||||
@@ -615,6 +666,9 @@ export function ComponentsToolsPage() {
|
||||
<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>
|
||||
@@ -647,6 +701,8 @@ 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} />
|
||||
)}
|
||||
@@ -720,6 +776,42 @@ 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,
|
||||
@@ -845,11 +937,13 @@ function JsonField({
|
||||
value,
|
||||
onChange,
|
||||
rows = 4,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
@@ -857,6 +951,7 @@ function JsonField({
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
className="font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
449
frontend/src/components/tools/McpServersSection.tsx
Normal file
449
frontend/src/components/tools/McpServersSection.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ServerCog,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataList, type DataListColumn } from "@/components/ui/data-list";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
mcpServersApi,
|
||||
type McpServer,
|
||||
type McpServerUpsert,
|
||||
type ToolStatus,
|
||||
} from "@/lib/api";
|
||||
|
||||
type McpServerForm = {
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
timeoutSeconds: string;
|
||||
headers: string;
|
||||
secretHeaders: string;
|
||||
status: ToolStatus;
|
||||
};
|
||||
|
||||
const EMPTY_OBJECT = "{}";
|
||||
|
||||
function blankForm(): McpServerForm {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
url: "",
|
||||
timeoutSeconds: "30",
|
||||
headers: EMPTY_OBJECT,
|
||||
secretHeaders: EMPTY_OBJECT,
|
||||
status: "active",
|
||||
};
|
||||
}
|
||||
|
||||
function formFromServer(server: McpServer): McpServerForm {
|
||||
return {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
url: server.url,
|
||||
timeoutSeconds: String(server.timeoutSeconds),
|
||||
headers: JSON.stringify(server.headers ?? {}, null, 2),
|
||||
secretHeaders: JSON.stringify(server.secretHeaders ?? {}, null, 2),
|
||||
status: server.status,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStringMap(value: string, label: string): Record<string, string> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value || EMPTY_OBJECT);
|
||||
} catch {
|
||||
throw new Error(`${label}不是有效的 JSON`);
|
||||
}
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
||||
throw new Error(`${label}必须是 JSON 对象`);
|
||||
}
|
||||
const entries = Object.entries(parsed);
|
||||
if (entries.some(([, item]) => typeof item !== "string")) {
|
||||
throw new Error(`${label}的值必须全部是字符串`);
|
||||
}
|
||||
return Object.fromEntries(entries) as Record<string, string>;
|
||||
}
|
||||
|
||||
function payloadFromForm(form: McpServerForm): McpServerUpsert {
|
||||
if (!form.name.trim()) throw new Error("请输入 MCP Server 名称");
|
||||
if (!form.url.startsWith("http://") && !form.url.startsWith("https://")) {
|
||||
throw new Error("MCP Server URL 必须使用 http:// 或 https://");
|
||||
}
|
||||
const timeoutSeconds = Number(form.timeoutSeconds);
|
||||
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
|
||||
throw new Error("超时时间必须是 1 到 120 秒之间的整数");
|
||||
}
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
transport: "streamable_http",
|
||||
url: form.url.trim(),
|
||||
timeoutSeconds,
|
||||
headers: parseStringMap(form.headers, "Header"),
|
||||
secretHeaders: parseStringMap(form.secretHeaders, "敏感 Header"),
|
||||
status: form.status,
|
||||
};
|
||||
}
|
||||
|
||||
export function McpServersSection({
|
||||
onToolsChanged,
|
||||
}: {
|
||||
onToolsChanged: () => void | Promise<void>;
|
||||
}) {
|
||||
const [servers, setServers] = useState<McpServer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<McpServer | null>(null);
|
||||
const [form, setForm] = useState<McpServerForm>(blankForm);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [syncingId, setSyncingId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const loadServers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setServers(await mcpServersApi.list());
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "加载 MCP Server 失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadServers();
|
||||
}, [loadServers]);
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setForm(blankForm());
|
||||
setFormError(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(server: McpServer) {
|
||||
setEditing(server);
|
||||
setForm(formFromServer(server));
|
||||
setFormError(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
async function syncServer(serverId: string) {
|
||||
setSyncingId(serverId);
|
||||
setError(null);
|
||||
try {
|
||||
await mcpServersApi.sync(serverId);
|
||||
await Promise.all([loadServers(), onToolsChanged()]);
|
||||
} catch (syncError) {
|
||||
setError(syncError instanceof Error ? syncError.message : "同步 MCP 工具失败");
|
||||
} finally {
|
||||
setSyncingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndSync() {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const payload = payloadFromForm(form);
|
||||
const saved = editing
|
||||
? await mcpServersApi.update(editing.id, payload)
|
||||
: await mcpServersApi.create(payload);
|
||||
await mcpServersApi.sync(saved.id);
|
||||
setDialogOpen(false);
|
||||
await Promise.all([loadServers(), onToolsChanged()]);
|
||||
} catch (saveError) {
|
||||
setFormError(
|
||||
saveError instanceof Error ? saveError.message : "保存或同步 MCP Server 失败",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(server: McpServer) {
|
||||
if (!window.confirm(`确认删除 MCP Server“${server.name}”及其同步工具?`)) {
|
||||
return;
|
||||
}
|
||||
setDeletingId(server.id);
|
||||
setError(null);
|
||||
try {
|
||||
await mcpServersApi.remove(server.id);
|
||||
await Promise.all([loadServers(), onToolsChanged()]);
|
||||
} catch (removeError) {
|
||||
setError(removeError instanceof Error ? removeError.message : "删除 MCP Server 失败");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: DataListColumn<McpServer>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "MCP SERVER",
|
||||
cell: (server) => (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ServerCog size={15} className="shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium text-foreground">{server.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-xs text-muted-soft">
|
||||
{server.url}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
header: "工具",
|
||||
width: "md:w-[120px]",
|
||||
cell: (server) => (
|
||||
<span className="text-muted-foreground">{server.toolCount} 个</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
width: "md:w-[120px]",
|
||||
cell: (server) => (
|
||||
<Badge variant="outline" className="h-6 px-3">
|
||||
{server.status === "active"
|
||||
? "启用"
|
||||
: server.status === "draft"
|
||||
? "草稿"
|
||||
: "归档"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "操作",
|
||||
width: "md:w-[260px]",
|
||||
align: "right",
|
||||
cellClassName: "flex justify-end gap-2",
|
||||
cell: (server) => (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 border-hairline-strong"
|
||||
disabled={syncingId === server.id}
|
||||
onClick={() => void syncServer(server.id)}
|
||||
>
|
||||
{syncingId === server.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
同步
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`编辑 ${server.name}`}
|
||||
onClick={() => openEdit(server)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`删除 ${server.name}`}
|
||||
disabled={deletingId === server.id}
|
||||
onClick={() => void removeServer(server)}
|
||||
>
|
||||
{deletingId === server.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="caption-label text-muted-soft">MCP CONNECTIONS</div>
|
||||
<h2 className="mt-1 text-lg font-medium text-foreground">MCP Server</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
连接远端 MCP Server,并将发现的工具同步到下方工具资源。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" className="gap-2" onClick={openCreate}>
|
||||
<Plus size={15} />
|
||||
添加 MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
<DataList<McpServer>
|
||||
columns={columns}
|
||||
rows={servers}
|
||||
rowKey={(server) => server.id}
|
||||
loading={loading}
|
||||
loadingText="正在加载 MCP Server…"
|
||||
error={error}
|
||||
onRetry={() => void loadServers()}
|
||||
empty={{
|
||||
title: "暂无 MCP Server",
|
||||
description: "添加连接后,系统会显式同步远端工具,不会自动暴露新能力。",
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑 MCP Server" : "添加 MCP Server"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
保存后会测试连接并同步工具。首版仅支持 Streamable HTTP。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">名称</span>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, name: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">Streamable HTTP URL</span>
|
||||
<Input
|
||||
value={form.url}
|
||||
placeholder="https://mcp.example.com/mcp"
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, url: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">超时时间(秒)</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={form.timeoutSeconds}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
timeoutSeconds: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">状态</span>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">普通 Header(JSON)</span>
|
||||
<Textarea
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
value={form.headers}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, headers: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">敏感 Header(JSON)</span>
|
||||
<Textarea
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
value={form.secretHeaders}
|
||||
placeholder={'{"Authorization":"Bearer ..."}'}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
secretHeaders: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||||
密钥只保存在后端,重新打开时显示为打码占位符。
|
||||
</span>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium">描述</span>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
description: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formError && <div className="text-sm text-destructive">{formError}</div>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void saveAndSync()} disabled={saving}>
|
||||
{saving && <Loader2 size={15} className="animate-spin" />}
|
||||
保存并同步工具
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import type { Edge } from "@xyflow/react";
|
||||
import {
|
||||
Braces,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
GitBranch,
|
||||
MessageSquareText,
|
||||
Plus,
|
||||
@@ -92,6 +94,24 @@ export function EdgeSettingsPanel({
|
||||
publish({ nextRules });
|
||||
};
|
||||
|
||||
const applyActionResultPreset = (status: "ok" | "error") => {
|
||||
const nextRules: ExpressionRule[] = [
|
||||
{
|
||||
variable: "system__last_action_status",
|
||||
operator: "eq",
|
||||
value: status,
|
||||
},
|
||||
];
|
||||
setMode("expression");
|
||||
setCombinator("and");
|
||||
setRules(nextRules);
|
||||
publish({
|
||||
nextMode: "expression",
|
||||
nextCombinator: "and",
|
||||
nextRules,
|
||||
});
|
||||
};
|
||||
|
||||
const parseValue = (value: string): unknown => {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
@@ -139,6 +159,35 @@ export function EdgeSettingsPanel({
|
||||
Agent 不能只有默认路径;请改为条件路径,或删除连接以持续对话。
|
||||
</span>
|
||||
)}
|
||||
{sourceType === "action" && (
|
||||
<div className="rounded-xl border border-hairline bg-canvas-soft p-3">
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
Action 常用结果条件
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
onClick={() => applyActionResultPreset("ok")}
|
||||
>
|
||||
<CircleCheck size={14} />
|
||||
执行成功
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
onClick={() => applyActionResultPreset("error")}
|
||||
>
|
||||
<CircleX size={14} />
|
||||
执行失败
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mode !== "always" && (
|
||||
<label className="block">
|
||||
<div className="mb-1.5 text-sm font-medium text-foreground">
|
||||
|
||||
@@ -347,19 +347,35 @@ export type HttpToolDefinition = {
|
||||
};
|
||||
};
|
||||
|
||||
export type McpToolDefinition = {
|
||||
schemaVersion: number;
|
||||
type: "mcp";
|
||||
config: {
|
||||
remoteToolName: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
schemaHash: string;
|
||||
dynamicVariableAssignments: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
export type Tool = {
|
||||
id: string;
|
||||
name: string;
|
||||
functionName: string;
|
||||
type: "end_call" | "http";
|
||||
type: "end_call" | "http" | "mcp";
|
||||
description: string;
|
||||
definition: EndCallToolDefinition | HttpToolDefinition;
|
||||
definition: EndCallToolDefinition | HttpToolDefinition | McpToolDefinition;
|
||||
secrets: Record<string, unknown>;
|
||||
status: ToolStatus;
|
||||
mcpServerId?: string | null;
|
||||
remoteToolName?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type ToolUpsert = Omit<Tool, "id" | "type" | "updatedAt">;
|
||||
export type ToolUpsert = Omit<
|
||||
Tool,
|
||||
"id" | "type" | "remoteToolName" | "updatedAt"
|
||||
>;
|
||||
|
||||
export const toolsApi = {
|
||||
list: () => request<Tool[]>("/api/tools"),
|
||||
@@ -379,6 +395,55 @@ export const toolsApi = {
|
||||
request<Tool>(`/api/tools/${id}/duplicate`, { method: "POST" }),
|
||||
};
|
||||
|
||||
export type McpServer = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
transport: "streamable_http";
|
||||
url: string;
|
||||
timeoutSeconds: number;
|
||||
headers: Record<string, string>;
|
||||
secretHeaders: Record<string, string>;
|
||||
status: ToolStatus;
|
||||
toolCount: number;
|
||||
lastSyncedAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type McpServerUpsert = Omit<
|
||||
McpServer,
|
||||
"id" | "toolCount" | "lastSyncedAt" | "updatedAt"
|
||||
>;
|
||||
|
||||
export type McpSyncResult = {
|
||||
server: McpServer;
|
||||
created: number;
|
||||
updated: number;
|
||||
tools: Tool[];
|
||||
};
|
||||
|
||||
export const mcpServersApi = {
|
||||
list: () => request<McpServer[]>("/api/mcp-servers"),
|
||||
create: (body: McpServerUpsert) =>
|
||||
request<McpServer>("/api/mcp-servers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (id: string, body: McpServerUpsert) =>
|
||||
request<McpServer>(`/api/mcp-servers/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
sync: (id: string) =>
|
||||
request<McpSyncResult>(`/api/mcp-servers/${id}/sync`, {
|
||||
method: "POST",
|
||||
}),
|
||||
remove: (id: string) =>
|
||||
request<{ ok: boolean }>(`/api/mcp-servers/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
};
|
||||
|
||||
// ---------- 知识库 ----------
|
||||
export type KnowledgeBase = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user