@stalwart-agents/agent-ui 0.0.0-stage → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stalwart Technologies Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # @stalwart-agents/agent-ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A white-label interface for an agent Stalwart routes and checks: streamed replies,
4
+ confirmations that show where each argument came from, refusals with their rule,
5
+ rollbacks with what was undone and what could not be, and questions to a person when an
6
+ outcome is unknown or an undo is stuck.
7
+
8
+ ```sh
9
+ npm install @stalwart-agents/agent-ui
10
+ ```
11
+
12
+ ```html
13
+ <script type="module" src="https://your.host/agent-ui/index.js"></script>
14
+ <stalwart-chat thread="u-17" base="/agent"></stalwart-chat>
15
+ ```
16
+
17
+ ```jsx
18
+ import { StalwartChat } from "@stalwart-agents/agent-ui/react";
19
+ <StalwartChat thread="u-17" base="/agent" />
20
+ ```
21
+
22
+ - **Talks to your backend only**: the resume server (`stalwart_agents.langchain.server`, or
23
+ `stalwart serve`). No telemetry, no font, no request to any other host.
24
+ - **White-label**: every colour and size is a `--stalwart-*` CSS custom property, every
25
+ region a `::part`, `header` and `footer` slots, no brand of ours, both colour schemes
26
+ (or `theme="light|dark"`).
27
+ - **Every string replaceable**: `setStrings({ send: "Enviar", approve: "Aprovar" })`.
28
+ - **Small**: under 45 kB compressed, without React (`npm run size` checks it).
29
+ - **Thumbs, where you turn them on**: `<stalwart-chat thumbs>` puts a thumb up and a thumb
30
+ down under each reply and each question the bot asks, with one line saying that a
31
+ rated message is kept to improve the assistant and nothing else of the conversation. A
32
+ pressed thumb shows as pressed and can be changed once (up, then down, posts down). The
33
+ thumb goes to `POST /threads/{id}/feedback` with the reply's id, from the session that
34
+ was shown it (`stalwart serve` takes it). Off where `thumbs` is not set.
35
+
36
+ ```html
37
+ <stalwart-chat thread="u-17" base="/agent" thumbs></stalwart-chat>
38
+ ```
39
+
40
+ | Element | Shows |
41
+ |---|---|
42
+ | `<stalwart-chat>` | the conversation, and whatever waits for a decision |
43
+ | `<stalwart-confirm>` | one call that waits: its arguments with their origins, the reasons, the irreversible warning, and *approve / skip / cancel all* |
44
+ | `<stalwart-decision>` | a refusal in one sentence, its rule on disclosure |
45
+ | `<stalwart-rollback>` | what a run undid, and what it could not |
46
+ | `<stalwart-trace>` | a thread's steps |
47
+
48
+ MIT licensed.
package/index.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ /** An interrupt the resume server lists: what waits for a person. */
2
+ export interface Interrupt {
3
+ id: string;
4
+ kind: "confirm" | "outcome_unknown" | "saga_stuck" | "ask" | "delegate";
5
+ tool: string;
6
+ /** Each argument with the origin it came from: `typed`, `reply`, `context`, `computed`, `model`, `result:<step>`, `agent:<name>`. */
7
+ arguments: { name: string; value: unknown; origin: string }[];
8
+ reasons: string[];
9
+ irreversible: boolean;
10
+ wording: string;
11
+ token?: string;
12
+ decisions: string[];
13
+ /** What the kind adds: a `delegate`'s `turn`, a stuck undo's `remaining`. */
14
+ detail?: Record<string, unknown>;
15
+ }
16
+
17
+ /** What a turn or a resume answers. */
18
+ export interface Answer {
19
+ thread: string;
20
+ reply: string | null;
21
+ interrupts: Interrupt[];
22
+ /** The router's refusal of the turn, with its rule; null where it did not refuse. */
23
+ refusal?: { sentence: string; rule: string } | null;
24
+ /** What a rollback of the turn's run undid, and what it could not; null where nothing was rolled back. */
25
+ rollback?: { failed: string | null; summary: string; undone: string[]; not_undoable: string[] } | null;
26
+ /** The new interrupts a webhook could not be told of; the turn ran all the same. */
27
+ not_notified?: { id: string; error: string }[];
28
+ /** The reply's id, which a thumb on it names (`stalwart serve`). */
29
+ message?: string;
30
+ /** The session the reply was shown to; the client sends it back as `Stalwart-Session`. */
31
+ session?: string;
32
+ }
33
+
34
+ /** What a thumb answers: the reply rated, the thumb, and the service's answer to the record. */
35
+ export interface Rated {
36
+ thread: string;
37
+ message: string;
38
+ thumb: "up" | "down";
39
+ recorded?: Record<string, unknown>;
40
+ }
41
+
42
+ /** The team's backend, by its base address: the resume server's routes, and no other host. */
43
+ export declare function backend(
44
+ base?: string,
45
+ fetcher?: typeof fetch,
46
+ ): {
47
+ turn(thread: string, text: string): Promise<Answer>;
48
+ interrupts(thread: string): Promise<{ thread: string; interrupts: Interrupt[] }>;
49
+ resume(thread: string, interrupt: Interrupt, decision: string): Promise<Answer>;
50
+ rollback(thread: string): Promise<Answer>;
51
+ /** A person's thumb on a reply, by the id its answer named. */
52
+ feedback(thread: string, message: string, thumb: "up" | "down"): Promise<Rated>;
53
+ streamUrl(thread: string): string;
54
+ };
55
+
56
+ /** The strings as shipped, by key. */
57
+ export declare const DEFAULT_STRINGS: Readonly<Record<string, string>>;
58
+ /** Replaces any of the interface's strings. */
59
+ export declare function setStrings(replacements: Partial<Record<string, string>>): void;
60
+ /** A string by its key, its `{name}`s filled. */
61
+ export declare function t(key: string, values?: Record<string, unknown>): string;
62
+ /** Defines the five elements under their names, once. Importing the module does it. */
63
+ export declare function define(): void;
64
+
65
+ /** `<stalwart-chat base="" thread="" thumbs>`: the whole interaction; `thumbs` shows a thumb up and down under each reply. */
66
+ export declare class StalwartChat extends HTMLElement {
67
+ readonly thread: string;
68
+ /** Makes the step stream's connection; the page's own `EventSource` where not set. */
69
+ eventSource?: (url: string) => EventSource;
70
+ submit(text: string): Promise<void>;
71
+ decide(interrupt: Interrupt, decision: string): Promise<void>;
72
+ /** Posts a thumb on the reply `message` names; a pressed thumb is not posted again, and a thumb changes once. */
73
+ rate(message: string, thumb: "up" | "down"): Promise<void>;
74
+ }
75
+ /** `<stalwart-confirm>`: a call that waits for a person; dispatches `stalwart-decision`. */
76
+ export declare class StalwartConfirm extends HTMLElement {
77
+ interrupt: Interrupt | undefined;
78
+ }
79
+ /** `<stalwart-decision sentence="" rule="">`: a refusal and its rule. */
80
+ export declare class StalwartDecision extends HTMLElement {}
81
+ /** `<stalwart-rollback>`: what a run undid, and what it could not. */
82
+ export declare class StalwartRollback extends HTMLElement {
83
+ report: { undone?: string[]; notUndoable?: string[]; summary?: string };
84
+ }
85
+ /** `<stalwart-trace>`: a thread's steps. */
86
+ export declare class StalwartTrace extends HTMLElement {
87
+ events: { id: number; step: number; nodes: string[] }[];
88
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@stalwart-agents/agent-ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.3",
4
+ "description": "A white-label interface for an agent run by Stalwart: chat, confirmations with each argument's origin, refusals with their rule, rollbacks and resumes, against your own backend.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "types": "./index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "./react": {
15
+ "types": "./react.d.ts",
16
+ "default": "./src/react.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "src",
21
+ "index.d.ts",
22
+ "react.d.ts",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "sideEffects": [
27
+ "./src/index.js"
28
+ ],
29
+ "peerDependencies": {
30
+ "react": ">=18"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "react": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "devDependencies": {
38
+ "happy-dom": "20.14.5"
39
+ },
40
+ "scripts": {
41
+ "test": "node --test test/elements.test.mjs",
42
+ "size": "node scripts/size.mjs",
43
+ "vendor": "node scripts/vendor.mjs"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/Stalwart-Holdings/quasilinear-solver.git",
48
+ "directory": "packages/agent-ui"
49
+ },
50
+ "homepage": "https://stalwart.it",
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }
package/react.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** `<stalwart-chat>` as a React component. */
2
+ export declare function StalwartChat(props: { thread?: string; base?: string; theme?: "light" | "dark"; children?: unknown }): unknown;
3
+ /** `<stalwart-decision>` as a React component: a refusal and its rule. */
4
+ export declare function StalwartDecision(props: { sentence: string; rule?: string }): unknown;
package/src/client.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The calls the interface makes: to the team's own backend, the resume server, and nowhere else.
3
+ *
4
+ * Every request goes to `base` + one of the server's routes. Nothing is sent to any
5
+ * other host -- no telemetry, no font, no script. Each request names the page's session
6
+ * (`Stalwart-Session`) once the server has handed one out, so a thumb is taken only from
7
+ * the session that was shown the reply.
8
+ */
9
+
10
+ /**
11
+ * The team's backend, by its base address.
12
+ * @param {string} base where the resume server is mounted ("" for the same origin).
13
+ * @param {typeof fetch} [fetcher] the fetch to use; the page's own where not given.
14
+ * @returns {{turn: Function, interrupts: Function, resume: Function, rollback: Function, feedback: Function, streamUrl: Function}}
15
+ */
16
+ export function backend(base = "", fetcher = globalThis.fetch.bind(globalThis)) {
17
+ const root = base.replace(/\/$/, "");
18
+ const at = (thread, what) => `${root}/threads/${encodeURIComponent(thread)}/${what}`;
19
+ let session = "";
20
+
21
+ async function send(url, method, body) {
22
+ const headers = body === undefined ? {} : { "Content-Type": "application/json" };
23
+ if (session) headers["Stalwart-Session"] = session;
24
+ const answer = await fetcher(url, {
25
+ method,
26
+ headers,
27
+ body: body === undefined ? undefined : JSON.stringify(body),
28
+ credentials: "same-origin",
29
+ });
30
+ const document = await answer.json().catch(() => ({}));
31
+ if (!answer.ok) {
32
+ const failure = new Error(document.message || `${answer.status}`);
33
+ failure.status = answer.status;
34
+ failure.code = document.code;
35
+ throw failure;
36
+ }
37
+ if (typeof document.session === "string" && document.session) session = document.session;
38
+ return document;
39
+ }
40
+
41
+ return {
42
+ /** One turn: `{reply, interrupts}`. */
43
+ turn: (thread, text) => send(at(thread, "messages"), "POST", { text }),
44
+ /** What waits for a person: `{interrupts}`. */
45
+ interrupts: (thread) => send(at(thread, "interrupts"), "GET"),
46
+ /** A person's decision on one interrupt, by its id: `{reply, interrupts}`. */
47
+ resume: (thread, interrupt, decision) =>
48
+ send(at(thread, "resume"), "POST", { interrupt_id: interrupt.id, decision, token: interrupt.token }),
49
+ /** An operator's rollback. */
50
+ rollback: (thread) => send(at(thread, "rollback"), "POST", {}),
51
+ /** A person's thumb on a reply, by the id its answer named: `up` or `down`. */
52
+ feedback: (thread, message, thumb) => send(at(thread, "feedback"), "POST", { message, thumb }),
53
+ /** The address of the thread's step stream, for an EventSource. */
54
+ streamUrl: (thread) => at(thread, "stream"),
55
+ };
56
+ }
@@ -0,0 +1,456 @@
1
+ /**
2
+ * The custom elements: `<stalwart-chat>`, `<stalwart-confirm>`, `<stalwart-decision>`,
3
+ * `<stalwart-rollback>` and `<stalwart-trace>`.
4
+ *
5
+ * White-label by design: no brand of ours, every colour and size a CSS custom property
6
+ * (`--stalwart-*`), every region a `::part`, every string from the table in `strings.js`,
7
+ * both colour schemes. They talk to the team's backend only.
8
+ */
9
+
10
+ import { backend } from "./client.js";
11
+ import { t } from "./strings.js";
12
+
13
+ const BASE_STYLE = `
14
+ :host {
15
+ --stalwart-bg: #ffffff; --stalwart-fg: #1a1a1a; --stalwart-muted: #595959; --stalwart-line: #d0d0d0;
16
+ --stalwart-accent: #0b57d0; --stalwart-accent-fg: #ffffff; --stalwart-warn: #8a4b00; --stalwart-bad: #b3261e;
17
+ --stalwart-radius: 8px; --stalwart-gap: 12px; --stalwart-font: system-ui, sans-serif;
18
+ display: block; color: var(--stalwart-fg); background: var(--stalwart-bg); font: 1rem/1.5 var(--stalwart-font);
19
+ box-sizing: border-box; max-width: 100%;
20
+ }
21
+ @media (prefers-color-scheme: dark) {
22
+ :host { --stalwart-bg: #121212; --stalwart-fg: #f1f1f1; --stalwart-muted: #bdbdbd; --stalwart-line: #3c3c3c;
23
+ --stalwart-accent: #8ab4f8; --stalwart-accent-fg: #0b0b0b; --stalwart-warn: #ffb74d; --stalwart-bad: #f28b82; }
24
+ }
25
+ :host([theme="light"]) { color-scheme: light; }
26
+ :host([theme="dark"]) { --stalwart-bg: #121212; --stalwart-fg: #f1f1f1; --stalwart-muted: #bdbdbd; --stalwart-line: #3c3c3c;
27
+ --stalwart-accent: #8ab4f8; --stalwart-accent-fg: #0b0b0b; --stalwart-warn: #ffb74d; --stalwart-bad: #f28b82; }
28
+ *, *::before, *::after { box-sizing: inherit; }
29
+ button { font: inherit; min-height: 44px; padding: 8px 16px; border-radius: var(--stalwart-radius);
30
+ border: 1px solid var(--stalwart-line); background: transparent; color: inherit; cursor: pointer; }
31
+ button.primary { background: var(--stalwart-accent); color: var(--stalwart-accent-fg); border-color: var(--stalwart-accent); }
32
+ button:focus-visible, input:focus-visible, summary:focus-visible { outline: 3px solid var(--stalwart-accent); outline-offset: 2px; }
33
+ .muted { color: var(--stalwart-muted); }
34
+ .warn { color: var(--stalwart-warn); }
35
+ .bad { color: var(--stalwart-bad); }
36
+ `;
37
+
38
+ function element(tag, attributes = {}, children = []) {
39
+ const node = document.createElement(tag);
40
+ for (const [name, value] of Object.entries(attributes)) {
41
+ if (value === undefined || value === null || value === false) continue;
42
+ if (name === "text") node.textContent = String(value);
43
+ else node.setAttribute(name, value === true ? "" : String(value));
44
+ }
45
+ for (const child of children) if (child) node.append(child);
46
+ return node;
47
+ }
48
+
49
+ function shadow(host, css) {
50
+ const root = host.shadowRoot || host.attachShadow({ mode: "open" });
51
+ root.replaceChildren(element("style", { text: BASE_STYLE + css }));
52
+ return root;
53
+ }
54
+
55
+ function originLabel(origin) {
56
+ const head = String(origin || "model").split(":")[0];
57
+ return t(`origin_${head}`);
58
+ }
59
+
60
+ /**
61
+ * `<stalwart-confirm>`: a call that waits for a person.
62
+ *
63
+ * Property `interrupt`: the payload the server lists -- `{id, kind, tool, arguments,
64
+ * reasons, irreversible, wording, token, decisions}`. Each argument is shown with where
65
+ * it came from. Answering dispatches `stalwart-decision` with `{interrupt, decision}`:
66
+ * `approve`, `skip` or `cancel_all` for a confirmation; the kind's own decisions for an
67
+ * unknown outcome or a stuck undo.
68
+ */
69
+ export class StalwartConfirm extends HTMLElement {
70
+ set interrupt(value) {
71
+ this._interrupt = value;
72
+ this.render();
73
+ }
74
+
75
+ get interrupt() {
76
+ return this._interrupt;
77
+ }
78
+
79
+ connectedCallback() {
80
+ this.render();
81
+ }
82
+
83
+ render() {
84
+ const interrupt = this._interrupt;
85
+ const root = shadow(this, `
86
+ section { border: 1px solid var(--stalwart-line); border-radius: var(--stalwart-radius); padding: var(--stalwart-gap); }
87
+ h2 { font-size: 1.1rem; margin: 0 0 8px; }
88
+ dl { display: grid; grid-template-columns: minmax(0, max-content) minmax(0, 1fr); gap: 4px 12px; margin: 8px 0; }
89
+ dt { font-weight: 600; overflow-wrap: anywhere; } dd { margin: 0; overflow-wrap: anywhere; }
90
+ .choices { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }`);
91
+ if (!interrupt) return;
92
+ const kind = interrupt.kind || "confirm";
93
+ const titles = { confirm: "confirmTitle", outcome_unknown: "unknownTitle", saga_stuck: "stuck", delegate: "delegateTitle" };
94
+ const heading = element("h2", { id: "title", text: t(titles[kind] || "confirmTitle", { tool: interrupt.tool }) });
95
+ const section = element("section", { part: "confirm", role: "group", "aria-labelledby": "title" }, [heading]);
96
+ if (interrupt.wording && kind !== "delegate") section.append(element("p", { text: interrupt.wording }));
97
+ if (kind === "delegate" && interrupt.detail && interrupt.detail.turn) {
98
+ section.append(element("blockquote", { part: "turn", text: interrupt.detail.turn }));
99
+ }
100
+ const args = interrupt.arguments || [];
101
+ if (args.length) {
102
+ const list = element("dl", { part: "arguments", "aria-label": t("argumentsTitle") });
103
+ for (const argument of args) {
104
+ list.append(element("dt", { text: argument.name }));
105
+ list.append(element("dd", {}, [
106
+ element("span", { text: typeof argument.value === "string" ? argument.value : JSON.stringify(argument.value) }),
107
+ element("span", { class: "muted", text: ` (${originLabel(argument.origin)})` }),
108
+ ]));
109
+ }
110
+ section.append(element("h3", { class: "muted", text: t("argumentsTitle") }), list);
111
+ }
112
+ if ((interrupt.reasons || []).length) {
113
+ const reasons = element("ul", { part: "reasons" });
114
+ for (const reason of interrupt.reasons) reasons.append(element("li", { text: reason }));
115
+ section.append(element("h3", { class: "muted", text: t("reasonsTitle") }), reasons);
116
+ }
117
+ if (interrupt.irreversible) section.append(element("p", { class: "warn", part: "irreversible", text: t("irreversible") }));
118
+ const labels = { approve: kind === "delegate" ? "delegateApprove" : "approve", skip: "skip", cancel_all: "cancelAll",
119
+ happened: "happened", did_not_happen: "didNotHappen", retry: "retry", done_by_hand: "doneByHand",
120
+ abandon: "abandon", decline: "decline" };
121
+ const choices = element("div", { class: "choices", part: "choices" });
122
+ for (const decision of interrupt.decisions || ["approve", "skip", "cancel_all"]) {
123
+ const button = element("button", { type: "button", class: decision === "approve" ? "primary" : undefined,
124
+ "data-decision": decision, text: t(labels[decision] || decision) });
125
+ button.addEventListener("click", () => this.dispatchEvent(new CustomEvent("stalwart-decision", {
126
+ bubbles: true, composed: true, detail: { interrupt, decision },
127
+ })));
128
+ choices.append(button);
129
+ }
130
+ section.append(choices);
131
+ root.append(section);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * `<stalwart-decision>`: a refusal, in one sentence, with the rule's name on disclosure.
137
+ * Attributes: `sentence`, `rule`.
138
+ */
139
+ export class StalwartDecision extends HTMLElement {
140
+ static get observedAttributes() {
141
+ return ["sentence", "rule"];
142
+ }
143
+
144
+ attributeChangedCallback() {
145
+ this.render();
146
+ }
147
+
148
+ connectedCallback() {
149
+ this.render();
150
+ }
151
+
152
+ render() {
153
+ const root = shadow(this, `p { margin: 0; } details { margin-top: 4px; } summary { cursor: pointer; }`);
154
+ const box = element("div", { part: "decision", role: "status" }, [
155
+ element("p", {}, [element("strong", { class: "bad", text: `${t("refused")}: ` }), document.createTextNode(this.getAttribute("sentence") || "")]),
156
+ ]);
157
+ const rule = this.getAttribute("rule");
158
+ if (rule) {
159
+ box.append(element("details", { part: "rule" }, [element("summary", { text: t("showRule") }),
160
+ element("p", { class: "muted", text: `${t("rule")}: ${rule}` })]));
161
+ }
162
+ root.append(box);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * `<stalwart-rollback>`: what a run undid, and what it could not.
168
+ * Property `report`: `{undone: [tool, ...], notUndoable: [tool, ...], summary}`.
169
+ */
170
+ export class StalwartRollback extends HTMLElement {
171
+ set report(value) {
172
+ this._report = value;
173
+ this.render();
174
+ }
175
+
176
+ connectedCallback() {
177
+ this.render();
178
+ }
179
+
180
+ render() {
181
+ const report = this._report || {};
182
+ const root = shadow(this, `ul { margin: 4px 0; padding-left: 20px; }`);
183
+ const box = element("section", { part: "rollback", "aria-label": t("rollbackTitle") }, [element("h3", { text: t("rollbackTitle") })]);
184
+ if (report.summary) box.append(element("p", { text: report.summary }));
185
+ for (const [key, items] of [["undone", report.undone], ["notUndoable", report.notUndoable]]) {
186
+ if (!items || !items.length) continue;
187
+ const list = element("ul");
188
+ for (const item of items) list.append(element("li", { text: item }));
189
+ box.append(element("p", { class: key === "notUndoable" ? "warn" : "muted", text: t(key) }), list);
190
+ }
191
+ root.append(box);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * `<stalwart-trace>`: a thread's steps, as the stream reports them.
197
+ * Property `events`: the stream's events, `{id, step, nodes, messages, interrupts}`.
198
+ */
199
+ export class StalwartTrace extends HTMLElement {
200
+ set events(value) {
201
+ this._events = value || [];
202
+ this.render();
203
+ }
204
+
205
+ connectedCallback() {
206
+ this.render();
207
+ }
208
+
209
+ render() {
210
+ const root = shadow(this, `ol { padding-left: 20px; } li { margin: 2px 0; overflow-wrap: anywhere; }`);
211
+ const list = element("ol", { part: "trace", "aria-label": t("traceTitle") });
212
+ for (const event of this._events || []) {
213
+ const text = `${t("step", { step: event.step })}: ${(event.nodes || []).join(", ")}`;
214
+ list.append(element("li", { text }));
215
+ }
216
+ root.append(element("h3", { text: t("traceTitle") }), list);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * `<stalwart-chat>`: the whole interaction against the team's backend.
222
+ *
223
+ * Attributes: `base` (where the resume server is mounted; the same origin where not
224
+ * set), `thread` (the conversation's id), `thumbs` (a thumb up and a thumb down under
225
+ * each reply and each question the bot asks, with one line saying what a rating keeps;
226
+ * off where not set). A pressed thumb shows as pressed and can be changed once. Shows each
227
+ * confirmation with its arguments'
228
+ * origins, each refusal with its rule and each rollback with what it undid -- from the
229
+ * answer's `refusal` and `rollback`, never from the reply's words -- and asks the person
230
+ * when an outcome is unknown, an undo is stuck, or a turn may go to the assistant. Follows
231
+ * the thread's step stream while a run goes on; a dropped stream reconnects from the
232
+ * last step it saw, and what waits is read again from the server.
233
+ *
234
+ * Property `eventSource`: makes the stream's connection, `(url) => EventSource`; the
235
+ * page's own `EventSource` where not set.
236
+ */
237
+ export class StalwartChat extends HTMLElement {
238
+ constructor() {
239
+ super();
240
+ this._log = [];
241
+ this._pending = [];
242
+ this._busy = false;
243
+ this._events = [];
244
+ this._reconnecting = false;
245
+ this._thumbs = new Map();
246
+ this._asking = null;
247
+ }
248
+
249
+ disconnectedCallback() {
250
+ this.closeStream();
251
+ }
252
+
253
+ /** Follows the thread's steps while a run goes on, into `<stalwart-trace>`. */
254
+ openStream() {
255
+ const make = this.eventSource || (globalThis.EventSource ? (url) => new globalThis.EventSource(url) : null);
256
+ if (!make || this._stream) return;
257
+ const source = make(this.api.streamUrl(this.thread));
258
+ this._stream = source;
259
+ source.addEventListener("step", (event) => {
260
+ this._reconnecting = false;
261
+ try {
262
+ const step = JSON.parse(event.data);
263
+ if (!this._events.some((seen) => seen.id === step.id)) this._events.push(step);
264
+ } catch {
265
+ return;
266
+ }
267
+ this.render();
268
+ });
269
+ source.addEventListener("end", () => this.closeStream());
270
+ // EventSource reconnects by itself and sends the last step's id: the stream goes on from there.
271
+ source.onerror = () => {
272
+ this._reconnecting = true;
273
+ this.render();
274
+ this.refresh();
275
+ };
276
+ }
277
+
278
+ closeStream() {
279
+ if (this._stream) this._stream.close();
280
+ this._stream = null;
281
+ if (this._reconnecting) {
282
+ this._reconnecting = false;
283
+ this.render();
284
+ }
285
+ }
286
+
287
+ connectedCallback() {
288
+ this.api = backend(this.getAttribute("base") || "", this.fetcher);
289
+ this.render();
290
+ this.refresh();
291
+ }
292
+
293
+ get thread() {
294
+ return this.getAttribute("thread") || "default";
295
+ }
296
+
297
+ async refresh() {
298
+ try {
299
+ const listed = await this.api.interrupts(this.thread);
300
+ this._pending = listed.interrupts || [];
301
+ this.render();
302
+ } catch {
303
+ /* no thread yet: nothing waits */
304
+ }
305
+ }
306
+
307
+ async submit(text) {
308
+ if (!text.trim() || this._busy) return;
309
+ this._log.push({ who: "user", text });
310
+ this._busy = true;
311
+ this.render();
312
+ this.openStream();
313
+ try {
314
+ this.show(await this.api.turn(this.thread, text));
315
+ } catch (failure) {
316
+ this._log.push({ who: "error", text: t("error", { message: failure.message }) });
317
+ }
318
+ this._busy = false;
319
+ this.render();
320
+ }
321
+
322
+ async decide(interrupt, decision) {
323
+ this._busy = true;
324
+ this.render();
325
+ this.openStream();
326
+ try {
327
+ this.show(await this.api.resume(this.thread, interrupt, decision));
328
+ } catch (failure) {
329
+ if (failure.code === "interrupt_stale") await this.refresh();
330
+ else this._log.push({ who: "error", text: t("error", { message: failure.message }) });
331
+ }
332
+ this._busy = false;
333
+ this.render();
334
+ }
335
+
336
+ show(answer) {
337
+ const message = answer.message;
338
+ const before = this._log.length;
339
+ if (answer.refusal) {
340
+ this._log.push({ who: "refusal", text: answer.refusal.sentence || answer.reply || "", rule: answer.refusal.rule, message });
341
+ } else if (answer.rollback) {
342
+ this._log.push({ who: "rollback", message, report: { summary: answer.rollback.summary || answer.reply,
343
+ undone: answer.rollback.undone, notUndoable: answer.rollback.not_undoable } });
344
+ } else if (answer.reply) {
345
+ this._log.push({ who: "assistant", text: answer.reply, message });
346
+ }
347
+ this._pending = answer.interrupts || [];
348
+ // A question with no reply of its own (a confirmation, a hand-over) is rated under the question.
349
+ this._asking = this._log.length === before && this._pending.length ? message || null : null;
350
+ }
351
+
352
+ /** Posts a thumb on the reply @p message names; a pressed thumb is not posted again, and a thumb changes once. */
353
+ async rate(message, thumb) {
354
+ const held = this._thumbs.get(message);
355
+ if (!message || (held && (held.thumb === thumb || held.changed))) return;
356
+ try {
357
+ await this.api.feedback(this.thread, message, thumb);
358
+ this._thumbs.set(message, { thumb, changed: Boolean(held) });
359
+ } catch (failure) {
360
+ this._log.push({ who: "error", text: t("error", { message: failure.message }) });
361
+ }
362
+ this.render();
363
+ }
364
+
365
+ thumbsFor(message) {
366
+ if (!this.hasAttribute("thumbs") || !message) return null;
367
+ const held = this._thumbs.get(message) || {};
368
+ const bar = element("div", { part: "thumbs", class: "thumbs" });
369
+ for (const thumb of ["up", "down"]) {
370
+ const button = element("button", { type: "button", "data-thumb": thumb, "aria-pressed": held.thumb === thumb ? "true" : "false",
371
+ disabled: Boolean(held.changed), text: t(thumb === "up" ? "thumbUp" : "thumbDown") });
372
+ button.addEventListener("click", () => this.rate(message, thumb));
373
+ bar.append(button);
374
+ }
375
+ bar.append(element("p", { class: "muted", part: "thumbs-notice", text: t("thumbsNotice") }));
376
+ return bar;
377
+ }
378
+
379
+ render() {
380
+ const root = shadow(this, `
381
+ .log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
382
+ .log li { padding: 8px 12px; border-radius: var(--stalwart-radius); max-width: 100%; overflow-wrap: anywhere; }
383
+ .user { align-self: flex-end; background: var(--stalwart-accent); color: var(--stalwart-accent-fg); }
384
+ .assistant { align-self: flex-start; border: 1px solid var(--stalwart-line); }
385
+ form { display: flex; gap: 8px; margin-top: var(--stalwart-gap); }
386
+ input { flex: 1; min-width: 0; font: inherit; padding: 8px; border-radius: var(--stalwart-radius);
387
+ border: 1px solid var(--stalwart-line); background: var(--stalwart-bg); color: var(--stalwart-fg); min-height: 44px; }
388
+ .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
389
+ stalwart-confirm { margin-top: var(--stalwart-gap); }
390
+ .thumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
391
+ .thumbs p { margin: 0; font-size: 0.875rem; }
392
+ .thumbs button[aria-pressed="true"] { background: var(--stalwart-accent); color: var(--stalwart-accent-fg); }`);
393
+ const log = element("ol", { class: "log", part: "log", "aria-live": "polite", "aria-label": t("conversation") });
394
+ for (const entry of this._log) {
395
+ if (entry.who === "refusal") {
396
+ const decision = element("stalwart-decision", { sentence: entry.text, rule: entry.rule });
397
+ log.append(element("li", { class: "assistant" }, [decision, this.thumbsFor(entry.message)]));
398
+ } else if (entry.who === "rollback") {
399
+ const rollback = element("stalwart-rollback");
400
+ rollback.report = entry.report;
401
+ log.append(element("li", { class: "assistant" }, [rollback, this.thumbsFor(entry.message)]));
402
+ } else {
403
+ const item = element("li", { class: entry.who === "user" ? "user" : entry.who === "error" ? "assistant bad" : "assistant",
404
+ text: entry.text });
405
+ if (entry.who === "assistant") {
406
+ const bar = this.thumbsFor(entry.message);
407
+ if (bar) item.append(bar);
408
+ }
409
+ log.append(item);
410
+ }
411
+ }
412
+ root.append(element("slot", { name: "header" }), log);
413
+ if (this._reconnecting) root.append(element("p", { class: "muted", role: "status", part: "status", text: t("reconnecting") }));
414
+ if (this._events.length) {
415
+ const trace = element("stalwart-trace");
416
+ trace.events = this._events;
417
+ root.append(element("details", { part: "steps" }, [element("summary", { text: t("traceTitle") }), trace]));
418
+ }
419
+ for (const interrupt of this._pending) {
420
+ const confirm = element("stalwart-confirm");
421
+ confirm.interrupt = interrupt;
422
+ confirm.addEventListener("stalwart-decision", (event) => this.decide(event.detail.interrupt, event.detail.decision));
423
+ root.append(confirm);
424
+ }
425
+ const asked = this._pending.length ? this.thumbsFor(this._asking) : null;
426
+ if (asked) root.append(asked);
427
+ const input = element("input", { id: "message", name: "message", autocomplete: "off", placeholder: t("placeholder"),
428
+ disabled: this._busy || this._pending.length > 0 });
429
+ const form = element("form", { part: "composer" }, [
430
+ element("label", { for: "message", class: "visually-hidden", text: t("placeholder") }),
431
+ input,
432
+ element("button", { type: "submit", class: "primary", disabled: this._busy || this._pending.length > 0,
433
+ text: this._busy ? t("sending") : t("send") }),
434
+ ]);
435
+ form.addEventListener("submit", (event) => {
436
+ event.preventDefault();
437
+ const text = input.value;
438
+ input.value = "";
439
+ this.submit(text);
440
+ });
441
+ root.append(form, element("slot", { name: "footer" }));
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Defines the elements under their names, once.
447
+ * @returns {void}
448
+ */
449
+ export function define() {
450
+ const registry = globalThis.customElements;
451
+ for (const [name, type] of [["stalwart-chat", StalwartChat], ["stalwart-confirm", StalwartConfirm],
452
+ ["stalwart-decision", StalwartDecision], ["stalwart-rollback", StalwartRollback],
453
+ ["stalwart-trace", StalwartTrace]]) {
454
+ if (!registry.get(name)) registry.define(name, type);
455
+ }
456
+ }
package/src/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @stalwart-agents/agent-ui: a white-label interface for an agent run by Stalwart.
3
+ *
4
+ * Importing this module defines `<stalwart-chat>`, `<stalwart-confirm>`,
5
+ * `<stalwart-decision>`, `<stalwart-rollback>` and `<stalwart-trace>`. They talk to the
6
+ * team's own backend -- the resume server -- and to nothing else.
7
+ *
8
+ * @example
9
+ * <script type="module" src="/agent-ui.js"></script>
10
+ * <stalwart-chat thread="u-17"></stalwart-chat>
11
+ */
12
+
13
+ import { define } from "./elements.js";
14
+
15
+ export { backend } from "./client.js";
16
+ export { StalwartChat, StalwartConfirm, StalwartDecision, StalwartRollback, StalwartTrace, define } from "./elements.js";
17
+ export { DEFAULT_STRINGS, setStrings, t } from "./strings.js";
18
+
19
+ if (globalThis.customElements) define();
package/src/react.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The React entry point: the custom elements as components.
3
+ *
4
+ * @example
5
+ * import { StalwartChat } from "@stalwart-agents/agent-ui/react";
6
+ * <StalwartChat thread="u-17" base="/agent" />
7
+ */
8
+
9
+ import { createElement } from "react";
10
+
11
+ import "./index.js";
12
+
13
+ /**
14
+ * `<stalwart-chat>` as a React component.
15
+ * @param {{thread?: string, base?: string, theme?: "light" | "dark", children?: unknown}} props
16
+ * @returns {unknown} the element.
17
+ */
18
+ export function StalwartChat(props) {
19
+ return createElement("stalwart-chat", { thread: props.thread, base: props.base, theme: props.theme }, props.children);
20
+ }
21
+
22
+ /**
23
+ * `<stalwart-decision>` as a React component: a refusal and its rule.
24
+ * @param {{sentence: string, rule?: string}} props
25
+ * @returns {unknown} the element.
26
+ */
27
+ export function StalwartDecision(props) {
28
+ return createElement("stalwart-decision", { sentence: props.sentence, rule: props.rule });
29
+ }
package/src/strings.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Every string the interface shows, in one table the team can replace.
3
+ *
4
+ * `setStrings({...})` replaces any of them; a `{name}` in a string is filled from the
5
+ * moment it is shown. Nothing the interface renders is written anywhere else.
6
+ *
7
+ * @example
8
+ * import { setStrings } from "@stalwart-agents/agent-ui";
9
+ * setStrings({ send: "Enviar", approve: "Aprovar" });
10
+ */
11
+
12
+ /** @type {Record<string, string>} The strings as shipped. */
13
+ export const DEFAULT_STRINGS = Object.freeze({
14
+ conversation: "Conversation",
15
+ placeholder: "Write a message",
16
+ send: "Send",
17
+ sending: "Sending…",
18
+ waiting: "Waiting for your decision",
19
+ confirmTitle: "Confirm {tool}?",
20
+ argumentsTitle: "What it will use",
21
+ origin_typed: "you typed it",
22
+ origin_reply: "you answered it",
23
+ origin_context: "from your account",
24
+ origin_computed: "calculated",
25
+ origin_model: "the assistant wrote it",
26
+ origin_result: "from an earlier step",
27
+ origin_agent: "from another assistant",
28
+ reasonsTitle: "Why this needs you",
29
+ irreversible: "Nothing can undo this step once it runs.",
30
+ approve: "Approve",
31
+ skip: "Skip this step",
32
+ cancelAll: "Cancel everything",
33
+ refused: "Not done",
34
+ rule: "Rule",
35
+ showRule: "Which rule?",
36
+ rollbackTitle: "Undoing what this run did",
37
+ undone: "Undone",
38
+ notUndoable: "Could not be undone",
39
+ stuck: "Undoing {tool} failed. What should happen?",
40
+ retry: "Try again",
41
+ doneByHand: "I undid it myself",
42
+ abandon: "Leave it",
43
+ unknownTitle: "Did {tool} take effect?",
44
+ happened: "Yes, it happened",
45
+ didNotHappen: "No, it did not",
46
+ delegateTitle: "I couldn't work that out. Shall I pass it to the assistant?",
47
+ delegateApprove: "Yes, pass it on",
48
+ decline: "No",
49
+ reconnecting: "Reconnecting…",
50
+ traceTitle: "What happened",
51
+ step: "Step {step}",
52
+ error: "Something went wrong: {message}",
53
+ thumbUp: "Helpful",
54
+ thumbDown: "Not helpful",
55
+ thumbsNotice: "A rated message is kept to improve the assistant, and nothing else of the conversation.",
56
+ });
57
+
58
+ let current = { ...DEFAULT_STRINGS };
59
+
60
+ /**
61
+ * Replaces any of the interface's strings.
62
+ * @param {Partial<Record<string, string>>} replacements the strings to use instead, by key.
63
+ * @returns {void}
64
+ */
65
+ export function setStrings(replacements) {
66
+ current = { ...current, ...replacements };
67
+ }
68
+
69
+ /**
70
+ * A string by its key, its `{name}`s filled from @p values.
71
+ * @param {string} key the string's key in the table.
72
+ * @param {Record<string, unknown>} [values] what fills its placeholders.
73
+ * @returns {string} the string, or the key itself where the table has none.
74
+ */
75
+ export function t(key, values = {}) {
76
+ const text = current[key] ?? key;
77
+ return text.replace(/\{(\w+)\}/g, (_, name) => (values[name] === undefined ? `{${name}}` : String(values[name])));
78
+ }