@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.
- package/CHANGELOG.md +291 -1
- package/README.md +191 -8
- package/dist/ag-ui-web-component.bundle.js +50 -50
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +61 -3
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +8 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +58 -3
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/create_http_agent.d.ts +13 -0
- package/dist/core/create_http_agent.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +29 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/utils.d.ts +42 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.js +602 -104
- package/dist/index.js.map +4 -4
- package/dist/tools/is_destructive.d.ts +8 -2
- package/dist/tools/is_destructive.d.ts.map +1 -1
- package/dist/tools/parse_tool_catalog.d.ts +11 -4
- package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
- package/dist/ui/render_markdown.d.ts +23 -5
- package/dist/ui/render_markdown.d.ts.map +1 -1
- package/dist/ui/resize_handle.d.ts +5 -1
- package/dist/ui/resize_handle.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +13 -7
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/dist/ui/voice_input.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +444 -45
- package/src/core/agui_client.ts +43 -2
- package/src/core/conversation_store.ts +146 -49
- package/src/core/create_http_agent.ts +24 -2
- package/src/core/remote_conversation_store.ts +45 -3
- package/src/core/utils.ts +83 -1
- package/src/tools/is_destructive.ts +8 -2
- package/src/tools/parse_tool_catalog.ts +18 -6
- package/src/ui/render_markdown.ts +111 -21
- package/src/ui/resize_handle.ts +32 -2
- package/src/ui/ui_strings.ts +19 -8
- package/src/ui/voice_input.ts +43 -0
- 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
|
-
|
|
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
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
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
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
82
|
-
*
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
}
|
package/src/ui/resize_handle.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/src/ui/ui_strings.ts
CHANGED
|
@@ -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
|
-
// ──
|
|
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",
|
package/src/ui/voice_input.ts
CHANGED
|
@@ -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.
|
|
1
|
+
export const VERSION: string = "0.28.0";
|