@anchrd/intel-ui 0.14.0 → 0.15.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.
@@ -11,10 +11,12 @@ import { AgentProfile } from "@/agent/agent-profile/agent-profile.tsx";
11
11
  import {
12
12
  agentAccessDenied,
13
13
  agentKeyMissing,
14
+ agentProblemDetail,
14
15
  agentRuntimeMissing,
15
16
  agentStateKey,
16
17
  useAgentState,
17
18
  } from "@/agent/agent-state/agent-state.ts";
19
+ import { agentStatus, statusDotClass } from "@/agent/agent-status-dot/agent-status-dot.ts";
18
20
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
19
21
  import {
20
22
  DropdownMenu,
@@ -23,9 +25,11 @@ import {
23
25
  DropdownMenuTrigger,
24
26
  } from "@/components/ui/dropdown-menu";
25
27
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
28
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
26
29
  import { useCapabilities } from "@/hooks/use-capabilities.ts";
27
30
  import { useI18n } from "@/i18n/i18n-context.tsx";
28
31
  import { useIntelRouterContext } from "@/router/router-context.ts";
32
+ import { useUserName } from "@/user-name/user-name.ts";
29
33
 
30
34
  const tabs = ["profile", "chat", "calendar", "log"] as const;
31
35
  type Tab = (typeof tabs)[number];
@@ -66,6 +70,9 @@ export function AgentPanel({ node }: { node: Node }) {
66
70
  <ActionSlot name="title-meta">
67
71
  <AgentStatus agentId={node.id} definition={agent.definition} />
68
72
  </ActionSlot>
73
+ <ActionSlot name="title-byline">
74
+ <AgentByline node={node} />
75
+ </ActionSlot>
69
76
  <ActionSlot name="title-actions">
70
77
  <AgentActions agentId={node.id} definition={agent.definition} />
71
78
  </ActionSlot>
@@ -108,6 +115,35 @@ export function AgentPanel({ node }: { node: Node }) {
108
115
  );
109
116
  }
110
117
 
118
+ /**
119
+ * Who made this agent — the first question anybody asks about one that works in folders with rights
120
+ * of its own (#258).
121
+ *
122
+ * ⚠️ Provenance and nothing else. An agent does NOT run with its creator's rights: it has a Gate
123
+ * Application of its own (D29), the assertion's subject is the agent, and Intel's grants on the
124
+ * tree decide what a run may read and write. Only the TOOLS run on a human's connection, and that
125
+ * is `delegatedBy` — a different field, in a different place on this page, on purpose. So this line
126
+ * says "by", never "runs as", and it carries no permission wording that would imply otherwise.
127
+ *
128
+ * ⚠️ `ownerId` and not the current version's `createdBy`: the owner is written once, from the actor
129
+ * who created the node, and never again — while `createdBy` moves to whoever saved last, which is a
130
+ * different fact and belongs to a different question.
131
+ *
132
+ * ⚠️ Unresolvable means NO line, not "by —". Most ids are unresolvable today (see `useUserName`),
133
+ * and an em dash under every foreign agent's name would be a row of furniture saying nothing.
134
+ */
135
+ function AgentByline({ node }: { node: Node }) {
136
+ const i18n = useI18n();
137
+ const name = useUserName(node.ownerId);
138
+ if (name === null) return null;
139
+
140
+ return (
141
+ <span className="block truncate text-xs text-muted-foreground" title={name}>
142
+ {i18n.t("agent.createdBy", { name })}
143
+ </span>
144
+ );
145
+ }
146
+
111
147
  /**
112
148
  * What somebody without `agents/run` sees instead of the chat and the log.
113
149
  *
@@ -141,11 +177,20 @@ function NoRuntime() {
141
177
  }
142
178
 
143
179
  /**
144
- * Whether the agent is working, whether it is switched off, and when it next will be.
180
+ * Whether the agent is working, whether it is switched off, and when it next will be — as a dot
181
+ * beside the name, and only when there is something to say (#253).
182
+ *
183
+ * ⚠️ A dot and not a sentence, and NOT ALWAYS a dot. What stood here was "Idle · no scheduled run"
184
+ * — the absence of a state, spelled out twice, at the most prominent place on the page. `null` from
185
+ * `agentStatus` is the ordinary case and renders nothing at all.
186
+ *
187
+ * ⚠️ The colour is never the only information. The state is the trigger's accessible NAME, the dot
188
+ * itself is `aria-hidden`, and the shapes differ as well as the hues — see `statusDotClass`. A
189
+ * reader who cannot separate two greys, and every reader who is listening rather than looking, gets
190
+ * the same answer as everybody else.
145
191
  *
146
192
  * ⚠️ "Running" is read from the runtime's own run list, and its absence is NOT read as "idle" — a
147
- * runtime that cannot be reached says nothing about what the agent is doing, and a status line that
148
- * quietly reported "idle" for an unreachable runtime would be wrong exactly when it matters.
193
+ * runtime that cannot be reached says nothing about what the agent is doing.
149
194
  *
150
195
  * ⚠️ "Next run" is derived from the cron expressions rather than asked. The runtime arms one alarm
151
196
  * and publishes neither it nor a schedule, so this is the same reading `AgentCalendar` does — and
@@ -164,22 +209,21 @@ export function AgentStatus({
164
209
  const runs = useQuery({
165
210
  queryKey: ["agent-runs", agentId],
166
211
  queryFn: () => data.listAgentRuns(agentId),
167
- // A status line is not worth a red screen: the runtime being unreachable is reported by the
212
+ // A status dot is not worth a red screen: the runtime being unreachable is reported by the
168
213
  // Log tab, which is the view that is actually about runs.
169
214
  retry: false,
170
215
  });
171
- const running = runs.data?.items.some((run) => run.status === "running") ?? false;
172
- const paused = state.data?.paused ?? false;
173
216
  const next = nextRunAt(definition, new Date());
174
- const parts = [
175
- runs.isError
176
- ? i18n.t("agent.statusUnknown")
177
- : paused
178
- ? i18n.t("agent.statusPaused")
179
- : running
180
- ? i18n.t("agent.statusRunning")
181
- : i18n.t("agent.statusIdle"),
182
- paused
217
+ const status = agentStatus({
218
+ runsUnreadable: runs.isError,
219
+ runs: runs.data?.items ?? [],
220
+ paused: state.data?.paused ?? false,
221
+ scheduled: next !== null,
222
+ });
223
+ if (status === null) return null;
224
+
225
+ const when =
226
+ status.kind === "paused"
183
227
  ? i18n.t("agent.pausedNoRuns")
184
228
  : next
185
229
  ? // ⚠️ Formatted in the SCHEDULE's zone and named with it (#228), not in the reader's.
@@ -194,9 +238,38 @@ export function AgentStatus({
194
238
  }).format(next.at),
195
239
  zone: next.timezone,
196
240
  })
197
- : i18n.t("agent.noNextRun"),
198
- ];
199
- return <span>{parts.join(" · ")}</span>;
241
+ : null;
242
+ // ⚠️ A past instant, so it is read in the reader's own zone and names none: unlike a schedule,
243
+ // which fires in the zone it carries, a finished run happened at one moment for everybody.
244
+ const last =
245
+ status.lastRunAt === null
246
+ ? null
247
+ : i18n.t("agent.lastRun", {
248
+ when: new Intl.DateTimeFormat(i18n.locale, {
249
+ dateStyle: "medium",
250
+ timeStyle: "short",
251
+ }).format(new Date(status.lastRunAt)),
252
+ });
253
+ const label = [i18n.t(`agent.status.${status.kind}`), last, when].filter(Boolean).join(" · ");
254
+
255
+ return (
256
+ <TooltipProvider delayDuration={300}>
257
+ <Tooltip>
258
+ {/* A real button, so the dot is reached by Tab and opened without a pointer. */}
259
+ <TooltipTrigger
260
+ type="button"
261
+ aria-label={label}
262
+ className="inline-flex items-center rounded-full p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring"
263
+ >
264
+ <span
265
+ aria-hidden="true"
266
+ className={`block size-2.5 rounded-full ${statusDotClass[status.kind]}`}
267
+ />
268
+ </TooltipTrigger>
269
+ <TooltipContent className="max-w-xs">{label}</TooltipContent>
270
+ </Tooltip>
271
+ </TooltipProvider>
272
+ );
200
273
  }
