@anchrd/intel-ui 0.8.7 → 0.9.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/README.md +2 -2
- package/package.json +5 -2
- package/src/agent/agent-avatar/agent-avatar.tsx +34 -0
- package/src/agent/agent-calendar/agent-calendar.tsx +79 -0
- package/src/agent/agent-chat/agent-chat.tsx +116 -0
- package/src/agent/agent-cron/agent-cron.ts +132 -0
- package/src/agent/agent-definition/agent-definition.ts +64 -0
- package/src/agent/agent-entry-title/agent-entry-title.ts +29 -0
- package/src/agent/agent-log/agent-log.tsx +159 -0
- package/src/agent/agent-models/agent-models.ts +63 -0
- package/src/agent/agent-profile/agent-profile.tsx +512 -0
- package/src/agent/agent-state/agent-state.ts +48 -0
- package/src/agent/agent.tsx +361 -0
- package/src/app/app-sidebar/app-sidebar.tsx +2 -2
- package/src/app/app-tree/app-tree.tsx +151 -23
- package/src/app/app.tsx +2 -2
- package/src/app/header-search/header-search.tsx +16 -16
- package/src/app/tree-move/tree-move.tsx +10 -10
- package/src/app/user-footer/user-footer.tsx +7 -1
- package/src/archive/archive.tsx +154 -0
- package/src/components/ui/avatar.tsx +39 -0
- package/src/components/ui/select.tsx +163 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/data/agent-runtime/agent-runtime.ts +93 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +206 -120
- package/src/data/intel-data-provider/intel-data-provider.types.ts +112 -54
- package/src/entry-picker/entry-picker.tsx +53 -17
- package/src/flow-runs/flow-runs.tsx +1 -1
- package/src/flows/flows.tsx +20 -20
- package/src/folder-contents/folder-contents.tsx +7 -7
- package/src/graph-pane/graph-pane.tsx +1 -1
- package/src/hooks/use-capabilities.ts +22 -0
- package/src/i18n/en.json +156 -64
- package/src/kind-icon.ts +5 -2
- package/src/{knowledge-editor/knowledge-editor.tsx → node-editor/node-editor.tsx} +20 -20
- package/src/{knowledge-graph → node-graph}/graph-notice.tsx +1 -1
- package/src/{knowledge-graph/knowledge-graph.tsx → node-graph/node-graph.tsx} +4 -4
- package/src/{knowledge-table/knowledge-table.tsx → node-table/node-table.tsx} +14 -14
- package/src/{knowledge/knowledge.tsx → nodes/nodes.tsx} +46 -29
- package/src/resource-menu/resource-menu.tsx +105 -62
- package/src/router/router.tsx +17 -5
- package/src/title-row/title-row.tsx +13 -5
- package/vite.config.ts +4 -0
- /package/src/{knowledge-graph/knowledge-graph.ts → node-graph/node-graph.ts} +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { AgentModel } from "@anchrd/intel-contract";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Which models this installation offers.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ There is no endpoint to ask. The runtime's registry answers one question — "is this provider
|
|
8
|
+
* configured" — and only when a run is already starting; nothing anywhere lists what a deployment
|
|
9
|
+
* 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 two the repository's own
|
|
11
|
+
* fixtures use as the default.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ The definition's current model is always offered, even when it is not in the list. A select
|
|
14
|
+
* that silently dropped it would turn "look at this agent" into "change this agent" for anyone who
|
|
15
|
+
* then saved — and the definition is written by MCP and by other installations too, not only here.
|
|
16
|
+
*/
|
|
17
|
+
const ConfiguredModels = z.array(AgentModel);
|
|
18
|
+
|
|
19
|
+
const firstFallback: AgentModel = { provider: "anthropic", model: "claude-sonnet-4" };
|
|
20
|
+
const fallback: [AgentModel, ...AgentModel[]] = [
|
|
21
|
+
firstFallback,
|
|
22
|
+
{ provider: "workers-ai", model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function modelKey(model: AgentModel): string {
|
|
26
|
+
return `${model.provider}:${model.model}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseModelKey(key: string): AgentModel | null {
|
|
30
|
+
const boundary = key.indexOf(":");
|
|
31
|
+
if (boundary <= 0) return null;
|
|
32
|
+
const parsed = AgentModel.safeParse({
|
|
33
|
+
provider: key.slice(0, boundary),
|
|
34
|
+
model: key.slice(boundary + 1),
|
|
35
|
+
});
|
|
36
|
+
return parsed.success ? parsed.data : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function configuredModels(): AgentModel[] {
|
|
40
|
+
const raw = import.meta.env.VITE_AGENT_MODELS;
|
|
41
|
+
if (!raw) return fallback;
|
|
42
|
+
try {
|
|
43
|
+
const parsed = ConfiguredModels.safeParse(JSON.parse(raw));
|
|
44
|
+
// A malformed variable falls back rather than emptying the select: an agent whose model cannot
|
|
45
|
+
// be chosen is an agent that cannot be repaired from this screen.
|
|
46
|
+
return parsed.success && parsed.data.length > 0 ? parsed.data : fallback;
|
|
47
|
+
} catch {
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The offered list with the agent's own model folded in, deduplicated, order preserved.
|
|
53
|
+
export function selectableModels(current: AgentModel | null): AgentModel[] {
|
|
54
|
+
const models = configuredModels();
|
|
55
|
+
if (!current || models.some((model) => modelKey(model) === modelKey(current))) return models;
|
|
56
|
+
return [current, ...models];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// What a newly created agent gets. The first offered model, because a create dialog that asked for
|
|
60
|
+
// a provider before the agent has a name would be a settings screen wearing a form's clothes.
|
|
61
|
+
export function defaultModel(): AgentModel {
|
|
62
|
+
return configuredModels()[0] ?? firstFallback;
|
|
63
|
+
}
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentDefinition,
|
|
3
|
+
type AgentReference,
|
|
4
|
+
AgentReferenceRole,
|
|
5
|
+
type AgentSchedule,
|
|
6
|
+
type Node,
|
|
7
|
+
} from "@anchrd/intel-contract";
|
|
8
|
+
import { useQuery } from "@tanstack/react-query";
|
|
9
|
+
import { Check, Copy, MessageSquare, Plus, Trash2, Wrench } from "lucide-react";
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import type { AgentDefinitionHandle } from "@/agent/agent-definition/agent-definition.ts";
|
|
12
|
+
import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
|
|
13
|
+
import { modelKey, parseModelKey, selectableModels } from "@/agent/agent-models/agent-models.ts";
|
|
14
|
+
import {
|
|
15
|
+
Select,
|
|
16
|
+
SelectContent,
|
|
17
|
+
SelectItem,
|
|
18
|
+
SelectTrigger,
|
|
19
|
+
SelectValue,
|
|
20
|
+
} from "@/components/ui/select";
|
|
21
|
+
import { agentMcpAddress } from "@/data/agent-runtime/agent-runtime.ts";
|
|
22
|
+
import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
|
|
23
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
24
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
25
|
+
|
|
26
|
+
function Section({
|
|
27
|
+
title,
|
|
28
|
+
hint,
|
|
29
|
+
children,
|
|
30
|
+
}: {
|
|
31
|
+
title: string;
|
|
32
|
+
hint?: string;
|
|
33
|
+
children: React.ReactNode;
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<section aria-label={title} className="border-b px-6 py-5 last:border-b-0">
|
|
37
|
+
<h3 className="text-sm font-semibold">{title}</h3>
|
|
38
|
+
{hint ? <p className="mt-1 max-w-2xl text-xs text-muted-foreground">{hint}</p> : null}
|
|
39
|
+
<div className="mt-3">{children}</div>
|
|
40
|
+
</section>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 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, and which model does the thinking.
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ Four of the five sections write, and all four write the SAME way — a whole new definition
|
|
49
|
+
* version through `save` (see `agent-definition.ts`). Tools is the exception and stays read-only on
|
|
50
|
+
* purpose: the portal decides who reaches which tool, Intel stores none of it, and a control here
|
|
51
|
+
* would be a promise this product is not allowed to keep (ADR-0003).
|
|
52
|
+
*/
|
|
53
|
+
export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
|
|
54
|
+
const { i18n } = useIntelRouterContext();
|
|
55
|
+
const definition = agent.definition;
|
|
56
|
+
|
|
57
|
+
// The definition is `null` in exactly one window: the node exists and no version has been written
|
|
58
|
+
// yet. Nothing on this page can be edited until there is one, and saying so beats five sections
|
|
59
|
+
// of empty controls that all refuse.
|
|
60
|
+
if (agent.query.isPending) {
|
|
61
|
+
return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
|
|
62
|
+
}
|
|
63
|
+
if (agent.query.isError) {
|
|
64
|
+
return (
|
|
65
|
+
<div role="alert" className="space-y-3 p-6 text-sm">
|
|
66
|
+
<p className="text-destructive">{i18n.t("agent.loadFailed")}</p>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={() => void agent.query.refetch()}
|
|
70
|
+
className="rounded-md border px-3 py-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
71
|
+
>
|
|
72
|
+
{i18n.t("common.retry")}
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (!definition) {
|
|
78
|
+
return <p className="p-6 text-sm text-muted-foreground">{i18n.t("agent.noDefinition")}</p>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
83
|
+
{agent.save.isError ? (
|
|
84
|
+
<p role="alert" className="border-b bg-destructive/5 px-6 py-3 text-sm text-destructive">
|
|
85
|
+
{i18n.t("agent.saveFailed")}
|
|
86
|
+
</p>
|
|
87
|
+
) : null}
|
|
88
|
+
<ContactSection node={node} />
|
|
89
|
+
<KnowledgeSection definition={definition} agent={agent} />
|
|
90
|
+
<ToolsSection />
|
|
91
|
+
<ScheduleSection definition={definition} agent={agent} />
|
|
92
|
+
<ModelSection definition={definition} agent={agent} />
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How to reach this agent.
|
|
99
|
+
*
|
|
100
|
+
* ⚠️ The built-in chat is listed first and cannot be removed, because it is not a channel somebody
|
|
101
|
+
* configured — it is the runtime itself (ADR-0005 §5). Listing it beside the others, with no
|
|
102
|
+
* delete, is what says "built in, always there" without a sentence explaining it.
|
|
103
|
+
*/
|
|
104
|
+
function ContactSection({ node }: { node: Node }) {
|
|
105
|
+
const { i18n } = useIntelRouterContext();
|
|
106
|
+
const [copied, setCopied] = useState(false);
|
|
107
|
+
// ⚠️ The runtime's own address, NOT intel's proxy. This one is dialled by a portal or another
|
|
108
|
+
// agent with its own credential (ADR-0005 §7); intel's door is for this page's browser calls and
|
|
109
|
+
// deliberately does not carry `/mcp` (#178).
|
|
110
|
+
const mcpAddress = agentMcpAddress(node.id);
|
|
111
|
+
// A relative path is what the page was built with when the runtime shares the origin, and a
|
|
112
|
+
// relative MCP address is useless to the client that has to dial it. Resolving against the page
|
|
113
|
+
// makes the copied value absolute wherever it came from.
|
|
114
|
+
const absolute =
|
|
115
|
+
typeof window === "undefined" ? mcpAddress : new URL(mcpAddress, window.location.origin).href;
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<Section title={i18n.t("agent.contact")} hint={i18n.t("agent.contactHint")}>
|
|
119
|
+
<ul className="space-y-2">
|
|
120
|
+
<li className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
|
121
|
+
<MessageSquare aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
122
|
+
<span className="min-w-0 flex-1">
|
|
123
|
+
<span className="block text-sm font-medium">{i18n.t("agent.contactChat")}</span>
|
|
124
|
+
<span className="block text-xs text-muted-foreground">
|
|
125
|
+
{i18n.t("agent.contactChatBuiltIn")}
|
|
126
|
+
</span>
|
|
127
|
+
</span>
|
|
128
|
+
</li>
|
|
129
|
+
<li className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
|
130
|
+
<Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
131
|
+
<span className="min-w-0 flex-1">
|
|
132
|
+
<span className="block text-sm font-medium">{i18n.t("agent.contactMcp")}</span>
|
|
133
|
+
<code className="mt-0.5 block truncate text-xs text-muted-foreground">{absolute}</code>
|
|
134
|
+
</span>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onClick={() => {
|
|
138
|
+
void navigator.clipboard.writeText(absolute).then(() => {
|
|
139
|
+
setCopied(true);
|
|
140
|
+
setTimeout(() => setCopied(false), 2_000);
|
|
141
|
+
});
|
|
142
|
+
}}
|
|
143
|
+
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"
|
|
144
|
+
>
|
|
145
|
+
{copied ? (
|
|
146
|
+
<Check aria-hidden="true" className="size-3.5" />
|
|
147
|
+
) : (
|
|
148
|
+
<Copy aria-hidden="true" className="size-3.5" />
|
|
149
|
+
)}
|
|
150
|
+
{i18n.t(copied ? "agent.copied" : "agent.copy")}
|
|
151
|
+
</button>
|
|
152
|
+
</li>
|
|
153
|
+
</ul>
|
|
154
|
+
{/* ⚠️ Disabled, and the reason is written rather than hovered. A mail contact is deployment
|
|
155
|
+
configuration (`AGENT_MAILBOXES`) and deliberately not part of the definition — ADR-0005
|
|
156
|
+
§4 — so a working button here would write a field the runtime never reads. */}
|
|
157
|
+
<div className="mt-3">
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
disabled
|
|
161
|
+
aria-describedby="agent-contact-add-reason"
|
|
162
|
+
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm disabled:opacity-50"
|
|
163
|
+
>
|
|
164
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
165
|
+
{i18n.t("agent.contactAdd")}
|
|
166
|
+
</button>
|
|
167
|
+
<p id="agent-contact-add-reason" className="mt-1.5 text-xs text-muted-foreground">
|
|
168
|
+
{i18n.t("agent.contactAddReason")}
|
|
169
|
+
</p>
|
|
170
|
+
</div>
|
|
171
|
+
</Section>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* What the agent reads, and in which capacity.
|
|
177
|
+
*
|
|
178
|
+
* ⚠️ The role is a select with a visible chevron, not a coloured word. #143 asks for that
|
|
179
|
+
* explicitly, and the reason is that the three roles do very different things — a folder read as
|
|
180
|
+
* the system message becomes instructions, one read as memory is written back to. Something that
|
|
181
|
+
* changes what a document MEANS to an agent must look changeable.
|
|
182
|
+
*/
|
|
183
|
+
function KnowledgeSection({
|
|
184
|
+
definition,
|
|
185
|
+
agent,
|
|
186
|
+
}: {
|
|
187
|
+
definition: AgentDefinition;
|
|
188
|
+
agent: AgentDefinitionHandle;
|
|
189
|
+
}) {
|
|
190
|
+
const { i18n } = useIntelRouterContext();
|
|
191
|
+
const [adding, setAdding] = useState(false);
|
|
192
|
+
|
|
193
|
+
function write(references: AgentReference[]) {
|
|
194
|
+
agent.save.mutate({ ...definition, references });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<Section title={i18n.t("agent.knows")} hint={i18n.t("agent.knowsHint")}>
|
|
199
|
+
{definition.references.length === 0 ? (
|
|
200
|
+
<p className="text-sm text-muted-foreground">{i18n.t("agent.knowsEmpty")}</p>
|
|
201
|
+
) : (
|
|
202
|
+
<ul className="space-y-2">
|
|
203
|
+
{/* ⚠️ The position IS the identity here: `references` is an ordered array with no ids,
|
|
204
|
+
the contract permits the same folder twice, and every edit below addresses a row by
|
|
205
|
+
its index. A key built from the contents alone would collide on exactly the duplicate
|
|
206
|
+
the array is allowed to hold. */}
|
|
207
|
+
{definition.references.map((reference, index) => (
|
|
208
|
+
<ReferenceRow
|
|
209
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: the position is the identity — see above
|
|
210
|
+
key={`${reference.nodeId}:${index}`}
|
|
211
|
+
reference={reference}
|
|
212
|
+
setRole={(role) =>
|
|
213
|
+
write(
|
|
214
|
+
definition.references.map((current, position) =>
|
|
215
|
+
position === index ? { ...current, role } : current,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
remove={() =>
|
|
220
|
+
write(definition.references.filter((_, position) => position !== index))
|
|
221
|
+
}
|
|
222
|
+
/>
|
|
223
|
+
))}
|
|
224
|
+
</ul>
|
|
225
|
+
)}
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
onClick={() => setAdding(true)}
|
|
229
|
+
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"
|
|
230
|
+
>
|
|
231
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
232
|
+
{i18n.t("agent.addReference")}
|
|
233
|
+
</button>
|
|
234
|
+
{adding ? (
|
|
235
|
+
<Modal title={i18n.t("agent.addReference")} close={() => setAdding(false)}>
|
|
236
|
+
<EntryPicker
|
|
237
|
+
kind="folder"
|
|
238
|
+
value=""
|
|
239
|
+
label={i18n.t("agent.pickFolder")}
|
|
240
|
+
onSelect={(nodeId) => {
|
|
241
|
+
write([...definition.references, { nodeId, role: "semantic-context" }]);
|
|
242
|
+
setAdding(false);
|
|
243
|
+
}}
|
|
244
|
+
/>
|
|
245
|
+
</Modal>
|
|
246
|
+
) : null}
|
|
247
|
+
</Section>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* One folder the agent reads, and what it counts as.
|
|
253
|
+
*
|
|
254
|
+
* ⚠️ Its own component so the select can be NAMED after the folder. Three rows carrying the same
|
|
255
|
+
* "What this folder counts as" are three identical controls to anybody listening rather than
|
|
256
|
+
* looking — the name is beside them on screen, and the screen is exactly what that reader does not
|
|
257
|
+
* have. It needs the resolved title, which is why the lookup is a hook rather than a component here.
|
|
258
|
+
*/
|
|
259
|
+
function ReferenceRow({
|
|
260
|
+
reference,
|
|
261
|
+
setRole,
|
|
262
|
+
remove,
|
|
263
|
+
}: {
|
|
264
|
+
reference: AgentReference;
|
|
265
|
+
setRole(role: AgentReferenceRole): void;
|
|
266
|
+
remove(): void;
|
|
267
|
+
}) {
|
|
268
|
+
const { i18n } = useIntelRouterContext();
|
|
269
|
+
const { title, known } = useEntryTitle(reference.nodeId);
|
|
270
|
+
return (
|
|
271
|
+
<li className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
|
272
|
+
<span className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}>
|
|
273
|
+
{title}
|
|
274
|
+
</span>
|
|
275
|
+
<Select
|
|
276
|
+
value={reference.role}
|
|
277
|
+
onValueChange={(role) => {
|
|
278
|
+
const parsed = AgentReferenceRole.safeParse(role);
|
|
279
|
+
if (parsed.success) setRole(parsed.data);
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
<SelectTrigger size="sm" aria-label={i18n.t("agent.roleFor", { title })}>
|
|
283
|
+
<SelectValue />
|
|
284
|
+
</SelectTrigger>
|
|
285
|
+
<SelectContent>
|
|
286
|
+
{AgentReferenceRole.options.map((role) => (
|
|
287
|
+
<SelectItem key={role} value={role}>
|
|
288
|
+
{i18n.t(`agent.role.${role}`)}
|
|
289
|
+
</SelectItem>
|
|
290
|
+
))}
|
|
291
|
+
</SelectContent>
|
|
292
|
+
</Select>
|
|
293
|
+
<button
|
|
294
|
+
type="button"
|
|
295
|
+
onClick={remove}
|
|
296
|
+
aria-label={i18n.t("agent.removeReferenceOf", { title })}
|
|
297
|
+
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
298
|
+
>
|
|
299
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
300
|
+
</button>
|
|
301
|
+
</li>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function EntryTitle({ entryId, flow = false }: { entryId: string; flow?: boolean }) {
|
|
306
|
+
const { title, known } = useEntryTitle(entryId, flow);
|
|
307
|
+
return (
|
|
308
|
+
<span className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}>
|
|
309
|
+
{title}
|
|
310
|
+
</span>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* What the agent may call.
|
|
316
|
+
*
|
|
317
|
+
* ⚠️ Read-only, and read live. Intel stores no tool permissions — the catalog is one `tools/list`
|
|
318
|
+
* with the signed-in user's own portal token (ADR-0003), and this section shows exactly what the
|
|
319
|
+
* Tools screen shows because it asks the same question through the same provider method. Mirroring
|
|
320
|
+
* the catalog into the definition is the one thing this product must not do.
|
|
321
|
+
*/
|
|
322
|
+
function ToolsSection() {
|
|
323
|
+
const { data, i18n } = useIntelRouterContext();
|
|
324
|
+
const catalog = useQuery({ queryKey: ["tools"], queryFn: () => data.listTools() });
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<Section title={i18n.t("agent.tools")} hint={i18n.t("agent.toolsHint")}>
|
|
328
|
+
{catalog.isPending ? (
|
|
329
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
330
|
+
) : catalog.isError ? (
|
|
331
|
+
<p role="alert" className="text-sm text-destructive">
|
|
332
|
+
{i18n.t("tools.unreachableHelp")}
|
|
333
|
+
</p>
|
|
334
|
+
) : catalog.data && catalog.data.items.length === 0 ? (
|
|
335
|
+
<p className="text-sm text-muted-foreground">{i18n.t("tools.emptyHelp")}</p>
|
|
336
|
+
) : (
|
|
337
|
+
<ul className="flex flex-wrap gap-2">
|
|
338
|
+
{catalog.data?.items.map((tool) => (
|
|
339
|
+
<li
|
|
340
|
+
key={tool.name}
|
|
341
|
+
className="rounded-full border bg-card px-3 py-1 text-xs text-muted-foreground"
|
|
342
|
+
>
|
|
343
|
+
{tool.name}
|
|
344
|
+
</li>
|
|
345
|
+
))}
|
|
346
|
+
</ul>
|
|
347
|
+
)}
|
|
348
|
+
</Section>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* When the agent acts without being asked.
|
|
354
|
+
*
|
|
355
|
+
* ⚠️ The target is a flow OR a document and nothing else — the contract says so, and the picker is
|
|
356
|
+
* told to offer both rather than being given a free text field. A schedule aimed at something that
|
|
357
|
+
* cannot be run is a run that fails at 3 a.m. with nobody watching.
|
|
358
|
+
*/
|
|
359
|
+
function ScheduleSection({
|
|
360
|
+
definition,
|
|
361
|
+
agent,
|
|
362
|
+
}: {
|
|
363
|
+
definition: AgentDefinition;
|
|
364
|
+
agent: AgentDefinitionHandle;
|
|
365
|
+
}) {
|
|
366
|
+
const { i18n } = useIntelRouterContext();
|
|
367
|
+
const [adding, setAdding] = useState(false);
|
|
368
|
+
|
|
369
|
+
function write(schedules: AgentSchedule[]) {
|
|
370
|
+
agent.save.mutate({ ...definition, schedules });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<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
|
+
) : (
|
|
378
|
+
<ul className="space-y-2">
|
|
379
|
+
{definition.schedules.map((schedule, index) => (
|
|
380
|
+
<li
|
|
381
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: same as the references above — an ordered array with no ids, edited by position
|
|
382
|
+
key={`${schedule.cron}:${schedule.target.id}:${index}`}
|
|
383
|
+
className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
|
|
384
|
+
>
|
|
385
|
+
<code className="rounded bg-muted px-2 py-1 text-xs">{schedule.cron}</code>
|
|
386
|
+
<EntryTitle entryId={schedule.target.id} flow={schedule.target.kind === "flow"} />
|
|
387
|
+
<span className="text-xs text-muted-foreground">
|
|
388
|
+
{i18n.t(`node.kind.${schedule.target.kind}`)}
|
|
389
|
+
</span>
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
onClick={() =>
|
|
393
|
+
write(definition.schedules.filter((_, position) => position !== index))
|
|
394
|
+
}
|
|
395
|
+
aria-label={i18n.t("agent.removeSchedule")}
|
|
396
|
+
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
397
|
+
>
|
|
398
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
399
|
+
</button>
|
|
400
|
+
</li>
|
|
401
|
+
))}
|
|
402
|
+
</ul>
|
|
403
|
+
)}
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
onClick={() => setAdding(true)}
|
|
407
|
+
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"
|
|
408
|
+
>
|
|
409
|
+
<Plus aria-hidden="true" className="size-4" />
|
|
410
|
+
{i18n.t("agent.addSchedule")}
|
|
411
|
+
</button>
|
|
412
|
+
{adding ? (
|
|
413
|
+
<ScheduleDialog
|
|
414
|
+
close={() => setAdding(false)}
|
|
415
|
+
add={(schedule) => {
|
|
416
|
+
write([...definition.schedules, schedule]);
|
|
417
|
+
setAdding(false);
|
|
418
|
+
}}
|
|
419
|
+
/>
|
|
420
|
+
) : null}
|
|
421
|
+
</Section>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSchedule): void }) {
|
|
426
|
+
const { i18n } = useIntelRouterContext();
|
|
427
|
+
const [cron, setCron] = useState("0 8 * * *");
|
|
428
|
+
const [target, setTarget] = useState<{ id: string; kind: "document" | "flow" } | null>(null);
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<Modal title={i18n.t("agent.addSchedule")} close={close}>
|
|
432
|
+
<form
|
|
433
|
+
className="space-y-4"
|
|
434
|
+
onSubmit={(event) => {
|
|
435
|
+
event.preventDefault();
|
|
436
|
+
if (target && cron.trim()) add({ cron: cron.trim(), target });
|
|
437
|
+
}}
|
|
438
|
+
>
|
|
439
|
+
<label className="block text-sm font-medium">
|
|
440
|
+
{i18n.t("agent.cron")}
|
|
441
|
+
<input
|
|
442
|
+
required
|
|
443
|
+
value={cron}
|
|
444
|
+
onChange={(event) => setCron(event.target.value)}
|
|
445
|
+
aria-describedby="agent-cron-hint"
|
|
446
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
447
|
+
/>
|
|
448
|
+
<span
|
|
449
|
+
id="agent-cron-hint"
|
|
450
|
+
className="mt-1 block text-xs font-normal text-muted-foreground"
|
|
451
|
+
>
|
|
452
|
+
{i18n.t("agent.cronHint")}
|
|
453
|
+
</span>
|
|
454
|
+
</label>
|
|
455
|
+
<EntryPicker
|
|
456
|
+
kind="document"
|
|
457
|
+
flows
|
|
458
|
+
value={target?.id ?? ""}
|
|
459
|
+
label={i18n.t("agent.scheduleTarget")}
|
|
460
|
+
onSelect={(entryId, entryKind: PickerKind) => {
|
|
461
|
+
if (entryKind !== "document" && entryKind !== "flow") return;
|
|
462
|
+
setTarget({ id: entryId, kind: entryKind });
|
|
463
|
+
}}
|
|
464
|
+
/>
|
|
465
|
+
<button
|
|
466
|
+
type="submit"
|
|
467
|
+
disabled={!target || cron.trim().length === 0}
|
|
468
|
+
className="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"
|
|
469
|
+
>
|
|
470
|
+
{i18n.t("common.create")}
|
|
471
|
+
</button>
|
|
472
|
+
</form>
|
|
473
|
+
</Modal>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Which model does the thinking. Changing it writes a new definition version like every other edit
|
|
478
|
+
// here — the runtime picks the provider up on its next read (its definition cache is 60 seconds).
|
|
479
|
+
function ModelSection({
|
|
480
|
+
definition,
|
|
481
|
+
agent,
|
|
482
|
+
}: {
|
|
483
|
+
definition: AgentDefinition;
|
|
484
|
+
agent: AgentDefinitionHandle;
|
|
485
|
+
}) {
|
|
486
|
+
const { i18n } = useIntelRouterContext();
|
|
487
|
+
const models = selectableModels(definition.model);
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<Section title={i18n.t("agent.model")} hint={i18n.t("agent.modelHint")}>
|
|
491
|
+
<Select
|
|
492
|
+
value={modelKey(definition.model)}
|
|
493
|
+
onValueChange={(key) => {
|
|
494
|
+
const model = parseModelKey(key);
|
|
495
|
+
if (model) agent.save.mutate({ ...definition, model });
|
|
496
|
+
}}
|
|
497
|
+
>
|
|
498
|
+
<SelectTrigger aria-label={i18n.t("agent.model")} className="w-full max-w-md">
|
|
499
|
+
<SelectValue />
|
|
500
|
+
</SelectTrigger>
|
|
501
|
+
<SelectContent>
|
|
502
|
+
{models.map((model) => (
|
|
503
|
+
<SelectItem key={modelKey(model)} value={modelKey(model)}>
|
|
504
|
+
{model.model}
|
|
505
|
+
<span className="text-xs text-muted-foreground">{model.provider}</span>
|
|
506
|
+
</SelectItem>
|
|
507
|
+
))}
|
|
508
|
+
</SelectContent>
|
|
509
|
+
</Select>
|
|
510
|
+
</Section>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type UseQueryResult, useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { AgentState } from "@/data/agent-runtime/agent-runtime.ts";
|
|
3
|
+
import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
4
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
5
|
+
|
|
6
|
+
export const agentStateKey = (agentId: string) => ["agent-state", agentId] as const;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether this agent is switched off, read from the runtime through intel.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ One query for the whole page, shared by the status line, the two head actions and the tabs
|
|
12
|
+
* that have to say "not for you". It is also the page's PROBE: the answer to "may this reader drive
|
|
13
|
+
* this agent" is only knowable by asking, because `/session` deliberately carries identity and no
|
|
14
|
+
* capabilities (`SessionUser` — "no capabilities, no token"). So a 403 here is not a failure to
|
|
15
|
+
* report but a fact to render.
|
|
16
|
+
*/
|
|
17
|
+
export function useAgentState(agentId: string): UseQueryResult<AgentState> {
|
|
18
|
+
const { data } = useIntelRouterContext();
|
|
19
|
+
return useQuery({
|
|
20
|
+
queryKey: agentStateKey(agentId),
|
|
21
|
+
queryFn: () => data.getAgentState(agentId),
|
|
22
|
+
// A refusal is an answer, and retrying it three times only delays the sentence that explains it.
|
|
23
|
+
retry: false,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The refusal that is about permission rather than about something being broken.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ 403 and nothing else. A 401 has already sent the page to the sign-in, and a 503 means this
|
|
31
|
+
* installation runs no agent runtime — reading either as "you may not" would tell somebody they
|
|
32
|
+
* lack a permission they actually have.
|
|
33
|
+
*/
|
|
34
|
+
export function agentAccessDenied(error: unknown): boolean {
|
|
35
|
+
return error instanceof IntelRequestError && error.status === 403;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The refusal that says this installation runs no agent runtime at all (#190).
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ The code, not the 503: a runtime that exists but is down may answer 503 too, and reading that
|
|
42
|
+
* as "this deployment has no agents" would explain a permanent absence to somebody who is looking
|
|
43
|
+
* at an outage. Normally `/capabilities` has already said so and the page never asks — this is the
|
|
44
|
+
* belt to that brace, for the probe that reached the door anyway.
|
|
45
|
+
*/
|
|
46
|
+
export function agentRuntimeMissing(error: unknown): boolean {
|
|
47
|
+
return error instanceof IntelRequestError && error.code === "agent_runtime_not_configured";
|
|
48
|
+
}
|