@anchrd/intel-ui 0.8.8 → 0.9.1
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/README.md +2 -2
- package/package.json +5 -2
- package/src/agent/agent-avatar/agent-avatar.tsx +34 -0
- package/src/agent/agent-calendar/agent-calendar.tsx +79 -0
- package/src/agent/agent-chat/agent-chat.tsx +116 -0
- package/src/agent/agent-cron/agent-cron.ts +132 -0
- package/src/agent/agent-definition/agent-definition.ts +64 -0
- package/src/agent/agent-entry-title/agent-entry-title.ts +29 -0
- package/src/agent/agent-log/agent-log.tsx +159 -0
- package/src/agent/agent-models/agent-models.ts +63 -0
- package/src/agent/agent-profile/agent-profile.tsx +547 -0
- package/src/agent/agent-state/agent-state.ts +48 -0
- package/src/agent/agent.tsx +361 -0
- package/src/app/app-sidebar/app-sidebar.tsx +2 -2
- package/src/app/app-tree/app-tree.tsx +151 -23
- package/src/app/app.tsx +2 -2
- package/src/app/header-search/header-search.tsx +16 -16
- package/src/app/tree-move/tree-move.tsx +10 -10
- package/src/archive/archive.tsx +8 -8
- package/src/components/ui/avatar.tsx +39 -0
- package/src/components/ui/select.tsx +163 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/data/agent-runtime/agent-runtime.ts +93 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +201 -120
- package/src/data/intel-data-provider/intel-data-provider.types.ts +112 -54
- package/src/entry-picker/entry-picker.tsx +53 -17
- package/src/flow-runs/flow-runs.tsx +1 -1
- package/src/flows/flows.tsx +20 -20
- package/src/folder-contents/folder-contents.tsx +7 -7
- package/src/graph-pane/graph-pane.tsx +1 -1
- package/src/hooks/use-capabilities.ts +22 -0
- package/src/i18n/en.json +147 -64
- package/src/kind-icon.ts +5 -2
- package/src/{knowledge-editor/knowledge-editor.tsx → node-editor/node-editor.tsx} +20 -20
- package/src/{knowledge-graph → node-graph}/graph-notice.tsx +1 -1
- package/src/{knowledge-graph/knowledge-graph.tsx → node-graph/node-graph.tsx} +4 -4
- package/src/{knowledge-table/knowledge-table.tsx → node-table/node-table.tsx} +14 -14
- package/src/{knowledge/knowledge.tsx → nodes/nodes.tsx} +47 -29
- package/src/resource-menu/resource-menu.tsx +105 -62
- package/src/router/router.tsx +5 -5
- package/src/title-row/title-row.tsx +20 -6
- package/vite.config.ts +4 -0
- /package/src/{knowledge-graph/knowledge-graph.ts → node-graph/node-graph.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @anchrd/intel-ui
|
|
2
2
|
|
|
3
|
-
The Intel browser application:
|
|
3
|
+
The Intel browser application: Intelligence, Flows and Tools for the
|
|
4
4
|
[`@anchrd/intel-api`](https://www.npmjs.com/package/@anchrd/intel-api) server.
|
|
5
5
|
|
|
6
6
|
This package ships **source, not a bundle**. You compile it yourself so your own theme, logo and
|
|
@@ -45,7 +45,7 @@ silently producing a half-branded application.
|
|
|
45
45
|
|
|
46
46
|
## What is inside
|
|
47
47
|
|
|
48
|
-
Vite, React, TanStack Router and Query, with shadcn components.
|
|
48
|
+
Vite, React, TanStack Router and Query, with shadcn components. The node editor uses BlockNote
|
|
49
49
|
and Sigma for the graph, Flows uses React Flow, and Tools presents the discovered MCP catalog.
|
|
50
50
|
Route-level code splitting keeps both editors out of the initial bundle.
|
|
51
51
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@anchrd/intel-contract": "^0.
|
|
32
|
+
"@anchrd/intel-contract": "^0.5.0",
|
|
33
|
+
"@assistant-ui/react": "^0.15.4",
|
|
34
|
+
"@assistant-ui/react-ai-sdk": "^1.4.4",
|
|
33
35
|
"@blocknote/core": "^0.52.1",
|
|
34
36
|
"@blocknote/react": "^0.52.1",
|
|
35
37
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -43,6 +45,7 @@
|
|
|
43
45
|
"clsx": "^2.1.1",
|
|
44
46
|
"cmdk": "^1.1.1",
|
|
45
47
|
"colorjs.io": "^0.7.1",
|
|
48
|
+
"fflate": "^0.8.3",
|
|
46
49
|
"graphology": "^0.26.0",
|
|
47
50
|
"lucide-react": "^1.25.0",
|
|
48
51
|
"radix-ui": "^1.6.7",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The first letters of the words in a name, at most two.
|
|
5
|
+
*
|
|
6
|
+
* ⚠️ `Intl.Segmenter` rather than `slice(0, 1)`: an agent may be called "Émile" or "🛰 Radar", and
|
|
7
|
+
* a code unit is not a character. Slicing a surrogate pair in half puts a replacement glyph where
|
|
8
|
+
* the initial should be.
|
|
9
|
+
*/
|
|
10
|
+
export function initialsOf(title: string): string {
|
|
11
|
+
const words = title.trim().split(/\s+/).filter(Boolean).slice(0, 2);
|
|
12
|
+
const segmenter = new Intl.Segmenter();
|
|
13
|
+
return words
|
|
14
|
+
.map((word) => [...segmenter.segment(word)][0]?.segment ?? "")
|
|
15
|
+
.join("")
|
|
16
|
+
.toLocaleUpperCase();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Who the agent is, as a picture.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ Decorative on purpose: the name it abbreviates stands right beside it in the title line, so a
|
|
23
|
+
* screen reader that announced both would read the same name twice — once spelled out letter by
|
|
24
|
+
* letter. The picture is the redundant half here, not the text.
|
|
25
|
+
*/
|
|
26
|
+
export function AgentAvatar({ title }: { title: string }) {
|
|
27
|
+
return (
|
|
28
|
+
<Avatar aria-hidden="true" className="size-9">
|
|
29
|
+
<AvatarFallback className="bg-primary/10 text-sm font-semibold text-primary">
|
|
30
|
+
{initialsOf(title)}
|
|
31
|
+
</AvatarFallback>
|
|
32
|
+
</Avatar>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { AgentDefinition } from "@anchrd/intel-contract";
|
|
2
|
+
import { CalendarClock } from "lucide-react";
|
|
3
|
+
import { nextCronFires } from "@/agent/agent-cron/agent-cron.ts";
|
|
4
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
5
|
+
|
|
6
|
+
// How far ahead one schedule is shown. Enough to recognise a rhythm — daily, weekly, the first of
|
|
7
|
+
// the month — and not so much that the page becomes a year planner nobody asked for.
|
|
8
|
+
const PerSchedule = 5;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What this agent is going to do, and when.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ Derived, never asked. The runtime arms one alarm at a time and publishes neither it nor a
|
|
14
|
+
* calendar, so these times are this page's own reading of the same cron expressions — by the same
|
|
15
|
+
* rules, pinned by `agent-cron.unit.ts` against the runtime's test.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ UTC, and it says so. The definition carries no timezone, so the runtime fires in UTC; showing
|
|
18
|
+
* these in the reader's local zone would be a different hour from the one the agent keeps, and the
|
|
19
|
+
* two would only be noticed when a schedule "ran at the wrong time".
|
|
20
|
+
*/
|
|
21
|
+
export function AgentCalendar({
|
|
22
|
+
definition,
|
|
23
|
+
now,
|
|
24
|
+
}: {
|
|
25
|
+
definition: AgentDefinition | null;
|
|
26
|
+
// Handed in rather than read from the clock, so the view is a function of its input and a test
|
|
27
|
+
// does not have to travel in time to assert on it.
|
|
28
|
+
now: Date;
|
|
29
|
+
}) {
|
|
30
|
+
const { i18n } = useIntelRouterContext();
|
|
31
|
+
const schedules = definition?.schedules ?? [];
|
|
32
|
+
const upcoming = schedules
|
|
33
|
+
.flatMap((schedule) =>
|
|
34
|
+
nextCronFires(schedule.cron, now, PerSchedule).map((at) => ({ at, schedule })),
|
|
35
|
+
)
|
|
36
|
+
.sort((left, right) => left.at.getTime() - right.at.getTime());
|
|
37
|
+
|
|
38
|
+
const format = new Intl.DateTimeFormat(i18n.locale, {
|
|
39
|
+
dateStyle: "full",
|
|
40
|
+
timeStyle: "short",
|
|
41
|
+
timeZone: "UTC",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<section
|
|
46
|
+
aria-label={i18n.t("agent.calendar")}
|
|
47
|
+
className="min-h-0 flex-1 overflow-y-auto px-6 py-5"
|
|
48
|
+
>
|
|
49
|
+
<p className="text-xs text-muted-foreground">{i18n.t("agent.calendarUtc")}</p>
|
|
50
|
+
{schedules.length === 0 ? (
|
|
51
|
+
<p className="mt-4 text-sm text-muted-foreground">{i18n.t("agent.scheduleEmpty")}</p>
|
|
52
|
+
) : upcoming.length === 0 ? (
|
|
53
|
+
// Schedules exist and none of them fires: every expression is unusable. A different answer
|
|
54
|
+
// from "no schedules", because it is a different problem and needs a different fix.
|
|
55
|
+
<p role="alert" className="mt-4 text-sm text-destructive">
|
|
56
|
+
{i18n.t("agent.calendarUnreadable")}
|
|
57
|
+
</p>
|
|
58
|
+
) : (
|
|
59
|
+
<ol className="mt-4 space-y-2">
|
|
60
|
+
{upcoming.map((entry) => (
|
|
61
|
+
<li
|
|
62
|
+
key={`${entry.schedule.cron}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
|
|
63
|
+
className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm"
|
|
64
|
+
>
|
|
65
|
+
<CalendarClock aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
|
66
|
+
<time dateTime={entry.at.toISOString()} className="font-medium">
|
|
67
|
+
{format.format(entry.at)}
|
|
68
|
+
</time>
|
|
69
|
+
<code className="rounded bg-muted px-2 py-0.5 text-xs">{entry.schedule.cron}</code>
|
|
70
|
+
<span className="text-xs text-muted-foreground">
|
|
71
|
+
{i18n.t(`node.kind.${entry.schedule.target.kind}`)}
|
|
72
|
+
</span>
|
|
73
|
+
</li>
|
|
74
|
+
))}
|
|
75
|
+
</ol>
|
|
76
|
+
)}
|
|
77
|
+
</section>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AssistantRuntimeProvider,
|
|
3
|
+
ComposerPrimitive,
|
|
4
|
+
ErrorPrimitive,
|
|
5
|
+
MessagePrimitive,
|
|
6
|
+
ThreadPrimitive,
|
|
7
|
+
} from "@assistant-ui/react";
|
|
8
|
+
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
|
|
9
|
+
import { ArrowDown, Send } from "lucide-react";
|
|
10
|
+
import { useMemo } from "react";
|
|
11
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The built-in chat, against the agent runtime's own SSE endpoint.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ assistant-ui rather than a hand-rolled reader, and the reason is the wire: the runtime already
|
|
17
|
+
* frames its answer as a Vercel AI UI message stream (`x-vercel-ai-ui-message-stream: v1`) with
|
|
18
|
+
* `text-delta`, `tool-input-available` and `tool-output-available` parts. That format has a client,
|
|
19
|
+
* and writing a second one would mean re-implementing tool-call rendering and stream recovery to
|
|
20
|
+
* arrive at the same place.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ The endpoint is on INTEL, which forwards the turn to the runtime with the caller's own Gate
|
|
23
|
+
* token (#178). That is what `credentials: "include"` is carrying: the intel session cookie, on the
|
|
24
|
+
* origin this page came from. The runtime still demands `agents/run` — it just hears it from intel
|
|
25
|
+
* rather than from a bearer this page would have had to hold.
|
|
26
|
+
*/
|
|
27
|
+
export function AgentChat({ agentId }: { agentId: string }) {
|
|
28
|
+
const { data, i18n } = useIntelRouterContext();
|
|
29
|
+
// ⚠️ Built once per agent. A transport rebuilt on every render restarts the runtime underneath
|
|
30
|
+
// assistant-ui and takes the open thread with it.
|
|
31
|
+
const transport = useMemo(
|
|
32
|
+
() =>
|
|
33
|
+
new AssistantChatTransport({
|
|
34
|
+
api: data.agentChatApi(agentId),
|
|
35
|
+
credentials: "include",
|
|
36
|
+
}),
|
|
37
|
+
[data, agentId],
|
|
38
|
+
);
|
|
39
|
+
const runtime = useChatRuntime({ transport });
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
43
|
+
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col bg-card">
|
|
44
|
+
<ThreadPrimitive.Viewport
|
|
45
|
+
autoScroll
|
|
46
|
+
className="relative flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 py-5"
|
|
47
|
+
>
|
|
48
|
+
<ThreadPrimitive.Empty>
|
|
49
|
+
<p className="m-auto max-w-md text-center text-sm text-muted-foreground">
|
|
50
|
+
{i18n.t("agent.chatEmpty")}
|
|
51
|
+
</p>
|
|
52
|
+
</ThreadPrimitive.Empty>
|
|
53
|
+
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
|
54
|
+
<ThreadPrimitive.ScrollToBottom asChild>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
aria-label={i18n.t("agent.chatScrollDown")}
|
|
58
|
+
className="sticky bottom-0 z-10 mx-auto inline-flex size-8 items-center justify-center rounded-full border bg-background shadow-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:invisible"
|
|
59
|
+
>
|
|
60
|
+
<ArrowDown aria-hidden="true" className="size-4" />
|
|
61
|
+
</button>
|
|
62
|
+
</ThreadPrimitive.ScrollToBottom>
|
|
63
|
+
</ThreadPrimitive.Viewport>
|
|
64
|
+
<ComposerPrimitive.Root className="flex items-end gap-2 border-t px-6 py-4">
|
|
65
|
+
<ComposerPrimitive.Input
|
|
66
|
+
rows={1}
|
|
67
|
+
autoFocus
|
|
68
|
+
aria-label={i18n.t("agent.chatInput")}
|
|
69
|
+
placeholder={i18n.t("agent.chatPlaceholder")}
|
|
70
|
+
className="max-h-40 min-h-9 flex-1 resize-none rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
71
|
+
/>
|
|
72
|
+
<ComposerPrimitive.Send asChild>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
aria-label={i18n.t("agent.chatSend")}
|
|
76
|
+
className="inline-flex size-9 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
77
|
+
>
|
|
78
|
+
<Send aria-hidden="true" className="size-4" />
|
|
79
|
+
</button>
|
|
80
|
+
</ComposerPrimitive.Send>
|
|
81
|
+
</ComposerPrimitive.Root>
|
|
82
|
+
</ThreadPrimitive.Root>
|
|
83
|
+
</AssistantRuntimeProvider>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function UserMessage() {
|
|
88
|
+
return (
|
|
89
|
+
<MessagePrimitive.Root className="flex justify-end">
|
|
90
|
+
<div className="max-w-[80%] rounded-2xl bg-primary px-4 py-2 text-sm text-primary-foreground">
|
|
91
|
+
<MessagePrimitive.Parts />
|
|
92
|
+
</div>
|
|
93
|
+
</MessagePrimitive.Root>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function AssistantMessage() {
|
|
98
|
+
return (
|
|
99
|
+
<MessagePrimitive.Root className="flex justify-start">
|
|
100
|
+
<div className="max-w-[80%] space-y-2 text-sm">
|
|
101
|
+
<MessagePrimitive.Parts />
|
|
102
|
+
{/* The runtime reports a failed turn as an `error` part on a 200 stream, so a refusal shows
|
|
103
|
+
up here rather than as a request that failed. It is worded by the runtime, which words
|
|
104
|
+
it to be read (see `packages/agent`'s guard on model-facing errors). */}
|
|
105
|
+
<MessagePrimitive.Error>
|
|
106
|
+
<ErrorPrimitive.Root
|
|
107
|
+
role="alert"
|
|
108
|
+
className="rounded-md border border-destructive/40 px-3 py-2 text-destructive"
|
|
109
|
+
>
|
|
110
|
+
<ErrorPrimitive.Message />
|
|
111
|
+
</ErrorPrimitive.Root>
|
|
112
|
+
</MessagePrimitive.Error>
|
|
113
|
+
</div>
|
|
114
|
+
</MessagePrimitive.Root>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a five-field cron expression fires next, in UTC.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ This is a second copy of `packages/agent/src/schedule/cron/cron.ts`, on purpose and with a
|
|
5
|
+
* cost. The runtime imports nothing from the contract and this package cannot import a Worker
|
|
6
|
+
* package, so there is no third place the matcher could live without inventing one for two callers.
|
|
7
|
+
* What must never happen is the two disagreeing: a calendar that promises 08:00 while the alarm
|
|
8
|
+
* fires at 09:00 is worse than a calendar that says nothing. `agent-cron.unit.ts` therefore pins
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ UTC, with no timezone option, because the definition carries none: `schedules[{cron, target}]`
|
|
13
|
+
* and nothing else. Reading these in local time here would show a person a different hour from the
|
|
14
|
+
* one the agent runs at.
|
|
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
|
+
|
|
53
|
+
export interface CronExpression {
|
|
54
|
+
minute: Field;
|
|
55
|
+
hour: Field;
|
|
56
|
+
dayOfMonth: Field;
|
|
57
|
+
month: Field;
|
|
58
|
+
dayOfWeek: Field;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseCron(expression: string): CronExpression | null {
|
|
62
|
+
const parts = expression.trim().split(/\s+/);
|
|
63
|
+
if (parts.length !== 5) return null;
|
|
64
|
+
// Sunday is 0 in the standard and 7 in several dialects; both are accepted and normalised below.
|
|
65
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [
|
|
66
|
+
string,
|
|
67
|
+
string,
|
|
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) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (fields.dayOfWeek.values.has(7)) fields.dayOfWeek.values.add(0);
|
|
83
|
+
return fields as CronExpression;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function matches(cron: CronExpression, date: Date): boolean {
|
|
87
|
+
if (!cron.minute.values.has(date.getUTCMinutes())) return false;
|
|
88
|
+
if (!cron.hour.values.has(date.getUTCHours())) return false;
|
|
89
|
+
if (!cron.month.values.has(date.getUTCMonth() + 1)) return false;
|
|
90
|
+
const dayOfMonth = cron.dayOfMonth.values.has(date.getUTCDate());
|
|
91
|
+
const dayOfWeek = cron.dayOfWeek.values.has(date.getUTCDay());
|
|
92
|
+
// ⚠️ The standard's oddity, kept on purpose: when both day fields are restricted they are ORed,
|
|
93
|
+
// not ANDed. "0 0 1 * 1" is the first of the month AND every Monday, and a matcher that ANDs them
|
|
94
|
+
// silently lists about four times fewer runs than the agent will actually make.
|
|
95
|
+
if (cron.dayOfMonth.restricted && cron.dayOfWeek.restricted) return dayOfMonth || dayOfWeek;
|
|
96
|
+
return dayOfMonth && dayOfWeek;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// One year of minutes is the search bound. An expression that matches nothing within a year — "31st
|
|
100
|
+
// of February" — has no next fire time, and saying so beats stepping forever.
|
|
101
|
+
const MaxMinutesAhead = 366 * 24 * 60;
|
|
102
|
+
|
|
103
|
+
export function nextCronFire(expression: string, after: Date): Date | null {
|
|
104
|
+
const cron = parseCron(expression);
|
|
105
|
+
if (!cron) return null;
|
|
106
|
+
// Strictly after: a schedule that just fired must not match the same minute again.
|
|
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;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The next `count` fire times of one expression.
|
|
117
|
+
*
|
|
118
|
+
* ⚠️ Stepping from each hit rather than scanning once, so an expression that fires every minute
|
|
119
|
+
* costs `count` iterations and not a year of them. An unparseable or never-matching expression
|
|
120
|
+
* yields an empty list — the calendar says "nothing" rather than guessing.
|
|
121
|
+
*/
|
|
122
|
+
export function nextCronFires(expression: string, after: Date, count: number): Date[] {
|
|
123
|
+
const fires: Date[] = [];
|
|
124
|
+
let cursor = after;
|
|
125
|
+
for (let index = 0; index < count; index += 1) {
|
|
126
|
+
const next = nextCronFire(expression, cursor);
|
|
127
|
+
if (!next) break;
|
|
128
|
+
fires.push(next);
|
|
129
|
+
cursor = next;
|
|
130
|
+
}
|
|
131
|
+
return fires;
|
|
132
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { AgentDefinition, 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, AgentDefinition>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One agent's definition, and the one way to change it.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ Every edit writes a whole new definition version — there is no field-level update, by design
|
|
24
|
+
* (ADR-0005): a definition is one immutable document, and what a run read is meant to stay
|
|
25
|
+
* readable afterwards. So each section on the profile hands over the definition it wants next,
|
|
26
|
+
* whole, and the version it was read from travels as `baseVersionId`. Somebody else's parallel
|
|
27
|
+
* change is then a refusal instead of a silent overwrite.
|
|
28
|
+
*/
|
|
29
|
+
export function useAgentDefinition(nodeId: string): AgentDefinitionHandle {
|
|
30
|
+
const { data } = useIntelRouterContext();
|
|
31
|
+
const queryClient = useQueryClient();
|
|
32
|
+
const query = useQuery({ queryKey: agentKey(nodeId), queryFn: () => data.getAgent(nodeId) });
|
|
33
|
+
const save = useMutation({
|
|
34
|
+
mutationFn: async (definition: AgentDefinition) =>
|
|
35
|
+
await data.saveAgentDefinition({
|
|
36
|
+
nodeId,
|
|
37
|
+
baseVersionId: query.data?.version?.id ?? null,
|
|
38
|
+
definition,
|
|
39
|
+
idempotencyKey: crypto.randomUUID(),
|
|
40
|
+
}),
|
|
41
|
+
// The answer carries the new version, so the cache is set from it rather than refetched: the
|
|
42
|
+
// next save needs that id as its `baseVersionId`, and a round trip would leave a window in
|
|
43
|
+
// which it is still the old one.
|
|
44
|
+
onSuccess: (saved) => queryClient.setQueryData(agentKey(nodeId), saved),
|
|
45
|
+
});
|
|
46
|
+
return { query, definition: query.data?.definition ?? null, save };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* When this agent next does something on its own, across all of its schedules.
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ Derived from the cron expressions, never asked. The runtime arms one alarm at a time and does
|
|
53
|
+
* not publish it, so this is a reading of the same expressions by the same rules — see
|
|
54
|
+
* `agent-cron.ts` for why that duplication is pinned by a test rather than left to agree by luck.
|
|
55
|
+
*/
|
|
56
|
+
export function nextRunAt(definition: AgentDefinition | null, now: Date): Date | null {
|
|
57
|
+
const fires = (definition?.schedules ?? []).flatMap(
|
|
58
|
+
(schedule) => nextCronFires(schedule.cron, now, 1)[0] ?? [],
|
|
59
|
+
);
|
|
60
|
+
return fires.reduce<Date | null>(
|
|
61
|
+
(earliest, fire) => (earliest === null || fire < earliest ? fire : earliest),
|
|
62
|
+
null,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The name behind an id, read from the lists the tree already loads.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ An entry the reader may not see is absent from both lists, and this then answers "not
|
|
8
|
+
* available to you" rather than a raw id or an invented title. Deliberately the same answer a
|
|
9
|
+
* reference to something deleted gets: the two must not be distinguishable, or the row would
|
|
10
|
+
* confirm that a document exists to somebody who may not know it (#41).
|
|
11
|
+
*
|
|
12
|
+
* Its own module because two screens need it — the profile's references and schedules, and the
|
|
13
|
+
* "run now" menu in the page head, which has to name the targets it offers.
|
|
14
|
+
*/
|
|
15
|
+
export function useEntryTitle(entryId: string, flow = false): { title: string; known: boolean } {
|
|
16
|
+
const { data, i18n } = useIntelRouterContext();
|
|
17
|
+
const graph = useQuery({
|
|
18
|
+
queryKey: ["node-graph"],
|
|
19
|
+
queryFn: () => data.getNodeGraph(),
|
|
20
|
+
enabled: !flow,
|
|
21
|
+
});
|
|
22
|
+
const flows = useQuery({ queryKey: ["flows"], queryFn: () => data.listFlows(), enabled: flow });
|
|
23
|
+
const found = flow
|
|
24
|
+
? flows.data?.items.find((item) => item.id === entryId)
|
|
25
|
+
: graph.data?.nodes.find((node) => node.id === entryId);
|
|
26
|
+
if (found) return { title: found.title, known: true };
|
|
27
|
+
const pending = flow ? flows.isPending : graph.isPending;
|
|
28
|
+
return { title: i18n.t(pending ? "common.loading" : "agent.unknownEntry"), known: false };
|
|
29
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { AgentRun } from "@/data/agent-runtime/agent-runtime.ts";
|
|
5
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What this agent has done, newest first.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ Read from the RUNTIME, not from intel: runs live in the agent's own Durable Object and are
|
|
11
|
+
* trimmed there. So this list is short by design and has no paging — the runtime answers at most a
|
|
12
|
+
* hundred runs and keeps no more.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ A run says what it did and, when it failed, what stopped it. It does not carry the answer it
|
|
15
|
+
* produced: a chat answer belongs to the person it was streamed to and a mail answer to the
|
|
16
|
+
* correspondent, and the runtime deliberately stores neither where this list could reach it.
|
|
17
|
+
*/
|
|
18
|
+
export function AgentLog({ agentId }: { agentId: string }) {
|
|
19
|
+
const { data, i18n } = useIntelRouterContext();
|
|
20
|
+
const [openRunId, setOpenRunId] = useState<string | null>(null);
|
|
21
|
+
const runs = useQuery({
|
|
22
|
+
queryKey: ["agent-runs", agentId],
|
|
23
|
+
queryFn: () => data.listAgentRuns(agentId),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<section aria-label={i18n.t("agent.log")} className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
|
28
|
+
{runs.isPending ? (
|
|
29
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
30
|
+
) : null}
|
|
31
|
+
{runs.isError ? (
|
|
32
|
+
<div role="alert" className="space-y-3 text-sm">
|
|
33
|
+
<p className="text-destructive">{i18n.t("agent.logFailed")}</p>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
onClick={() => void runs.refetch()}
|
|
37
|
+
className="rounded-md border px-3 py-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
38
|
+
>
|
|
39
|
+
{i18n.t("common.retry")}
|
|
40
|
+
</button>
|
|
41
|
+
</div>
|
|
42
|
+
) : null}
|
|
43
|
+
{runs.data && runs.data.items.length === 0 ? (
|
|
44
|
+
<p className="text-sm text-muted-foreground">{i18n.t("agent.logEmpty")}</p>
|
|
45
|
+
) : null}
|
|
46
|
+
{runs.data && runs.data.items.length > 0 ? (
|
|
47
|
+
<ul className="space-y-2">
|
|
48
|
+
{runs.data.items.map((run) => (
|
|
49
|
+
<RunRow
|
|
50
|
+
key={run.id}
|
|
51
|
+
agentId={agentId}
|
|
52
|
+
run={run}
|
|
53
|
+
open={openRunId === run.id}
|
|
54
|
+
toggle={() => setOpenRunId(openRunId === run.id ? null : run.id)}
|
|
55
|
+
/>
|
|
56
|
+
))}
|
|
57
|
+
</ul>
|
|
58
|
+
) : null}
|
|
59
|
+
</section>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function RunRow({
|
|
64
|
+
agentId,
|
|
65
|
+
run,
|
|
66
|
+
open,
|
|
67
|
+
toggle,
|
|
68
|
+
}: {
|
|
69
|
+
agentId: string;
|
|
70
|
+
run: AgentRun;
|
|
71
|
+
open: boolean;
|
|
72
|
+
toggle(): void;
|
|
73
|
+
}) {
|
|
74
|
+
const { i18n } = useIntelRouterContext();
|
|
75
|
+
const failed = run.status === "failed";
|
|
76
|
+
const Chevron = open ? ChevronDown : ChevronRight;
|
|
77
|
+
return (
|
|
78
|
+
<li className="rounded-lg border bg-card">
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={toggle}
|
|
82
|
+
aria-expanded={open}
|
|
83
|
+
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"
|
|
84
|
+
>
|
|
85
|
+
<Chevron aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
86
|
+
<span className="min-w-0 flex-1">
|
|
87
|
+
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
88
|
+
<span
|
|
89
|
+
className={`rounded-full border px-2 py-0.5 text-xs ${failed ? "border-destructive/40 text-destructive" : "text-muted-foreground"}`}
|
|
90
|
+
>
|
|
91
|
+
{i18n.t(`runs.status.${run.status}`)}
|
|
92
|
+
</span>
|
|
93
|
+
<time dateTime={run.startedAt}>
|
|
94
|
+
{new Date(run.startedAt).toLocaleString(i18n.locale)}
|
|
95
|
+
</time>
|
|
96
|
+
<span className="text-muted-foreground">{i18n.t(`agent.trigger.${run.trigger}`)}</span>
|
|
97
|
+
<span className="text-muted-foreground">
|
|
98
|
+
{i18n.t("agent.steps", { count: run.steps })}
|
|
99
|
+
</span>
|
|
100
|
+
</span>
|
|
101
|
+
{/* The failure in the words the failure itself used. Summarising it into "failed" is what
|
|
102
|
+
the status pill already says, and it is never the sentence that helps. */}
|
|
103
|
+
{run.error ? (
|
|
104
|
+
<span className="mt-1.5 block text-sm text-destructive">{run.error}</span>
|
|
105
|
+
) : null}
|
|
106
|
+
</span>
|
|
107
|
+
</button>
|
|
108
|
+
{open ? <RunDetail agentId={agentId} runId={run.id} /> : null}
|
|
109
|
+
</li>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The operational log of one run: what it tried, in order. Fetched only when a row is opened —
|
|
114
|
+
// every run carries its own entries and loading all of them would be the whole log for one screen.
|
|
115
|
+
function RunDetail({ agentId, runId }: { agentId: string; runId: string }) {
|
|
116
|
+
const { data, i18n } = useIntelRouterContext();
|
|
117
|
+
const detail = useQuery({
|
|
118
|
+
queryKey: ["agent-run", agentId, runId],
|
|
119
|
+
queryFn: () => data.getAgentRun(agentId, runId),
|
|
120
|
+
});
|
|
121
|
+
return (
|
|
122
|
+
<div className="border-t px-4 py-3">
|
|
123
|
+
{detail.isPending ? (
|
|
124
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
125
|
+
) : null}
|
|
126
|
+
{detail.isError ? (
|
|
127
|
+
<p role="alert" className="text-sm text-destructive">
|
|
128
|
+
{i18n.t("agent.runFailed")}
|
|
129
|
+
</p>
|
|
130
|
+
) : null}
|
|
131
|
+
{detail.data && detail.data.log.length === 0 ? (
|
|
132
|
+
<p className="text-sm text-muted-foreground">{i18n.t("agent.runLogEmpty")}</p>
|
|
133
|
+
) : null}
|
|
134
|
+
{detail.data && detail.data.log.length > 0 ? (
|
|
135
|
+
<ol aria-label={i18n.t("agent.runLog")} className="space-y-2">
|
|
136
|
+
{detail.data.log.map((entry) => (
|
|
137
|
+
<li key={entry.id} className="text-sm">
|
|
138
|
+
<span
|
|
139
|
+
className={entry.level === "error" ? "font-medium text-destructive" : "font-medium"}
|
|
140
|
+
>
|
|
141
|
+
{entry.event}
|
|
142
|
+
</span>
|
|
143
|
+
<time dateTime={entry.at} className="ml-2 text-xs text-muted-foreground">
|
|
144
|
+
{new Date(entry.at).toLocaleTimeString(i18n.locale)}
|
|
145
|
+
</time>
|
|
146
|
+
{entry.detail ? (
|
|
147
|
+
<span
|
|
148
|
+
className={`mt-0.5 block text-xs ${entry.level === "error" ? "text-destructive" : "text-muted-foreground"}`}
|
|
149
|
+
>
|
|
150
|
+
{entry.detail}
|
|
151
|
+
</span>
|
|
152
|
+
) : null}
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ol>
|
|
156
|
+
) : null}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|