Files
UHD_Media_Tool/apps/web/app/components/settings/agent-tools.tsx
T
rootandClaude Opus 5.5 c57411f9a5 docs(copy): 设置页账户与智能体分区文案按 human-copy 重写
- 改密码后的会话说法统一为「其他会话已退出」
- 模型服务向导里的 provider 统一写「提供方」
- 能力类别「提案」改「方案」;自动调查说明去掉「有界调查」

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:48:45 +08:00

496 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: "智能体、MCP、API Key 调用按持续授权放行" },
needs_approval: {
label: "需审批",
tone: "warning",
hint: "没有覆盖它的持续授权,智能体、MCP、API Key 调用需要单次审批",
},
agent_only: { label: "仅智能体会话", tone: "neutral", hint: "只在智能体会话内可用,不经 MCP 暴露" },
};
export const CALLER_LABEL: Record<CapabilityCaller, string> = {
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="外部程序用 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="智能体、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 ? "只读对账模式" : "自动化总开关已关闭"}
:执行类能力对智能体、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>
);
}