@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.
- package/package.json +2 -2
- package/src/agent/agent-calendar/agent-calendar.tsx +42 -4
- package/src/agent/agent-costs/agent-costs.ts +95 -0
- package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +48 -0
- package/src/agent/agent-entry-title/agent-entry-title.ts +48 -7
- package/src/agent/agent-log/agent-log.tsx +127 -11
- package/src/agent/agent-models/agent-models.ts +31 -67
- package/src/agent/agent-models/agent-models.types.ts +11 -5
- package/src/agent/agent-profile/agent-profile.tsx +233 -51
- package/src/agent/agent-state/agent-state.ts +17 -0
- package/src/agent/agent-status-dot/agent-status-dot.ts +73 -0
- package/src/agent/agent.tsx +159 -50
- package/src/app/app-tree/app-tree.tsx +9 -3
- package/src/app/settings-dialog/settings-dialog.tsx +13 -4
- package/src/data/agent-runtime/agent-runtime.ts +26 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +20 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +19 -0
- package/src/entry-picker/entry-picker.tsx +16 -5
- package/src/hooks/use-model-catalog.ts +26 -0
- package/src/i18n/de.json +28 -10
- package/src/i18n/en.json +28 -10
- package/src/i18n/es.json +28 -10
- package/src/section-hint/section-hint.tsx +40 -0
- package/src/timezone/timezone-context.tsx +25 -5
- package/src/timezone/timezone.ts +20 -5
- package/src/title-row/title-row.tsx +9 -4
- package/src/user-name/user-name.ts +41 -0
- package/tsconfig.json +7 -1
package/src/agent/agent.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
175
|
-
runs.isError
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
:
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
|
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`
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
{
|
|
354
|
+
{agentKeyMissing(failure)
|
|
355
|
+
? i18n.t("agent.keyMissing")
|
|
356
|
+
: (agentProblemDetail(failure) ?? i18n.t("agent.actionFailed"))}
|
|
278
357
|
</span>
|
|
279
358
|
) : null}
|
|
280
|
-
<
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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}
|
|
480
|
-
{/* ⚠️
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
286
|
-
"agent.
|
|
287
|
-
"agent.
|
|
288
|
-
"agent.
|
|
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.
|
|
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": "
|
|
324
|
-
"agent.addReference": "
|
|
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.
|
|
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.",
|