feat(web): tools and MCP section in Agent settings, tool call IO drawer
Settings > Agent: MCP server card (endpoint, enable switch, allowed API keys, live sessions, recent calls, copyable client config without secrets) and the capability list (scope, risk, idempotency/replay, authorization state, 7-day calls and failure rate, last call, schema sheet). Agent session tool cards get a drawer with input/output schema and the redacted input/output of the call. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
653100ceb4
commit
b3cc5a8313
5 files changed
+656
No files matched your search
@@ -0,0 +1,70 @@
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CapabilityStatsResponse,
|
||||
CapabilityToolsResponse,
|
||||
McpServerConfig,
|
||||
type McpServerConfigUpdate,
|
||||
McpServerStatus,
|
||||
} from "@umt/contracts";
|
||||
import { TTL_MINUTE, TTL_SECOND } from "~/lib/api.ts";
|
||||
import { apiParsed } from "./parse.ts";
|
||||
|
||||
// 能力清单 / 调用统计 / 对外 MCP 服务端(AG-1):GET /capabilities/tools、/capabilities/stats、/mcp/status,PATCH /mcp/config。
|
||||
|
||||
const KEY = {
|
||||
tools: ["capabilities", "tools"] as const,
|
||||
stats: (days: number) => ["capabilities", "stats", days] as const,
|
||||
mcp: ["mcp", "status"] as const,
|
||||
};
|
||||
|
||||
/** 能力清单(描述符 + JSON Schema + 授权状态);会话页工具卡片与设置页共用,改动少,缓存久一点 */
|
||||
export function useCapabilityTools(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: KEY.tools,
|
||||
queryFn: ({ signal }) => apiParsed(CapabilityToolsResponse, "/capabilities/tools", { signal }),
|
||||
staleTime: TTL_MINUTE,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCapabilityStats(days = 7) {
|
||||
return useQuery({
|
||||
queryKey: KEY.stats(days),
|
||||
queryFn: ({ signal }) => apiParsed(CapabilityStatsResponse, "/capabilities/stats", { params: { days }, signal }),
|
||||
staleTime: 30 * TTL_SECOND,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMcpStatus() {
|
||||
return useQuery({
|
||||
queryKey: KEY.mcp,
|
||||
queryFn: ({ signal }) => apiParsed(McpServerStatus, "/mcp/status", { signal }),
|
||||
refetchInterval: 15 * TTL_SECOND,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMcpConfig() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: McpServerConfigUpdate) => apiParsed(McpServerConfig, "/mcp/config", { method: "PATCH", body }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY.mcp }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 给 Claude Code 等 MCP 客户端的接入配置(Streamable HTTP)。密钥用环境变量占位,不含明文:
|
||||
* 客户端侧把 UMT_API_KEY 设为在「API Key」分区新建的 Key。
|
||||
*/
|
||||
export function mcpClientConfig(origin: string, path: string, serverName: string) {
|
||||
return {
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
type: "http",
|
||||
url: `${origin}${path}`,
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: 客户端侧的环境变量占位,不是本地模板
|
||||
headers: { Authorization: "Bearer ${UMT_API_KEY}" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/component
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { ToolCallIo } from "./tool-call-card-io.tsx";
|
||||
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
|
||||
|
||||
// Agent 会话的展示层:把 use-agent-stream 折叠出的 StreamItem 映射到 agentui 组件(components/agentui/,
|
||||
@@ -230,6 +231,7 @@ export function ToolCallCard({
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
<ToolCallIo call={call} />
|
||||
</ToolResult>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Braces } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useCapabilityTools } from "~/api/mcp.ts";
|
||||
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 { Button } from "~/components/ui/button.tsx";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import type { ToolCallView } from "./use-agent-stream.ts";
|
||||
|
||||
// 会话页工具卡片的"查看 schema 与本次输入输出"抽屉。
|
||||
// 本次输入输出取事件流里的摘要:服务端已脱敏(凭证键一律遮盖),超长部分截断;schema 取能力清单(GET /capabilities/tools)。
|
||||
|
||||
/** 摘要尽量按 JSON 美化;截断后的摘要不是合法 JSON,原样显示 */
|
||||
export function parseSummary(text: string | null): { value: unknown; truncated: boolean } {
|
||||
if (text === null) return { value: null, truncated: false };
|
||||
const truncated = /…\(\d+ more\)$/.test(text);
|
||||
if (truncated) return { value: text, truncated };
|
||||
try {
|
||||
return { value: JSON.parse(text), truncated };
|
||||
} catch {
|
||||
return { value: text, truncated };
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolCallIo({ call }: { call: ToolCallView }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" className="justify-self-start" onClick={() => setOpen(true)}>
|
||||
<Braces />
|
||||
查看 schema 与输入输出
|
||||
</Button>
|
||||
{open ? <ToolCallIoSheet call={call} onClose={() => setOpen(false)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallIoSheet({ call, onClose }: { call: ToolCallView; onClose: () => void }) {
|
||||
const tools = useCapabilityTools();
|
||||
const tool = tools.data?.items.find((t) => t.id === call.capabilityId) ?? null;
|
||||
const input = parseSummary(call.inputSummary);
|
||||
const output = parseSummary(call.outputSummary);
|
||||
const failed = call.status === "failed" || call.status === "denied";
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={tool?.title ?? call.capabilityId}
|
||||
description={call.capabilityId}
|
||||
width="lg"
|
||||
>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "调用 ID", value: call.toolCallId, mono: true, wide: true },
|
||||
{ label: "开始", value: formatDateTime(call.startedAt) },
|
||||
{ label: "结束", value: call.endedAt ? formatDateTime(call.endedAt) : "—" },
|
||||
]}
|
||||
/>
|
||||
<DetailSection title="本次输入(已脱敏)">
|
||||
<JsonView value={input.value} maxHeight="18rem" />
|
||||
</DetailSection>
|
||||
<DetailSection title={failed ? "本次错误" : "本次输出(已脱敏)"}>
|
||||
<JsonView value={output.value ?? call.reason} maxHeight="18rem" tone={failed ? "destructive" : "neutral"} />
|
||||
</DetailSection>
|
||||
{input.truncated || output.truncated ? (
|
||||
<p className="text-caption text-muted-foreground">事件流只保存 500 字符以内的摘要,超出部分已截断。</p>
|
||||
) : null}
|
||||
<DetailSection title="输入 schema">
|
||||
{tools.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : tools.error ? (
|
||||
<ErrorState title="读取能力清单失败" error={tools.error} onRetry={() => tools.refetch()} plain />
|
||||
) : tool ? (
|
||||
<JsonView value={tool.inputJsonSchema} maxHeight="20rem" />
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">这个能力没有注册,没有 schema。</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
{tool ? (
|
||||
<JsonView value={tool.outputJsonSchema} maxHeight="20rem" defaultOpen={false} label="输出 schema" />
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentToolsBlock } from "./agent-tools.tsx";
|
||||
import { PiUpstreamsBlock } from "./pi-upstreams.tsx";
|
||||
import type { FieldValue } from "./resource-section.tsx";
|
||||
import { num, str } from "./resource-section.tsx";
|
||||
@@ -194,6 +195,7 @@ export function AgentSection() {
|
||||
</>
|
||||
)}
|
||||
</SettingsGate>
|
||||
<AgentToolsBlock />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user