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:
1 parent
144415247e
commit
684261f4b4
97 files changed
+3075
-5554
No files matched your search
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in new issue
Block a user