@anchrd/intel-ui 0.11.0 → 0.13.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 +3 -2
- package/src/agent/agent-calendar/agent-calendar.tsx +32 -14
- package/src/agent/agent-chat/agent-chat.tsx +3 -1
- package/src/agent/agent-cron/agent-cron.ts +49 -113
- package/src/agent/agent-definition/agent-definition.ts +18 -9
- package/src/agent/agent-entry-title/agent-entry-title.ts +3 -1
- package/src/agent/agent-log/agent-log.tsx +6 -3
- package/src/agent/agent-models/agent-models.ts +142 -3
- package/src/agent/agent-models/agent-models.types.ts +18 -0
- package/src/agent/agent-profile/agent-profile.tsx +114 -31
- package/src/agent/agent.tsx +17 -9
- package/src/app/app-sidebar/app-sidebar.tsx +2 -2
- package/src/app/app-tree/app-tree.tsx +5 -3
- package/src/app/app.tsx +2 -2
- package/src/app/header-search/header-search.tsx +3 -1
- package/src/app/settings-dialog/settings-dialog.tsx +133 -0
- package/src/app/tree-move/tree-move.tsx +3 -1
- package/src/app/user-footer/user-footer.tsx +25 -2
- package/src/app/view-toggle/view-toggle.tsx +2 -2
- package/src/archive/archive.tsx +3 -1
- package/src/entry-picker/entry-picker.tsx +3 -1
- package/src/flow-runs/flow-runs.tsx +6 -3
- package/src/flows/flows.tsx +13 -9
- package/src/flows/node-palette/node-palette.tsx +2 -2
- package/src/folder-contents/folder-contents.tsx +3 -1
- package/src/graph-pane/graph-pane.tsx +2 -2
- package/src/i18n/de.json +402 -0
- package/src/i18n/en.json +17 -5
- package/src/i18n/es.json +402 -0
- package/src/i18n/i18n-context.tsx +65 -0
- package/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/src/i18n/i18n.ts +15 -11
- package/src/i18n/i18n.types.ts +43 -0
- package/src/main.tsx +33 -12
- package/src/modal/modal.tsx +2 -2
- package/src/node-table/node-table.tsx +3 -1
- package/src/nodes/nodes.tsx +7 -3
- package/src/resource-menu/resource-menu.tsx +8 -4
- package/src/router/router.types.ts +3 -2
- package/src/save-button/save-button.tsx +3 -3
- package/src/sign-in-refused/sign-in-refused.tsx +3 -2
- package/src/theme/theme-context.tsx +89 -0
- package/src/theme/theme.ts +51 -21
- package/src/timezone/timezone-context.tsx +45 -0
- package/src/timezone/timezone.ts +60 -0
- package/src/tools/tools.tsx +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.8.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",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"clsx": "^2.1.1",
|
|
46
46
|
"cmdk": "^1.1.1",
|
|
47
47
|
"colorjs.io": "^0.7.1",
|
|
48
|
+
"croner": "^10.0.1",
|
|
48
49
|
"fflate": "^0.8.3",
|
|
49
50
|
"graphology": "^0.26.0",
|
|
50
51
|
"lucide-react": "^1.25.0",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
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
|
-
import {
|
|
4
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
5
5
|
|
|
6
6
|
// How far ahead one schedule is shown. Enough to recognise a rhythm — daily, weekly, the first of
|
|
7
7
|
// the month — and not so much that the page becomes a year planner nobody asked for.
|
|
@@ -14,9 +14,11 @@ const PerSchedule = 5;
|
|
|
14
14
|
* calendar, so these times are this page's own reading of the same cron expressions — by the same
|
|
15
15
|
* rules, pinned by `agent-cron.unit.ts` against the runtime's test.
|
|
16
16
|
*
|
|
17
|
-
* ⚠️
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* ⚠️ Each entry is drawn in ITS OWN schedule's timezone and names it (#228). Not in the reader's:
|
|
18
|
+
* the agent fires at the hour its schedule says, and an instant shown in the zone of whoever is
|
|
19
|
+
* looking would be a different hour from the one the agent keeps — a difference that only ever
|
|
20
|
+
* surfaces as "it ran at the wrong time". And not one zone for the whole list either, because two
|
|
21
|
+
* schedules of one agent may sit in different ones.
|
|
20
22
|
*/
|
|
21
23
|
export function AgentCalendar({
|
|
22
24
|
definition,
|
|
@@ -27,26 +29,39 @@ export function AgentCalendar({
|
|
|
27
29
|
// does not have to travel in time to assert on it.
|
|
28
30
|
now: Date;
|
|
29
31
|
}) {
|
|
30
|
-
const
|
|
32
|
+
const i18n = useI18n();
|
|
31
33
|
const schedules = definition?.schedules ?? [];
|
|
32
34
|
const upcoming = schedules
|
|
33
35
|
.flatMap((schedule) =>
|
|
34
|
-
nextCronFires(schedule.cron, now, PerSchedule).map((at) => ({
|
|
36
|
+
nextCronFires(schedule.cron, now, PerSchedule, schedule.timezone).map((at) => ({
|
|
37
|
+
at,
|
|
38
|
+
schedule,
|
|
39
|
+
})),
|
|
35
40
|
)
|
|
36
41
|
.sort((left, right) => left.at.getTime() - right.at.getTime());
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
// One formatter per zone rather than one per row: a DateTimeFormat is expensive to build, and a
|
|
44
|
+
// calendar of five entries per schedule would otherwise build one for each of them.
|
|
45
|
+
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
46
|
+
const formatIn = (zone: string, at: Date) => {
|
|
47
|
+
let format = formatters.get(zone);
|
|
48
|
+
if (!format) {
|
|
49
|
+
format = new Intl.DateTimeFormat(i18n.locale, {
|
|
50
|
+
dateStyle: "full",
|
|
51
|
+
timeStyle: "short",
|
|
52
|
+
timeZone: zone,
|
|
53
|
+
});
|
|
54
|
+
formatters.set(zone, format);
|
|
55
|
+
}
|
|
56
|
+
return format.format(at);
|
|
57
|
+
};
|
|
43
58
|
|
|
44
59
|
return (
|
|
45
60
|
<section
|
|
46
61
|
aria-label={i18n.t("agent.calendar")}
|
|
47
62
|
className="min-h-0 flex-1 overflow-y-auto px-6 py-5"
|
|
48
63
|
>
|
|
49
|
-
<p className="text-xs text-muted-foreground">{i18n.t("agent.
|
|
64
|
+
<p className="text-xs text-muted-foreground">{i18n.t("agent.calendarZones")}</p>
|
|
50
65
|
{schedules.length === 0 ? (
|
|
51
66
|
<p className="mt-4 text-sm text-muted-foreground">{i18n.t("agent.scheduleEmpty")}</p>
|
|
52
67
|
) : upcoming.length === 0 ? (
|
|
@@ -59,13 +74,16 @@ export function AgentCalendar({
|
|
|
59
74
|
<ol className="mt-4 space-y-2">
|
|
60
75
|
{upcoming.map((entry) => (
|
|
61
76
|
<li
|
|
62
|
-
key={`${entry.schedule.cron}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
|
|
77
|
+
key={`${entry.schedule.cron}@${entry.schedule.timezone}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
|
|
63
78
|
className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm"
|
|
64
79
|
>
|
|
65
80
|
<CalendarClock aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
|
66
81
|
<time dateTime={entry.at.toISOString()} className="font-medium">
|
|
67
|
-
{
|
|
82
|
+
{formatIn(entry.schedule.timezone, entry.at)}
|
|
68
83
|
</time>
|
|
84
|
+
{/* The zone stands next to the time, not once above the list: without it the same
|
|
85
|
+
wall-clock hour from two schedules reads as a duplicate. */}
|
|
86
|
+
<span className="text-xs text-muted-foreground">{entry.schedule.timezone}</span>
|
|
69
87
|
<code className="rounded bg-muted px-2 py-0.5 text-xs">{entry.schedule.cron}</code>
|
|
70
88
|
<span className="text-xs text-muted-foreground">
|
|
71
89
|
{i18n.t(`node.kind.${entry.schedule.target.kind}`)}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
|
|
9
9
|
import { ArrowDown, Send } from "lucide-react";
|
|
10
10
|
import { useMemo } from "react";
|
|
11
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
11
12
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -25,7 +26,8 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
|
25
26
|
* rather than from a bearer this page would have had to hold.
|
|
26
27
|
*/
|
|
27
28
|
export function AgentChat({ agentId }: { agentId: string }) {
|
|
28
|
-
const { data
|
|
29
|
+
const { data } = useIntelRouterContext();
|
|
30
|
+
const i18n = useI18n();
|
|
29
31
|
// ⚠️ Built once per agent. A transport rebuilt on every render restarts the runtime underneath
|
|
30
32
|
// assistant-ui and takes the open thread with it.
|
|
31
33
|
const transport = useMemo(
|
|
@@ -1,132 +1,68 @@
|
|
|
1
|
+
import { Cron } from "croner";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* When a five-field cron expression fires next, in
|
|
4
|
+
* When a five-field cron expression fires next, read in the schedule's own timezone.
|
|
3
5
|
*
|
|
4
|
-
* ⚠️ This is a second copy of `packages/agent/src/schedule/cron/cron.ts
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* exactly the cases the runtime's own test pins — including the day-field OR rule, which is the
|
|
10
|
-
* one an independent reimplementation gets wrong.
|
|
6
|
+
* ⚠️ This is still a second copy of `packages/agent/src/schedule/cron/cron.ts` — the runtime imports
|
|
7
|
+
* nothing from the contract and this package cannot import a Worker package, so there remains no
|
|
8
|
+
* third place for it. What changed with #228 is WHAT is duplicated: both files are now a thin
|
|
9
|
+
* wrapper over croner, and the part that could actually disagree — the matching, DST, the day-field
|
|
10
|
+
* OR rule — is one implementation in the library.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* That was the real risk. The old comment put it plainly: "a calendar that promises 08:00 while the
|
|
13
|
+
* alarm fires at 09:00 is worse than a calendar that says nothing" — and two independent
|
|
14
|
+
* reimplementations of timezone arithmetic is exactly how that happens.
|
|
15
15
|
*/
|
|
16
|
-
interface Field {
|
|
17
|
-
values: Set<number>;
|
|
18
|
-
restricted: boolean;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function parseField(source: string, min: number, max: number): Field | null {
|
|
22
|
-
const values = new Set<number>();
|
|
23
|
-
let restricted = false;
|
|
24
|
-
for (const part of source.split(",")) {
|
|
25
|
-
const [range, stepText] = part.split("/");
|
|
26
|
-
if (range === undefined || range === "") return null;
|
|
27
|
-
const step = stepText === undefined ? 1 : Number(stepText);
|
|
28
|
-
if (!Number.isInteger(step) || step < 1) return null;
|
|
29
|
-
let from: number;
|
|
30
|
-
let to: number;
|
|
31
|
-
if (range === "*") {
|
|
32
|
-
from = min;
|
|
33
|
-
to = max;
|
|
34
|
-
if (step !== 1) restricted = true;
|
|
35
|
-
} else if (range.includes("-")) {
|
|
36
|
-
const [fromText, toText] = range.split("-");
|
|
37
|
-
from = Number(fromText);
|
|
38
|
-
to = Number(toText);
|
|
39
|
-
restricted = true;
|
|
40
|
-
} else {
|
|
41
|
-
from = Number(range);
|
|
42
|
-
to = from;
|
|
43
|
-
restricted = true;
|
|
44
|
-
}
|
|
45
|
-
if (!Number.isInteger(from) || !Number.isInteger(to) || from < min || to > max || from > to) {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
for (let value = from; value <= to; value += step) values.add(value);
|
|
49
|
-
}
|
|
50
|
-
return values.size > 0 ? { values, restricted } : null;
|
|
51
|
-
}
|
|
52
16
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
dayOfMonth: Field;
|
|
57
|
-
month: Field;
|
|
58
|
-
dayOfWeek: Field;
|
|
59
|
-
}
|
|
17
|
+
// One year is the search bound. An expression that matches nothing within a year — "31st of
|
|
18
|
+
// February" — has no next fire time, and saying so beats stepping forever.
|
|
19
|
+
const MaxYearsAhead = 1;
|
|
60
20
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
string,
|
|
69
|
-
string,
|
|
70
|
-
string,
|
|
71
|
-
];
|
|
72
|
-
const fields = {
|
|
73
|
-
minute: parseField(minute, 0, 59),
|
|
74
|
-
hour: parseField(hour, 0, 23),
|
|
75
|
-
dayOfMonth: parseField(dayOfMonth, 1, 31),
|
|
76
|
-
month: parseField(month, 1, 12),
|
|
77
|
-
dayOfWeek: parseField(dayOfWeek, 0, 7),
|
|
78
|
-
};
|
|
79
|
-
if (!fields.minute || !fields.hour || !fields.dayOfMonth || !fields.month || !fields.dayOfWeek) {
|
|
21
|
+
function schedule(expression: string, timezone: string): Cron | null {
|
|
22
|
+
try {
|
|
23
|
+
const job = new Cron(expression.trim(), { timezone, paused: true });
|
|
24
|
+
// croner also accepts six fields (with seconds). Intel's contract is five, and so is the hint
|
|
25
|
+
// shown next to the input — a six-field expression must not quietly mean something else.
|
|
26
|
+
return expression.trim().split(/\s+/).length === 5 ? job : null;
|
|
27
|
+
} catch {
|
|
80
28
|
return null;
|
|
81
29
|
}
|
|
82
|
-
if (fields.dayOfWeek.values.has(7)) fields.dayOfWeek.values.add(0);
|
|
83
|
-
return fields as CronExpression;
|
|
84
30
|
}
|
|
85
31
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
32
|
+
// ⚠️ The croner call is inside the try, not only the construction. An unknown timezone can surface
|
|
33
|
+
// either place depending on when croner first converts a date — and an exception escaping here ends
|
|
34
|
+
// up inside a Durable Object alarm, which is the one place nobody is watching.
|
|
35
|
+
function runs(expression: string, after: Date, count: number, timezone: string): Date[] {
|
|
36
|
+
const job = schedule(expression, timezone);
|
|
37
|
+
if (!job) return [];
|
|
38
|
+
try {
|
|
39
|
+
return job.nextRuns(count, after);
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
97
43
|
}
|
|
98
44
|
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const start = Math.floor(after.getTime() / 60_000) * 60_000 + 60_000;
|
|
108
|
-
for (let step = 0; step < MaxMinutesAhead; step += 1) {
|
|
109
|
-
const candidate = new Date(start + step * 60_000);
|
|
110
|
-
if (matches(cron, candidate)) return candidate;
|
|
111
|
-
}
|
|
112
|
-
return null;
|
|
45
|
+
export function nextCronFire(expression: string, after: Date, timezone: string): Date | null {
|
|
46
|
+
// Strictly after: a schedule that just fired must not match the same minute again. croner's
|
|
47
|
+
// `nextRuns(…, from)` is already exclusive of `from`.
|
|
48
|
+
const next = runs(expression, after, 1, timezone)[0];
|
|
49
|
+
if (!next) return null;
|
|
50
|
+
const limit = new Date(after.getTime());
|
|
51
|
+
limit.setUTCFullYear(limit.getUTCFullYear() + MaxYearsAhead);
|
|
52
|
+
return next > limit ? null : next;
|
|
113
53
|
}
|
|
114
54
|
|
|
115
55
|
/**
|
|
116
56
|
* The next `count` fire times of one expression.
|
|
117
57
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* yields an empty list — the calendar says "nothing" rather than guessing.
|
|
58
|
+
* An unparseable or never-matching expression yields an empty list — the caller says "nothing"
|
|
59
|
+
* rather than guessing.
|
|
121
60
|
*/
|
|
122
|
-
export function nextCronFires(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
cursor = next;
|
|
130
|
-
}
|
|
131
|
-
return fires;
|
|
61
|
+
export function nextCronFires(
|
|
62
|
+
expression: string,
|
|
63
|
+
after: Date,
|
|
64
|
+
count: number,
|
|
65
|
+
timezone: string,
|
|
66
|
+
): Date[] {
|
|
67
|
+
return runs(expression, after, count, timezone);
|
|
132
68
|
}
|
|
@@ -70,18 +70,27 @@ export function useAgentDefinition(nodeId: string): AgentDefinitionHandle {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
* When this agent next does something on its own, across all of its schedules
|
|
73
|
+
* When this agent next does something on its own, across all of its schedules — and in which zone
|
|
74
|
+
* that time is meant.
|
|
74
75
|
*
|
|
75
76
|
* ⚠️ Derived from the cron expressions, never asked. The runtime arms one alarm at a time and does
|
|
76
|
-
* not publish it, so this is a reading of the same expressions by the same rules —
|
|
77
|
-
*
|
|
77
|
+
* not publish it, so this is a reading of the same expressions by the same rules — since #228 both
|
|
78
|
+
* sides read them through croner, so the reading and the alarm cannot drift apart.
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ The zone travels WITH the time. Two schedules of one agent may sit in different zones, so a
|
|
81
|
+
* single "all times are X" line above the screen would be a lie for one of them; the caller formats
|
|
82
|
+
* each instant in the zone it came back with.
|
|
78
83
|
*/
|
|
79
|
-
export function nextRunAt(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
export function nextRunAt(
|
|
85
|
+
definition: AgentDefinition | null,
|
|
86
|
+
now: Date,
|
|
87
|
+
): { at: Date; timezone: string } | null {
|
|
88
|
+
const fires = (definition?.schedules ?? []).flatMap((schedule) => {
|
|
89
|
+
const at = nextCronFires(schedule.cron, now, 1, schedule.timezone)[0];
|
|
90
|
+
return at ? [{ at, timezone: schedule.timezone }] : [];
|
|
91
|
+
});
|
|
92
|
+
return fires.reduce<{ at: Date; timezone: string } | null>(
|
|
93
|
+
(earliest, fire) => (earliest === null || fire.at < earliest.at ? fire : earliest),
|
|
85
94
|
null,
|
|
86
95
|
);
|
|
87
96
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
2
3
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -13,7 +14,8 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
|
13
14
|
* "run now" menu in the page head, which has to name the targets it offers.
|
|
14
15
|
*/
|
|
15
16
|
export function useEntryTitle(entryId: string, flow = false): { title: string; known: boolean } {
|
|
16
|
-
const { data
|
|
17
|
+
const { data } = useIntelRouterContext();
|
|
18
|
+
const i18n = useI18n();
|
|
17
19
|
const graph = useQuery({
|
|
18
20
|
queryKey: ["node-graph"],
|
|
19
21
|
queryFn: () => data.getNodeGraph(),
|
|
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
2
2
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import type { AgentRun } from "@/data/agent-runtime/agent-runtime.ts";
|
|
5
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
5
6
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -16,7 +17,8 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
|
16
17
|
* correspondent, and the runtime deliberately stores neither where this list could reach it.
|
|
17
18
|
*/
|
|
18
19
|
export function AgentLog({ agentId }: { agentId: string }) {
|
|
19
|
-
const { data
|
|
20
|
+
const { data } = useIntelRouterContext();
|
|
21
|
+
const i18n = useI18n();
|
|
20
22
|
const [openRunId, setOpenRunId] = useState<string | null>(null);
|
|
21
23
|
const runs = useQuery({
|
|
22
24
|
queryKey: ["agent-runs", agentId],
|
|
@@ -71,7 +73,7 @@ function RunRow({
|
|
|
71
73
|
open: boolean;
|
|
72
74
|
toggle(): void;
|
|
73
75
|
}) {
|
|
74
|
-
const
|
|
76
|
+
const i18n = useI18n();
|
|
75
77
|
const failed = run.status === "failed";
|
|
76
78
|
const Chevron = open ? ChevronDown : ChevronRight;
|
|
77
79
|
return (
|
|
@@ -113,7 +115,8 @@ function RunRow({
|
|
|
113
115
|
// The operational log of one run: what it tried, in order. Fetched only when a row is opened —
|
|
114
116
|
// every run carries its own entries and loading all of them would be the whole log for one screen.
|
|
115
117
|
function RunDetail({ agentId, runId }: { agentId: string; runId: string }) {
|
|
116
|
-
const { data
|
|
118
|
+
const { data } = useIntelRouterContext();
|
|
119
|
+
const i18n = useI18n();
|
|
117
120
|
const detail = useQuery({
|
|
118
121
|
queryKey: ["agent-run", agentId, runId],
|
|
119
122
|
queryFn: () => data.getAgentRun(agentId, runId),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AgentModel } from "@anchrd/intel-contract";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import type { ModelFacts, ModelPrice } from "./agent-models.types.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Which models this installation offers.
|
|
@@ -7,8 +8,7 @@ import { z } from "zod";
|
|
|
7
8
|
* ⚠️ There is no endpoint to ask. The runtime's registry answers one question — "is this provider
|
|
8
9
|
* configured" — and only when a run is already starting; nothing anywhere lists what a deployment
|
|
9
10
|
* pays for. Rather than invent an API for one screen, the list is build configuration
|
|
10
|
-
* (`VITE_AGENT_MODELS`, a JSON array of `{provider, model}`), with the
|
|
11
|
-
* fixtures use as the default.
|
|
11
|
+
* (`VITE_AGENT_MODELS`, a JSON array of `{provider, model}`), with the offering below as the default.
|
|
12
12
|
*
|
|
13
13
|
* ⚠️ The definition's current model is always offered, even when it is not in the list. A select
|
|
14
14
|
* that silently dropped it would turn "look at this agent" into "change this agent" for anyone who
|
|
@@ -16,10 +16,105 @@ import { z } from "zod";
|
|
|
16
16
|
*/
|
|
17
17
|
const ConfiguredModels = z.array(AgentModel);
|
|
18
18
|
|
|
19
|
-
|
|
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
|
+
/**
|
|
79
|
+
* ⚠️ The brand comes from the model id, never from `provider`. `provider` says who serves the model,
|
|
80
|
+
* so every Workers AI entry would read "Cloudflare" — thirteen identical labels in a list whose whole
|
|
81
|
+
* job is to tell them apart. The name a reader looks for sits in the id, as its second segment:
|
|
82
|
+
* `@cf/openai/gpt-oss-120b` is OpenAI's.
|
|
83
|
+
*/
|
|
84
|
+
const brands: Record<string, string> = {
|
|
85
|
+
aisingapore: "AI Singapore",
|
|
86
|
+
"deepseek-ai": "DeepSeek",
|
|
87
|
+
google: "Google",
|
|
88
|
+
"ibm-granite": "IBM",
|
|
89
|
+
meta: "Meta",
|
|
90
|
+
"meta-llama": "Meta",
|
|
91
|
+
mistralai: "Mistral",
|
|
92
|
+
moonshotai: "Moonshot",
|
|
93
|
+
nvidia: "NVIDIA",
|
|
94
|
+
openai: "OpenAI",
|
|
95
|
+
qwen: "Qwen",
|
|
96
|
+
"zai-org": "Z.ai",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const firstFallback: AgentModel = { provider: "anthropic", model: "claude-sonnet-5" };
|
|
20
100
|
const fallback: [AgentModel, ...AgentModel[]] = [
|
|
21
101
|
firstFallback,
|
|
102
|
+
{ provider: "anthropic", model: "claude-opus-5" },
|
|
103
|
+
{ provider: "anthropic", model: "claude-haiku-4-5" },
|
|
104
|
+
{ provider: "anthropic", model: "claude-sonnet-4" },
|
|
105
|
+
{ provider: "workers-ai", model: "@cf/openai/gpt-oss-120b" },
|
|
106
|
+
{ provider: "workers-ai", model: "@cf/openai/gpt-oss-20b" },
|
|
107
|
+
{ provider: "workers-ai", model: "@cf/moonshotai/kimi-k2.6" },
|
|
108
|
+
{ provider: "workers-ai", model: "@cf/moonshotai/kimi-k2.7-code" },
|
|
109
|
+
{ provider: "workers-ai", model: "@cf/zai-org/glm-5.2" },
|
|
110
|
+
{ provider: "workers-ai", model: "@cf/zai-org/glm-4.7-flash" },
|
|
111
|
+
{ provider: "workers-ai", model: "@cf/google/gemma-4-26b-a4b-it" },
|
|
112
|
+
{ provider: "workers-ai", model: "@cf/nvidia/nemotron-3-120b-a12b" },
|
|
113
|
+
{ provider: "workers-ai", model: "@cf/meta/llama-4-scout-17b-16e-instruct" },
|
|
22
114
|
{ provider: "workers-ai", model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast" },
|
|
115
|
+
{ provider: "workers-ai", model: "@cf/mistralai/mistral-small-3.1-24b-instruct" },
|
|
116
|
+
{ provider: "workers-ai", model: "@cf/ibm-granite/granite-4.0-h-micro" },
|
|
117
|
+
{ provider: "workers-ai", model: "@cf/qwen/qwen3-30b-a3b-fp8" },
|
|
23
118
|
];
|
|
24
119
|
|
|
25
120
|
export function modelKey(model: AgentModel): string {
|
|
@@ -36,6 +131,50 @@ export function parseModelKey(key: string): AgentModel | null {
|
|
|
36
131
|
return parsed.success ? parsed.data : null;
|
|
37
132
|
}
|
|
38
133
|
|
|
134
|
+
// How a model is written for a person: brand first, then the model's own name, then the two numbers
|
|
135
|
+
// somebody actually chooses on. A model outside the catalog keeps its own id as the name and reports
|
|
136
|
+
// no figures at all.
|
|
137
|
+
export function modelFacts(model: AgentModel): ModelFacts {
|
|
138
|
+
const known = catalog[modelKey(model)];
|
|
139
|
+
if (known) {
|
|
140
|
+
return {
|
|
141
|
+
brand: brandOf(model),
|
|
142
|
+
name: known.name,
|
|
143
|
+
contextTokens: known.contextTokens,
|
|
144
|
+
price: known.price,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { brand: brandOf(model), name: unknownName(model), contextTokens: null, price: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function brandOf(model: AgentModel): string {
|
|
151
|
+
if (model.provider === "anthropic") return "Claude";
|
|
152
|
+
const vendor = model.model.split("/")[1];
|
|
153
|
+
if (!vendor) return "Workers AI";
|
|
154
|
+
return brands[vendor] ?? vendor;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The id itself, not a prettified guess. "claude-opus-4-1" would become "Opus 4 1" under any rule
|
|
158
|
+
// simple enough to write here, and a wrong name is harder to recognise than a raw one.
|
|
159
|
+
function unknownName(model: AgentModel): string {
|
|
160
|
+
if (model.provider === "anthropic") return model.model.replace(/^claude-/, "");
|
|
161
|
+
const segments = model.model.split("/");
|
|
162
|
+
return segments[segments.length - 1] ?? model.model;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function formatContextTokens(tokens: number): string {
|
|
166
|
+
if (tokens >= 1_000_000) return `${Math.round(tokens / 1_000_000)}M`;
|
|
167
|
+
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
|
168
|
+
return String(tokens);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ⚠️ Two decimals at least, three at most, and always per one million tokens. Granite costs $0.017
|
|
172
|
+
// per million in: rounded to two decimals it reads $0.02, which is the same price as a model that
|
|
173
|
+
// costs seventeen percent more.
|
|
174
|
+
export function formatPricePerMillion(dollars: number, locale: string): string {
|
|
175
|
+
return dollars.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 3 });
|
|
176
|
+
}
|
|
177
|
+
|
|
39
178
|
export function configuredModels(): AgentModel[] {
|
|
40
179
|
const raw = import.meta.env.VITE_AGENT_MODELS;
|
|
41
180
|
if (!raw) return fallback;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** What a reader needs in order to pick a model: whose it is, what it is called, and what it costs. */
|
|
2
|
+
export interface ModelFacts {
|
|
3
|
+
brand: string;
|
|
4
|
+
name: string;
|
|
5
|
+
/**
|
|
6
|
+
* ⚠️ Both are null for a model this installation has no figures for, and the display then shows
|
|
7
|
+
* nothing rather than a zero. A definition is written by MCP and by other installations too, so an
|
|
8
|
+
* unknown model is normal — and an invented "0" would be a false statement about money.
|
|
9
|
+
*/
|
|
10
|
+
contextTokens: number | null;
|
|
11
|
+
price: ModelPrice | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Always US dollars per one million tokens — never per 1k, never a single blended number. */
|
|
15
|
+
export interface ModelPrice {
|
|
16
|
+
inputPerMillion: number;
|
|
17
|
+
outputPerMillion: number;
|
|
18
|
+
}
|