201
274
 
202
275
  /**
@@ -245,7 +318,7 @@ export function AgentActions({
245
318
  const reason = denied
246
319
  ? i18n.t("agent.notPermitted")
247
320
  : state.isError
248
- ? i18n.t("agent.statusUnknown")
321
+ ? i18n.t("agent.status.unknown")
249
322
  : null;
250
323
  const runReason =
251
324
  reason ??
@@ -258,51 +331,87 @@ export function AgentActions({
258
331
 
259
332
  return (
260
333
  <span className="flex items-center gap-2">
261
- {/* ⚠️ `sr-only` rather than `hidden` where the bar is narrow. A disabled control whose reason
262
- is `display: none` has no reason at all for a screen reader the element it points at is
263
- not in the accessibility tree. */}
334
+ {/* ⚠️ `sr-only` and no longer beside the buttons (#253): the reason belongs in the tooltip of
335
+ the control it disables, not as a sentence in the title line. But it stays IN THE
336
+ ACCESSIBILITY TREE `hidden` or `display: none` would leave `aria-describedby` pointing at
337
+ nothing, and Radix puts the tooltip's own content there only while it is open. So the words
338
+ are here for the keyboard and on the hover for the pointer, and never in the bar. */}
264
339
  {runReason ? (
265
- <span
266
- id="agent-actions-reason"
267
- className="text-xs text-muted-foreground max-sm:sr-only sm:inline"
268
- >
340
+ <span id="agent-actions-reason" className="sr-only">
269
341
  {runReason}
270
342
  </span>
271
343
  ) : null}
