@ai-gui/core 0.1.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/LICENSE +21 -0
- package/README.md +44 -0
- package/dist/index.cjs +645 -0
- package/dist/index.d.cts +245 -0
- package/dist/index.d.ts +245 -0
- package/dist/index.js +611 -0
- package/package.json +56 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import DOMPurify from "dompurify";
|
|
2
|
+
import MarkdownIt from "markdown-it";
|
|
3
|
+
|
|
4
|
+
//#region src/partial-json.ts
|
|
5
|
+
/**
|
|
6
|
+
* Parse a possibly-incomplete JSON string, as produced by a streaming source.
|
|
7
|
+
*
|
|
8
|
+
* If the input is valid JSON, it is returned with `complete: true`. Otherwise
|
|
9
|
+
* the parser attempts to repair the fragment by dropping any trailing
|
|
10
|
+
* incomplete token (a dangling key, colon, or comma) and auto-closing unclosed
|
|
11
|
+
* strings, objects, and arrays, returning whatever data has arrived so far with
|
|
12
|
+
* `complete: false`. Unparseable input yields `{ data: undefined, complete: false }`
|
|
13
|
+
* and never throws.
|
|
14
|
+
*/
|
|
15
|
+
function parsePartialJSON(input) {
|
|
16
|
+
const str = input.trim();
|
|
17
|
+
if (str === "") return {
|
|
18
|
+
data: void 0,
|
|
19
|
+
complete: false
|
|
20
|
+
};
|
|
21
|
+
try {
|
|
22
|
+
return {
|
|
23
|
+
data: JSON.parse(str),
|
|
24
|
+
complete: true
|
|
25
|
+
};
|
|
26
|
+
} catch {}
|
|
27
|
+
for (const candidate of repair(str)) try {
|
|
28
|
+
return {
|
|
29
|
+
data: JSON.parse(candidate),
|
|
30
|
+
complete: false
|
|
31
|
+
};
|
|
32
|
+
} catch {}
|
|
33
|
+
return {
|
|
34
|
+
data: void 0,
|
|
35
|
+
complete: false
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const LITERAL_CHAR = /[0-9a-zA-Z+.\-]/;
|
|
39
|
+
const WHITESPACE = /\s/;
|
|
40
|
+
function repair(str) {
|
|
41
|
+
const stack = [];
|
|
42
|
+
let inString = false;
|
|
43
|
+
let escaped = false;
|
|
44
|
+
let stringIsKey = false;
|
|
45
|
+
let inLiteral = false;
|
|
46
|
+
let mode = "value";
|
|
47
|
+
let lastGoodEnd = -1;
|
|
48
|
+
for (let i = 0; i < str.length; i++) {
|
|
49
|
+
const ch = str[i];
|
|
50
|
+
if (inString) {
|
|
51
|
+
if (escaped) escaped = false;
|
|
52
|
+
else if (ch === "\\") escaped = true;
|
|
53
|
+
else if (ch === "\"") {
|
|
54
|
+
inString = false;
|
|
55
|
+
if (stringIsKey) mode = "objColon";
|
|
56
|
+
else {
|
|
57
|
+
mode = "afterValue";
|
|
58
|
+
lastGoodEnd = i + 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (inLiteral) {
|
|
64
|
+
if (LITERAL_CHAR.test(ch)) continue;
|
|
65
|
+
inLiteral = false;
|
|
66
|
+
mode = "afterValue";
|
|
67
|
+
lastGoodEnd = i;
|
|
68
|
+
}
|
|
69
|
+
if (WHITESPACE.test(ch)) continue;
|
|
70
|
+
if (ch === "}" || ch === "]") {
|
|
71
|
+
stack.pop();
|
|
72
|
+
mode = "afterValue";
|
|
73
|
+
lastGoodEnd = i + 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
switch (mode) {
|
|
77
|
+
case "value":
|
|
78
|
+
if (ch === "{") {
|
|
79
|
+
stack.push("obj");
|
|
80
|
+
mode = "objKey";
|
|
81
|
+
lastGoodEnd = i + 1;
|
|
82
|
+
} else if (ch === "[") {
|
|
83
|
+
stack.push("arr");
|
|
84
|
+
mode = "value";
|
|
85
|
+
lastGoodEnd = i + 1;
|
|
86
|
+
} else if (ch === "\"") {
|
|
87
|
+
inString = true;
|
|
88
|
+
stringIsKey = false;
|
|
89
|
+
} else inLiteral = true;
|
|
90
|
+
break;
|
|
91
|
+
case "objKey":
|
|
92
|
+
if (ch === "\"") {
|
|
93
|
+
inString = true;
|
|
94
|
+
stringIsKey = true;
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
case "objColon":
|
|
98
|
+
if (ch === ":") mode = "value";
|
|
99
|
+
break;
|
|
100
|
+
case "afterValue":
|
|
101
|
+
if (ch === ",") mode = stack[stack.length - 1] === "obj" ? "objKey" : "value";
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (inString && !stringIsKey) return [closeContainers(str + "\"", stack)];
|
|
106
|
+
const fallback = lastGoodEnd === -1 ? [] : [closeContainers(str.slice(0, lastGoodEnd), stack)];
|
|
107
|
+
if (inLiteral) return [closeContainers(str, stack), ...fallback];
|
|
108
|
+
return fallback;
|
|
109
|
+
}
|
|
110
|
+
function closeContainers(body, stack) {
|
|
111
|
+
let out = body.replace(/,\s*$/, "");
|
|
112
|
+
for (let i = stack.length - 1; i >= 0; i--) out += stack[i] === "obj" ? "}" : "]";
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/repair-markdown.ts
|
|
118
|
+
/**
|
|
119
|
+
* Temporarily repair half-finished markdown so a partial streaming buffer
|
|
120
|
+
* renders smoothly. Operates on a copy of the buffer and returns a new string;
|
|
121
|
+
* it never mutates the input. Pure function, no dependencies.
|
|
122
|
+
*
|
|
123
|
+
* The repairs are intentionally conservative: only unambiguous unclosed
|
|
124
|
+
* inline/fence syntax is completed. Ambiguous constructs (e.g. a dangling
|
|
125
|
+
* link text `[docs`) are left untouched to avoid guessing wrong.
|
|
126
|
+
*/
|
|
127
|
+
function repairMarkdown(buffer) {
|
|
128
|
+
let out = buffer;
|
|
129
|
+
const fenceCount = (out.match(/^```/gm) ?? []).length;
|
|
130
|
+
if (fenceCount % 2 === 1) {
|
|
131
|
+
if (!out.endsWith("\n")) out += "\n";
|
|
132
|
+
out += "```";
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
const tick = (out.match(/`/g) ?? []).length;
|
|
136
|
+
if (tick % 2 === 1) out += "`";
|
|
137
|
+
const bold = (out.match(/\*\*/g) ?? []).length;
|
|
138
|
+
if (bold % 2 === 1) out += "**";
|
|
139
|
+
const strike = (out.match(/~~/g) ?? []).length;
|
|
140
|
+
if (strike % 2 === 1) out += "~~";
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/sanitizer.ts
|
|
146
|
+
/** Escape the HTML-significant characters so a string renders as inert text. */
|
|
147
|
+
function escapeHtml(html) {
|
|
148
|
+
return html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Sanitize an HTML string, stripping scripts and unsafe attributes.
|
|
152
|
+
*
|
|
153
|
+
* When a DOM is available (browser, or Node with jsdom) DOMPurify is used. In a
|
|
154
|
+
* bare Node environment without a global `window`, DOMPurify cannot run, so we
|
|
155
|
+
* fall back to escaping the HTML-significant characters. This never emits raw
|
|
156
|
+
* markup and never throws.
|
|
157
|
+
*/
|
|
158
|
+
function sanitizeHtml(html) {
|
|
159
|
+
if (typeof window === "undefined") return escapeHtml(html);
|
|
160
|
+
return DOMPurify.sanitize(html);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/card-registry.ts
|
|
165
|
+
var CardRegistry = class {
|
|
166
|
+
cards = new Map();
|
|
167
|
+
register(def) {
|
|
168
|
+
this.cards.set(def.type, def);
|
|
169
|
+
}
|
|
170
|
+
has(type) {
|
|
171
|
+
return this.cards.has(type);
|
|
172
|
+
}
|
|
173
|
+
getRender(type) {
|
|
174
|
+
return this.cards.get(type)?.render;
|
|
175
|
+
}
|
|
176
|
+
parse(type, rawJson) {
|
|
177
|
+
const def = this.cards.get(type);
|
|
178
|
+
const { data, complete } = parsePartialJSON(rawJson);
|
|
179
|
+
if (!def) return {
|
|
180
|
+
data,
|
|
181
|
+
complete,
|
|
182
|
+
valid: false
|
|
183
|
+
};
|
|
184
|
+
const valid = complete && this.validate(def, data);
|
|
185
|
+
return {
|
|
186
|
+
data,
|
|
187
|
+
complete,
|
|
188
|
+
valid
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
validate(def, data) {
|
|
192
|
+
if (def.validate) return def.validate(data);
|
|
193
|
+
if (def.schema) return validateSchema(def.schema, data);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
toPromptSpec() {
|
|
197
|
+
const lines = ["You can output cards. Format: a ```card:<type> fenced block with JSON inside. Available cards:"];
|
|
198
|
+
for (const def of this.cards.values()) {
|
|
199
|
+
lines.push(`- \`card:${def.type}\`: ${def.description}`);
|
|
200
|
+
if (def.schema?.properties) {
|
|
201
|
+
const fields = Object.entries(def.schema.properties).map(([k, v]) => `${k}(${v.type ?? "any"})`).join(", ");
|
|
202
|
+
lines.push(` fields: ${fields}`);
|
|
203
|
+
}
|
|
204
|
+
if (def.example !== void 0) lines.push(` example: ${JSON.stringify(def.example)}`);
|
|
205
|
+
}
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
toJSONSchema() {
|
|
209
|
+
const properties = {};
|
|
210
|
+
for (const def of this.cards.values()) if (def.schema) properties[def.type] = def.schema;
|
|
211
|
+
return {
|
|
212
|
+
type: "object",
|
|
213
|
+
properties
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
/** Minimal JSON Schema validation: covers type / required / properties, enough for cards. */
|
|
218
|
+
function validateSchema(schema, data) {
|
|
219
|
+
if (schema.type === "object") {
|
|
220
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return false;
|
|
221
|
+
const obj = data;
|
|
222
|
+
for (const req of schema.required ?? []) if (!(req in obj)) return false;
|
|
223
|
+
for (const [key, sub] of Object.entries(schema.properties ?? {})) if (key in obj && !validateSchema(sub, obj[key])) return false;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
if (schema.type === "array") {
|
|
227
|
+
if (!Array.isArray(data)) return false;
|
|
228
|
+
return schema.items ? data.every((d) => validateSchema(schema.items, d)) : true;
|
|
229
|
+
}
|
|
230
|
+
if (schema.type === "string") return typeof data === "string";
|
|
231
|
+
if (schema.type === "number") return typeof data === "number";
|
|
232
|
+
if (schema.type === "boolean") return typeof data === "boolean";
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/plugins.ts
|
|
238
|
+
/** Merge every plugin's `nodeRenderers` into a single map (later plugins win). */
|
|
239
|
+
function collectNodeRenderers(plugins = []) {
|
|
240
|
+
const map = {};
|
|
241
|
+
for (const p of plugins) for (const [k, v] of Object.entries(p.nodeRenderers ?? {})) map[k] = v;
|
|
242
|
+
return map;
|
|
243
|
+
}
|
|
244
|
+
/** The set of node types claimed by the given plugins. */
|
|
245
|
+
function pluginNodeTypes(plugins = []) {
|
|
246
|
+
return new Set(Object.keys(collectNodeRenderers(plugins)));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region src/parser.ts
|
|
251
|
+
/** Build a parser that turns markdown source into a flat list of ASTNodes. */
|
|
252
|
+
function createParser(options = {}) {
|
|
253
|
+
const md = new MarkdownIt({
|
|
254
|
+
html: true,
|
|
255
|
+
linkify: true
|
|
256
|
+
});
|
|
257
|
+
options.configureMd?.(md);
|
|
258
|
+
for (const plugin of options.plugins ?? []) plugin.extendParser?.(md);
|
|
259
|
+
const pluginTypes = pluginNodeTypes(options.plugins);
|
|
260
|
+
return (src) => {
|
|
261
|
+
const tokens = md.parse(src, {});
|
|
262
|
+
const nodes = [];
|
|
263
|
+
let index = 0;
|
|
264
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
265
|
+
const t = tokens[i];
|
|
266
|
+
if (t.type === "fence") {
|
|
267
|
+
const info = t.info.trim();
|
|
268
|
+
if (info.startsWith("card:") && options.registry) {
|
|
269
|
+
const cardType = info.slice(5);
|
|
270
|
+
const res = options.registry.parse(cardType, t.content);
|
|
271
|
+
nodes.push({
|
|
272
|
+
key: `${index++}:card`,
|
|
273
|
+
type: "card",
|
|
274
|
+
card: {
|
|
275
|
+
type: cardType,
|
|
276
|
+
data: res.data,
|
|
277
|
+
complete: res.complete,
|
|
278
|
+
valid: res.valid
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
} else if (pluginTypes.has(info)) nodes.push({
|
|
282
|
+
key: `${index++}:${info}`,
|
|
283
|
+
type: info,
|
|
284
|
+
content: t.content,
|
|
285
|
+
attrs: { info }
|
|
286
|
+
});
|
|
287
|
+
else nodes.push({
|
|
288
|
+
key: `${index++}:code`,
|
|
289
|
+
type: "code",
|
|
290
|
+
tag: "code",
|
|
291
|
+
attrs: info ? { lang: info } : void 0,
|
|
292
|
+
content: t.content
|
|
293
|
+
});
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (t.type === "hr") {
|
|
297
|
+
nodes.push({
|
|
298
|
+
key: `${index++}:hr`,
|
|
299
|
+
type: "hr",
|
|
300
|
+
tag: "hr"
|
|
301
|
+
});
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (t.type === "code_block") {
|
|
305
|
+
nodes.push({
|
|
306
|
+
key: `${index++}:code`,
|
|
307
|
+
type: "code",
|
|
308
|
+
tag: "code",
|
|
309
|
+
content: t.content
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (t.type === "html_block") {
|
|
314
|
+
nodes.push({
|
|
315
|
+
key: `${index++}:html`,
|
|
316
|
+
type: "html",
|
|
317
|
+
content: t.content
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (t.type === "heading_open") {
|
|
322
|
+
const inline = tokens[i + 1];
|
|
323
|
+
const raw = inline?.content ?? "";
|
|
324
|
+
nodes.push({
|
|
325
|
+
key: `${index++}:heading`,
|
|
326
|
+
type: "heading",
|
|
327
|
+
tag: t.tag,
|
|
328
|
+
content: raw,
|
|
329
|
+
html: md.renderInline(raw)
|
|
330
|
+
});
|
|
331
|
+
i += 2;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (t.type === "paragraph_open") {
|
|
335
|
+
const inline = tokens[i + 1];
|
|
336
|
+
const raw = inline?.content ?? "";
|
|
337
|
+
nodes.push({
|
|
338
|
+
key: `${index++}:paragraph`,
|
|
339
|
+
type: "paragraph",
|
|
340
|
+
tag: "p",
|
|
341
|
+
content: raw,
|
|
342
|
+
html: md.renderInline(raw)
|
|
343
|
+
});
|
|
344
|
+
i += 2;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (t.type.endsWith("_open") && t.level === 0) {
|
|
348
|
+
const closeType = t.type.replace("_open", "_close");
|
|
349
|
+
let j = i;
|
|
350
|
+
let depth = 0;
|
|
351
|
+
for (; j < tokens.length; j++) {
|
|
352
|
+
if (tokens[j].type === t.type) depth++;
|
|
353
|
+
if (tokens[j].type === closeType) {
|
|
354
|
+
depth--;
|
|
355
|
+
if (depth === 0) break;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const slice = tokens.slice(i, j + 1);
|
|
359
|
+
nodes.push({
|
|
360
|
+
key: `${index++}:${t.type}`,
|
|
361
|
+
type: "html",
|
|
362
|
+
content: md.renderer.render(slice, md.options, {})
|
|
363
|
+
});
|
|
364
|
+
i = j;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (t.block && t.level === 0) {
|
|
368
|
+
nodes.push({
|
|
369
|
+
key: `${index++}:${t.type}`,
|
|
370
|
+
type: "html",
|
|
371
|
+
content: md.renderer.render([t], md.options, {})
|
|
372
|
+
});
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return nodes;
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/diff.ts
|
|
382
|
+
/** Produce a minimal set of patches turning `prev` into `next`, keyed by node key. */
|
|
383
|
+
function diffAst(prev, next) {
|
|
384
|
+
const patches = [];
|
|
385
|
+
const prevByKey = new Map(prev.map((n) => [n.key, n]));
|
|
386
|
+
const nextKeys = new Set(next.map((n) => n.key));
|
|
387
|
+
next.forEach((node, index) => {
|
|
388
|
+
const old = prevByKey.get(node.key);
|
|
389
|
+
if (!old) patches.push({
|
|
390
|
+
op: "insert",
|
|
391
|
+
index,
|
|
392
|
+
node
|
|
393
|
+
});
|
|
394
|
+
else if (!nodeEqual(old, node)) patches.push({
|
|
395
|
+
op: "update",
|
|
396
|
+
key: node.key,
|
|
397
|
+
node
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
for (const node of prev) if (!nextKeys.has(node.key)) patches.push({
|
|
401
|
+
op: "remove",
|
|
402
|
+
key: node.key
|
|
403
|
+
});
|
|
404
|
+
return patches;
|
|
405
|
+
}
|
|
406
|
+
function nodeEqual(a, b) {
|
|
407
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/renderer.ts
|
|
412
|
+
/**
|
|
413
|
+
* Streaming render orchestrator: accumulate incoming markdown chunks, repair the
|
|
414
|
+
* partial buffer, parse it into an AST, diff against the previous AST, and emit
|
|
415
|
+
* the resulting patches via `onPatch`.
|
|
416
|
+
*/
|
|
417
|
+
var Renderer = class {
|
|
418
|
+
buffer = "";
|
|
419
|
+
prevAst = [];
|
|
420
|
+
parse;
|
|
421
|
+
options;
|
|
422
|
+
sanitize;
|
|
423
|
+
constructor(options = {}) {
|
|
424
|
+
this.options = options;
|
|
425
|
+
this.sanitize = options.sanitize !== false;
|
|
426
|
+
if (options.registry) for (const plugin of options.plugins ?? []) for (const card of plugin.cards ?? []) options.registry.register(card);
|
|
427
|
+
this.parse = createParser({
|
|
428
|
+
registry: options.registry,
|
|
429
|
+
plugins: options.plugins
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
push(chunk) {
|
|
433
|
+
this.buffer += chunk;
|
|
434
|
+
this.render();
|
|
435
|
+
}
|
|
436
|
+
async feed(source) {
|
|
437
|
+
if (Symbol.asyncIterator in source) {
|
|
438
|
+
for await (const chunk of source) this.push(chunk);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const reader = source.getReader();
|
|
442
|
+
for (;;) {
|
|
443
|
+
const { done, value } = await reader.read();
|
|
444
|
+
if (done) break;
|
|
445
|
+
if (value != null) this.push(value);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
reset() {
|
|
449
|
+
this.buffer = "";
|
|
450
|
+
this.prevAst = [];
|
|
451
|
+
}
|
|
452
|
+
render() {
|
|
453
|
+
const repaired = repairMarkdown(this.buffer);
|
|
454
|
+
const nextAst = this.parse(repaired);
|
|
455
|
+
if (this.sanitize) sanitizeNodes(nextAst);
|
|
456
|
+
const patches = diffAst(this.prevAst, nextAst);
|
|
457
|
+
this.prevAst = nextAst;
|
|
458
|
+
if (patches.length > 0) this.options.onPatch?.(patches, nextAst);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
/**
|
|
462
|
+
* Recursively sanitize node markup in place: the content of `html` nodes and
|
|
463
|
+
* the rendered inline `html` field carried by any node.
|
|
464
|
+
*/
|
|
465
|
+
function sanitizeNodes(nodes) {
|
|
466
|
+
for (const node of nodes) {
|
|
467
|
+
if (node.type === "html" && typeof node.content === "string") node.content = sanitizeHtml(node.content);
|
|
468
|
+
if (node.html) node.html = sanitizeHtml(node.html);
|
|
469
|
+
if (node.children) sanitizeNodes(node.children);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
//#endregion
|
|
474
|
+
//#region src/stream-router.ts
|
|
475
|
+
const DEFAULT_CHANNEL = "content";
|
|
476
|
+
/**
|
|
477
|
+
* Demultiplexes one incoming stream into multiple named channels so different
|
|
478
|
+
* UI regions can update independently (e.g. `progress` + `content` from a single
|
|
479
|
+
* SSE). Framing is auto-detected per line and supports:
|
|
480
|
+
*
|
|
481
|
+
* 1. JSON envelope: `{"ch":"<channel>","delta":"text"}` (a text delta) or
|
|
482
|
+
* `{"ch":"<channel>","data":<any>}` (a structured value). May appear bare or
|
|
483
|
+
* after a `data: ` prefix.
|
|
484
|
+
* 2. SSE `event: <name>` sets the channel for the following `data:` line(s).
|
|
485
|
+
* 3. A plain `data: <text>` line with no `ch` and no preceding `event:` is a
|
|
486
|
+
* text delta on the default `content` channel.
|
|
487
|
+
*/
|
|
488
|
+
var StreamRouter = class {
|
|
489
|
+
sinks = new Map();
|
|
490
|
+
handlers = new Map();
|
|
491
|
+
/** Bind a text-stream sink to a channel: text deltas call `sink.push(delta)`. */
|
|
492
|
+
channel(name, sink) {
|
|
493
|
+
this.sinks.set(name, sink);
|
|
494
|
+
return this;
|
|
495
|
+
}
|
|
496
|
+
/** Bind a handler to a channel: data values (and deltas as strings) call it. */
|
|
497
|
+
on(name, handler) {
|
|
498
|
+
this.handlers.set(name, handler);
|
|
499
|
+
return this;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Consume a stream to completion, dispatching each parsed line to its channel.
|
|
503
|
+
* Accepts an async iterable of strings, or a `ReadableStream` of either strings
|
|
504
|
+
* or `Uint8Array` bytes (decoded as UTF-8).
|
|
505
|
+
*/
|
|
506
|
+
async feed(source) {
|
|
507
|
+
let buffer = "";
|
|
508
|
+
let currentEvent;
|
|
509
|
+
const consume = (chunk) => {
|
|
510
|
+
buffer += chunk;
|
|
511
|
+
let index;
|
|
512
|
+
while ((index = buffer.indexOf("\n")) !== -1) {
|
|
513
|
+
const line = buffer.slice(0, index);
|
|
514
|
+
buffer = buffer.slice(index + 1);
|
|
515
|
+
currentEvent = this.processLine(line, currentEvent);
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
if ("getReader" in source && typeof source.getReader === "function") {
|
|
519
|
+
const reader = source.getReader();
|
|
520
|
+
const decoder = new TextDecoder();
|
|
521
|
+
for (;;) {
|
|
522
|
+
const { done, value } = await reader.read();
|
|
523
|
+
if (done) break;
|
|
524
|
+
if (value == null) continue;
|
|
525
|
+
consume(typeof value === "string" ? value : decoder.decode(value, { stream: true }));
|
|
526
|
+
}
|
|
527
|
+
const tail = decoder.decode();
|
|
528
|
+
if (tail) consume(tail);
|
|
529
|
+
} else for await (const chunk of source) consume(chunk);
|
|
530
|
+
if (buffer.length > 0) this.processLine(buffer, currentEvent);
|
|
531
|
+
}
|
|
532
|
+
/** Process a single raw line; returns the (possibly updated) current event. */
|
|
533
|
+
processLine(rawLine, currentEvent) {
|
|
534
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
535
|
+
if (line.trim() === "") return void 0;
|
|
536
|
+
if (line.startsWith("event:")) return line.slice(6).trim();
|
|
537
|
+
let payload = line;
|
|
538
|
+
if (payload.startsWith("data:")) {
|
|
539
|
+
payload = payload.slice(5);
|
|
540
|
+
if (payload.startsWith(" ")) payload = payload.slice(1);
|
|
541
|
+
}
|
|
542
|
+
if (payload.startsWith("{")) {
|
|
543
|
+
const parsed = tryParseJson(payload);
|
|
544
|
+
if (parsed !== void 0 && isRecord(parsed) && typeof parsed.ch === "string") {
|
|
545
|
+
if ("delta" in parsed) this.routeDelta(parsed.ch, String(parsed.delta));
|
|
546
|
+
else this.routeData(parsed.ch, parsed.data);
|
|
547
|
+
return currentEvent;
|
|
548
|
+
}
|
|
549
|
+
if (parsed !== void 0) {
|
|
550
|
+
this.routeData(currentEvent ?? DEFAULT_CHANNEL, parsed);
|
|
551
|
+
return currentEvent;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (currentEvent !== void 0) {
|
|
555
|
+
const parsed = tryParseJson(payload);
|
|
556
|
+
this.routeData(currentEvent, parsed !== void 0 ? parsed : payload);
|
|
557
|
+
return currentEvent;
|
|
558
|
+
}
|
|
559
|
+
this.routeDelta(DEFAULT_CHANNEL, payload);
|
|
560
|
+
return currentEvent;
|
|
561
|
+
}
|
|
562
|
+
/** Route a text delta: to a bound sink and/or an on() handler (either/both). */
|
|
563
|
+
routeDelta(channel, text) {
|
|
564
|
+
const sink = this.sinks.get(channel);
|
|
565
|
+
const handler = this.handlers.get(channel);
|
|
566
|
+
if (sink) sink.push(text);
|
|
567
|
+
if (handler) handler(text);
|
|
568
|
+
}
|
|
569
|
+
/** Route a structured value: to the handler, or a string value to a lone sink. */
|
|
570
|
+
routeData(channel, value) {
|
|
571
|
+
const handler = this.handlers.get(channel);
|
|
572
|
+
if (handler) {
|
|
573
|
+
handler(value);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const sink = this.sinks.get(channel);
|
|
577
|
+
if (sink && typeof value === "string") sink.push(value);
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
function tryParseJson(text) {
|
|
581
|
+
try {
|
|
582
|
+
return JSON.parse(text);
|
|
583
|
+
} catch {
|
|
584
|
+
return void 0;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function isRecord(value) {
|
|
588
|
+
return typeof value === "object" && value !== null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region src/build-system-prompt.ts
|
|
593
|
+
/**
|
|
594
|
+
* Assembles the LLM system-prompt guidance: an optional base, the registered
|
|
595
|
+
* cards' spec, and every plugin's promptSpec. Consumers prepend this to their
|
|
596
|
+
* own system prompt so the model knows which fenced blocks it may emit.
|
|
597
|
+
*/
|
|
598
|
+
function buildSystemPrompt(options = {}) {
|
|
599
|
+
const parts = [];
|
|
600
|
+
if (options.base) parts.push(options.base);
|
|
601
|
+
const cardSpec = options.registry?.toPromptSpec();
|
|
602
|
+
if (options.registry && hasCards(options.registry) && cardSpec) parts.push(cardSpec);
|
|
603
|
+
for (const p of options.plugins ?? []) if (p.promptSpec) parts.push(p.promptSpec);
|
|
604
|
+
return parts.join("\n\n");
|
|
605
|
+
}
|
|
606
|
+
function hasCards(registry) {
|
|
607
|
+
return registry.toPromptSpec().includes("card:");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
//#endregion
|
|
611
|
+
export { CardRegistry, Renderer, StreamRouter, buildSystemPrompt, collectNodeRenderers, createParser, diffAst, parsePartialJSON, pluginNodeTypes, repairMarkdown, sanitizeHtml };
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ai-gui/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless streaming renderer core for LLM-generated UI — markdown/JSON repair, card registry, AST diff, plugin engine, sanitizer.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"llm",
|
|
7
|
+
"ai",
|
|
8
|
+
"streaming",
|
|
9
|
+
"markdown",
|
|
10
|
+
"renderer",
|
|
11
|
+
"headless",
|
|
12
|
+
"sanitizer",
|
|
13
|
+
"ast-diff",
|
|
14
|
+
"aigui"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Liang Li <ll_faw@hotmail.com>",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/liliang-cn/aigui.git",
|
|
21
|
+
"directory": "packages/core"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/liliang-cn/aigui#readme",
|
|
24
|
+
"bugs": "https://github.com/liliang-cn/aigui/issues",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./dist/index.cjs",
|
|
27
|
+
"module": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"require": "./dist/index.cjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"dompurify": "^3.4.12",
|
|
46
|
+
"markdown-it": "^14.1.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/markdown-it": "^14.1.2",
|
|
50
|
+
"jsdom": "^29.1.1"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsdown",
|
|
54
|
+
"typecheck": "tsc --noEmit"
|
|
55
|
+
}
|
|
56
|
+
}
|