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