272
344
  {/* ⚠️ An agent with no key in its Durable Object fails every run the same way forever, so
273
345
  "try again" is advice that cannot work (#200). The one refusal with a repair path says
274
- what the repair is, and names where it lives — the profile, not this bar (D29). */}
346
+ what the repair is, and names where it lives — the profile, not this bar (D29).
347
+
348
+ ⚠️ Every other refusal is shown in its OWN words where it brought any (#201). `Resume`
349
+ fails for reasons only the runtime knows, and "that did not work" turns each of them into
350
+ the same shrug — which is how a resume that could not arm the alarm looked like one that
351
+ simply needed another click. */}
275
352
  {failure ? (
276
353
  <span role="alert" className="text-xs text-destructive max-sm:sr-only sm:inline">
277
- {i18n.t(agentKeyMissing(failure) ? "agent.keyMissing" : "agent.actionFailed")}
354
+ {agentKeyMissing(failure)
355
+ ? i18n.t("agent.keyMissing")
356
+ : (agentProblemDetail(failure) ?? i18n.t("agent.actionFailed"))}
278
357
  </span>
279
358
  ) : null}
280
- <button
281
- type="button"
282
- onClick={() => toggle.mutate()}
283
- disabled={reason !== null || state.isPending || toggle.isPending}
284
- aria-busy={toggle.isPending}
285
- {...(reason ? { "aria-describedby": "agent-actions-reason" } : {})}
286
- className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
287
- >
288
- {paused ? (
289
- <Play aria-hidden="true" className="size-4" />
290
- ) : (
291
- <Pause aria-hidden="true" className="size-4" />
292
- )}
293
- {i18n.t(paused ? "agent.resume" : "agent.pause")}
294
- </button>
295
- <RunNow
296
- targets={targets}
297
- disabled={runReason !== null || runNow.isPending}
298
- busy={runNow.isPending}
299
- describedBy={runReason ? "agent-actions-reason" : undefined}
300
- start={(target) => runNow.mutate(target)}
301
- />
359
+ <WhyDisabled reason={reason}>
360
+ <button
361
+ type="button"
362
+ onClick={() => toggle.mutate()}
363
+ disabled={reason !== null || state.isPending || toggle.isPending}
364
+ aria-busy={toggle.isPending}
365
+ {...(reason ? { "aria-describedby": "agent-actions-reason" } : {})}
366
+ className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
367
+ >
368
+ {paused ? (
369
+ <Play aria-hidden="true" className="size-4" />
370
+ ) : (
371
+ <Pause aria-hidden="true" className="size-4" />
372
+ )}
373
+ {i18n.t(paused ? "agent.resume" : "agent.pause")}
374
+ </button>
375
+ </WhyDisabled>
376
+ <WhyDisabled reason={runReason}>
377
+ <RunNow
378
+ targets={targets}
379
+ disabled={runReason !== null || runNow.isPending}
380
+ busy={runNow.isPending}
381
+ describedBy={runReason ? "agent-actions-reason" : undefined}
382
+ start={(target) => runNow.mutate(target)}
383
+ />
384
+ </WhyDisabled>
302
385
  </span>
303
386
  );
304
387
  }
305
388
 
