create-windy 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/cli.js +18 -1
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/server/src/agent/admin-catalog-bootstrap.ts +25 -0
  5. package/template/apps/server/src/agent/agent-session-bootstrap.ts +7 -8
  6. package/template/apps/server/src/agent/agent-session-facade.ts +12 -0
  7. package/template/apps/server/src/agent/agent-session-validation.ts +43 -0
  8. package/template/apps/server/src/agent/operation-catalog.test.ts +202 -0
  9. package/template/apps/server/src/agent/operation-catalog.ts +135 -0
  10. package/template/apps/server/src/agent/run-browser-visibility.test.ts +59 -0
  11. package/template/apps/server/src/agent/run-browser-visibility.ts +11 -0
  12. package/template/apps/server/src/agent/run-operation-policy.ts +1 -0
  13. package/template/apps/server/src/agent/run-route-response.ts +35 -0
  14. package/template/apps/server/src/agent/run-routes.ts +46 -43
  15. package/template/apps/server/src/agent/runtime-bootstrap.ts +21 -20
  16. package/template/apps/server/src/agent/runtime-disabled-composition.ts +28 -0
  17. package/template/apps/server/src/agent/session-projection-facade.ts +8 -9
  18. package/template/apps/server/src/agent/session-projection-governance.ts +10 -0
  19. package/template/apps/server/src/agent/session-projection-view.ts +19 -16
  20. package/template/apps/server/src/agent/session-recording-reader.contract.test.ts +131 -0
  21. package/template/apps/server/src/agent/session-recording-reader.ts +62 -0
  22. package/template/apps/server/src/agent/tool-catalog-audit.ts +22 -0
  23. package/template/apps/server/src/agent/tool-catalog-availability.ts +21 -0
  24. package/template/apps/server/src/agent/tool-catalog-routes.test.ts +103 -0
  25. package/template/apps/server/src/agent/tool-catalog-routes.ts +62 -0
  26. package/template/apps/server/src/agent/tool-catalog.test.ts +122 -0
  27. package/template/apps/server/src/agent/tool-catalog.ts +44 -0
  28. package/template/apps/server/src/agent/tool-registry.ts +45 -14
  29. package/template/apps/server/src/agent/workbench-bootstrap.ts +39 -0
  30. package/template/apps/server/src/agent/workbench-event-projection.ts +115 -0
  31. package/template/apps/server/src/agent/workbench-facade.contract.test.ts +189 -0
  32. package/template/apps/server/src/agent/workbench-facade.ts +339 -0
  33. package/template/apps/server/src/agent/workbench-recording.contract.test.ts +328 -0
  34. package/template/apps/server/src/agent/workbench-routes.ts +97 -0
  35. package/template/apps/server/src/agent/workbench-run-integration.test.ts +231 -0
  36. package/template/apps/server/src/agent/workbench-run-reader.ts +187 -0
  37. package/template/apps/server/src/agent/workbench-safe-result.ts +61 -0
  38. package/template/apps/server/src/agent/workbench-sse.ts +50 -0
  39. package/template/apps/server/src/agent-workflow/query-service.ts +36 -0
  40. package/template/apps/server/src/agent-workflow/recording-projection.contract.test.ts +196 -0
  41. package/template/apps/server/src/agent-workflow/recording-projection.ts +86 -0
  42. package/template/apps/web/src/pages/system/AgentDebugPage.vue +14 -0
  43. package/template/apps/web/src/pages/system/AgentSessionPage.vue +29 -8
  44. package/template/apps/web/src/pages/system/AgentToolsPage.vue +76 -0
  45. package/template/apps/web/src/pages/system/components/agent-workbench/AgentWorkbench.vue +236 -0
  46. package/template/apps/web/src/pages/system/components/agent-workbench/DraftLeaveDialog.vue +34 -0
  47. package/template/apps/web/src/pages/system/components/agent-workbench/ReplayEventDetail.vue +175 -0
  48. package/template/apps/web/src/pages/system/components/agent-workbench/SessionReplayTimeline.vue +142 -0
  49. package/template/apps/web/src/pages/system/components/agent-workbench/SessionReplayTimeline.webtest.ts +242 -0
  50. package/template/apps/web/src/pages/system/components/agent-workbench/TestRunComposer.vue +97 -0
  51. package/template/apps/web/src/pages/system/components/agent-workbench/ToolCatalogPanel.vue +100 -0
  52. package/template/apps/web/src/pages/system/components/agent-workbench/ToolCatalogPanel.webtest.ts +55 -0
  53. package/template/apps/web/src/pages/system/components/agent-workbench/ToolRuleDetail.vue +81 -0
  54. package/template/apps/web/src/pages/system/components/agent-workbench/WorkbenchSessionList.vue +57 -0
  55. package/template/apps/web/src/pages/system/components/agent-workbench/catalog-presenter.ts +20 -0
  56. package/template/apps/web/src/pages/system/components/agent-workbench/replay-presenter.ts +94 -0
  57. package/template/apps/web/src/pages/system/composables/useAgentWorkbench.ts +298 -0
  58. package/template/apps/web/src/pages/system/composables/useAgentWorkbench.webtest.ts +164 -0
  59. package/template/apps/web/src/pages/system/composables/useSessionReplay.ts +48 -0
  60. package/template/apps/web/src/router/index.ts +4 -0
  61. package/template/apps/web/src/router/platform-admin-route-bindings.ts +4 -0
  62. package/template/apps/web/src/services/agent-admin-api.ts +155 -0
  63. package/template/apps/web/src/services/agent-admin-api.webtest.ts +120 -0
  64. package/template/apps/web/src/services/agent-workbench-event.ts +60 -0
  65. package/template/docs/architecture/agent-harness.md +4 -0
  66. package/template/packages/modules/src/ai-operation-composition.ts +9 -0
  67. package/template/packages/modules/src/ai-operation-visibility.test.ts +33 -0
  68. package/template/packages/modules/src/manifest.ts +2 -0
  69. package/template/packages/modules/src/system-admin-routes.ts +16 -0
  70. package/template/packages/modules/src/system-admin-ui-catalog.ts +7 -1
  71. package/template/packages/modules/src/system-agent-admin-bindings.ts +42 -0
  72. package/template/packages/modules/src/system-agent-admin-menus.ts +26 -0
  73. package/template/packages/modules/src/system-agent-admin-registration.test.ts +52 -0
  74. package/template/packages/modules/src/system-agent-audit-actions.ts +5 -0
  75. package/template/packages/modules/src/system-ai-api-bindings.ts +2 -0
  76. package/template/packages/modules/src/system-audit-actions.ts +25 -0
  77. package/template/packages/modules/src/system-features.ts +19 -0
  78. package/template/packages/modules/src/system-module-catalog.ts +14 -12
  79. package/template/packages/modules/src/system-module-ownership.ts +12 -0
  80. package/template/packages/modules/src/system-modules.ts +2 -0
  81. package/template/packages/modules/src/system-permissions.ts +5 -0
  82. package/template/packages/server-sdk/index.ts +2 -0
  83. package/template/packages/server-sdk/src/agent-session-provider.ts +19 -0
  84. package/template/packages/shared/index.ts +2 -0
  85. package/template/packages/shared/src/agent-admin-catalog.ts +73 -0
  86. package/template/packages/shared/src/agent-workbench.ts +100 -0
