@anchrd/intel-ui 0.18.0 → 0.20.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 -9
- package/src/app/app-tree/app-tree.tsx +68 -147
- package/src/app/header-search/header-search.tsx +2 -1
- package/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/src/app/settings-dialog/settings-dialog.tsx +10 -32
- package/src/app/user-footer/user-footer.tsx +19 -0
- package/src/archive/archive.tsx +2 -1
- package/src/data/intel-data-provider/intel-data-provider.ts +42 -191
- package/src/data/intel-data-provider/intel-data-provider.types.ts +36 -120
- package/src/document-link/document-link.tsx +1 -1
- package/src/entry-picker/entry-picker.tsx +7 -7
- package/src/flow-runs/flow-runs.tsx +1 -1
- package/src/flows/flows.tsx +3 -2
- package/src/flows/node-icon/node-icon.ts +1 -1
- package/src/flows/node-palette/node-palette.tsx +1 -1
- package/src/flows/node-palette/node-palette.types.ts +1 -1
- package/src/graph-pane/graph-pane.tsx +1 -1
- package/src/i18n/de.json +14 -217
- package/src/i18n/en.json +14 -217
- package/src/i18n/es.json +14 -217
- package/src/import-bundle/import-bundle.tsx +152 -0
- package/src/kind-icon.ts +1 -15
- package/src/main.tsx +7 -10
- package/src/node-editor/node-editor.tsx +1 -1
- package/src/node-graph/graph-notice.tsx +1 -1
- package/src/node-graph/node-graph.ts +1 -1
- package/src/node-graph/node-graph.tsx +1 -1
- package/src/node-table/node-table.tsx +3 -3
- package/src/nodes/nodes.tsx +1 -35
- package/src/resource-menu/resource-menu.tsx +45 -21
- package/src/section-hint/section-hint.tsx +2 -2
- package/src/title-row/title-row.tsx +5 -22
- package/src/tools/tools.tsx +1 -3
- package/vite.config.ts +0 -4
- package/src/agent/agent-avatar/agent-avatar.tsx +0 -34
- package/src/agent/agent-calendar/agent-calendar.tsx +0 -160
- package/src/agent/agent-chat/agent-chat.tsx +0 -118
- package/src/agent/agent-costs/agent-costs.ts +0 -95
- package/src/agent/agent-cron/agent-cron.ts +0 -68
- package/src/agent/agent-definition/agent-definition.ts +0 -96
- package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +0 -48
- package/src/agent/agent-entry-title/agent-entry-title.ts +0 -72
- package/src/agent/agent-log/agent-log.tsx +0 -308
- package/src/agent/agent-models/agent-models.ts +0 -166
- package/src/agent/agent-models/agent-models.types.ts +0 -24
- package/src/agent/agent-profile/agent-profile.tsx +0 -1006
- package/src/agent/agent-state/agent-state.ts +0 -85
- package/src/agent/agent-status-dot/agent-status-dot.ts +0 -73
- package/src/agent/agent.tsx +0 -507
- package/src/board/board-calendar/board-calendar.tsx +0 -89
- package/src/board/board-card/board-card.tsx +0 -106
- package/src/board/board-data/board-data.ts +0 -241
- package/src/board/board-data/board-data.types.ts +0 -63
- package/src/board/board-detail/board-detail.tsx +0 -629
- package/src/board/board-gantt/board-gantt.ts +0 -545
- package/src/board/board-gantt/board-gantt.tsx +0 -286
- package/src/board/board-graph/board-graph.ts +0 -174
- package/src/board/board-graph/board-graph.tsx +0 -168
- package/src/board/board-items/board-items.ts +0 -183
- package/src/board/board-kanban/board-kanban.ts +0 -137
- package/src/board/board-kanban/board-kanban.tsx +0 -257
- package/src/board/board-status/board-status.ts +0 -59
- package/src/board/board-statuses/board-statuses.ts +0 -63
- package/src/board/board-statuses/board-statuses.tsx +0 -228
- package/src/board/board-table/board-table.ts +0 -33
- package/src/board/board-table/board-table.tsx +0 -413
- package/src/board/board-views/board-views.tsx +0 -68
- package/src/board/board-views/board-views.types.ts +0 -29
- package/src/board/board.tsx +0 -251
- package/src/components/ui/item-calendar.tsx +0 -181
- package/src/components/ui/item-gantt.tsx +0 -463
- package/src/components/ui/kanban.tsx +0 -282
- package/src/data/agent-runtime/agent-runtime.ts +0 -131
- package/src/hooks/use-capabilities.ts +0 -22
- package/src/hooks/use-model-catalog.ts +0 -26
- package/src/timezone/timezone-combobox/timezone-combobox.tsx +0 -149
- package/src/timezone/timezone-context.tsx +0 -65
- package/src/timezone/timezone.ts +0 -174
|
@@ -1,1006 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type AgentCosts,
|
|
3
|
-
type AgentCostWindow,
|
|
4
|
-
type AgentDefinition,
|
|
5
|
-
type AgentReference,
|
|
6
|
-
AgentReferenceKinds,
|
|
7
|
-
AgentReferenceRole,
|
|
8
|
-
type AgentSchedule,
|
|
9
|
-
agentReferenceRolesFor,
|
|
10
|
-
type Node,
|
|
11
|
-
type NodeKind,
|
|
12
|
-
} from "@anchrd/intel-contract";
|
|
13
|
-
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
14
|
-
import {
|
|
15
|
-
Bot,
|
|
16
|
-
Check,
|
|
17
|
-
Copy,
|
|
18
|
-
FileText,
|
|
19
|
-
Folder,
|
|
20
|
-
KanbanSquare,
|
|
21
|
-
KeyRound,
|
|
22
|
-
MessageSquare,
|
|
23
|
-
Paperclip,
|
|
24
|
-
Plus,
|
|
25
|
-
Table2,
|
|
26
|
-
Trash2,
|
|
27
|
-
Wrench,
|
|
28
|
-
} from "lucide-react";
|
|
29
|
-
import { useState } from "react";
|
|
30
|
-
import { costWindow, formatCost, useAgentCosts } from "@/agent/agent-costs/agent-costs.ts";
|
|
31
|
-
import { type AgentDefinitionHandle, asDraft } from "@/agent/agent-definition/agent-definition.ts";
|
|
32
|
-
import { delegationNotice } from "@/agent/agent-delegation-notice/agent-delegation-notice.ts";
|
|
33
|
-
import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
|
|
34
|
-
import {
|
|
35
|
-
formatContextTokens,
|
|
36
|
-
formatPricePerMillion,
|
|
37
|
-
modelFacts,
|
|
38
|
-
modelKey,
|
|
39
|
-
parseModelKey,
|
|
40
|
-
selectableModels,
|
|
41
|
-
} from "@/agent/agent-models/agent-models.ts";
|
|
42
|
-
import type { ModelFacts } from "@/agent/agent-models/agent-models.types.ts";
|
|
43
|
-
import {
|
|
44
|
-
Select,
|
|
45
|
-
SelectContent,
|
|
46
|
-
SelectItem,
|
|
47
|
-
SelectTrigger,
|
|
48
|
-
SelectValue,
|
|
49
|
-
} from "@/components/ui/select";
|
|
50
|
-
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
51
|
-
import { agentMcpAddress } from "@/data/agent-runtime/agent-runtime.ts";
|
|
52
|
-
import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
|
|
53
|
-
import { useModelCatalog } from "@/hooks/use-model-catalog.ts";
|
|
54
|
-
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
55
|
-
import { Modal } from "@/modal/modal.tsx";
|
|
56
|
-
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
57
|
-
import { SectionHint } from "@/section-hint/section-hint.tsx";
|
|
58
|
-
import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
|
|
59
|
-
import { useTimezone } from "@/timezone/timezone-context.tsx";
|
|
60
|
-
import { useSessionUser, useUserName } from "@/user-name/user-name.ts";
|
|
61
|
-
|
|
62
|
-
// No dividers between sections — the space is the separation (#204). The explanation sits behind
|
|
63
|
-
// the icon rather than under the title, so the page shows what the agent IS and keeps the prose
|
|
64
|
-
// for whoever asks.
|
|
65
|
-
function Section({
|
|
66
|
-
title,
|
|
67
|
-
hint,
|
|
68
|
-
children,
|
|
69
|
-
}: {
|
|
70
|
-
title: string;
|
|
71
|
-
hint?: string;
|
|
72
|
-
children: React.ReactNode;
|
|
73
|
-
}) {
|
|
74
|
-
return (
|
|
75
|
-
<section aria-label={title} className="px-6 py-7">
|
|
76
|
-
<div className="flex items-center gap-1.5">
|
|
77
|
-
<h3 className="text-sm font-semibold">{title}</h3>
|
|
78
|
-
{hint ? <SectionHint hint={hint} /> : null}
|
|
79
|
-
</div>
|
|
80
|
-
<div className="mt-3">{children}</div>
|
|
81
|
-
</section>
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Everything an agent is, on one page: how to reach it, what it reads, what it may call, when it
|
|
87
|
-
* acts on its own, which model does the thinking, and who it is in Gate.
|
|
88
|
-
*
|
|
89
|
-
* ⚠️ Four of the six sections write, and they all write the SAME way — a whole new definition
|
|
90
|
-
* version through `save` (see `agent-definition.ts`). Tools joined them with D30: what it writes is
|
|
91
|
-
* a selection of whole MCP servers out of the signed-in person's own portal connection, never a
|
|
92
|
-
* mirrored permission, so ADR-0003 still holds — the catalog behind a chosen server stays a live
|
|
93
|
-
* `tools/list`.
|
|
94
|
-
*
|
|
95
|
-
* ⚠️ Contact and Identity are the two that do not. Identity acts in Gate and in the agent runtime
|
|
96
|
-
* rather than on the document, and a key has no business being in a definition — a definition is
|
|
97
|
-
* versioned, shared and read into model context (ADR-0005 §4).
|
|
98
|
-
*/
|
|
99
|
-
export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
|
|
100
|
-
const i18n = useI18n();
|
|
101
|
-
const definition = agent.definition;
|
|
102
|
-
|
|
103
|
-
// The definition is `null` in exactly one window: the node exists and no version has been written
|
|
104
|
-
// yet. Nothing on this page can be edited until there is one, and saying so beats five sections
|
|
105
|
-
// of empty controls that all refuse.
|
|
106
|
-
if (agent.query.isPending) {
|
|
107
|
-
return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
|
|
108
|
-
}
|
|
109
|
-
if (agent.query.isError) {
|
|
110
|
-
return (
|
|
111
|
-
<div role="alert" className="space-y-3 p-6 text-sm">
|
|
112
|
-
<p className="text-destructive">{i18n.t("agent.loadFailed")}</p>
|
|
113
|
-
<button
|
|
114
|
-
type="button"
|
|
115
|
-
onClick={() => void agent.query.refetch()}
|
|
116
|
-
className="rounded-md border px-3 py-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
117
|
-
>
|
|
118
|
-
{i18n.t("common.retry")}
|
|
119
|
-
</button>
|
|
120
|
-
</div>
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
if (!definition) {
|
|
124
|
-
return <p className="p-6 text-sm text-muted-foreground">{i18n.t("agent.noDefinition")}</p>;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return (
|
|
128
|
-
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
129
|
-
{agent.save.isError ? (
|
|
130
|
-
<p role="alert" className="border-b bg-destructive/5 px-6 py-3 text-sm text-destructive">
|
|
131
|
-
{i18n.t("agent.saveFailed")}
|
|
132
|
-
</p>
|
|
133
|
-
) : null}
|
|
134
|
-
<ContactSection node={node} />
|
|
135
|
-
<KnowledgeSection definition={definition} agent={agent} />
|
|
136
|
-
<ToolsSection definition={definition} agent={agent} />
|
|
137
|
-
<ScheduleSection definition={definition} agent={agent} />
|
|
138
|
-
<ModelSection definition={definition} agent={agent} agentId={node.id} />
|
|
139
|
-
<IdentitySection node={node} agent={agent} />
|
|
140
|
-
</div>
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Who the agent IS to the rest of the installation, and the one button that repairs it (D29, #207).
|
|
146
|
-
*
|
|
147
|
-
* ⚠️ The application ID is shown and the key is not, because only one of the two is a name. The key
|
|
148
|
-
* exists in Gate for the length of one call and goes straight into the agent runtime; there is
|
|
149
|
-
* nothing for this page to reveal, copy or store, and that is what makes an agent created here able
|
|
150
|
-
* to run without anybody opening a terminal (#200).
|
|
151
|
-
*
|
|
152
|
-
* ⚠️ Last on the page on purpose. It is the section a reader needs on the day something is wrong,
|
|
153
|
-
* not while they are describing what the agent should do.
|
|
154
|
-
*/
|
|
155
|
-
function IdentitySection({ node, agent }: { node: Node; agent: AgentDefinitionHandle }) {
|
|
156
|
-
const { data } = useIntelRouterContext();
|
|
157
|
-
const i18n = useI18n();
|
|
158
|
-
const queryClient = useQueryClient();
|
|
159
|
-
const [confirming, setConfirming] = useState(false);
|
|
160
|
-
const applicationId = agent.query.data?.applicationId ?? null;
|
|
161
|
-
|
|
162
|
-
const rotate = useMutation({
|
|
163
|
-
mutationFn: async () => await data.rotateAgentKey(node.id),
|
|
164
|
-
onSuccess: () => {
|
|
165
|
-
setConfirming(false);
|
|
166
|
-
// The run that failed with `agent_key_missing` is the reason somebody pressed this, so the
|
|
167
|
-
// page's own state is re-read rather than left showing the refusal that sent them here.
|
|
168
|
-
void queryClient.invalidateQueries({ queryKey: ["agent-state", node.id] });
|
|
169
|
-
},
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
return (
|
|
173
|
-
<Section title={i18n.t("agent.identity")} hint={i18n.t("agent.identityHint")}>
|
|
174
|
-
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
|
175
|
-
<KeyRound aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
176
|
-
<span className="min-w-0 flex-1">
|
|
177
|
-
<span className="block text-sm font-medium">{i18n.t("agent.principal")}</span>
|
|
178
|
-
<code className="mt-0.5 block truncate text-xs text-muted-foreground">
|
|
179
|
-
{applicationId ?? i18n.t("agent.principalNone")}
|
|
180
|
-
</code>
|
|
181
|
-
</span>
|
|
182
|
-
<button
|
|
183
|
-
type="button"
|
|
184
|
-
onClick={() => setConfirming(true)}
|
|
185
|
-
disabled={applicationId === null || rotate.isPending}
|
|
186
|
-
aria-busy={rotate.isPending}
|
|
187
|
-
{...(applicationId === null ? { "aria-describedby": "agent-rotate-reason" } : {})}
|
|
188
|
-
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"
|
|
189
|
-
>
|
|
190
|
-
{i18n.t("agent.rotateKey")}
|
|
191
|
-
</button>
|
|
192
|
-
</div>
|
|
193
|
-
{applicationId === null ? (
|
|
194
|
-
<p id="agent-rotate-reason" className="mt-3 text-xs text-muted-foreground">
|
|
195
|
-
{i18n.t("agent.principalNoneHint")}
|
|
196
|
-
</p>
|
|
197
|
-
) : null}
|
|
198
|
-
{rotate.isError ? (
|
|
199
|
-
<p role="alert" className="mt-3 text-xs text-destructive">
|
|
200
|
-
{i18n.t("agent.rotateKeyFailed")}
|
|
201
|
-
</p>
|
|
202
|
-
) : null}
|
|
203
|
-
{rotate.isSuccess ? (
|
|
204
|
-
<p role="status" className="mt-3 text-xs text-muted-foreground">
|
|
205
|
-
{i18n.t("agent.rotateKeyDone")}
|
|
206
|
-
</p>
|
|
207
|
-
) : null}
|
|
208
|
-
{confirming ? (
|
|
209
|
-
<Modal title={i18n.t("agent.rotateKey")} close={() => setConfirming(false)}>
|
|
210
|
-
{/* ⚠️ Confirmed rather than done on the first click. The previous key stops working the
|
|
211
|
-
moment this runs, so a misclick takes a working agent down until the call finishes. */}
|
|
212
|
-
<p className="text-sm text-muted-foreground">{i18n.t("agent.rotateKeyConfirm")}</p>
|
|
213
|
-
<button
|
|
214
|
-
type="button"
|
|
215
|
-
onClick={() => rotate.mutate()}
|
|
216
|
-
disabled={rotate.isPending}
|
|
217
|
-
aria-busy={rotate.isPending}
|
|
218
|
-
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"
|
|
219
|
-
>
|
|
220
|
-
{i18n.t("agent.rotateKey")}
|
|
221
|
-
</button>
|
|
222
|
-
</Modal>
|
|
223
|
-
) : null}
|
|
224
|
-
</Section>
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* How to reach this agent.
|
|
230
|
-
*
|
|
231
|
-
* ⚠️ The built-in chat is listed first and cannot be removed, because it is not a channel somebody
|
|
232
|
-
* configured — it is the runtime itself (ADR-0005 §5). Listing it beside the others, with no
|
|
233
|
-
* delete, is what says "built in, always there" without a sentence explaining it.
|
|
234
|
-
*/
|
|
235
|
-
function ContactSection({ node }: { node: Node }) {
|
|
236
|
-
const i18n = useI18n();
|
|
237
|
-
const [copied, setCopied] = useState(false);
|
|
238
|
-
// ⚠️ The runtime's own address, NOT intel's proxy. This one is dialled by a portal or another
|
|
239
|
-
// agent with its own credential (ADR-0005 §7); intel's door is for this page's browser calls and
|
|
240
|
-
// deliberately does not carry `/mcp` (#178).
|
|
241
|
-
const mcpAddress = agentMcpAddress(node.id);
|
|
242
|
-
// A relative path is what the page was built with when the runtime shares the origin, and a
|
|
243
|
-
// relative MCP address is useless to the client that has to dial it. Resolving against the page
|
|
244
|
-
// makes the copied value absolute wherever it came from.
|
|
245
|
-
const absolute =
|
|
246
|
-
typeof window === "undefined" ? mcpAddress : new URL(mcpAddress, window.location.origin).href;
|
|
247
|
-
|
|
248
|
-
return (
|
|
249
|
-
<Section title={i18n.t("agent.contact")} hint={i18n.t("agent.contactHint")}>
|
|
250
|
-
<ul className="space-y-2">
|
|
251
|
-
<li className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
|
252
|
-
<MessageSquare aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
253
|
-
<span className="min-w-0 flex-1">
|
|
254
|
-
<span className="block text-sm font-medium">{i18n.t("agent.contactChat")}</span>
|
|
255
|
-
<span className="block text-xs text-muted-foreground">
|
|
256
|
-
{i18n.t("agent.contactChatBuiltIn")}
|
|
257
|
-
</span>
|
|
258
|
-
</span>
|
|
259
|
-
</li>
|
|
260
|
-
<li className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
|
261
|
-
<Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
262
|
-
<span className="min-w-0 flex-1">
|
|
263
|
-
<span className="block text-sm font-medium">{i18n.t("agent.contactMcp")}</span>
|
|
264
|
-
<code className="mt-0.5 block truncate text-xs text-muted-foreground">{absolute}</code>
|
|
265
|
-
</span>
|
|
266
|
-
<button
|
|
267
|
-
type="button"
|
|
268
|
-
onClick={() => {
|
|
269
|
-
void navigator.clipboard.writeText(absolute).then(() => {
|
|
270
|
-
setCopied(true);
|
|
271
|
-
setTimeout(() => setCopied(false), 2_000);
|
|
272
|
-
});
|
|
273
|
-
}}
|
|
274
|
-
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"
|
|
275
|
-
>
|
|
276
|
-
{copied ? (
|
|
277
|
-
<Check aria-hidden="true" className="size-3.5" />
|
|
278
|
-
) : (
|
|
279
|
-
<Copy aria-hidden="true" className="size-3.5" />
|
|
280
|
-
)}
|
|
281
|
-
{i18n.t(copied ? "agent.copied" : "agent.copy")}
|
|
282
|
-
</button>
|
|
283
|
-
</li>
|
|
284
|
-
</ul>
|
|
285
|
-
{/* ⚠️ Disabled, with the reason behind a hover rather than on the page (#204). A mail contact
|
|
286
|
-
is deployment configuration (`AGENT_MAILBOXES`) and deliberately not part of the definition
|
|
287
|
-
— ADR-0005 §4 — so a working button here would write a field the runtime never reads. The
|
|
288
|
-
span carries the tooltip because a disabled button swallows the pointer events the trigger
|
|
289
|
-
listens for; the sr-only copy keeps the reason where `aria-describedby` points. */}
|
|
290
|
-
<div className="mt-3">
|
|
291
|
-
<TooltipProvider delayDuration={300}>
|
|
292
|
-
<Tooltip>
|
|
293
|
-
<TooltipTrigger asChild>
|
|
294
|
-
<span className="inline-flex">
|
|
295
|
-
<button
|
|
296
|
-
type="button"
|
|
297
|
-
disabled
|
|
298
|
-
aria-describedby="agent-contact-add-reason"
|
|
299
|
-
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm disabled:opacity-50"
|
|
300
|
-
>
|
|
301
|
-
<Plus aria-hidden="true" className="size-4" />
|
|
302
|
-
{i18n.t("agent.contactAdd")}
|
|
303
|
-
</button>
|
|
304
|
-
</span>
|
|
305
|
-
</TooltipTrigger>
|
|
306
|
-
<TooltipContent className="max-w-xs">{i18n.t("agent.contactAddReason")}</TooltipContent>
|
|
307
|
-
</Tooltip>
|
|
308
|
-
</TooltipProvider>
|
|
309
|
-
<p id="agent-contact-add-reason" className="sr-only">
|
|
310
|
-
{i18n.t("agent.contactAddReason")}
|
|
311
|
-
</p>
|
|
312
|
-
</div>
|
|
313
|
-
</Section>
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Everything any role accepts, which is what the picker may offer. Derived from the contract's one
|
|
318
|
-
// table rather than written out again — a second list here would drift.
|
|
319
|
-
const pickableKinds: NodeKind[] = [
|
|
320
|
-
...new Set(Object.values(AgentReferenceKinds).flat()),
|
|
321
|
-
] as NodeKind[];
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* The role a freshly picked entry starts as — the widest thing that kind can honestly be.
|
|
325
|
-
*
|
|
326
|
-
* A folder starts as search space, the way it always has; a document or a table starts as the
|
|
327
|
-
* system message, which is the only role that reads a single node at all. `null` for a flow and for
|
|
328
|
-
* a kind no role accepts, and the caller then adds nothing rather than inventing a role.
|
|
329
|
-
*/
|
|
330
|
-
function defaultRoleFor(kind: PickerKind): AgentReferenceRole | null {
|
|
331
|
-
if (kind === "flow") return null;
|
|
332
|
-
return kind === "folder" ? "semantic-context" : (agentReferenceRolesFor(kind)[0] ?? null);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* What the agent reads, and in which capacity.
|
|
337
|
-
*
|
|
338
|
-
* ⚠️ The role is a select with a visible chevron, not a coloured word. #143 asks for that
|
|
339
|
-
* explicitly, and the reason is that the three roles do very different things — a folder read as
|
|
340
|
-
* the system message becomes instructions, one read as memory is written back to. Something that
|
|
341
|
-
* changes what a document MEANS to an agent must look changeable.
|
|
342
|
-
*
|
|
343
|
-
* ⚠️ Since #255 a row can also be a single document or table, and the role select shows only the
|
|
344
|
-
* roles that kind can carry. `memory` and `semantic-context` stay folders — the agent searches both
|
|
345
|
-
* and writes into one of them — so a document simply never offers them.
|
|
346
|
-
*
|
|
347
|
-
* ⚠️ Memory is searched too, since #259, and the hint beside this section says so in words (#269).
|
|
348
|
-
* The role name alone reads as a write-only notebook, and somebody who believes that files things
|
|
349
|
-
* there that come back at them out of every later search.
|
|
350
|
-
*/
|
|
351
|
-
function KnowledgeSection({
|
|
352
|
-
definition,
|
|
353
|
-
agent,
|
|
354
|
-
}: {
|
|
355
|
-
definition: AgentDefinition;
|
|
356
|
-
agent: AgentDefinitionHandle;
|
|
357
|
-
}) {
|
|
358
|
-
const i18n = useI18n();
|
|
359
|
-
const [adding, setAdding] = useState(false);
|
|
360
|
-
|
|
361
|
-
function write(references: AgentReference[]) {
|
|
362
|
-
agent.save.mutate({ ...asDraft(definition), references });
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
return (
|
|
366
|
-
<Section title={i18n.t("agent.knows")} hint={i18n.t("agent.knowsHint")}>
|
|
367
|
-
{/* An empty list shows nothing at all — the add button says what belongs here (#204). */}
|
|
368
|
-
{definition.references.length === 0 ? null : (
|
|
369
|
-
<ul className="space-y-2">
|
|
370
|
-
{/* ⚠️ The position IS the identity here: `references` is an ordered array with no ids,
|
|
371
|
-
the contract permits the same folder twice, and every edit below addresses a row by
|
|
372
|
-
its index. A key built from the contents alone would collide on exactly the duplicate
|
|
373
|
-
the array is allowed to hold. */}
|
|
374
|
-
{definition.references.map((reference, index) => (
|
|
375
|
-
<ReferenceRow
|
|
376
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: the position is the identity — see above
|
|
377
|
-
key={`${reference.nodeId}:${index}`}
|
|
378
|
-
reference={reference}
|
|
379
|
-
setRole={(role) =>
|
|
380
|
-
write(
|
|
381
|
-
definition.references.map((current, position) =>
|
|
382
|
-
position === index ? { ...current, role } : current,
|
|
383
|
-
),
|
|
384
|
-
)
|
|
385
|
-
}
|
|
386
|
-
remove={() =>
|
|
387
|
-
write(definition.references.filter((_, position) => position !== index))
|
|
388
|
-
}
|
|
389
|
-
/>
|
|
390
|
-
))}
|
|
391
|
-
</ul>
|
|
392
|
-
)}
|
|
393
|
-
<button
|
|
394
|
-
type="button"
|
|
395
|
-
onClick={() => setAdding(true)}
|
|
396
|
-
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"
|
|
397
|
-
>
|
|
398
|
-
<Plus aria-hidden="true" className="size-4" />
|
|
399
|
-
{i18n.t("agent.addReference")}
|
|
400
|
-
</button>
|
|
401
|
-
{adding ? (
|
|
402
|
-
<Modal title={i18n.t("agent.addReference")} close={() => setAdding(false)}>
|
|
403
|
-
{/* ⚠️ The picker offers the UNION of what any role accepts, and the row's role select
|
|
404
|
-
then offers only the roles that accept what was picked (#255). The rule is asked in
|
|
405
|
-
this direction because the thing is chosen first: a role chosen up front would mean a
|
|
406
|
-
second dialog before the picker, and a picker that let anything be chosen and refused
|
|
407
|
-
afterwards is worse than one that never offered it. Either way no illegal pair is
|
|
408
|
-
reachable — `agentReferenceKinds` is the one list both sides read. */}
|
|
409
|
-
<EntryPicker
|
|
410
|
-
kind={pickableKinds}
|
|
411
|
-
value=""
|
|
412
|
-
label={i18n.t("agent.pickEntry")}
|
|
413
|
-
onSelect={(nodeId, entryKind) => {
|
|
414
|
-
const role = defaultRoleFor(entryKind);
|
|
415
|
-
if (role === null) return;
|
|
416
|
-
write([...definition.references, { nodeId, role }]);
|
|
417
|
-
setAdding(false);
|
|
418
|
-
}}
|
|
419
|
-
/>
|
|
420
|
-
</Modal>
|
|
421
|
-
) : null}
|
|
422
|
-
</Section>
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// What each kind looks like at a glance. An `attachment` and an `agent` cannot be picked, but an
|
|
427
|
-
// older definition may still name one, so every kind has a mark rather than a fallback that would
|
|
428
|
-
// draw two different things the same way.
|
|
429
|
-
const KindIcon: Record<NodeKind, typeof Folder> = {
|
|
430
|
-
folder: Folder,
|
|
431
|
-
document: FileText,
|
|
432
|
-
table: Table2,
|
|
433
|
-
attachment: Paperclip,
|
|
434
|
-
agent: Bot,
|
|
435
|
-
board: KanbanSquare,
|
|
436
|
-
};
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* One thing the agent reads, where it sits, and what it counts as.
|
|
440
|
-
*
|
|
441
|
-
* ⚠️ Its own component so the select can be NAMED after the entry. Three rows carrying the same
|
|
442
|
-
* "What this counts as" are three identical controls to anybody listening rather than looking — the
|
|
443
|
-
* name is beside them on screen, and the screen is exactly what that reader does not have. It needs
|
|
444
|
-
* the resolved title, which is why the lookup is a hook rather than a component here.
|
|
445
|
-
*
|
|
446
|
-
* ⚠️ The path is not decoration (#255). Two folders called `Notes` in different corners of the tree
|
|
447
|
-
* were indistinguishable here, and this is the screen on which somebody decides what an agent may
|
|
448
|
-
* read and write into.
|
|
449
|
-
*
|
|
450
|
-
* ⚠️ The kind is a symbol AND a word. The symbol is for the eye and the `sr-only` word is for
|
|
451
|
-
* everyone else — an icon with no text is a row that says "Notes" three times to a screen reader.
|
|
452
|
-
*/
|
|
453
|
-
function ReferenceRow({
|
|
454
|
-
reference,
|
|
455
|
-
setRole,
|
|
456
|
-
remove,
|
|
457
|
-
}: {
|
|
458
|
-
reference: AgentReference;
|
|
459
|
-
setRole(role: AgentReferenceRole): void;
|
|
460
|
-
remove(): void;
|
|
461
|
-
}) {
|
|
462
|
-
const i18n = useI18n();
|
|
463
|
-
const { title, known, kind, path } = useEntryTitle(reference.nodeId);
|
|
464
|
-
const Icon = kind === null ? null : KindIcon[kind];
|
|
465
|
-
// ⚠️ Only the roles this kind can carry, so an impossible pair is never offered — and the current
|
|
466
|
-
// role is kept in the list whatever it is, or an older definition would open on an empty select
|
|
467
|
-
// and the first touch of the control would silently change what the agent does.
|
|
468
|
-
const roles = [
|
|
469
|
-
...new Set([
|
|
470
|
-
...(kind === null ? AgentReferenceRole.options : agentReferenceRolesFor(kind)),
|
|
471
|
-
reference.role,
|
|
472
|
-
]),
|
|
473
|
-
];
|
|
474
|
-
|
|
475
|
-
return (
|
|
476
|
-
<li className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
|
477
|
-
{Icon ? <Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" /> : null}
|
|
478
|
-
<span className={`min-w-0 flex-1${known ? "" : " text-muted-foreground"}`}>
|
|
479
|
-
<span className="block truncate text-sm">
|
|
480
|
-
{title}
|
|
481
|
-
{kind === null ? null : <span className="sr-only"> — {i18n.t(`node.kind.${kind}`)}</span>}
|
|
482
|
-
</span>
|
|
483
|
-
{path.length > 0 ? (
|
|
484
|
-
<span className="block truncate text-xs text-muted-foreground" title={path.join(" / ")}>
|
|
485
|
-
{path.join(" / ")}
|
|
486
|
-
</span>
|
|
487
|
-
) : null}
|
|
488
|
-
</span>
|
|
489
|
-
<Select
|
|
490
|
-
value={reference.role}
|
|
491
|
-
onValueChange={(role) => {
|
|
492
|
-
const parsed = AgentReferenceRole.safeParse(role);
|
|
493
|
-
if (parsed.success) setRole(parsed.data);
|
|
494
|
-
}}
|
|
495
|
-
>
|
|
496
|
-
<SelectTrigger size="sm" aria-label={i18n.t("agent.roleFor", { title })}>
|
|
497
|
-
<SelectValue />
|
|
498
|
-
</SelectTrigger>
|
|
499
|
-
<SelectContent>
|
|
500
|
-
{roles.map((role) => (
|
|
501
|
-
<SelectItem key={role} value={role}>
|
|
502
|
-
{i18n.t(`agent.role.${role}`)}
|
|
503
|
-
</SelectItem>
|
|
504
|
-
))}
|
|
505
|
-
</SelectContent>
|
|
506
|
-
</Select>
|
|
507
|
-
<button
|
|
508
|
-
type="button"
|
|
509
|
-
onClick={remove}
|
|
510
|
-
aria-label={i18n.t("agent.removeReferenceOf", { title })}
|
|
511
|
-
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
512
|
-
>
|
|
513
|
-
<Trash2 aria-hidden="true" className="size-4" />
|
|
514
|
-
</button>
|
|
515
|
-
</li>
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function EntryTitle({ entryId, flow = false }: { entryId: string; flow?: boolean }) {
|
|
520
|
-
const { title, known } = useEntryTitle(entryId, flow);
|
|
521
|
-
return (
|
|
522
|
-
<span className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}>
|
|
523
|
-
{title}
|
|
524
|
-
</span>
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/**
|
|
529
|
-
* What the agent may call: whole MCP servers, given from the signed-in person's own portal
|
|
530
|
-
* connection (D30).
|
|
531
|
-
*
|
|
532
|
-
* ⚠️ What is saved is a SELECTION, never a catalog. The tools behind a server stay a live
|
|
533
|
-
* `tools/list` made with the delegator's token at the moment the agent runs (ADR-0003), so a server
|
|
534
|
-
* listed here is a name, not a promise — losing it in the portal takes it away from the agent with
|
|
535
|
-
* nothing to edit.
|
|
536
|
-
*
|
|
537
|
-
* ⚠️ The notice is not decoration, and it stands whether or not anything is listed yet. A shared
|
|
538
|
-
* agent runs on the DELEGATOR's connection whoever starts it, which is a real widening of who can
|
|
539
|
-
* act as this person. It is no longer a paragraph under the list (#254) — it said the same thing on
|
|
540
|
-
* every visit at the place where somebody was doing something else — but it is not gone either: it
|
|
541
|
-
* is the second half of this section's own hint, so it is in the trigger's accessible name and a
|
|
542
|
-
* keyboard reaches it, and it still stands in the picker where the delegation actually happens.
|
|
543
|
-
*/
|
|
544
|
-
function ToolsSection({
|
|
545
|
-
definition,
|
|
546
|
-
agent,
|
|
547
|
-
}: {
|
|
548
|
-
definition: AgentDefinition;
|
|
549
|
-
agent: AgentDefinitionHandle;
|
|
550
|
-
}) {
|
|
551
|
-
const { data } = useIntelRouterContext();
|
|
552
|
-
const i18n = useI18n();
|
|
553
|
-
const me = useSessionUser();
|
|
554
|
-
const delegatedBy = definition.tools?.delegatedBy ?? null;
|
|
555
|
-
const delegatorName = useUserName(delegatedBy);
|
|
556
|
-
const [adding, setAdding] = useState(false);
|
|
557
|
-
const servers = useQuery({ queryKey: ["tool-servers"], queryFn: () => data.listToolServers() });
|
|
558
|
-
const selected = definition.tools?.servers ?? [];
|
|
559
|
-
|
|
560
|
-
// ⚠️ Servers, and nothing about who delegates them. The screen has no `delegatedBy` to send and
|
|
561
|
-
// no business inventing one — Intel writes it from the session, and `AgentDefinitionInput` has no
|
|
562
|
-
// field it could travel in. This is also what lets an agent with `tools: null`, which is every
|
|
563
|
-
// freshly created one, receive its first server at all.
|
|
564
|
-
function write(next: string[]) {
|
|
565
|
-
agent.save.mutate({ ...asDraft(definition), tools: { servers: next } });
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const available = (servers.data?.items ?? []).filter(
|
|
569
|
-
(server) => !selected.includes(server.handle),
|
|
570
|
-
);
|
|
571
|
-
|
|
572
|
-
// Two whole sentences joined, never one sentence built from two fragments: each stays its own
|
|
573
|
-
// catalog entry and each translates on its own. What the join fixes is only their order.
|
|
574
|
-
const notice = delegationNotice(i18n, {
|
|
575
|
-
delegatedBy,
|
|
576
|
-
viewerId: me?.id ?? null,
|
|
577
|
-
delegatorName,
|
|
578
|
-
});
|
|
579
|
-
const hint = `${i18n.t("agent.toolsHint")} ${notice}`;
|
|
580
|
-
|
|
581
|
-
return (
|
|
582
|
-
<Section title={i18n.t("agent.tools")} hint={hint}>
|
|
583
|
-
{selected.length === 0 ? null : (
|
|
584
|
-
<ul className="space-y-2">
|
|
585
|
-
{selected.map((handle) => {
|
|
586
|
-
const known = servers.data?.items.find((server) => server.handle === handle);
|
|
587
|
-
/**
|
|
588
|
-
* ⚠️ Silence about the question is not a negative answer to it (#350). While the list is
|
|
589
|
-
* loading — and after a failed read — `servers.data` is `undefined`, and `known` is
|
|
590
|
-
* then falsy for exactly the same reason as "the portal answered and this handle was
|
|
591
|
-
* not in it". Drawn as one, a row said `You no longer reach this server` for half a
|
|
592
|
-
* second on every visit: a statement about a REVOKED permission, and the sentence that
|
|
593
|
-
* makes somebody re-delegate, sign in to the portal again, or open a ticket.
|
|
594
|
-
*
|
|
595
|
-
* It is the same rule `adapters/tools` keeps for `portalConnected` and `reached` on the
|
|
596
|
-
* server side, where `packages/api/CLAUDE.md` spells it out — a missing field is a
|
|
597
|
-
* reason to say less, never to refuse more. This is the one place that said it the
|
|
598
|
-
* other way round.
|
|
599
|
-
*/
|
|
600
|
-
const answered = servers.isSuccess;
|
|
601
|
-
return (
|
|
602
|
-
<li
|
|
603
|
-
key={handle}
|
|
604
|
-
className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
|
|
605
|
-
>
|
|
606
|
-
<Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
607
|
-
<span
|
|
608
|
-
className={`min-w-0 flex-1 truncate text-sm${known ? "" : " text-muted-foreground"}`}
|
|
609
|
-
>
|
|
610
|
-
{known?.name ?? handle}
|
|
611
|
-
</span>
|
|
612
|
-
{/* A server the signed-in person no longer reaches still stands in the definition,
|
|
613
|
-
and saying so beats drawing it as if it worked. The agent gets nothing from it
|
|
614
|
-
either — the catalog is cut against what the delegator reaches. But only once
|
|
615
|
-
the portal has actually answered: until then the row carries the handle and
|
|
616
|
-
nothing else, which is everything that is known about it. */}
|
|
617
|
-
<span className="text-xs text-muted-foreground">
|
|
618
|
-
{known
|
|
619
|
-
? i18n.t("tools.toolCount", { count: String(known.toolCount) })
|
|
620
|
-
: answered
|
|
621
|
-
? i18n.t("agent.toolServerUnavailable")
|
|
622
|
-
: null}
|
|
623
|
-
</span>
|
|
624
|
-
<button
|
|
625
|
-
type="button"
|
|
626
|
-
onClick={() => write(selected.filter((current) => current !== handle))}
|
|
627
|
-
aria-label={i18n.t("agent.removeToolServerOf", { name: known?.name ?? handle })}
|
|
628
|
-
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
629
|
-
>
|
|
630
|
-
<Trash2 aria-hidden="true" className="size-4" />
|
|
631
|
-
</button>
|
|
632
|
-
</li>
|
|
633
|
-
);
|
|
634
|
-
})}
|
|
635
|
-
</ul>
|
|
636
|
-
)}
|
|
637
|
-
<button
|
|
638
|
-
type="button"
|
|
639
|
-
onClick={() => setAdding(true)}
|
|
640
|
-
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"
|
|
641
|
-
>
|
|
642
|
-
<Plus aria-hidden="true" className="size-4" />
|
|
643
|
-
{i18n.t("agent.addTool")}
|
|
644
|
-
</button>
|
|
645
|
-
{adding ? (
|
|
646
|
-
<Modal title={i18n.t("agent.addTool")} close={() => setAdding(false)}>
|
|
647
|
-
{servers.isPending ? (
|
|
648
|
-
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
649
|
-
) : servers.isError ? (
|
|
650
|
-
<p role="alert" className="text-sm text-destructive">
|
|
651
|
-
{i18n.t("tools.unreachableHelp")}
|
|
652
|
-
</p>
|
|
653
|
-
) : servers.data?.portalConnected === false ? (
|
|
654
|
-
<p className="text-sm text-muted-foreground">{i18n.t("agent.toolsNotConnected")}</p>
|
|
655
|
-
) : available.length === 0 ? (
|
|
656
|
-
<p className="text-sm text-muted-foreground">{i18n.t("agent.toolsNoneLeft")}</p>
|
|
657
|
-
) : (
|
|
658
|
-
<ul className="space-y-2">
|
|
659
|
-
{available.map((server) => (
|
|
660
|
-
<li key={server.handle}>
|
|
661
|
-
<button
|
|
662
|
-
type="button"
|
|
663
|
-
onClick={() => {
|
|
664
|
-
write([...selected, server.handle]);
|
|
665
|
-
setAdding(false);
|
|
666
|
-
}}
|
|
667
|
-
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"
|
|
668
|
-
>
|
|
669
|
-
<Wrench aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
|
670
|
-
<span className="min-w-0 flex-1 truncate text-sm">{server.name}</span>
|
|
671
|
-
<span className="text-xs text-muted-foreground">
|
|
672
|
-
{i18n.t("tools.toolCount", { count: String(server.toolCount) })}
|
|
673
|
-
</span>
|
|
674
|
-
</button>
|
|
675
|
-
</li>
|
|
676
|
-
))}
|
|
677
|
-
</ul>
|
|
678
|
-
)}
|
|
679
|
-
{/* ⚠️ Here it stays a visible paragraph, unlike the section's own (#254). This is the
|
|
680
|
-
moment the delegation is actually made, and a consequence that is only reachable by
|
|
681
|
-
hovering an icon somewhere behind the dialog is a consequence nobody reads. */}
|
|
682
|
-
<p className="mt-4 text-xs text-muted-foreground">{notice}</p>
|
|
683
|
-
</Modal>
|
|
684
|
-
) : null}
|
|
685
|
-
</Section>
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* When the agent acts without being asked.
|
|
691
|
-
*
|
|
692
|
-
* ⚠️ The target is a flow OR a document and nothing else — the contract says so, and the picker is
|
|
693
|
-
* told to offer both rather than being given a free text field. A schedule aimed at something that
|
|
694
|
-
* cannot be run is a run that fails at 3 a.m. with nobody watching.
|
|
695
|
-
*/
|
|
696
|
-
function ScheduleSection({
|
|
697
|
-
definition,
|
|
698
|
-
agent,
|
|
699
|
-
}: {
|
|
700
|
-
definition: AgentDefinition;
|
|
701
|
-
agent: AgentDefinitionHandle;
|
|
702
|
-
}) {
|
|
703
|
-
const i18n = useI18n();
|
|
704
|
-
const [adding, setAdding] = useState(false);
|
|
705
|
-
|
|
706
|
-
function write(schedules: AgentSchedule[]) {
|
|
707
|
-
agent.save.mutate({ ...asDraft(definition), schedules });
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
return (
|
|
711
|
-
<Section title={i18n.t("agent.schedule")} hint={i18n.t("agent.scheduleHint")}>
|
|
712
|
-
{definition.schedules.length === 0 ? null : (
|
|
713
|
-
<ul className="space-y-2">
|
|
714
|
-
{definition.schedules.map((schedule, index) => (
|
|
715
|
-
<li
|
|
716
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: same as the references above — an ordered array with no ids, edited by position
|
|
717
|
-
key={`${schedule.cron}@${schedule.timezone}:${schedule.target.id}:${index}`}
|
|
718
|
-
className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3"
|
|
719
|
-
>
|
|
720
|
-
<code className="rounded bg-muted px-2 py-1 text-xs">{schedule.cron}</code>
|
|
721
|
-
{/* The zone belongs beside the expression: "0 8 * * *" alone does not say eight
|
|
722
|
-
o'clock WHERE, and that is the whole difference this ticket is about. */}
|
|
723
|
-
<span className="text-xs text-muted-foreground">{schedule.timezone}</span>
|
|
724
|
-
<EntryTitle entryId={schedule.target.id} flow={schedule.target.kind === "flow"} />
|
|
725
|
-
<span className="text-xs text-muted-foreground">
|
|
726
|
-
{i18n.t(`node.kind.${schedule.target.kind}`)}
|
|
727
|
-
</span>
|
|
728
|
-
<button
|
|
729
|
-
type="button"
|
|
730
|
-
onClick={() =>
|
|
731
|
-
write(definition.schedules.filter((_, position) => position !== index))
|
|
732
|
-
}
|
|
733
|
-
aria-label={i18n.t("agent.removeSchedule")}
|
|
734
|
-
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
735
|
-
>
|
|
736
|
-
<Trash2 aria-hidden="true" className="size-4" />
|
|
737
|
-
</button>
|
|
738
|
-
</li>
|
|
739
|
-
))}
|
|
740
|
-
</ul>
|
|
741
|
-
)}
|
|
742
|
-
<button
|
|
743
|
-
type="button"
|
|
744
|
-
onClick={() => setAdding(true)}
|
|
745
|
-
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"
|
|
746
|
-
>
|
|
747
|
-
<Plus aria-hidden="true" className="size-4" />
|
|
748
|
-
{i18n.t("agent.addSchedule")}
|
|
749
|
-
</button>
|
|
750
|
-
{adding ? (
|
|
751
|
-
<ScheduleDialog
|
|
752
|
-
close={() => setAdding(false)}
|
|
753
|
-
add={(schedule) => {
|
|
754
|
-
write([...definition.schedules, schedule]);
|
|
755
|
-
setAdding(false);
|
|
756
|
-
}}
|
|
757
|
-
/>
|
|
758
|
-
) : null}
|
|
759
|
-
</Section>
|
|
760
|
-
);
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSchedule): void }) {
|
|
764
|
-
const i18n = useI18n();
|
|
765
|
-
const [cron, setCron] = useState("0 8 * * *");
|
|
766
|
-
// ⚠️ The reader's preference is the SUGGESTION, and only at creation time (#228). What is stored
|
|
767
|
-
// is this value, so the schedule keeps meaning the same hour after its author travels — and it
|
|
768
|
-
// means the same hour to everybody else reading the agent.
|
|
769
|
-
const preferred = useTimezone();
|
|
770
|
-
const [timezone, setTimezone] = useState(preferred);
|
|
771
|
-
const [target, setTarget] = useState<{ id: string; kind: "document" | "flow" } | null>(null);
|
|
772
|
-
|
|
773
|
-
return (
|
|
774
|
-
<Modal title={i18n.t("agent.addSchedule")} close={close}>
|
|
775
|
-
<form
|
|
776
|
-
className="space-y-4"
|
|
777
|
-
onSubmit={(event) => {
|
|
778
|
-
event.preventDefault();
|
|
779
|
-
if (target && cron.trim()) add({ cron: cron.trim(), timezone, target });
|
|
780
|
-
}}
|
|
781
|
-
>
|
|
782
|
-
<label className="block text-sm font-medium">
|
|
783
|
-
{i18n.t("agent.cron")}
|
|
784
|
-
<input
|
|
785
|
-
required
|
|
786
|
-
value={cron}
|
|
787
|
-
onChange={(event) => setCron(event.target.value)}
|
|
788
|
-
aria-describedby="agent-cron-hint"
|
|
789
|
-
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"
|
|
790
|
-
/>
|
|
791
|
-
<span
|
|
792
|
-
id="agent-cron-hint"
|
|
793
|
-
className="mt-1 block text-xs font-normal text-muted-foreground"
|
|
794
|
-
>
|
|
795
|
-
{i18n.t("agent.cronHint")}
|
|
796
|
-
</span>
|
|
797
|
-
</label>
|
|
798
|
-
{/* ⚠️ The hint is behind the ⓘ (#249) and ALSO `sr-only` below. The two are not a
|
|
799
|
-
duplicate: the tooltip's content is in the accessibility tree only while it is open, so
|
|
800
|
-
the field would lose the description `aria-describedby` promises the moment the sentence
|
|
801
|
-
became a hover. Route (1) of the ticket, and the cheaper of the two — nothing has to be
|
|
802
|
-
argued about a description that is simply still there. */}
|
|
803
|
-
<div className="flex items-center gap-1.5">
|
|
804
|
-
<label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
|
|
805
|
-
{i18n.t("agent.scheduleTimezone")}
|
|
806
|
-
</label>
|
|
807
|
-
<SectionHint hint={i18n.t("agent.scheduleTimezoneHint")} />
|
|
808
|
-
</div>
|
|
809
|
-
<TimezoneCombobox
|
|
810
|
-
id="agent-schedule-timezone"
|
|
811
|
-
value={timezone}
|
|
812
|
-
onChange={setTimezone}
|
|
813
|
-
describedBy="agent-timezone-hint"
|
|
814
|
-
/>
|
|
815
|
-
<span id="agent-timezone-hint" className="sr-only">
|
|
816
|
-
{i18n.t("agent.scheduleTimezoneHint")}
|
|
817
|
-
</span>
|
|
818
|
-
<EntryPicker
|
|
819
|
-
kind="document"
|
|
820
|
-
flows
|
|
821
|
-
value={target?.id ?? ""}
|
|
822
|
-
label={i18n.t("agent.scheduleTarget")}
|
|
823
|
-
onSelect={(entryId, entryKind: PickerKind) => {
|
|
824
|
-
if (entryKind !== "document" && entryKind !== "flow") return;
|
|
825
|
-
setTarget({ id: entryId, kind: entryKind });
|
|
826
|
-
}}
|
|
827
|
-
/>
|
|
828
|
-
<button
|
|
829
|
-
type="submit"
|
|
830
|
-
disabled={!target || cron.trim().length === 0}
|
|
831
|
-
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"
|
|
832
|
-
>
|
|
833
|
-
{i18n.t("common.create")}
|
|
834
|
-
</button>
|
|
835
|
-
</form>
|
|
836
|
-
</Modal>
|
|
837
|
-
);
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
/**
|
|
841
|
-
* Which model does the thinking, what it costs per million tokens — and what this agent has
|
|
842
|
-
* actually cost with it.
|
|
843
|
-
*
|
|
844
|
-
* ⚠️ Changing it writes a new definition version like every other edit here; the runtime picks the
|
|
845
|
-
* provider up on its next read (its definition cache is 60 seconds).
|
|
846
|
-
*
|
|
847
|
-
* ⚠️ The figures come from intel's catalog since #257 and no longer from a table in this bundle.
|
|
848
|
-
* The table was a copy that aged silently, and the reason it stayed one — "a token does not belong
|
|
849
|
-
* in a SPA" — was correct and is exactly why the fetch moved to intel rather than into this
|
|
850
|
-
* component.
|
|
851
|
-
*
|
|
852
|
-
* ⚠️ The two numbers beside each other are a price and a bill, and they must not read as one thing.
|
|
853
|
-
* A price per million tokens answers "what does this model cost"; the thirty-day figure answers
|
|
854
|
-
* "what has this agent cost", it is money already spent on runs that used whatever model was chosen
|
|
855
|
-
* AT THE TIME, and the sentence under it says so. Without that, switching the model would make the
|
|
856
|
-
* old figure read as a forecast for the new one.
|
|
857
|
-
*/
|
|
858
|
-
function ModelSection({
|
|
859
|
-
definition,
|
|
860
|
-
agent,
|
|
861
|
-
agentId,
|
|
862
|
-
}: {
|
|
863
|
-
definition: AgentDefinition;
|
|
864
|
-
agent: AgentDefinitionHandle;
|
|
865
|
-
agentId: string;
|
|
866
|
-
}) {
|
|
867
|
-
const i18n = useI18n();
|
|
868
|
-
const catalog = useModelCatalog();
|
|
869
|
-
const costs = useAgentCosts(agentId);
|
|
870
|
-
const models = selectableModels(definition.model);
|
|
871
|
-
const current = modelFacts(definition.model, catalog.data?.entries);
|
|
872
|
-
const spent = costWindow(costs.data, 30);
|
|
873
|
-
|
|
874
|
-
return (
|
|
875
|
-
<Section title={i18n.t("agent.model")} hint={i18n.t("agent.modelHint")}>
|
|
876
|
-
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
877
|
-
<Select
|
|
878
|
-
value={modelKey(definition.model)}
|
|
879
|
-
onValueChange={(key) => {
|
|
880
|
-
const model = parseModelKey(key);
|
|
881
|
-
if (model) agent.save.mutate({ ...asDraft(definition), model });
|
|
882
|
-
}}
|
|
883
|
-
>
|
|
884
|
-
<SelectTrigger aria-label={i18n.t("agent.model")} className="w-full max-w-md sm:w-72">
|
|
885
|
-
{/* ⚠️ Own children rather than the default mirror of the chosen item: the item carries
|
|
886
|
-
the figures too, and mirrored into the trigger they would stand twice in one row. */}
|
|
887
|
-
<SelectValue>
|
|
888
|
-
<ModelName facts={current} />
|
|
889
|
-
</SelectValue>
|
|
890
|
-
</SelectTrigger>
|
|
891
|
-
<SelectContent>
|
|
892
|
-
{models.map((model) => {
|
|
893
|
-
const facts = modelFacts(model, catalog.data?.entries);
|
|
894
|
-
return (
|
|
895
|
-
<SelectItem key={modelKey(model)} value={modelKey(model)}>
|
|
896
|
-
{/* ⚠️ The space is not formatting. The option's accessible name is its text run
|
|
897
|
-
together, so without it a screen reader reads "…70B Fast24k context". The eye
|
|
898
|
-
never notices, because the row is a flex box with a gap. */}
|
|
899
|
-
<ModelName facts={facts} /> <ModelFigures facts={facts} />
|
|
900
|
-
</SelectItem>
|
|
901
|
-
);
|
|
902
|
-
})}
|
|
903
|
-
</SelectContent>
|
|
904
|
-
</Select>
|
|
905
|
-
<ModelFigures facts={current} />
|
|
906
|
-
</div>
|
|
907
|
-
{/* ⚠️ Said once, under the row, and only when something on screen actually came off the
|
|
908
|
-
table. A silent fallback to typed-out prices is the state this feature replaced. */}
|
|
909
|
-
{current.stale ? (
|
|
910
|
-
<p className="mt-2 text-xs text-muted-foreground">{i18n.t("agent.modelFiguresStale")}</p>
|
|
911
|
-
) : null}
|
|
912
|
-
<AgentSpend
|
|
913
|
-
spent={spent}
|
|
914
|
-
status={costs.data?.status}
|
|
915
|
-
partial={costs.data?.partial ?? false}
|
|
916
|
-
/>
|
|
917
|
-
</Section>
|
|
918
|
-
);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
/**
|
|
922
|
-
* What this agent has actually cost in the last thirty days (#257).
|
|
923
|
-
*
|
|
924
|
-
* ⚠️ Written in the past tense and labelled with the models that produced it, because it is the
|
|
925
|
-
* only thing standing between "$0.23" and a reader taking it for what the model above will cost.
|
|
926
|
-
* The models are named from the gateway's own log lines, so a period spent on a different model
|
|
927
|
-
* says so by itself.
|
|
928
|
-
*
|
|
929
|
-
* ⚠️ Nothing is rendered where the figure is not known. The Runs tab is where the reason belongs —
|
|
930
|
-
* it is the screen about runs — and repeating "no read token" beside a model select would be an
|
|
931
|
-
* error message in the middle of an unrelated decision.
|
|
932
|
-
*
|
|
933
|
-
* ⚠️ `partial` is the one exception to that division, and it has to be repeated here. It does not
|
|
934
|
-
* say the figure is missing — it says the figure shown IS NOT THE TOTAL, because the gateway read
|
|
935
|
-
* stopped at its page limit. A lower bound printed as if it were a sum is the same lie as a zero
|
|
936
|
-
* printed as if it were free, and this is where somebody reads it while deciding what to spend.
|
|
937
|
-
* An agent on a frequent schedule crosses that limit within thirty days as a matter of course; the
|
|
938
|
-
* seven-day figure practically never does, which makes the case rarer and therefore easier to miss.
|
|
939
|
-
*/
|
|
940
|
-
function AgentSpend({
|
|
941
|
-
spent,
|
|
942
|
-
status,
|
|
943
|
-
partial,
|
|
944
|
-
}: {
|
|
945
|
-
spent: AgentCostWindow | null;
|
|
946
|
-
status: AgentCosts["status"] | undefined;
|
|
947
|
-
partial: boolean;
|
|
948
|
-
}) {
|
|
949
|
-
const i18n = useI18n();
|
|
950
|
-
if (status !== "read" || !spent) return null;
|
|
951
|
-
|
|
952
|
-
return (
|
|
953
|
-
<p className="mt-3 text-sm">
|
|
954
|
-
<span className="font-medium">
|
|
955
|
-
{i18n.t("agent.costsSpent", {
|
|
956
|
-
amount: formatCost(spent.cost, i18n.locale),
|
|
957
|
-
days: String(spent.days),
|
|
958
|
-
})}
|
|
959
|
-
</span>
|
|
960
|
-
{spent.models.length > 0 ? (
|
|
961
|
-
<span className="text-muted-foreground">
|
|
962
|
-
{" "}
|
|
963
|
-
{i18n.t("agent.costsSpentWith", { models: spent.models.join(", ") })}
|
|
964
|
-
</span>
|
|
965
|
-
) : null}
|
|
966
|
-
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
967
|
-
{i18n.t("agent.costsSpentPast")}
|
|
968
|
-
</span>
|
|
969
|
-
{/* The Runs tab's own wording, deliberately the same key: two different sentences for one
|
|
970
|
-
fact would leave a reader wondering whether they are the same fact. */}
|
|
971
|
-
{partial ? (
|
|
972
|
-
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
973
|
-
{i18n.t("agent.costPartial")}
|
|
974
|
-
</span>
|
|
975
|
-
) : null}
|
|
976
|
-
</p>
|
|
977
|
-
);
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
// Brand first, then the model's own name — "Claude Sonnet 5", not "claude-sonnet-5 · anthropic". The
|
|
981
|
-
// provider stays in the definition; it names who serves the model, which is not what anybody reads a
|
|
982
|
-
// list of models for.
|
|
983
|
-
function ModelName({ facts }: { facts: ModelFacts }) {
|
|
984
|
-
return (
|
|
985
|
-
<span className="truncate">
|
|
986
|
-
<span className="font-medium">{facts.brand}</span> {facts.name}
|
|
987
|
-
</span>
|
|
988
|
-
);
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// The two numbers a model is actually chosen on. Absent for a model this installation has no figures
|
|
992
|
-
// for — the row then simply ends, because a made-up zero would be a false statement about money.
|
|
993
|
-
function ModelFigures({ facts }: { facts: ModelFacts }) {
|
|
994
|
-
const i18n = useI18n();
|
|
995
|
-
if (facts.contextTokens === null || facts.price === null) return null;
|
|
996
|
-
|
|
997
|
-
return (
|
|
998
|
-
<span className="text-xs whitespace-nowrap text-muted-foreground">
|
|
999
|
-
{i18n.t("agent.modelFigures", {
|
|
1000
|
-
context: formatContextTokens(facts.contextTokens),
|
|
1001
|
-
input: formatPricePerMillion(facts.price.inputPerMillion, i18n.locale),
|
|
1002
|
-
output: formatPricePerMillion(facts.price.outputPerMillion, i18n.locale),
|
|
1003
|
-
})}
|
|
1004
|
-
</span>
|
|
1005
|
-
);
|
|
1006
|
-
}
|