Export template source from be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9:quixos-instance/packages/agent-presentation

This commit is contained in:
Quixos Template Publisher
2000-01-01 00:00:00 +00:00
commit 329a46a075
17 changed files with 1740 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"monorepoCommit": "be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9",
"sourcePath": "quixos-instance/packages/agent-presentation"
}
+6
View File
@@ -0,0 +1,6 @@
{
"component:org.quixos.agent:thread" = {
entry = "src/thread.tsx";
export = "Thread";
};
}
Generated
+108
View File
@@ -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
}
+44
View File
@@ -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; };
}
);
}
+12
View File
@@ -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
View File
@@ -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;}
}
+1
View File
@@ -0,0 +1 @@
query Settings {root {_camino {ref} data {name description enabled settings} instructions}}
+1
View File
@@ -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}} } }
+4
View File
@@ -0,0 +1,4 @@
{
"backend": "execution",
"queryFrontends": ["react"]
}
+9
View File
@@ -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"; }
}
+19
View File
@@ -0,0 +1,19 @@
{
"modules": {
"Thread": {
"inputs": [
{
"name": "body",
"query": "View",
"root": {
"from": "input",
"path": ["subject"]
},
"arguments": {
"record": {}
}
}
]
}
}
}
+431
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+23
View File
@@ -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} />} />
);
};
+25
View File
@@ -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}
/>
);
}