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:
Xin Wang
2026-07-19 12:04:11 +08:00
parent c54dac403b
commit f027ed99b7
8 changed files with 887 additions and 784 deletions

View File

@@ -0,0 +1,460 @@
"use client";
import { ChevronDown, ChevronRight, Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
TOOL_DIALOG_CONTENT_CLASS,
ToolFormField as Field,
ToolFormSection as FormSection,
ToolJsonField as JsonField,
} from "@/components/tools/tool-form-controls";
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
mcpServersApi,
type McpServer,
type McpServerUpsert,
type McpToolDefinition,
type Tool,
type ToolStatus,
} from "@/lib/api";
type McpServerForm = {
name: string;
description: string;
transport: McpServer["transport"];
url: string;
timeoutSeconds: string;
headers: string;
secretHeaders: string;
status: ToolStatus;
};
type McpServerDialogProps = {
open: boolean;
server: McpServer | null;
onOpenChange: (open: boolean) => void;
onChanged: () => void | Promise<void>;
};
const EMPTY_OBJECT = "{}";
function blankForm(): McpServerForm {
return {
name: "",
description: "",
transport: "streamable_http",
url: "",
timeoutSeconds: "30",
headers: EMPTY_OBJECT,
secretHeaders: EMPTY_OBJECT,
status: "active",
};
}
function formFromServer(server: McpServer): McpServerForm {
return {
name: server.name,
description: server.description,
transport: server.transport,
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: form.transport,
url: form.url.trim(),
timeoutSeconds,
headers: parseStringMap(form.headers, "Header"),
secretHeaders: parseStringMap(form.secretHeaders, "敏感 Header"),
status: form.status,
};
}
function formatTimestamp(value?: string | null): string {
if (!value) return "尚未同步";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
export function McpServerDialog({
open,
server,
onOpenChange,
onChanged,
}: McpServerDialogProps) {
const [form, setForm] = useState<McpServerForm>(blankForm);
const [activeTab, setActiveTab] = useState("connection");
const [serverTools, setServerTools] = useState<Tool[]>([]);
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null);
const [expandedToolIds, setExpandedToolIds] = useState<Set<string>>(new Set());
const [loadingTools, setLoadingTools] = useState(false);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [toolsError, setToolsError] = useState<string | null>(null);
const loadServerTools = useCallback(async () => {
if (!server) return;
setLoadingTools(true);
setToolsError(null);
try {
setServerTools(await mcpServersApi.tools(server.id));
} catch (error) {
setToolsError(error instanceof Error ? error.message : "加载 MCP 工具失败");
} finally {
setLoadingTools(false);
}
}, [server]);
useEffect(() => {
if (!open) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm(server ? formFromServer(server) : blankForm());
setActiveTab("connection");
setServerTools([]);
setLastSyncedAt(server?.lastSyncedAt ?? null);
setExpandedToolIds(new Set());
setFormError(null);
setToolsError(null);
if (server) void loadServerTools();
}, [loadServerTools, open, server]);
async function saveAndSync() {
if (saving) return;
setSaving(true);
setFormError(null);
try {
const payload = payloadFromForm(form);
const saved = server
? await mcpServersApi.update(server.id, payload)
: await mcpServersApi.create(payload);
await mcpServersApi.sync(saved.id);
await onChanged();
onOpenChange(false);
} catch (error) {
setFormError(error instanceof Error ? error.message : "保存或同步 MCP Server 失败");
} finally {
setSaving(false);
}
}
async function syncTools() {
if (!server || syncing) return;
setSyncing(true);
setToolsError(null);
try {
const result = await mcpServersApi.sync(server.id);
setServerTools(result.tools);
setLastSyncedAt(result.server.lastSyncedAt ?? null);
await onChanged();
} catch (error) {
setToolsError(error instanceof Error ? error.message : "同步 MCP 工具失败");
} finally {
setSyncing(false);
}
}
function toggleTool(toolId: string) {
setExpandedToolIds((current) => {
const next = new Set(current);
if (next.has(toolId)) next.delete(toolId);
else next.add(toolId);
return next;
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={TOOL_DIALOG_CONTENT_CLASS}>
<DialogHeader>
<DialogTitle>{server ? "编辑 MCP 工具资源" : "添加 MCP 工具资源"}</DialogTitle>
<DialogDescription>
MCP Server
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full min-h-0">
<TabsList variant="line" className="w-full justify-start border-b border-hairline px-1">
<TabsTrigger value="connection" className="flex-none px-4">
</TabsTrigger>
<TabsTrigger value="tools" className="flex-none px-4" disabled={!server}>
{server ? ` (${loadingTools ? server.toolCount : serverTools.length})` : ""}
</TabsTrigger>
</TabsList>
<TabsContent value="connection" className="max-h-[60vh] overflow-y-auto px-1 pt-4">
<div className="grid gap-5 lg:grid-cols-2">
<FormSection title="基本信息">
<Field label="资源类型">
<Input value="MCP Server" disabled />
</Field>
<Field label="名称" required>
<Input
value={form.name}
placeholder="例如:订单系统 MCP"
onChange={(event) =>
setForm((current) => ({ ...current, name: event.target.value }))
}
/>
</Field>
<Field label="描述">
<Textarea
rows={4}
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
/>
</Field>
</FormSection>
<FormSection title="连接参数">
<Field label="传输方式">
<Select
value={form.transport}
onValueChange={(transport: McpServer["transport"]) =>
setForm((current) => ({ ...current, transport }))
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="streamable_http">Streamable HTTP</SelectItem>
<SelectItem value="sse">SSE</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Server URL" required>
<Input
value={form.url}
placeholder="https://mcp.example.com/mcp"
onChange={(event) =>
setForm((current) => ({ ...current, url: event.target.value }))
}
/>
</Field>
<Field label="超时时间(秒)">
<Input
type="number"
min={1}
max={120}
value={form.timeoutSeconds}
onChange={(event) =>
setForm((current) => ({
...current,
timeoutSeconds: event.target.value,
}))
}
/>
</Field>
<JsonField
label="普通 HeaderJSON"
value={form.headers}
onChange={(headers) => setForm((current) => ({ ...current, headers }))}
/>
<JsonField
label="敏感 HeaderJSON"
value={form.secretHeaders}
placeholder={'{"Authorization":"Bearer ..."}'}
onChange={(secretHeaders) =>
setForm((current) => ({ ...current, secretHeaders }))
}
hint="密钥只保存在后端,重新打开时显示为打码占位符。"
/>
</FormSection>
</div>
{formError && <div className="mt-4 text-sm text-destructive">{formError}</div>}
</TabsContent>
<TabsContent value="tools" className="min-h-0 pt-4">
<div className="mb-4 flex items-center justify-between gap-4 rounded-xl border border-hairline bg-surface-strong/30 px-4 py-3">
<div>
<div className="text-sm font-medium text-foreground"></div>
<div className="mt-0.5 text-xs text-muted-foreground">
{formatTimestamp(lastSyncedAt)}
</div>
</div>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={syncing}
onClick={() => void syncTools()}
>
{syncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
</Button>
</div>
<div className="max-h-[52vh] overflow-y-auto rounded-xl border border-hairline">
{loadingTools ? (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : toolsError ? (
<div className="px-5 py-10 text-center text-sm text-destructive">{toolsError}</div>
) : serverTools.length === 0 ? (
<div className="px-5 py-12 text-center">
<div className="font-medium text-foreground"></div>
<div className="mt-2 text-sm text-muted-foreground"> MCP Server </div>
</div>
) : (
<div className="divide-y divide-hairline">
{serverTools.map((tool) => (
<McpToolRow
key={tool.id}
tool={tool}
expanded={expandedToolIds.has(tool.id)}
onToggle={() => toggleTool(tool.id)}
/>
))}
</div>
)}
</div>
</TabsContent>
</Tabs>
{activeTab === "connection" && (
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={() => void saveAndSync()} disabled={saving}>
{saving && <Loader2 size={15} className="animate-spin" />}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
function McpToolRow({
tool,
expanded,
onToggle,
}: {
tool: Tool;
expanded: boolean;
onToggle: () => void;
}) {
const definition = tool.definition as McpToolDefinition;
const schema = definition.config.inputSchema ?? {};
const properties = useMemo(
() => Object.entries((schema.properties as Record<string, unknown> | undefined) ?? {}),
[schema.properties],
);
const required = new Set(Array.isArray(schema.required) ? schema.required.map(String) : []);
return (
<div>
<button
type="button"
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-strong/40"
onClick={onToggle}
aria-expanded={expanded}
>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">{tool.name}</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-soft">
{definition.config.remoteToolName}
</div>
</div>
<Badge variant="outline" className="shrink-0">{properties.length} </Badge>
</button>
{expanded && (
<div className="border-t border-hairline bg-canvas-soft px-5 py-4">
{tool.description && (
<p className="mb-4 text-sm leading-6 text-muted-foreground">{tool.description}</p>
)}
{properties.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<div className="overflow-hidden rounded-lg border border-hairline bg-card">
{properties.map(([name, rawDefinition]) => {
const parameter =
rawDefinition && typeof rawDefinition === "object"
? (rawDefinition as Record<string, unknown>)
: {};
return (
<div
key={name}
className="grid gap-2 border-b border-hairline px-4 py-3 last:border-b-0 sm:grid-cols-[160px_100px_1fr]"
>
<div className="font-mono text-xs text-foreground">
{name}{required.has(name) && <span className="ml-1 text-destructive">*</span>}
</div>
<div className="text-xs text-muted-foreground">
{String(parameter.type ?? "any")}
</div>
<div className="text-xs leading-5 text-muted-foreground">
{String(parameter.description ?? "—")}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,449 +0,0 @@
"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"> HeaderJSON</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"> HeaderJSON</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>
</>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import type { ReactNode } from "react";
import { Textarea } from "@/components/ui/textarea";
export const TOOL_DIALOG_CONTENT_CLASS =
"max-h-[calc(100vh-3rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-y-auto sm:h-[48.875rem] sm:max-w-6xl lg:overflow-hidden";
export function ToolFormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: 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>
);
}
export function ToolFormSection({
title,
scrollable = false,
tall = false,
children,
}: {
title: string;
scrollable?: boolean;
tall?: boolean;
children: 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>
);
}
export function ToolJsonField({
label,
value,
onChange,
rows = 4,
disabled = false,
placeholder,
hint,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
disabled?: boolean;
placeholder?: string;
hint?: string;
}) {
return (
<ToolFormField label={label}>
<Textarea
value={value}
onChange={(event) => onChange(event.target.value)}
rows={rows}
disabled={disabled}
placeholder={placeholder}
className="font-mono text-xs"
spellCheck={false}
/>
{hint && <span className="block text-xs text-muted-foreground">{hint}</span>}
</ToolFormField>
);
}