commit 3f1962d5df4afc7d353b1f96071899cc6152815a Author: Quixos Template Publisher Date: Sat Jan 1 00:00:00 2000 +0000 Export template source from a3e23220cb07d56bad7513d2ac7f4eae9e383cd6:quixos-instance/packages/agent-presentation diff --git a/.quixos-template-source.json b/.quixos-template-source.json new file mode 100644 index 0000000..d65c814 --- /dev/null +++ b/.quixos-template-source.json @@ -0,0 +1,4 @@ +{ + "monorepoCommit": "a3e23220cb07d56bad7513d2ac7f4eae9e383cd6", + "sourcePath": "quixos-instance/packages/agent-presentation" +} diff --git a/components.nix b/components.nix new file mode 100644 index 0000000..78adf7f --- /dev/null +++ b/components.nix @@ -0,0 +1,6 @@ +{ + "component:org.quixos.agent:thread" = { + entry = "src/thread.tsx"; + export = "Thread"; + }; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..8da63db --- /dev/null +++ b/flake.lock @@ -0,0 +1,108 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "helpers": { + "flake": false, + "locked": { + "lastModified": 1791886789, + "narHash": "sha256-7YF98v0AcxD1qkAdS+nL/aDtmTvcRzld9CB9AxY0qaA=", + "ref": "refs/tags/quixos-reachability/5657043450c46fc84e1a6d38c2f8919ba1e9d10c", + "rev": "5657043450c46fc84e1a6d38c2f8919ba1e9d10c", + "revCount": 78, + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git" + }, + "original": { + "ref": "refs/tags/quixos-reachability/5657043450c46fc84e1a6d38c2f8919ba1e9d10c", + "rev": "5657043450c46fc84e1a6d38c2f8919ba1e9d10c", + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1783776592, + "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "protocol": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1792381599, + "narHash": "sha256-ojsUJmm+cDuIbxskC2RDOy5HiyIg+R48yRs3dqaccgo=", + "ref": "refs/tags/quixos-reachability/5520c2b0116696152ce64d797153133475c4b259", + "rev": "5520c2b0116696152ce64d797153133475c4b259", + "revCount": 234, + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" + }, + "original": { + "ref": "refs/tags/quixos-reachability/5520c2b0116696152ce64d797153133475c4b259", + "rev": "5520c2b0116696152ce64d797153133475c4b259", + "type": "git", + "url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git" + } + }, + "root": { + "inputs": { + "flake-utils": [ + "protocol", + "flake-utils" + ], + "helpers": "helpers", + "nixpkgs": [ + "protocol", + "nixpkgs" + ], + "protocol": "protocol" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7a8b7fd --- /dev/null +++ b/flake.nix @@ -0,0 +1,44 @@ +{ + inputs.protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/tags/quixos-reachability/5520c2b0116696152ce64d797153133475c4b259&rev=5520c2b0116696152ce64d797153133475c4b259"; + inputs.nixpkgs.follows = "protocol/nixpkgs"; + inputs.flake-utils.follows = "protocol/flake-utils"; + inputs.helpers = { + url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git?ref=refs/tags/quixos-reachability/5657043450c46fc84e1a6d38c2f8919ba1e9d10c&rev=5657043450c46fc84e1a6d38c2f8919ba1e9d10c"; + flake = false; + }; + outputs = + { + nixpkgs, + flake-utils, + helpers, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + camino = import (helpers + "/quixos-package-helpers.nix"); + in + { + quixosPackages.checkedArtifacts = + context: + let + pkgs = camino.packageSet { inherit nixpkgs system context; }; + dependencies = + if builtins.pathExists ./yarn-project.nix then + camino.mkCaminoYarnDependencies { + inherit pkgs; + src = ./.; + } + else + null; + in + (camino.mkCaminoReactPackage { + inherit pkgs context dependencies; + src = ./.; + modules = import ./components.nix; + }); + devShells.default = camino.mkCaminoYarnShell { inherit pkgs; }; + } + ); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9f5b2d1 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "@quixos/agent-presentation", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + "./shared": "./src/shared.tsx", + "./http": "./src/http.ts", + "./protocol": "./src/protocol.ts", + "./widget": "./src/widget.tsx" + } +} diff --git a/package.qx b/package.qx new file mode 100644 index 0000000..13056f9 --- /dev/null +++ b/package.qx @@ -0,0 +1,17 @@ +import interface StandaloneView; +import interface AgentThread; +import interface Agent; +import interface AgentRequest; +import interface AgentItem; +import interface AgentQuestion; +external atom AgentObject id "atom:org.quixos.agent:agent"; +external atom AgentRequestObject id "atom:org.quixos.agent:request"; +external atom AgentItemObject id "atom:org.quixos.agent:item"; +external atom AgentQuestionObject id "atom:org.quixos.agent:question"; +package AgentPresentation id "package:org.quixos.agent:presentation" revision "package:org.quixos.agent:presentation@1" { + module Thread id "component:org.quixos.agent:thread" implements StandaloneView.render; + query SettingsInputs id "agent:presentation:settings-inputs" root Agent document "queries/settings.graphql" operation "Settings" {replica;max rows 1;} + query Settings id "agent:presentation:settings" root Agent document "queries/settings.graphql" operation "Settings" {local from SettingsInputs;watch;max rows 1;} + query Inputs id "agent:presentation:inputs" root AgentThread document "queries/thread.graphql" operation "Thread" {view AgentObject as Agent; view AgentRequestObject as AgentRequest; view AgentItemObject as AgentItem; view AgentQuestionObject as AgentQuestion;replica;max rows 1500;max result-bytes 8388608;} + query View id "agent:presentation:view" root AgentThread document "queries/thread.graphql" operation "Thread" {view AgentObject as Agent; view AgentRequestObject as AgentRequest; view AgentItemObject as AgentItem; view AgentQuestionObject as AgentQuestion;local from Inputs;watch;max rows 1500;max result-bytes 8388608;} +} diff --git a/queries/settings.graphql b/queries/settings.graphql new file mode 100644 index 0000000..a8eeeff --- /dev/null +++ b/queries/settings.graphql @@ -0,0 +1 @@ +query Settings {root {_camino {ref} data {name description enabled settings} instructions}} diff --git a/queries/thread.graphql b/queries/thread.graphql new file mode 100644 index 0000000..4af8743 --- /dev/null +++ b/queries/thread.graphql @@ -0,0 +1 @@ +query Thread { root { _camino {ref} data {title kind archived pending} agent {_camino {ref} data {name}} active {_camino {ref} progress {status attempt generation reason cancelRequested}} items(first: 30, orderBy: [{data: {sequence: DESC}}]) {entries {node {_camino {ref} data {key kind text phase activity presentation sequence version createdAt updatedAt} subject {_camino {ref}} presentationMount {_camino {ref}} request {_camino {ref} admission {sequence createdAt sourceKind sourceLabel responseKind} progress {status attempt generation reason cancelRequested result rawOutput} questions(first: 16) {entries {node {_camino {ref} data {key title options status answer attempt generation expiresAt}}}}}}} pageInfo {hasNextPage endCursor}} } } diff --git a/quixos.check.json b/quixos.check.json new file mode 100644 index 0000000..528d4e1 --- /dev/null +++ b/quixos.check.json @@ -0,0 +1,4 @@ +{ + "backend": "execution", + "queryFrontends": ["react"] +} diff --git a/quixos.lock b/quixos.lock new file mode 100644 index 0000000..5feac34 --- /dev/null +++ b/quixos.lock @@ -0,0 +1,9 @@ +quixos-lock version 1 { + quixos source { repository "https://gitea.internal.quixos.org/quixos/quixos.git"; commit "a3e23220cb07d56bad7513d2ac7f4eae9e383cd6"; } + interface StandaloneView source { repository "https://repos.quixos.org/quixos-studio/interface-react-standalone-view.git"; commit "67b00f7ff8df87a477e12c5668c42684d8fa1708"; } + interface AgentThread source { repository "https://repos.quixos.org/quixos-studio/interface-agent-thread.git"; commit "9d689bbeecfc3321f97bfbaa9adf405eeee89e5c"; } + interface Agent source { repository "https://repos.quixos.org/quixos-studio/interface-agent.git"; commit "e5aeaa3a59e3c0cac99e43553b04227fb99a7462"; } + interface AgentRequest source { repository "https://repos.quixos.org/quixos-studio/interface-agent-request.git"; commit "d9bb364113d6590770f4733d3a9d42364e3b8806"; } + interface AgentItem source { repository "https://repos.quixos.org/quixos-studio/interface-agent-item.git"; commit "a80f9eebbefe6fdfe8b86db3103eed1a4b8e55f7"; } + interface AgentQuestion source { repository "https://repos.quixos.org/quixos-studio/interface-agent-question.git"; commit "c6fb1da16e29c599c1ba0dc510faa926615f0686"; } +} diff --git a/quixos.react.json b/quixos.react.json new file mode 100644 index 0000000..47964b9 --- /dev/null +++ b/quixos.react.json @@ -0,0 +1,19 @@ +{ + "modules": { + "Thread": { + "inputs": [ + { + "name": "body", + "query": "View", + "root": { + "from": "input", + "path": ["subject"] + }, + "arguments": { + "record": {} + } + } + ] + } + } +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..dd13174 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,507 @@ +import { + type Item, + type Request, + type Ref, + type ThreadState, + type ThreadController, + type Thread, + type Cursor, + itemKey, + terminal, +} from "./protocol.js"; + +export async function api(path: string, input?: unknown, signal?: AbortSignal): Promise { + const response = await fetch(`/api/agent${path}`, { + credentials: "same-origin", + signal, + ...(input === undefined + ? {} + : { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) }), + }); + const value = await response.json(); + if (!response.ok) + throw Object.assign(Error(value.error ?? `Agent service unavailable (${response.status})`), { + knownRejected: value.knownRejected === true, + }); + if (value.codec !== "quixos-agent-http-v1") throw Error("Unsupported Agent service response"); + return value; +} +export const control = async (input: Record) => { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(input))); + const key = `quixos.agent.control:${location.origin}:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`; + const previous = sessionStorage.getItem(key), + retained = previous + ? JSON.parse(previous) + : { + operationId: crypto.randomUUID(), + createdAt: new Date().toISOString(), + ...input, + }; + sessionStorage.setItem(key, JSON.stringify(retained)); + let result; + try { + result = await api("/control", retained); + } catch (error) { + if ((error as { knownRejected?: boolean }).knownRejected) sessionStorage.removeItem(key); + throw error; + } + sessionStorage.removeItem(key); + return result; +}; +export const draftKey = (thread: string) => `quixos.agent.draft:${location.origin}:${thread}`; + +/** Status and directory invalidation shared by shells, without a recurring + * snapshot timer. Reconnecting rechecks state to cover disconnected changes. */ +export function subscribeAgentUpdates(listener: () => void, onError: (error: unknown) => void) { + const abort = new AbortController(); + let timer: ReturnType | undefined; + const changed = () => { + timer ??= setTimeout(() => { + timer = undefined; + listener(); + }, 100); + }; + void (async () => { + while (!abort.signal.aborted) { + try { + const response = await fetch("/api/agent/updates", { credentials: "same-origin", signal: abort.signal }); + if (!response.ok || !response.body) throw Error("Agent updates unavailable"); + const reader = response.body.getReader(), + decoder = new TextDecoder(); + let buffer = ""; + try { + while (!abort.signal.aborted) { + const chunk = await reader.read(); + if (chunk.done) break; + buffer += decoder.decode(chunk.value, { stream: true }); + if (buffer.length > 65536) throw Error("Agent update frame exceeds limit"); + let end: number; + while ((end = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, end); + buffer = buffer.slice(end + 1); + if (line && JSON.parse(line).type === "changed") changed(); + } + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } + } catch (error) { + if (!abort.signal.aborted) onError(error); + } + if (!abort.signal.aborted) + await new Promise((resolve) => { + const finish = () => { + clearTimeout(retry); + abort.signal.removeEventListener("abort", finish); + resolve(); + }; + const retry = setTimeout(finish, 1000); + abort.signal.addEventListener("abort", finish, { once: true }); + if (abort.signal.aborted) finish(); + }); + } + })(); + return () => { + abort.abort(); + clearTimeout(timer); + }; +} + +/** A browser observer owns no executor. Closing it only stops reads. */ +export class HttpThreadController implements ThreadController { + private state: ThreadState = { + items: [], + requests: [], + maintenance: false, + connected: false, + loading: true, + error: "", + }; + private listeners = new Set<() => void>(); + private items = new Map(); + private requests = new Map(); + private aliases = new Map(); + private abort = new AbortController(); + private timer?: ReturnType; + private refreshNeeded = false; + private refreshing?: Promise; + private closed = false; + constructor( + readonly thread: string, + initial?: Thread, + ) { + if (initial) this.ingest({ data: { root: initial } }); + void this.refresh() + .catch((error) => this.patch({ error: String(error), loading: false })) + .finally(() => this.stream()); + } + getSnapshot = () => this.state; + subscribe = (callback: () => void) => { + this.listeners.add(callback); + return () => { + this.listeners.delete(callback); + }; + }; + private patch(value: Partial) { + if (this.closed) return; + this.state = { ...this.state, ...value }; + for (const listener of this.listeners) listener(); + } + private request(value: Request) { + const object = value._camino.ref.object, + old = this.requests.get(object); + const progress = + old && + ((terminal.has(old.progress.status) && !terminal.has(value.progress.status)) || + value.progress.generation < old.progress.generation) + ? old.progress + : value.progress; + this.requests.set(object, { ...old, ...value, progress }); + } + private item(value: Item) { + if (value.data.kind === "user") + value = { + ...value, + request: { + ...value.request, + admission: { + createdAt: value.data.createdAt, + sourceKind: "human", + sourceLabel: "User", + ...value.request.admission, + text: value.data.text, + }, + }, + }; + const replacement = this.aliases.get(value.request._camino.ref.object); + if (replacement) value = { ...value, request: { ...value.request, _camino: { ref: replacement } } }; + this.request(value.request); + const key = itemKey(value), + previous = this.items.get(key); + if (!previous || BigInt(value.data.version) >= BigInt(previous.data.version)) + this.items.set(key, { ...previous, ...value }); + } + private publish() { + const items = [...this.items.values()].map((item) => ({ + ...item, + request: this.requests.get(item.request._camino.ref.object) ?? item.request, + })); + items.sort((a, b) => { + const ar = a.request.admission, + br = b.request.admission; + if (a.request._camino.ref.object !== b.request._camino.ref.object) { + if (ar?.sequence && br?.sequence && ar.sequence !== "0" && br.sequence !== "0") + return BigInt(ar.sequence) < BigInt(br.sequence) ? -1 : 1; + return (ar?.createdAt ?? a.data.createdAt).localeCompare(br?.createdAt ?? b.data.createdAt); + } + if (a.data.key === "input") return -1; + if (b.data.key === "input") return 1; + if (a.data.sequence !== "0" && b.data.sequence !== "0") + return BigInt(a.data.sequence) < BigInt(b.data.sequence) ? -1 : 1; + return a.data.createdAt.localeCompare(b.data.createdAt); + }); + this.patch({ items, requests: [...this.requests.values()] }); + } + private ingest(value: any, older = false) { + for (const repair of value.repair ?? []) { + if (repair.graphRequest) { + this.aliases.set(repair.request.object, repair.graphRequest); + const previous = this.requests.get(repair.request.object); + if (previous) { + this.requests.delete(repair.request.object); + this.request({ ...previous, _camino: { ref: repair.graphRequest }, pendingGraph: false }); + } + for (const [key, item] of this.items) + if (item.request._camino.ref.object === repair.request.object) { + this.items.delete(key); + this.item({ + ...item, + request: { ...item.request, _camino: { ref: repair.graphRequest }, pendingGraph: false }, + }); + } + } else { + const request: Request = { + _camino: { ref: repair.request }, + repair: repair.key, + pendingGraph: true, + admission: { + text: repair.text, + createdAt: repair.createdAt, + sourceKind: "human", + sourceLabel: "Source repair", + }, + progress: { + status: "pending_graph", + attempt: "", + generation: 0, + reason: "Awaiting graph recording", + cancelRequested: false, + }, + }; + this.item({ + request, + live: true, + data: { + key: "input", + kind: "user", + text: repair.text, + phase: "input", + activity: null, + presentation: null, + sequence: "0", + version: "0", + createdAt: repair.createdAt, + updatedAt: repair.createdAt, + }, + }); + } + } + const root: Thread | undefined = value.data?.root; + if (root) { + if (root.active) this.request(root.active); + for (const row of root.requests?.entries ?? []) this.request(row.node); + for (const row of root.items?.entries ?? []) this.item({ ...row.node, live: false }); + this.patch({ + thread: root, + pageInfo: older ? root.items?.pageInfo : (root.items?.pageInfo ?? this.state.pageInfo), + }); + } + for (const row of value.requests ?? []) { + const request: Request = { + _camino: { ref: row.request }, + admission: row.admission, + progress: { + ...row.progress, + status: row.phase === "projected" ? row.progress.status : terminal.has(row.phase) ? "recording" : row.phase, + attempt: row.attempt ?? row.progress?.attempt ?? "", + generation: row.generation ?? row.progress?.generation ?? 0, + reason: row.reason ?? "", + cancelRequested: row.cancelRequested ?? false, + }, + pendingGraph: row.pendingGraph, + repair: row.repair, + }; + this.request(request); + if (request.admission?.text && !this.items.has(`${request._camino.ref.object}/input`)) + this.item({ + request, + live: true, + data: { + key: "input", + kind: "user", + text: request.admission.text, + phase: "input", + activity: null, + presentation: null, + sequence: "0", + version: "0", + createdAt: request.admission.createdAt, + updatedAt: request.admission.createdAt, + }, + }); + } + for (const live of value.live ?? []) { + const replacement = this.aliases.get(live.request.object) ?? live.request; + const request = this.requests.get(replacement.object) ?? { + _camino: { ref: replacement }, + progress: { + status: "running", + attempt: live.attempt, + generation: live.generation, + reason: "", + cancelRequested: false, + }, + }; + if ( + !this.items.has(`${replacement.object}/${live.data.key}`) || + BigInt(live.data.version) > BigInt(this.items.get(`${replacement.object}/${live.data.key}`)!.data.version) + ) + this.item({ request, data: live.data, live: true }); + } + this.patch({ + maintenance: value.maintenance === true, + loading: false, + error: "", + ...(!older && value.cursor ? { cursor: value.cursor } : {}), + }); + this.publish(); + } + refresh = (older = false): Promise => { + if (!older && this.refreshing) return this.refreshing; + const after = older ? this.state.pageInfo?.endCursor : null; + const task = api( + `/thread?thread=${encodeURIComponent(this.thread)}&first=30${after ? `&after=${encodeURIComponent(after)}` : ""}`, + undefined, + this.abort.signal, + ) + .then((value) => this.ingest(value, older)) + .catch((error) => { + if (!this.closed) this.patch({ error: String(error), loading: false }); + throw error; + }) + .finally(() => { + if (!older) this.refreshing = undefined; + }); + if (!older) this.refreshing = task; + return task; + }; + private scheduleRefresh() { + this.refreshNeeded = true; + if (this.closed || this.timer) return; + this.timer = setTimeout(() => { + this.timer = undefined; + this.refreshNeeded = false; + void (async () => { + // A notification arriving during a previous snapshot needs a subsequent + // read, not merely the same in-flight promise. + await this.refreshing?.catch(() => {}); + await this.refresh().catch(() => {}); + if (this.refreshNeeded) this.scheduleRefresh(); + })(); + }, 100); + } + private async stream() { + while (!this.closed) { + try { + const cursor = this.state.cursor; + const response = await fetch( + `/api/agent/events?thread=${encodeURIComponent(this.thread)}&after=${cursor?.sequence ?? 0}${cursor?.epoch ? `&epoch=${encodeURIComponent(cursor.epoch)}` : ""}`, + { credentials: "same-origin", signal: this.abort.signal }, + ); + if (!response.ok || !response.body) throw Error("Live Agent stream unavailable"); + this.patch({ connected: true }); + this.scheduleRefresh(); + const reader = response.body.getReader(), + decoder = new TextDecoder(); + let buffer = ""; + try { + while (!this.closed) { + const chunk = await reader.read(); + if (chunk.done) break; + buffer += decoder.decode(chunk.value, { stream: true }); + if (buffer.length > 1024 * 1024) throw Error("Agent stream frame exceeds limit"); + let end: number; + while ((end = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, end); + buffer = buffer.slice(end + 1); + if (!line) continue; + const event = JSON.parse(line); + if (event.type === "graph") { + this.scheduleRefresh(); + continue; + } + if (event.type === "reset") { + await this.refresh(); + break; + } + if (event.sequence) this.patch({ cursor: { epoch: event.epoch, sequence: event.sequence } }); + const ref: Ref | undefined = event.request; + if (event.kind === "item" && ref) { + const request = this.requests.get(ref.object) ?? { + _camino: { ref }, + progress: { + status: "running", + attempt: event.attempt, + generation: event.generation, + reason: "", + cancelRequested: false, + }, + }; + this.item({ request, data: event.item, live: true }); + this.publish(); + } else if (ref && ["request", "execution"].includes(event.kind)) { + const previous = this.requests.get(ref.object); + if (previous) + this.request({ + ...previous, + progress: { + ...previous.progress, + status: event.kind === "execution" && terminal.has(event.status) ? "recording" : event.status, + attempt: event.attempt, + generation: event.generation, + ...(event.kind === "request" ? { result: event.result, reason: event.reason ?? "" } : {}), + }, + }); + this.publish(); + void this.refresh().catch(() => {}); + } else if (event.kind === "reconciled") { + await this.refresh(); + } + } + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + this.patch({ connected: false }); + } + } catch (error) { + if (!this.closed) this.patch({ connected: false }); + } + if (!this.closed) await new Promise((resolve) => setTimeout(resolve, 750)); + } + } + submit = async ( + text: string, + options: { profile?: string; response?: unknown; repair?: boolean; retryOf?: string } = {}, + ) => { + const key = `${draftKey(this.thread)}:pending`, + previous = sessionStorage.getItem(key), + input = previous + ? JSON.parse(previous) + : { + operationId: crypto.randomUUID(), + createdAt: new Date().toISOString(), + thread: this.thread, + text, + ...options, + }; + if (previous && input.text !== text) + throw Error("Recover the pending submission before sending a different message"); + sessionStorage.setItem(key, JSON.stringify(input)); + let value; + try { + value = await api(input.repair ? "/repair" : "/submit", input, this.abort.signal); + } catch (error) { + if ((error as { knownRejected?: boolean }).knownRejected) sessionStorage.removeItem(key); + throw error; + } + sessionStorage.removeItem(key); + if (value.repair) this.ingest({ repair: [value.repair] }); + else if (value.admission.accepted && value.admission.request) { + const request: Request = { + _camino: { ref: value.admission.request }, + admission: { text, createdAt: input.createdAt, sourceKind: "human", sourceLabel: "User" }, + progress: { status: "queued", attempt: "", generation: 0, reason: "", cancelRequested: false }, + }; + this.item({ + request, + data: { + key: "input", + kind: "user", + text, + phase: "input", + activity: null, + presentation: null, + sequence: "0", + version: "0", + createdAt: input.createdAt, + updatedAt: input.createdAt, + }, + }); + this.publish(); + } + await this.refresh().catch(() => {}); + }; + control = async (input: Record) => { + await control(input); + await this.refresh(); + }; + details = (request: string) => api(`/request?request=${encodeURIComponent(request)}`); + close = () => { + this.closed = true; + this.abort.abort(); + clearTimeout(this.timer); + this.listeners.clear(); + }; +} diff --git a/src/protocol.ts b/src/protocol.ts new file mode 100644 index 0000000..745529c --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,127 @@ +import type { ExactReference } from "@quixos/camino-react"; +export type Ref = ExactReference; +export type PageInfo = { hasNextPage: boolean; endCursor: string | null }; +export type Profile = { + revision: string; + modelProvider?: string; + available?: boolean; + model?: string | null; + effort?: string | null; + serviceTier?: string; + description?: string; + power?: number; + speed?: number; + cost?: number; + fastProfile?: string; + standardProfile?: string; +}; +export type Profiles = Record; +export type Connection = { entries: { node: T }[]; pageInfo: PageInfo }; +export type NodeRef = { _camino: { ref: Ref } }; +export type Progress = { + status: string; + attempt: string; + generation: number; + reason: string; + result?: string | null; + rawOutput?: string | null; + cancelRequested: boolean; + notificationPending?: boolean; + startedAt?: string | null; + endedAt?: string | null; + usage?: string | null; +}; +export type Question = NodeRef & { + data: { + key: string; + title: string; + options: string; + status: string; + answer: string | null; + attempt: string; + generation: number; + expiresAt: string; + }; +}; +export type Request = NodeRef & { + progress: Progress; + admission?: { + text?: string; + sequence?: string; + createdAt: string; + sourceKind: string; + sourceLabel: string; + responseKind?: string; + configuration?: string; + configurationDigest?: string; + }; + questions?: Connection; + repair?: string; + pendingGraph?: boolean; +}; +export type ItemData = { + key: string; + kind: string; + text: string; + phase: string; + activity: string | null; + presentation: string | null; + sequence: string; + version: string; + createdAt: string; + updatedAt: string; + control?: { attempt: string; generation: number; status: string; reason: string } | null; +}; +export type Item = { + _camino?: { ref: Ref }; + data: ItemData; + request: Request; + subject?: NodeRef | null; + presentationMount?: NodeRef | null; + live?: boolean; +}; +export type Agent = NodeRef & { + data: { name: string; description: string; enabled: boolean; settings: string }; + instructions: string; +}; +export type Thread = NodeRef & { + data: { title: string; kind: string; archived: boolean; pending: number; updatedAt?: string }; + agent: NodeRef & { data: { name: string } }; + active?: Request | null; + requests?: Connection; + items?: Connection; +}; +export type Cursor = { epoch: string; sequence: number }; +export type ThreadState = { + thread?: Thread; + items: Item[]; + requests: Request[]; + cursor?: Cursor; + pageInfo?: PageInfo; + maintenance: boolean; + connected: boolean; + loading: boolean; + error: string; +}; +export type Status = { + account: { available: boolean; label?: string; plan?: string; error?: string }; + providers?: { cerebras?: { available: boolean } }; + graphReady: boolean; + login?: { verificationUrl: string; userCode: string }; + error?: string | null; + seed?: { directory: Ref; agent: Ref; thread: Ref; data: Agent["data"]; instructions: string; confirmedAt: string }; +}; +export const terminal = new Set(["completed", "failed", "cancelled", "uncertain"]); +export type ThreadController = { + getSnapshot(): ThreadState; + subscribe(callback: () => void): () => void; + submit( + text: string, + options?: { profile?: string; response?: unknown; repair?: boolean; retryOf?: string }, + ): Promise; + control(input: Record): Promise; + refresh(older?: boolean): Promise; + details(request: string): Promise; + close(): void; +}; +export const itemKey = (item: Item) => `${item.request._camino.ref.object}/${item.data.key}`; diff --git a/src/shared.tsx b/src/shared.tsx new file mode 100644 index 0000000..7ec3ed6 --- /dev/null +++ b/src/shared.tsx @@ -0,0 +1,606 @@ +import React, { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { + type Item, + type Request, + type Question, + type ThreadController, + type Profiles, + itemKey, + terminal, +} from "./protocol.js"; +import { LocalInput, LocalTextArea, LocalSelect, Markdown } from "@quixos/camino-react/ui"; +import { draftKey } from "./http.js"; + +const labels: Record = { + queued: "Queued", + blocked: "Waiting to start", + running: "Working", + waiting_input: "Needs your answer", + recovering: "Checking execution status", + recording: "Recording result", + pending_graph: "Awaiting graph recording", + awaiting_graph: "Awaiting graph recording", + completed: "Completed", + failed: "Failed", + cancelled: "Cancelled", + uncertain: "Outcome uncertain", + parked: "Needs your answer", +}; +function ResultValue({ value }: { value: unknown }) { + if (Array.isArray(value)) + return ( +
    + {value.map((entry, index) => ( +
  1. + +
  2. + ))} +
+ ); + if (value && typeof value === "object") + return ( +
+ {Object.entries(value).map(([key, entry]) => ( + +
{key}
+
+ +
+
+ ))} +
+ ); + return {value === null ? "null" : String(value)}; +} +export function StructuredResult({ value }: { value: string }) { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return
{value}
; + } + return ( +
+ +
+ Raw JSON +
{value}
+ +
+
+ ); +} +function QuestionForm({ + question, + request, + controller, +}: { + question: Question; + request: Request; + controller: ThreadController; +}) { + const [answer, setAnswer] = useState(""), + [busy, setBusy] = useState(false), + [error, setError] = useState(""); + let options: string[] = []; + try { + options = JSON.parse(question.data.options); + } catch {} + const open = + question.data.status === "open" && + !terminal.has(request.progress.status) && + !request.progress.cancelRequested && + Date.parse(question.data.expiresAt) > Date.now(); + return ( +
{ + event.preventDefault(); + if (!open || !answer.trim() || busy) return; + setBusy(true); + setError(""); + void controller + .control({ + kind: "answer", + question: question._camino.ref.object, + attempt: question.data.attempt, + generation: question.data.generation, + answer, + }) + .catch((error) => setError(String(error))) + .finally(() => setBusy(false)); + }} + > +
+ {question.data.title} + {options.map((option) => ( + + ))} + + +
+ {!open && ( +

+ {question.data.answer + ? `Answered: ${question.data.answer}` + : question.data.status === "open" + ? "This question is no longer accepting answers." + : question.data.status} +

+ )} + {error &&

{error}

} +
+ ); +} +function Activity({ item }: { item: Item }) { + let value: any; + try { + value = JSON.parse(item.data.activity ?? "null"); + } catch {} + const title = value?.command ?? value?.label ?? item.data.text ?? "Activity"; + return ( +
+ {String(title).slice(0, 300)} +
{value?.aggregatedOutput ?? value?.output ?? value?.detail ?? item.data.text}
+ {value?.exitCode !== undefined &&

Exit code: {value.exitCode}

} +
+ ); +} +function RequestCard({ + request, + controller, + inspection, +}: { + request: Request; + controller: ThreadController; + inspection: boolean; +}) { + const [busy, setBusy] = useState(false), + [error, setError] = useState(""), + [details, setDetails] = useState(); + const p = request.progress, + cancel = () => { + setBusy(true); + setError(""); + const action = + request.pendingGraph && request.repair + ? import("./http.js").then(({ api }) => + api("/repair-control", { + operationId: crypto.randomUUID(), + repair: request.repair, + attempt: p.attempt, + generation: p.generation, + kind: "cancel", + }), + ) + : controller.control({ kind: "cancel", request: request._camino.ref.object }); + void action.catch((error) => setError(String(error))).finally(() => setBusy(false)); + }; + return ( +
+
+ {(inspection || p.status !== "completed") && {labels[p.status] ?? p.status}} + {request.pendingGraph && · Awaiting graph recording} + {p.cancelRequested && !terminal.has(p.status) && · Stop requested} + {!terminal.has(p.status) && ( + + )} + {!request.pendingGraph && (inspection || p.status !== "completed") && ( + + )} +
+ {p.reason &&

{p.reason}

} + {p.status === "uncertain" &&

Execution may have occurred. Check activity before starting another request.

} + {p.status === "completed" && request.admission?.responseKind === "json" && p.result && ( + + )} + {p.status === "completed" && request.admission?.responseKind === "unit" &&

Actions completed.

} + {["failed", "cancelled", "uncertain"].includes(p.status) && request.admission?.text && ( + + )} + {p.status === "failed" && p.rawOutput && ( +
+ Returned output +
{p.rawOutput}
+
+ )} + {details !== undefined && ( +
+ Captured request and execution +
{JSON.stringify(details, null, 2)}
+
+ )} + {error &&

{error}

} +
+ ); +} +export function ThreadView({ + controller, + thread, + profiles = {}, + workspace = false, + defaultProfile = "balanced", + allowRepair = false, + renderWidget, + renderItem, +}: { + controller: ThreadController; + thread: string; + profiles?: Profiles; + workspace?: boolean; + defaultProfile?: string; + allowRepair?: boolean; + renderWidget?(item: Item): React.ReactNode; + /** Return undefined for normal rendering; null hides an item. */ + renderItem?(item: Item, request: Request): React.ReactNode | undefined; +}) { + const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot), + [draft, setDraft] = useState(() => sessionStorage.getItem(draftKey(thread)) ?? ""), + [busy, setBusy] = useState(false), + [error, setError] = useState(""), + [profile, setProfile] = useState(() => sessionStorage.getItem(`${draftKey(thread)}:profile`) ?? ""), + [response, setResponse] = useState(""), + [repair, setRepair] = useState(false), + [latest, setLatest] = useState(true), + [now, setNow] = useState(Date.now()), + scroll = useRef(null), + previousSize = useRef(0); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + useEffect(() => { + sessionStorage.setItem(draftKey(thread), draft); + }, [thread, draft]); + useEffect(() => { + if (latest && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; + }, [state.items, latest]); + const active = + (state.thread?.active && + state.requests.find( + (request) => + request._camino.ref.object === state.thread!.active!._camino.ref.object && + !terminal.has(request.progress.status), + )) ?? + state.requests.find((request) => + ["running", "waiting_input", "stopping", "parked"].includes(request.progress.status), + ), + pending = state.requests.filter((request) => !terminal.has(request.progress.status)), + archived = state.thread?.data.archived; + const run = async (steer = false) => { + if (!draft.trim() || busy) return; + setBusy(true); + setError(""); + const text = draft; + try { + if (steer && active) + await controller.control({ + kind: "steer", + request: active._camino.ref.object, + attempt: active.progress.attempt, + generation: active.progress.generation, + text, + }); + else + await controller.submit(text, { + ...(profile ? { profile } : {}), + ...(response ? { response: { kind: response } } : {}), + repair: state.maintenance && repair, + }); + setDraft((current) => (current === text ? "" : current)); + } catch (error) { + setError(String(error)); + } finally { + setBusy(false); + } + }; + const selected = profile || defaultProfile; + const route = profiles[selected]; + const standard = route?.standardProfile ?? selected; + const changeProfile = async (next: string) => { + setProfile(next); + sessionStorage.setItem(`${draftKey(thread)}:profile`, next); + if (active?.progress.status !== "running" || state.maintenance) return; + setBusy(true); + setError(""); + try { + await controller.control({ + kind: "steer", + request: active._camino.ref.object, + attempt: active.progress.attempt, + generation: active.progress.generation, + text: "", + profile: next, + }); + } catch (error) { + setError(String(error)); + } finally { + setBusy(false); + } + }; + const grouped = new Map(); + for (const item of state.items) { + const key = item.request._camino.ref.object, + group = grouped.get(key) ?? { request: item.request, items: [] }; + group.items.push(item); + group.request = item.request; + grouped.set(key, group); + } + for (const request of pending) + if (!grouped.has(request._camino.ref.object)) grouped.set(request._camino.ref.object, { request, items: [] }); + return ( +
+
+ {state.loading + ? "Loading thread…" + : state.maintenance + ? "Workspace graph unavailable. Known activity remains visible." + : state.connected + ? "Connected" + : "Reconnecting…"} + {active?.progress.startedAt && ( + · Working for {Math.max(0, Math.floor((now - Date.parse(active.progress.startedAt)) / 1000))}s + )} +
+
{ + const element = scroll.current!; + setLatest(element.scrollHeight - element.scrollTop - element.clientHeight < 80); + }} + > + {state.pageInfo?.hasNextPage && ( + + )} + {grouped.size === 0 && !state.loading && ( +
+

A new conversation

+

Send a message or a task to this Agent.

+
+ )} + {[...grouped.values()].map(({ request, items }) => ( +
+
+ {request.admission?.sourceLabel ?? "Agent request"} ·{" "} + {request.admission?.createdAt && ( + + )} +
+ {items.map((item) => { + const custom = renderItem?.(item, request); + return ( + + {custom !== undefined ? ( + custom + ) : item.data.kind === "steering" && item.data.activity ? ( + + ) : item.data.kind === "activity" ? ( + + ) : item.data.kind === "widget" ? ( +
+ {renderWidget ? ( + renderWidget(item) + ) : ( +

+ Widget unavailable in this view.{" "} + + Open object + +

+ )} +
+ ) : item.data.kind === "status" ? ( + + ) : item.data.phase === "commentary" ? ( +
+ Agent commentary + +
+ ) : request.admission?.responseKind === "json" && item.data.kind === "assistant" ? null : ( +
+ {item.data.kind === "assistant" ? : item.data.text} +
+ )} +
+ ); + })} + {(request.questions?.entries ?? []).map(({ node }) => ( + + ))} + +
+ ))} +
+ {!latest && ( + + )} +
{ + event.preventDefault(); + void run(); + }} + > + {state.maintenance && allowRepair && ( + + )} + +
+ {workspace && Object.keys(profiles).length > 0 && ( +
+ + + {active?.progress.status === "running" && Changes apply between inference steps.} +
+ )} +
+ Next request options + + +
+ {pending.length ? `${pending.length} outstanding request${pending.length === 1 ? "" : "s"}` : ""} + {active?.progress.status === "running" && !state.maintenance && ( + + )} + +
+ {archived &&

This thread is archived. Reopen it to send a message.

} + {(error || state.error) && ( +

+ {error || state.error}{" "} + +

+ )} +
+
+ ); +} diff --git a/src/thread.css b/src/thread.css new file mode 100644 index 0000000..0d1761d --- /dev/null +++ b/src/thread.css @@ -0,0 +1,303 @@ +.agent-thread { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + position: relative; + font: + 14px/1.6 system-ui, + sans-serif; + color: var(--ink, #314034); +} +.agent-thread-status { + padding: 7px 24px; + color: #65728a; + font-size: 12px; + border-bottom: 1px solid #edf0f5; +} +.agent-thread-history { + overflow: auto; + flex: 1; + min-height: 100px; + padding: 20px max(24px, calc((100% - 820px) / 2)); +} +.agent-request { + margin: 0 0 30px; + min-width: 0; +} +.agent-request-source { + font-size: 11px; + color: #748098; + margin: 0 0 10px; +} +.agent-message { + white-space: pre-wrap; + overflow-wrap: anywhere; + margin: 10px 0; +} +.agent-message-user { + background: #edf1fa; + border-radius: 9px; + padding: 12px 16px; +} +.agent-message-assistant { + padding: 6px 2px; +} +.agent-request-card { + padding: 8px 2px; + color: #65728a; + font-size: 12px; +} +.agent-request-card > div:first-child { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} +.agent-request-card button { + font-size: 12px; +} +.agent-request-card p { + margin: 5px 0; +} +.agent-request-failed, +.agent-request-uncertain { + border-left: 3px solid #c77528; + padding-left: 12px; +} +.agent-thread pre { + max-height: 320px; + overflow: auto; + font: + 12px/1.6 ui-monospace, + monospace; + padding: 12px; + background: #f4f6fa; + border-radius: 5px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.agent-activity-card, +.agent-commentary { + border: 1px solid #e0e5ee; + border-radius: 7px; + padding: 8px 12px; + margin: 10px 0; + font-size: 12px; + overflow-wrap: anywhere; +} +.agent-thread summary { + cursor: pointer; +} +.agent-widget { + margin: 14px 0; + padding: 14px; + border: 1px solid #dae0e8; + border-radius: 8px; + min-width: 0; + overflow: auto; +} +.agent-question { + background: #fff8e9; + padding: 16px; + border: 1px solid #e8d5a6; + border-radius: 8px; + margin: 16px 0; +} +.agent-question fieldset { + display: flex; + flex-direction: column; + gap: 10px; + border: 0; + padding: 0; +} +.agent-question legend { + font-weight: 600; + margin-bottom: 10px; +} +.agent-question input { + margin: 0 7px; +} +.agent-result { + font-size: 14px; + color: #253044; + padding: 10px 0; +} +.agent-result dl { + display: grid; + grid-template-columns: minmax(70px, 120px) minmax(0, 1fr); + gap: 8px 16px; + margin: 0 0 12px; +} +.agent-result dt { + color: #65728a; +} +.agent-result dd { + margin: 0; + overflow-wrap: anywhere; +} +.agent-status-card { + border-left: 3px solid #7894cc; + background: #f2f5fb; + padding: 12px; + margin: 12px 0; +} +.agent-thread-composer { + padding: 12px 24px 18px; + border-top: 1px solid #dae0e8; + background: #fff; +} +.agent-compose-label { + display: block; + font-size: 12px; + color: #65728a; +} +.agent-compose-label textarea { + width: 100%; + box-sizing: border-box; + display: block; + font: + 14px/1.5 system-ui, + sans-serif; + padding: 10px; + margin-top: 4px; + border: 1px solid #cbd3df; + border-radius: 7px; + min-height: 78px; + max-height: 240px; +} +.agent-compose-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-top: 10px; +} +.agent-compose-actions > span { + margin-left: auto; + color: #748098; + font-size: 11px; +} +.agent-compose-actions details { + font-size: 12px; + position: relative; +} +.agent-compose-actions details label { + display: flex; + gap: 8px; + margin: 7px 0; +} +.agent-jump { + position: absolute; + bottom: 180px; + align-self: center; + box-shadow: 0 2px 10px #23304722; +} +.agent-empty { + padding: 40px 0; + color: #65728a; + text-align: center; +} +.agent-empty h2 { + font-weight: 500; +} +@media (max-width: 700px) { + .agent-shell { + height: 100dvh; + } + .agent-shell-header { + padding: 9px 12px; + gap: 12px; + } + .agent-account > span { + display: none; + } + .agent-shell-body { + flex-direction: column; + } + .agent-sidebar { + width: auto; + max-height: 150px; + padding: 10px 12px; + border-right: 0; + border-bottom: 1px solid #dae0e8; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + } + .agent-sidebar > label { + flex: 1; + min-width: 100px; + } + .agent-sidebar nav { + flex-direction: row; + width: 100%; + flex: 1 0 100%; + } + .agent-sidebar nav a { + min-width: 140px; + padding: 6px 10px; + } + .agent-directory { + width: 100%; + } + .agent-thread-header { + padding: 10px 12px; + } + .agent-thread-header h1 { + font-size: 16px; + } + .agent-thread-header button { + font-size: 12px; + } + .agent-thread-status { + padding: 5px 12px; + } + .agent-thread-history { + padding: 12px; + } + .agent-thread-composer { + padding: 10px 12px; + } + .agent-compose-actions { + gap: 8px; + } + .agent-compose-actions > span { + display: none; + } + .agent-settings-page { + padding: 16px; + } + .agent-settings-columns { + grid-template-columns: 1fr; + } +} +.agent-model-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} +.agent-model-controls label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; +} +.agent-model-controls small { + flex-basis: 100%; + color: #73806f; +} +.agent-routing-change { + border-left: 2px solid #9cae8c; + padding: 6px 12px; + font-size: 12px; + color: #65745d; +} +.agent-thread .agent-result :is(ol, dl) { + margin: 4px 0; +} +.agent-result dd { + min-width: 0; + overflow-wrap: anywhere; +} diff --git a/src/thread.tsx b/src/thread.tsx new file mode 100644 index 0000000..8f20605 --- /dev/null +++ b/src/thread.tsx @@ -0,0 +1,23 @@ +import { useEffect, useMemo } from "react"; +import { AgentWidget } from "./widget.js"; +import type { Modules } from "./gen/client.js"; +import { ThreadView } from "./shared.js"; +import { HttpThreadController } from "./http.js"; +import type { Thread as ThreadData } from "./protocol.js"; +import "./thread.css"; +export const Thread: Modules["Thread"] = ({ subject, input }) => { + const controller = useMemo( + () => + new HttpThreadController( + subject.object, + JSON.parse( + JSON.stringify(input.body.root, (_key, value) => (typeof value === "bigint" ? value.toString() : value)), + ) as ThreadData, + ), + [subject.object], + ); + useEffect(() => () => controller.close(), [controller]); + return ( + } /> + ); +}; diff --git a/src/widget.tsx b/src/widget.tsx new file mode 100644 index 0000000..24a2f74 --- /dev/null +++ b/src/widget.tsx @@ -0,0 +1,25 @@ +import { LoadPresentation, useCaminoClient } from "@quixos/camino-react"; +import type { Item } from "./protocol.js"; + +export function AgentWidget({ item }: { item: Item }) { + const client = useCaminoClient(); + const record = (value: unknown): Record | undefined => + value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; + const selected = client.catalog.presentations.map(record).find((value) => value?.id === item.data.presentation); + const subject = item.subject?._camino.ref, + mount = item.presentationMount?._camino.ref; + if (!subject || !mount || typeof selected?.packageRevision !== "string" || typeof selected.component !== "string") + return ( +

+ This widget is unavailable.{" "} + {subject && Open object} +

+ ); + return ( + + ); +}