389
+ /**
390
+ * The reason a control is shut, on its own hover instead of beside it (#253).
391
+ *
392
+ * ⚠️ The wrapping span is not decoration. A disabled button fires no pointer events, so a trigger
393
+ * bound directly to it never opens — the same trick the profile's disabled "Add contact" needs, and
394
+ * the reason both keep an `sr-only` paragraph for `aria-describedby` beside the tooltip.
395
+ *
396
+ * ⚠️ The wrapper is ALWAYS here, and only the content comes and goes. A reason appears and vanishes
397
+ * as the state query settles, and a version of this that returned the bare child when there was
398
+ * nothing to say re-created the button on every such change — which throws away its focus and, in a
399
+ * test, leaves whoever grabbed it holding a node that is no longer on screen. Without content Radix
400
+ * opens nothing at all, so the empty case costs a span and shows no bubble.
401
+ */
402
+ function WhyDisabled({ reason, children }: { reason: string | null; children: React.ReactNode }) {
403
+ return (
404
+ <TooltipProvider delayDuration={300}>
405
+ <Tooltip>
406
+ <TooltipTrigger asChild>
407
+ <span className="inline-flex">{children}</span>
408
+ </TooltipTrigger>
409
+ {reason === null ? null : <TooltipContent className="max-w-xs">{reason}</TooltipContent>}
410
+ </Tooltip>
411
+ </TooltipProvider>
412
+ );
413
+ }
414
+
306
415
  /**
307
416
  * ⚠️ One schedule runs straight away; several open a menu. A button that silently picked the first
308
417
  * of three targets would run the wrong thing at a moment somebody chose deliberately, and a menu in
@@ -476,8 +476,14 @@ export function AppTree() {
476
476
  : "opacity-40";
477
477
 
478
478
  return (
479
- <SidebarMenuItem key={entry.id} className="group/row">
480
- {/* ⚠️ The colouring sits on the ROW, not on the button inside it the difference the
479
+ <SidebarMenuItem key={entry.id}>
480
+ {/* ⚠️ `group/row` belongs HERE and not on the `SidebarMenuItem` above, which is what #252
481
+ was: the item also holds the open sub-level, and `group-hover/row` is a DESCENDANT
482
+ selector — every plus further down matched the ancestor's hover, so pointing at one
483
+ folder lit up its whole open subtree. This div holds one row and none of its children,
484
+ which is the only element on which "the row is hovered" means that one row.
485
+
486
+ ⚠️ The colouring sits on the ROW, not on the button inside it — the difference the
481
487
  ticket is about. It used to hang on `SidebarMenuButton`, whose siblings the plus and this
482
488
  menu are, so the grey stopped short of them: two icons standing outside the very row they
483
489
  operate, with a pale strip left over on the right. Taken from gate, where the same
@@ -486,7 +492,7 @@ export function AppTree() {
486
492
  The button keeps its own hover and active fill switched off rather than doubled, so the
487
493
  row shows one surface instead of two overlapping ones. */}
488
494
  <div
