Export template source from be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9:quixos-instance/packages/agent-presentation
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"monorepoCommit": "be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9",
|
||||
"sourcePath": "quixos-instance/packages/agent-presentation"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component:org.quixos.agent:thread" = {
|
||||
entry = "src/thread.tsx";
|
||||
export = "Thread";
|
||||
};
|
||||
}
|
||||
Generated
+108
@@ -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": 1790852599,
|
||||
"narHash": "sha256-MdNtZpcsoguLPSCpAy9Naz6fkVPrftbZQbz1Aj7Zm9A=",
|
||||
"ref": "refs/tags/quixos-reachability/91679e8cc82133093cb0b4f9e519cbb39a5bba4f",
|
||||
"rev": "91679e8cc82133093cb0b4f9e519cbb39a5bba4f",
|
||||
"revCount": 54,
|
||||
"type": "git",
|
||||
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-nix-helpers.git"
|
||||
},
|
||||
"original": {
|
||||
"ref": "refs/tags/quixos-reachability/91679e8cc82133093cb0b4f9e519cbb39a5bba4f",
|
||||
"rev": "91679e8cc82133093cb0b4f9e519cbb39a5bba4f",
|
||||
"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": 1791261504,
|
||||
"narHash": "sha256-QSk9DWR7n5KL6cMHzmSKKXNIyaFA0s8Nu+wXjhhlENI=",
|
||||
"ref": "refs/tags/quixos-reachability/5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87",
|
||||
"rev": "5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87",
|
||||
"revCount": 184,
|
||||
"type": "git",
|
||||
"url": "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git"
|
||||
},
|
||||
"original": {
|
||||
"ref": "refs/tags/quixos-reachability/5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87",
|
||||
"rev": "5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87",
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
inputs.protocol.url = "git+https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos-protocol.git?ref=refs/tags/quixos-reachability/5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87&rev=5b44c8154dc038efd87cf1cbdd2c00b66bfd4f87";
|
||||
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/91679e8cc82133093cb0b4f9e519cbb39a5bba4f&rev=91679e8cc82133093cb0b4f9e519cbb39a5bba4f";
|
||||
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; };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+17
@@ -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<interface AgentThread>.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;}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
query Settings {root {_camino {ref} data {name description enabled settings} instructions}}
|
||||
@@ -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}} } }
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"backend": "execution",
|
||||
"queryFrontends": ["react"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
quixos-lock version 1 {
|
||||
quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9"; }
|
||||
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"; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"modules": {
|
||||
"Thread": {
|
||||
"inputs": [
|
||||
{
|
||||
"name": "body",
|
||||
"query": "View",
|
||||
"root": {
|
||||
"from": "input",
|
||||
"path": ["subject"]
|
||||
},
|
||||
"arguments": {
|
||||
"record": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
import {
|
||||
type Item,
|
||||
type Request,
|
||||
type Ref,
|
||||
type ThreadState,
|
||||
type ThreadController,
|
||||
type Thread,
|
||||
type Cursor,
|
||||
itemKey,
|
||||
terminal,
|
||||
} from "./protocol.js";
|
||||
|
||||
export async function api<T = any>(path: string, input?: unknown, signal?: AbortSignal): Promise<T> {
|
||||
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<string, unknown>) => {
|
||||
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}`;
|
||||
|
||||
/** 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<string, Item>();
|
||||
private requests = new Map<string, Request>();
|
||||
private aliases = new Map<string, Ref>();
|
||||
private abort = new AbortController();
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
private refreshing?: Promise<void>;
|
||||
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());
|
||||
this.timer = setInterval(() => {
|
||||
void this.refresh().catch(() => {});
|
||||
}, 2000);
|
||||
}
|
||||
getSnapshot = () => this.state;
|
||||
subscribe = (callback: () => void) => {
|
||||
this.listeners.add(callback);
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
};
|
||||
private patch(value: Partial<ThreadState>) {
|
||||
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<void> => {
|
||||
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 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 });
|
||||
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 === "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<string, unknown>) => {
|
||||
await control(input);
|
||||
await this.refresh();
|
||||
};
|
||||
details = (request: string) => api(`/request?request=${encodeURIComponent(request)}`);
|
||||
close = () => {
|
||||
this.closed = true;
|
||||
this.abort.abort();
|
||||
clearInterval(this.timer);
|
||||
this.listeners.clear();
|
||||
};
|
||||
}
|
||||
+127
@@ -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<string, Profile>;
|
||||
export type Connection<T> = { 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<Question>;
|
||||
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<Request>;
|
||||
items?: Connection<Item>;
|
||||
};
|
||||
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<void>;
|
||||
control(input: Record<string, unknown>): Promise<void>;
|
||||
refresh(older?: boolean): Promise<void>;
|
||||
details(request: string): Promise<unknown>;
|
||||
close(): void;
|
||||
};
|
||||
export const itemKey = (item: Item) => `${item.request._camino.ref.object}/${item.data.key}`;
|
||||
+606
@@ -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<string, string> = {
|
||||
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 (
|
||||
<ol>
|
||||
{value.map((entry, index) => (
|
||||
<li key={index}>
|
||||
<ResultValue value={entry} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
if (value && typeof value === "object")
|
||||
return (
|
||||
<dl>
|
||||
{Object.entries(value).map(([key, entry]) => (
|
||||
<React.Fragment key={key}>
|
||||
<dt>{key}</dt>
|
||||
<dd>
|
||||
<ResultValue value={entry} />
|
||||
</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
return <span>{value === null ? "null" : String(value)}</span>;
|
||||
}
|
||||
export function StructuredResult({ value }: { value: string }) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return <pre>{value}</pre>;
|
||||
}
|
||||
return (
|
||||
<div className="agent-result" aria-label="Structured result">
|
||||
<ResultValue value={parsed} />
|
||||
<details>
|
||||
<summary>Raw JSON</summary>
|
||||
<pre>{value}</pre>
|
||||
<button onClick={() => void navigator.clipboard.writeText(value)}>Copy JSON</button>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<form
|
||||
className="agent-question"
|
||||
onSubmit={(event) => {
|
||||
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));
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={!open || busy}>
|
||||
<legend>{question.data.title}</legend>
|
||||
{options.map((option) => (
|
||||
<label key={option}>
|
||||
<LocalInput
|
||||
type="radio"
|
||||
name={question._camino.ref.object}
|
||||
value={option}
|
||||
checked={answer === option}
|
||||
onChange={() => setAnswer(option)}
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
<label>
|
||||
Your answer
|
||||
<LocalInput aria-label="Your answer" value={answer} onChange={(event) => setAnswer(event.target.value)} />
|
||||
</label>
|
||||
<button disabled={!answer.trim()}>{busy ? "Sending answer…" : "Answer"}</button>
|
||||
</fieldset>
|
||||
{!open && (
|
||||
<p>
|
||||
{question.data.answer
|
||||
? `Answered: ${question.data.answer}`
|
||||
: question.data.status === "open"
|
||||
? "This question is no longer accepting answers."
|
||||
: question.data.status}
|
||||
</p>
|
||||
)}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<details className="agent-activity-card">
|
||||
<summary>{String(title).slice(0, 300)}</summary>
|
||||
<pre>{value?.aggregatedOutput ?? value?.output ?? value?.detail ?? item.data.text}</pre>
|
||||
{value?.exitCode !== undefined && <p>Exit code: {value.exitCode}</p>}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
function RequestCard({
|
||||
request,
|
||||
controller,
|
||||
inspection,
|
||||
}: {
|
||||
request: Request;
|
||||
controller: ThreadController;
|
||||
inspection: boolean;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false),
|
||||
[error, setError] = useState(""),
|
||||
[details, setDetails] = useState<unknown>();
|
||||
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 (
|
||||
<footer className={`agent-request-card agent-request-${p.status}`} data-request={request._camino.ref.object}>
|
||||
<div>
|
||||
{(inspection || p.status !== "completed") && <strong>{labels[p.status] ?? p.status}</strong>}
|
||||
{request.pendingGraph && <span> · Awaiting graph recording</span>}
|
||||
{p.cancelRequested && !terminal.has(p.status) && <span> · Stop requested</span>}
|
||||
{!terminal.has(p.status) && (
|
||||
<button disabled={busy || p.cancelRequested || (request.pendingGraph && !p.attempt)} onClick={cancel}>
|
||||
Stop request
|
||||
</button>
|
||||
)}
|
||||
{!request.pendingGraph && (inspection || p.status !== "completed") && (
|
||||
<button
|
||||
onClick={() => {
|
||||
void controller
|
||||
.details(request._camino.ref.object)
|
||||
.then(setDetails)
|
||||
.catch((error) => setError(String(error)));
|
||||
}}
|
||||
>
|
||||
Request details
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{p.reason && <p>{p.reason}</p>}
|
||||
{p.status === "uncertain" && <p>Execution may have occurred. Check activity before starting another request.</p>}
|
||||
{p.status === "completed" && request.admission?.responseKind === "json" && p.result && (
|
||||
<StructuredResult value={p.result} />
|
||||
)}
|
||||
{p.status === "completed" && request.admission?.responseKind === "unit" && <p>Actions completed.</p>}
|
||||
{["failed", "cancelled", "uncertain"].includes(p.status) && request.admission?.text && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setBusy(true);
|
||||
void controller
|
||||
.submit(request.admission!.text!, { retryOf: request._camino.ref.object })
|
||||
.catch((error) => setError(String(error)))
|
||||
.finally(() => setBusy(false));
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Try again with current settings
|
||||
</button>
|
||||
)}
|
||||
{p.status === "failed" && p.rawOutput && (
|
||||
<details>
|
||||
<summary>Returned output</summary>
|
||||
<pre>{p.rawOutput}</pre>
|
||||
</details>
|
||||
)}
|
||||
{details !== undefined && (
|
||||
<details open>
|
||||
<summary>Captured request and execution</summary>
|
||||
<pre>{JSON.stringify(details, null, 2)}</pre>
|
||||
</details>
|
||||
)}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
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<HTMLDivElement>(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<string, { request: Request; items: Item[] }>();
|
||||
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 (
|
||||
<section className="agent-thread" aria-label="Agent conversation">
|
||||
<div className="agent-thread-status" role="status">
|
||||
{state.loading
|
||||
? "Loading thread…"
|
||||
: state.maintenance
|
||||
? "Workspace graph unavailable. Known activity remains visible."
|
||||
: state.connected
|
||||
? "Connected"
|
||||
: "Reconnecting…"}
|
||||
{active?.progress.startedAt && (
|
||||
<span> · Working for {Math.max(0, Math.floor((now - Date.parse(active.progress.startedAt)) / 1000))}s</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="agent-thread-history"
|
||||
ref={scroll}
|
||||
onScroll={() => {
|
||||
const element = scroll.current!;
|
||||
setLatest(element.scrollHeight - element.scrollTop - element.clientHeight < 80);
|
||||
}}
|
||||
>
|
||||
{state.pageInfo?.hasNextPage && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const element = scroll.current!,
|
||||
height = element.scrollHeight;
|
||||
previousSize.current = height;
|
||||
setLatest(false);
|
||||
void controller
|
||||
.refresh(true)
|
||||
.then(() =>
|
||||
requestAnimationFrame(() => {
|
||||
element.scrollTop += element.scrollHeight - previousSize.current;
|
||||
}),
|
||||
)
|
||||
.catch((error) => setError(String(error)));
|
||||
}}
|
||||
>
|
||||
Load earlier messages
|
||||
</button>
|
||||
)}
|
||||
{grouped.size === 0 && !state.loading && (
|
||||
<div className="agent-empty">
|
||||
<h2>A new conversation</h2>
|
||||
<p>Send a message or a task to this Agent.</p>
|
||||
</div>
|
||||
)}
|
||||
{[...grouped.values()].map(({ request, items }) => (
|
||||
<article className="agent-request" key={request._camino.ref.object}>
|
||||
<div className="agent-request-source">
|
||||
{request.admission?.sourceLabel ?? "Agent request"} ·{" "}
|
||||
{request.admission?.createdAt && (
|
||||
<time dateTime={request.admission.createdAt}>
|
||||
{new Date(request.admission.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
const custom = renderItem?.(item, request);
|
||||
return (
|
||||
<React.Fragment key={itemKey(item)}>
|
||||
{custom !== undefined ? (
|
||||
custom
|
||||
) : item.data.kind === "steering" && item.data.activity ? (
|
||||
<aside className="agent-routing-change" role="status">
|
||||
Model profile · {JSON.parse(item.data.activity).profile} ·{" "}
|
||||
{item.data.control?.status === "accepted"
|
||||
? "Applied to subsequent inference"
|
||||
: item.data.control?.status === "rejected"
|
||||
? "Change rejected by Codex"
|
||||
: item.data.control?.status === "uncertain"
|
||||
? "Change outcome uncertain"
|
||||
: "Changing…"}
|
||||
{item.data.control?.reason && <p>{item.data.control.reason}</p>}
|
||||
{item.data.text && <p>{item.data.text}</p>}
|
||||
</aside>
|
||||
) : item.data.kind === "activity" ? (
|
||||
<Activity item={item} />
|
||||
) : item.data.kind === "widget" ? (
|
||||
<div className="agent-widget">
|
||||
{renderWidget ? (
|
||||
renderWidget(item)
|
||||
) : (
|
||||
<p>
|
||||
Widget unavailable in this view.{" "}
|
||||
<a href={`/entity/${encodeURIComponent(item.subject?._camino.ref.object ?? "")}`}>
|
||||
Open object
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : item.data.kind === "status" ? (
|
||||
<aside className="agent-status-card">
|
||||
{item.data.text}
|
||||
<Activity item={item} />
|
||||
</aside>
|
||||
) : item.data.phase === "commentary" ? (
|
||||
<details className="agent-commentary">
|
||||
<summary>Agent commentary</summary>
|
||||
<Markdown text={item.data.text} />
|
||||
</details>
|
||||
) : request.admission?.responseKind === "json" && item.data.kind === "assistant" ? null : (
|
||||
<div className={`agent-message agent-message-${item.data.kind}`}>
|
||||
{item.data.kind === "assistant" ? <Markdown text={item.data.text} /> : item.data.text}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{(request.questions?.entries ?? []).map(({ node }) => (
|
||||
<QuestionForm key={node._camino.ref.object} question={node} request={request} controller={controller} />
|
||||
))}
|
||||
<RequestCard request={request} controller={controller} inspection={!workspace} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{!latest && (
|
||||
<button
|
||||
className="agent-jump"
|
||||
onClick={() => {
|
||||
setLatest(true);
|
||||
if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight;
|
||||
}}
|
||||
>
|
||||
Jump to latest
|
||||
</button>
|
||||
)}
|
||||
<form
|
||||
className="agent-thread-composer"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void run();
|
||||
}}
|
||||
>
|
||||
{state.maintenance && allowRepair && (
|
||||
<label>
|
||||
<LocalInput type="checkbox" checked={repair} onChange={(event) => setRepair(event.target.checked)} />
|
||||
Source repair in the Workspace thread
|
||||
</label>
|
||||
)}
|
||||
<label className="agent-compose-label">
|
||||
Message
|
||||
<LocalTextArea
|
||||
aria-label="Message"
|
||||
rows={3}
|
||||
value={draft}
|
||||
disabled={archived || busy}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
void run();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="agent-compose-actions">
|
||||
{workspace && Object.keys(profiles).length > 0 && (
|
||||
<div className="agent-model-controls">
|
||||
<label>
|
||||
Model
|
||||
<LocalSelect
|
||||
aria-label="Model"
|
||||
disabled={busy}
|
||||
value={standard}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
void changeProfile(
|
||||
route?.standardProfile && profiles[name]?.fastProfile ? profiles[name].fastProfile! : name,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{Object.entries(profiles)
|
||||
.filter(([, p]) => !p.standardProfile)
|
||||
.map(([name, p]) => (
|
||||
<option key={name} value={name} disabled={p.available === false}>
|
||||
{p.description ?? p.model ?? name}
|
||||
</option>
|
||||
))}
|
||||
</LocalSelect>
|
||||
</label>
|
||||
<label>
|
||||
<LocalInput
|
||||
type="checkbox"
|
||||
aria-label="Fast mode"
|
||||
checked={!!route?.standardProfile}
|
||||
disabled={busy || !(route?.fastProfile || route?.standardProfile)}
|
||||
onChange={(event) =>
|
||||
void changeProfile(event.target.checked ? route!.fastProfile! : route!.standardProfile!)
|
||||
}
|
||||
/>
|
||||
Fast
|
||||
</label>
|
||||
{active?.progress.status === "running" && <small>Changes apply between inference steps.</small>}
|
||||
</div>
|
||||
)}
|
||||
<details>
|
||||
<summary>Next request options</summary>
|
||||
<label>
|
||||
Profile
|
||||
<LocalSelect aria-label="Profile" value={profile} onChange={(event) => setProfile(event.target.value)}>
|
||||
<option value="">Agent default</option>
|
||||
{Object.keys(profiles).map((value) => (
|
||||
<option key={value} disabled={profiles[value].available === false}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</LocalSelect>
|
||||
</label>
|
||||
<label>
|
||||
Response
|
||||
<LocalSelect aria-label="Response" value={response} onChange={(event) => setResponse(event.target.value)}>
|
||||
<option value="">Agent default</option>
|
||||
<option value="text">Text follow-up</option>
|
||||
<option value="unit">Actions only</option>
|
||||
</LocalSelect>
|
||||
</label>
|
||||
</details>
|
||||
<span>{pending.length ? `${pending.length} outstanding request${pending.length === 1 ? "" : "s"}` : ""}</span>
|
||||
{active?.progress.status === "running" && !state.maintenance && (
|
||||
<button type="button" disabled={!draft.trim() || busy} onClick={() => void run(true)}>
|
||||
Steer current
|
||||
</button>
|
||||
)}
|
||||
<button disabled={!draft.trim() || busy || archived || (state.maintenance && !(allowRepair && repair))}>
|
||||
{busy ? "Sending…" : state.maintenance ? "Submit source repair" : pending.length ? "Send to queue" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
{archived && <p>This thread is archived. Reopen it to send a message.</p>}
|
||||
{(error || state.error) && (
|
||||
<p role="alert">
|
||||
{error || state.error}{" "}
|
||||
<button type="button" onClick={() => void run()}>
|
||||
Retry retained submission
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+303
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<ThreadView thread={subject.object} controller={controller} renderWidget={(item) => <AgentWidget item={item} />} />
|
||||
);
|
||||
};
|
||||
@@ -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<string, unknown> | undefined =>
|
||||
value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : 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 (
|
||||
<p role="alert">
|
||||
This widget is unavailable.{" "}
|
||||
{subject && <a href={`/entity/${encodeURIComponent(subject.object)}`}>Open object</a>}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<LoadPresentation
|
||||
subject={subject}
|
||||
provider={{ packageRevision: selected.packageRevision, id: selected.component }}
|
||||
presentationMount={mount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user