@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
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type AgentCosts,
|
|
3
|
+
type AgentCostWindow,
|
|
2
4
|
type AgentDefinition,
|
|
3
5
|
type AgentReference,
|
|
6
|
+
AgentReferenceKinds,
|
|
4
7
|
AgentReferenceRole,
|
|
5
8
|
type AgentSchedule,
|
|
9
|
+
agentReferenceRolesFor,
|
|
6
10
|
type Node,
|
|
11
|
+
type NodeKind,
|
|
7
12
|
} from "@anchrd/intel-contract";
|
|
8
13
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
Bot,
|
|
16
|
+
Check,
|
|
17
|
+
Copy,
|
|
18
|
+
FileText,
|
|
19
|
+
Folder,
|
|
20
|
+
KeyRound,
|
|
21
|
+
MessageSquare,
|
|
22
|
+
Paperclip,
|
|
23
|
+
Plus,
|
|
24
|
+
Table2,
|
|
25
|
+
Trash2,
|
|
26
|
+
Wrench,
|
|
27
|
+
} from "lucide-react";
|
|
10
28
|
import { useState } from "react";
|
|
29
|
+
import { costWindow, formatCost, useAgentCosts } from "@/agent/agent-costs/agent-costs.ts";
|
|
11
30
|
import { type AgentDefinitionHandle, asDraft } from "@/agent/agent-definition/agent-definition.ts";
|
|
31
|
+
import { delegationNotice } from "@/agent/agent-delegation-notice/agent-delegation-notice.ts";
|
|
12
32
|
import { useEntryTitle } from "@/agent/agent-entry-title/agent-entry-title.ts";
|
|
13
33
|
import {
|
|
14
34
|
formatContextTokens,
|
|
@@ -29,11 +49,14 @@ import {
|
|
|
29
49
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
30
50
|
import { agentMcpAddress } from "@/data/agent-runtime/agent-runtime.ts";
|
|
31
51
|
import { EntryPicker, type PickerKind } from "@/entry-picker/entry-picker.tsx";
|
|
52
|
+
import { useModelCatalog } from "@/hooks/use-model-catalog.ts";
|
|
32
53
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
33
54
|
import { Modal } from "@/modal/modal.tsx";
|
|
34
55
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
35
|
-
import {
|
|
56
|
+
import { SectionHint } from "@/section-hint/section-hint.tsx";
|
|
57
|
+
import { TimezoneCombobox } from "@/timezone/timezone-combobox/timezone-combobox.tsx";
|
|
36
58
|
import { useTimezone } from "@/timezone/timezone-context.tsx";
|
|
59
|
+
import { useSessionUser, useUserName } from "@/user-name/user-name.ts";
|
|
37
60
|
|
|
38
61
|
// No dividers between sections — the space is the separation (#204). The explanation sits behind
|
|
39
62
|
// the icon rather than under the title, so the page shows what the agent IS and keeps the prose
|
|
@@ -58,26 +81,6 @@ function Section({
|
|
|
58
81
|
);
|
|
59
82
|
}
|
|
60
83
|
|
|
61
|
-
// ⚠️ The hint is the trigger's accessible name, not only the tooltip's content: Radix describes the
|
|
62
|
-
// trigger by the content only while it is open, and a listener who tabs past a nameless icon button
|
|
63
|
-
// would never learn there is anything behind it.
|
|
64
|
-
function SectionHint({ hint }: { hint: string }) {
|
|
65
|
-
return (
|
|
66
|
-
<TooltipProvider delayDuration={300}>
|
|
67
|
-
<Tooltip>
|
|
68
|
-
<TooltipTrigger
|
|
69
|
-
type="button"
|
|
70
|
-
aria-label={hint}
|
|
71
|
-
className="rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
72
|
-
>
|
|
73
|
-
<Info aria-hidden="true" className="size-3.5" />
|
|
74
|
-
</TooltipTrigger>
|
|
75
|
-
<TooltipContent className="max-w-xs">{hint}</TooltipContent>
|
|
76
|
-
</Tooltip>
|
|
77
|
-
</TooltipProvider>
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
84
|
/**
|
|
82
85
|
* Everything an agent is, on one page: how to reach it, what it reads, what it may call, when it
|
|
83
86
|
* acts on its own, which model does the thinking, and who it is in Gate.
|
|
@@ -131,7 +134,7 @@ export function AgentProfile({ node, agent }: { node: Node; agent: AgentDefiniti
|
|
|
131
134
|
<KnowledgeSection definition={definition} agent={agent} />
|
|
132
135
|
<ToolsSection definition={definition} agent={agent} />
|
|
133
136
|
<ScheduleSection definition={definition} agent={agent} />
|
|
134
|
-
<ModelSection definition={definition} agent={agent} />
|
|
137
|
+
<ModelSection definition={definition} agent={agent} agentId={node.id} />
|
|
135
138
|
<IdentitySection node={node} agent={agent} />
|
|
136
139
|
</div>
|
|
137
140
|
);
|
|
@@ -310,6 +313,24 @@ function ContactSection({ node }: { node: Node }) {
|
|
|
310
313
|
);
|
|
311
314
|
}
|
|
312
315
|
|
|
316
|
+
// Everything any role accepts, which is what the picker may offer. Derived from the contract's one
|
|
317
|
+
// table rather than written out again — a second list here would drift.
|
|
318
|
+
const pickableKinds: NodeKind[] = [
|
|
319
|
+
...new Set(Object.values(AgentReferenceKinds).flat()),
|
|
320
|
+
] as NodeKind[];
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The role a freshly picked entry starts as — the widest thing that kind can honestly be.
|
|
324
|
+
*
|
|
325
|
+
* A folder starts as search space, the way it always has; a document or a table starts as the
|
|
326
|
+
* system message, which is the only role that reads a single node at all. `null` for a flow and for
|
|
327
|
+
* a kind no role accepts, and the caller then adds nothing rather than inventing a role.
|
|
328
|
+
*/
|
|
329
|
+
function defaultRoleFor(kind: PickerKind): AgentReferenceRole | null {
|
|
330
|
+
if (kind === "flow") return null;
|
|
331
|
+
return kind === "folder" ? "semantic-context" : (agentReferenceRolesFor(kind)[0] ?? null);
|
|
332
|
+
}
|
|
333
|
+
|
|
313
334
|
/**
|
|
314
335
|
* What the agent reads, and in which capacity.
|
|
315
336
|
*
|
|
@@ -317,6 +338,10 @@ function ContactSection({ node }: { node: Node }) {
|
|
|
317
338
|
* explicitly, and the reason is that the three roles do very different things — a folder read as
|
|
318
339
|
* the system message becomes instructions, one read as memory is written back to. Something that
|
|
319
340
|
* changes what a document MEANS to an agent must look changeable.
|
|
341
|
+
*
|
|
342
|
+
* ⚠️ Since #255 a row can also be a single document or table, and the role select shows only the
|
|
343
|
+
* roles that kind can carry. `memory` and `semantic-context` stay folders — the agent WRITES into
|
|
344
|
+
* one and SEARCHES the other — so a document simply never offers them.
|
|
320
345
|
*/
|
|
321
346
|
function KnowledgeSection({
|
|
322
347
|
definition,
|
|
@@ -370,12 +395,20 @@ function KnowledgeSection({
|
|
|
370
395
|
</button>
|
|
371
396
|
{adding ? (
|
|
372
397
|
<Modal title={i18n.t("agent.addReference")} close={() => setAdding(false)}>
|
|
398
|
+
{/* ⚠️ The picker offers the UNION of what any role accepts, and the row's role select
|
|
399
|
+
then offers only the roles that accept what was picked (#255). The rule is asked in
|
|
400
|
+
this direction because the thing is chosen first: a role chosen up front would mean a
|
|
401
|
+
second dialog before the picker, and a picker that let anything be chosen and refused
|
|
402
|
+
afterwards is worse than one that never offered it. Either way no illegal pair is
|
|
403
|
+
reachable — `agentReferenceKinds` is the one list both sides read. */}
|
|
373
404
|
<EntryPicker
|
|
374
|
-
kind=
|
|
405
|
+
kind={pickableKinds}
|
|
375
406
|
value=""
|
|
376
|
-
label={i18n.t("agent.
|
|
377
|
-
onSelect={(nodeId) => {
|
|
378
|
-
|
|
407
|
+
label={i18n.t("agent.pickEntry")}
|
|
408
|
+
onSelect={(nodeId, entryKind) => {
|
|
409
|
+
const role = defaultRoleFor(entryKind);
|
|
410
|
+
if (role === null) return;
|
|
411
|
+
write([...definition.references, { nodeId, role }]);
|
|
379
412
|
setAdding(false);
|
|
380
413
|
}}
|
|
381
414
|
/>
|
|
@@ -385,13 +418,31 @@ function KnowledgeSection({
|
|
|
385
418
|
);
|
|
386
419
|
}
|
|
387
420
|
|
|
421
|
+
// What each kind looks like at a glance. An `attachment` and an `agent` cannot be picked, but an
|
|
422
|
+
// older definition may still name one, so every kind has a mark rather than a fallback that would
|
|
423
|
+
// draw two different things the same way.
|
|
424
|
+
const KindIcon: Record<NodeKind, typeof Folder> = {
|
|
425
|
+
folder: Folder,
|
|
426
|
+
document: FileText,
|
|
427
|
+
table: Table2,
|
|
428
|
+
attachment: Paperclip,
|
|
429
|
+
agent: Bot,
|
|
430
|
+
};
|
|
431
|
+
|
|
388
432
|
/**
|
|
389
|
-
* One
|
|
433
|
+
* One thing the agent reads, where it sits, and what it counts as.
|
|
434
|
+
*
|
|
435
|
+
* ⚠️ Its own component so the select can be NAMED after the entry. Three rows carrying the same
|
|
436
|
+
* "What this counts as" are three identical controls to anybody listening rather than looking — the
|
|
437
|
+
* name is beside them on screen, and the screen is exactly what that reader does not have. It needs
|
|
438
|
+
* the resolved title, which is why the lookup is a hook rather than a component here.
|
|
439
|
+
*
|
|
440
|
+
* ⚠️ The path is not decoration (#255). Two folders called `Notes` in different corners of the tree
|
|
441
|
+
* were indistinguishable here, and this is the screen on which somebody decides what an agent may
|
|
442
|
+
* read and write into.
|
|
390
443
|
*
|
|
391
|
-
* ⚠️
|
|
392
|
-
*
|
|
393
|
-
* looking — the name is beside them on screen, and the screen is exactly what that reader does not
|
|
394
|
-
* have. It needs the resolved title, which is why the lookup is a hook rather than a component here.
|
|
444
|
+
* ⚠️ The kind is a symbol AND a word. The symbol is for the eye and the `sr-only` word is for
|
|
445
|
+
* everyone else — an icon with no text is a row that says "Notes" three times to a screen reader.
|
|
395
446
|
*/
|
|
396
447
|
function ReferenceRow({
|
|
397
448
|
reference,
|
|
@@ -403,11 +454,31 @@ function ReferenceRow({
|
|
|
403
454
|
remove(): void;
|
|
404
455
|
}) {
|
|
405
456
|
const i18n = useI18n();
|
|
406
|
-
const { title, known } = useEntryTitle(reference.nodeId);
|
|
457
|
+
const { title, known, kind, path } = useEntryTitle(reference.nodeId);
|
|
458
|
+
const Icon = kind === null ? null : KindIcon[kind];
|
|
459
|
+
// ⚠️ Only the roles this kind can carry, so an impossible pair is never offered — and the current
|
|
460
|
+
// role is kept in the list whatever it is, or an older definition would open on an empty select
|
|
461
|
+
// and the first touch of the control would silently change what the agent does.
|
|
462
|
+
const roles = [
|
|
463
|
+
...new Set([
|
|
464
|
+
...(kind === null ? AgentReferenceRole.options : agentReferenceRolesFor(kind)),
|
|
465
|
+
reference.role,
|
|
466
|
+
]),
|
|
467
|
+
];
|
|
468
|
+
|
|
407
469
|
return (
|
|
408
470
|
<li className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3">
|
|
409
|
-
|
|
410
|
-
|
|
471
|
+
{Icon ? <Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" /> : null}
|
|
472
|
+
<span className={`min-w-0 flex-1${known ? "" : " text-muted-foreground"}`}>
|
|
473
|
+
<span className="block truncate text-sm">
|
|
474
|
+
{title}
|
|
475
|
+
{kind === null ? null : <span className="sr-only"> — {i18n.t(`node.kind.${kind}`)}</span>}
|
|
476
|
+
</span>
|
|
477
|
+
{path.length > 0 ? (
|
|
478
|
+
<span className="block truncate text-xs text-muted-foreground" title={path.join(" / ")}>
|
|
479
|
+
{path.join(" / ")}
|
|
480
|
+
</span>
|
|
481
|
+
) : null}
|
|
411
482
|
</span>
|
|
412
483
|
<Select
|
|
413
484
|
value={reference.role}
|
|
@@ -420,7 +491,7 @@ function ReferenceRow({
|
|
|
420
491
|
<SelectValue />
|
|
421
492
|
</SelectTrigger>
|
|
422
493
|
<SelectContent>
|
|
423
|
-
{
|
|
494
|
+
{roles.map((role) => (
|
|
424
495
|
<SelectItem key={role} value={role}>
|
|
425
496
|
{i18n.t(`agent.role.${role}`)}
|
|
426
497
|
</SelectItem>
|
|
@@ -459,9 +530,10 @@ function EntryTitle({ entryId, flow = false }: { entryId: string; flow?: boolean
|
|
|
459
530
|
*
|
|
460
531
|
* ⚠️ The notice is not decoration, and it stands whether or not anything is listed yet. A shared
|
|
461
532
|
* agent runs on the DELEGATOR's connection whoever starts it, which is a real widening of who can
|
|
462
|
-
* act as this person
|
|
463
|
-
*
|
|
464
|
-
*
|
|
533
|
+
* act as this person. It is no longer a paragraph under the list (#254) — it said the same thing on
|
|
534
|
+
* every visit at the place where somebody was doing something else — but it is not gone either: it
|
|
535
|
+
* is the second half of this section's own hint, so it is in the trigger's accessible name and a
|
|
536
|
+
* keyboard reaches it, and it still stands in the picker where the delegation actually happens.
|
|
465
537
|
*/
|
|
466
538
|
function ToolsSection({
|
|
467
539
|
definition,
|
|
@@ -472,6 +544,9 @@ function ToolsSection({
|
|
|
472
544
|
}) {
|
|
473
545
|
const { data } = useIntelRouterContext();
|
|
474
546
|
const i18n = useI18n();
|
|
547
|
+
const me = useSessionUser();
|
|
548
|
+
const delegatedBy = definition.tools?.delegatedBy ?? null;
|
|
549
|
+
const delegatorName = useUserName(delegatedBy);
|
|
475
550
|
const [adding, setAdding] = useState(false);
|
|
476
551
|
const servers = useQuery({ queryKey: ["tool-servers"], queryFn: () => data.listToolServers() });
|
|
477
552
|
const selected = definition.tools?.servers ?? [];
|
|
@@ -488,8 +563,17 @@ function ToolsSection({
|
|
|
488
563
|
(server) => !selected.includes(server.handle),
|
|
489
564
|
);
|
|
490
565
|
|
|
566
|
+
// Two whole sentences joined, never one sentence built from two fragments: each stays its own
|
|
567
|
+
// catalog entry and each translates on its own. What the join fixes is only their order.
|
|
568
|
+
const notice = delegationNotice(i18n, {
|
|
569
|
+
delegatedBy,
|
|
570
|
+
viewerId: me?.id ?? null,
|
|
571
|
+
delegatorName,
|
|
572
|
+
});
|
|
573
|
+
const hint = `${i18n.t("agent.toolsHint")} ${notice}`;
|
|
574
|
+
|
|
491
575
|
return (
|
|
492
|
-
<Section title={i18n.t("agent.tools")} hint={
|
|
576
|
+
<Section title={i18n.t("agent.tools")} hint={hint}>
|
|
493
577
|
{selected.length === 0 ? null : (
|
|
494
578
|
<ul className="space-y-2">
|
|
495
579
|
{selected.map((handle) => {
|
|
@@ -534,7 +618,6 @@ function ToolsSection({
|
|
|
534
618
|
<Plus aria-hidden="true" className="size-4" />
|
|
535
619
|
{i18n.t("agent.addTool")}
|
|
536
620
|
</button>
|
|
537
|
-
<p className="mt-3 text-xs text-muted-foreground">{i18n.t("agent.toolsDelegationNotice")}</p>
|
|
538
621
|
{adding ? (
|
|
539
622
|
<Modal title={i18n.t("agent.addTool")} close={() => setAdding(false)}>
|
|
540
623
|
{servers.isPending ? (
|
|
@@ -569,9 +652,10 @@ function ToolsSection({
|
|
|
569
652
|
))}
|
|
570
653
|
</ul>
|
|
571
654
|
)}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
655
|
+
{/* ⚠️ Here it stays a visible paragraph, unlike the section's own (#254). This is the
|
|
656
|
+
moment the delegation is actually made, and a consequence that is only reachable by
|
|
657
|
+
hovering an icon somewhere behind the dialog is a consequence nobody reads. */}
|
|
658
|
+
<p className="mt-4 text-xs text-muted-foreground">{notice}</p>
|
|
575
659
|
</Modal>
|
|
576
660
|
) : null}
|
|
577
661
|
</Section>
|
|
@@ -660,8 +744,6 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
|
|
|
660
744
|
// means the same hour to everybody else reading the agent.
|
|
661
745
|
const preferred = useTimezone();
|
|
662
746
|
const [timezone, setTimezone] = useState(preferred);
|
|
663
|
-
const zones = availableTimezones();
|
|
664
|
-
const options = zones.includes(timezone) ? zones : [timezone, ...zones];
|
|
665
747
|
const [target, setTarget] = useState<{ id: string; kind: "document" | "flow" } | null>(null);
|
|
666
748
|
|
|
667
749
|
return (
|
|
@@ -689,23 +771,24 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
|
|
|
689
771
|
{i18n.t("agent.cronHint")}
|
|
690
772
|
</span>
|
|
691
773
|
</label>
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
774
|
+
{/* ⚠️ The hint is behind the ⓘ (#249) and ALSO `sr-only` below. The two are not a
|
|
775
|
+
duplicate: the tooltip's content is in the accessibility tree only while it is open, so
|
|
776
|
+
the field would lose the description `aria-describedby` promises the moment the sentence
|
|
777
|
+
became a hover. Route (1) of the ticket, and the cheaper of the two — nothing has to be
|
|
778
|
+
argued about a description that is simply still there. */}
|
|
779
|
+
<div className="flex items-center gap-1.5">
|
|
780
|
+
<label className="block text-sm font-medium" htmlFor="agent-schedule-timezone">
|
|
781
|
+
{i18n.t("agent.scheduleTimezone")}
|
|
782
|
+
</label>
|
|
783
|
+
<SectionHint hint={i18n.t("agent.scheduleTimezoneHint")} />
|
|
784
|
+
</div>
|
|
785
|
+
<TimezoneCombobox
|
|
696
786
|
id="agent-schedule-timezone"
|
|
697
787
|
value={timezone}
|
|
698
|
-
onChange={
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
>
|
|
702
|
-
{options.map((zone) => (
|
|
703
|
-
<option key={zone} value={zone}>
|
|
704
|
-
{zone.replace(/_/g, " ")}
|
|
705
|
-
</option>
|
|
706
|
-
))}
|
|
707
|
-
</select>
|
|
708
|
-
<span id="agent-timezone-hint" className="-mt-2 block text-xs text-muted-foreground">
|
|
788
|
+
onChange={setTimezone}
|
|
789
|
+
describedBy="agent-timezone-hint"
|
|
790
|
+
/>
|
|
791
|
+
<span id="agent-timezone-hint" className="sr-only">
|
|
709
792
|
{i18n.t("agent.scheduleTimezoneHint")}
|
|
710
793
|
</span>
|
|
711
794
|
<EntryPicker
|
|
@@ -730,18 +813,39 @@ function ScheduleDialog({ close, add }: { close(): void; add(schedule: AgentSche
|
|
|
730
813
|
);
|
|
731
814
|
}
|
|
732
815
|
|
|
733
|
-
|
|
734
|
-
|
|
816
|
+
/**
|
|
817
|
+
* Which model does the thinking, what it costs per million tokens — and what this agent has
|
|
818
|
+
* actually cost with it.
|
|
819
|
+
*
|
|
820
|
+
* ⚠️ Changing it writes a new definition version like every other edit here; the runtime picks the
|
|
821
|
+
* provider up on its next read (its definition cache is 60 seconds).
|
|
822
|
+
*
|
|
823
|
+
* ⚠️ The figures come from intel's catalog since #257 and no longer from a table in this bundle.
|
|
824
|
+
* The table was a copy that aged silently, and the reason it stayed one — "a token does not belong
|
|
825
|
+
* in a SPA" — was correct and is exactly why the fetch moved to intel rather than into this
|
|
826
|
+
* component.
|
|
827
|
+
*
|
|
828
|
+
* ⚠️ The two numbers beside each other are a price and a bill, and they must not read as one thing.
|
|
829
|
+
* A price per million tokens answers "what does this model cost"; the thirty-day figure answers
|
|
830
|
+
* "what has this agent cost", it is money already spent on runs that used whatever model was chosen
|
|
831
|
+
* AT THE TIME, and the sentence under it says so. Without that, switching the model would make the
|
|
832
|
+
* old figure read as a forecast for the new one.
|
|
833
|
+
*/
|
|
735
834
|
function ModelSection({
|
|
736
835
|
definition,
|
|
737
836
|
agent,
|
|
837
|
+
agentId,
|
|
738
838
|
}: {
|
|
739
839
|
definition: AgentDefinition;
|
|
740
840
|
agent: AgentDefinitionHandle;
|
|
841
|
+
agentId: string;
|
|
741
842
|
}) {
|
|
742
843
|
const i18n = useI18n();
|
|
844
|
+
const catalog = useModelCatalog();
|
|
845
|
+
const costs = useAgentCosts(agentId);
|
|
743
846
|
const models = selectableModels(definition.model);
|
|
744
|
-
const current = modelFacts(definition.model);
|
|
847
|
+
const current = modelFacts(definition.model, catalog.data?.entries);
|
|
848
|
+
const spent = costWindow(costs.data, 30);
|
|
745
849
|
|
|
746
850
|
return (
|
|
747
851
|
<Section title={i18n.t("agent.model")} hint={i18n.t("agent.modelHint")}>
|
|
@@ -762,7 +866,7 @@ function ModelSection({
|
|
|
762
866
|
</SelectTrigger>
|
|
763
867
|
<SelectContent>
|
|
764
868
|
{models.map((model) => {
|
|
765
|
-
const facts = modelFacts(model);
|
|
869
|
+
const facts = modelFacts(model, catalog.data?.entries);
|
|
766
870
|
return (
|
|
767
871
|
<SelectItem key={modelKey(model)} value={modelKey(model)}>
|
|
768
872
|
{/* ⚠️ The space is not formatting. The option's accessible name is its text run
|
|
@@ -776,10 +880,79 @@ function ModelSection({
|
|
|
776
880
|
</Select>
|
|
777
881
|
<ModelFigures facts={current} />
|
|
778
882
|
</div>
|
|
883
|
+
{/* ⚠️ Said once, under the row, and only when something on screen actually came off the
|
|
884
|
+
table. A silent fallback to typed-out prices is the state this feature replaced. */}
|
|
885
|
+
{current.stale ? (
|
|
886
|
+
<p className="mt-2 text-xs text-muted-foreground">{i18n.t("agent.modelFiguresStale")}</p>
|
|
887
|
+
) : null}
|
|
888
|
+
<AgentSpend
|
|
889
|
+
spent={spent}
|
|
890
|
+
status={costs.data?.status}
|
|
891
|
+
partial={costs.data?.partial ?? false}
|
|
892
|
+
/>
|
|
779
893
|
</Section>
|
|
780
894
|
);
|
|
781
895
|
}
|
|
782
896
|
|
|
897
|
+
/**
|
|
898
|
+
* What this agent has actually cost in the last thirty days (#257).
|
|
899
|
+
*
|
|
900
|
+
* ⚠️ Written in the past tense and labelled with the models that produced it, because it is the
|
|
901
|
+
* only thing standing between "$0.23" and a reader taking it for what the model above will cost.
|
|
902
|
+
* The models are named from the gateway's own log lines, so a period spent on a different model
|
|
903
|
+
* says so by itself.
|
|
904
|
+
*
|
|
905
|
+
* ⚠️ Nothing is rendered where the figure is not known. The Runs tab is where the reason belongs —
|
|
906
|
+
* it is the screen about runs — and repeating "no read token" beside a model select would be an
|
|
907
|
+
* error message in the middle of an unrelated decision.
|
|
908
|
+
*
|
|
909
|
+
* ⚠️ `partial` is the one exception to that division, and it has to be repeated here. It does not
|
|
910
|
+
* say the figure is missing — it says the figure shown IS NOT THE TOTAL, because the gateway read
|
|
911
|
+
* stopped at its page limit. A lower bound printed as if it were a sum is the same lie as a zero
|
|
912
|
+
* printed as if it were free, and this is where somebody reads it while deciding what to spend.
|
|
913
|
+
* An agent on a frequent schedule crosses that limit within thirty days as a matter of course; the
|
|
914
|
+
* seven-day figure practically never does, which makes the case rarer and therefore easier to miss.
|
|
915
|
+
*/
|
|
916
|
+
function AgentSpend({
|
|
917
|
+
spent,
|
|
918
|
+
status,
|
|
919
|
+
partial,
|
|
920
|
+
}: {
|
|
921
|
+
spent: AgentCostWindow | null;
|
|
922
|
+
status: AgentCosts["status"] | undefined;
|
|
923
|
+
partial: boolean;
|
|
924
|
+
}) {
|
|
925
|
+
const i18n = useI18n();
|
|
926
|
+
if (status !== "read" || !spent) return null;
|
|
927
|
+
|
|
928
|
+
return (
|
|
929
|
+
<p className="mt-3 text-sm">
|
|
930
|
+
<span className="font-medium">
|
|
931
|
+
{i18n.t("agent.costsSpent", {
|
|
932
|
+
amount: formatCost(spent.cost, i18n.locale),
|
|
933
|
+
days: String(spent.days),
|
|
934
|
+
})}
|
|
935
|
+
</span>
|
|
936
|
+
{spent.models.length > 0 ? (
|
|
937
|
+
<span className="text-muted-foreground">
|
|
938
|
+
{" "}
|
|
939
|
+
{i18n.t("agent.costsSpentWith", { models: spent.models.join(", ") })}
|
|
940
|
+
</span>
|
|
941
|
+
) : null}
|
|
942
|
+
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
943
|
+
{i18n.t("agent.costsSpentPast")}
|
|
944
|
+
</span>
|
|
945
|
+
{/* The Runs tab's own wording, deliberately the same key: two different sentences for one
|
|
946
|
+
fact would leave a reader wondering whether they are the same fact. */}
|
|
947
|
+
{partial ? (
|
|
948
|
+
<span className="mt-0.5 block text-xs text-muted-foreground">
|
|
949
|
+
{i18n.t("agent.costPartial")}
|
|
950
|
+
</span>
|
|
951
|
+
) : null}
|
|
952
|
+
</p>
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
783
956
|
// Brand first, then the model's own name — "Claude Sonnet 5", not "claude-sonnet-5 · anthropic". The
|
|
784
957
|
// provider stays in the definition; it names who serves the model, which is not what anybody reads a
|
|
785
958
|
// list of models for.
|
|
@@ -60,6 +60,23 @@ export function agentRuntimeMissing(error: unknown): boolean {
|
|
|
60
60
|
* deployment's master secret was replaced — but the reader's move is identical, and a second
|
|
61
61
|
* sentence saying the same thing differently would only be a second thing to keep in step.
|
|
62
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* The refusal in the words the refusal itself used (#201).
|
|
65
|
+
*
|
|
66
|
+
* ⚠️ Only for an answer that carried a problem document — `code` is what proves it did. Everything
|
|
67
|
+
* else that reaches here is a timeout, a proxy page or a parse failure, whose `message` is
|
|
68
|
+
* `Intel responded with 502`: true, and no help to anybody. Those keep the generic sentence.
|
|
69
|
+
*
|
|
70
|
+
* The reason has to reach the screen because `Resume` can fail for reasons only the runtime knows —
|
|
71
|
+
* "none of this agent's schedules has a next fire time" is one, and it is not something a reader
|
|
72
|
+
* can guess from `That did not work. Try again.` The runtime words its problems to be read (see
|
|
73
|
+
* `packages/agent`'s guard on model-facing errors), and the log tab already shows a run's failure
|
|
74
|
+
* verbatim for the same reason.
|
|
75
|
+
*/
|
|
76
|
+
export function agentProblemDetail(error: unknown): string | null {
|
|
77
|
+
return error instanceof IntelRequestError && error.code !== null ? error.message : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
export function agentKeyMissing(error: unknown): boolean {
|
|
64
81
|
return (
|
|
65
82
|
error instanceof IntelRequestError &&
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AgentRun } from "@/data/agent-runtime/agent-runtime.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The five things an agent's head can have to say. There is deliberately no "idle": the absence of
|
|
5
|
+
* a state is not a state, and writing it out at the most prominent place on the page was what #253
|
|
6
|
+
* removed.
|
|
7
|
+
*/
|
|
8
|
+
export type AgentStatusKind = "unknown" | "paused" | "running" | "failed" | "waiting";
|
|
9
|
+
|
|
10
|
+
export interface AgentStatusReading {
|
|
11
|
+
kind: AgentStatusKind;
|
|
12
|
+
/** When the newest finished run ended — `null` while the agent has never finished one. */
|
|
13
|
+
lastRunAt: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AgentStatusInput {
|
|
17
|
+
/** The run list could not be read. NOT the same as "no runs". */
|
|
18
|
+
runsUnreadable: boolean;
|
|
19
|
+
/** Newest first, the way the runtime answers. */
|
|
20
|
+
runs: AgentRun[];
|
|
21
|
+
paused: boolean;
|
|
22
|
+
/** Whether any schedule will fire — derived from the cron expressions, never asked. */
|
|
23
|
+
scheduled: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the head says about this agent, or `null` when it has nothing to say (#253).
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ `null` is the ordinary case and the whole point of the ticket: an agent with no schedule, no
|
|
30
|
+
* run in flight and no failure behind it is not "Idle · no scheduled run", it is a name. Two
|
|
31
|
+
* sentences spelling out the same absence stood at the most prominent place on the page.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ It is NOT simply "has a schedule". An agent somebody drives from the chat can be running, can
|
|
34
|
+
* be switched off, and can have failed its last run without ever carrying a schedule — hiding any
|
|
35
|
+
* of those would trade two useless sentences for one missing fact. What disappears is the absence,
|
|
36
|
+
* not the presence.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ An unreadable run list outranks everything and is its own state. A runtime that cannot be
|
|
39
|
+
* reached says nothing about what the agent is doing, and reporting "waiting" for it would be wrong
|
|
40
|
+
* exactly when something is wrong.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ Paused outranks running, the way the status line has always read it: a run still finishing
|
|
43
|
+
* while the agent is switched off does not make the agent switched on, and the reader's next
|
|
44
|
+
* question is why nothing starts afterwards.
|
|
45
|
+
*/
|
|
46
|
+
export function agentStatus(input: AgentStatusInput): AgentStatusReading | null {
|
|
47
|
+
const finished = input.runs.find((run) => run.status !== "running") ?? null;
|
|
48
|
+
const lastRunAt = finished?.finishedAt ?? finished?.startedAt ?? null;
|
|
49
|
+
|
|
50
|
+
if (input.runsUnreadable) return { kind: "unknown", lastRunAt: null };
|
|
51
|
+
if (input.paused) return { kind: "paused", lastRunAt };
|
|
52
|
+
if (input.runs.some((run) => run.status === "running")) return { kind: "running", lastRunAt };
|
|
53
|
+
if (finished?.status === "failed") return { kind: "failed", lastRunAt };
|
|
54
|
+
if (input.scheduled) return { kind: "waiting", lastRunAt };
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* How each state is drawn.
|
|
60
|
+
*
|
|
61
|
+
* ⚠️ Shape carries what colour cannot. This theme is monochrome apart from `destructive` (see
|
|
62
|
+
* `styles.css`), so five states cannot be five hues without inventing colours the customer's own
|
|
63
|
+
* theme would then fight — and colour alone is no information for a reader who cannot separate two
|
|
64
|
+
* greys. Filled, hollow, dashed and moving are told apart without any hue at all, and the state is
|
|
65
|
+
* in the trigger's accessible name regardless.
|
|
66
|
+
*/
|
|
67
|
+
export const statusDotClass: Record<AgentStatusKind, string> = {
|
|
68
|
+
unknown: "border border-dashed border-muted-foreground",
|
|
69
|
+
paused: "bg-muted-foreground",
|
|
70
|
+
running: "bg-primary motion-safe:animate-pulse",
|
|
71
|
+
failed: "bg-destructive",
|
|
72
|
+
waiting: "border border-muted-foreground",
|
|
73
|
+
};
|