@anchrd/intel-ui 0.9.1 → 0.12.0
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.
- package/package.json +2 -2
- package/src/agent/agent-definition/agent-definition.ts +26 -3
- package/src/agent/agent-models/agent-models.ts +142 -3
- package/src/agent/agent-models/agent-models.types.ts +18 -0
- package/src/agent/agent-profile/agent-profile.tsx +285 -57
- package/src/agent/agent-state/agent-state.ts +20 -0
- package/src/agent/agent.tsx +5 -1
- package/src/app/app-tree/app-tree.tsx +3 -1
- package/src/data/intel-data-provider/intel-data-provider.ts +25 -3
- package/src/data/intel-data-provider/intel-data-provider.types.ts +7 -0
- package/src/i18n/en.json +22 -2
- package/src/tools/tools.tsx +155 -53
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@anchrd/intel-contract": "^0.
|
|
32
|
+
"@anchrd/intel-contract": "^0.7.0",
|
|
33
33
|
"@assistant-ui/react": "^0.15.4",
|
|
34
34
|
"@assistant-ui/react-ai-sdk": "^1.4.4",
|
|
35
35
|
"@blocknote/core": "^0.52.1",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentDefinition, NodeAgent } from "@anchrd/intel-contract";
|
|
1
|
+
import type { AgentDefinition, AgentDefinitionInput, NodeAgent } from "@anchrd/intel-contract";
|
|
2
2
|
import {
|
|
3
3
|
type UseMutationResult,
|
|
4
4
|
type UseQueryResult,
|
|
@@ -14,7 +14,25 @@ export const agentKey = (nodeId: string) => ["agent", nodeId] as const;
|
|
|
14
14
|
export interface AgentDefinitionHandle {
|
|
15
15
|
query: UseQueryResult<NodeAgent>;
|
|
16
16
|
definition: AgentDefinition | null;
|
|
17
|
-
save: UseMutationResult<NodeAgent, Error,
|
|
17
|
+
save: UseMutationResult<NodeAgent, Error, AgentDefinitionInput>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The definition as it was read, turned back into something this screen may ASK for (D30).
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ It exists to drop exactly one field: `tools.delegatedBy`. Whose portal connection an agent
|
|
24
|
+
* acts on is Intel's to write, out of the session, and the input shape has no place for it — so a
|
|
25
|
+
* section that edits schedules must not carry it along just because it was in what it read. Every
|
|
26
|
+
* section spreads this rather than the definition itself, which is why none of them has to invent a
|
|
27
|
+
* user id for an agent that has no tools yet.
|
|
28
|
+
*/
|
|
29
|
+
export function asDraft(definition: AgentDefinition): AgentDefinitionInput {
|
|
30
|
+
return {
|
|
31
|
+
references: definition.references,
|
|
32
|
+
schedules: definition.schedules,
|
|
33
|
+
model: definition.model,
|
|
34
|
+
tools: definition.tools === null ? null : { servers: definition.tools.servers },
|
|
35
|
+
};
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
/**
|
|
@@ -31,7 +49,7 @@ export function useAgentDefinition(nodeId: string): AgentDefinitionHandle {
|
|
|
31
49
|
const queryClient = useQueryClient();
|
|
32
50
|
const query = useQuery({ queryKey: agentKey(nodeId), queryFn: () => data.getAgent(nodeId) });
|
|
33
51
|
const save = useMutation({
|
|
34
|
-
mutationFn: async (definition:
|
|
52
|
+
mutationFn: async (definition: AgentDefinitionInput) =>
|
|
35
53
|
await data.saveAgentDefinition({
|
|
36
54
|
nodeId,
|
|
37
55
|
baseVersionId: query.data?.version?.id ?? null,
|
|
@@ -42,6 +60,11 @@ export function useAgentDefinition(nodeId: string): AgentDefinitionHandle {
|
|
|
42
60
|
// next save needs that id as its `baseVersionId`, and a round trip would leave a window in
|
|
43
61
|
// which it is still the old one.
|
|
44
62
|
onSuccess: (saved) => queryClient.setQueryData(agentKey(nodeId), saved),
|
|
63
|
+
// ⚠️ A failed save may still have MOVED the version (#214): `agent_schedules_not_armed` means
|
|
64
|
+
// the definition was written and only the runtime's alarm was not set, and the repair it names
|
|
65
|
+
// is saving again. With the old `baseVersionId` still cached that second save would answer 409
|
|
66
|
+
// instead — so the version this screen writes against is refetched whenever a save fails.
|
|
67
|
+
onError: () => queryClient.invalidateQueries({ queryKey: agentKey(nodeId) }),
|
|
45
68
|
});
|
|
46
69
|
return { query, definition: query.data?.definition ?? null, save };
|
|
47
70
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AgentModel } from "@anchrd/intel-contract";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import type { ModelFacts, ModelPrice } from "./agent-models.types.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Which models this installation offers.
|
|
@@ -7,8 +8,7 @@ import { z } from "zod";
|
|
|
7
8
|
* ⚠️ There is no endpoint to ask. The runtime's registry answers one question — "is this provider
|
|
8
9
|
* configured" — and only when a run is already starting; nothing anywhere lists what a deployment
|
|
9
10
|
* pays for. Rather than invent an API for one screen, the list is build configuration
|
|
10
|
-
* (`VITE_AGENT_MODELS`, a JSON array of `{provider, model}`), with the
|
|
11
|
-
* fixtures use as the default.
|
|
11
|
+
* (`VITE_AGENT_MODELS`, a JSON array of `{provider, model}`), with the offering below as the default.
|
|
12
12
|
*
|
|
13
13
|
* ⚠️ The definition's current model is always offered, even when it is not in the list. A select
|
|
14
14
|
* that silently dropped it would turn "look at this agent" into "change this agent" for anyone who
|
|
@@ -16,10 +16,105 @@ import { z } from "zod";
|
|
|
16
16
|
*/
|
|
17
17
|
const ConfiguredModels = z.array(AgentModel);
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Context window and price per model, collected 2026-08-06 from
|
|
21
|
+
* `GET /accounts/{id}/ai/models/search` (Workers AI, every entry carrying `function_calling`) and
|
|
22
|
+
* from Anthropic's model list. Both figures are shown beside the select, so this table is the one
|
|
23
|
+
* place to correct when they move.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ It is a copy and it ages silently. Cloudflare changes context windows and prices without
|
|
26
|
+
* notice, and a wrong price in the interface is worse than none — re-read the endpoint above rather
|
|
27
|
+
* than editing a number from memory. Asking live from the browser is not the way out: the account
|
|
28
|
+
* token does not belong in a SPA.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ Workers AI models that do not carry `function_calling` are deliberately absent. An agent is a
|
|
31
|
+
* tool loop; a model that cannot call a tool cannot run one, and offering it would only produce a
|
|
32
|
+
* broken agent for whoever picked it.
|
|
33
|
+
*/
|
|
34
|
+
const catalog: Record<string, { name: string; contextTokens: number; price: ModelPrice }> = {
|
|
35
|
+
"anthropic:claude-opus-5": price("Opus 5", 1_000_000, 5, 25),
|
|
36
|
+
"anthropic:claude-sonnet-5": price("Sonnet 5", 1_000_000, 3, 15),
|
|
37
|
+
"anthropic:claude-sonnet-4": price("Sonnet 4", 200_000, 3, 15),
|
|
38
|
+
"anthropic:claude-haiku-4-5": price("Haiku 4.5", 200_000, 1, 5),
|
|
39
|
+
"workers-ai:@cf/openai/gpt-oss-120b": price("GPT-OSS 120B", 128_000, 0.35, 0.75),
|
|
40
|
+
"workers-ai:@cf/openai/gpt-oss-20b": price("GPT-OSS 20B", 128_000, 0.2, 0.3),
|
|
41
|
+
"workers-ai:@cf/moonshotai/kimi-k2.6": price("Kimi K2.6", 262_144, 0.95, 4),
|
|
42
|
+
"workers-ai:@cf/moonshotai/kimi-k2.7-code": price("Kimi K2.7 Code", 262_144, 0.95, 4),
|
|
43
|
+
"workers-ai:@cf/zai-org/glm-5.2": price("GLM 5.2", 262_144, 1.4, 4.4),
|
|
44
|
+
"workers-ai:@cf/zai-org/glm-4.7-flash": price("GLM 4.7 Flash", 131_072, 0.0605, 0.4),
|
|
45
|
+
"workers-ai:@cf/google/gemma-4-26b-a4b-it": price("Gemma 4 26B", 256_000, 0.1, 0.3),
|
|
46
|
+
"workers-ai:@cf/nvidia/nemotron-3-120b-a12b": price("Nemotron 3 120B", 256_000, 0.5, 1.5),
|
|
47
|
+
"workers-ai:@cf/meta/llama-4-scout-17b-16e-instruct": price("Llama 4 Scout", 131_000, 0.27, 0.85),
|
|
48
|
+
"workers-ai:@cf/meta/llama-3.3-70b-instruct-fp8-fast": price(
|
|
49
|
+
"Llama 3.3 70B Fast",
|
|
50
|
+
24_000,
|
|
51
|
+
0.293,
|
|
52
|
+
2.253,
|
|
53
|
+
),
|
|
54
|
+
"workers-ai:@cf/mistralai/mistral-small-3.1-24b-instruct": price(
|
|
55
|
+
"Small 3.1 24B",
|
|
56
|
+
128_000,
|
|
57
|
+
0.351,
|
|
58
|
+
0.555,
|
|
59
|
+
),
|
|
60
|
+
"workers-ai:@cf/ibm-granite/granite-4.0-h-micro": price(
|
|
61
|
+
"Granite 4.0 Micro",
|
|
62
|
+
131_000,
|
|
63
|
+
0.017,
|
|
64
|
+
0.112,
|
|
65
|
+
),
|
|
66
|
+
"workers-ai:@cf/qwen/qwen3-30b-a3b-fp8": price("Qwen3 30B", 32_768, 0.0509, 0.335),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function price(
|
|
70
|
+
name: string,
|
|
71
|
+
contextTokens: number,
|
|
72
|
+
inputPerMillion: number,
|
|
73
|
+
outputPerMillion: number,
|
|
74
|
+
): { name: string; contextTokens: number; price: ModelPrice } {
|
|
75
|
+
return { name, contextTokens, price: { inputPerMillion, outputPerMillion } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ⚠️ The brand comes from the model id, never from `provider`. `provider` says who serves the model,
|
|
80
|
+
* so every Workers AI entry would read "Cloudflare" — thirteen identical labels in a list whose whole
|
|
81
|
+
* job is to tell them apart. The name a reader looks for sits in the id, as its second segment:
|
|
82
|
+
* `@cf/openai/gpt-oss-120b` is OpenAI's.
|
|
83
|
+
*/
|
|
84
|
+
const brands: Record<string, string> = {
|
|
85
|
+
aisingapore: "AI Singapore",
|
|
86
|
+
"deepseek-ai": "DeepSeek",
|
|
87
|
+
google: "Google",
|
|
88
|
+
"ibm-granite": "IBM",
|
|
89
|
+
meta: "Meta",
|
|
90
|
+
"meta-llama": "Meta",
|
|
91
|
+
mistralai: "Mistral",
|
|
92
|
+
moonshotai: "Moonshot",
|
|
93
|
+
nvidia: "NVIDIA",
|
|
94
|
+
openai: "OpenAI",
|
|
95
|
+
qwen: "Qwen",
|
|
96
|
+
"zai-org": "Z.ai",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const firstFallback: AgentModel = { provider: "anthropic", model: "claude-sonnet-5" };
|
|
20
100
|
const fallback: [AgentModel, ...AgentModel[]] = [
|
|
21
101
|
firstFallback,
|
|
102
|
+
{ provider: "anthropic", model: "claude-opus-5" },
|
|
103
|
+
{ provider: "anthropic", model: "claude-haiku-4-5" },
|
|
104
|
+
{ provider: "anthropic", model: "claude-sonnet-4" },
|
|
105
|
+
{ provider: "workers-ai", model: "@cf/openai/gpt-oss-120b" },
|
|
106
|
+
{ provider: "workers-ai", model: "@cf/openai/gpt-oss-20b" },
|
|
107
|
+
{ provider: "workers-ai", model: "@cf/moonshotai/kimi-k2.6" },
|
|
108
|
+
{ provider: "workers-ai", model: "@cf/moonshotai/kimi-k2.7-code" },
|
|
109
|
+
{ provider: "workers-ai", model: "@cf/zai-org/glm-5.2" },
|
|
110
|
+
{ provider: "workers-ai", model: "@cf/zai-org/glm-4.7-flash" },
|
|
111
|
+
{ provider: "workers-ai", model: "@cf/google/gemma-4-26b-a4b-it" },
|
|
112
|
+
{ provider: "workers-ai", model: "@cf/nvidia/nemotron-3-120b-a12b" },
|
|
113
|
+
{ provider: "workers-ai", model: "@cf/meta/llama-4-scout-17b-16e-instruct" },
|
|
22
114
|
{ provider: "workers-ai", model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast" },
|
|
115
|
+
{ provider: "workers-ai", model: "@cf/mistralai/mistral-small-3.1-24b-instruct" },
|
|
116
|
+
{ provider: "workers-ai", model: "@cf/ibm-granite/granite-4.0-h-micro" },
|
|
117
|
+
{ provider: "workers-ai", model: "@cf/qwen/qwen3-30b-a3b-fp8" },
|
|
23
118
|
];
|
|
24
119
|
|
|
25
120
|
export function modelKey(model: AgentModel): string {
|
|
@@ -36,6 +131,50 @@ export function parseModelKey(key: string): AgentModel | null {
|
|
|
36
131
|
return parsed.success ? parsed.data : null;
|
|
37
132
|
}
|
|
38
133
|
|
|
134
|
+
// How a model is written for a person: brand first, then the model's own name, then the two numbers
|
|
135
|
+
// somebody actually chooses on. A model outside the catalog keeps its own id as the name and reports
|
|
136
|
+
// no figures at all.
|
|
137
|
+
export function modelFacts(model: AgentModel): ModelFacts {
|
|
138
|
+
const known = catalog[modelKey(model)];
|
|
139
|
+
if (known) {
|
|
140
|
+
return {
|
|
141
|
+
brand: brandOf(model),
|
|
142
|
+
name: known.name,
|
|
143
|
+
contextTokens: known.contextTokens,
|
|
144
|
+
price: known.price,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { brand: brandOf(model), name: unknownName(model), contextTokens: null, price: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function brandOf(model: AgentModel): string {
|
|
151
|
+
if (model.provider === "anthropic") return "Claude";
|
|
152
|
+
const vendor = model.model.split("/")[1];
|
|
153
|
+
if (!vendor) return "Workers AI";
|
|
154
|
+
return brands[vendor] ?? vendor;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The id itself, not a prettified guess. "claude-opus-4-1" would become "Opus 4 1" under any rule
|
|
158
|
+
// simple enough to write here, and a wrong name is harder to recognise than a raw one.
|
|
159
|
+
function unknownName(model: AgentModel): string {
|
|
160
|
+
if (model.provider === "anthropic") return model.model.replace(/^claude-/, "");
|
|
161
|
+
const segments = model.model.split("/");
|
|
162
|
+
return segments[segments.length - 1] ?? model.model;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function formatContextTokens(tokens: number): string {
|
|
166
|
+
if (tokens >= 1_000_000) return `${Math.round(tokens / 1_000_000)}M`;
|
|
167
|
+
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
|
168
|
+
return String(tokens);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ⚠️ Two decimals at least, three at most, and always per one million tokens. Granite costs $0.017
|
|
172
|
+
// per million in: rounded to two decimals it reads $0.02, which is the same price as a model that
|
|
173
|
+
// costs seventeen percent more.
|
|
174
|
+
export function formatPricePerMillion(dollars: number, locale: string): string {
|
|
175
|
+
return dollars.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 3 });
|
|
176
|
+
}
|
|
177
|
+
|
|
39
178
|
export function configuredModels(): AgentModel[] {
|
|
40
179
|
const raw = import.meta.env.VITE_AGENT_MODELS;
|
|
41
180
|
if (!raw) return fallback;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** What a reader needs in order to pick a model: whose it is, what it is called, and what it costs. */
|
|
2
|
+
export interface ModelFacts {
|
|
3
|
+
brand: string;
|
|
4
|
+
name: string;
|
|
5
|
+
/**
|
|
6
|
+
* ⚠️ Both are null for a model this installation has no figures for, and the display then shows
|
|
7
|
+
* nothing rather than a zero. A definition is written by MCP and by other installations too, so an
|
|
8
|
+
* unknown model is normal — and an invented "0" would be a false statement about money.
|
|
9
|
+
*/
|
|
10
|
+
contextTokens: number | null;
|
|
11
|
+
price: ModelPrice | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Always US dollars per one million tokens — never per 1k, never a single blended number. */
|
|
15
|
+
export interface ModelPrice {
|
|
16
|
+
inputPerMillion: number;
|
|
17
|
+
outputPerMillion: number;
|
|
18
|
+
}
|
|
@@ -5,12 +5,20 @@ import {
|
|
|
5
5
|
type AgentSchedule,
|
|
6
6
|
type Node,
|
|
7
7
|
} from "@anchrd/intel-contract";
|
|
8
|
-
import { useQuery } from "@tanstack/react-query";
|
|
9
|
-
import { Check, Copy, Info, MessageSquare, Plus, Trash2, Wrench } from "lucide-react";
|
|
8
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
|
+
import { Check, Copy, Info, KeyRound, MessageSquare, Plus, Trash2, Wrench } from "lucide-react";
|
|
10
10
|
import { useState } from "react";
|
|
11
|
-
import type
|
|
11
|
+
import { type AgentDefinitionHandle, asDraft } from "@/agent/agent-definition/agent-definition.ts";
|
|
12
12
|
import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
formatContextTokens,
|
|
15
|
+
formatPricePerMillion,
|
|
16
|
+
modelFacts,
|
|
17
|
+
modelKey,
|
|
18
|
+
parseModelKey,
|
|
19
|
+
selectableModels,
|
|
20
|
+
} from "@/agent/agent-models/agent-models.ts";
|
|
21
|
+
import type { ModelFacts } from "@/agent/agent-models/agent-models.types.ts";
|
|
14
22
|
import {
|
|
15
23
|
Select,
|
|
16
24
|
SelectContent,
|
|
@@ -69,12 +77,17 @@ function SectionHint({ hint }: { hint: string }) {
|
|
|
69
77
|
|
|
70
78
|
/**
|
|
71
79
|
* Everything an agent is, on one page: how to reach it, what it reads, what it may call, when it
|
|
72
|
-
* acts on its own,
|
|
80
|
+
* acts on its own, which model does the thinking, and who it is in Gate.
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ Four of the six sections write, and they all write the SAME way — a whole new definition
|
|
83
|
+
* version through `save` (see `agent-definition.ts`). Tools joined them with D30: what it writes is
|
|
84
|
+
* a selection of whole MCP servers out of the signed-in person's own portal connection, never a
|
|
85
|
+
* mirrored permission, so ADR-0003 still holds — the catalog behind a chosen server stays a live
|
|
86
|
+
* `tools/list`.
|
|
73
87
|
*
|
|
74
|
-
* ⚠️
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* would be a promise this product is not allowed to keep (ADR-0003).
|
|
88
|
+
* ⚠️ Contact and Identity are the two that do not. Identity acts in Gate and in the agent runtime
|
|
89
|
+
* rather than on the document, and a key has no business being in a definition — a definition is
|
|
90
|
+
* versioned, shared and read into model context (ADR-0005 §4).
|
|
78
91
|
*/
|
|
79
92
|
export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
|
|
80
93
|
const { i18n } = useIntelRouterContext();
|
|
@@ -113,13 +126,97 @@ export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefiniti
|
|
|
113
126
|
) : null}
|
|
114
127
|
<ContactSection node={node} />
|
|
115
128
|
<KnowledgeSection definition={definition} agent={agent} />
|
|
116
|
-
<ToolsSection />
|
|
129
|
+
<ToolsSection definition={definition} agent={agent} />
|
|
117
130
|
<ScheduleSection definition={definition} agent={agent} />
|
|
118
131
|
<ModelSection definition={definition} agent={agent} />
|
|
132
|
+
<IdentitySection node={node} agent={agent} />
|
|
119
133
|
</div>
|
|
120
134
|
);
|
|
121
135
|
}
|
|
122
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Who the agent IS to the rest of the installation, and the one button that repairs it (D29, #207).
|
|
139
|
+
*
|
|
140
|
+
* ⚠️ The application ID is shown and the key is not, because only one of the two is a name. The key
|
|
141
|
+
* exists in Gate for the length of one call and goes straight into the agent runtime; there is
|
|
142
|
+
* nothing for this page to reveal, copy or store, and that is what makes an agent created here able
|
|
143
|
+
* to run without anybody opening a terminal (#200).
|
|
144
|
+
*
|
|
145
|
+
* ⚠️ Last on the page on purpose. It is the section a reader needs on the day something is wrong,
|
|
146
|
+
* not while they are describing what the agent should do.
|
|
147
|
+
*/
|
|
148
|
+
function IdentitySection({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
|
|
149
|
+
const { data, i18n } = useIntelRouterContext();
|
|
150
|
+
const queryClient = useQueryClient();
|
|
151
|
+
const [confirming, setConfirming] = useState(false);
|
|
152
|
+
const applicationId = agent.query.data?.applicationId ?? null;
|
|
153
|
+
|
|
154
|
+
const rotate = useMutation({
|
|
155
|
+
mutationFn: async () => await data.rotateAgentKey(node.id),
|
|
156
|
+
onSuccess: () => {
|
|
157
|
+
setConfirming(false);
|
|
158
|
+
// The run that failed with `agent_key_missing` is the reason somebody pressed this, so the
|
|
159
|
+
// page's own state is re-read rather than left showing the refusal that sent them here.
|
|
160
|
+
void queryClient.invalidateQueries({ queryKey: ["agent-state", node.id] });
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<Section title={i18n.t("agent.identity")} hint={i18n.t("agent.identityHint")}>
|
|
166
|
+
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
|
167
|
+
<KeyRound aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
168
|
+
<span className="min-w-0 flex-1">
|
|
169
|
+
<span className="block text-sm font-medium">{i18n.t("agent.principal")}</span>
|
|
170
|
+
<code className="mt-0.5 block truncate text-xs text-muted-foreground">
|
|
171
|
+
{applicationId ?? i18n.t("agent.principalNone")}
|
|
172
|
+
</code>
|
|
173
|
+
</span>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
onClick={() => setConfirming(true)}
|
|
177
|
+
disabled={applicationId === null || rotate.isPending}
|
|
178
|
+
aria-busy={rotate.isPending}
|
|
179
|
+
{...(applicationId === null ? { "aria-describedby": "agent-rotate-reason" } : {})}
|
|
180
|
+
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
181
|
+
>
|
|
182
|
+
{i18n.t("agent.rotateKey")}
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
{applicationId === null ? (
|
|
186
|
+
<p id="agent-rotate-reason" className="mt-3 text-xs text-muted-foreground">
|
|
187
|
+
{i18n.t("agent.principalNoneHint")}
|
|
188
|
+
</p>
|
|
189
|
+
) : null}
|
|
190
|
+
{rotate.isError ? (
|
|
191
|
+
<p role="alert" className="mt-3 text-xs text-destructive">
|
|
192
|
+
{i18n.t("agent.rotateKeyFailed")}
|
|
193
|
+
</p>
|
|
194
|
+
) : null}
|
|
195
|
+
{rotate.isSuccess ? (
|
|
196
|
+
<p role="status" className="mt-3 text-xs text-muted-foreground">
|
|
197
|
+
{i18n.t("agent.rotateKeyDone")}
|
|
198
|
+
</p>
|
|
199
|
+
) : null}
|
|
200
|
+
{confirming ? (
|
|
201
|
+
<Modal title={i18n.t("agent.rotateKey")} close={() => setConfirming(false)}>
|
|
202
|
+
{/* ⚠️ Confirmed rather than done on the first click. The previous key stops working the
|
|
203
|
+
moment this runs, so a misclick takes a working agent down until the call finishes. */}
|
|
204
|
+
<p className="text-sm text-muted-foreground">{i18n.t("agent.rotateKeyConfirm")}</p>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={() => rotate.mutate()}
|
|
208
|
+
disabled={rotate.isPending}
|
|
209
|
+
aria-busy={rotate.isPending}
|
|
210
|
+
className="mt-4 w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
211
|
+
>
|
|
212
|
+
{i18n.t("agent.rotateKey")}
|
|
213
|
+
</button>
|
|
214
|
+
</Modal>
|
|
215
|
+
) : null}
|
|
216
|
+
</Section>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
123
220
|
/**
|
|
124
221
|
* How to reach this agent.
|
|
125
222
|
*
|
|
@@ -228,7 +325,7 @@ function KnowledgeSection({
|
|
|
228
325
|
const [adding, setAdding] = useState(false);
|
|
229
326
|
|
|
230
327
|
function write(references: AgentReference[]) {
|
|
231
|
-
agent.save.mutate({ ...definition, references });
|
|
328
|
+
agent.save.mutate({ ...asDraft(definition), references });
|
|
232
329
|
}
|
|
233
330
|
|
|
234
331
|
return (
|
|
@@ -348,40 +445,130 @@ function EntryTitle({ entryId, flow = false }: { entryId: string; flow?: boolean
|
|
|
348
445
|
}
|
|
349
446
|
|
|
350
447
|
/**
|
|
351
|
-
* What the agent may call
|
|
448
|
+
* What the agent may call: whole MCP servers, given from the signed-in person's own portal
|
|
449
|
+
* connection (D30).
|
|
450
|
+
*
|
|
451
|
+
* ⚠️ What is saved is a SELECTION, never a catalog. The tools behind a server stay a live
|
|
452
|
+
* `tools/list` made with the delegator's token at the moment the agent runs (ADR-0003), so a server
|
|
453
|
+
* listed here is a name, not a promise — losing it in the portal takes it away from the agent with
|
|
454
|
+
* nothing to edit.
|
|
352
455
|
*
|
|
353
|
-
* ⚠️
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
456
|
+
* ⚠️ The notice is not decoration, and it stands whether or not anything is listed yet. A shared
|
|
457
|
+
* agent runs on the DELEGATOR's connection whoever starts it, which is a real widening of who can
|
|
458
|
+
* act as this person — so it belongs where somebody reads it BEFORE clicking `+ Add tool`, not as a
|
|
459
|
+
* footnote that appears once the first server is already given away. The empty list itself stays
|
|
460
|
+
* wordless, the way the other sections' do (#204): the button says what belongs here.
|
|
357
461
|
*/
|
|
358
|
-
function ToolsSection(
|
|
462
|
+
function ToolsSection({
|
|
463
|
+
definition,
|
|
464
|
+
agent,
|
|
465
|
+
}: {
|
|
466
|
+
definition: AgentDefinition;
|
|
467
|
+
agent: AgentDefinitionHandle;
|
|
468
|
+
}) {
|
|
359
469
|
const { data, i18n } = useIntelRouterContext();
|
|
360
|
-
const
|
|
470
|
+
const [adding, setAdding] = useState(false);
|
|
471
|
+
const servers = useQuery({ queryKey: ["tool-servers"], queryFn: () => data.listToolServers() });
|
|
472
|
+
const selected = definition.tools?.servers ?? [];
|
|
473
|
+
|
|
474
|
+
// ⚠️ Servers, and nothing about who delegates them. The screen has no `delegatedBy` to send and
|
|
475
|
+
// no business inventing one — Intel writes it from the session, and `AgentDefinitionInput` has no
|
|
476
|
+
// field it could travel in. This is also what lets an agent with `tools: null`, which is every
|
|
477
|
+
// freshly created one, receive its first server at all.
|
|
478
|
+
function write(next: string[]) {
|
|
479
|
+
agent.save.mutate({ ...asDraft(definition), tools: { servers: next } });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const available = (servers.data?.items ?? []).filter(
|
|
483
|
+
(server) => !selected.includes(server.handle),
|
|
484
|
+
);
|
|
361
485
|
|
|
362
486
|
return (
|
|
363
487
|
<Section title={i18n.t("agent.tools")} hint={i18n.t("agent.toolsHint")}>
|
|
364
|
-
{
|
|
365
|
-
<
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
488
|
+
{selected.length === 0 ? null : (
|
|
489
|
+
<ul className="space-y-2">
|
|
490
|
+
{selected.map((handle) => {
|
|
491
|
+
const known = servers.data?.items.find((server) => server.handle === handle);
|
|
492
|
+
return (
|
|
493
|
+
<li
|
|
494
|
+
key={handle}
|
|
495
|
+
className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
|
|
496
|
+
>
|
|
497
|
+
<Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
498
|
+
<span
|
|
499
|
+
className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}
|
|
500
|
+
>
|
|
501
|
+
{known?.name ?? handle}
|
|
502
|
+
</span>
|
|
503
|
+
{/* A server the signed-in person no longer reaches still stands in the definition,
|
|
504
|
+
and saying so beats drawing it as if it worked. The agent gets nothing from it
|
|
505
|
+
either — the catalog is cut against what the delegator reaches. */}
|
|
506
|
+
<span className="text-xs text-muted-foreground">
|
|
507
|
+
{known
|
|
508
|
+
? i18n.t("tools.toolCount", { count: String(known.toolCount) })
|
|
509
|
+
: i18n.t("agent.toolServerUnavailable")}
|
|
510
|
+
</span>
|
|
511
|
+
<button
|
|
512
|
+
type="button"
|
|
513
|
+
onClick={() => write(selected.filter((current) => current !== handle))}
|
|
514
|
+
aria-label={i18n.t("agent.removeToolServerOf", { name: known?.name ?? handle })}
|
|
515
|
+
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
516
|
+
>
|
|
517
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
518
|
+
</button>
|
|
519
|
+
</li>
|
|
520
|
+
);
|
|
521
|
+
})}
|
|
383
522
|
</ul>
|
|
384
523
|
)}
|
|
524
|
+
<button
|
|
525
|
+
type="button"
|
|
526
|
+
onClick={() => setAdding(true)}
|
|
527
|
+
className="mt-3 inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
528
|
+
>
|
|
529
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
530
|
+
{i18n.t("agent.addTool")}
|
|
531
|
+
</button>
|
|
532
|
+
<p className="mt-3 text-xs text-muted-foreground">{i18n.t("agent.toolsDelegationNotice")}</p>
|
|
533
|
+
{adding ? (
|
|
534
|
+
<Modal title={i18n.t("agent.addTool")} close={() => setAdding(false)}>
|
|
535
|
+
{servers.isPending ? (
|
|
536
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
537
|
+
) : servers.isError ? (
|
|
538
|
+
<p role="alert" className="text-sm text-destructive">
|
|
539
|
+
{i18n.t("tools.unreachableHelp")}
|
|
540
|
+
</p>
|
|
541
|
+
) : servers.data?.portalConnected === false ? (
|
|
542
|
+
<p className="text-sm text-muted-foreground">{i18n.t("agent.toolsNotConnected")}</p>
|
|
543
|
+
) : available.length === 0 ? (
|
|
544
|
+
<p className="text-sm text-muted-foreground">{i18n.t("agent.toolsNoneLeft")}</p>
|
|
545
|
+
) : (
|
|
546
|
+
<ul className="space-y-2">
|
|
547
|
+
{available.map((server) => (
|
|
548
|
+
<li key={server.handle}>
|
|
549
|
+
<button
|
|
550
|
+
type="button"
|
|
551
|
+
onClick={() => {
|
|
552
|
+
write([...selected, server.handle]);
|
|
553
|
+
setAdding(false);
|
|
554
|
+
}}
|
|
555
|
+
className="flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
556
|
+
>
|
|
557
|
+
<Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
558
|
+
<span className="min-w-0 flex-1 truncate text-sm">{server.name}</span>
|
|
559
|
+
<span className="text-xs text-muted-foreground">
|
|
560
|
+
{i18n.t("tools.toolCount", { count: String(server.toolCount) })}
|
|
561
|
+
</span>
|
|
562
|
+
</button>
|
|
563
|
+
</li>
|
|
564
|
+
))}
|
|
565
|
+
</ul>
|
|
566
|
+
)}
|
|
567
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
568
|
+
{i18n.t("agent.toolsDelegationNotice")}
|
|
569
|
+
</p>
|
|
570
|
+
</Modal>
|
|
571
|
+
) : null}
|
|
385
572
|
</Section>
|
|
386
573
|
);
|
|
387
574
|
}
|
|
@@ -404,7 +591,7 @@ function ScheduleSection({
|
|
|
404
591
|
const [adding, setAdding] = useState(false);
|
|
405
592
|
|
|
406
593
|
function write(schedules: AgentSchedule[]) {
|
|
407
|
-
agent.save.mutate({ ...definition, schedules });
|
|
594
|
+
agent.save.mutate({ ...asDraft(definition), schedules });
|
|
408
595
|
}
|
|
409
596
|
|
|
410
597
|
return (
|
|
@@ -520,28 +707,69 @@ function ModelSection({
|
|
|
520
707
|
}) {
|
|
521
708
|
const { i18n } = useIntelRouterContext();
|
|
522
709
|
const models = selectableModels(definition.model);
|
|
710
|
+
const current = modelFacts(definition.model);
|
|
523
711
|
|
|
524
712
|
return (
|
|
525
713
|
<Section title={i18n.t("agent.model")} hint={i18n.t("agent.modelHint")}>
|
|
526
|
-
<
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
<
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
714
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
715
|
+
<Select
|
|
716
|
+
value={modelKey(definition.model)}
|
|
717
|
+
onValueChange={(key) => {
|
|
718
|
+
const model = parseModelKey(key);
|
|
719
|
+
if (model) agent.save.mutate({ ...asDraft(definition), model });
|
|
720
|
+
}}
|
|
721
|
+
>
|
|
722
|
+
<SelectTrigger aria-label={i18n.t("agent.model")} className="w-full max-w-md sm:w-72">
|
|
723
|
+
{/* ⚠️ Own children rather than the default mirror of the chosen item: the item carries
|
|
724
|
+
the figures too, and mirrored into the trigger they would stand twice in one row. */}
|
|
725
|
+
<SelectValue>
|
|
726
|
+
<ModelName facts={current} />
|
|
727
|
+
</SelectValue>
|
|
728
|
+
</SelectTrigger>
|
|
729
|
+
<SelectContent>
|
|
730
|
+
{models.map((model) => {
|
|
731
|
+
const facts = modelFacts(model);
|
|
732
|
+
return (
|
|
733
|
+
<SelectItem key={modelKey(model)} value={modelKey(model)}>
|
|
734
|
+
{/* ⚠️ The space is not formatting. The option's accessible name is its text run
|
|
735
|
+
together, so without it a screen reader reads "…70B Fast24k context". The eye
|
|
736
|
+
never notices, because the row is a flex box with a gap. */}
|
|
737
|
+
<ModelName facts={facts} /> <ModelFigures facts={facts} />
|
|
738
|
+
</SelectItem>
|
|
739
|
+
);
|
|
740
|
+
})}
|
|
741
|
+
</SelectContent>
|
|
742
|
+
</Select>
|
|
743
|
+
<ModelFigures facts={current} />
|
|
744
|
+
</div>
|
|
545
745
|
</Section>
|
|
546
746
|
);
|
|
547
747
|
}
|
|
748
|
+
|
|
749
|
+
// Brand first, then the model's own name — "Claude Sonnet 5", not "claude-sonnet-5 · anthropic". The
|
|
750
|
+
// provider stays in the definition; it names who serves the model, which is not what anybody reads a
|
|
751
|
+
// list of models for.
|
|
752
|
+
function ModelName({ facts }: { facts: ModelFacts }) {
|
|
753
|
+
return (
|
|
754
|
+
<span className="truncate">
|
|
755
|
+
<span className="font-medium">{facts.brand}</span> {facts.name}
|
|
756
|
+
</span>
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// The two numbers a model is actually chosen on. Absent for a model this installation has no figures
|
|
761
|
+
// for — the row then simply ends, because a made-up zero would be a false statement about money.
|
|
762
|
+
function ModelFigures({ facts }: { facts: ModelFacts }) {
|
|
763
|
+
const { i18n } = useIntelRouterContext();
|
|
764
|
+
if (facts.contextTokens === null || facts.price === null) return null;
|
|
765
|
+
|
|
766
|
+
return (
|
|
767
|
+
<span className="text-xs whitespace-nowrap text-muted-foreground">
|
|
768
|
+
{i18n.t("agent.modelFigures", {
|
|
769
|
+
context: formatContextTokens(facts.contextTokens),
|
|
770
|
+
input: formatPricePerMillion(facts.price.inputPerMillion, i18n.locale),
|
|
771
|
+
output: formatPricePerMillion(facts.price.outputPerMillion, i18n.locale),
|
|
772
|
+
})}
|
|
773
|
+
</span>
|
|
774
|
+
);
|
|
775
|
+
}
|
|
@@ -46,3 +46,23 @@ export function agentAccessDenied(error: unknown): boolean {
|
|
|
46
46
|
export function agentRuntimeMissing(error: unknown): boolean {
|
|
47
47
|
return error instanceof IntelRequestError && error.code === "agent_runtime_not_configured";
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The failure that has a repair path, and a name for it (D29, #207, and the short half of #200).
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ The code, not the 500. An agent whose Durable Object holds no key starts no run at all — the
|
|
54
|
+
* runtime refuses before Gate is asked — and until #207 that arrived on this page as
|
|
55
|
+
* `That did not work. Try again.` next to `Run now`, which is advice that cannot work: trying again
|
|
56
|
+
* produces the same refusal forever. Naming it is what lets the page send the reader to the one
|
|
57
|
+
* thing that fixes it, which is replacing the key in the agent's profile.
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ `agent_key_unreadable` counts as the same case on purpose. Its cause is different — the
|
|
60
|
+
* deployment's master secret was replaced — but the reader's move is identical, and a second
|
|
61
|
+
* sentence saying the same thing differently would only be a second thing to keep in step.
|
|
62
|
+
*/
|
|
63
|
+
export function agentKeyMissing(error: unknown): boolean {
|
|
64
|
+
return (
|
|
65
|
+
error instanceof IntelRequestError &&
|
|
66
|
+
(error.code === "agent_key_missing" || error.code === "agent_key_unreadable")
|
|
67
|
+
);
|
|
68
|
+
}
|
package/src/agent/agent.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { AgentLog } from "@/agent/agent-log/agent-log.tsx";
|
|
|
10
10
|
import { AgentProfile } from "@/agent/agent-profile/agent-profile.tsx";
|
|
11
11
|
import {
|
|
12
12
|
agentAccessDenied,
|
|
13
|
+
agentKeyMissing,
|
|
13
14
|
agentRuntimeMissing,
|
|
14
15
|
agentStateKey,
|
|
15
16
|
useAgentState,
|
|
@@ -260,9 +261,12 @@ export function AgentActions({
|
|
|
260
261
|
{runReason}
|
|
261
262
|
</span>
|
|
262
263
|
) : null}
|
|
264
|
+
{/* ⚠️ An agent with no key in its Durable Object fails every run the same way forever, so
|
|
265
|
+
"try again" is advice that cannot work (#200). The one refusal with a repair path says
|
|
266
|
+
what the repair is, and names where it lives — the profile, not this bar (D29). */}
|
|
263
267
|
{failure ? (
|
|
264
268
|
<span role="alert" className="text-xs text-destructive max-sm:sr-only sm:inline">
|
|
265
|
-
{i18n.t("agent.actionFailed")}
|
|
269
|
+
{i18n.t(agentKeyMissing(failure) ? "agent.keyMissing" : "agent.actionFailed")}
|
|
266
270
|
</span>
|
|
267
271
|
) : null}
|
|
268
272
|
<button
|
|
@@ -213,7 +213,9 @@ export function AppTree() {
|
|
|
213
213
|
parentId,
|
|
214
214
|
title,
|
|
215
215
|
description: null,
|
|
216
|
-
|
|
216
|
+
// A new agent has no tools: giving it any is a choice made in the profile, out of what
|
|
217
|
+
// this person reaches in the portal (D30).
|
|
218
|
+
definition: { references: [], schedules: [], model: defaultModel(), tools: null },
|
|
217
219
|
idempotencyKey: crypto.randomUUID(),
|
|
218
220
|
});
|
|
219
221
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AgentKeyRotated,
|
|
2
3
|
AppendTableRowsInput,
|
|
3
4
|
AppendTableRowsResult,
|
|
4
5
|
ArchiveFlowInput,
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
ShareResult,
|
|
53
54
|
StartFlowRunInput,
|
|
54
55
|
ToolCatalog,
|
|
56
|
+
ToolServerCatalog,
|
|
55
57
|
UpdateFlowInput,
|
|
56
58
|
UpdateNodeInput,
|
|
57
59
|
} from "@anchrd/intel-contract";
|
|
@@ -461,18 +463,38 @@ export function createIntelDataProvider(
|
|
|
461
463
|
async listTools() {
|
|
462
464
|
return await request("/tools", ToolCatalog);
|
|
463
465
|
},
|
|
466
|
+
// The servers behind the same live list (D30). Its own call rather than a grouping done in the
|
|
467
|
+
// browser: which server a tool belongs to is the portal's answer, and a UI that split tool
|
|
468
|
+
// names would be guessing where the namespace ends.
|
|
469
|
+
async listToolServers() {
|
|
470
|
+
return await request("/tools/servers", ToolServerCatalog);
|
|
471
|
+
},
|
|
464
472
|
async getAgent(nodeId) {
|
|
465
473
|
return await request(`/nodes/${encodeURIComponent(nodeId)}/agent`, NodeAgent);
|
|
466
474
|
},
|
|
467
|
-
// ⚠️ `CreatedAgent
|
|
468
|
-
//
|
|
469
|
-
//
|
|
475
|
+
// ⚠️ `CreatedAgent` is `NodeAgent` since D29, and that is the point rather than a leftover: the
|
|
476
|
+
// create answer used to carry the Gate application key once (#182), and the browser had to be
|
|
477
|
+
// given a credential it could not do anything with. It now goes from Gate into the agent runtime
|
|
478
|
+
// inside the request, so the create answers exactly what a read answers.
|
|
470
479
|
async createAgent(input) {
|
|
471
480
|
return await request("/nodes/agents", CreatedAgent, {
|
|
472
481
|
method: "POST",
|
|
473
482
|
body: JSON.stringify(CreateAgentInput.parse(input)),
|
|
474
483
|
});
|
|
475
484
|
},
|
|
485
|
+
/**
|
|
486
|
+
* Replace the key of the Gate Application an agent runs as (D29, #207).
|
|
487
|
+
*
|
|
488
|
+
* ⚠️ The repair path for `agent_key_missing`, and the answer carries no key — intel asks Gate
|
|
489
|
+
* and hands the new one to the runtime itself. Nothing here has a credential to hold.
|
|
490
|
+
*/
|
|
491
|
+
async rotateAgentKey(nodeId) {
|
|
492
|
+
return await request(
|
|
493
|
+
`/nodes/agents/${encodeURIComponent(nodeId)}/rotate-key`,
|
|
494
|
+
AgentKeyRotated,
|
|
495
|
+
{ method: "POST" },
|
|
496
|
+
);
|
|
497
|
+
},
|
|
476
498
|
async saveAgentDefinition(input) {
|
|
477
499
|
const parsed = SaveAgentDefinitionInput.parse(input);
|
|
478
500
|
return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}/agent`, NodeAgent, {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AgentKeyRotated,
|
|
2
3
|
AgentScheduleTarget,
|
|
3
4
|
AppendTableRowsInput,
|
|
4
5
|
AppendTableRowsResult,
|
|
@@ -53,6 +54,7 @@ import type {
|
|
|
53
54
|
ShareResult,
|
|
54
55
|
StartFlowRunInput,
|
|
55
56
|
ToolCatalog,
|
|
57
|
+
ToolServerCatalog,
|
|
56
58
|
UpdateFlowInput,
|
|
57
59
|
UpdateNodeInput,
|
|
58
60
|
} from "@anchrd/intel-contract";
|
|
@@ -164,6 +166,7 @@ export interface IntelDataProvider {
|
|
|
164
166
|
// Reading the catalog is the whole of the tool surface here: calling a tool belongs to a flow
|
|
165
167
|
// or to the Intel MCP surface, not to the screen that shows what the portal offers.
|
|
166
168
|
listTools(): Promise<ToolCatalog>;
|
|
169
|
+
listToolServers(): Promise<ToolServerCatalog>;
|
|
167
170
|
|
|
168
171
|
// An agent node and the definition its current version carries. `definition` is `null` in the one
|
|
169
172
|
// window where the node exists and no version has been written — the same window a document's
|
|
@@ -177,6 +180,10 @@ export interface IntelDataProvider {
|
|
|
177
180
|
// is edited in place (ADR-0005). `baseVersionId` is the version the editor read, so a parallel
|
|
178
181
|
// change is refused rather than silently overwritten.
|
|
179
182
|
saveAgentDefinition(input: SaveAgentDefinitionInput): Promise<NodeAgent>;
|
|
183
|
+
// Replace the key of the Gate Application an agent runs as (D29). The repair path an agent that
|
|
184
|
+
// answers `agent_key_missing` has, and the only one — Gate keeps a key hashed. ⚠️ No key comes
|
|
185
|
+
// back: intel hands the new one to the agent runtime itself.
|
|
186
|
+
rotateAgentKey(nodeId: string): Promise<AgentKeyRotated>;
|
|
180
187
|
|
|
181
188
|
// ⚠️ The seven below are answered by the agent RUNTIME, a separate Worker — but they are asked of
|
|
182
189
|
// intel, which proxies them with the caller's own Gate token (#178). One origin, one cookie, no
|
package/src/i18n/en.json
CHANGED
|
@@ -170,7 +170,10 @@
|
|
|
170
170
|
"tools.connectFailed": "Portal sign-in failed",
|
|
171
171
|
"tools.connectFailedHelp": "The sign-in to the company MCP portal broke before it could be answered. This is a technical fault, not a decision about your access — reload to try again, and tell an administrator if it stays.",
|
|
172
172
|
"tools.origin": "Origin",
|
|
173
|
-
"tools.
|
|
173
|
+
"tools.originUnlisted": "Not from a listed server",
|
|
174
|
+
"tools.originUnlistedHelp": "The portal's server list names no server for these. Its own management tools sit here — the portal does not list itself — and so does anything from a server the list did not mention.",
|
|
175
|
+
"tools.ungrouped": "Not sorted by server",
|
|
176
|
+
"tools.ungroupedHelp": "The company MCP portal did not answer which servers these tools come from, so Intel cannot say which tool belongs where. Everything you can reach is listed below; only the grouping is missing.",
|
|
174
177
|
"tools.inputSchema": "Input schema",
|
|
175
178
|
"tools.outputSchema": "Result schema",
|
|
176
179
|
"tools.destructiveShort": "Destructive",
|
|
@@ -292,11 +295,21 @@
|
|
|
292
295
|
"agent.runNowNoTarget": "This agent has no schedule, so there is nothing to run.",
|
|
293
296
|
"agent.runNowPaused": "Resume the agent before running it.",
|
|
294
297
|
"agent.actionFailed": "That did not work. Try again.",
|
|
298
|
+
"agent.keyMissing": "This agent has no application key, so no run can start. Replace its key under Identity in the profile.",
|
|
295
299
|
"agent.notPermitted": "You may read this agent, but not drive it. Driving an agent needs the “run agents” permission.",
|
|
296
300
|
"agent.noRuntime": "This installation runs no agent runtime, so this agent cannot chat or run here. Its definition is kept and works again on an installation that has one.",
|
|
297
301
|
"agent.loadFailed": "This agent could not be loaded. Check your access and try again.",
|
|
298
302
|
"agent.saveFailed": "The change was not saved. Somebody else may have changed this agent first — reload and try again.",
|
|
299
303
|
"agent.noDefinition": "This agent has no definition yet, so there is nothing to change here.",
|
|
304
|
+
"agent.identity": "Identity",
|
|
305
|
+
"agent.identityHint": "The Gate application this agent acts as. Its grants in the tree decide what a run may read and write, and its key lives encrypted in the agent runtime — never here and never in the definition.",
|
|
306
|
+
"agent.principal": "Gate application",
|
|
307
|
+
"agent.principalNone": "None",
|
|
308
|
+
"agent.principalNoneHint": "This agent has no Gate application, so there is no key to replace. Agents created before this feature, imported, or restored from a bundle are given one in Gate.",
|
|
309
|
+
"agent.rotateKey": "Replace key",
|
|
310
|
+
"agent.rotateKeyConfirm": "A new key is issued and given to the agent runtime. The previous key stops working immediately, and no key is ever shown here.",
|
|
311
|
+
"agent.rotateKeyDone": "The key was replaced. The agent runs with the new one from its next run.",
|
|
312
|
+
"agent.rotateKeyFailed": "The key was not replaced. Try again — and keep trying until it succeeds: if Gate already issued the new key, this agent cannot run until the agent runtime has it.",
|
|
300
313
|
"agent.contact": "Contact",
|
|
301
314
|
"agent.contactHint": "How this agent is reached. The built-in chat is part of the runtime and is always there.",
|
|
302
315
|
"agent.contactChat": "Built-in chat",
|
|
@@ -317,7 +330,13 @@
|
|
|
317
330
|
"agent.role.memory": "Memory",
|
|
318
331
|
"agent.unknownEntry": "Not available to you",
|
|
319
332
|
"agent.tools": "Tools",
|
|
320
|
-
"agent.toolsHint": "
|
|
333
|
+
"agent.toolsHint": "The MCP servers you give this agent, out of the ones you reach yourself. Intel stores the choice, never the permission — the tools behind a server are read live with your portal access every time the agent runs.",
|
|
334
|
+
"agent.addTool": "Add tool",
|
|
335
|
+
"agent.removeToolServerOf": "Take {name} away from this agent",
|
|
336
|
+
"agent.toolServerUnavailable": "You no longer reach this server",
|
|
337
|
+
"agent.toolsDelegationNotice": "Anyone who may run this agent acts on your connection.",
|
|
338
|
+
"agent.toolsNotConnected": "You are not signed in to the company portal yet, so there is nothing to give away. Open Tools to connect.",
|
|
339
|
+
"agent.toolsNoneLeft": "Every server you reach is already given to this agent.",
|
|
321
340
|
"agent.schedule": "Schedule",
|
|
322
341
|
"agent.scheduleHint": "When this agent acts on its own, and what it runs. Times are UTC — a definition carries no timezone.",
|
|
323
342
|
"agent.scheduleEmpty": "This agent runs only when it is asked.",
|
|
@@ -328,6 +347,7 @@
|
|
|
328
347
|
"agent.cronHint": "Five fields, in UTC: minute, hour, day of month, month, day of week. “0 8 * * *” is every day at 08:00.",
|
|
329
348
|
"agent.model": "Model",
|
|
330
349
|
"agent.modelHint": "Which model does the thinking. A change is picked up by the runtime within a minute.",
|
|
350
|
+
"agent.modelFigures": "{context} context · ${input} in / ${output} out per 1M tokens",
|
|
331
351
|
"agent.calendar": "Upcoming runs",
|
|
332
352
|
"agent.calendarUtc": "All times are UTC, because that is when the agent runs.",
|
|
333
353
|
"agent.calendarUnreadable": "None of this agent's schedules can be read as a cron expression, so nothing is planned.",
|
package/src/tools/tools.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { serverOf, type ToolCapability, type ToolServer } from "@anchrd/intel-contract";
|
|
2
2
|
import { useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { useRouterState } from "@tanstack/react-router";
|
|
4
4
|
import { AlertTriangle, ChevronRight, PlugZap, ShieldOff, Timer, Wrench } from "lucide-react";
|
|
@@ -9,38 +9,55 @@ import type { I18n } from "@/i18n/i18n.types.ts";
|
|
|
9
9
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
10
10
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
11
11
|
|
|
12
|
-
// The portal namespaces every tool as `<server>__<tool>`, and that prefix is the whole of what
|
|
13
|
-
// Intel ever learns about a tool's origin: the portal resolves the real server and holds its
|
|
14
|
-
// credentials (ADR-0003). A name without a namespace has no origin beyond the portal itself.
|
|
15
|
-
const NamespaceSeparator = "__";
|
|
16
|
-
|
|
17
|
-
function toolOrigin(name: string): string | null {
|
|
18
|
-
const boundary = name.indexOf(NamespaceSeparator);
|
|
19
|
-
return boundary > 0 ? name.slice(0, boundary) : null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
12
|
interface ToolGroup {
|
|
23
|
-
//
|
|
24
|
-
|
|
13
|
+
// The handle the portal named, or `null` for the box holding what no named server carries.
|
|
14
|
+
handle: string | null;
|
|
15
|
+
label: string;
|
|
25
16
|
items: ToolCapability[];
|
|
26
17
|
}
|
|
27
18
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The tools of one catalog, under the servers the portal itself named (#212).
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ The grouping comes from `GET /api/v1/tools/servers`, never from the tool name alone. The name
|
|
23
|
+
* cannot say where the server part ends — `intel_flow_get` reads as `intel` + `flow_get` and as
|
|
24
|
+
* `intel_flow` + `get` — so this screen matches the portal's own handles with the same `serverOf`
|
|
25
|
+
* the delegation is cut with, longest match wins. The earlier reading, a cut at a separator, was
|
|
26
|
+
* shipped twice and was wrong twice: at `__` it put all 92 tools of the running installation in one
|
|
27
|
+
* box, because the portal writes a single underscore (#106), and at `_` it would break the first
|
|
28
|
+
* server whose slug carries one (#107, closed for that reason).
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ A tool that matches no named handle is NOT dropped. It goes into its own box: the portal's
|
|
31
|
+
* management tools (`portal_list_servers`) live there, because the portal does not list itself as
|
|
32
|
+
* an upstream, and so does anything from a server the directory failed to mention. Hiding those
|
|
33
|
+
* would make the screen quietly disagree with `tools/list` — and the count on the lid is what the
|
|
34
|
+
* reader compares against the portal.
|
|
35
|
+
*
|
|
36
|
+
* The order is the portal's own: a group appears where its first tool appears, because the portal
|
|
37
|
+
* returns a server's tools together and sorting would replace its answer with a preference. Only
|
|
38
|
+
* the unattributed box is forced last — it is a remainder, not a server, and it must not lead.
|
|
39
|
+
*/
|
|
40
|
+
function groupByServer(
|
|
41
|
+
items: ToolCapability[],
|
|
42
|
+
servers: ToolServer[],
|
|
43
|
+
unlistedLabel: string,
|
|
44
|
+
): ToolGroup[] {
|
|
45
|
+
const named = new Map(servers.map((server) => [server.handle, server.name]));
|
|
35
46
|
const groups = new Map<string, ToolGroup>();
|
|
47
|
+
const unlisted: ToolGroup = { handle: null, label: unlistedLabel, items: [] };
|
|
36
48
|
for (const item of items) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
const handle = serverOf(item.name, named.keys());
|
|
50
|
+
if (handle === null) {
|
|
51
|
+
unlisted.items.push(item);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
// The server's display name, not its handle: `notion` is what the wire says, and what the
|
|
55
|
+
// portal calls it is what the reader knows it by.
|
|
56
|
+
const group = groups.get(handle) ?? { handle, label: named.get(handle) ?? handle, items: [] };
|
|
40
57
|
group.items.push(item);
|
|
41
|
-
groups.set(
|
|
58
|
+
groups.set(handle, group);
|
|
42
59
|
}
|
|
43
|
-
return [...groups.values()];
|
|
60
|
+
return unlisted.items.length > 0 ? [...groups.values(), unlisted] : [...groups.values()];
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
// Signed in to Intel is signed in to the portal, so the sign-in is attempted at most once per
|
|
@@ -65,6 +82,22 @@ function attemptStore(): Storage | null {
|
|
|
65
82
|
export function Tools() {
|
|
66
83
|
const { data, i18n } = useIntelRouterContext();
|
|
67
84
|
const catalog = useQuery({ queryKey: ["tools"], queryFn: () => data.listTools() });
|
|
85
|
+
// The servers behind that same live list (D30). Its own query, and its own failure: a portal that
|
|
86
|
+
// answers tools but no directory still has a usable screen, it just has no boxes to put them in.
|
|
87
|
+
// The key is the one the agent profile already reads the directory under, so a visit to either
|
|
88
|
+
// screen serves the other from cache.
|
|
89
|
+
// ⚠️ No retry on this one, and that is a decision about the screen rather than about the request.
|
|
90
|
+
// The tools are held back until the directory has answered, so every further attempt is time
|
|
91
|
+
// somebody spends in front of the word "Loading" — and a retry does not even have to happen: a
|
|
92
|
+
// React Query retry is PAUSED while the tab is unfocused, so a screen waiting on one can stay
|
|
93
|
+
// "Loading" for as long as the reader is looking elsewhere. A portal that offers no directory
|
|
94
|
+
// answers the same 502 every time anyway; one attempt settles it, and what follows is the list
|
|
95
|
+
// plus the sentence saying why it is not sorted.
|
|
96
|
+
const servers = useQuery({
|
|
97
|
+
queryKey: ["tool-servers"],
|
|
98
|
+
queryFn: () => data.listToolServers(),
|
|
99
|
+
retry: false,
|
|
100
|
+
});
|
|
68
101
|
// A hit from the header search arrives as `?select=<tool name>`, and the row it names opens with
|
|
69
102
|
// the list. Nothing else about the row changes: it is still only a disclosure.
|
|
70
103
|
const requested = useRouterState({ select: (state) => selectedFrom(state.location.search) });
|
|
@@ -162,26 +195,77 @@ export function Tools() {
|
|
|
162
195
|
title={i18n.t("tools.empty")}
|
|
163
196
|
help={i18n.t("tools.emptyHelp")}
|
|
164
197
|
/>
|
|
198
|
+
) : servers.isPending ? (
|
|
199
|
+
// The tools are here and the servers are not yet. Showing the flat list first and
|
|
200
|
+
// reshuffling it a moment later would be a screen that moves under the reader's hand.
|
|
201
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
165
202
|
) : (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
i18n={i18n}
|
|
173
|
-
requested={requested}
|
|
174
|
-
/>
|
|
175
|
-
))}
|
|
176
|
-
</div>
|
|
177
|
-
<p className="text-xs text-muted-foreground">{i18n.t("tools.liveNote")}</p>
|
|
178
|
-
</>
|
|
203
|
+
<ToolCatalogView
|
|
204
|
+
items={catalog.data.items}
|
|
205
|
+
servers={servers.isError ? [] : servers.data.items}
|
|
206
|
+
i18n={i18n}
|
|
207
|
+
requested={requested}
|
|
208
|
+
/>
|
|
179
209
|
)}
|
|
180
210
|
</div>
|
|
181
211
|
</div>
|
|
182
212
|
);
|
|
183
213
|
}
|
|
184
214
|
|
|
215
|
+
/**
|
|
216
|
+
* The catalog, under the portal's servers where there are any.
|
|
217
|
+
*
|
|
218
|
+
* ⚠️ No directory, no boxes — and the screen says why instead of inventing them. A portal that does
|
|
219
|
+
* not offer `portal_list_servers`, or whose answer Intel cannot read, makes `/tools/servers` fail,
|
|
220
|
+
* and every alternative to this sentence is a guess: a cut at an underscore, or the one box called
|
|
221
|
+
* "Company portal" that held all 92 tools of the running installation (#106). The tools are all
|
|
222
|
+
* here, in the order the portal answered them; only the grouping is missing.
|
|
223
|
+
*/
|
|
224
|
+
function ToolCatalogView({
|
|
225
|
+
items,
|
|
226
|
+
servers,
|
|
227
|
+
i18n,
|
|
228
|
+
requested,
|
|
229
|
+
}: {
|
|
230
|
+
items: ToolCapability[];
|
|
231
|
+
servers: ToolServer[];
|
|
232
|
+
i18n: I18n;
|
|
233
|
+
requested: string | null;
|
|
234
|
+
}) {
|
|
235
|
+
if (servers.length === 0) {
|
|
236
|
+
return (
|
|
237
|
+
<>
|
|
238
|
+
<section className="rounded-xl border border-dashed bg-card px-5 py-4">
|
|
239
|
+
<h2 className="text-sm font-semibold">{i18n.t("tools.ungrouped")}</h2>
|
|
240
|
+
<p className="mt-1 text-sm text-muted-foreground">{i18n.t("tools.ungroupedHelp")}</p>
|
|
241
|
+
</section>
|
|
242
|
+
{/* Open, not a lid: with no server to name, a closed box would hide the whole catalog
|
|
243
|
+
behind a word that says nothing. */}
|
|
244
|
+
<section className="overflow-hidden rounded-xl border bg-card shadow-sm">
|
|
245
|
+
<ToolRows items={items} i18n={i18n} requested={requested} />
|
|
246
|
+
</section>
|
|
247
|
+
<p className="text-xs text-muted-foreground">{i18n.t("tools.liveNote")}</p>
|
|
248
|
+
</>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return (
|
|
252
|
+
<>
|
|
253
|
+
<div className="space-y-3">
|
|
254
|
+
{groupByServer(items, servers, i18n.t("tools.originUnlisted")).map((group) => (
|
|
255
|
+
<ServerGroup
|
|
256
|
+
key={group.handle ?? ""}
|
|
257
|
+
group={group}
|
|
258
|
+
note={group.handle === null ? i18n.t("tools.originUnlistedHelp") : null}
|
|
259
|
+
i18n={i18n}
|
|
260
|
+
requested={requested}
|
|
261
|
+
/>
|
|
262
|
+
))}
|
|
263
|
+
</div>
|
|
264
|
+
<p className="text-xs text-muted-foreground">{i18n.t("tools.liveNote")}</p>
|
|
265
|
+
</>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
185
269
|
function Notice({
|
|
186
270
|
icon,
|
|
187
271
|
title,
|
|
@@ -213,10 +297,14 @@ function Notice({
|
|
|
213
297
|
// somebody wants to know about it (#99).
|
|
214
298
|
function ServerGroup({
|
|
215
299
|
group,
|
|
300
|
+
note,
|
|
216
301
|
i18n,
|
|
217
302
|
requested,
|
|
218
303
|
}: {
|
|
219
304
|
group: ToolGroup;
|
|
305
|
+
// Why this box exists at all, for the one box that is not a server. A group named by the portal
|
|
306
|
+
// needs no explanation; the remainder does, or it reads as a server called something odd.
|
|
307
|
+
note: string | null;
|
|
220
308
|
i18n: I18n;
|
|
221
309
|
requested: string | null;
|
|
222
310
|
}) {
|
|
@@ -224,14 +312,13 @@ function ServerGroup({
|
|
|
224
312
|
// with it — otherwise Enter on a hit lands on a screen where nothing happened.
|
|
225
313
|
const holdsRequested = group.items.some((item) => item.name === requested);
|
|
226
314
|
const destructive = group.items.filter((item) => item.annotations.destructiveHint).length;
|
|
227
|
-
const label = group.origin ?? i18n.t("tools.originPortal");
|
|
228
315
|
const count =
|
|
229
316
|
group.items.length === 1
|
|
230
317
|
? i18n.t("tools.toolCountOne")
|
|
231
318
|
: i18n.t("tools.toolCount", { count: group.items.length });
|
|
232
319
|
// The badges beside the name read as one run-on word when a screen reader concatenates them
|
|
233
320
|
// ("wiki1 tool"), so the control says its own name instead of being read off its contents.
|
|
234
|
-
const spoken = [`${i18n.t("tools.origin")}: ${label}`, count];
|
|
321
|
+
const spoken = [`${i18n.t("tools.origin")}: ${group.label}`, count];
|
|
235
322
|
if (destructive > 0) spoken.push(i18n.t("tools.destructiveCount", { count: destructive }));
|
|
236
323
|
|
|
237
324
|
return (
|
|
@@ -246,7 +333,7 @@ function ServerGroup({
|
|
|
246
333
|
className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90"
|
|
247
334
|
/>
|
|
248
335
|
<span className="min-w-0 flex-1">
|
|
249
|
-
<span className="block truncate text-sm font-semibold">{label}</span>
|
|
336
|
+
<span className="block truncate text-sm font-semibold">{group.label}</span>
|
|
250
337
|
</span>
|
|
251
338
|
{/* Closed, the box hides every destructive badge inside it. The count says so on the lid
|
|
252
339
|
rather than making somebody open each server to find out. */}
|
|
@@ -258,24 +345,39 @@ function ServerGroup({
|
|
|
258
345
|
)}
|
|
259
346
|
<span className="shrink-0 text-xs text-muted-foreground">{count}</span>
|
|
260
347
|
</CollapsibleTrigger>
|
|
261
|
-
<CollapsibleContent>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
capability={capability}
|
|
267
|
-
i18n={i18n}
|
|
268
|
-
open={capability.name === requested}
|
|
269
|
-
/>
|
|
270
|
-
</li>
|
|
271
|
-
))}
|
|
272
|
-
</ul>
|
|
348
|
+
<CollapsibleContent className="border-t">
|
|
349
|
+
{note && (
|
|
350
|
+
<p className="border-b bg-muted/50 px-5 py-3 text-xs text-muted-foreground">{note}</p>
|
|
351
|
+
)}
|
|
352
|
+
<ToolRows items={group.items} i18n={i18n} requested={requested} />
|
|
273
353
|
</CollapsibleContent>
|
|
274
354
|
</section>
|
|
275
355
|
</Collapsible>
|
|
276
356
|
);
|
|
277
357
|
}
|
|
278
358
|
|
|
359
|
+
// The rows themselves, with or without a box around them: the ungrouped screen shows the same list
|
|
360
|
+
// without a lid, and duplicating it there is how the two would drift apart.
|
|
361
|
+
function ToolRows({
|
|
362
|
+
items,
|
|
363
|
+
i18n,
|
|
364
|
+
requested,
|
|
365
|
+
}: {
|
|
366
|
+
items: ToolCapability[];
|
|
367
|
+
i18n: I18n;
|
|
368
|
+
requested: string | null;
|
|
369
|
+
}) {
|
|
370
|
+
return (
|
|
371
|
+
<ul className="divide-y">
|
|
372
|
+
{items.map((capability) => (
|
|
373
|
+
<li key={capability.name}>
|
|
374
|
+
<ToolEntry capability={capability} i18n={i18n} open={capability.name === requested} />
|
|
375
|
+
</li>
|
|
376
|
+
))}
|
|
377
|
+
</ul>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
279
381
|
// One tool, one disclosure. The row is the only control in the list: it opens the schema and does
|
|
280
382
|
// nothing else — enabling, sharing or permitting a tool happens in the portal, never here.
|
|
281
383
|
function ToolEntry({
|