- Section header (h2 + description) and section blocks as h3; unified block padding and title size - Global settings forms use label-left / control-right rows, stacked below md; control width fixed by type (number short, text/select medium, wide/textarea full) - Per-block SaveBar pinned at the block end with dirty hint; naming template saves moved from the header to the bar - Console shell uses overflow-x-clip so the sticky header and sticky section nav actually stick; anchor jumps re-align while async content loads and land below the header - Section content uses a single minmax(0,1fr) column so wide tables scroll inside their card instead of widening the page - useSettings fills missing keys from DEFAULT_SETTINGS so an older backend no longer crashes the metadata section Co-Authored-By: Claude Opus 5.5 <[email protected]>
496 lines
19 KiB
TypeScript
496 lines
19 KiB
TypeScript
import type {
|
||
CapabilityAuthState,
|
||
CapabilityCaller,
|
||
CapabilityKind,
|
||
CapabilityStatsItem,
|
||
CapabilityToolInfo,
|
||
McpServerStatus,
|
||
RiskLevel,
|
||
} from "@umt/contracts";
|
||
import { Check, Copy } from "lucide-react";
|
||
import { useMemo, useState } from "react";
|
||
import { toast } from "sonner";
|
||
import { useApiKeys } from "~/api/api-keys.ts";
|
||
import {
|
||
mcpClientConfig,
|
||
useCapabilityStats,
|
||
useCapabilityTools,
|
||
useMcpStatus,
|
||
useUpdateMcpConfig,
|
||
} from "~/api/mcp.ts";
|
||
import { DataTable } from "~/components/console/data-table.tsx";
|
||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||
import { JsonView } from "~/components/console/json-view.tsx";
|
||
import { KvList } from "~/components/console/kv-list.tsx";
|
||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { Switch } from "~/components/ui/switch.tsx";
|
||
import { errorMessage } from "~/lib/api.ts";
|
||
import { formatRelative } from "~/lib/labels.ts";
|
||
import { formatDateTime } from "~/lib/utils.ts";
|
||
import { SettingsBlock } from "./settings-block.tsx";
|
||
|
||
// 设置 → Agent → 工具与 MCP:对外 MCP 服务端(端点、启用、允许的 API Key、会话、最近调用、接入配置)
|
||
// + 能力清单(作用域 / 风险 / 幂等与重放 / 授权状态 / 7 天调用与失败率 / 最近一次,点开看 schema)。
|
||
|
||
export const KIND_LABEL: Record<CapabilityKind, string> = {
|
||
read: "只读",
|
||
preview: "预览",
|
||
propose: "提案",
|
||
approve: "审批",
|
||
execute: "执行",
|
||
};
|
||
|
||
const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
|
||
low: { label: "低风险", tone: "neutral" },
|
||
medium: { label: "中风险", tone: "warning" },
|
||
high: { label: "高风险", tone: "destructive" },
|
||
};
|
||
|
||
const AUTH: Record<CapabilityAuthState, { label: string; tone: StatusTone; hint: string }> = {
|
||
read_only: { label: "无需授权", tone: "neutral", hint: "只读或预览,不落变更,Key 有对应 scope 即可调用" },
|
||
granted: { label: "有持续授权", tone: "success", hint: "Agent、MCP、API Key 调用按持续授权放行" },
|
||
needs_approval: {
|
||
label: "需审批",
|
||
tone: "warning",
|
||
hint: "没有覆盖它的持续授权,Agent、MCP、API Key 调用需要单次审批",
|
||
},
|
||
agent_only: { label: "仅 Agent 会话", tone: "neutral", hint: "只在 Agent 会话内可用,不经 MCP 暴露" },
|
||
};
|
||
|
||
export const CALLER_LABEL: Record<CapabilityCaller, string> = {
|
||
agent: "内置 Agent",
|
||
mcp: "MCP",
|
||
rest: "REST",
|
||
other: "其他",
|
||
};
|
||
|
||
const percent = (n: number, d: number) => (d > 0 ? `${Math.round((n / d) * 100)}%` : "—");
|
||
|
||
export function AgentToolsBlock() {
|
||
return (
|
||
<div className="grid grid-cols-1 gap-8">
|
||
<McpServerBlock />
|
||
<CapabilitiesBlock />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── MCP 服务端 ──
|
||
|
||
function McpServerBlock() {
|
||
const status = useMcpStatus();
|
||
return (
|
||
<SettingsBlock
|
||
section="agent"
|
||
anchor="mcp"
|
||
title="MCP 服务端"
|
||
description="外部 Agent 用 API Key 经 MCP 调用同一套能力。授权、审计、幂等与控制台一致,有副作用的工具没有持续授权或单次审批时被拒绝。"
|
||
>
|
||
{status.isPending ? (
|
||
<LoadingState rows={3} />
|
||
) : status.error ? (
|
||
<ErrorState title="读取 MCP 状态失败" error={status.error} onRetry={() => status.refetch()} plain />
|
||
) : (
|
||
<McpServerBody s={status.data} />
|
||
)}
|
||
</SettingsBlock>
|
||
);
|
||
}
|
||
|
||
function McpServerBody({ s }: { s: McpServerStatus }) {
|
||
const update = useUpdateMcpConfig();
|
||
const keys = useApiKeys();
|
||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||
const endpoint = `${origin}${s.path}`;
|
||
const config = useMemo(() => mcpClientConfig(origin, s.path, s.serverName), [origin, s.path, s.serverName]);
|
||
const activeKeys = (keys.data?.items ?? []).filter((k) => !k.revokedAt);
|
||
const restricted = s.allowedApiKeyIds !== null;
|
||
|
||
const save = (patch: Parameters<typeof update.mutate>[0], ok: string) =>
|
||
update.mutate(patch, {
|
||
onSuccess: () => toast.success(ok),
|
||
onError: (e) => toast.error(errorMessage(e, "保存失败")),
|
||
});
|
||
|
||
const toggleKey = (id: string, on: boolean) => {
|
||
const cur = new Set(s.allowedApiKeyIds ?? []);
|
||
if (on) cur.add(id);
|
||
else cur.delete(id);
|
||
save({ allowedApiKeyIds: [...cur] }, "已更新允许的 API Key");
|
||
};
|
||
|
||
return (
|
||
<div className="grid gap-5">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex items-center gap-3 text-sm">
|
||
<Switch
|
||
checked={s.enabled}
|
||
disabled={update.isPending}
|
||
onCheckedChange={(v) => save({ enabled: v }, v ? "MCP 服务端已启用" : "MCP 服务端已停用,现有会话已断开")}
|
||
aria-labelledby="mcp-enabled-label"
|
||
/>
|
||
<span id="mcp-enabled-label" className="font-medium">
|
||
{s.enabled ? "已启用" : "已停用"}
|
||
</span>
|
||
</div>
|
||
<StatusBadge tone={s.sessions.length > 0 ? "success" : "neutral"}>{s.sessions.length} 个会话在线</StatusBadge>
|
||
</div>
|
||
|
||
<KvList
|
||
items={[
|
||
{ label: "端点", value: <CopyText text={endpoint} />, wide: true },
|
||
{ label: "传输", value: "Streamable HTTP(POST / GET / DELETE)" },
|
||
{ label: "鉴权", value: "Authorization: Bearer <API Key>" },
|
||
{
|
||
label: "限速",
|
||
value: `每个 Key 每分钟 ${s.rateLimitPerMinute} 次工具调用,最多 ${s.maxSessionsPerKey} 个会话`,
|
||
},
|
||
{
|
||
label: "可见工具",
|
||
value: `read ${s.toolsByScope.read} 个 · read + organize.execute ${s.toolsByScope.readExecute} 个 · admin ${s.toolsByScope.admin} 个`,
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<DetailSection title="允许的 API Key">
|
||
<div className="grid gap-2 text-sm">
|
||
<label className="flex items-center gap-2">
|
||
<input
|
||
type="radio"
|
||
name="mcp-keys"
|
||
checked={!restricted}
|
||
disabled={update.isPending}
|
||
onChange={() => save({ allowedApiKeyIds: null }, "所有 API Key 都可以连接")}
|
||
/>
|
||
所有未撤销的 API Key
|
||
</label>
|
||
<label className="flex items-center gap-2">
|
||
<input
|
||
type="radio"
|
||
name="mcp-keys"
|
||
checked={restricted}
|
||
disabled={update.isPending}
|
||
onChange={() => save({ allowedApiKeyIds: [] }, "只允许勾选的 API Key")}
|
||
/>
|
||
只允许勾选的 Key
|
||
</label>
|
||
{restricted ? (
|
||
activeKeys.length === 0 ? (
|
||
<p className="pl-6 text-caption text-muted-foreground">还没有 API Key。在「API Key」分区新建。</p>
|
||
) : (
|
||
<ul className="grid gap-1.5 pl-6">
|
||
{activeKeys.map((k) => (
|
||
<li key={k.id}>
|
||
<label className="flex flex-wrap items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={s.allowedApiKeyIds?.includes(k.id) ?? false}
|
||
disabled={update.isPending}
|
||
onChange={(e) => toggleKey(k.id, e.target.checked)}
|
||
/>
|
||
<span className="font-medium">{k.name}</span>
|
||
<span className="text-meta text-muted-foreground">{k.scopes.join(" · ")}</span>
|
||
</label>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
) : null}
|
||
<p className="text-caption text-muted-foreground">
|
||
Key 的 scope 决定能看到哪些工具:read 只有只读和预览类,organize.execute 或 admin 才有执行类。
|
||
</p>
|
||
</div>
|
||
</DetailSection>
|
||
|
||
<DetailSection title="当前会话">
|
||
{s.sessions.length === 0 ? (
|
||
<p className="text-caption text-muted-foreground">
|
||
没有在线会话。客户端连上后出现在这里,空闲 30 分钟自动断开。
|
||
</p>
|
||
) : (
|
||
<ul className="grid gap-1.5 text-sm">
|
||
{s.sessions.map((x) => (
|
||
<li key={x.idPrefix} className="flex flex-wrap items-center gap-x-3 gap-y-0.5">
|
||
<code className="text-meta">{x.idPrefix}</code>
|
||
<span className="font-medium">{x.apiKeyName}</span>
|
||
<span className="text-meta text-muted-foreground">{x.clientName ?? "未知客户端"}</span>
|
||
<span className="text-meta text-muted-foreground">
|
||
{x.toolCalls} 次调用 · 最近 {formatRelative(x.lastSeenAt)}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</DetailSection>
|
||
|
||
<DetailSection title="最近调用">
|
||
{s.recentCalls.length === 0 ? (
|
||
<p className="text-caption text-muted-foreground">还没有经 MCP 的调用。</p>
|
||
) : (
|
||
<ul className="grid gap-1.5 text-sm">
|
||
{s.recentCalls.slice(0, 8).map((c) => (
|
||
<li key={`${c.at}-${c.capabilityId}`} className="flex flex-wrap items-center gap-x-3 gap-y-0.5">
|
||
<code className="text-meta">{c.capabilityId}</code>
|
||
<OutcomeBadge outcome={c.outcome} />
|
||
<span className="text-meta text-muted-foreground" title={formatDateTime(c.at)}>
|
||
{formatRelative(c.at)}
|
||
{c.durationMs !== null ? ` · ${c.durationMs} ms` : ""}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</DetailSection>
|
||
|
||
<DetailSection
|
||
title="接入配置"
|
||
actions={<CopyButton text={JSON.stringify(config, null, 2)} label="复制接入配置" />}
|
||
>
|
||
<p className="mb-2 text-caption text-muted-foreground">
|
||
适用于 Claude Code 等支持 Streamable HTTP 的客户端。密钥用环境变量 UMT_API_KEY 占位,配置里不含明文。
|
||
</p>
|
||
<JsonView value={config} maxHeight="14rem" />
|
||
</DetailSection>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OutcomeBadge({ outcome }: { outcome: string | null }) {
|
||
if (!outcome) return null;
|
||
const map: Record<string, { label: string; tone: StatusTone }> = {
|
||
ok: { label: "成功", tone: "success" },
|
||
denied: { label: "被拒绝", tone: "warning" },
|
||
invalid: { label: "参数不符", tone: "destructive" },
|
||
error: { label: "失败", tone: "destructive" },
|
||
};
|
||
const m = map[outcome] ?? { label: outcome, tone: "neutral" as const };
|
||
return <StatusBadge tone={m.tone}>{m.label}</StatusBadge>;
|
||
}
|
||
|
||
function CopyText({ text }: { text: string }) {
|
||
return (
|
||
<span className="flex min-w-0 items-center gap-2">
|
||
<code className="min-w-0 break-all text-meta">{text}</code>
|
||
<CopyButton text={text} label="复制" />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function CopyButton({ text, label }: { text: string; label: string }) {
|
||
const [copied, setCopied] = useState(false);
|
||
return (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
setCopied(true);
|
||
window.setTimeout(() => setCopied(false), 1500);
|
||
} catch {
|
||
toast.error("复制失败,手动选中文本复制");
|
||
}
|
||
}}
|
||
>
|
||
{copied ? <Check /> : <Copy />}
|
||
{copied ? "已复制" : label}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
// ── 能力清单 ──
|
||
|
||
type Row = CapabilityToolInfo & { stats: CapabilityStatsItem | null };
|
||
|
||
function CapabilitiesBlock() {
|
||
const tools = useCapabilityTools();
|
||
const stats = useCapabilityStats(7);
|
||
const [openId, setOpenId] = useState<string | null>(null);
|
||
const byId = useMemo(() => new Map((stats.data?.items ?? []).map((s) => [s.capabilityId, s])), [stats.data]);
|
||
const rows: Row[] | undefined = tools.data?.items.map((t) => ({ ...t, stats: byId.get(t.id) ?? null }));
|
||
const open = rows?.find((r) => r.id === openId) ?? null;
|
||
const automationOff = tools.data && (!tools.data.automationEnabled || tools.data.readOnlyReconcile);
|
||
|
||
return (
|
||
<SettingsBlock
|
||
section="agent"
|
||
anchor="capabilities"
|
||
title="能力清单"
|
||
description="Agent、MCP、REST 共用这些能力。调用数与失败率为最近 7 天,含被拒绝的调用。"
|
||
plain
|
||
>
|
||
{automationOff ? (
|
||
<p className="mb-3 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||
{tools.data?.readOnlyReconcile ? "只读对账模式" : "自动化总开关已关闭"}
|
||
:执行类能力对 Agent、MCP、API Key 一律拒绝,持续授权暂不生效。
|
||
</p>
|
||
) : null}
|
||
<DataTable
|
||
rows={rows}
|
||
rowKey={(r) => r.id}
|
||
loading={tools.isPending}
|
||
error={tools.error}
|
||
onRetry={() => tools.refetch()}
|
||
onRowClick={(r) => setOpenId(r.id)}
|
||
empty={{ title: "没有已注册的能力" }}
|
||
columns={[
|
||
{
|
||
key: "id",
|
||
header: "能力",
|
||
cell: (r) => (
|
||
<div className="min-w-0">
|
||
<p className="truncate font-medium">{r.title}</p>
|
||
<code className="text-meta break-all text-muted-foreground">{r.id}</code>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: "kind",
|
||
header: "类别 / scope",
|
||
hideBelow: "md",
|
||
cell: (r) => (
|
||
<div className="grid gap-0.5">
|
||
<span>{KIND_LABEL[r.kind]}</span>
|
||
<code className="text-meta text-muted-foreground">{r.scope}</code>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: "risk",
|
||
header: "风险",
|
||
hideBelow: "sm",
|
||
cell: (r) => <StatusBadge tone={RISK[r.riskLevel].tone}>{RISK[r.riskLevel].label}</StatusBadge>,
|
||
},
|
||
{
|
||
key: "idem",
|
||
header: "幂等 / 重放",
|
||
hideBelow: "lg",
|
||
cell: (r) => (
|
||
<span className="text-meta">
|
||
{r.idempotent ? "幂等" : "非幂等"} · {r.replay === "safe" ? "可重放" : "不重放"}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
key: "auth",
|
||
header: "授权状态",
|
||
cell: (r) => (
|
||
<span title={AUTH[r.authState].hint}>
|
||
<StatusBadge tone={AUTH[r.authState].tone}>{AUTH[r.authState].label}</StatusBadge>
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
key: "calls",
|
||
header: "7 天调用",
|
||
align: "right",
|
||
hideBelow: "sm",
|
||
cell: (r) =>
|
||
r.stats ? (
|
||
<div className="grid justify-items-end gap-0.5 tabular-nums">
|
||
<span>{r.stats.calls}</span>
|
||
<span
|
||
className={
|
||
r.stats.failures + r.stats.denied > 0
|
||
? "text-meta text-warning"
|
||
: "text-meta text-muted-foreground"
|
||
}
|
||
>
|
||
失败 {percent(r.stats.failures, r.stats.calls)}
|
||
{r.stats.denied > 0 ? ` · 拒绝 ${r.stats.denied}` : ""}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<span className="text-muted-foreground">0</span>
|
||
),
|
||
},
|
||
{
|
||
key: "last",
|
||
header: "最近一次",
|
||
hideBelow: "md",
|
||
cell: (r) =>
|
||
r.stats?.lastCalledAt ? (
|
||
<div className="grid gap-0.5">
|
||
<span title={formatDateTime(r.stats.lastCalledAt)}>{formatRelative(r.stats.lastCalledAt)}</span>
|
||
<span className="text-meta text-muted-foreground">
|
||
{r.stats.lastCaller ? CALLER_LABEL[r.stats.lastCaller] : ""}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<span className="text-muted-foreground">—</span>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
<CapabilitySheet row={open} onClose={() => setOpenId(null)} />
|
||
</SettingsBlock>
|
||
);
|
||
}
|
||
|
||
function CapabilitySheet({ row, onClose }: { row: Row | null; onClose: () => void }) {
|
||
return (
|
||
<DetailSheet
|
||
open={!!row}
|
||
onOpenChange={(o) => !o && onClose()}
|
||
title={row?.title ?? ""}
|
||
description={row?.id}
|
||
width="lg"
|
||
>
|
||
{row ? (
|
||
<>
|
||
<p className="text-sm">{row.description}</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
<StatusBadge>{KIND_LABEL[row.kind]}</StatusBadge>
|
||
<StatusBadge>scope · {row.scope}</StatusBadge>
|
||
<StatusBadge tone={RISK[row.riskLevel].tone}>{RISK[row.riskLevel].label}</StatusBadge>
|
||
<StatusBadge tone={AUTH[row.authState].tone}>{AUTH[row.authState].label}</StatusBadge>
|
||
</div>
|
||
<KvList
|
||
items={[
|
||
{ label: "版本", value: `v${row.version}` },
|
||
{ label: "MCP 工具名", value: row.mcpExposed ? row.toolName : "不经 MCP 暴露", mono: row.mcpExposed },
|
||
{ label: "幂等", value: row.idempotent ? "是" : "否" },
|
||
{ label: "崩溃后重放", value: row.replay === "safe" ? "可以" : "不重放" },
|
||
{ label: "支持预览", value: row.supportsPreview ? "是" : "否" },
|
||
{
|
||
label: "有效持续授权",
|
||
value: row.kind === "read" || row.kind === "preview" ? "—" : `${row.activeGrants} 条`,
|
||
},
|
||
{ label: "授权说明", value: AUTH[row.authState].hint, wide: true },
|
||
]}
|
||
/>
|
||
{row.stats ? (
|
||
<DetailSection title="最近 7 天">
|
||
<KvList
|
||
items={[
|
||
{ label: "调用", value: row.stats.calls },
|
||
{ label: "失败", value: `${row.stats.failures}(${percent(row.stats.failures, row.stats.calls)})` },
|
||
{ label: "被拒绝", value: row.stats.denied },
|
||
{
|
||
label: "平均耗时",
|
||
value: row.stats.avgDurationMs !== null ? `${row.stats.avgDurationMs} ms` : "—",
|
||
},
|
||
{
|
||
label: "调用来源",
|
||
value: (Object.keys(CALLER_LABEL) as CapabilityCaller[])
|
||
.filter((k) => row.stats!.byCaller[k] > 0)
|
||
.map((k) => `${CALLER_LABEL[k]} ${row.stats!.byCaller[k]}`)
|
||
.join(" · "),
|
||
wide: true,
|
||
},
|
||
]}
|
||
/>
|
||
</DetailSection>
|
||
) : null}
|
||
<DetailSection title="输入 schema">
|
||
<JsonView value={row.inputJsonSchema} maxHeight="24rem" />
|
||
</DetailSection>
|
||
<JsonView value={row.outputJsonSchema} maxHeight="24rem" defaultOpen={false} label="输出 schema" />
|
||
</>
|
||
) : null}
|
||
</DetailSheet>
|
||
);
|
||
}
|