@anchrd/intel-ui 0.9.0 → 0.11.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-profile/agent-profile.tsx +273 -59
- 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 +21 -3
- package/src/nodes/nodes.tsx +1 -0
- package/src/title-row/title-row.tsx +7 -1
- 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.11.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
|
}
|
|
@@ -5,10 +5,10 @@ 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, 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
13
|
import { modelKey, parseModelKey, selectableModels } from "@/agent/agent-models/agent-models.ts";
|
|
14
14
|
import {
|
|
@@ -18,11 +18,15 @@ import {
|
|
|
18
18
|
SelectTrigger,
|
|
19
19
|
SelectValue,
|
|
20
20
|
} from "@/components/ui/select";
|
|
21
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
21
22
|
import { agentMcpAddress } from "@/data/agent-runtime/agent-runtime.ts";
|
|
22
23
|
import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
|
|
23
24
|
import { Modal } from "@/modal/modal.tsx";
|
|
24
25
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
25
26
|
|
|
27
|
+
// No dividers between sections — the space is the separation (#204). The explanation sits behind
|
|
28
|
+
// the icon rather than under the title, so the page shows what the agent IS and keeps the prose
|
|
29
|
+
// for whoever asks.
|
|
26
30
|
function Section({
|
|
27
31
|
title,
|
|
28
32
|
hint,
|
|
@@ -33,22 +37,49 @@ function Section({
|
|
|
33
37
|
children: React.ReactNode;
|
|
34
38
|
}) {
|
|
35
39
|
return (
|
|
36
|
-
<section aria-label={title} className="
|
|
37
|
-
<
|
|
38
|
-
|
|
40
|
+
<section aria-label={title} className="px-6 py-7">
|
|
41
|
+
<div className="flex items-center gap-1.5">
|
|
42
|
+
<h3 className="text-sm font-semibold">{title}</h3>
|
|
43
|
+
{hint ? <SectionHint hint={hint} /> : null}
|
|
44
|
+
</div>
|
|
39
45
|
<div className="mt-3">{children}</div>
|
|
40
46
|
</section>
|
|
41
47
|
);
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
// ⚠️ The hint is the trigger's accessible name, not only the tooltip's content: Radix describes the
|
|
51
|
+
// trigger by the content only while it is open, and a listener who tabs past a nameless icon button
|
|
52
|
+
// would never learn there is anything behind it.
|
|
53
|
+
function SectionHint({ hint }: { hint: string }) {
|
|
54
|
+
return (
|
|
55
|
+
<TooltipProvider delayDuration={300}>
|
|
56
|
+
<Tooltip>
|
|
57
|
+
<TooltipTrigger
|
|
58
|
+
type="button"
|
|
59
|
+
aria-label={hint}
|
|
60
|
+
className="rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
61
|
+
>
|
|
62
|
+
<Info aria-hidden="true" className="size-3.5" />
|
|
63
|
+
</TooltipTrigger>
|
|
64
|
+
<TooltipContent className="max-w-xs">{hint}</TooltipContent>
|
|
65
|
+
</Tooltip>
|
|
66
|
+
</TooltipProvider>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
/**
|
|
45
71
|
* Everything an agent is, on one page: how to reach it, what it reads, what it may call, when it
|
|
46
|
-
* acts on its own,
|
|
72
|
+
* acts on its own, which model does the thinking, and who it is in Gate.
|
|
47
73
|
*
|
|
48
|
-
* ⚠️ Four of the
|
|
49
|
-
* version through `save` (see `agent-definition.ts`). Tools
|
|
50
|
-
*
|
|
51
|
-
*
|
|
74
|
+
* ⚠️ Four of the six sections write, and they all write the SAME way — a whole new definition
|
|
75
|
+
* version through `save` (see `agent-definition.ts`). Tools joined them with D30: what it writes is
|
|
76
|
+
* a selection of whole MCP servers out of the signed-in person's own portal connection, never a
|
|
77
|
+
* mirrored permission, so ADR-0003 still holds — the catalog behind a chosen server stays a live
|
|
78
|
+
* `tools/list`.
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ Contact and Identity are the two that do not. Identity acts in Gate and in the agent runtime
|
|
81
|
+
* rather than on the document, and a key has no business being in a definition — a definition is
|
|
82
|
+
* versioned, shared and read into model context (ADR-0005 §4).
|
|
52
83
|
*/
|
|
53
84
|
export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
|
|
54
85
|
const { i18n } = useIntelRouterContext();
|
|
@@ -87,13 +118,97 @@ export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefiniti
|
|
|
87
118
|
) : null}
|
|
88
119
|
<ContactSection node={node} />
|
|
89
120
|
<KnowledgeSection definition={definition} agent={agent} />
|
|
90
|
-
<ToolsSection />
|
|
121
|
+
<ToolsSection definition={definition} agent={agent} />
|
|
91
122
|
<ScheduleSection definition={definition} agent={agent} />
|
|
92
123
|
<ModelSection definition={definition} agent={agent} />
|
|
124
|
+
<IdentitySection node={node} agent={agent} />
|
|
93
125
|
</div>
|
|
94
126
|
);
|
|
95
127
|
}
|
|
96
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Who the agent IS to the rest of the installation, and the one button that repairs it (D29, #207).
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ The application ID is shown and the key is not, because only one of the two is a name. The key
|
|
133
|
+
* exists in Gate for the length of one call and goes straight into the agent runtime; there is
|
|
134
|
+
* nothing for this page to reveal, copy or store, and that is what makes an agent created here able
|
|
135
|
+
* to run without anybody opening a terminal (#200).
|
|
136
|
+
*
|
|
137
|
+
* ⚠️ Last on the page on purpose. It is the section a reader needs on the day something is wrong,
|
|
138
|
+
* not while they are describing what the agent should do.
|
|
139
|
+
*/
|
|
140
|
+
function IdentitySection({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
|
|
141
|
+
const { data, i18n } = useIntelRouterContext();
|
|
142
|
+
const queryClient = useQueryClient();
|
|
143
|
+
const [confirming, setConfirming] = useState(false);
|
|
144
|
+
const applicationId = agent.query.data?.applicationId ?? null;
|
|
145
|
+
|
|
146
|
+
const rotate = useMutation({
|
|
147
|
+
mutationFn: async () => await data.rotateAgentKey(node.id),
|
|
148
|
+
onSuccess: () => {
|
|
149
|
+
setConfirming(false);
|
|
150
|
+
// The run that failed with `agent_key_missing` is the reason somebody pressed this, so the
|
|
151
|
+
// page's own state is re-read rather than left showing the refusal that sent them here.
|
|
152
|
+
void queryClient.invalidateQueries({ queryKey: ["agent-state", node.id] });
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<Section title={i18n.t("agent.identity")} hint={i18n.t("agent.identityHint")}>
|
|
158
|
+
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
|
159
|
+
<KeyRound aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
160
|
+
<span className="min-w-0 flex-1">
|
|
161
|
+
<span className="block text-sm font-medium">{i18n.t("agent.principal")}</span>
|
|
162
|
+
<code className="mt-0.5 block truncate text-xs text-muted-foreground">
|
|
163
|
+
{applicationId ?? i18n.t("agent.principalNone")}
|
|
164
|
+
</code>
|
|
165
|
+
</span>
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={() => setConfirming(true)}
|
|
169
|
+
disabled={applicationId === null || rotate.isPending}
|
|
170
|
+
aria-busy={rotate.isPending}
|
|
171
|
+
{...(applicationId === null ? { "aria-describedby": "agent-rotate-reason" } : {})}
|
|
172
|
+
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"
|
|
173
|
+
>
|
|
174
|
+
{i18n.t("agent.rotateKey")}
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
{applicationId === null ? (
|
|
178
|
+
<p id="agent-rotate-reason" className="mt-3 text-xs text-muted-foreground">
|
|
179
|
+
{i18n.t("agent.principalNoneHint")}
|
|
180
|
+
</p>
|
|
181
|
+
) : null}
|
|
182
|
+
{rotate.isError ? (
|
|
183
|
+
<p role="alert" className="mt-3 text-xs text-destructive">
|
|
184
|
+
{i18n.t("agent.rotateKeyFailed")}
|
|
185
|
+
</p>
|
|
186
|
+
) : null}
|
|
187
|
+
{rotate.isSuccess ? (
|
|
188
|
+
<p role="status" className="mt-3 text-xs text-muted-foreground">
|
|
189
|
+
{i18n.t("agent.rotateKeyDone")}
|
|
190
|
+
</p>
|
|
191
|
+
) : null}
|
|
192
|
+
{confirming ? (
|
|
193
|
+
<Modal title={i18n.t("agent.rotateKey")} close={() => setConfirming(false)}>
|
|
194
|
+
{/* ⚠️ Confirmed rather than done on the first click. The previous key stops working the
|
|
195
|
+
moment this runs, so a misclick takes a working agent down until the call finishes. */}
|
|
196
|
+
<p className="text-sm text-muted-foreground">{i18n.t("agent.rotateKeyConfirm")}</p>
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
onClick={() => rotate.mutate()}
|
|
200
|
+
disabled={rotate.isPending}
|
|
201
|
+
aria-busy={rotate.isPending}
|
|
202
|
+
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"
|
|
203
|
+
>
|
|
204
|
+
{i18n.t("agent.rotateKey")}
|
|
205
|
+
</button>
|
|
206
|
+
</Modal>
|
|
207
|
+
) : null}
|
|
208
|
+
</Section>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
97
212
|
/**
|
|
98
213
|
* How to reach this agent.
|
|
99
214
|
*
|
|
@@ -151,20 +266,31 @@ function ContactSection({ node }: { node: Node }) {
|
|
|
151
266
|
</button>
|
|
152
267
|
</li>
|
|
153
268
|
</ul>
|
|
154
|
-
{/* ⚠️ Disabled,
|
|
155
|
-
configuration (`AGENT_MAILBOXES`) and deliberately not part of the definition
|
|
156
|
-
§4 — so a working button here would write a field the runtime never reads.
|
|
269
|
+
{/* ⚠️ Disabled, with the reason behind a hover rather than on the page (#204). A mail contact
|
|
270
|
+
is deployment configuration (`AGENT_MAILBOXES`) and deliberately not part of the definition
|
|
271
|
+
— ADR-0005 §4 — so a working button here would write a field the runtime never reads. The
|
|
272
|
+
span carries the tooltip because a disabled button swallows the pointer events the trigger
|
|
273
|
+
listens for; the sr-only copy keeps the reason where `aria-describedby` points. */}
|
|
157
274
|
<div className="mt-3">
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
275
|
+
<TooltipProvider delayDuration={300}>
|
|
276
|
+
<Tooltip>
|
|
277
|
+
<TooltipTrigger asChild>
|
|
278
|
+
<span className="inline-flex">
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
disabled
|
|
282
|
+
aria-describedby="agent-contact-add-reason"
|
|
283
|
+
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm disabled:opacity-50"
|
|
284
|
+
>
|
|
285
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
286
|
+
{i18n.t("agent.contactAdd")}
|
|
287
|
+
</button>
|
|
288
|
+
</span>
|
|
289
|
+
</TooltipTrigger>
|
|
290
|
+
<TooltipContent className="max-w-xs">{i18n.t("agent.contactAddReason")}</TooltipContent>
|
|
291
|
+
</Tooltip>
|
|
292
|
+
</TooltipProvider>
|
|
293
|
+
<p id="agent-contact-add-reason" className="sr-only">
|
|
168
294
|
{i18n.t("agent.contactAddReason")}
|
|
169
295
|
</p>
|
|
170
296
|
</div>
|
|
@@ -191,14 +317,13 @@ function KnowledgeSection({
|
|
|
191
317
|
const [adding, setAdding] = useState(false);
|
|
192
318
|
|
|
193
319
|
function write(references: AgentReference[]) {
|
|
194
|
-
agent.save.mutate({ ...definition, references });
|
|
320
|
+
agent.save.mutate({ ...asDraft(definition), references });
|
|
195
321
|
}
|
|
196
322
|
|
|
197
323
|
return (
|
|
198
324
|
<Section title={i18n.t("agent.knows")} hint={i18n.t("agent.knowsHint")}>
|
|
199
|
-
{
|
|
200
|
-
|
|
201
|
-
) : (
|
|
325
|
+
{/* An empty list shows nothing at all — the add button says what belongs here (#204). */}
|
|
326
|
+
{definition.references.length === 0 ? null : (
|
|
202
327
|
<ul className="space-y-2">
|
|
203
328
|
{/* ⚠️ The position IS the identity here: `references` is an ordered array with no ids,
|
|
204
329
|
the contract permits the same folder twice, and every edit below addresses a row by
|
|
@@ -312,39 +437,130 @@ function EntryTitle({ entryId, flow = false }: { entryId: string; flow?: boolean
|
|
|
312
437
|
}
|
|
313
438
|
|
|
314
439
|
/**
|
|
315
|
-
* What the agent may call
|
|
440
|
+
* What the agent may call: whole MCP servers, given from the signed-in person's own portal
|
|
441
|
+
* connection (D30).
|
|
442
|
+
*
|
|
443
|
+
* ⚠️ What is saved is a SELECTION, never a catalog. The tools behind a server stay a live
|
|
444
|
+
* `tools/list` made with the delegator's token at the moment the agent runs (ADR-0003), so a server
|
|
445
|
+
* listed here is a name, not a promise — losing it in the portal takes it away from the agent with
|
|
446
|
+
* nothing to edit.
|
|
316
447
|
*
|
|
317
|
-
* ⚠️
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
448
|
+
* ⚠️ The notice is not decoration, and it stands whether or not anything is listed yet. A shared
|
|
449
|
+
* agent runs on the DELEGATOR's connection whoever starts it, which is a real widening of who can
|
|
450
|
+
* act as this person — so it belongs where somebody reads it BEFORE clicking `+ Add tool`, not as a
|
|
451
|
+
* footnote that appears once the first server is already given away. The empty list itself stays
|
|
452
|
+
* wordless, the way the other sections' do (#204): the button says what belongs here.
|
|
321
453
|
*/
|
|
322
|
-
function ToolsSection(
|
|
454
|
+
function ToolsSection({
|
|
455
|
+
definition,
|
|
456
|
+
agent,
|
|
457
|
+
}: {
|
|
458
|
+
definition: AgentDefinition;
|
|
459
|
+
agent: AgentDefinitionHandle;
|
|
460
|
+
}) {
|
|
323
461
|
const { data, i18n } = useIntelRouterContext();
|
|
324
|
-
const
|
|
462
|
+
const [adding, setAdding] = useState(false);
|
|
463
|
+
const servers = useQuery({ queryKey: ["tool-servers"], queryFn: () => data.listToolServers() });
|
|
464
|
+
const selected = definition.tools?.servers ?? [];
|
|
465
|
+
|
|
466
|
+
// ⚠️ Servers, and nothing about who delegates them. The screen has no `delegatedBy` to send and
|
|
467
|
+
// no business inventing one — Intel writes it from the session, and `AgentDefinitionInput` has no
|
|
468
|
+
// field it could travel in. This is also what lets an agent with `tools: null`, which is every
|
|
469
|
+
// freshly created one, receive its first server at all.
|
|
470
|
+
function write(next: string[]) {
|
|
471
|
+
agent.save.mutate({ ...asDraft(definition), tools: { servers: next } });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const available = (servers.data?.items ?? []).filter(
|
|
475
|
+
(server) => !selected.includes(server.handle),
|
|
476
|
+
);
|
|
325
477
|
|
|
326
478
|
return (
|
|
327
479
|
<Section title={i18n.t("agent.tools")} hint={i18n.t("agent.toolsHint")}>
|
|
328
|
-
{
|
|
329
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
480
|
+
{selected.length === 0 ? null : (
|
|
481
|
+
<ul className="space-y-2">
|
|
482
|
+
{selected.map((handle) => {
|
|
483
|
+
const known = servers.data?.items.find((server) => server.handle === handle);
|
|
484
|
+
return (
|
|
485
|
+
<li
|
|
486
|
+
key={handle}
|
|
487
|
+
className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
|
|
488
|
+
>
|
|
489
|
+
<Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
490
|
+
<span
|
|
491
|
+
className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}
|
|
492
|
+
>
|
|
493
|
+
{known?.name ?? handle}
|
|
494
|
+
</span>
|
|
495
|
+
{/* A server the signed-in person no longer reaches still stands in the definition,
|
|
496
|
+
and saying so beats drawing it as if it worked. The agent gets nothing from it
|
|
497
|
+
either — the catalog is cut against what the delegator reaches. */}
|
|
498
|
+
<span className="text-xs text-muted-foreground">
|
|
499
|
+
{known
|
|
500
|
+
? i18n.t("tools.toolCount", { count: String(known.toolCount) })
|
|
501
|
+
: i18n.t("agent.toolServerUnavailable")}
|
|
502
|
+
</span>
|
|
503
|
+
<button
|
|
504
|
+
type="button"
|
|
505
|
+
onClick={() => write(selected.filter((current) => current !== handle))}
|
|
506
|
+
aria-label={i18n.t("agent.removeToolServerOf", { name: known?.name ?? handle })}
|
|
507
|
+
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
508
|
+
>
|
|
509
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
510
|
+
</button>
|
|
511
|
+
</li>
|
|
512
|
+
);
|
|
513
|
+
})}
|
|
346
514
|
</ul>
|
|
347
515
|
)}
|
|
516
|
+
<button
|
|
517
|
+
type="button"
|
|
518
|
+
onClick={() => setAdding(true)}
|
|
519
|
+
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"
|
|
520
|
+
>
|
|
521
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
522
|
+
{i18n.t("agent.addTool")}
|
|
523
|
+
</button>
|
|
524
|
+
<p className="mt-3 text-xs text-muted-foreground">{i18n.t("agent.toolsDelegationNotice")}</p>
|
|
525
|
+
{adding ? (
|
|
526
|
+
<Modal title={i18n.t("agent.addTool")} close={() => setAdding(false)}>
|
|
527
|
+
{servers.isPending ? (
|
|
528
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
529
|
+
) : servers.isError ? (
|
|
530
|
+
<p role="alert" className="text-sm text-destructive">
|
|
531
|
+
{i18n.t("tools.unreachableHelp")}
|
|
532
|
+
</p>
|
|
533
|
+
) : servers.data?.portalConnected === false ? (
|
|
534
|
+
<p className="text-sm text-muted-foreground">{i18n.t("agent.toolsNotConnected")}</p>
|
|
535
|
+
) : available.length === 0 ? (
|
|
536
|
+
<p className="text-sm text-muted-foreground">{i18n.t("agent.toolsNoneLeft")}</p>
|
|
537
|
+
) : (
|
|
538
|
+
<ul className="space-y-2">
|
|
539
|
+
{available.map((server) => (
|
|
540
|
+
<li key={server.handle}>
|
|
541
|
+
<button
|
|
542
|
+
type="button"
|
|
543
|
+
onClick={() => {
|
|
544
|
+
write([...selected, server.handle]);
|
|
545
|
+
setAdding(false);
|
|
546
|
+
}}
|
|
547
|
+
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"
|
|
548
|
+
>
|
|
549
|
+
<Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
550
|
+
<span className="min-w-0 flex-1 truncate text-sm">{server.name}</span>
|
|
551
|
+
<span className="text-xs text-muted-foreground">
|
|
552
|
+
{i18n.t("tools.toolCount", { count: String(server.toolCount) })}
|
|
553
|
+
</span>
|
|
554
|
+
</button>
|
|
555
|
+
</li>
|
|
556
|
+
))}
|
|
557
|
+
</ul>
|
|
558
|
+
)}
|
|
559
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
560
|
+
{i18n.t("agent.toolsDelegationNotice")}
|
|
561
|
+
</p>
|
|
562
|
+
</Modal>
|
|
563
|
+
) : null}
|
|
348
564
|
</Section>
|
|
349
565
|
);
|
|
350
566
|
}
|
|
@@ -367,14 +583,12 @@ function ScheduleSection({
|
|
|
367
583
|
const [adding, setAdding] = useState(false);
|
|
368
584
|
|
|
369
585
|
function write(schedules: AgentSchedule[]) {
|
|
370
|
-
agent.save.mutate({ ...definition, schedules });
|
|
586
|
+
agent.save.mutate({ ...asDraft(definition), schedules });
|
|
371
587
|
}
|
|
372
588
|
|
|
373
589
|
return (
|
|
374
590
|
<Section title={i18n.t("agent.schedule")} hint={i18n.t("agent.scheduleHint")}>
|
|
375
|
-
{definition.schedules.length === 0 ? (
|
|
376
|
-
<p className="text-sm text-muted-foreground">{i18n.t("agent.scheduleEmpty")}</p>
|
|
377
|
-
) : (
|
|
591
|
+
{definition.schedules.length === 0 ? null : (
|
|
378
592
|
<ul className="space-y-2">
|
|
379
593
|
{definition.schedules.map((schedule, index) => (
|
|
380
594
|
<li
|
|
@@ -492,7 +706,7 @@ function ModelSection({
|
|
|
492
706
|
value={modelKey(definition.model)}
|
|
493
707
|
onValueChange={(key) => {
|
|
494
708
|
const model = parseModelKey(key);
|
|
495
|
-
if (model) agent.save.mutate({ ...definition, model });
|
|
709
|
+
if (model) agent.save.mutate({ ...asDraft(definition), model });
|
|
496
710
|
}}
|
|
497
711
|
>
|
|
498
712
|
<SelectTrigger aria-label={i18n.t("agent.model")} className="w-full max-w-md">
|
|
@@ -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",
|
|
@@ -308,7 +321,6 @@
|
|
|
308
321
|
"agent.contactAddReason": "A mail address belongs to the installation, not to the agent's definition, so it is configured where the runtime is deployed.",
|
|
309
322
|
"agent.knows": "What it knows",
|
|
310
323
|
"agent.knowsHint": "The folders this agent reads, and what each one counts as. A system message instructs it, semantic context is searched, memory is written back to.",
|
|
311
|
-
"agent.knowsEmpty": "This agent reads nothing yet.",
|
|
312
324
|
"agent.addReference": "Add folder",
|
|
313
325
|
"agent.removeReferenceOf": "Stop reading {title}",
|
|
314
326
|
"agent.pickFolder": "Folder to read",
|
|
@@ -318,7 +330,13 @@
|
|
|
318
330
|
"agent.role.memory": "Memory",
|
|
319
331
|
"agent.unknownEntry": "Not available to you",
|
|
320
332
|
"agent.tools": "Tools",
|
|
321
|
-
"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.",
|
|
322
340
|
"agent.schedule": "Schedule",
|
|
323
341
|
"agent.scheduleHint": "When this agent acts on its own, and what it runs. Times are UTC — a definition carries no timezone.",
|
|
324
342
|
"agent.scheduleEmpty": "This agent runs only when it is asked.",
|
package/src/nodes/nodes.tsx
CHANGED
|
@@ -114,6 +114,7 @@ export function Nodes() {
|
|
|
114
114
|
description={selected.description}
|
|
115
115
|
target={{ type: "node", node: selected }}
|
|
116
116
|
leading={selected.kind === "agent" ? <AgentAvatar title={selected.title} /> : null}
|
|
117
|
+
divider={selected.kind !== "agent"}
|
|
117
118
|
>
|
|
118
119
|
{selected.kind !== "folder" && (
|
|
119
120
|
<TooltipProvider delayDuration={300}>
|
|
@@ -21,6 +21,7 @@ export function TitleRow({
|
|
|
21
21
|
description,
|
|
22
22
|
target,
|
|
23
23
|
leading,
|
|
24
|
+
divider = true,
|
|
24
25
|
children,
|
|
25
26
|
}: {
|
|
26
27
|
title: string;
|
|
@@ -30,10 +31,15 @@ export function TitleRow({
|
|
|
30
31
|
// prop rather than another slot because it must sit BEFORE the title, and a portal can only
|
|
31
32
|
// append — the same limitation that gave the shell two boxes instead of one list (#56).
|
|
32
33
|
leading?: React.ReactNode;
|
|
34
|
+
// Off for the one kind whose next line is its own tab bar: two rules a few pixels apart read as
|
|
35
|
+
// clutter, so the agent page keeps only the tab bar's (#204).
|
|
36
|
+
divider?: boolean;
|
|
33
37
|
children?: React.ReactNode;
|
|
34
38
|
}) {
|
|
35
39
|
return (
|
|
36
|
-
<div
|
|
40
|
+
<div
|
|
41
|
+
className={`flex items-start justify-between gap-5 px-6 py-4${divider ? " border-b" : ""}`}
|
|
42
|
+
>
|
|
37
43
|
<div className="flex min-w-0 items-center gap-3">
|
|
38
44
|
{leading}
|
|
39
45
|
<div className="min-w-0">
|
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({
|