489
- className={`flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
495
+ className={`group/row flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
490
496
  isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : ""
491
497
  }`}
492
498
  >
@@ -14,6 +14,7 @@ import {
14
14
  } from "@/components/ui/select";
15
15
  import { useI18n, useLanguage } from "@/i18n/i18n-context.tsx";
16
16
  import { languageName } from "@/i18n/i18n-languages/i18n-languages.ts";
17
+ import { SectionHint } from "@/section-hint/section-hint.tsx";
17
18
  import { type ThemeChoice, ThemeChoices } from "@/theme/theme.ts";
18
19
  import { useTheme } from "@/theme/theme-context.tsx";
19
20
  import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
@@ -94,16 +95,24 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
94
95
  ) : null}
95
96
  {timezone ? (
96
97
  <div className="grid gap-2">
97
- <label className="text-sm font-medium" htmlFor="settings-timezone">
98
- {i18n.t("settings.timezone")}
99
- </label>
98
+ {/* ⚠️ Behind the ⓘ (#249), and the SAME component the agent profile uses — three
99
+ lines of prose under a dropdown explained something once and then pushed the
100
+ dialog apart on every open. The `sr-only` copy below is not a duplicate: Radix
101
+ puts a tooltip's content in the accessibility tree only while it is open, so the
102
+ field would silently lose the description `aria-describedby` points at. */}
103
+ <div className="flex items-center gap-1.5">
104
+ <label className="text-sm font-medium" htmlFor="settings-timezone">
105
+ {i18n.t("settings.timezone")}
106
+ </label>
107
+ <SectionHint hint={i18n.t("settings.timezoneHint")} />
108
+ </div>
100
109
  <TimezoneCombobox
101
110
  id="settings-timezone"
102
111
  value={timezone.current}
103
112
  onChange={timezone.select}
104
113
  describedBy="settings-timezone-hint"
105
114
  />
106
- <p id="settings-timezone-hint" className="text-xs text-muted-foreground">
115
+ <p id="settings-timezone-hint" className="sr-only">
107
116
  {i18n.t("settings.timezoneHint")}
108
117
  </p>
109
118
  </div>
@@ -15,6 +15,25 @@ export const AgentRunTarget = z.object({
15
15
  });
16
16
  export type AgentRunTarget = z.infer<typeof AgentRunTarget>;
17
17
 
18
+ /**
19
+ * What one run consumed at the provider, as the runtime recorded it (#250).
20
+ *
21
+ * ⚠️ `inputTokens` is the part that was NOT cached, not the size of the prompt — the prompt is all
22
+ * four fields added together. Anything that shows the first as "the prompt" will report a run that
23
+ * suddenly shrank to a twentieth of the previous one.
24
+ *
25
+ * ⚠️ Optional here, and the optionality is the feature. A run recorded before #250 carries no
26
+ * numbers at all, and that is a different fact from "used no tokens" — the row shows an empty state
27
+ * rather than four zeroes, because a zero reads as "cost nothing".
28
+ */
29
+ export const AgentUsage = z.object({
30
+ inputTokens: z.number(),
31
+ outputTokens: z.number(),
32
+ cacheCreationInputTokens: z.number(),
33
+ cacheReadInputTokens: z.number(),
34
+ });
35
+ export type AgentUsage = z.infer<typeof AgentUsage>;
36
+
18
37
  export const AgentRun = z.object({
19
38
  id: z.string(),
20
39
  trigger: z.enum(["chat", "schedule", "mail", "task", "manual"]),
@@ -24,6 +43,13 @@ export const AgentRun = z.object({
24
43
  finishedAt: z.string().nullable(),
25
44
  steps: z.number(),
26
45
  error: z.string().nullable(),
46
+ // ⚠️ `.nullish()` on both, not `.nullable()`. The runtime is a separate deployment and may be a
47
+ // version BEHIND this page: a run list from a runtime without #250/#251 has no such field at all,
48
+ // and a required one would blank the whole Log tab over a missing number.
49
+ usage: AgentUsage.nullish(),
50
+ // Which model did the thinking, as `provider:model`, written into the run rather than looked up:
51
+ // switching the model in the profile must not relabel every past run (#251).
52
+ model: z.string().nullish(),
27
53
  });
28
54
  export type AgentRun = z.infer<typeof AgentRun>;
29
55
 
@@ -1,4 +1,5 @@
1
1
  import {
2
+ AgentCosts,
2
3
  AgentKeyRotated,
3
4
  AppendTableRowsInput,
4
5
  AppendTableRowsResult,
@@ -24,6 +25,7 @@ import {
24
25
  ListFlowRunsInput,
25
26
  ListFlowsInput,
26
27
  ListNodesInput,
28
+ ModelCatalog,
27
29
  Node,
28
30
  NodeAgent,
29
31
  NodeDocument,
@@ -203,6 +205,16 @@ export function createIntelDataProvider(
203
205
  async getCapabilities() {
204
206
  return await request("/capabilities", IntelCapabilities);
205
207
  },
208
+ /**
209
+ * What the offered models cost and hold (#257).
210
+ *
211
+ * ⚠️ Asked of intel, never of Cloudflare. The account endpoint needs a token and a token does
212
+ * not belong in a SPA — which was the stated reason the price table stayed hard-coded in this
213
+ * package until now. The figures cross the wire; the credential does not.
214
+ */
215
+ async listModels() {
216
+ return await request("/models", ModelCatalog);
217
+ },
206
218
  listNodes,
207
219
  async listTreeChildren(parentId) {
208
220
  // Both sides of one folder, asked for in parallel and merged here rather than on the server:
@@ -517,6 +529,14 @@ export function createIntelDataProvider(
517
529
  async listAgentRuns(agentId) {
518
530
  return await request(agentRuntimePath(agentId, "/runs"), AgentRunList);
519
531
  },
532
+ /**
533
+ * ⚠️ The one path under `/agents/:agentId` that intel answers ITSELF (#251). The runtime has no
534
+ * idea what anything costs — the figures come out of Cloudflare's AI Gateway log, read with a
535
+ * token that lives in intel and never in the browser.
536
+ */
537
+ async getAgentCosts(agentId) {
538
+ return await request(agentRuntimePath(agentId, "/costs"), AgentCosts);
539
+ },
520
540
  async getAgentRun(agentId, runId) {
521
541
  return await request(
522
542
  agentRuntimePath(agentId, `/runs/${encodeURIComponent(runId)}`),
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AgentCosts,
2
3
  AgentKeyRotated,
3
4
  AgentScheduleTarget,
4
5
  AppendTableRowsInput,
@@ -25,6 +26,7 @@ import type {
25
26
  ListFlowRunsInput,
26
27
  ListFlowsInput,
27
28
  ListNodesInput,
29
+ ModelCatalog,
28
30
  Node,
29
31
  NodeAgent,
30
32
  NodeDocument,
@@ -86,6 +88,14 @@ export interface IntelDataProvider {
86
88
  // What this installation is equipped to do (#190) — deployment facts, not the caller's
87
89
  // permissions. `/session` stays identity only on purpose; this is the separate question.
88
90
  getCapabilities(): Promise<IntelCapabilities>;
91
+ /**
92
+ * What the offered models cost and how much they hold (#257).
93
+ *
94
+ * ⚠️ Answered by intel, which asks Cloudflare with a token this page never sees. Each entry says
95
+ * whether its figures were read live or came off intel's written-out table, and the select says so
96
+ * too — a silently stale price is the state this replaced, with more code.
97
+ */
98
+ listModels(): Promise<ModelCatalog>;
89
99
  listNodes(input?: Partial<ListNodesInput>): Promise<NodeList>;
90
100
  // One level of the shared tree: the documents and the flows filed in the same folder, in one
91
101
  // sorted list. Per level rather than recursive, so opening a folder is what costs a request.
@@ -190,6 +200,15 @@ export interface IntelDataProvider {
190
200
  // CORS. What that costs is that a 403 here means "you may not drive agents", which is a state the
191
201
  // agent page renders as an answer rather than as a failure.
192
202
  listAgentRuns(agentId: string): Promise<AgentRunList>;
203
+ /**
204
+ * What this agent has actually cost, out of Cloudflare's AI Gateway log (#251).
205
+ *
206
+ * ⚠️ Unlike its neighbours this one is answered by INTEL and not forwarded to the runtime, which
207
+ * knows nothing about money. ⚠️ Read `status` before the numbers: empty with `not_configured` or
208
+ * `unreadable` means "not known", and rendering it like "cost nothing" turns an outage into a
209
+ * saving.
210
+ */
211
+ getAgentCosts(agentId: string): Promise<AgentCosts>;
193
212
  getAgentRun(agentId: string, runId: string): Promise<AgentRunDetail>;
194
213
  // Whether the agent is switched off. Runtime state, deliberately not part of the definition: a
195
214
  // definition is versioned, shared and read into model context (#179).
@@ -32,7 +32,12 @@ export function EntryPicker({
32
32
  label,
33
33
  }: {
34
34
  // Which kind of node may be chosen. `null` offers every kind — what the Flow step uses.
35
- kind: NodeKind | null;
35
+ //
36
+ // ⚠️ A list is not a convenience (#255). An agent's system message takes a folder, a document or
37
+ // a table, and the alternative — one picker per kind, or a picker that lets everything be chosen
38
+ // and refuses afterwards — is either three dialogs or a rejection after the fact. What may be
39
+ // picked has to be the same question the caller can answer in one place.
40
+ kind: NodeKind | NodeKind[] | null;
36
41
  // ⚠️ Flows are a second list, not a kind of node, so they are asked for rather than assumed. An
37
42
  // agent's schedule may aim at either (#143); every other caller wants nodes only and pays for no
38
43
  // second request.
@@ -75,10 +80,16 @@ export function EntryPicker({
75
80
  }, [graph.data, flowList.data, flows]);
76
81
  // A flow is offered exactly when it was asked for — it is only in `nodes` at all in that case,
77
82
  // and saying so here keeps "may this be picked" one rule rather than two that must agree.
78
- const pickable = useMemo(
79
- () => (entry: PickerEntry) => entry.kind === "flow" || kind === null || entry.kind === kind,
80
- [kind],
81
- );
83
+ //
84
+ // ⚠️ The array is rebuilt from `kind` on every render when a caller passes a literal, so the set
85
+ // is keyed by its contents rather than by identity — a `useMemo` on the array itself would be a
86
+ // memo that never hits.
87
+ const wanted = Array.isArray(kind) ? kind.join(",") : kind;
88
+ const pickable = useMemo(() => {
89
+ const allowed = wanted === null ? null : new Set(wanted.split(","));
90
+ return (entry: PickerEntry) =>
91
+ entry.kind === "flow" || allowed === null || allowed.has(entry.kind);
92
+ }, [wanted]);
82
93
  const eligible = useMemo(() => nodes.filter(pickable), [nodes, pickable]);
83
94
 
84
95
  // Searching looks at every eligible node wherever it sits; browsing looks at one level. The two
@@ -0,0 +1,26 @@
1
+ import type { ModelCatalog } from "@anchrd/intel-contract";
2
+ import { type UseQueryResult, useQuery } from "@tanstack/react-query";
3
+ import { useIntelRouterContext } from "@/router/router-context.ts";
4
+
5
+ /**
6
+ * What the offered models cost and how much they hold, asked of intel once an hour at most (#257).
7
+ *
8
+ * ⚠️ The caching is the point of the hook and not an optimisation. Before this, the figures were a
9
+ * table in the bundle; the ticket's own condition for taking that away is that the replacement is
10
+ * not "one fetch every time somebody opens the select". Prices move a few times a year, so an hour
11
+ * is generous in the direction that matters.
12
+ *
13
+ * ⚠️ `retry: false`, like the tools screen's grouping query (#212). A React Query retry is PAUSED
14
+ * while the tab is unfocused, so a select that waited on a retried query could sit unusable for as
15
+ * long as the reader is looking elsewhere. This query only ENRICHES the select — the model can be
16
+ * chosen without it — so it fails fast and the row simply shows no figures.
17
+ */
18
+ export function useModelCatalog(): UseQueryResult<ModelCatalog> {
19
+ const { data } = useIntelRouterContext();
20
+ return useQuery({
21
+ queryKey: ["model-catalog"],
22
+ queryFn: () => data.listModels(),
23
+ staleTime: 60 * 60 * 1_000,
24
+ retry: false,
25
+ });
26
+ }
package/src/i18n/de.json CHANGED
@@ -282,12 +282,13 @@
282
282
  "agent.tab.chat": "Chat",
283
283
  "agent.tab.calendar": "Kalender",
284
284
  "agent.tab.log": "Protokoll",
285
- "agent.statusRunning": "Läuft",
286
- "agent.statusIdle": "Bereit",
287
- "agent.statusPaused": "Pausiert",
288
- "agent.statusUnknown": "Laufzeit nicht erreichbar",
285
+ "agent.status.running": "Läuft",
286
+ "agent.status.waiting": "Wartet auf den nächsten Lauf",
287
+ "agent.status.failed": "Letzter Lauf fehlgeschlagen",
288
+ "agent.status.paused": "Pausiert",
289
+ "agent.status.unknown": "Laufzeit nicht erreichbar",
289
290
  "agent.nextRun": "nächster Lauf {when} ({zone})",
290
- "agent.noNextRun": "kein geplanter Lauf",
291
+ "agent.lastRun": "letzter Lauf {when}",
291
292
  "agent.pausedNoRuns": "kein Zeitplan feuert, keine Aufgabe und keine Mail wird angenommen; der Chat antwortet weiter",
292
293
  "agent.pause": "Pausieren",
293
294
  "agent.resume": "Fortsetzen",
@@ -296,6 +297,7 @@
296
297
  "agent.runNowPaused": "Setze den Agenten fort, bevor du ihn ausführst.",
297
298
  "agent.actionFailed": "Das hat nicht funktioniert. Versuche es erneut.",
298
299
  "agent.keyMissing": "Dieser Agent hat keinen Application-Key, deshalb kann kein Lauf starten. Ersetze seinen Key unter „Identität“ im Profil.",
300
+ "agent.createdBy": "von {name}",
299
301
  "agent.notPermitted": "Du darfst diesen Agenten lesen, aber nicht steuern. Dafür braucht es die Berechtigung „Agenten ausführen“.",
300
302
  "agent.noRuntime": "Diese Installation betreibt keine Agenten-Laufzeit, deshalb kann dieser Agent hier weder chatten noch laufen. Seine Definition bleibt erhalten und funktioniert wieder auf einer Installation, die eine hat.",
301
303
  "agent.loadFailed": "Dieser Agent konnte nicht geladen werden. Prüfe deinen Zugriff und versuche es erneut.",
@@ -320,10 +322,10 @@
320
322
  "agent.contactAdd": "Erreichbarkeit hinzufügen",
321
323
  "agent.contactAddReason": "Eine Mail-Adresse gehört der Installation, nicht der Definition des Agenten — sie wird dort konfiguriert, wo die Laufzeit betrieben wird.",
322
324
  "agent.knows": "Was er weiß",
323
- "agent.knowsHint": "Die Ordner, die dieser Agent liest, und wofür jeder davon zählt. Eine Systemnachricht weist ihn an, semantischer Kontext wird durchsucht, in das Gedächtnis wird zurückgeschrieben.",
324
- "agent.addReference": "Ordner hinzufügen",
325
+ "agent.knowsHint": "Was dieser Agent liest, und wofür jeder Eintrag zählt. Eine Systemnachricht weist ihn an — ein Ordner, ein Dokument oder eine Tabelle. Semantischer Kontext wird durchsucht und in das Gedächtnis wird zurückgeschrieben, deshalb sind beide Ordner.",
326
+ "agent.addReference": "Etwas zum Lesen hinzufügen",
325
327
  "agent.removeReferenceOf": "{title} nicht mehr lesen",
326
- "agent.pickFolder": "Zu lesender Ordner",
328
+ "agent.pickEntry": "Was gelesen wird",
327
329
  "agent.roleFor": "Wofür {title} zählt",
328
330
  "agent.role.system-message": "Systemnachricht",
329
331
  "agent.role.semantic-context": "Semantischer Kontext",
@@ -335,11 +337,12 @@
335
337
  "agent.removeToolServerOf": "{name} diesem Agenten wegnehmen",
336
338
  "agent.toolServerUnavailable": "Du erreichst diesen Server nicht mehr",
337
339
  "agent.toolsDelegationNotice": "Wer diesen Agenten ausführen darf, handelt auf deiner Verbindung.",
340
+ "agent.toolsDelegationNoticeBy": "Wer diesen Agenten ausführen darf, handelt auf der Verbindung von {name}.",
341
+ "agent.toolsDelegationNoticeSomebody": "Wer diesen Agenten ausführen darf, handelt auf der Verbindung dessen, der ihm diese Werkzeuge gegeben hat — nicht auf deiner.",
338
342
  "agent.toolsNotConnected": "Du bist noch nicht am Firmenportal angemeldet, es gibt also nichts abzugeben. Öffne „Werkzeuge“, um dich zu verbinden.",
339
343
  "agent.toolsNoneLeft": "Jeder Server, den du erreichst, ist diesem Agenten bereits gegeben.",
340
344
  "agent.schedule": "Zeitplan",
341
345
  "agent.scheduleHint": "Wann dieser Agent von sich aus handelt und was er ausführt. Jeder Zeitplan behält die Zeitzone, die er bekommen hat — er meint damit für alle Lesenden dieselbe Stunde.",
342
- "agent.scheduleEmpty": "Dieser Agent läuft nur, wenn er gefragt wird.",
343
346
  "agent.addSchedule": "Zeitplan hinzufügen",
344
347
  "agent.removeSchedule": "Diesen Zeitplan entfernen",
345
348
  "agent.scheduleTarget": "Was ausgeführt wird",
@@ -350,8 +353,23 @@
350
353
  "agent.model": "Modell",
351
354
  "agent.modelHint": "Welches Modell denkt. Eine Änderung übernimmt die Laufzeit innerhalb einer Minute.",
352
355
  "agent.modelFigures": "{context} Kontext · {input} $ rein / {output} $ raus je 1 Mio. Token",
356
+ "agent.usage": "{input} rein · {output} raus · {cacheRead} Cache gelesen · {cacheWrite} Cache geschrieben",
357
+ "agent.usageUnknown": "Für diesen Lauf sind keine Verbrauchszahlen erfasst",
358
+ "agent.usageNotReported": "Dieser Anbieter meldet keinen Verbrauch",
359
+ "agent.costMissing": "Kosten stehen nicht im Gateway-Log",
360
+ "agent.costNotConfigured": "Keine Kostenangabe: Diese Installation hat kein Lese-Token für das AI Gateway.",
361
+ "agent.costUnreadable": "Keine Kostenangabe: Das AI-Gateway-Log war nicht lesbar.",
362
+ "agent.costPartial": "Das Gateway-Log wurde bis zur Seitengrenze gelesen — diese Summen sind eine Untergrenze.",
363
+ "agent.costWindow": "Letzte {days} Tage",
364
+ "agent.costWindowCalls": "{count} Modellaufrufe",
365
+ "agent.costs": "Kosten",
366
+ "agent.costsSpent": "{amount} in den letzten {days} Tagen",
367
+ "agent.costsSpentPast": "Bereits ausgegeben, für vergangene Läufe — keine Vorhersage für das oben gewählte Modell.",
368
+ "agent.costsSpentWith": "mit {models}",
369
+ "agent.modelFiguresStale": "Diese Angaben stammen aus einer eingebauten Tabelle und können veraltet sein.",
353
370
  "agent.calendar": "Kommende Läufe",
354
- "agent.calendarZones": "Jede Zeit steht in der Zeitzone ihres eigenen Zeitplans, denn dann läuft der Agent.",
371
+ "agent.calendarZones": "Jede Zeit steht in der Zeitzone ihres eigenen Zeitplans, denn dann läuft der Agent. Weicht deine eigene Zeitzone ab, steht sie daneben.",
372
+ "agent.calendarYourTime": "{when} bei dir",
355
373
  "agent.calendarUnreadable": "Keiner der Zeitpläne dieses Agenten lässt sich als Cron-Ausdruck lesen, es ist also nichts geplant.",
356
374
  "agent.log": "Läufe",
357
375
  "agent.logEmpty": "Dieser Agent ist noch nicht gelaufen.",