@anchrd/intel-ui 0.13.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 +238 -65
- 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 +22 -31
- package/src/components/ui/popover.tsx +41 -0
- 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 +32 -10
- package/src/i18n/en.json +32 -10
- package/src/i18n/es.json +32 -10
- package/src/section-hint/section-hint.tsx +40 -0
- package/src/timezone/timezone-combobox/timezone-combobox.tsx +149 -0
- package/src/timezone/timezone-context.tsx +25 -5
- package/src/timezone/timezone.ts +119 -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,9 +14,10 @@ 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
|
-
import {
|
|
20
|
+
import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
|
|
20
21
|
import { useTimezoneSelection } from "@/timezone/timezone-context.tsx";
|
|
21
22
|
|
|
22
23
|
// The settings that belong to the person rather than to the installation: language, appearance and
|
|
@@ -38,18 +39,6 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|
|
38
39
|
const theme = useTheme();
|
|
39
40
|
const timezone = useTimezoneSelection();
|
|
40
41
|
|
|
41
|
-
// ⚠️ The active zone is put in front of the list unless it is already there, and that is not
|
|
42
|
-
// belt-and-braces: `Intl.supportedValuesOf("timeZone")` returns only CANONICAL IANA names, and
|
|
43
|
-
// "UTC" is not among them (it is an alias of Etc/UTC). A reader whose browser reports "UTC" —
|
|
44
|
-
// every CI machine, plenty of containers and servers — would otherwise get a Select whose value
|
|
45
|
-
// matches no item, and Radix renders that as an EMPTY field. Found by CI, not by the test.
|
|
46
|
-
//
|
|
47
|
-
// The same line covers the engine without `supportedValuesOf` at all: the list is empty and the
|
|
48
|
-
// reader still has their own zone to look at.
|
|
49
|
-
const zones = availableTimezones();
|
|
50
|
-
const current = timezone?.current ?? browserTimezone();
|
|
51
|
-
const options = zones.includes(current) ? zones : [current, ...zones];
|
|
52
|
-
|
|
53
42
|
return (
|
|
54
43
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
55
44
|
<DialogContent className="sm:max-w-md">
|
|
@@ -106,24 +95,26 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|
|
106
95
|
) : null}
|
|
107
96
|
{timezone ? (
|
|
108
97
|
<div className="grid gap-2">
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
<p
|
|
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>
|
|
109
|
+
<TimezoneCombobox
|
|
110
|
+
id="settings-timezone"
|
|
111
|
+
value={timezone.current}
|
|
112
|
+
onChange={timezone.select}
|
|
113
|
+
describedBy="settings-timezone-hint"
|
|
114
|
+
/>
|
|
115
|
+
<p id="settings-timezone-hint" className="sr-only">
|
|
116
|
+
{i18n.t("settings.timezoneHint")}
|
|
117
|
+
</p>
|
|
127
118
|
</div>
|
|
128
119
|
) : null}
|
|
129
120
|
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
4
|
+
import type * as React from "react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
|
8
|
+
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
|
12
|
+
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function PopoverContent({
|
|
16
|
+
className,
|
|
17
|
+
align = "center",
|
|
18
|
+
sideOffset = 4,
|
|
19
|
+
...props
|
|
20
|
+
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
|
21
|
+
return (
|
|
22
|
+
<PopoverPrimitive.Portal>
|
|
23
|
+
<PopoverPrimitive.Content
|
|
24
|
+
data-slot="popover-content"
|
|
25
|
+
align={align}
|
|
26
|
+
sideOffset={sideOffset}
|
|
27
|
+
className={cn(
|
|
28
|
+
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
29
|
+
className,
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
</PopoverPrimitive.Portal>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
|
38
|
+
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
|
|
@@ -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
|
+
}
|