@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@anchrd/intel-contract": "^0.
|
|
32
|
+
"@anchrd/intel-contract": "^0.9.0",
|
|
33
33
|
"@assistant-ui/react": "^0.15.4",
|
|
34
34
|
"@assistant-ui/react-ai-sdk": "^1.4.4",
|
|
35
35
|
"@blocknote/core": "^0.52.1",
|
|
@@ -2,6 +2,8 @@ import type { AgentDefinition } from "@anchrd/intel-contract";
|
|
|
2
2
|
import { CalendarClock } from "lucide-react";
|
|
3
3
|
import { nextCronFires } from "@/agent/agent-cron/agent-cron.ts";
|
|
4
4
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
5
|
+
import { SectionHint } from "@/section-hint/section-hint.tsx";
|
|
6
|
+
import { useChosenTimezone } from "@/timezone/timezone-context.tsx";
|
|
5
7
|
|
|
6
8
|
// How far ahead one schedule is shown. Enough to recognise a rhythm — daily, weekly, the first of
|
|
7
9
|
// the month — and not so much that the page becomes a year planner nobody asked for.
|
|
@@ -19,6 +21,15 @@ const PerSchedule = 5;
|
|
|
19
21
|
* looking would be a different hour from the one the agent keeps — a difference that only ever
|
|
20
22
|
* surfaces as "it ran at the wrong time". And not one zone for the whole list either, because two
|
|
21
23
|
* schedules of one agent may sit in different ones.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ The reader's own reading is ADDED beside it, never in its place (#256). The schedule's time is
|
|
26
|
+
* the leading one because it is the reason the agent runs; the second is a reading aid, smaller and
|
|
27
|
+
* behind it. A calendar that converted everything to the reader would have undone #228 with a
|
|
28
|
+
* feature request.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ And only for a zone somebody SET. `useChosenTimezone` answers `null` where nothing was chosen,
|
|
31
|
+
* and then this screen is exactly what it was — no browser guess dressed up as "your time", and no
|
|
32
|
+
* second time on every row for a reader who never asked for one.
|
|
22
33
|
*/
|
|
23
34
|
export function AgentCalendar({
|
|
24
35
|
definition,
|
|
@@ -30,6 +41,7 @@ export function AgentCalendar({
|
|
|
30
41
|
now: Date;
|
|
31
42
|
}) {
|
|
32
43
|
const i18n = useI18n();
|
|
44
|
+
const mine = useChosenTimezone();
|
|
33
45
|
const schedules = definition?.schedules ?? [];
|
|
34
46
|
const upcoming = schedules
|
|
35
47
|
.flatMap((schedule) =>
|
|
@@ -56,15 +68,33 @@ export function AgentCalendar({
|
|
|
56
68
|
return format.format(at);
|
|
57
69
|
};
|
|
58
70
|
|
|
71
|
+
// Shorter than the leading time on purpose: it is a reading aid, not a second headline. The date
|
|
72
|
+
// rides along because a schedule in Tokyo is regularly the reader's previous day.
|
|
73
|
+
const myFormat =
|
|
74
|
+
mine === null
|
|
75
|
+
? null
|
|
76
|
+
: new Intl.DateTimeFormat(i18n.locale, {
|
|
77
|
+
dateStyle: "medium",
|
|
78
|
+
timeStyle: "short",
|
|
79
|
+
timeZone: mine,
|
|
80
|
+
});
|
|
81
|
+
|
|
59
82
|
return (
|
|
60
83
|
<section
|
|
61
84
|
aria-label={i18n.t("agent.calendar")}
|
|
62
85
|
className="min-h-0 flex-1 overflow-y-auto px-6 py-5"
|
|
63
86
|
>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
{/* ⚠️ Behind the ⓘ rather than as a paragraph (#256): it was the longest text on the tab and
|
|
88
|
+
it explained, on every visit, a rule the list itself shows. The heading is what the hint
|
|
89
|
+
now hangs off — the same shape the profile's sections have. */}
|
|
90
|
+
<div className="flex items-center gap-1.5">
|
|
91
|
+
<h3 className="text-sm font-semibold">{i18n.t("agent.calendar")}</h3>
|
|
92
|
+
<SectionHint hint={i18n.t("agent.calendarZones")} />
|
|
93
|
+
</div>
|
|
94
|
+
{/* ⚠️ An agent with no schedule shows an empty calendar and nothing else (#254). The sentence
|
|
95
|
+
that used to stand here described the emptiness the reader is already looking at; the two
|
|
96
|
+
other states below are different, because each of them is a fact the list cannot show. */}
|
|
97
|
+
{schedules.length === 0 ? null : upcoming.length === 0 ? (
|
|
68
98
|
// Schedules exist and none of them fires: every expression is unusable. A different answer
|
|
69
99
|
// from "no schedules", because it is a different problem and needs a different fix.
|
|
70
100
|
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
@@ -84,6 +114,14 @@ export function AgentCalendar({
|
|
|
84
114
|
{/* The zone stands next to the time, not once above the list: without it the same
|
|
85
115
|
wall-clock hour from two schedules reads as a duplicate. */}
|
|
86
116
|
<span className="text-xs text-muted-foreground">{entry.schedule.timezone}</span>
|
|
117
|
+
{/* ⚠️ Nothing at all where the two zones agree (#256). For the ordinary reader — one
|
|
118
|
+
zone, their own — the row must not grow a second time saying what the first
|
|
119
|
+
already says. */}
|
|
120
|
+
{myFormat && mine !== entry.schedule.timezone ? (
|
|
121
|
+
<span className="text-xs text-muted-foreground">
|
|
122
|
+
{i18n.t("agent.calendarYourTime", { when: myFormat.format(entry.at) })}
|
|
123
|
+
</span>
|
|
124
|
+
) : null}
|
|
87
125
|
<code className="rounded bg-muted px-2 py-0.5 text-xs">{entry.schedule.cron}</code>
|
|
88
126
|
<span className="text-xs text-muted-foreground">
|
|
89
127
|
{i18n.t(`node.kind.${entry.schedule.target.kind}`)}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { AgentCosts, AgentCostWindow } from "@anchrd/intel-contract";
|
|
2
|
+
import { type UseQueryResult, useQuery } from "@tanstack/react-query";
|
|
3
|
+
import type { AgentRun, AgentUsage } from "@/data/agent-runtime/agent-runtime.ts";
|
|
4
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What this agent has cost, out of Cloudflare's AI Gateway log (#251).
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ `retry: false`, and the run list beside it keeps its retries. These figures ENRICH a screen
|
|
10
|
+
* that is already answered — the runs and their tokens come from the runtime and never depended on
|
|
11
|
+
* Cloudflare — and a retry is paused while the tab is unfocused (#212), so a list that waited on
|
|
12
|
+
* this one could sit on "Loading" for as long as the reader is looking elsewhere.
|
|
13
|
+
*/
|
|
14
|
+
export function useAgentCosts(agentId: string): UseQueryResult<AgentCosts> {
|
|
15
|
+
const { data } = useIntelRouterContext();
|
|
16
|
+
return useQuery({
|
|
17
|
+
queryKey: ["agent-costs", agentId],
|
|
18
|
+
queryFn: () => data.getAgentCosts(agentId),
|
|
19
|
+
retry: false,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function costWindow(costs: AgentCosts | undefined, days: number): AgentCostWindow | null {
|
|
24
|
+
return costs?.windows.find((window) => window.days === days) ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What one run cost, or why that is not known.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ Four outcomes and not two, because "nothing" and "not known" are different answers and only one
|
|
31
|
+
* of them is about money. A gateway that was never configured, a gateway that did not answer, and a
|
|
32
|
+
* run the gateway has no line for all mean "not known" — and rendering any of them as `$0.00` says
|
|
33
|
+
* the agent was free, which is the failure this ticket exists to remove.
|
|
34
|
+
*/
|
|
35
|
+
export type RunCost =
|
|
36
|
+
| { kind: "known"; cost: number; calls: number }
|
|
37
|
+
| { kind: "not_configured" }
|
|
38
|
+
| { kind: "unreadable" }
|
|
39
|
+
| { kind: "missing" };
|
|
40
|
+
|
|
41
|
+
export function runCost(costs: AgentCosts | undefined, runId: string): RunCost {
|
|
42
|
+
if (!costs || costs.status === "unreadable") return { kind: "unreadable" };
|
|
43
|
+
if (costs.status === "not_configured") return { kind: "not_configured" };
|
|
44
|
+
const found = costs.runs.find((run) => run.runId === runId);
|
|
45
|
+
// ⚠️ A run with no line is `missing`, never zero. Every run before #251 is one, and so is any run
|
|
46
|
+
// whose window has scrolled past the read's page limit.
|
|
47
|
+
return found ? { kind: "known", cost: found.cost, calls: found.calls } : { kind: "missing" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What one run consumed, or why that is not known either.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ All four counters at zero is read as "the provider reported nothing", NOT as "no tokens". A
|
|
54
|
+
* completed model call always consumed input tokens, so four zeroes cannot honestly mean zero — and
|
|
55
|
+
* Workers AI reports no usage at all, so every Workers AI run looks exactly like this. Showing them
|
|
56
|
+
* as `0 in, 0 out` would put a false and very reassuring number on the screen.
|
|
57
|
+
*/
|
|
58
|
+
export function runUsage(run: AgentRun): AgentUsage | null {
|
|
59
|
+
const usage = run.usage;
|
|
60
|
+
if (!usage) return null;
|
|
61
|
+
const total =
|
|
62
|
+
usage.inputTokens +
|
|
63
|
+
usage.outputTokens +
|
|
64
|
+
usage.cacheCreationInputTokens +
|
|
65
|
+
usage.cacheReadInputTokens;
|
|
66
|
+
return total > 0 ? usage : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Money, with enough decimals to still be money.
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ Four decimals below a dollar, not two. A single model call costs $0.0565 — at two decimals it
|
|
73
|
+
* is "$0.06", and two runs that differ by half again read identically. Above a dollar the extra
|
|
74
|
+
* digits are noise and are dropped.
|
|
75
|
+
*
|
|
76
|
+
* ⚠️ A cost that is real but smaller than the smallest digit shown gets a "<", never a rounded
|
|
77
|
+
* "$0.00". The whole point of the figure is that nothing is silently free.
|
|
78
|
+
*/
|
|
79
|
+
export function formatCost(dollars: number, locale: string): string {
|
|
80
|
+
const digits = Math.abs(dollars) >= 1 ? 2 : 4;
|
|
81
|
+
const format = (value: number) =>
|
|
82
|
+
value.toLocaleString(locale, {
|
|
83
|
+
style: "currency",
|
|
84
|
+
currency: "USD",
|
|
85
|
+
minimumFractionDigits: 2,
|
|
86
|
+
maximumFractionDigits: digits,
|
|
87
|
+
});
|
|
88
|
+
if (dollars > 0 && dollars < 0.0001) return `< ${format(0.0001)}`;
|
|
89
|
+
return format(dollars);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Token counts, short. 56 473 is "56.5K" — the exact figure helps nobody and costs the eye a line. */
|
|
93
|
+
export function formatTokens(tokens: number, locale: string): string {
|
|
94
|
+
return tokens.toLocaleString(locale, { notation: "compact", maximumFractionDigits: 1 });
|
|
95
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
2
|
+
|
|
3
|
+
export interface DelegationReading {
|
|
4
|
+
/** Whose portal connection the agent's tools run on — `null` while nothing is delegated. */
|
|
5
|
+
delegatedBy: string | null;
|
|
6
|
+
/** The signed-in person, or `null` while the session has not been read yet. */
|
|
7
|
+
viewerId: string | null;
|
|
8
|
+
/** The delegator's display name, or `null` where this browser cannot resolve it (#261). */
|
|
9
|
+
delegatorName: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whose connection the tools run on, said so that it stays true for whoever is reading (#258).
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ "Anyone who may run this agent acts on YOUR connection" was the whole sentence until #258, and
|
|
16
|
+
* it is wrong for everybody except the delegator — on the one line in the product where somebody
|
|
17
|
+
* learns whose access is being spent. It survives only where it is the strongest true form:
|
|
18
|
+
*
|
|
19
|
+
* nothing delegated nobody has given anything away yet, and whoever saves the tool selection
|
|
20
|
+
* next becomes the delegator. For the reader, that is them.
|
|
21
|
+
* the reader's own they ARE the delegator, so "your connection" says it harder than their name
|
|
22
|
+
* would. Two readings rather than one are worth it for exactly this: the
|
|
23
|
+
* sentence is about a consequence for the reader, and "your" is the word that
|
|
24
|
+
* carries a consequence.
|
|
25
|
+
* somebody else's the name. And where the name cannot be resolved — which is most ids today,
|
|
26
|
+
* because Intel has no user directory (#261) — a sentence that names nobody,
|
|
27
|
+
* never an invented name and never silence. WHOSE access is spent may be
|
|
28
|
+
* unknown; THAT somebody else's is spent must not be.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ One catalog key per reading, each a whole sentence with the name as a parameter. A sentence
|
|
31
|
+
* glued together from "acts on" + name + "'s connection" bakes an English possessive into the code,
|
|
32
|
+
* and no other language would get to put it anywhere else.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ An empty `delegatedBy` reads as "somebody else's", not as "yours". The runtime has that case
|
|
35
|
+
* for an archived agent and a definition it could not read (`packages/api/src/tools/tools.ts:39`),
|
|
36
|
+
* and it is the one case where being wrong in the reader's favour is worst.
|
|
37
|
+
*/
|
|
38
|
+
export function delegationNotice(i18n: I18n, reading: DelegationReading): string {
|
|
39
|
+
const { delegatedBy, viewerId, delegatorName } = reading;
|
|
40
|
+
if (delegatedBy === null) return i18n.t("agent.toolsDelegationNotice");
|
|
41
|
+
if (delegatedBy.length > 0 && delegatedBy === viewerId) {
|
|
42
|
+
return i18n.t("agent.toolsDelegationNotice");
|
|
43
|
+
}
|
|
44
|
+
if (delegatorName !== null) {
|
|
45
|
+
return i18n.t("agent.toolsDelegationNoticeBy", { name: delegatorName });
|
|
46
|
+
}
|
|
47
|
+
return i18n.t("agent.toolsDelegationNoticeSomebody");
|
|
48
|
+
}
|
|
@@ -1,7 +1,24 @@
|
|
|
1
|
+
import type { NodeKind } from "@anchrd/intel-contract";
|
|
1
2
|
import { useQuery } from "@tanstack/react-query";
|
|
2
3
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
3
4
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
4
5
|
|
|
6
|
+
export interface EntryTitle {
|
|
7
|
+
title: string;
|
|
8
|
+
known: boolean;
|
|
9
|
+
/** The kind of node this is, or `null` for a flow and for anything not available to the reader. */
|
|
10
|
+
kind: NodeKind | null;
|
|
11
|
+
/**
|
|
12
|
+
* The folders it is filed in, outermost first — empty at the top of the tree (#255).
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ Only the ancestors the reader may see. The walk stops at the first one that is missing from
|
|
15
|
+
* the graph, so a partial path is possible and is the honest answer: the graph holds exactly what
|
|
16
|
+
* this reader may know about, and inventing a step would name a folder to somebody who may not
|
|
17
|
+
* know it exists (#41).
|
|
18
|
+
*/
|
|
19
|
+
path: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
/**
|
|
6
23
|
* The name behind an id, read from the lists the tree already loads.
|
|
7
24
|
*
|
|
@@ -13,7 +30,7 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
|
13
30
|
* Its own module because two screens need it — the profile's references and schedules, and the
|
|
14
31
|
* "run now" menu in the page head, which has to name the targets it offers.
|
|
15
32
|
*/
|
|
16
|
-
export function useEntryTitle(entryId: string, flow = false):
|
|
33
|
+
export function useEntryTitle(entryId: string, flow = false): EntryTitle {
|
|
17
34
|
const { data } = useIntelRouterContext();
|
|
18
35
|
const i18n = useI18n();
|
|
19
36
|
const graph = useQuery({
|
|
@@ -22,10 +39,34 @@ export function useEntryTitle(entryId: string, flow = false): { title: string; k
|
|
|
22
39
|
enabled: !flow,
|
|
23
40
|
});
|
|
24
41
|
const flows = useQuery({ queryKey: ["flows"], queryFn: () => data.listFlows(), enabled: flow });
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
if (flow) {
|
|
43
|
+
const found = flows.data?.items.find((item) => item.id === entryId);
|
|
44
|
+
if (found) return { title: found.title, known: true, kind: null, path: [] };
|
|
45
|
+
return { ...unknown(i18n, flows.isPending), kind: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const nodes = graph.data?.nodes ?? [];
|
|
49
|
+
const found = nodes.find((node) => node.id === entryId);
|
|
50
|
+
if (!found) return { ...unknown(i18n, graph.isPending), kind: null };
|
|
51
|
+
|
|
52
|
+
// Upwards from the node, then reversed: outermost folder first, which is how a path reads.
|
|
53
|
+
const path: string[] = [];
|
|
54
|
+
const seen = new Set<string>([found.id]);
|
|
55
|
+
let parentId = found.parentId;
|
|
56
|
+
while (parentId !== null && !seen.has(parentId)) {
|
|
57
|
+
seen.add(parentId);
|
|
58
|
+
const parent = nodes.find((node) => node.id === parentId);
|
|
59
|
+
if (!parent) break;
|
|
60
|
+
path.unshift(parent.title);
|
|
61
|
+
parentId = parent.parentId;
|
|
62
|
+
}
|
|
63
|
+
return { title: found.title, known: true, kind: found.kind, path };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function unknown(i18n: ReturnType<typeof useI18n>, pending: boolean) {
|
|
67
|
+
return {
|
|
68
|
+
title: i18n.t(pending ? "common.loading" : "agent.unknownEntry"),
|
|
69
|
+
known: false,
|
|
70
|
+
path: [] as string[],
|
|
71
|
+
};
|
|
31
72
|
}
|
|
@@ -1,7 +1,18 @@
|
|
|
1
|
+
import type { AgentCosts } from "@anchrd/intel-contract";
|
|
1
2
|
import { useQuery } from "@tanstack/react-query";
|
|
2
3
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
3
4
|
import { useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
costWindow,
|
|
7
|
+
formatCost,
|
|
8
|
+
formatTokens,
|
|
9
|
+
type RunCost,
|
|
10
|
+
runCost,
|
|
11
|
+
runUsage,
|
|
12
|
+
useAgentCosts,
|
|
13
|
+
} from "@/agent/agent-costs/agent-costs.ts";
|
|
4
14
|
import type { AgentRun } from "@/data/agent-runtime/agent-runtime.ts";
|
|
15
|
+
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
5
16
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
6
17
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
7
18
|
|
|
@@ -15,6 +26,11 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
|
15
26
|
* ⚠️ A run says what it did and, when it failed, what stopped it. It does not carry the answer it
|
|
16
27
|
* produced: a chat answer belongs to the person it was streamed to and a mail answer to the
|
|
17
28
|
* correspondent, and the runtime deliberately stores neither where this list could reach it.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ Since #251 it also says what each run consumed and what it cost — and the two come from
|
|
31
|
+
* DIFFERENT places on purpose. The tokens are the runtime's own record; the money is Cloudflare's
|
|
32
|
+
* gateway log, read by intel. Either can be missing without the other, and each says so on its own
|
|
33
|
+
* rather than being folded into one number that would be half a guess.
|
|
18
34
|
*/
|
|
19
35
|
export function AgentLog({ agentId }: { agentId: string }) {
|
|
20
36
|
const { data } = useIntelRouterContext();
|
|
@@ -24,6 +40,7 @@ export function AgentLog({ agentId }: { agentId: string }) {
|
|
|
24
40
|
queryKey: ["agent-runs", agentId],
|
|
25
41
|
queryFn: () => data.listAgentRuns(agentId),
|
|
26
42
|
});
|
|
43
|
+
const costs = useAgentCosts(agentId);
|
|
27
44
|
|
|
28
45
|
return (
|
|
29
46
|
<section aria-label={i18n.t("agent.log")} className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
|
@@ -46,30 +63,125 @@ export function AgentLog({ agentId }: { agentId: string }) {
|
|
|
46
63
|
<p className="text-sm text-muted-foreground">{i18n.t("agent.logEmpty")}</p>
|
|
47
64
|
) : null}
|
|
48
65
|
{runs.data && runs.data.items.length > 0 ? (
|
|
49
|
-
|
|
50
|
-
{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
<>
|
|
67
|
+
<CostSummary costs={costs.data} />
|
|
68
|
+
<ul className="space-y-2">
|
|
69
|
+
{runs.data.items.map((run) => (
|
|
70
|
+
<RunRow
|
|
71
|
+
key={run.id}
|
|
72
|
+
agentId={agentId}
|
|
73
|
+
run={run}
|
|
74
|
+
cost={runCost(costs.data, run.id)}
|
|
75
|
+
open={openRunId === run.id}
|
|
76
|
+
toggle={() => setOpenRunId(openRunId === run.id ? null : run.id)}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</ul>
|
|
80
|
+
</>
|
|
60
81
|
) : null}
|
|
61
82
|
</section>
|
|
62
83
|
);
|
|
63
84
|
}
|
|
64
85
|
|
|
86
|
+
/**
|
|
87
|
+
* What the agent has cost over the two windows, above the list it belongs to.
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ Where the figures are missing this says WHY, in a sentence, instead of showing nothing. A cost
|
|
90
|
+
* view that stayed blank when the gateway was unreachable looks exactly like an agent that has cost
|
|
91
|
+
* nothing — which is the reading this whole feature exists to prevent.
|
|
92
|
+
*
|
|
93
|
+
* ⚠️ `partial` is spelled out rather than hidden behind a symbol. A total that stopped counting is
|
|
94
|
+
* not a total, and a reader comparing this month with last needs to know the number is a floor.
|
|
95
|
+
*/
|
|
96
|
+
function CostSummary({ costs }: { costs: AgentCosts | undefined }) {
|
|
97
|
+
const i18n = useI18n();
|
|
98
|
+
if (!costs) return null;
|
|
99
|
+
if (costs.status !== "read") {
|
|
100
|
+
return (
|
|
101
|
+
<p className="mb-3 text-sm text-muted-foreground">
|
|
102
|
+
{i18n.t(
|
|
103
|
+
costs.status === "not_configured" ? "agent.costNotConfigured" : "agent.costUnreadable",
|
|
104
|
+
)}
|
|
105
|
+
</p>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className="mb-3 space-y-1">
|
|
111
|
+
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-1 text-sm">
|
|
112
|
+
{costs.windows.map((window) => (
|
|
113
|
+
<div key={window.days} className="flex items-baseline gap-2">
|
|
114
|
+
<dt className="text-muted-foreground">
|
|
115
|
+
{i18n.t("agent.costWindow", { days: String(window.days) })}
|
|
116
|
+
</dt>
|
|
117
|
+
<dd className="font-medium">{formatCost(window.cost, i18n.locale)}</dd>
|
|
118
|
+
<dd className="text-xs text-muted-foreground">
|
|
119
|
+
{i18n.t("agent.costWindowCalls", { count: String(window.calls) })}
|
|
120
|
+
</dd>
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
</dl>
|
|
124
|
+
{costs.partial ? (
|
|
125
|
+
<p className="text-xs text-muted-foreground">{i18n.t("agent.costPartial")}</p>
|
|
126
|
+
) : null}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What one run consumed, in the four counters that are priced differently.
|
|
133
|
+
*
|
|
134
|
+
* ⚠️ Cache reads stand APART from fresh input and are never added into one number. They are the
|
|
135
|
+
* figure #250 is visible in — a read costs about a tenth of an ordinary input token — and in a
|
|
136
|
+
* total they disappear completely.
|
|
137
|
+
*/
|
|
138
|
+
function RunUsage({ run, i18n }: { run: AgentRun; i18n: I18n }) {
|
|
139
|
+
const usage = runUsage(run);
|
|
140
|
+
if (!usage) {
|
|
141
|
+
// ⚠️ Two different sentences, and the difference is worth the extra key. "Nothing recorded" is a
|
|
142
|
+
// run from before #250; "the provider reports nothing" is every Workers AI run, for good.
|
|
143
|
+
return (
|
|
144
|
+
<span className="text-muted-foreground">
|
|
145
|
+
{i18n.t(run.usage ? "agent.usageNotReported" : "agent.usageUnknown")}
|
|
146
|
+
</span>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return (
|
|
150
|
+
<span className="text-muted-foreground">
|
|
151
|
+
{i18n.t("agent.usage", {
|
|
152
|
+
input: formatTokens(usage.inputTokens, i18n.locale),
|
|
153
|
+
output: formatTokens(usage.outputTokens, i18n.locale),
|
|
154
|
+
cacheRead: formatTokens(usage.cacheReadInputTokens, i18n.locale),
|
|
155
|
+
cacheWrite: formatTokens(usage.cacheCreationInputTokens, i18n.locale),
|
|
156
|
+
})}
|
|
157
|
+
</span>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* ⚠️ Four outcomes, one of which is a number. A run the gateway has no line for says so; it does
|
|
163
|
+
* NOT show a zero, and it does not silently disappear from a column its neighbours fill.
|
|
164
|
+
*/
|
|
165
|
+
function RunCostLabel({ cost, i18n }: { cost: RunCost; i18n: I18n }) {
|
|
166
|
+
if (cost.kind === "known") {
|
|
167
|
+
return <span className="font-medium">{formatCost(cost.cost, i18n.locale)}</span>;
|
|
168
|
+
}
|
|
169
|
+
// The two deployment-wide reasons are already stated once above the list, so a row under them
|
|
170
|
+
// stays quiet rather than repeating the same sentence for every run on the screen.
|
|
171
|
+
if (cost.kind !== "missing") return null;
|
|
172
|
+
return <span className="text-muted-foreground">{i18n.t("agent.costMissing")}</span>;
|
|
173
|
+
}
|
|
174
|
+
|
|
65
175
|
function RunRow({
|
|
66
176
|
agentId,
|
|
67
177
|
run,
|
|
178
|
+
cost,
|
|
68
179
|
open,
|
|
69
180
|
toggle,
|
|
70
181
|
}: {
|
|
71
182
|
agentId: string;
|
|
72
183
|
run: AgentRun;
|
|
184
|
+
cost: RunCost;
|
|
73
185
|
open: boolean;
|
|
74
186
|
toggle(): void;
|
|
75
187
|
}) {
|
|
@@ -99,6 +211,10 @@ function RunRow({
|
|
|
99
211
|
<span className="text-muted-foreground">
|
|
100
212
|
{i18n.t("agent.steps", { count: run.steps })}
|
|
101
213
|
</span>
|
|
214
|
+
<RunCostLabel cost={cost} i18n={i18n} />
|
|
215
|
+
</span>
|
|
216
|
+
<span className="mt-1 block text-xs">
|
|
217
|
+
<RunUsage run={run} i18n={i18n} />
|
|
102
218
|
</span>
|
|
103
219
|
{/* The failure in the words the failure itself used. Summarising it into "failed" is what
|
|
104
220
|
the status pill already says, and it is never the sentence that helps. */}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { AgentModel } from "@anchrd/intel-contract";
|
|
1
|
+
import { AgentModel, type ModelCatalogEntry } from "@anchrd/intel-contract";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import type { ModelFacts
|
|
3
|
+
import type { ModelFacts } from "./agent-models.types.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Which models this installation offers.
|
|
@@ -16,65 +16,6 @@ import type { ModelFacts, ModelPrice } from "./agent-models.types.ts";
|
|
|
16
16
|
*/
|
|
17
17
|
const ConfiguredModels = z.array(AgentModel);
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Context window and price per model, collected 2026-08-06 from
|
|
21
|
-
* `GET /accounts/{id}/ai/models/search` (Workers AI, every entry carrying `function_calling`) and
|
|
22
|
-
* from Anthropic's model list. Both figures are shown beside the select, so this table is the one
|
|
23
|
-
* place to correct when they move.
|
|
24
|
-
*
|
|
25
|
-
* ⚠️ It is a copy and it ages silently. Cloudflare changes context windows and prices without
|
|
26
|
-
* notice, and a wrong price in the interface is worse than none — re-read the endpoint above rather
|
|
27
|
-
* than editing a number from memory. Asking live from the browser is not the way out: the account
|
|
28
|
-
* token does not belong in a SPA.
|
|
29
|
-
*
|
|
30
|
-
* ⚠️ Workers AI models that do not carry `function_calling` are deliberately absent. An agent is a
|
|
31
|
-
* tool loop; a model that cannot call a tool cannot run one, and offering it would only produce a
|
|
32
|
-
* broken agent for whoever picked it.
|
|
33
|
-
*/
|
|
34
|
-
const catalog: Record<string, { name: string; contextTokens: number; price: ModelPrice }> = {
|
|
35
|
-
"anthropic:claude-opus-5": price("Opus 5", 1_000_000, 5, 25),
|
|
36
|
-
"anthropic:claude-sonnet-5": price("Sonnet 5", 1_000_000, 3, 15),
|
|
37
|
-
"anthropic:claude-sonnet-4": price("Sonnet 4", 200_000, 3, 15),
|
|
38
|
-
"anthropic:claude-haiku-4-5": price("Haiku 4.5", 200_000, 1, 5),
|
|
39
|
-
"workers-ai:@cf/openai/gpt-oss-120b": price("GPT-OSS 120B", 128_000, 0.35, 0.75),
|
|
40
|
-
"workers-ai:@cf/openai/gpt-oss-20b": price("GPT-OSS 20B", 128_000, 0.2, 0.3),
|
|
41
|
-
"workers-ai:@cf/moonshotai/kimi-k2.6": price("Kimi K2.6", 262_144, 0.95, 4),
|
|
42
|
-
"workers-ai:@cf/moonshotai/kimi-k2.7-code": price("Kimi K2.7 Code", 262_144, 0.95, 4),
|
|
43
|
-
"workers-ai:@cf/zai-org/glm-5.2": price("GLM 5.2", 262_144, 1.4, 4.4),
|
|
44
|
-
"workers-ai:@cf/zai-org/glm-4.7-flash": price("GLM 4.7 Flash", 131_072, 0.0605, 0.4),
|
|
45
|
-
"workers-ai:@cf/google/gemma-4-26b-a4b-it": price("Gemma 4 26B", 256_000, 0.1, 0.3),
|
|
46
|
-
"workers-ai:@cf/nvidia/nemotron-3-120b-a12b": price("Nemotron 3 120B", 256_000, 0.5, 1.5),
|
|
47
|
-
"workers-ai:@cf/meta/llama-4-scout-17b-16e-instruct": price("Llama 4 Scout", 131_000, 0.27, 0.85),
|
|
48
|
-
"workers-ai:@cf/meta/llama-3.3-70b-instruct-fp8-fast": price(
|
|
49
|
-
"Llama 3.3 70B Fast",
|
|
50
|
-
24_000,
|
|
51
|
-
0.293,
|
|
52
|
-
2.253,
|
|
53
|
-
),
|
|
54
|
-
"workers-ai:@cf/mistralai/mistral-small-3.1-24b-instruct": price(
|
|
55
|
-
"Small 3.1 24B",
|
|
56
|
-
128_000,
|
|
57
|
-
0.351,
|
|
58
|
-
0.555,
|
|
59
|
-
),
|
|
60
|
-
"workers-ai:@cf/ibm-granite/granite-4.0-h-micro": price(
|
|
61
|
-
"Granite 4.0 Micro",
|
|
62
|
-
131_000,
|
|
63
|
-
0.017,
|
|
64
|
-
0.112,
|
|
65
|
-
),
|
|
66
|
-
"workers-ai:@cf/qwen/qwen3-30b-a3b-fp8": price("Qwen3 30B", 32_768, 0.0509, 0.335),
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
function price(
|
|
70
|
-
name: string,
|
|
71
|
-
contextTokens: number,
|
|
72
|
-
inputPerMillion: number,
|
|
73
|
-
outputPerMillion: number,
|
|
74
|
-
): { name: string; contextTokens: number; price: ModelPrice } {
|
|
75
|
-
return { name, contextTokens, price: { inputPerMillion, outputPerMillion } };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
19
|
/**
|
|
79
20
|
* ⚠️ The brand comes from the model id, never from `provider`. `provider` says who serves the model,
|
|
80
21
|
* so every Workers AI entry would read "Cloudflare" — thirteen identical labels in a list whose whole
|
|
@@ -131,20 +72,43 @@ export function parseModelKey(key: string): AgentModel | null {
|
|
|
131
72
|
return parsed.success ? parsed.data : null;
|
|
132
73
|
}
|
|
133
74
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
75
|
+
/**
|
|
76
|
+
* How a model is written for a person: brand first, then the model's own name, then the two numbers
|
|
77
|
+
* somebody actually chooses on.
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ The figures come IN, from intel's catalog (#257). There is no table in this package any more,
|
|
80
|
+
* and putting one back would restore exactly what the removed one was accused of: it was a copy, it
|
|
81
|
+
* aged silently, and a wrong price in the interface is worse than none. What this file still owns is
|
|
82
|
+
* how a model is written down — the brand, the fallback name, the two formats — none of which is a
|
|
83
|
+
* fact about money.
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ A missing catalog and an unknown model produce the SAME answer: a name and no figures. The
|
|
86
|
+
* select therefore renders while the catalog is still on its way, and it renders at all when the
|
|
87
|
+
* catalog could not be fetched — an agent whose model cannot be chosen is an agent nobody can repair
|
|
88
|
+
* from this screen.
|
|
89
|
+
*/
|
|
90
|
+
export function modelFacts(model: AgentModel, catalog?: ModelCatalogEntry[]): ModelFacts {
|
|
91
|
+
const known = catalog?.find(
|
|
92
|
+
(entry) => entry.provider === model.provider && entry.model === model.model,
|
|
93
|
+
);
|
|
139
94
|
if (known) {
|
|
140
95
|
return {
|
|
141
96
|
brand: brandOf(model),
|
|
142
97
|
name: known.name,
|
|
143
98
|
contextTokens: known.contextTokens,
|
|
144
99
|
price: known.price,
|
|
100
|
+
// Nothing is stale when there is nothing to be stale: a model the catalog carries no price
|
|
101
|
+
// for shows no price, and no warning about a price it never showed.
|
|
102
|
+
stale: known.source === "builtin" && known.price !== null,
|
|
145
103
|
};
|
|
146
104
|
}
|
|
147
|
-
return {
|
|
105
|
+
return {
|
|
106
|
+
brand: brandOf(model),
|
|
107
|
+
name: unknownName(model),
|
|
108
|
+
contextTokens: null,
|
|
109
|
+
price: null,
|
|
110
|
+
stale: false,
|
|
111
|
+
};
|
|
148
112
|
}
|
|
149
113
|
|
|
150
114
|
function brandOf(model: AgentModel): string {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ModelPrice } from "@anchrd/intel-contract";
|
|
2
|
+
|
|
1
3
|
/** What a reader needs in order to pick a model: whose it is, what it is called, and what it costs. */
|
|
2
4
|
export interface ModelFacts {
|
|
3
5
|
brand: string;
|
|
@@ -9,10 +11,14 @@ export interface ModelFacts {
|
|
|
9
11
|
*/
|
|
10
12
|
contextTokens: number | null;
|
|
11
13
|
price: ModelPrice | null;
|
|
14
|
+
/**
|
|
15
|
+
* Whether the two figures came off a written-out table rather than from Cloudflare (#257).
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ It exists so the screen can SAY so. A silent fallback onto typed-out prices is the state
|
|
18
|
+
* this feature replaced, with more code — and a wrong price in the interface is worse than none.
|
|
19
|
+
* `false` for a model with no figures at all: there is nothing there to be stale.
|
|
20
|
+
*/
|
|
21
|
+
stale: boolean;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
|
-
|
|
15
|
-
export interface ModelPrice {
|
|
16
|
-
inputPerMillion: number;
|
|
17
|
-
outputPerMillion: number;
|
|
18
|
-
}
|
|
24
|
+
export type { ModelPrice };
|