run_turn takes its tools, system prompt and tool caller; the chat passes the MCP tools as before. The assistant node offers one tool per wired target, reads its other inputs as context, streams its reply a sentence at a time, warms the provider on `listening` and says so when the provider is not there. Completions share the per-server HTTP client. wren replies when nothing reads `unhandled`, stt shows a note when nothing was heard. /ai/control lists one conversation per entry node with the messages to watch.
589 lines
18 KiB
TypeScript
589 lines
18 KiB
TypeScript
import { useCallback, useRef, useSyncExternalStore } from "react"
|
|
|
|
import { OpenAPI } from "@/client"
|
|
import { request } from "@/client/core/request"
|
|
|
|
/**
|
|
* Live engine state, deliberately outside React Query.
|
|
*
|
|
* Values can arrive many times a second. Keeping them here, with one subscriber
|
|
* set per key, means a changing value re-renders the chip showing it and
|
|
* nothing else.
|
|
*/
|
|
|
|
/** Who caused a value. The canvas needs it to pulse the right edge. */
|
|
export type ValueSource = {
|
|
kind: "node" | "dashboard" | "flow" | "agent" | "chat" | "api" | "panel"
|
|
id: string
|
|
label: string
|
|
detail?: string
|
|
}
|
|
export type LiveValue = {
|
|
value: unknown
|
|
ts: number | null
|
|
source?: ValueSource
|
|
/**
|
|
* Caught up from a snapshot rather than seen arriving. It happened, but not
|
|
* while anyone was watching — the same distinction as `priorEmits`.
|
|
*/
|
|
prior?: boolean
|
|
}
|
|
export type LiveStatus = {
|
|
status: "active" | "error" | "queued" | "running" | "success"
|
|
error?: string | null
|
|
/** Why it is queued — what it is waiting for, and on which machine. */
|
|
detail?: string | null
|
|
}
|
|
/**
|
|
* A node's last failure, kept after it has run again.
|
|
*
|
|
* `LiveStatus` answers "how did the last run go", which is what the red dot
|
|
* has to keep telling the truth about. This answers the other question — did
|
|
* this node fail at all since anyone looked — and the engine answers it too,
|
|
* so a reload and a second browser see the same thing. Only acknowledging one
|
|
* clears it.
|
|
*/
|
|
export type NodeFailure = { error: string; ts: number }
|
|
/** Something the engine reported about itself, for the health page. */
|
|
export type EngineEvent = {
|
|
type: string
|
|
flow?: string
|
|
node?: string
|
|
detail?: string
|
|
ts: number
|
|
}
|
|
/** One node execution's output, as the log panel shows it. */
|
|
export type LogLine = {
|
|
flow: string
|
|
node: string
|
|
text: string
|
|
level: "info" | "error"
|
|
truncated?: boolean
|
|
ts: number
|
|
}
|
|
|
|
type Listener = () => void
|
|
|
|
/** Enough to see what a flow has been doing, not a log store. */
|
|
const LOG_LIMIT = 500
|
|
/** The health page reads these to know when to refetch; it is not a history. */
|
|
const ENGINE_EVENT_LIMIT = 100
|
|
|
|
const values = new Map<string, LiveValue>()
|
|
const statuses = new Map<string, LiveStatus>()
|
|
const failures = new Map<string, NodeFailure>()
|
|
let engineEvents: EngineEvent[] = []
|
|
// How many times this page has seen a node emit. The number itself means
|
|
// nothing; a change is what restarts the pulse.
|
|
const emits = new Map<string, number>()
|
|
// What the engine had already counted when we connected, from the snapshot.
|
|
// Kept apart from the live count on purpose: it says the node has been busy,
|
|
// which is worth drawing, but it happened before anyone was watching, and a
|
|
// pulse claims something is happening now.
|
|
const priorEmits = new Map<string, number>()
|
|
let logLines: LogLine[] = []
|
|
const paused = new Set<string>()
|
|
const listeners = new Map<string, Set<Listener>>()
|
|
|
|
let connected = false
|
|
const connectionListeners = new Set<Listener>()
|
|
|
|
/**
|
|
* Object URLs for frames the engine pushed, keyed by digest.
|
|
*
|
|
* Small on purpose: a camera's frames are worth exactly as long as the next
|
|
* one takes to arrive, and a tab that held every one of them would grow
|
|
* without bound. The oldest is revoked when the room runs out.
|
|
*/
|
|
const BYTES_LIMIT = 8
|
|
const bytes = new Map<string, string>()
|
|
|
|
/**
|
|
* Which messages this page is drawing bytes for, and how many tiles each.
|
|
*
|
|
* Refcounted because two tiles may show one camera and the first to unmount
|
|
* must not stop the second's frames. The socket sends the key set whenever it
|
|
* changes; nothing is pushed for a name nobody is looking at.
|
|
*/
|
|
const wanted = new Map<string, number>()
|
|
const wantedListeners = new Set<Listener>()
|
|
|
|
function notify(key: string) {
|
|
for (const listener of listeners.get(key) ?? []) listener()
|
|
}
|
|
|
|
function subscribeKey(key: string, listener: Listener) {
|
|
let set = listeners.get(key)
|
|
if (!set) {
|
|
set = new Set()
|
|
listeners.set(key, set)
|
|
}
|
|
set.add(listener)
|
|
return () => {
|
|
set.delete(listener)
|
|
if (set.size === 0) listeners.delete(key)
|
|
}
|
|
}
|
|
|
|
export const liveStore = {
|
|
/** Told of every change to one key, such as `value:<name>`, as it happens. */
|
|
subscribe: subscribeKey,
|
|
setValue(name: string, value: LiveValue) {
|
|
values.set(name, value)
|
|
notify(`value:${name}`)
|
|
},
|
|
setValues(entries: Record<string, LiveValue>) {
|
|
for (const [name, value] of Object.entries(entries)) {
|
|
values.set(name, { ...value, prior: true })
|
|
notify(`value:${name}`)
|
|
}
|
|
},
|
|
getValue(name: string) {
|
|
return values.get(name)
|
|
},
|
|
setStatus(nodeId: string, status: LiveStatus) {
|
|
statuses.set(nodeId, status)
|
|
notify(`status:${nodeId}`)
|
|
// Recorded here rather than waited for: the engine keeps the same record,
|
|
// but the canvas has to mark the failure as it happens. A node only ever
|
|
// reaches this store as "error" by having raised.
|
|
if (status.status === "error") {
|
|
failures.set(nodeId, {
|
|
error: status.error || "The node raised while running.",
|
|
ts: Date.now() / 1000,
|
|
})
|
|
notify(`failure:${nodeId}`)
|
|
}
|
|
},
|
|
setStatuses(
|
|
entries: {
|
|
id: string
|
|
status: string
|
|
error?: string | null
|
|
/** The engine's own record of the last failure — see `NodeFailure`. */
|
|
last_error?: string | null
|
|
last_error_ts?: number | null
|
|
}[],
|
|
) {
|
|
for (const entry of entries) {
|
|
statuses.set(entry.id, {
|
|
status: entry.status as LiveStatus["status"],
|
|
error: entry.error,
|
|
})
|
|
notify(`status:${entry.id}`)
|
|
// The engine's record wins, in both directions: it is what a reload
|
|
// reads, and an empty one means someone has acknowledged the failure.
|
|
if (entry.last_error) {
|
|
failures.set(entry.id, {
|
|
error: entry.last_error,
|
|
ts: entry.last_error_ts ?? Date.now() / 1000,
|
|
})
|
|
notify(`failure:${entry.id}`)
|
|
} else if (failures.delete(entry.id)) {
|
|
notify(`failure:${entry.id}`)
|
|
}
|
|
}
|
|
},
|
|
getStatus(nodeId: string) {
|
|
return statuses.get(nodeId)
|
|
},
|
|
getFailure(nodeId: string) {
|
|
return failures.get(nodeId)
|
|
},
|
|
/** Drop the marker for a failure the engine has already been told about. */
|
|
clearFailure(nodeId: string) {
|
|
if (failures.delete(nodeId)) notify(`failure:${nodeId}`)
|
|
},
|
|
/**
|
|
* Dismiss a node's failure, here and on the engine.
|
|
*
|
|
* The only thing that clears one: a good run afterwards deliberately does
|
|
* not, which is what makes a failure between two glances at the canvas
|
|
* findable at all.
|
|
*/
|
|
acknowledgeFailure(nodeId: string) {
|
|
if (!failures.delete(nodeId)) return
|
|
notify(`failure:${nodeId}`)
|
|
const [flow, node] = nodeId.split(".")
|
|
if (!flow || !node) return
|
|
// Hand-written rather than generated: nothing hangs off the response, and
|
|
// a call that does not arrive only means the marker is back after a
|
|
// reload — which is the safer way round for a failure.
|
|
request(OpenAPI, {
|
|
method: "POST",
|
|
url: "/api/v1/flows/{name}/nodes/{node_id}/acknowledge",
|
|
path: { name: flow, node_id: node },
|
|
}).catch(() => {})
|
|
},
|
|
recordEngineEvent(event: EngineEvent) {
|
|
// A new array each time, so the hook's snapshot comparison sees the change.
|
|
engineEvents = [...engineEvents, event].slice(-ENGINE_EVENT_LIMIT)
|
|
notify("engine")
|
|
},
|
|
recordEmit(nodeId: string) {
|
|
emits.set(nodeId, (emits.get(nodeId) ?? 0) + 1)
|
|
notify(`emit:${nodeId}`)
|
|
},
|
|
/**
|
|
* The counts a snapshot carries, so a page that connects into an engine
|
|
* already at work draws what has been busy rather than a graph of idle
|
|
* neurons.
|
|
*
|
|
* Absent keys are left alone rather than cleared: a restarted engine counts
|
|
* from zero again, which is no reason to forget what this page saw.
|
|
*/
|
|
setEmits(entries: Record<string, number>) {
|
|
for (const [nodeId, count] of Object.entries(entries)) {
|
|
if (priorEmits.get(nodeId) === count) continue
|
|
priorEmits.set(nodeId, count)
|
|
notify(`emit:${nodeId}`)
|
|
}
|
|
},
|
|
appendLog(line: LogLine) {
|
|
// A new array each time, so the hook's snapshot comparison sees the change.
|
|
logLines = [...logLines, line].slice(-LOG_LIMIT)
|
|
notify("logs")
|
|
},
|
|
setLogs(lines: LogLine[]) {
|
|
logLines = lines.slice(-LOG_LIMIT)
|
|
notify("logs")
|
|
},
|
|
clearLogs() {
|
|
logLines = []
|
|
notify("logs")
|
|
},
|
|
setPaused(flow: string, isPaused: boolean) {
|
|
if (isPaused) paused.add(flow)
|
|
else paused.delete(flow)
|
|
notify(`paused:${flow}`)
|
|
},
|
|
setPausedFlows(flows: string[]) {
|
|
const next = new Set(flows)
|
|
for (const flow of new Set([...paused, ...next])) {
|
|
if (paused.has(flow) === next.has(flow)) continue
|
|
if (next.has(flow)) paused.add(flow)
|
|
else paused.delete(flow)
|
|
notify(`paused:${flow}`)
|
|
}
|
|
},
|
|
setConnected(next: boolean) {
|
|
if (connected === next) return
|
|
connected = next
|
|
for (const listener of connectionListeners) listener()
|
|
},
|
|
isConnected() {
|
|
return connected
|
|
},
|
|
/**
|
|
* Take a frame the socket pushed, as an object URL the tile can draw.
|
|
*
|
|
* Content addressing means a digest already here is the same bytes, so the
|
|
* blob is dropped rather than replacing an identical one.
|
|
*/
|
|
setBytes(digest: string, blob: Blob) {
|
|
if (bytes.has(digest)) return
|
|
bytes.set(digest, URL.createObjectURL(blob))
|
|
while (bytes.size > BYTES_LIMIT) {
|
|
const oldest = bytes.keys().next().value
|
|
if (oldest === undefined) break
|
|
const url = bytes.get(oldest)
|
|
bytes.delete(oldest)
|
|
if (url) URL.revokeObjectURL(url)
|
|
notify(`bytes:${oldest}`)
|
|
}
|
|
notify(`bytes:${digest}`)
|
|
},
|
|
getBytes(digest: string) {
|
|
return bytes.get(digest)
|
|
},
|
|
/** Ask for this message's frames while the caller is drawing it. */
|
|
wantBytes(name: string) {
|
|
wanted.set(name, (wanted.get(name) ?? 0) + 1)
|
|
if (wanted.get(name) === 1)
|
|
for (const listener of wantedListeners) listener()
|
|
return () => {
|
|
const left = (wanted.get(name) ?? 1) - 1
|
|
if (left > 0) {
|
|
wanted.set(name, left)
|
|
return
|
|
}
|
|
wanted.delete(name)
|
|
for (const listener of wantedListeners) listener()
|
|
}
|
|
},
|
|
wantedNames() {
|
|
return [...wanted.keys()]
|
|
},
|
|
/** Told when the set changes, so the socket can say so. */
|
|
onWanted(listener: Listener) {
|
|
wantedListeners.add(listener)
|
|
return () => {
|
|
wantedListeners.delete(listener)
|
|
}
|
|
},
|
|
reset() {
|
|
for (const key of values.keys()) notify(`value:${key}`)
|
|
values.clear()
|
|
for (const key of statuses.keys()) notify(`status:${key}`)
|
|
statuses.clear()
|
|
for (const key of failures.keys()) notify(`failure:${key}`)
|
|
failures.clear()
|
|
for (const key of new Set([...emits.keys(), ...priorEmits.keys()]))
|
|
notify(`emit:${key}`)
|
|
emits.clear()
|
|
priorEmits.clear()
|
|
engineEvents = []
|
|
notify("engine")
|
|
logLines = []
|
|
notify("logs")
|
|
for (const flow of paused) notify(`paused:${flow}`)
|
|
paused.clear()
|
|
for (const [digest, url] of bytes) {
|
|
URL.revokeObjectURL(url)
|
|
notify(`bytes:${digest}`)
|
|
}
|
|
bytes.clear()
|
|
// `wanted` is deliberately kept: the tiles asking are still mounted, and a
|
|
// reconnecting socket has to say what they want all over again.
|
|
},
|
|
}
|
|
|
|
export function useLiveValue(name: string | undefined): LiveValue | undefined {
|
|
return useSyncExternalStore(
|
|
(listener) => (name ? subscribeKey(`value:${name}`, listener) : () => {}),
|
|
() => (name ? values.get(name) : undefined),
|
|
)
|
|
}
|
|
|
|
/** The pushed bytes for a digest, if the socket carried them. */
|
|
export function useLiveBytes(digest: string | undefined): string | undefined {
|
|
return useSyncExternalStore(
|
|
(listener) =>
|
|
digest ? subscribeKey(`bytes:${digest}`, listener) : () => {},
|
|
() => (digest ? bytes.get(digest) : undefined),
|
|
)
|
|
}
|
|
|
|
export function useNodeStatus(nodeId: string): LiveStatus | undefined {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey(`status:${nodeId}`, listener),
|
|
() => statuses.get(nodeId),
|
|
)
|
|
}
|
|
|
|
/** The node's last failure, until someone acknowledges it. */
|
|
export function useNodeFailure(nodeId: string): NodeFailure | undefined {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey(`failure:${nodeId}`, listener),
|
|
() => failures.get(nodeId),
|
|
)
|
|
}
|
|
|
|
/** The last hundred things the engine said about itself, oldest first. */
|
|
export function useEngineEvents(): EngineEvent[] {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey("engine", listener),
|
|
() => engineEvents,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The minute the newest engine event landed in, or 0 for none yet.
|
|
*
|
|
* A scalar, so a subscriber that only wants "has something gone wrong
|
|
* lately" re-renders when the answer changes rather than on every event.
|
|
* `recordEngineEvent` allocates a new array each time, so subscribing to the
|
|
* array itself means a flapping node re-renders whatever is watching — which
|
|
* on Home is two uPlot charts that rebuild their series from scratch.
|
|
*/
|
|
export function useEngineEventMinute(): number {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey("engine", listener),
|
|
() =>
|
|
engineEvents.length
|
|
? Math.floor(engineEvents[engineEvents.length - 1].ts / 60)
|
|
: 0,
|
|
)
|
|
}
|
|
|
|
/** Increments each time the node publishes something. */
|
|
export function useNodeEmits(nodeId: string): number {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey(`emit:${nodeId}`, listener),
|
|
() => emits.get(nodeId) ?? 0,
|
|
)
|
|
}
|
|
|
|
/** Every captured line, newest last. Filtered by the panel that shows it. */
|
|
export function useLiveLogs(): LogLine[] {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey("logs", listener),
|
|
() => logLines,
|
|
)
|
|
}
|
|
|
|
export function useFlowPaused(flow: string): boolean {
|
|
return useSyncExternalStore(
|
|
(listener) => subscribeKey(`paused:${flow}`, listener),
|
|
() => paused.has(flow),
|
|
)
|
|
}
|
|
|
|
/*
|
|
* Group hooks, for the brain graph: one neuron stands for several nodes, and
|
|
* one edge for several messages.
|
|
*
|
|
* Each returns a primitive. `useSyncExternalStore` compares snapshots by
|
|
* identity, so a fresh object or array every render would re-render forever.
|
|
* The names arrive as an array, which is also new every render, so they are
|
|
* joined into one string before anything hangs off them.
|
|
*/
|
|
|
|
const SEP = "\u0000"
|
|
|
|
/** Back to the array. `"".split(SEP)` is `[""]`, which is one empty name. */
|
|
const parts = (joined: string) => (joined ? joined.split(SEP) : [])
|
|
|
|
function subscribeAll(keys: string[], listener: Listener) {
|
|
const unsubscribes = keys.map((key) => subscribeKey(key, listener))
|
|
return () => {
|
|
for (const unsubscribe of unsubscribes) unsubscribe()
|
|
}
|
|
}
|
|
|
|
/** How many times any of these nodes has published. A change is a pulse. */
|
|
export function useGroupEmits(ids: string[]): number {
|
|
const joined = ids.join(SEP)
|
|
return useSyncExternalStore(
|
|
useCallback(
|
|
(listener: Listener) =>
|
|
subscribeAll(
|
|
parts(joined).map((id) => `emit:${id}`),
|
|
listener,
|
|
),
|
|
[joined],
|
|
),
|
|
() => parts(joined).reduce((total, id) => total + (emits.get(id) ?? 0), 0),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Whether any of these nodes has ever published, this page or before it.
|
|
*
|
|
* Separate from the count above because it answers a different question: the
|
|
* count is what pulses, and this is what tells a neuron nobody has ever seen
|
|
* run from one that is merely quiet just now.
|
|
*/
|
|
export function useGroupActive(ids: string[]): boolean {
|
|
const joined = ids.join(SEP)
|
|
return useSyncExternalStore(
|
|
useCallback(
|
|
(listener: Listener) =>
|
|
subscribeAll(
|
|
parts(joined).map((id) => `emit:${id}`),
|
|
listener,
|
|
),
|
|
[joined],
|
|
),
|
|
() =>
|
|
parts(joined).some(
|
|
(id) => (emits.get(id) ?? 0) + (priorEmits.get(id) ?? 0) > 0,
|
|
),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* What any of these nodes last failed with, or `""` for none.
|
|
*
|
|
* The failure rather than the live status: a neuron stands for nodes across
|
|
* several flows, and Home would otherwise disagree with the canvas the moment
|
|
* one of them ran again. The reason itself, because a string is a snapshot
|
|
* `useSyncExternalStore` can compare and the neuron shows it in its tooltip.
|
|
*/
|
|
export function useGroupFailure(ids: string[]): string {
|
|
const joined = ids.join(SEP)
|
|
return useSyncExternalStore(
|
|
useCallback(
|
|
(listener: Listener) =>
|
|
subscribeAll(
|
|
parts(joined).map((id) => `failure:${id}`),
|
|
listener,
|
|
),
|
|
[joined],
|
|
),
|
|
() =>
|
|
parts(joined)
|
|
.map((id) => failures.get(id)?.error ?? "")
|
|
.find(Boolean) ?? "",
|
|
)
|
|
}
|
|
|
|
/** When any of these messages last carried a value. */
|
|
export function useLatestTs(names: string[]): number {
|
|
const joined = names.join(SEP)
|
|
return useSyncExternalStore(
|
|
useCallback(
|
|
(listener: Listener) =>
|
|
subscribeAll(
|
|
parts(joined).map((name) => `value:${name}`),
|
|
listener,
|
|
),
|
|
[joined],
|
|
),
|
|
() =>
|
|
parts(joined).reduce(
|
|
(latest, name) => Math.max(latest, values.get(name)?.ts ?? 0),
|
|
0,
|
|
),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Several messages at once, for a widget whose bindings are a list.
|
|
*
|
|
* `useLiveValue` is one hook per name, and hooks cannot be called in a loop —
|
|
* which is why a chart used to call it five times and a bar three, whatever
|
|
* the document actually bound. This subscribes to as many names as it is
|
|
* given, so the widget's shape follows its configuration instead of the other
|
|
* way round.
|
|
*
|
|
* The snapshot must keep its identity while nothing has changed:
|
|
* `useSyncExternalStore` compares by reference, and a fresh array every render
|
|
* would re-render for ever.
|
|
*/
|
|
export function useLiveValues(names: string[]): (LiveValue | undefined)[] {
|
|
const joined = names.join(SEP)
|
|
const last = useRef<(LiveValue | undefined)[]>([])
|
|
return useSyncExternalStore(
|
|
useCallback(
|
|
(listener: Listener) =>
|
|
subscribeAll(
|
|
parts(joined).map((name) => `value:${name}`),
|
|
listener,
|
|
),
|
|
[joined],
|
|
),
|
|
() => {
|
|
const next = parts(joined).map((name) =>
|
|
name ? values.get(name) : undefined,
|
|
)
|
|
const kept = last.current
|
|
if (next.length === kept.length && next.every((v, i) => v === kept[i])) {
|
|
return kept
|
|
}
|
|
last.current = next
|
|
return next
|
|
},
|
|
)
|
|
}
|
|
|
|
export function useLiveConnection(): boolean {
|
|
return useSyncExternalStore(
|
|
(listener) => {
|
|
connectionListeners.add(listener)
|
|
return () => connectionListeners.delete(listener)
|
|
},
|
|
() => connected,
|
|
)
|
|
}
|