@artooi/ag-ui-web-component 0.26.1 → 0.28.0

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +291 -1
  2. package/README.md +191 -8
  3. package/dist/ag-ui-web-component.bundle.js +50 -50
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +61 -3
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +8 -1
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/conversation_store.d.ts +58 -3
  10. package/dist/core/conversation_store.d.ts.map +1 -1
  11. package/dist/core/create_http_agent.d.ts +13 -0
  12. package/dist/core/create_http_agent.d.ts.map +1 -1
  13. package/dist/core/remote_conversation_store.d.ts +29 -1
  14. package/dist/core/remote_conversation_store.d.ts.map +1 -1
  15. package/dist/core/utils.d.ts +42 -0
  16. package/dist/core/utils.d.ts.map +1 -1
  17. package/dist/index.js +602 -104
  18. package/dist/index.js.map +4 -4
  19. package/dist/tools/is_destructive.d.ts +8 -2
  20. package/dist/tools/is_destructive.d.ts.map +1 -1
  21. package/dist/tools/parse_tool_catalog.d.ts +11 -4
  22. package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
  23. package/dist/ui/render_markdown.d.ts +23 -5
  24. package/dist/ui/render_markdown.d.ts.map +1 -1
  25. package/dist/ui/resize_handle.d.ts +5 -1
  26. package/dist/ui/resize_handle.d.ts.map +1 -1
  27. package/dist/ui/ui_strings.d.ts +13 -7
  28. package/dist/ui/ui_strings.d.ts.map +1 -1
  29. package/dist/ui/voice_input.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/core/ag_ui_chat.ts +444 -45
  32. package/src/core/agui_client.ts +43 -2
  33. package/src/core/conversation_store.ts +146 -49
  34. package/src/core/create_http_agent.ts +24 -2
  35. package/src/core/remote_conversation_store.ts +45 -3
  36. package/src/core/utils.ts +83 -1
  37. package/src/tools/is_destructive.ts +8 -2
  38. package/src/tools/parse_tool_catalog.ts +18 -6
  39. package/src/ui/render_markdown.ts +111 -21
  40. package/src/ui/resize_handle.ts +32 -2
  41. package/src/ui/ui_strings.ts +19 -8
  42. package/src/ui/voice_input.ts +43 -0
  43. package/src/version.ts +1 -1
@@ -4,7 +4,7 @@
4
4
  // happy-dom-only suite goes green while this module strips nothing at all.
5
5
  // Moving those assertions back under happy-dom for speed removes the only check
6
6
  // that this module does anything. CLAUDE.md records the upstream root cause.
7
- import DOMPurify from "dompurify";
7
+ import DOMPurify, { type Config, type DOMPurify as Purifier } from "dompurify";
8
8
  import { Marked } from "marked";
9
9
 
10
10
  // A local parser instance, so configuration never leaks into the shared
@@ -54,11 +54,108 @@ const ALLOWED_TAGS = [
54
54
  "td",
55
55
  ];
56
56
 
57
- const ALLOWED_ATTR = ["href", "title", "class"];
57
+ // `target` and `rel` are on the list because {@link harden} writes them onto
58
+ // every link. An attribute the returned markup carries but the config does not
59
+ // name would be markup the sanitiser never approved — which is the whole defect
60
+ // the hook exists to avoid.
61
+ //
62
+ // `class` is here only for marked's `language-*` code-fence hint, and `harden`
63
+ // narrows it to exactly that. Left wide, it is a chrome-forgery channel: the
64
+ // shadow stylesheet's component classes are unscoped selectors, so a `<span
65
+ // class="approval-btn approval-btn--approve">` in model output resolves to the
66
+ // same pixels as the real human-in-the-loop approval button, inside the surface
67
+ // where the user decides whether to approve.
68
+ const ALLOWED_ATTR = ["href", "title", "class", "target", "rel"];
69
+
70
+ /**
71
+ * The sanitiser's declared allowlist — and, because {@link harden} runs inside
72
+ * the sanitiser rather than after it, its *effective* one too.
73
+ *
74
+ * `ALLOW_DATA_ATTR` and `ALLOW_ARIA_ATTR` default to `true`, which would admit
75
+ * every `data-*` and `aria-*` attribute on top of the five named above. Both
76
+ * matter here: the cards drive their resolved / expanded / status appearance off
77
+ * `[data-resolved]`, `[data-status]` and `[data-expanded]`, and an `aria-label`
78
+ * on model output makes a screen reader announce something other than what is on
79
+ * screen. Turning them off is what makes the declared list the real one.
80
+ *
81
+ * Exported so a test can assert the returned markup is a fixed point of it.
82
+ */
83
+ export const SANITIZE_CONFIG: Config = {
84
+ ALLOWED_TAGS,
85
+ ALLOWED_ATTR,
86
+ ALLOW_DATA_ATTR: false,
87
+ ALLOW_ARIA_ATTR: false,
88
+ };
58
89
 