@@ -0,0 +1,142 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import type { AgentWorkbenchRecordingV1 } from "@southwind-ai/shared";
4
+ import { Button } from "@/components/ui/button";
5
+ import { useSessionReplay } from "../../composables/useSessionReplay";
6
+ import { replayEntries } from "./replay-presenter";
7
+ import ReplayEventDetail from "./ReplayEventDetail.vue";
8
+ const props = defineProps<{ recording?: AgentWorkbenchRecordingV1 }>();
9
+ const entries = computed(() => replayEntries(props.recording));
10
+ const events = computed(() => entries.value.map((entry) => entry.event));
11
+ const replay = useSessionReplay(events);
12
+ </script>
13
+ <template>
14
+ <section class="space-y-4" aria-label="只读会话回放">
15
+ <header class="rounded-lg border bg-card p-4">
16
+ <h2 class="font-medium">只读历史回放</h2>
17
+ <p class="mt-1 text-xs text-muted-foreground">
18
+ 播放只移动本地查看位置,不重新调用模型或工具,不产生新的使用计量。每个日志按原始序号播放,关联子日志展开不代表全局时间排序。
19
+ </p>
20
+ <p v-if="recording" class="mt-2 text-xs">
21
+ 来源:{{ recording.source }} · {{ recording.sessionId }} · 关联{{
22
+ recording.correlationStatus === "complete"
23
+ ? "完整"
24
+ : recording.correlationStatus === "partial"
25
+ ? "部分缺失"
26
+ : "缺失"
27
+ }}
28
+ </p>
29
+ <ol
30
+ v-if="recording?.segments?.length"
31
+ class="mt-3 space-y-1 text-xs"
32
+ aria-label="父子 Session 回放范围"
33
+ >
34
+ <li
35
+ v-for="(segment, index) in recording.segments"
36
+ :key="segment.sessionId"
37
+ class="break-all"
38
+ >
39
+ {{ index + 1 }}. Session {{ segment.sessionId }} · 原始序号截至 #{{
40
+ segment.throughSequence
41
+ }}{{
42
+ segment.sessionId === recording.sessionId
43
+ ? "(当前分支)"
44
+ : "(父分支)"
45
+ }}
46
+ </li>
47
+ </ol>
48
+ <p
49
+ v-if="recording?.missing.length"
50
+ class="mt-2 text-sm text-muted-foreground"
51
+ >
52
+ 缺失记录:{{ recording.missing.join("、") }}
53
+ </p>
54
+ </header>
55
+ <div class="flex flex-wrap items-center gap-2">
56
+ <Button
57
+ variant="outline"
58
+ :disabled="!entries.length"
59
+ @click="replay.playing.value ? replay.pause() : replay.play()"
60
+ >{{ replay.playing.value ? "暂停" : "播放" }}</Button
61
+ ><Button
62
+ variant="outline"
63
+ :disabled="replay.cursor.value <= 0"
64
+ @click="replay.seek(replay.cursor.value - 1)"
65
+ >上一事件</Button
66
+ ><Button
67
+ variant="outline"
68
+ :disabled="replay.cursor.value >= entries.length - 1"
69
+ @click="replay.seek(replay.cursor.value + 1)"
70
+ >下一事件</Button
71
+ ><select
72
+ v-model.number="replay.speed.value"
73
+ aria-label="回放速度"
74
+ class="h-9 rounded-md border bg-background px-2 text-sm"
75
+ >
76
+ <option :value="0.5">0.5 倍速</option>
77
+ <option :value="1">1 倍速</option>
78
+ <option :value="2">2 倍速</option>
79
+ <option :value="4">4 倍速</option></select
80
+ ><span class="text-xs text-muted-foreground"
81
+ >{{ replay.cursor.value + 1 }} / {{ entries.length }}</span
82
+ >
83
+ </div>
84
+ <div
85
+ v-if="recording?.messages.length"
86
+ class="space-y-2 rounded-lg border bg-card p-4"
87
+ >
88
+ <h3 class="text-sm font-medium">受控会话内容</h3>
89
+ <article
90
+ v-for="item in recording.messages"
91
+ :key="item.sequence"
92
+ class="rounded-md bg-muted p-3 text-sm"
93
+ >
94
+ <p class="mb-1 text-xs text-muted-foreground">
95
+ {{ item.sequence }} · {{ item.role === "user" ? "用户输入" : "回答" }}
96
+ </p>
97
+ <p class="whitespace-pre-wrap break-words">{{ item.content }}</p>
98
+ </article>
99
+ </div>
100
+ <div
101
+ class="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(18rem,1fr)]"
102
+ >
103
+ <div
104
+ class="max-h-[36rem] space-y-2 overflow-auto rounded-lg border bg-card p-3"
105
+ >
106
+ <button
107
+ v-for="(entry, index) in entries"
108
+ :key="`${entry.ledger}:${index}`"
109
+ type="button"
110
+ :aria-current="index === replay.cursor.value ? 'step' : undefined"
111
+ class="w-full rounded-md border p-3 text-left hover:bg-muted aria-[current=step]:border-primary aria-[current=step]:bg-primary/5"
112
+ @click="replay.seek(index)"
113
+ >
114
+ <span class="block break-all text-xs text-muted-foreground"
115
+ >{{ entry.ledger
116
+ }}<span
117
+ v-if="entry.sessionId && !entry.ledger.startsWith('Session ')"
118
+ >
119
+ · Session {{ entry.sessionId }}</span
120
+ >
121
+ · #{{ entry.event.sequence }} · {{ entry.event.type }}</span
122
+ ><span class="block text-sm">{{ entry.event.summary }}</span
123
+ ><span
124
+ v-if="index > replay.cursor.value"
125
+ class="text-xs text-muted-foreground"
126
+ >尚未播放</span
127
+ >
128
+ </button>
129
+ <p v-if="!entries.length" class="p-4 text-sm text-muted-foreground">
130
+ 暂无可回放的历史事件;缺失关联不会补造。
131
+ </p>
132
+ </div>
133
+ <ReplayEventDetail
134
+ :event="replay.current.value"
135
+ :session-id="entries[replay.cursor.value]?.sessionId"
136
+ :recording="recording"
137
+ :events="events"
138
+ @seek="replay.seek"
139
+ />
140
+ </div>
141
+ </section>
142
+ </template>
@@ -0,0 +1,242 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, expect, test, vi } from "vitest";
3
+ import type { AgentWorkbenchRecordingV1 } from "@southwind-ai/shared";
4
+ import SessionReplayTimeline from "./SessionReplayTimeline.vue";
5
+ import { replayEntries } from "./replay-presenter";
6
+ import { publicWorkbenchEvent } from "@/services/agent-workbench-event";
7
+ const recording: AgentWorkbenchRecordingV1 = {
8
+ source: "business",
9
+ sessionId: "business_1",
10
+ title: "案件查询",
11
+ updatedAt: "2026-10-07",
12
+ mode: "read-only",
13
+ sideEffectPolicy: "no-reexecution",
14
+ correlationStatus: "partial",
15
+ missing: ["历史审批关联缺失"],
16
+ messages: [
17
+ {
18
+ sequence: 1,
19
+ role: "user",
20
+ content: "查看资金线索",
21
+ createdAt: "2026-10-07",
22
+ },
23
+ ],
24
+ timeline: [
25
+ { sequence: 3, type: "run.linked", summary: "第一轮", runId: "run_1" },
26
+ { sequence: 9, type: "run.linked", summary: "第二轮", runId: "run_2" },
27
+ ],
28
+ runs: [
29
+ {
30
+ runId: "run_1",
31
+ operationKey: "query",
32
+ status: "completed",
33
+ correlationStatus: "complete",
34
+ missing: [],
35
+ events: [
36
+ {
37
+ sequence: 1,
38
+ type: "tool.requested",
39
+ summary: "资金工具",
40
+ runId: "run_1",
41
+ toolKey: "money.read",
42
+ toolCallId: "tool_1",
43
+ },
44
+ ],
45
+ },
46
+ {
47
+ runId: "run_2",
48
+ operationKey: "query",
49
+ status: "completed",
50
+ correlationStatus: "complete",
51
+ missing: [],
52
+ result: { output: { total: 12 } },
53
+ events: [
54
+ {
55
+ sequence: 1,
56
+ type: "usage.reported",
57
+ summary: "模型用量",
58
+ runId: "run_2",
59
+ modelAttemptId: "attempt_2",
60
+ usage: { inputTokens: 15 },
61
+ },
62
+ ],
63
+ },
64
+ ],
65
+ workflows: [
66
+ {
67
+ workflowRunId: "workflow_2",
68
+ parentRunId: "run_2",
69
+ workflowKey: "review",
70
+ title: "复核",
71
+ status: "completed",
72
+ nodes: [
73
+ {
74
+ nodeRunId: "node_2",
75
+ nodeId: "review",
76
+ type: "model",
77
+ status: "completed",
78
+ attempt: 2,
79
+ selectedBranch: "approved",
80
+ workItemId: "workitem_review",
81
+ artifactIds: ["artifact_2"],
82
+ agentRunId: "run_2",
83
+ },
84
+ ],
85
+ events: [
86
+ {
87
+ sequence: 6,
88
+ type: "node.completed",
89
+ summary: "节点完成",
90
+ workItemId: "workitem_review",
91
+ outcome: "approved",
92
+ workflowRunId: "workflow_2",
93
+ nodeRunId: "node_2",
94
+ runId: "run_2",
95
+ },
96
+ ],
97
+ },
98
+ ],
99
+ };
100
+ describe("会话只读回放", () => {
101
+ test("fork按父子Session分段回放,各自原始序号从1开始,父Run不会误标为缺失", async () => {
102
+ const parentEvent = {
103
+ sequence: 1,
104
+ type: "run.linked",
105
+ summary: "父分支第一轮",
106
+ runId: "run_1",
107
+ };
108
+ const childEvent = {
109
+ sequence: 1,
110
+ type: "run.linked",
111
+ summary: "子分支第一轮",
112
+ runId: "run_2",
113
+ };
114
+ const fork: AgentWorkbenchRecordingV1 = {
115
+ ...recording,
116
+ source: "harness",
117
+ sessionId: "session_child",
118
+ timeline: [childEvent],
119
+ segments: [
120
+ {
121
+ sessionId: "session_parent",
122
+ throughSequence: 1,
123
+ timeline: [parentEvent],
124
+ },
125
+ {
126
+ sessionId: "session_child",
127
+ throughSequence: 1,
128
+ timeline: [childEvent],
129
+ },
130
+ ],
131
+ runs: recording.runs.map((run, index) => ({
132
+ ...run,
133
+ sessionId: index === 0 ? "session_parent" : "session_child",
134
+ })),
135
+ };
136
+ const entries = replayEntries(fork);
137
+ expect(entries.map((entry) => entry.event.sequence)).toEqual([
138
+ 1, 1, 1, 1, 6,
139
+ ]);
140
+ expect(entries[1]?.sessionId).toBe("session_parent");
141
+ expect(entries[3]?.sessionId).toBe("session_child");
142
+ expect(entries.some((entry) => entry.ledger.includes("独立 Run"))).toBe(
143
+ false,
144
+ );
145
+ const wrapper = mount(SessionReplayTimeline, {
146
+ props: { recording: fork },
147
+ });
148
+ expect(
149
+ wrapper.get('[aria-label="父子 Session 回放范围"]').text(),
150
+ ).toContain("session_parent");
151
+ expect(
152
+ wrapper.get('[aria-label="父子 Session 回放范围"]').text(),
153
+ ).toContain("session_child");
154
+ await wrapper
155
+ .findAll("button")
156
+ .find(
157
+ (button) =>
158
+ button.text().includes("Run run_1") &&
159
+ button.text().includes("资金工具"),
160
+ )!
161
+ .trigger("click");
162
+ expect(wrapper.get('[aria-label="调用关联详情"]').text()).toContain(
163
+ "session_parent",
164
+ );
165
+ await wrapper
166
+ .findAll("button")
167
+ .find((button) => button.text() === "下一事件")!
168
+ .trigger("click");
169
+ expect(wrapper.get('[aria-current="step"]').text()).toContain(
170
+ "Session session_child",
171
+ );
172
+ wrapper.unmount();
173
+ });
174
+
175
+ test("保留多轮日志各自序号而不补造全局序列,且播放不触发网络", async () => {
176
+ const fetcher = vi.spyOn(globalThis, "fetch");
177
+ const entries = replayEntries(recording);
178
+ expect(entries.map((entry) => entry.event.sequence)).toEqual([
179
+ 3, 1, 9, 1, 6,
180
+ ]);
181
+ const wrapper = mount(SessionReplayTimeline, { props: { recording } });
182
+ expect(wrapper.text()).toContain("历史审批关联缺失");
183
+ await wrapper
184
+ .findAll("button")
185
+ .find((button) => button.text() === "播放")!
186
+ .trigger("click");
187
+ await wrapper
188
+ .findAll("button")
189
+ .find((button) => button.text() === "暂停")!
190
+ .trigger("click");
191
+ await wrapper
192
+ .findAll("button")
193
+ .find((button) => button.text() === "下一事件")!
194
+ .trigger("click");
195
+ expect(wrapper.text()).toContain("money.read");
196
+ expect(fetcher).not.toHaveBeenCalled();
197
+ wrapper.unmount();
198
+ });
199
+ test("点击关联节点在统一视图中定位,详情显示结果和分支", async () => {
200
+ const wrapper = mount(SessionReplayTimeline, { props: { recording } });
201
+ await wrapper
202
+ .findAll("button")
203
+ .find(
204
+ (button) =>
205
+ button.text().includes("Run run_2") &&
206
+ button.text().includes("模型用量"),
207
+ )!
208
+ .trigger("click");
209
+ const detail = wrapper.get('[aria-label="调用关联详情"]');
210
+ expect(detail.text()).toContain("attempt_2");
211
+ expect(detail.text()).toContain("12");
212
+ expect(detail.text()).toContain("approved");
213
+ expect(detail.text()).toContain("审批任务:workitem_review");
214
+ await detail
215
+ .findAll("button")
216
+ .find((button) => button.text() === "定位审批记录")!
217
+ .trigger("click");
218
+ expect(wrapper.get('[aria-label="调用关联详情"]').text()).toContain(
219
+ "workitem_review",
220
+ );
221
+ await detail
222
+ .findAll("button")
223
+ .find((button) => button.text() === "定位节点 / Run 的记录")!
224
+ .trigger("click");
225
+ expect(wrapper.get('[aria-current="step"]').text()).toContain("节点完成");
226
+ wrapper.unmount();
227
+ });
228
+ test("SSE 客户端不保留未声明的敏感字段", () => {
229
+ const event = publicWorkbenchEvent({
230
+ sequence: 1,
231
+ type: "tool.requested",
232
+ summary: "已请求",
233
+ toolKey: "tool.read",
234
+ arguments: { secret: "secret" },
235
+ providerBody: "private",
236
+ chainOfThought: "hidden",
237
+ usage: { inputTokens: 3, secret: "unsafe" },
238
+ });
239
+ expect(JSON.stringify(event)).not.toMatch(/secret|private|hidden|unsafe/);
240
+ expect(event?.usage?.inputTokens).toBe(3);
241
+ });
242
+ });
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import type { AgentAdminOperationCatalogItem } from "@southwind-ai/shared";
4
+ import { Button } from "@/components/ui/button";
5
+ import { availabilityLabel, formatJson } from "./catalog-presenter";
6
+ const props = defineProps<{
7
+ operations: readonly AgentAdminOperationCatalogItem[];
8
+ operationKey: string;
9
+ input: string;
10
+ starting: boolean;
11
+ running: boolean;
12
+ canManage: boolean;
13
+ }>();
14
+ defineEmits<{
15
+ "update:input": [value: string];
16
+ operation: [key: string];
17
+ start: [];
18
+ }>();
19
+ const operation = computed(() =>
20
+ props.operations.find((item) => item.key === props.operationKey),
21
+ );
22
+ </script>
23
+ <template>
24
+ <form
25
+ class="space-y-3 rounded-lg border bg-card p-4"
26
+ @submit.prevent="$emit('start')"
27
+ >
28
+ <h2 class="font-medium">真实受治理测试</h2>
29
+ <p class="text-xs text-muted-foreground">
30
+ 使用当前登录身份,运行会真实调用模型与允许的工具。同一测试会话可连续运行多轮。
31
+ </p>
32
+ <label class="grid gap-2 text-sm"
33
+ ><span>Operation</span
34
+ ><select
35
+ required
36
+ :value="operationKey"
37
+ :disabled="starting || running || !canManage"
38
+ class="h-9 rounded-md border bg-background px-2 user-invalid:border-destructive"
39
+ @change="$emit('operation', ($event.target as HTMLSelectElement).value)"
40
+ >
41
+ <option value="" disabled>请选择已注册操作</option>
42
+ <option
43
+ v-for="item in operations"
44
+ :key="item.key"
45
+ :value="item.key"
46
+ :disabled="!item.selectable"
47
+ >
48
+ {{ item.key }} · {{ item.moduleLabel
49
+ }}{{
50
+ item.selectable
51
+ ? ""
52
+ : `(${availabilityLabel(item.availabilityReason)})`
53
+ }}
54
+ </option>
55
+ </select></label
56
+ >
57
+ <details v-if="operation">
58
+ <summary class="cursor-pointer text-xs">
59
+ 查看输入 Schema 与运行规则
60
+ </summary>
61
+ <pre
62
+ class="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-xs"
63
+ >{{ formatJson(operation.inputSchema) }}</pre>
64
+ <p class="mt-2 text-xs text-muted-foreground">
65
+ {{ availabilityLabel(operation.availabilityReason) }};最长
66
+ {{ operation.limits.maxDurationSeconds }} 秒;输入
67
+ {{ operation.limits.maxInputBytes }} 字节。允许工具:{{
68
+ operation.allowedToolKeys.join("、") || "无"
69
+ }}
70
+ </p>
71
+ </details>
72
+ <label class="grid gap-2 text-sm"
73
+ ><span>测试输入(JSON)</span
74
+ ><textarea
75
+ :value="input"
76
+ :disabled="starting || running || !canManage"
77
+ rows="5"
78
+ spellcheck="false"
79
+ aria-label="测试输入 JSON"
80
+ class="min-h-32 w-full rounded-md border border-input bg-background p-3 font-mono text-sm disabled:opacity-50"
81
+ @input="
82
+ $emit('update:input', ($event.target as HTMLTextAreaElement).value)
83
+ "
84
+ />
85
+ </label>
86
+ <p v-if="!canManage" class="text-sm text-muted-foreground">
87
+ 当前只有查看权限,不能新建测试运行。
88
+ </p>
89
+ <Button
90
+ type="submit"
91
+ :disabled="!canManage || !operation?.selectable || starting || running"
92
+ >{{
93
+ starting ? "创建中…" : running ? "运行进行中" : "开始真实测试"
94
+ }}</Button
95
+ >
96
+ </form>
97
+ </template>
@@ -0,0 +1,100 @@
1
+ <script setup lang="ts">
2
+ import type { AgentAdminToolCatalogItem } from "@southwind-ai/shared";
3
+ import { computed, shallowRef } from "vue";
4
+ import { Input } from "@/components/ui/input";
5
+ import { availabilityLabel } from "./catalog-presenter";
6
+ const props = defineProps<{
7
+ tools: readonly AgentAdminToolCatalogItem[];
8
+ selected: string;
9
+ }>();
10
+ defineEmits<{ select: [key: string] }>();
11
+ const query = shallowRef("");
12
+ const moduleKey = shallowRef("");
13
+ const operationKey = shallowRef("");
14
+ const availability = shallowRef("");
15
+ const modules = computed(() =>
16
+ [...new Set(props.tools.map((tool) => tool.module))].sort(),
17
+ );
18
+ const operations = computed(() =>
19
+ [...new Set(props.tools.flatMap((tool) => tool.allowedOperationKeys))].sort(),
20
+ );
21
+ const filtered = computed(() =>
22
+ props.tools.filter(
23
+ (tool) =>
24
+ (!moduleKey.value || tool.module === moduleKey.value) &&
25
+ (!operationKey.value ||
26
+ tool.allowedOperationKeys.includes(operationKey.value)) &&
27
+ (!availability.value ||
28
+ tool.allowed === (availability.value === "allowed")) &&
29
+ `${tool.key} ${tool.label} ${tool.description}`
30
+ .toLocaleLowerCase()
31
+ .includes(query.value.trim().toLocaleLowerCase()),
32
+ ),
33
+ );
34
+ </script>
35
+ <template>
36
+ <section
37
+ class="space-y-3 rounded-lg border bg-card p-4"
38
+ aria-label="工具目录"
39
+ >
40
+ <Input
41
+ v-model="query"
42
+ aria-label="搜索工具"
43
+ placeholder="搜索名称、说明或 Key"
44
+ />
45
+ <div class="grid gap-2 sm:grid-cols-3 lg:grid-cols-1">
46
+ <select
47
+ v-model="moduleKey"
48
+ aria-label="所属模块筛选"
49
+ class="h-9 rounded-md border bg-background px-2 text-sm"
50
+ >
51
+ <option value="">所有模块</option>
52
+ <option v-for="module in modules" :key="module">{{ module }}</option>
53
+ </select>
54
+ <select
55
+ v-model="operationKey"
56
+ aria-label="Operation 筛选"
57
+ class="h-9 rounded-md border bg-background px-2 text-sm"
58
+ >
59
+ <option value="">所有 Operations</option>
60
+ <option v-for="operation in operations" :key="operation">
61
+ {{ operation }}
62
+ </option>
63
+ </select>
64
+ <select
65
+ v-model="availability"
66
+ aria-label="调用条件筛选"
67
+ class="h-9 rounded-md border bg-background px-2 text-sm"
68
+ >
69
+ <option value="">所有调用状态</option>
70
+ <option value="allowed">具备调用条件</option>
71
+ <option value="denied">当前不可调用</option>
72
+ </select>
73
+ </div>
74
+ <p class="text-xs text-muted-foreground">
75
+ {{ filtered.length }} /
76
+ {{ tools.length }} 个工具。查看目录不会授予执行权限。
77
+ </p>
78
+ <div class="max-h-[65vh] space-y-2 overflow-y-auto">
79
+ <button
80
+ v-for="tool in filtered"
81
+ :key="tool.key"
82
+ type="button"
83
+ :aria-pressed="selected === tool.key"
84
+ class="w-full rounded-md border p-3 text-left transition-colors hover:bg-muted aria-pressed:border-primary aria-pressed:bg-primary/5"
85
+ @click="$emit('select', tool.key)"
86
+ >
87
+ <span class="block text-sm font-medium">{{ tool.label }}</span
88
+ ><span class="block break-all text-xs text-muted-foreground">{{
89
+ tool.key
90
+ }}</span
91
+ ><span class="mt-1 block text-xs">{{
92
+ availabilityLabel(tool.availabilityReason)
93
+ }}</span>
94
+ </button>
95
+ <p v-if="!filtered.length" class="p-5 text-sm text-muted-foreground">
96
+ 没有匹配的已装配工具。
97
+ </p>
98
+ </div>
99
+ </section>
100
+ </template>
@@ -0,0 +1,55 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, expect, test } from "vitest";
3
+ import type { AgentAdminToolCatalogItem } from "@southwind-ai/shared";
4
+ import ToolCatalogPanel from "./ToolCatalogPanel.vue";
5
+ import ToolRuleDetail from "./ToolRuleDetail.vue";
6
+ const tool: AgentAdminToolCatalogItem = {
7
+ key: "analysis.location-query.query",
8
+ label: "位置查询",
9
+ description: "查询已授权地理轨迹",
10
+ module: "analysis.location-query",
11
+ operation: "query",
12
+ featureKey: "analysis.location-query",
13
+ permissionKey: "analysis.location-query.read",
14
+ auditAction: "query.read",
15
+ scopeMode: "request",
16
+ executionMode: "host",
17
+ dataClassification: "sensitive",
18
+ approval: "required",
19
+ approvalRoles: [{ code: "reviewer", name: "复核员" }],
20
+ approvalTimeoutSeconds: 120,
21
+ idempotency: "required",
22
+ limits: { maxDurationSeconds: 60, maxInputBytes: 1024, maxOutputBytes: 2048 },
23
+ inputSchemaKey: "input.v1",
24
+ outputSchemaKey: "output.v1",
25
+ inputSchema: { type: "object", properties: { keyword: { type: "string" } } },
26
+ outputSchema: { type: "array" },
27
+ allowedOperationKeys: ["copilot.query"],
28
+ allowed: false,
29
+ availabilityReason: "permission-denied",
30
+ };
31
+ describe("动态工具目录", () => {
32
+ test("下游工具由服务投影显示,可见与执行权限分离且没有执行按钮", async () => {
33
+ const wrapper = mount(ToolCatalogPanel, {
34
+ props: { tools: [tool], selected: "" },
35
+ });
36
+ expect(wrapper.text()).toContain("位置查询");
37
+ expect(wrapper.text()).toContain("缺少执行权限");
38
+ expect(wrapper.text()).not.toContain("执行工具");
39
+ await wrapper.get("input").setValue("copilot");
40
+ expect(wrapper.text()).toContain("没有匹配");
41
+ await wrapper.get("input").setValue("位置");
42
+ await wrapper.get("button").trigger("click");
43
+ expect(wrapper.emitted("select")).toEqual([[tool.key]]);
44
+ await wrapper.get('[aria-label="调用条件筛选"]').setValue("allowed");
45
+ expect(wrapper.text()).toContain("没有匹配");
46
+ });
47
+ test("Schema 和审批/范围/限制展示使用实际注册投影", () => {
48
+ const wrapper = mount(ToolRuleDetail, { props: { tool } });
49
+ expect(wrapper.text()).toContain("keyword");
50
+ expect(wrapper.text()).toContain("input.v1");
51
+ expect(wrapper.text()).toContain("复核员");
52
+ expect(wrapper.text()).toContain("1024");
53
+ expect(wrapper.text()).toContain("copilot.query");
54
+ });
55
+ });