M5:百工与司命——能力注册表、授契行契请命、禁域裁决与观察标证

- 百工域 domains/baigong:capabilities(统一能力契约:执行类型 builtin/qiankun/mcp/http + 四维风险标注 + 来源版本)、mandates(授契 grant / 行契 charter 预算时间窗,撤销立即生效)、tool_calls(净化摘要 + 观察标证);请命复用 pending_confirmations 加 expires_at/brief_json(超时=拒绝执行 HJ-1302,六要素说明)
- 司命裁决器 siming.py:四维取最高档(能力/数据/后果/情境);禁域为代码常量不可配置(递归删根/格式化/写裸设备/fork 炸弹、file:// 协议、密钥材料外发)——专项测试断言恶意标注与篡改对象仍拒 HJ-1301
- 执行链:禁域→裁决(HJ-1303 未授契拒且双留痕)→请命→执行(乾坤经 SandboxClient HMAC;MCP 经 call_tool;内置无副作用)→净化标证(观察非事实,敏感遮蔽+体量截断)→行动账
- MCP 登记/启停/刷新自动派生与撤下 capabilities(博闻登记接入点);内置能力种子(echo/datetime/qiankun.shell/web.fetch)
- 端点:/capabilities /mandates /petitions /tool-calls + /tools 兼容投影;前端百工页四区(能力目录四维标注/授契行契/请命批准拒绝/行动账观察标证)
- 退役:modules/safety|skills|tool_runtime|agent、application/tool_runtime、services/skills|safety、tool_policies/skills/model_endpoint_overrides 表(迁移 006)、前端 SkillManager/ToolsConsole/旧 tools 页
- 验收(test_baigong_siming 四用例):未授契被拒留痕、请命超时不执行、禁域不可关闭、结果为观察不入忆;后端 454、前端 220、BFF 4、神识 5 包全绿(2 个预存环境失败不变)
This commit is contained in:
chenxin committed 2026-08-27 19:17:21 +08:00
1 parent 144415247e
commit 684261f4b4
97 files changed
+3075 -5554

No files matched your search

+34 -10
View File
@@ -1,20 +1,44 @@
import { ToolsConsole } from "@/components/tools/ToolsConsole";
import { BaigongWorkshop } from "@/components/baigong/BaigongWorkshop";
import { serverApi } from "@/lib/api/server-client";
import type { ToolInfo } from "@/types/api";
import { describeError } from "@/lib/utils/error";
import type {
Capability,
Mandate,
Petition,
ToolCallRecord,
} from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 工具页:日常工具调用控制台。Server 预取 GET /tools 注入 ToolsConsole。
* 百工枢治理(能力总览 / 工具注册 / Skill / MCP)在司命台 /admin/pivots/tool_runtime。
* 百工页:能力目录 / 授契与行契 / 请命 / 行动账。
* 司命裁决在后端(四维 + 禁域不可配置);本页呈现与操作。
*/
export default async function ToolsPage() {
let tools: ToolInfo[] = [];
export default async function BaigongPage() {
let capabilities: Capability[] = [];
let mandates: Mandate[] = [];
let petitions: Petition[] = [];
let calls: ToolCallRecord[] = [];
let error: string | null = null;
try {
tools = await serverApi.get<ToolInfo[]>("/tools");
} catch {
tools = [];
[capabilities, mandates, petitions, calls] = await Promise.all([
serverApi.get<Capability[]>("/capabilities"),
serverApi.get<Mandate[]>("/mandates"),
serverApi.get<Petition[]>("/petitions"),
serverApi.get<ToolCallRecord[]>("/tool-calls", { limit: 100 }),
]);
} catch (e) {
error = describeError(e);
}
return <ToolsConsole initialTools={tools} />;
return (
<BaigongWorkshop
initialCapabilities={capabilities}
initialMandates={mandates}
initialPetitions={petitions}
initialCalls={calls}
error={error}
/>
);
}
+3 -20
View File
@@ -14,7 +14,6 @@ import { PlanConsole } from "@/components/admin/PlanConsole";
import { AgentRoster } from "@/components/admin/AgentRoster";
import { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
import { SkillManager } from "@/components/tools/SkillManager";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Pivot } from "@/types/domain";
@@ -30,7 +29,6 @@ import type {
PersonalityVersion,
PlanSummary,
SafetyPolicy,
Skill,
ToolInfo,
WorldEntity,
} from "@/types/api";
@@ -154,16 +152,14 @@ async function SafetyGovernorDetail() {
);
}
/** 百工枢(百工阁):能力总览 + 工具注册表 + Skill 能力包 + MCP 服务器统一治理。 */
/** 百工枢(百工阁):能力目录 + 工具注册表 + MCP 服务器统一治理。 */
async function ToolRuntimeDetail() {
let tools: ToolInfo[] = [];
let skills: Skill[] = [];
let mcpServers: McpServer[] = [];
let error: string | null = null;
try {
[tools, skills, mcpServers] = await Promise.all([
[tools, mcpServers] = await Promise.all([
serverApi.get<ToolInfo[]>("/tools"),
serverApi.get<Skill[]>("/tools/skills"),
serverApi.get<McpServer[]>("/tools/mcp-servers"),
]);
} catch (e) {
@@ -173,7 +169,7 @@ async function ToolRuntimeDetail() {
return (
<PageContainer
title="百工枢"
description="器用技艺 · 能力注册、工具、MCP 与 Skill 统一治理"
description="器用技艺 · 能力目录与 MCP 登记统一治理"
maxWidth="max-w-5xl"
>
{error ? (
@@ -209,19 +205,6 @@ async function ToolRuntimeDetail() {
</section>
),
},
{
key: "skills",
label: "Skill 能力包",
badge: skills.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
Skill 能力包
</h2>
<SkillManager initial={skills} />
</section>
),
},
]}
defaultTab="tools"
/>
+4 -32
View File
@@ -3,32 +3,24 @@ import { ErrorState, EmptyState } from "@/components/shared/StateViews";
import { ToolRegistry } from "@/components/admin/ToolRegistry";
import { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
import { SkillManager } from "@/components/tools/SkillManager";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { McpServer, Skill, ToolInfo } from "@/types/api";
import type { McpServer, ToolInfo } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 百工枢 · 器用技艺治理(对应设计稿 admin-preview.html 百工枢 视图)。
*
* 三标签页:
* ① 工具注册表 — 清单与管理
* ② MCP 服务器 — 外部能力接入
* ③ Skill 能力包 — 可插拔能力
* 百工枢 · 器用治理(能力目录与 MCP 登记;授契/请命/行动账在 /tools 百工页)。
*/
export default async function AdminToolsPage() {
let tools: ToolInfo[] = [];
let skills: Skill[] = [];
let mcpServers: McpServer[] = [];
let error: string | null = null;
try {
[tools, skills, mcpServers] = await Promise.all([
[tools, mcpServers] = await Promise.all([
serverApi.get<ToolInfo[]>("/tools"),
serverApi.get<Skill[]>("/tools/skills"),
serverApi.get<McpServer[]>("/tools/mcp-servers"),
]);
} catch (e) {
@@ -38,7 +30,7 @@ export default async function AdminToolsPage() {
return (
<PageContainer
title="百工枢"
description="器用技艺 · 能力注册、工具、MCP 与 Skill 统一治理"
description="器用技艺 · 能力目录与 MCP 登记统一治理"
maxWidth="max-w-5xl"
>
{error ? (
@@ -80,26 +72,6 @@ export default async function AdminToolsPage() {
</section>
),
},
{
key: "skills",
label: "Skill 能力包",
badge: skills.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
Skill 能力包
</h2>
{skills.length === 0 ? (
<EmptyState
title="暂无 Skill"
description="当前没有安装 Skill 能力包。"
/>
) : (
<SkillManager initial={skills} />
)}
</section>
),
},
]}
defaultTab="tools"
/>
@@ -0,0 +1,174 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BaigongWorkshop } from "./BaigongWorkshop";
import { baigongApi } from "@/lib/api";
import type {
Capability,
Mandate,
Petition,
ToolCallRecord,
} from "@/types/api";
vi.mock("@/lib/api", () => ({
baigongApi: {
grantMandate: vi.fn(),
revokeMandate: vi.fn(),
approvePetition: vi.fn(),
denyPetition: vi.fn(),
listMandates: vi.fn(),
listPetitions: vi.fn(),
},
}));
const caps: Capability[] = [
{
id: "cap_1",
name: "qiankun.shell",
description: "在乾坤沙箱中执行命令",
executor_type: "qiankun",
executor_ref: "",
risk_dims: {
capability: "L2",
data: "L1",
consequence: "L3",
context: "L1",
},
data_scope: "沙箱工作目录",
destinations: "本机沙箱进程",
source: "builtin",
version: "1",
consent_mode: "ask",
enabled: true,
mandated: false,
},
];
const mandates: Mandate[] = [
{
id: "mdt_1",
capability_id: "cap_1",
character_id: null,
kind: "charter",
scope: {},
budget_total: 20,
budget_used: 3,
status: "active",
granted_at: "2026-08-27T10:00:00Z",
expires_at: null,
},
];
const petitions: Petition[] = [
{
id: "confirm_1",
capability: "qiankun.shell",
risk_level: "L3",
reason: "请命:高风险动作需当次确认",
brief: {
who: "character:c1",
what: "在乾坤沙箱中执行命令",
consequences: "风险等级 L3",
destinations: "本机沙箱进程",
},
run_id: "run_1",
character_id: "c1",
created_at: "2026-08-27T10:05:00Z",
expires_at: "2026-08-27T10:15:00Z",
},
];
const calls: ToolCallRecord[] = [
{
id: "tc_1",
capability_id: "cap_1",
run_id: "run_1",
character_id: "c1",
trace_id: null,
decision: "allow",
mandate_id: "mdt_1",
status: "ok",
error_code: null,
input_digest: "{}",
output_digest: "[echo] hi",
evidence: {
kind: "observation",
capability: "qiankun.shell",
epistemic: "observation_not_fact",
},
duration_ms: 2,
created_at: "2026-08-27T10:06:00Z",
},
];
function renderPage(
overrides: Partial<Parameters<typeof BaigongWorkshop>[0]> = {},
) {
return render(
<BaigongWorkshop
initialCapabilities={caps}
initialMandates={mandates}
initialPetitions={petitions}
initialCalls={calls}
error={null}
{...overrides}
/>,
);
}
describe("BaigongWorkshop(百工)", () => {
it("四区渲染:能力目录四维标注 / 请命 / 授契 / 行动账观察标证", () => {
renderPage();
expect(screen.getByText("能力目录")).toBeInTheDocument();
expect(screen.getByText("请命")).toBeInTheDocument();
expect(screen.getByText("授契与行契")).toBeInTheDocument();
expect(screen.getByText("行动账")).toBeInTheDocument();
// 四维风险标注呈现
expect(screen.getByText("能力 L2")).toBeInTheDocument();
expect(screen.getByText("后果 L3")).toBeInTheDocument();
// 观察标证
expect(screen.getByText("观察(非事实)")).toBeInTheDocument();
});
it("授行契走 grantMandate 并刷新清单", async () => {
const user = userEvent.setup();
vi.mocked(baigongApi.grantMandate).mockResolvedValue({} as Mandate);
vi.mocked(baigongApi.listMandates).mockResolvedValue(mandates);
renderPage({ initialMandates: [] });
await user.click(screen.getByRole("button", { name: /授行契/ }));
await waitFor(() => {
expect(baigongApi.grantMandate).toHaveBeenCalledWith({
capability_id: "cap_1",
kind: "charter",
budget_total: 20,
});
});
});
it("请命批准与拒绝动作", async () => {
const user = userEvent.setup();
vi.mocked(baigongApi.approvePetition).mockResolvedValue({
status: "resolved",
approved: true,
});
vi.mocked(baigongApi.listPetitions).mockResolvedValue([]);
renderPage();
await user.click(screen.getByRole("button", { name: "批准" }));
await waitFor(() =>
expect(baigongApi.approvePetition).toHaveBeenCalledWith("confirm_1"),
);
});
it("撤销授契立即刷新", async () => {
const user = userEvent.setup();
vi.mocked(baigongApi.revokeMandate).mockResolvedValue(undefined);
vi.mocked(baigongApi.listMandates).mockResolvedValue([]);
renderPage();
await user.click(screen.getByRole("button", { name: /撤销/ }));
await waitFor(() =>
expect(baigongApi.revokeMandate).toHaveBeenCalledWith("mdt_1"),
);
await waitFor(() => expect(screen.queryByText("3/20 次")).toBeNull());
});
});
@@ -0,0 +1,366 @@
"use client";
import { useState } from "react";
import {
BoxesIcon,
KeyRoundIcon,
PetitionIcon,
ScrollTextIcon,
ShieldCheckIcon,
TrashIcon,
} from "@/components/ui/Icons";
import { Button } from "@/components/ui/Button";
import { baigongApi } from "@/lib/api";
import { cn } from "@/lib/utils/cn";
import type {
Capability,
Mandate,
Petition,
ToolCallRecord,
} from "@/types/api";
interface Props {
initialCapabilities: Capability[];
initialMandates: Mandate[];
initialPetitions: Petition[];
initialCalls: ToolCallRecord[];
error: string | null;
}
const RISK_TONE: Record<string, string> = {
L0: "text-muted",
L1: "text-muted",
L2: "text-gold",
L3: "text-cinnabar",
L4: "text-cinnabar",
};
const DIM_LABELS: [string, string][] = [
["capability", "能力"],
["data", "数据"],
["consequence", "后果"],
["context", "情境"],
];
const EXECUTOR_LABEL: Record<string, string> = {
builtin: "内置",
qiankun: "乾坤",
mcp: "MCP",
http: "HTTP",
};
/**
* 百工:能力目录 / 授契与行契 / 请命 / 行动账 四区。
*
* 司命在后端裁决(四维 + 禁域不可配置);本页只呈现与操作:
* 授契、请命批准/拒绝、行动账观察标证回读。
*/
export function BaigongWorkshop({
initialCapabilities,
initialMandates,
initialPetitions,
initialCalls,
error,
}: Props) {
const [caps] = useState(initialCapabilities);
const [mandates, setMandates] = useState(initialMandates);
const [petitions, setPetitions] = useState(initialPetitions);
const [calls] = useState(initialCalls);
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const capName = (id: string) => caps.find((c) => c.id === id)?.name ?? id;
async function handleGrant(cap: Capability) {
setBusy(cap.id);
setNotice(null);
try {
await baigongApi.grantMandate({
capability_id: cap.id,
kind: "charter",
budget_total: 20,
});
setMandates(await baigongApi.listMandates());
setNotice(`已授行契:${cap.name}(预算 20 次)`);
} catch (e) {
setNotice(e instanceof Error ? e.message : "授契失败");
} finally {
setBusy(null);
}
}
async function handleRevoke(mandate: Mandate) {
setBusy(mandate.id);
try {
await baigongApi.revokeMandate(mandate.id);
setMandates(await baigongApi.listMandates());
} finally {
setBusy(null);
}
}
async function handlePetition(id: string, approved: boolean) {
setBusy(id);
setNotice(null);
try {
await (approved
? baigongApi.approvePetition(id)
: baigongApi.denyPetition(id));
setPetitions(await baigongApi.listPetitions());
setNotice(approved ? "已批准(当次有效)" : "已拒绝");
} catch (e) {
setNotice(e instanceof Error ? e.message : "请命处理失败");
setPetitions(await baigongApi.listPetitions());
} finally {
setBusy(null);
}
}
if (error) {
return (
<div className="glass rounded-2xl p-6 text-sm text-muted">{error}</div>
);
}
return (
<div className="flex flex-col gap-4">
{notice && (
<div className="glass rounded-xl px-4 py-2 text-sm text-patina">
{notice}
</div>
)}
{/* ═══ 请命(最前:有待决先处理) ═══ */}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<PetitionIcon className="size-4 text-gold" />
请命
</h3>
<span className="set-card-eng">Petitions</span>
</div>
<p className="set-card-desc">
高风险动作需当次同意;拒绝或超时不执行(HJ-1302)。
</p>
{petitions.length === 0 ? (
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
暂无待决请命。
</p>
) : (
<ul className="divide-y divide-rule rounded-xl border border-rule">
{petitions.map((p) => (
<li key={p.id} className="px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-serif text-sm text-fg">
{p.capability}
</span>
<span
className={cn(
"font-mono text-xs",
RISK_TONE[p.risk_level],
)}
>
{p.risk_level}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted">
{p.brief.what} · {p.brief.consequences} · 去向:
{p.brief.destinations || "无"}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="primary"
size="sm"
disabled={busy === p.id}
onClick={() => handlePetition(p.id, true)}
>
批准
</Button>
<Button
variant="ghost"
size="sm"
disabled={busy === p.id}
onClick={() => handlePetition(p.id, false)}
>
拒绝
</Button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
{/* ═══ 能力目录 ═══ */}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<BoxesIcon className="size-4 text-patina" />
能力目录
</h3>
<span className="set-card-eng">Capabilities</span>
</div>
<p className="set-card-desc">
统一能力契约:四维风险标注(能力/数据/后果/情境);未授契调用被拒并留痕。
</p>
<ul className="divide-y divide-rule rounded-xl border border-rule">
{caps.map((c) => (
<li key={c.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-serif text-sm text-fg">{c.name}</span>
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
{EXECUTOR_LABEL[c.executor_type] ?? c.executor_type}
</span>
{c.mandated ? (
<span className="flex items-center gap-1 text-xs text-patina">
<ShieldCheckIcon className="size-3.5" /> 已授契
</span>
) : (
<span className="text-xs text-muted">未授契</span>
)}
</div>
<p className="mt-0.5 line-clamp-1 text-xs text-muted">
{c.description || "(无描述)"}
</p>
<div className="mt-1 flex flex-wrap gap-2 font-mono text-[10px]">
{DIM_LABELS.map(([dim, label]) => {
const lv = c.risk_dims[dim];
if (!lv) return null;
return (
<span key={dim} className={RISK_TONE[lv]}>
{label} {lv}
</span>
);
})}
</div>
</div>
{!c.mandated && (
<Button
variant="outline"
size="sm"
disabled={busy === c.id}
onClick={() => handleGrant(c)}
>
<KeyRoundIcon className="mr-1 size-3.5" />
授行契
</Button>
)}
</li>
))}
</ul>
</div>
{/* ═══ 授契与行契 ═══ */}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<KeyRoundIcon className="size-4 text-gold" />
授契与行契
</h3>
<span className="set-card-eng">Mandates</span>
</div>
<p className="set-card-desc">
行契在预算与时间窗内重复执行低风险动作;撤销立即生效。
</p>
{mandates.filter((m) => m.status === "active").length === 0 ? (
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
暂无生效授契。
</p>
) : (
<ul className="divide-y divide-rule rounded-xl border border-rule">
{mandates
.filter((m) => m.status === "active")
.map((m) => (
<li key={m.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<span className="font-mono text-xs text-fg">
{capName(m.capability_id)}
</span>
<span className="ml-2 rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
{m.kind === "charter" ? "行契" : "授契"}
</span>
<span className="ml-2 font-mono text-xs text-muted">
{m.budget_total > 0
? `${m.budget_used}/${m.budget_total} 次`
: "不限次"}
</span>
</div>
<Button
variant="ghost"
size="sm"
disabled={busy === m.id}
onClick={() => handleRevoke(m)}
>
<TrashIcon className="mr-1 size-3.5" />
撤销
</Button>
</li>
))}
</ul>
)}
</div>
{/* ═══ 行动账 ═══ */}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<ScrollTextIcon className="size-4 text-patina" />
行动账
</h3>
<span className="set-card-eng">Ledger</span>
</div>
<p className="set-card-desc">
全部能力调用的净化摘要与观察标证——结果是观察,不是事实。
</p>
{calls.length === 0 ? (
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
暂无调用记录。
</p>
) : (
<ul className="divide-y divide-rule rounded-xl border border-rule">
{calls.slice(0, 20).map((t) => (
<li key={t.id} className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-fg">
{capName(t.capability_id)}
</span>
<span
className={cn(
"rounded-full border px-2 py-0.5 font-mono text-[10px]",
t.decision.startsWith("denied")
? "border-cinnabar/40 text-cinnabar"
: t.decision === "ask"
? "border-gold/40 text-gold"
: "border-rule text-muted",
)}
>
{t.decision}
</span>
{t.evidence.epistemic === "observation_not_fact" && (
<span className="text-[10px] text-muted">
观察(非事实)
</span>
)}
<span className="ml-auto font-mono text-[10px] text-muted">
{t.created_at?.slice(11, 19)}
</span>
</div>
{t.output_digest && (
<p className="mt-1 line-clamp-2 font-mono text-xs text-muted">
{t.output_digest}
</p>
)}
</li>
))}
</ul>
)}
</div>
</div>
);
}
@@ -1,326 +0,0 @@
"use client";
import { useState } from "react";
import { skillsApi } from "@/lib/api";
import { cn } from "@/lib/utils/cn";
import type { Skill } from "@/types/api";
import type { RiskLevel } from "@/types/domain";
const RISK_OPTIONS: RiskLevel[] = ["L0", "L1", "L2", "L3", "L4"];
type Draft = {
name: string;
description: string;
trigger: string;
prompt_fragment: string;
tool_bindings: string;
risk_level: RiskLevel;
status: string;
};
const EMPTY: Draft = {
name: "",
description: "",
trigger: "",
prompt_fragment: "",
tool_bindings: "",
risk_level: "L1",
status: "enabled",
};
/**
* Skill 能力包管理(百工枢,/tools/skills)。
* 列表 + 创建/编辑(inline 表单)+ 启用/禁用 + 删除。乐观本地态,写后直接更新列表。
*/
export function SkillManager({ initial }: { initial: Skill[] }) {
const [skills, setSkills] = useState(initial);
const [editingId, setEditingId] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<Draft>(EMPTY);
function parseBindings(text: string): string[] {
return text
.split(",")
.map((t) => t.trim())
.filter(Boolean);
}
function startEdit(s: Skill) {
setShowAdd(false);
setEditingId(s.id);
setDraft({
name: s.name,
description: s.description,
trigger: s.trigger,
prompt_fragment: s.prompt_fragment,
tool_bindings: s.tool_bindings.join(", "),
risk_level: s.risk_level,
status: s.status,
});
setError(null);
}
async function add() {
if (!draft.name.trim()) {
setError("请填写能力包名称。");
return;
}
setBusy(true);
setError(null);
try {
const created = await skillsApi.createSkill({
name: draft.name.trim(),
description: draft.description,
trigger: draft.trigger,
prompt_fragment: draft.prompt_fragment,
tool_bindings: parseBindings(draft.tool_bindings),
risk_level: draft.risk_level,
status: draft.status,
});
setSkills((p) => [...p, created]);
setDraft(EMPTY);
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败。");
} finally {
setBusy(false);
}
}
async function save(id: string) {
setBusy(true);
setError(null);
try {
const updated = await skillsApi.updateSkill(id, {
description: draft.description,
trigger: draft.trigger,
prompt_fragment: draft.prompt_fragment,
tool_bindings: parseBindings(draft.tool_bindings),
risk_level: draft.risk_level,
status: draft.status,
});
setSkills((p) => p.map((s) => (s.id === id ? updated : s)));
setEditingId(null);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败。");
} finally {
setBusy(false);
}
}
async function toggleStatus(s: Skill) {
setBusy(true);
setError(null);
try {
const updated = await skillsApi.updateSkill(s.id, {
status: s.status === "enabled" ? "disabled" : "enabled",
});
setSkills((p) => p.map((x) => (x.id === s.id ? updated : x)));
} catch (e) {
setError(e instanceof Error ? e.message : "切换失败。");
} finally {
setBusy(false);
}
}
async function remove(s: Skill) {
setBusy(true);
setError(null);
try {
await skillsApi.deleteSkill(s.id);
setSkills((p) => p.filter((x) => x.id !== s.id));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败。");
} finally {
setBusy(false);
}
}
const inputCls =
"w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
function renderForm(
onSave: () => void,
onCancel: () => void,
isNew: boolean,
) {
return (
<div className="space-y-2 rounded-lg border border-border-glass bg-glass/50 p-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="名称(唯一)"
className={inputCls}
disabled={!isNew}
/>
<input
value={draft.trigger}
onChange={(e) => setDraft({ ...draft, trigger: e.target.value })}
placeholder="触发条件"
className={inputCls}
/>
</div>
<input
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
placeholder="描述"
className={inputCls}
/>
<textarea
value={draft.prompt_fragment}
onChange={(e) =>
setDraft({ ...draft, prompt_fragment: e.target.value })
}
placeholder="提示词片段"
rows={2}
className={inputCls}
/>
<div className="flex flex-wrap items-center gap-2">
<input
value={draft.tool_bindings}
onChange={(e) =>
setDraft({ ...draft, tool_bindings: e.target.value })
}
placeholder="绑定工具(逗号分隔)"
className={`${inputCls} min-w-[12rem] flex-1`}
/>
<select
value={draft.risk_level}
onChange={(e) =>
setDraft({ ...draft, risk_level: e.target.value as RiskLevel })
}
className={`${inputCls} w-20`}
aria-label="风险等级"
>
{RISK_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<button
type="button"
onClick={onSave}
disabled={busy}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
{isNew ? "注册" : "保存"}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-muted">共 {skills.length} 个能力包</p>
{!showAdd && (
<button
type="button"
onClick={() => {
setDraft(EMPTY);
setEditingId(null);
setShowAdd(true);
}}
className="rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina"
>
+ 注册能力包
</button>
)}
</div>
{showAdd &&
renderForm(
() => add(),
() => setShowAdd(false),
true,
)}
<ul className="space-y-2">
{skills.map((s) => (
<li key={s.id} className="glass rounded-xl px-4 py-3">
{editingId === s.id ? (
renderForm(
() => save(s.id),
() => setEditingId(null),
false,
)
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<span className="font-serif text-sm font-semibold text-fg">
{s.name}
</span>
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px]",
s.status === "enabled"
? "bg-patina/15 text-patina"
: "bg-fg/10 text-muted",
)}
>
{s.status}
</span>
<span className="rounded bg-cinnabar/10 px-1.5 py-0.5 text-[10px] text-cinnabar">
{s.risk_level}
</span>
{s.tool_bindings.length > 0 && (
<span className="text-[10px] text-muted">
绑定:{s.tool_bindings.join("、")}
</span>
)}
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={() => toggleStatus(s)}
disabled={busy}
className="text-[11px] text-patina hover:underline disabled:opacity-50"
>
{s.status === "enabled" ? "禁用" : "启用"}
</button>
<button
type="button"
onClick={() => startEdit(s)}
className="text-[11px] text-patina hover:underline"
>
编辑
</button>
<button
type="button"
onClick={() => remove(s)}
disabled={busy}
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</div>
{s.trigger && (
<p className="mt-1 text-xs text-muted">触发:{s.trigger}</p>
)}
{s.prompt_fragment && (
<p className="mt-0.5 text-xs text-fg/80">
{s.prompt_fragment}
</p>
)}
</>
)}
</li>
))}
</ul>
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -1,99 +0,0 @@
"use client";
import { motion } from "framer-motion";
import { Badge, type BadgeTone } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { cn } from "@/lib/utils/cn";
const RISK_TONE: Record<string, BadgeTone> = {
L0: "info",
L1: "success",
L2: "warning",
L3: "warning",
L4: "danger",
};
/** 朱砂浓度档位(与设计稿 risk-l1/l3 同源):L0/L1 浅描 / L2 朱块(基类) / L3/L4 满朱晕染 */
const RISK_CONCENTRATION: Record<string, string> = {
L0: "vermilion-risk-l1",
L1: "vermilion-risk-l1",
L2: "",
L3: "vermilion-risk-l3",
L4: "vermilion-risk-l3",
};
export interface ToolConfirmationCardProps {
tool: string;
riskLevel: string;
reason: string;
args?: string;
onApprove: () => void;
onDeny: () => void;
loading?: boolean;
}
/**
* 工具确认卡片:高风险工具执行前的二次确认(琉璃镜浮层)。
* L4 用朱砂主按钮,L2/L3 用青玉主按钮;弹簧入场,矿物色徽章标风险。
*/
export function ToolConfirmationCard({
tool,
riskLevel,
reason,
args,
onApprove,
onDeny,
loading = false,
}: ToolConfirmationCardProps) {
const tone = RISK_TONE[riskLevel] ?? "warning";
const dangerous = riskLevel === "L4";
const concentration = RISK_CONCENTRATION[riskLevel] ?? "";
// 尝试格式化 args 为美观的 JSON
let formattedArgs = args;
if (args) {
try {
const parsed = JSON.parse(args);
formattedArgs = JSON.stringify(parsed, null, 2);
} catch {
// 保持原样
}
}
return (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 320, damping: 24 }}
className={cn(
"vermilion rounded-2xl p-4 shadow-lg shadow-black/10",
concentration,
)}
>
<div className="mb-2 flex flex-wrap items-center gap-2">
<Badge tone={tone}>{riskLevel}</Badge>
<span className="font-serif text-sm font-semibold text-fg">
工具确认 · {tool}
</span>
</div>
<p className="mb-2 text-sm text-fg/80">{reason}</p>
{formattedArgs && (
<pre className="mb-3 max-h-40 overflow-auto rounded-lg bg-black/20 p-2 font-mono text-xs text-fg/70">
{formattedArgs}
</pre>
)}
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onDeny} disabled={loading}>
拒绝
</Button>
<Button
variant={dangerous ? "danger" : "primary"}
size="sm"
onClick={onApprove}
loading={loading}
>
允许执行
</Button>
</div>
</motion.div>
);
}
@@ -1,95 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Badge, type BadgeTone } from "@/components/ui/Badge";
import { Card, CardHeader } from "@/components/ui/Card";
import { createRunStream } from "@/lib/sse-client";
import type { RunEvent } from "@/types/api";
const TONE: Record<string, BadgeTone> = {
"tool.started": "info",
"tool.completed": "success",
"tool.failed": "danger",
};
function summarize(event: RunEvent): string {
const payload = event.payload;
const tool = String(payload.tool ?? "");
if (event.type === "tool.started") {
const argc = Array.isArray(payload.argv) ? payload.argv.length : 0;
return `${tool} · ${argc} 参数`;
}
if (event.type === "tool.completed") {
return `${tool} · exit=${String(payload.exit_code ?? "?")} · ${String(payload.duration_ms ?? "?")}ms`;
}
if (event.type === "tool.failed") {
return `${tool} · ${String(payload.error_code ?? "")}`;
}
return JSON.stringify(payload).slice(0, 80);
}
/**
* Run 事件流面板:订阅 SSE /runs/{run_id}/stream,实时展示 tool.* 事件。
* 非 Mock 模式下走真实 EventSource;带 run 内工具调用时可见 tool.started/completed 实时到达。
*/
export function ToolEventStream({ runId }: { runId: string }) {
const [events, setEvents] = useState<RunEvent[]>([]);
const [connected, setConnected] = useState(false);
useEffect(() => {
// SSE 订阅依赖回调更新连接与事件状态,此场景关闭 set-state-in-effect 检查
/* eslint-disable react-hooks/set-state-in-effect */
if (!runId) return;
setEvents([]);
const controller = createRunStream(runId, {
onOpen: () => setConnected(true),
onError: () => setConnected(false),
onEvent: (event) => {
if (event.type.startsWith("tool.")) {
setEvents((prev) => [...prev.slice(-30), event]);
}
},
});
/* eslint-enable react-hooks/set-state-in-effect */
return () => controller.close();
}, [runId]);
return (
<Card className="p-4">
<CardHeader
title="Run 事件流(SSE)"
subtitle={`订阅 /runs/${runId}/stream · 实时推送 tool.* 事件`}
action={
<Badge tone={connected ? "success" : "neutral"}>
{connected ? "已连接" : "连接中"}
</Badge>
}
/>
{events.length === 0 ? (
<p className="py-4 text-center text-xs text-muted">
暂无工具事件。带上方 run_id 调用工具后,事件将实时出现在此。
</p>
) : (
<div className="flex flex-col gap-1.5">
<AnimatePresence initial={false}>
{events.map((event) => (
<motion.div
key={event.event_id}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
className="flex items-center gap-2 rounded-lg bg-fg/5 px-2.5 py-1.5"
>
<Badge tone={TONE[event.type] ?? "neutral"}>{event.type}</Badge>
<span className="truncate font-mono text-xs text-muted">
{summarize(event)}
</span>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</Card>
);
}
@@ -1,320 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { PageContainer } from "@/components/layout/PageContainer";
import { Badge, type BadgeTone } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader } from "@/components/ui/Card";
import { ToolConfirmationCard } from "@/components/tools/ToolConfirmationCard";
import { ToolEventStream } from "@/components/tools/ToolEventStream";
import { useToolStream } from "@/lib/hooks/useToolStream";
import type { ToolInfo } from "@/types/api";
type InputType = "text" | "textarea";
interface ToolInputDef {
key: string;
label: string;
type?: InputType;
placeholder?: string;
default?: string;
}
/**
* 前端预置的输入表单定义(按工具名映射)。后端 ToolSpec 不暴露入参 schema,
* 故表单结构由前端维护;后端新增且此处未映射的工具走 JSON 通用输入,
* 保证任何后端注册的工具都能被调用。
*/
const INPUTS_BY_NAME: Record<string, ToolInputDef[]> = {
echo: [{ key: "text", label: "文本", default: "琉璃之镜" }],
datetime: [],
calc: [{ key: "expr", label: "表达式", default: "6 * 7" }],
read_file: [{ key: "path", label: "文件路径", default: "note.txt" }],
list_dir: [{ key: "path", label: "目录路径", default: "." }],
write_file: [
{ key: "path", label: "文件路径", default: "note.txt" },
{ key: "content", label: "内容", type: "textarea", default: "由百工写入" },
],
shell: [
{
key: "command",
label: "命令",
type: "textarea",
default: "for i in 1 2 3; do echo line-$i; sleep 0.3; done",
},
],
};
const RISK_TONE: Record<string, BadgeTone> = {
L0: "info",
L1: "success",
L2: "warning",
L3: "warning",
L4: "danger",
};
/** 未预置表单的工具用此 key 承载 JSON 文本输入 */
const JSON_FALLBACK_KEY = "__json__";
function defaultsForInputs(
inputs: ToolInputDef[] | null,
): Record<string, string> {
const values: Record<string, string> = {};
if (inputs) {
for (const f of inputs) values[f.key] = f.default ?? "";
} else {
values[JSON_FALLBACK_KEY] = "{\n \n}\n";
}
return values;
}
/**
* 百工工具控制台(Client)。工具清单来自后端 ``GET /tools``(由 Server Component
* 预取注入,``force-dynamic`` 保证每次进入页面拉到最新列表,反映后端注册/注销变更);
* 输入表单按工具名在前端映射,未映射工具提供 JSON 通用输入。
*/
export function ToolsConsole({ initialTools }: { initialTools: ToolInfo[] }) {
const [toolName, setToolName] = useState<string>(initialTools[0]?.name ?? "");
const tool = useMemo(
() => initialTools.find((t) => t.name === toolName) ?? initialTools[0],
[initialTools, toolName],
);
const inputs = tool ? (INPUTS_BY_NAME[tool.name] ?? null) : null;
const [values, setValues] = useState<Record<string, string>>(
defaultsForInputs(inputs),
);
const [runId, setRunId] = useState<string>("run_demo");
const { state, stdout, stderr, busy, run, confirm, cancel, reset } =
useToolStream();
const selectTool = (name: string) => {
setToolName(name);
setValues(defaultsForInputs(INPUTS_BY_NAME[name] ?? null));
reset();
};
const handleInvoke = () => {
if (!tool) return;
let input: Record<string, unknown>;
if (inputs) {
input = values;
} else {
// 未预置表单的工具,由用户直接编辑 JSON
try {
const parsed = JSON.parse(values[JSON_FALLBACK_KEY] || "{}");
input =
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
input = {};
}
}
void run(tool.name, input, { run_id: runId || undefined });
};
const update = (key: string, value: string) =>
setValues((prev) => ({ ...prev, [key]: value }));
const streaming = state.phase === "streaming";
const completed = state.phase === "completed";
if (initialTools.length === 0) {
return (
<PageContainer
title="百工工具"
description="经司命审核与乾坤沙箱隔离执行;L0/L1 自动放行,L2/L3 需二次确认。"
>
<Card className="p-6">
<p className="text-sm text-muted">
后端暂无已注册工具,或工具列表加载失败。请确认后端已启动并完成工具注册。
</p>
</Card>
</PageContainer>
);
}
return (
<PageContainer
title="百工工具"
description="经司命审核与乾坤沙箱隔离执行;L0/L1 自动放行,L2/L3 需二次确认。stdout/stderr 流式实时回写。"
>
<Card className="p-4">
<CardHeader
title="调用工具"
subtitle="输出实时流式返回;文件类工具共享沙箱内 workspace 目录(按 run 隔离)。"
/>
<div className="mb-3 flex flex-wrap gap-2">
{initialTools.map((t) => (
<button
key={t.name}
type="button"
onClick={() => selectTool(t.name)}
className={
tool?.name === t.name
? "rounded-xl border border-patina/30 bg-patina/15 px-3 py-1.5 font-mono text-sm text-patina"
: "glass rounded-xl px-3 py-1.5 font-mono text-sm text-muted transition-colors hover:text-fg"
}
>
{t.name}
</button>
))}
</div>
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-muted">
{tool && (
<Badge tone={RISK_TONE[tool.risk_level] ?? "neutral"}>
{tool.risk_level}
</Badge>
)}
<span>{tool?.description}</span>
</div>
<label className="mb-3 block">
<span className="mb-1 block text-xs text-muted">
Run ID(事件归属;留空则不写入 run 流)
</span>
<input
value={runId}
onChange={(e) => setRunId(e.target.value)}
placeholder="例如:run_demo"
className="w-full rounded-xl border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
{inputs === null ? (
// 后端工具未在前端预置表单
<label className="mb-3 block">
<span className="mb-1 block text-xs text-muted">
输入参数(JSON 对象)
</span>
<textarea
value={values[JSON_FALLBACK_KEY] ?? ""}
onChange={(e) => update(JSON_FALLBACK_KEY, e.target.value)}
rows={5}
placeholder='{"key": "value"}'
className="w-full rounded-xl border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
) : inputs.length === 0 ? (
<p className="mb-3 text-sm text-muted">
该工具无需输入,直接执行即可。
</p>
) : (
<div className="mb-3 flex flex-col gap-3">
{inputs.map((field) => (
<label key={field.key} className="block">
<span className="mb-1 block text-xs text-muted">
{field.label}
</span>
{field.type === "textarea" ? (
<textarea
value={values[field.key] ?? ""}
onChange={(e) => update(field.key, e.target.value)}
rows={3}
placeholder={field.placeholder}
className="w-full rounded-xl border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
) : (
<input
value={values[field.key] ?? ""}
onChange={(e) => update(field.key, e.target.value)}
placeholder={field.placeholder}
className="w-full rounded-xl border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
)}
</label>
))}
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="primary"
onClick={handleInvoke}
loading={busy && streaming}
>
执行
</Button>
{streaming && (
<button
type="button"
onClick={cancel}
className="glass rounded-xl px-4 py-2 text-sm text-cinnabar transition-colors hover:text-cinnabar/80"
>
停止
</button>
)}
</div>
</Card>
{state.phase === "confirming" && (
<div className="mt-4">
<ToolConfirmationCard
tool={state.tool}
riskLevel={state.risk_level}
reason={state.reason}
onApprove={() => void confirm()}
onDeny={() => reset()}
loading={busy}
/>
</div>
)}
{(streaming || completed) && (
<Card className="mt-4 p-4">
<CardHeader
title={streaming ? "流式输出" : "执行结果"}
action={
completed ? (
<Badge
tone={
(state.done.exit_code ?? 1) === 0 ? "success" : "danger"
}
>
{(state.done.exit_code ?? 1) === 0 ? "成功" : "失败"}
</Badge>
) : (
<Badge tone="info">流式中</Badge>
)
}
/>
{stdout && (
<pre className="mb-2 max-h-80 overflow-auto rounded-lg bg-black/40 p-3 font-mono text-xs text-emerald-300">
{stdout}
</pre>
)}
{stderr && (
<pre className="mb-2 max-h-80 overflow-auto rounded-lg bg-black/40 p-3 font-mono text-xs text-cinnabar">
{stderr}
</pre>
)}
{!stdout && !stderr && streaming && (
<p className="py-2 text-xs text-muted">等待输出…</p>
)}
{completed && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-xs text-muted">
<span>exit: {state.done.exit_code ?? "null"}</span>
<span>耗时: {state.done.duration_ms}ms</span>
{state.done.timeout && <Badge tone="warning">超时</Badge>}
{state.done.cancelled && <Badge tone="warning">已取消</Badge>}
{state.done.truncated && <Badge tone="warning">截断</Badge>}
</div>
)}
</Card>
)}
{state.phase === "error" && (
<Card className="mt-4 border-cinnabar/30 p-4">
<p className="text-sm text-cinnabar">{state.message}</p>
</Card>
)}
{runId && (
<div className="mt-4">
<ToolEventStream runId={runId} />
</div>
)}
</PageContainer>
);
}
+133
View File
@@ -843,3 +843,136 @@ export function CircleAlertIcon(props: IconProps) {
</svg>
);
}
/** 能力盒(Lucide Boxes) */
export function BoxesIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M3 4.5 7.5 2l4.5 2.5v5L7.5 12 3 9.5v-5z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path
d="M12 4.5 16.5 2 21 4.5v5L16.5 12 12 9.5v-5z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path
d="M7.5 12v5l4.5 2.5 4.5-2.5v-5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
/** 卷轴(Lucide ScrollText) */
export function ScrollTextIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M15 12h-5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M15 8h-5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M19 17V5a2 2 0 0 0-2-2H4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
/** 请命(Lucide MessageSquareWarning) */
export function PetitionIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M12 7v4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M12 14h.01"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
/** 盾勾(Lucide ShieldCheck) */
export function ShieldCheckIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="m9 12 2 2 4-4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiDelete, apiGet, apiPost } from "./client";
import {
approvePetition,
denyPetition,
grantMandate,
invokeCapability,
listCapabilities,
listMandates,
listPetitions,
listToolCalls,
revokeMandate,
} from "./baigong";
describe("baigong api(能力/授契/请命/行动账)", () => {
it("listCapabilities GET /capabilities", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await listCapabilities();
expect(apiGet).toHaveBeenCalledWith("/capabilities");
});
it("invokeCapability POST 带 URI 编码与 confirmed 回执", async () => {
vi.mocked(apiPost).mockResolvedValue({ status: "ok" });
await invokeCapability("cap/a b", { params: { x: 1 }, confirmed: true });
expect(apiPost).toHaveBeenCalledWith("/capabilities/cap%2Fa%20b/invoke", {
params: { x: 1 },
confirmed: true,
});
});
it("grantMandate POST /mandates", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "mdt_1" });
await grantMandate({
capability_id: "cap_1",
kind: "charter",
budget_total: 20,
});
expect(apiPost).toHaveBeenCalledWith("/mandates", {
capability_id: "cap_1",
kind: "charter",
budget_total: 20,
});
});
it("revokeMandate DELETE 带 URI 编码", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await revokeMandate("mdt/a");
expect(apiDelete).toHaveBeenCalledWith("/mandates/mdt%2Fa");
});
it("petition approve/deny 与清单", async () => {
vi.mocked(apiPost).mockResolvedValue({
status: "resolved",
approved: true,
});
await approvePetition("c1");
expect(apiPost).toHaveBeenCalledWith("/petitions/c1/approve", {});
await denyPetition("c1");
expect(apiPost).toHaveBeenCalledWith("/petitions/c1/deny", {});
vi.mocked(apiGet).mockResolvedValue([]);
await listPetitions();
expect(apiGet).toHaveBeenCalledWith("/petitions");
});
it("listToolCalls GET /tool-calls 带 limit", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await listToolCalls(50);
expect(apiGet).toHaveBeenCalledWith("/tool-calls", { limit: 50 });
await listMandates();
expect(apiGet).toHaveBeenCalledWith("/mandates");
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* 百工 API Client:能力目录 / 授契与行契 / 请命 / 行动账。
*
* 未授契能力调用被拒(HJ-1303 留痕);高风险动作请命,超时不执行(HJ-1302);
* 禁域由后端代码常量裁决,前端不可配置。
*/
import { apiDelete, apiGet, apiPost } from "./client";
import type {
Capability,
InvokeResult,
Mandate,
Petition,
ToolCallRecord,
} from "@/types/api";
/** 能力目录(含四维风险与授契状态) */
export function listCapabilities() {
return apiGet<Capability[]>("/capabilities");
}
/** 调用能力(params 自由形;confirmed 为请命批准后的回执) */
export function invokeCapability(
capabilityId: string,
body: {
params: Record<string, unknown>;
confirmed?: boolean;
run_id?: string;
character_id?: string;
},
) {
return apiPost<InvokeResult>(
`/capabilities/${encodeURIComponent(capabilityId)}/invoke`,
body,
);
}
/** 授契/行契清单 */
export function listMandates() {
return apiGet<Mandate[]>("/mandates");
}
/** 授予授契(grant)或行契(charter:预算/时间窗内重复执行) */
export function grantMandate(body: {
capability_id: string;
kind: "grant" | "charter";
budget_total?: number;
}) {
return apiPost<Mandate>("/mandates", body);
}
/** 撤销授契(立即生效) */
export function revokeMandate(mandateId: string) {
return apiDelete<void>(`/mandates/${encodeURIComponent(mandateId)}`);
}
/** 待决请命清单 */
export function listPetitions() {
return apiGet<Petition[]>("/petitions");
}
/** 批准请命(当次执行;客户端随后以 confirmed 重调 invoke) */
export function approvePetition(petitionId: string) {
return apiPost<{ status: string; approved: boolean }>(
`/petitions/${encodeURIComponent(petitionId)}/approve`,
{},
);
}
/** 拒绝请命 */
export function denyPetition(petitionId: string) {
return apiPost<{ status: string; approved: boolean }>(
`/petitions/${encodeURIComponent(petitionId)}/deny`,
{},
);
}
/** 行动账(净化摘要 + 观察标证) */
export function listToolCalls(limit = 100) {
return apiGet<ToolCallRecord[]>("/tool-calls", { limit });
}
+1
View File
@@ -17,6 +17,7 @@ export * as safetyApi from "./safety";
export * as adminApi from "./admin";
export * as settingsApi from "./settings";
export * as meApi from "./me";
export * as baigongApi from "./baigong";
export * as importsApi from "./imports";
export * as extractionApi from "./extraction";
export * as worldApi from "./world";
-129
View File
@@ -1,129 +0,0 @@
"use client";
/**
* useToolStream:流式工具调用状态机。
*
* 通过 invokeToolStream(fetch + ReadableStream)订阅 SSE,实时把 stdout/stderr 增量
* 解码后 append 到 stdout/stderr 状态,供组件即时渲染。高风险工具经
* tool.requires_confirmation 进入 confirming 态,confirm 后以 confirmed=true 重跑。
* 支持 AbortController 中止(cancel)。组件卸载时应调用 cancel/reset。
*/
import { useCallback, useRef, useState } from "react";
import { invokeToolStream } from "@/lib/api/tools";
import { describeError } from "@/lib/utils/error";
import type { ToolStreamDone } from "@/types/api";
export type ToolStreamState =
| { phase: "idle" }
| { phase: "streaming"; tool: string }
| { phase: "completed"; done: ToolStreamDone }
| {
phase: "confirming";
tool: string;
risk_level: string;
reason: string;
}
| { phase: "error"; message: string };
export interface InvokeContext {
run_id?: string;
character_id?: string;
trace_id?: string;
}
export function useToolStream() {
const [state, setState] = useState<ToolStreamState>({ phase: "idle" });
const [stdout, setStdout] = useState("");
const [stderr, setStderr] = useState("");
const [busy, setBusy] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const pendingRef = useRef<{
tool: string;
input: Record<string, unknown>;
ctx?: InvokeContext;
} | null>(null);
const runOnce = useCallback(
async (
tool: string,
input: Record<string, unknown>,
ctx: InvokeContext | undefined,
confirmed: boolean,
) => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setStdout("");
setStderr("");
setBusy(true);
setState({ phase: "streaming", tool });
try {
await invokeToolStream(
tool,
{ input, confirmed, ...ctx },
{
onStdout: (data) =>
setStdout((prev) => prev + decodeB64UrlNoPad(data)),
onStderr: (data) =>
setStderr((prev) => prev + decodeB64UrlNoPad(data)),
onCompleted: (done) => setState({ phase: "completed", done }),
onRequiresConfirmation: (risk_level, reason) =>
setState({ phase: "confirming", tool, risk_level, reason }),
onFailed: (code, message) =>
setState({ phase: "error", message: `${code}: ${message}` }),
},
ctrl.signal,
);
} catch (e) {
if (ctrl.signal.aborted) {
setState({ phase: "idle" });
} else {
setState({ phase: "error", message: describeError(e) });
}
} finally {
setBusy(false);
}
},
[],
);
const run = useCallback(
async (
tool: string,
input: Record<string, unknown>,
ctx?: InvokeContext,
) => {
pendingRef.current = { tool, input, ctx };
await runOnce(tool, input, ctx, false);
},
[runOnce],
);
const confirm = useCallback(async () => {
const pending = pendingRef.current;
if (!pending) return;
await runOnce(pending.tool, pending.input, pending.ctx, true);
}, [runOnce]);
const cancel = useCallback(() => {
abortRef.current?.abort();
}, []);
const reset = useCallback(() => {
setState({ phase: "idle" });
setStdout("");
setStderr("");
}, []);
return { state, stdout, stderr, busy, run, confirm, cancel, reset };
}
/** 解码 base64(urlsafe-no-pad) → UTF-8 字符串(命令输出可能含多字节)。 */
function decodeB64UrlNoPad(b64: string): string {
const std = b64.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(std + "=".repeat((4 - (std.length % 4)) % 4));
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
return new TextDecoder("utf-8").decode(bytes);
}
+145
View File
@@ -82,6 +82,151 @@ export function resolveMock(
// —— 健康检查 ——
if (m === "GET" && path === "/health") return ok(data.health);
// —— 百工:能力目录 / 授契 / 请命 / 行动账 ——
if (m === "GET" && path === "/capabilities") {
return ok([
{
id: "cap_mock_001",
name: "builtin.echo",
description: "回显输入(无副作用演示能力)",
executor_type: "builtin",
executor_ref: "echo",
risk_dims: {
capability: "L0",
data: "L0",
consequence: "L0",
context: "L0",
},
data_scope: "",
destinations: "",
source: "builtin",
version: "1",
consent_mode: "allow",
enabled: true,
mandated: true,
},
{
id: "cap_mock_002",
name: "qiankun.shell",
description: "在乾坤沙箱中执行命令(隔离执行,HMAC 授契)",
executor_type: "qiankun",
executor_ref: "",
risk_dims: {
capability: "L2",
data: "L1",
consequence: "L3",
context: "L1",
},
data_scope: "沙箱工作目录",
destinations: "本机沙箱进程",
source: "builtin",
version: "1",
consent_mode: "ask",
enabled: true,
mandated: false,
},
]);
}
if (m === "GET" && path === "/mandates") {
return ok([
{
id: "mdt_mock_001",
capability_id: "cap_mock_001",
character_id: null,
kind: "charter",
scope: {},
budget_total: 20,
budget_used: 3,
status: "active",
granted_at: "2026-08-27T10:00:00Z",
expires_at: null,
},
]);
}
if (m === "POST" && path === "/mandates") {
return ok({ id: "mdt_mock_002", status: "active" }, 201);
}
if (m === "DELETE" && path.startsWith("/mandates/")) {
return ok(null, 204);
}
if (m === "GET" && path === "/petitions") {
return ok([
{
id: "confirm_mock_001",
capability: "qiankun.shell",
risk_level: "L3",
reason: "请命:高风险动作需当次确认",
brief: {
who: "character:character_mock",
what: "在乾坤沙箱中执行命令",
capability: "qiankun.shell",
executor: "qiankun",
destinations: "本机沙箱进程",
consequences: "风险等级 L3",
alternatives: "拒绝后可调整参数或改用低风险能力",
},
run_id: "run_mock_001",
character_id: "character_mock",
created_at: "2026-08-27T10:05:00Z",
expires_at: "2026-08-27T10:15:00Z",
},
]);
}
if (
m === "POST" &&
path.startsWith("/petitions/") &&
path.endsWith("/approve")
) {
return ok({ status: "resolved", approved: true });
}
if (
m === "POST" &&
path.startsWith("/petitions/") &&
path.endsWith("/deny")
) {
return ok({ status: "cancelled", approved: false });
}
if (m === "GET" && path === "/tool-calls") {
return ok([
{
id: "tc_mock_001",
capability_id: "cap_mock_001",
run_id: "run_mock_001",
character_id: "character_mock",
trace_id: "trace_mock_001",
decision: "allow",
mandate_id: "mdt_mock_001",
status: "ok",
error_code: null,
input_digest: '{"text":"琉璃之镜"}',
output_digest: "[echo] 琉璃之镜",
evidence: {
kind: "observation",
capability: "builtin.echo",
epistemic: "observation_not_fact",
},
duration_ms: 3,
created_at: "2026-08-27T10:06:00Z",
},
{
id: "tc_mock_002",
capability_id: "cap_mock_002",
run_id: null,
character_id: null,
trace_id: null,
decision: "denied_unmandated",
mandate_id: null,
status: "rejected",
error_code: "HJ-1303",
input_digest: '{"command":"ls"}',
output_digest: "",
evidence: { kind: "observation", capability: "qiankun.shell" },
duration_ms: 0,
created_at: "2026-08-27T10:07:00Z",
},
]);
}
// —— 神识阁:BYOK 密钥(脱敏)——
if (m === "GET" && path === "/me/byok-keys") {
return ok({
+88
View File
@@ -700,6 +700,94 @@ export interface MyUsage {
quota?: UsageQuota | null;
}
/** 百工能力(GET /capabilities;四维风险标注 + 授契状态) */
export interface Capability {
id: string;
name: string;
description: string;
executor_type: "builtin" | "qiankun" | "mcp" | "http" | string;
executor_ref: string;
risk_dims: Record<string, string>;
data_scope: string;
destinations: string;
source: string;
version: string;
consent_mode: "allow" | "ask" | string;
enabled: boolean;
mandated: boolean;
}
/** 授契/行契(GET/POST /mandates;撤销立即生效) */
export interface Mandate {
id: string;
capability_id: string;
character_id: string | null;
kind: "grant" | "charter";
scope: Record<string, unknown>;
budget_total: number;
budget_used: number;
status: "active" | "revoked" | string;
granted_at: string | null;
expires_at: string | null;
}
/** 待决请命(GET /petitions;超时=拒绝执行) */
export interface Petition {
id: string;
capability: string;
risk_level: string;
reason: string;
brief: {
who?: string;
what?: string;
capability?: string;
executor?: string;
destinations?: string;
consequences?: string;
alternatives?: string;
params?: string;
};
run_id: string | null;
character_id: string | null;
created_at: string | null;
expires_at: string | null;
}
/** 行动账条目(GET /tool-calls;净化摘要 + 观察标证) */
export interface ToolCallRecord {
id: string;
capability_id: string;
run_id: string | null;
character_id: string | null;
trace_id: string | null;
decision: string;
mandate_id: string | null;
status: string;
error_code: string | null;
input_digest: string;
output_digest: string;
evidence: {
kind?: string;
capability?: string;
executor?: string;
epistemic?: string;
[key: string]: unknown;
};
duration_ms: number;
created_at: string | null;
}
/** 能力调用响应:执行结果 / 请命 / 拒绝 */
export interface InvokeResult {
status: "ok" | "failed" | "requires_petition";
call_id?: string;
observation?: Capability["risk_dims"] & { output?: string };
petition_id?: string;
risk_level?: string;
expires_at?: string;
brief?: Petition["brief"];
}
/** 工具权限策略(契约字典 §5.6) */
export type PermissionPolicy = "allow" | "ask" | "always_ask" | "forbidden";