59
- // The image-permitting variants used when the host opts in.
60
- const ALLOWED_TAGS_WITH_IMAGES = [...ALLOWED_TAGS, "img"];
61
- const ALLOWED_ATTR_WITH_IMAGES = [...ALLOWED_ATTR, "src", "alt", "width", "height"];
90
+ /** {@link SANITIZE_CONFIG} plus the images a host opts into. */
91
+ export const SANITIZE_CONFIG_WITH_IMAGES: Config = {
92
+ ...SANITIZE_CONFIG,
93
+ ALLOWED_TAGS: [...ALLOWED_TAGS, "img"],
94
+ ALLOWED_ATTR: [...ALLOWED_ATTR, "src", "alt", "width", "height"],
95
+ };
96
+
97
+ /** The one class value markdown is allowed to carry, and where it may sit. */
98
+ const LANGUAGE_CLASS = /^language-[A-Za-z0-9_+#.-]+$/;
99
+ const LANGUAGE_HOSTS = new Set(["CODE", "PRE"]);
100
+
101
+ /**
102
+ * Per-element hardening, run *inside* the sanitiser.
103
+ *
104
+ * It belongs here rather than in a pass over the finished string because
105
+ * DOMPurify must be the last thing to touch the markup: anything edited in
106
+ * afterwards is inserted into the document without the sanitiser ever having
107
+ * seen it, and re-parsing sanitiser output is the shape every mXSS bypass takes.
108
+ * Running as a hook means DOMPurify serialises the result of these edits, so
109
+ * what the caller inserts is exactly what it approved.
110
+ *
111
+ * Two jobs:
112
+ *
113
+ * - links open in a new tab and never hand over their opener. `target`/`rel` are
114
+ * this module's to write, so they are stripped from everything else rather
115
+ * than inherited from the model's own markup;
116
+ * - `class` is narrowed to the code-fence language hint on `code`/`pre`, the
117
+ * only thing it is allowed for, and dropped everywhere else so model output
118
+ * cannot adopt the component's own chrome.
119
+ */
120
+ function harden(node: Element): void {
121
+ if (node.nodeName === "A" && node.hasAttribute("href")) {
122
+ node.setAttribute("target", "_blank");
123
+ node.setAttribute("rel", "noopener noreferrer");
124
+ } else {
125
+ node.removeAttribute("target");
126
+ node.removeAttribute("rel");
127
+ }
128
+ const classes = node.getAttribute("class");
129
+ if (classes === null) {
130
+ return;
131
+ }
132
+ const kept = LANGUAGE_HOSTS.has(node.nodeName)
133
+ ? classes.split(/\s+/).filter((token) => LANGUAGE_CLASS.test(token))
134
+ : [];
135
+ if (kept.length === 0) {
136
+ node.removeAttribute("class");
137
+ return;
138
+ }
139
+ node.setAttribute("class", kept.join(" "));
140
+ }
141
+
142
+ // DOMPurify's default export is a singleton, and `addHook` mutates it for every
143
+ // caller sharing that copy — including the host app, if its bundler deduped to
144
+ // ours. So this module builds its own instance, for the same reason `parser`
145
+ // above is a local `Marked`.
146
+ //
147
+ // Built on first render rather than at module scope: a DOMPurify instance
148
+ // created without a DOM has no `addHook` at all, and doing this eagerly would
149
+ // turn a server-side `import` of the package into a throw.
150
+ let purifier: Purifier | null = null;
151
+
152
+ function sanitizer(): Purifier {
153
+ if (purifier === null) {
154
+ purifier = DOMPurify();
155
+ purifier.addHook("afterSanitizeAttributes", harden);
156
+ }
157
+ return purifier;
158
+ }
62
159
 
63
160
  /** Options for {@link renderMarkdown}. */
64
161
  export interface RenderMarkdownOptions {
@@ -74,25 +171,18 @@ export interface RenderMarkdownOptions {
74
171
  * Render markdown (and any embedded raw HTML) to a sanitised HTML string.
75
172
  *
76
173
  * Markdown syntax and literal HTML share one path: `marked` emits HTML, then
77
- * DOMPurify strips everything outside {@link ALLOWED_TAGS} /
78
- * {@link ALLOWED_ATTR} — scripts, event handlers, `javascript:` URLs. Links are
79
- * hardened with `target="_blank"` and `rel="noopener noreferrer"`.
174
+ * DOMPurify strips everything outside {@link SANITIZE_CONFIG} — scripts, event
175
+ * handlers, `javascript:` URLs, `data-*`/`aria-*`, and every class but a code
176
+ * fence's `language-*` hint. Links come out with `target="_blank"` and
177
+ * `rel="noopener noreferrer"`.
80
178
  *
81
- * The result is trimmed so a single-paragraph message round-trips to clean
82
- * `textContent`, without the wrapping paragraph's trailing newline.
179
+ * The returned string is the sanitiser's own output, trimmed. Nothing edits it
180
+ * afterwards, so what a caller inserts is what DOMPurify approved.
83
181
  */
84
182
  export function renderMarkdown(text: string, options?: RenderMarkdownOptions): string {
85
183
  const allowImages = options?.allowImages === true;
86
184
  const rendered = parser.parse(text, { async: false });
87
- const clean = DOMPurify.sanitize(rendered, {
88
- ALLOWED_TAGS: allowImages ? ALLOWED_TAGS_WITH_IMAGES : ALLOWED_TAGS,
89
- ALLOWED_ATTR: allowImages ? ALLOWED_ATTR_WITH_IMAGES : ALLOWED_ATTR,
90
- });
91
- const template = document.createElement("template");
92
- template.innerHTML = clean;
93
- for (const anchor of template.content.querySelectorAll("a[href]")) {
94
- anchor.setAttribute("target", "_blank");
95
- anchor.setAttribute("rel", "noopener noreferrer");
96
- }
97
- return template.innerHTML.trim();
185
+ return sanitizer()
186
+ .sanitize(rendered, allowImages ? SANITIZE_CONFIG_WITH_IMAGES : SANITIZE_CONFIG)
187
+ .trim();
98
188
  }
@@ -45,7 +45,11 @@ export interface ResizeOptions {
45
45
  readonly rect: () => PanelRect;
46
46
  /** Apply a size (the host writes the custom properties). */
47
47
  readonly apply: (size: ResizeSize) => void;
48
- /** Called once per completed drag, for persistence. */
48
+ /**
49
+ * Called once per completed resize, for persistence: on `pointerup` for a
50
+ * drag, and when the key comes up (or focus leaves the handle) for a key
51
+ * press. Never per pointer move, and never per key repeat.
52
+ */
49
53
  readonly commit: (size: ResizeSize) => void;
50
54
  /** Accessible label. */
51
55
  readonly label: string;
@@ -127,6 +131,21 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
127
131
  event.preventDefault();
128
132
  });
129
133
 
134
+ // The size the current key gesture has applied but not yet persisted. The
135
+ // pointer path can commit inline because a drag has one unambiguous end;
136
+ // a key press does not, so the gesture's result is held here until it does.
137
+ let pending: ResizeSize | null = null;
138
+
139
+ /** End a key gesture: persist what it applied, once. */
140
+ const settle = (): void => {
141
+ if (pending === null) {
142
+ return;
143
+ }
144
+ const size = pending;
145
+ pending = null;
146
+ options.commit(size);
147
+ };
148
+
130
149
  // Keyboard parity: a pointer-only resize is unreachable without a mouse, and
131
150
  // this control has no equivalent elsewhere in the UI.
132
151
  handle.addEventListener("keydown", (event: KeyboardEvent) => {
@@ -155,9 +174,20 @@ export function createResizeHandle(options: ResizeOptions): HTMLDivElement {
155
174
  return;
156
175
  }
157
176
  event.preventDefault();
177
+ // Live feedback per key event, persistence only when the gesture ends:
178
+ // `commit` promises one call per completed resize, and a held arrow key
179
+ // repeats at the OS rate (20-30 events a second), so committing here would
180
+ // put that many storage writes or PATCHes behind a single press — landing
181
+ // hardest on the keyboard users this path exists for.
158
182
  options.apply(next);
159
- options.commit(next);
183
+ pending = next;
160
184
  });
161
185
 
186
+ // The key coming up ends the gesture, mirroring `pointerup`. `blur` closes one
187
+ // whose keyup never arrives here — focus moved on mid-press — because a size
188
+ // that was applied but never committed is a resize the host silently forgets.
189
+ handle.addEventListener("keyup", settle);
190
+ handle.addEventListener("blur", settle);
191
+
162
192
  return handle;
163
193
  }
@@ -80,6 +80,12 @@ export interface UiStrings {
80
80
  transcribing: string;
81
81
  /** Mic button fallback message when transcription fails. */
82
82
  transcriptionFailed: string;
83
+ /**
84
+ * Mic button message after a recording hit its length cap and stopped itself.
85
+ * The clip is kept and transcribed, so this explains the silence rather than
86
+ * reporting a loss. Token: `{n}` (the cap, in minutes).
87
+ */
88
+ recordingLimit: string;
83
89
 
84
90
  // ── Tool-call card ──────────────────────────────────────────────────────────
85
91
  /** Status pill while the call runs. */
@@ -171,20 +177,16 @@ export interface UiStrings {
171
177
  /** Remove-attachment button `aria-label`. */
172
178
  removeAttachment: string;
173
179
 
174
- // ── Relative time (drawer rows) ─────────────────────────────────────────────
175
- /** Under a minute ago. */
176
- justNow: string;
177
- /** Minutes ago. Token: `{n}`. */
178
- minutesAgo: string;
179
- /** Hours ago. Token: `{n}`. */
180
- hoursAgo: string;
181
- /** Title of the checkpoint panel. */
180
+ // ── Code blocks ─────────────────────────────────────────────────────────────
182
181
  /** Label on a code block's copy button. */
183
182
  copyCode: string;
184
183
  /** Shown on the copy button after the code reached the clipboard. */
185
184
  copied: string;
186
185
  /** Shown when the clipboard was unavailable or refused the write. */
187
186
  copyFailed: string;
187
+
188
+ // ── Checkpoint panel (continue a run) ───────────────────────────────────────
189
+ /** Title of the checkpoint panel. */
188
190
  checkpoints: string;
189
191
  /** Empty state when no run can be continued. */
190
192
  noCheckpoints: string;
@@ -194,6 +196,14 @@ export interface UiStrings {
194
196
  forkRun: string;
195
197
  /** Badge on a run that branched from another. */
196
198
  forkedRun: string;
199
+
200
+ // ── Relative time (drawer rows) ─────────────────────────────────────────────
201
+ /** Under a minute ago. */
202
+ justNow: string;
203
+ /** Minutes ago. Token: `{n}`. */
204
+ minutesAgo: string;
205
+ /** Hours ago. Token: `{n}`. */
206
+ hoursAgo: string;
197
207
  /** Days ago. Token: `{n}`. */
198
208
  daysAgo: string;
199
209
  /** Weeks ago. Token: `{n}`. */
@@ -244,6 +254,7 @@ export const DEFAULT_UI_STRINGS: UiStrings = {
244
254
  stopRecording: "Stop recording",
245
255
  transcribing: "Transcribing…",
246
256
  transcriptionFailed: "Transcription failed",
257
+ recordingLimit: "Stopped at the {n}-minute limit — transcribing what was recorded.",
247
258
 
248
259
  toolRunning: "running…",
249
260
  toolDeferred: "waiting for you",
@@ -5,6 +5,21 @@ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
5
5
  /** Lifecycle of the mic button, reflected on its `data-state` for CSS. */
6
6
  type VoiceState = "idle" | "recording" | "transcribing";
7
7
 
8
+ /**
9
+ * How long one recording may run before it stops itself.
10
+ *
11
+ * Two minutes is a ceiling, not a target: dictating into a chat composer is a
12
+ * sentence or two, and two minutes of speech is roughly 350 words — longer than
13
+ * any message this control is for. What it bounds is the case that has no end
14
+ * at all, a mic left live in a forgotten tab: about a megabyte of accumulated
15
+ * Opus, a recording indicator the user is no longer watching, and eventually a
16
+ * multipart upload no client-side check sizes.
17
+ *
18
+ * Hitting it stops and transcribes; the audio is never discarded, because a cap
19
+ * the user was not told about must not cost them the words they already spoke.
20
+ */
21
+ const MAX_RECORDING_MS = 120_000;
22
+
8
23
  /** Construction options for {@link VoiceInput}. */
9
24
  export interface VoiceInputOptions {
10
25
  /** Turns a recorded clip into text (the built-in or a custom transport). */
@@ -39,6 +54,8 @@ export class VoiceInput {
39
54
  #recorder: MediaRecorder | null = null;
40
55
  #stream: MediaStream | null = null;
41
56
  #chunks: Blob[] = [];
57
+ #capTimer: ReturnType<typeof setTimeout> | null = null;
58
+ #hitCap = false;
42
59
  #disposed = false;
43
60
 
44
61
  constructor(options: VoiceInputOptions) {
@@ -84,6 +101,7 @@ export class VoiceInput {
84
101
  }
85
102
  this.#stream = stream;
86
103
  this.#chunks = [];
104
+ this.#hitCap = false;
87
105
  const recorder = new MediaRecorder(stream);
88
106
  recorder.addEventListener("dataavailable", (event) => {
89
107
  this.#chunks.push(event.data);
@@ -93,14 +111,29 @@ export class VoiceInput {
93
111
  });
94
112
  this.#recorder = recorder;
95
113
  recorder.start();
114
+ // Nothing else ends a recording: `MediaRecorder` runs until it is told to
115
+ // stop, so without this the only exits are a second click and `dispose()`.
116
+ this.#capTimer = setTimeout(() => {
117
+ this.#hitCap = true;
118
+ this.#stop();
119
+ }, MAX_RECORDING_MS);
96
120
  this.#setState("recording");
97
121
  }
98
122
 
99
123
  #stop(): void {
124
+ this.#clearCap();
100
125
  // ``stop`` flushes a final ``dataavailable`` then fires ``stop`` → #finish.
101
126
  this.#recorder?.stop();
102
127
  }
103
128
 
129
+ /** Drop the cap timer; recording is over, by whichever route. */
130
+ #clearCap(): void {
131
+ if (this.#capTimer !== null) {
132
+ clearTimeout(this.#capTimer);
133
+ this.#capTimer = null;
134
+ }
135
+ }
136
+
104
137
  /**
105
138
  * Tear the control down, for a host element removed mid-recording. Stops any
106
139
  * live `MediaRecorder`, releases the mic tracks so the browser's recording
@@ -109,6 +142,7 @@ export class VoiceInput {
109
142
  */
110
143
  dispose(): void {
111
144
  this.#disposed = true;
145
+ this.#clearCap();
112
146
  if (this.#recorder !== null && this.#recorder.state !== "inactive") {
113
147
  this.#recorder.stop();
114
148
  }
@@ -126,6 +160,15 @@ export class VoiceInput {
126
160
  try {
127
161
  const text = await this.#transcribe(audio);
128
162
  this.#setState("idle");
163
+ if (this.#hitCap) {
164
+ // #setState has just reset the tooltip to the idle label, so this goes
165
+ // after it. It says why the mic went quiet on its own — the transcript
166
+ // below is the proof nothing was thrown away.
167
+ this.element.title = this.#strings.recordingLimit.replace(
168
+ "{n}",
169
+ String(MAX_RECORDING_MS / 60_000),
170
+ );
171
+ }
129
172
  if (text !== "") {
130
173
  this.#onText(text);
131
174
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.26.1";
1
+ export const VERSION: string = "0.28.0";