@anchrd/intel-ui 0.18.0 → 0.20.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 -9
- package/src/app/app-tree/app-tree.tsx +68 -147
- package/src/app/header-search/header-search.tsx +2 -1
- package/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/src/app/settings-dialog/settings-dialog.tsx +10 -32
- package/src/app/user-footer/user-footer.tsx +19 -0
- package/src/archive/archive.tsx +2 -1
- package/src/data/intel-data-provider/intel-data-provider.ts +42 -191
- package/src/data/intel-data-provider/intel-data-provider.types.ts +36 -120
- package/src/document-link/document-link.tsx +1 -1
- package/src/entry-picker/entry-picker.tsx +7 -7
- package/src/flow-runs/flow-runs.tsx +1 -1
- package/src/flows/flows.tsx +3 -2
- package/src/flows/node-icon/node-icon.ts +1 -1
- package/src/flows/node-palette/node-palette.tsx +1 -1
- package/src/flows/node-palette/node-palette.types.ts +1 -1
- package/src/graph-pane/graph-pane.tsx +1 -1
- package/src/i18n/de.json +14 -217
- package/src/i18n/en.json +14 -217
- package/src/i18n/es.json +14 -217
- package/src/import-bundle/import-bundle.tsx +152 -0
- package/src/kind-icon.ts +1 -15
- package/src/main.tsx +7 -10
- package/src/node-editor/node-editor.tsx +1 -1
- package/src/node-graph/graph-notice.tsx +1 -1
- package/src/node-graph/node-graph.ts +1 -1
- package/src/node-graph/node-graph.tsx +1 -1
- package/src/node-table/node-table.tsx +3 -3
- package/src/nodes/nodes.tsx +1 -35
- package/src/resource-menu/resource-menu.tsx +45 -21
- package/src/section-hint/section-hint.tsx +2 -2
- package/src/title-row/title-row.tsx +5 -22
- package/src/tools/tools.tsx +1 -3
- package/vite.config.ts +0 -4
- package/src/agent/agent-avatar/agent-avatar.tsx +0 -34
- package/src/agent/agent-calendar/agent-calendar.tsx +0 -160
- package/src/agent/agent-chat/agent-chat.tsx +0 -118
- package/src/agent/agent-costs/agent-costs.ts +0 -95
- package/src/agent/agent-cron/agent-cron.ts +0 -68
- package/src/agent/agent-definition/agent-definition.ts +0 -96
- package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +0 -48
- package/src/agent/agent-entry-title/agent-entry-title.ts +0 -72
- package/src/agent/agent-log/agent-log.tsx +0 -308
- package/src/agent/agent-models/agent-models.ts +0 -166
- package/src/agent/agent-models/agent-models.types.ts +0 -24
- package/src/agent/agent-profile/agent-profile.tsx +0 -1006
- package/src/agent/agent-state/agent-state.ts +0 -85
- package/src/agent/agent-status-dot/agent-status-dot.ts +0 -73
- package/src/agent/agent.tsx +0 -507
- package/src/board/board-calendar/board-calendar.tsx +0 -89
- package/src/board/board-card/board-card.tsx +0 -106
- package/src/board/board-data/board-data.ts +0 -241
- package/src/board/board-data/board-data.types.ts +0 -63
- package/src/board/board-detail/board-detail.tsx +0 -629
- package/src/board/board-gantt/board-gantt.ts +0 -545
- package/src/board/board-gantt/board-gantt.tsx +0 -286
- package/src/board/board-graph/board-graph.ts +0 -174
- package/src/board/board-graph/board-graph.tsx +0 -168
- package/src/board/board-items/board-items.ts +0 -183
- package/src/board/board-kanban/board-kanban.ts +0 -137
- package/src/board/board-kanban/board-kanban.tsx +0 -257
- package/src/board/board-status/board-status.ts +0 -59
- package/src/board/board-statuses/board-statuses.ts +0 -63
- package/src/board/board-statuses/board-statuses.tsx +0 -228
- package/src/board/board-table/board-table.ts +0 -33
- package/src/board/board-table/board-table.tsx +0 -413
- package/src/board/board-views/board-views.tsx +0 -68
- package/src/board/board-views/board-views.types.ts +0 -29
- package/src/board/board.tsx +0 -251
- package/src/components/ui/item-calendar.tsx +0 -181
- package/src/components/ui/item-gantt.tsx +0 -463
- package/src/components/ui/kanban.tsx +0 -282
- package/src/data/agent-runtime/agent-runtime.ts +0 -131
- package/src/hooks/use-capabilities.ts +0 -22
- package/src/hooks/use-model-catalog.ts +0 -26
- package/src/timezone/timezone-combobox/timezone-combobox.tsx +0 -149
- package/src/timezone/timezone-context.tsx +0 -65
- package/src/timezone/timezone.ts +0 -174
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { Cron } from "croner";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* When a five-field cron expression fires next, read in the schedule's own timezone.
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*/
|
|
16
|
-
|
|
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;
|
|
20
|
-
|
|
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 {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
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
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
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;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* The next `count` fire times of one expression.
|
|
57
|
-
*
|
|
58
|
-
* An unparseable or never-matching expression yields an empty list — the caller says "nothing"
|
|
59
|
-
* rather than guessing.
|
|
60
|
-
*/
|
|
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);
|
|
68
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import type { AgentDefinition, AgentDefinitionInput, NodeAgent } from "@anchrd/intel-contract";
|
|
2
|
-
import {
|
|
3
|
-
type UseMutationResult,
|
|
4
|
-
type UseQueryResult,
|
|
5
|
-
useMutation,
|
|
6
|
-
useQuery,
|
|
7
|
-
useQueryClient,
|
|
8
|
-
} from "@tanstack/react-query";
|
|
9
|
-
import { nextCronFires } from "@/agent/agent-cron/agent-cron.ts";
|
|
10
|
-
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
11
|
-
|
|
12
|
-
export const agentKey = (nodeId: string) => ["agent", nodeId] as const;
|
|
13
|
-
|
|
14
|
-
export interface AgentDefinitionHandle {
|
|
15
|
-
query: UseQueryResult<NodeAgent>;
|
|
16
|
-
definition: AgentDefinition | null;
|
|
17
|
-
save: UseMutationResult<NodeAgent, Error, AgentDefinitionInput>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* The definition as it was read, turned back into something this screen may ASK for (D30).
|
|
22
|
-
*
|
|
23
|
-
* ⚠️ It exists to drop exactly one field: `tools.delegatedBy`. Whose portal connection an agent
|
|
24
|
-
* acts on is Intel's to write, out of the session, and the input shape has no place for it — so a
|
|
25
|
-
* section that edits schedules must not carry it along just because it was in what it read. Every
|
|
26
|
-
* section spreads this rather than the definition itself, which is why none of them has to invent a
|
|
27
|
-
* user id for an agent that has no tools yet.
|
|
28
|
-
*/
|
|
29
|
-
export function asDraft(definition: AgentDefinition): AgentDefinitionInput {
|
|
30
|
-
return {
|
|
31
|
-
references: definition.references,
|
|
32
|
-
schedules: definition.schedules,
|
|
33
|
-
model: definition.model,
|
|
34
|
-
tools: definition.tools === null ? null : { servers: definition.tools.servers },
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* One agent's definition, and the one way to change it.
|
|
40
|
-
*
|
|
41
|
-
* ⚠️ Every edit writes a whole new definition version — there is no field-level update, by design
|
|
42
|
-
* (ADR-0005): a definition is one immutable document, and what a run read is meant to stay
|
|
43
|
-
* readable afterwards. So each section on the profile hands over the definition it wants next,
|
|
44
|
-
* whole, and the version it was read from travels as `baseVersionId`. Somebody else's parallel
|
|
45
|
-
* change is then a refusal instead of a silent overwrite.
|
|
46
|
-
*/
|
|
47
|
-
export function useAgentDefinition(nodeId: string): AgentDefinitionHandle {
|
|
48
|
-
const { data } = useIntelRouterContext();
|
|
49
|
-
const queryClient = useQueryClient();
|
|
50
|
-
const query = useQuery({ queryKey: agentKey(nodeId), queryFn: () => data.getAgent(nodeId) });
|
|
51
|
-
const save = useMutation({
|
|
52
|
-
mutationFn: async (definition: AgentDefinitionInput) =>
|
|
53
|
-
await data.saveAgentDefinition({
|
|
54
|
-
nodeId,
|
|
55
|
-
baseVersionId: query.data?.version?.id ?? null,
|
|
56
|
-
definition,
|
|
57
|
-
idempotencyKey: crypto.randomUUID(),
|
|
58
|
-
}),
|
|
59
|
-
// The answer carries the new version, so the cache is set from it rather than refetched: the
|
|
60
|
-
// next save needs that id as its `baseVersionId`, and a round trip would leave a window in
|
|
61
|
-
// which it is still the old one.
|
|
62
|
-
onSuccess: (saved) => queryClient.setQueryData(agentKey(nodeId), saved),
|
|
63
|
-
// ⚠️ A failed save may still have MOVED the version (#214): `agent_schedules_not_armed` means
|
|
64
|
-
// the definition was written and only the runtime's alarm was not set, and the repair it names
|
|
65
|
-
// is saving again. With the old `baseVersionId` still cached that second save would answer 409
|
|
66
|
-
// instead — so the version this screen writes against is refetched whenever a save fails.
|
|
67
|
-
onError: () => queryClient.invalidateQueries({ queryKey: agentKey(nodeId) }),
|
|
68
|
-
});
|
|
69
|
-
return { query, definition: query.data?.definition ?? null, save };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* When this agent next does something on its own, across all of its schedules — and in which zone
|
|
74
|
-
* that time is meant.
|
|
75
|
-
*
|
|
76
|
-
* ⚠️ Derived from the cron expressions, never asked. The runtime arms one alarm at a time and does
|
|
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.
|
|
83
|
-
*/
|
|
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),
|
|
94
|
-
null,
|
|
95
|
-
);
|
|
96
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
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,72 +0,0 @@
|
|
|
1
|
-
import type { NodeKind } from "@anchrd/intel-contract";
|
|
2
|
-
import { useQuery } from "@tanstack/react-query";
|
|
3
|
-
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
4
|
-
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
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
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The name behind an id, read from the lists the tree already loads.
|
|
24
|
-
*
|
|
25
|
-
* ⚠️ An entry the reader may not see is absent from both lists, and this then answers "not
|
|
26
|
-
* available to you" rather than a raw id or an invented title. Deliberately the same answer a
|
|
27
|
-
* reference to something deleted gets: the two must not be distinguishable, or the row would
|
|
28
|
-
* confirm that a document exists to somebody who may not know it (#41).
|
|
29
|
-
*
|
|
30
|
-
* Its own module because two screens need it — the profile's references and schedules, and the
|
|
31
|
-
* "run now" menu in the page head, which has to name the targets it offers.
|
|
32
|
-
*/
|
|
33
|
-
export function useEntryTitle(entryId: string, flow = false): EntryTitle {
|
|
34
|
-
const { data } = useIntelRouterContext();
|
|
35
|
-
const i18n = useI18n();
|
|
36
|
-
const graph = useQuery({
|
|
37
|
-
queryKey: ["node-graph"],
|
|
38
|
-
queryFn: () => data.getNodeGraph(),
|
|
39
|
-
enabled: !flow,
|
|
40
|
-
});
|
|
41
|
-
const flows = useQuery({ queryKey: ["flows"], queryFn: () => data.listFlows(), enabled: flow });
|
|
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
|
-
};
|
|
72
|
-
}
|
|
@@ -1,308 +0,0 @@
|
|
|
1
|
-
import type { AgentCosts } from "@anchrd/intel-contract";
|
|
2
|
-
import { useQuery } from "@tanstack/react-query";
|
|
3
|
-
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
4
|
-
import { useState } from "react";
|
|
5
|
-
import {
|
|
6
|
-
formatCost,
|
|
7
|
-
formatTokens,
|
|
8
|
-
type RunCost,
|
|
9
|
-
runCost,
|
|
10
|
-
runUsage,
|
|
11
|
-
useAgentCosts,
|
|
12
|
-
} from "@/agent/agent-costs/agent-costs.ts";
|
|
13
|
-
import type { AgentRun } from "@/data/agent-runtime/agent-runtime.ts";
|
|
14
|
-
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
15
|
-
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
16
|
-
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* What this agent has done, newest first.
|
|
20
|
-
*
|
|
21
|
-
* ⚠️ Read from the RUNTIME, not from intel: runs live in the agent's own Durable Object and are
|
|
22
|
-
* trimmed there. So this list is short by design and has no paging — the runtime answers at most a
|
|
23
|
-
* hundred runs and keeps no more.
|
|
24
|
-
*
|
|
25
|
-
* ⚠️ A run says what it did, when it failed what stopped it, and since #268 what it answered — but
|
|
26
|
-
* only for the runs nobody was watching (`schedule`, `manual`, `task`). A chat answer belongs to the
|
|
27
|
-
* person it was streamed to and a mail answer to the correspondent, and the runtime deliberately
|
|
28
|
-
* 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.
|
|
34
|
-
*/
|
|
35
|
-
export function AgentLog({ agentId }: { agentId: string }) {
|
|
36
|
-
const { data } = useIntelRouterContext();
|
|
37
|
-
const i18n = useI18n();
|
|
38
|
-
const [openRunId, setOpenRunId] = useState<string | null>(null);
|
|
39
|
-
const runs = useQuery({
|
|
40
|
-
queryKey: ["agent-runs", agentId],
|
|
41
|
-
queryFn: () => data.listAgentRuns(agentId),
|
|
42
|
-
});
|
|
43
|
-
const costs = useAgentCosts(agentId);
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<section aria-label={i18n.t("agent.log")} className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
|
47
|
-
{runs.isPending ? (
|
|
48
|
-
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
49
|
-
) : null}
|
|
50
|
-
{runs.isError ? (
|
|
51
|
-
<div role="alert" className="space-y-3 text-sm">
|
|
52
|
-
<p className="text-destructive">{i18n.t("agent.logFailed")}</p>
|
|
53
|
-
<button
|
|
54
|
-
type="button"
|
|
55
|
-
onClick={() => void runs.refetch()}
|
|
56
|
-
className="rounded-md border px-3 py-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
57
|
-
>
|
|
58
|
-
{i18n.t("common.retry")}
|
|
59
|
-
</button>
|
|
60
|
-
</div>
|
|
61
|
-
) : null}
|
|
62
|
-
{runs.data && runs.data.items.length === 0 ? (
|
|
63
|
-
<p className="text-sm text-muted-foreground">{i18n.t("agent.logEmpty")}</p>
|
|
64
|
-
) : null}
|
|
65
|
-
{runs.data && runs.data.items.length > 0 ? (
|
|
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
|
-
</>
|
|
81
|
-
) : null}
|
|
82
|
-
</section>
|
|
83
|
-
);
|
|
84
|
-
}
|
|
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
|
-
|
|
175
|
-
function RunRow({
|
|
176
|
-
agentId,
|
|
177
|
-
run,
|
|
178
|
-
cost,
|
|
179
|
-
open,
|
|
180
|
-
toggle,
|
|
181
|
-
}: {
|
|
182
|
-
agentId: string;
|
|
183
|
-
run: AgentRun;
|
|
184
|
-
cost: RunCost;
|
|
185
|
-
open: boolean;
|
|
186
|
-
toggle(): void;
|
|
187
|
-
}) {
|
|
188
|
-
const i18n = useI18n();
|
|
189
|
-
const failed = run.status === "failed";
|
|
190
|
-
const Chevron = open ? ChevronDown : ChevronRight;
|
|
191
|
-
return (
|
|
192
|
-
<li className="rounded-lg border bg-card">
|
|
193
|
-
<button
|
|
194
|
-
type="button"
|
|
195
|
-
onClick={toggle}
|
|
196
|
-
aria-expanded={open}
|
|
197
|
-
className="flex w-full items-start gap-3 rounded-lg px-4 py-3 text-left outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
198
|
-
>
|
|
199
|
-
<Chevron aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
200
|
-
<span className="min-w-0 flex-1">
|
|
201
|
-
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
202
|
-
<span
|
|
203
|
-
className={`rounded-full border px-2 py-0.5 text-xs ${failed ? "border-destructive/40 text-destructive" : "text-muted-foreground"}`}
|
|
204
|
-
>
|
|
205
|
-
{i18n.t(`runs.status.${run.status}`)}
|
|
206
|
-
</span>
|
|
207
|
-
<time dateTime={run.startedAt}>
|
|
208
|
-
{new Date(run.startedAt).toLocaleString(i18n.locale)}
|
|
209
|
-
</time>
|
|
210
|
-
<span className="text-muted-foreground">{i18n.t(`agent.trigger.${run.trigger}`)}</span>
|
|
211
|
-
<span className="text-muted-foreground">
|
|
212
|
-
{i18n.t("agent.steps", { count: run.steps })}
|
|
213
|
-
</span>
|
|
214
|
-
<RunCostLabel cost={cost} i18n={i18n} />
|
|
215
|
-
</span>
|
|
216
|
-
<span className="mt-1 block text-xs">
|
|
217
|
-
<RunUsage run={run} i18n={i18n} />
|
|
218
|
-
</span>
|
|
219
|
-
{/* The failure in the words the failure itself used. Summarising it into "failed" is what
|
|
220
|
-
the status pill already says, and it is never the sentence that helps. */}
|
|
221
|
-
{run.error ? (
|
|
222
|
-
<span className="mt-1.5 block text-sm text-destructive">{run.error}</span>
|
|
223
|
-
) : null}
|
|
224
|
-
</span>
|
|
225
|
-
</button>
|
|
226
|
-
{open ? <RunDetail agentId={agentId} run={run} /> : null}
|
|
227
|
-
</li>
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* What an unattended run produced, which is the only place a person can read it (#272).
|
|
233
|
-
*
|
|
234
|
-
* ⚠️ Rendered for `schedule`, `manual` and `task`, and for nothing else. A `chat` or `mail` run
|
|
235
|
-
* carries no answer by construction (#268), so an empty state under one of those would invent a
|
|
236
|
-
* loss: the answer was streamed into a window or posted to a correspondent and was never meant to
|
|
237
|
-
* come back here.
|
|
238
|
-
*
|
|
239
|
-
* ⚠️ Read from the run the row already holds, not from the detail fetch. `GET /runs` carries the
|
|
240
|
-
* whole record, and hanging the answer off the second request would hide it whenever the
|
|
241
|
-
* operational log — the smaller half of what this row is for — happens to be unreadable.
|
|
242
|
-
*/
|
|
243
|
-
function RunAnswer({ run, i18n }: { run: AgentRun; i18n: I18n }) {
|
|
244
|
-
if (run.trigger === "chat" || run.trigger === "mail") return null;
|
|
245
|
-
return (
|
|
246
|
-
<section aria-label={i18n.t("agent.runAnswer")} className="mb-3">
|
|
247
|
-
<h3 className="text-sm font-semibold">{i18n.t("agent.runAnswer")}</h3>
|
|
248
|
-
{run.answer ? (
|
|
249
|
-
// Whitespace preserved: a model answers in paragraphs and lists, and collapsing them turns
|
|
250
|
-
// a readable report into one block of prose.
|
|
251
|
-
<p className="mt-1 whitespace-pre-wrap text-sm">{run.answer}</p>
|
|
252
|
-
) : (
|
|
253
|
-
<p className="mt-1 text-sm text-muted-foreground">{i18n.t("agent.runAnswerEmpty")}</p>
|
|
254
|
-
)}
|
|
255
|
-
</section>
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// The operational log of one run: what it tried, in order. Fetched only when a row is opened —
|
|
260
|
-
// every run carries its own entries and loading all of them would be the whole log for one screen.
|
|
261
|
-
function RunDetail({ agentId, run }: { agentId: string; run: AgentRun }) {
|
|
262
|
-
const { data } = useIntelRouterContext();
|
|
263
|
-
const i18n = useI18n();
|
|
264
|
-
const runId = run.id;
|
|
265
|
-
const detail = useQuery({
|
|
266
|
-
queryKey: ["agent-run", agentId, runId],
|
|
267
|
-
queryFn: () => data.getAgentRun(agentId, runId),
|
|
268
|
-
});
|
|
269
|
-
return (
|
|
270
|
-
<div className="border-t px-4 py-3">
|
|
271
|
-
<RunAnswer run={run} i18n={i18n} />
|
|
272
|
-
{detail.isPending ? (
|
|
273
|
-
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
274
|
-
) : null}
|
|
275
|
-
{detail.isError ? (
|
|
276
|
-
<p role="alert" className="text-sm text-destructive">
|
|
277
|
-
{i18n.t("agent.runFailed")}
|
|
278
|
-
</p>
|
|
279
|
-
) : null}
|
|
280
|
-
{detail.data && detail.data.log.length === 0 ? (
|
|
281
|
-
<p className="text-sm text-muted-foreground">{i18n.t("agent.runLogEmpty")}</p>
|
|
282
|
-
) : null}
|
|
283
|
-
{detail.data && detail.data.log.length > 0 ? (
|
|
284
|
-
<ol aria-label={i18n.t("agent.runLog")} className="space-y-2">
|
|
285
|
-
{detail.data.log.map((entry) => (
|
|
286
|
-
<li key={entry.id} className="text-sm">
|
|
287
|
-
<span
|
|
288
|
-
className={entry.level === "error" ? "font-medium text-destructive" : "font-medium"}
|
|
289
|
-
>
|
|
290
|
-
{entry.event}
|
|
291
|
-
</span>
|
|
292
|
-
<time dateTime={entry.at} className="ml-2 text-xs text-muted-foreground">
|
|
293
|
-
{new Date(entry.at).toLocaleTimeString(i18n.locale)}
|
|
294
|
-
</time>
|
|
295
|
-
{entry.detail ? (
|
|
296
|
-
<span
|
|
297
|
-
className={`mt-0.5 block text-xs ${entry.level === "error" ? "text-destructive" : "text-muted-foreground"}`}
|
|
298
|
-
>
|
|
299
|
-
{entry.detail}
|
|
300
|
-
</span>
|
|
301
|
-
) : null}
|
|
302
|
-
</li>
|
|
303
|
-
))}
|
|
304
|
-
</ol>
|
|
305
|
-
) : null}
|
|
306
|
-
</div>
|
|
307
|
-
);
|
|
308
|
-
}
|