ngx-json-render 0.1.4 → 0.2.1
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/README.md +76 -35
- package/fesm2022/ngx-json-render.mjs +444 -304
- package/fesm2022/ngx-json-render.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-json-render.d.ts +103 -50
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { signal, Injectable, inject, computed, untracked, effect, isDevMode, input, output, ChangeDetectionStrategy, Component, DestroyRef, InjectionToken, Injector, forwardRef } from '@angular/core';
|
|
3
|
-
import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, createDirectiveRegistry, resolveRepeatStatePath, resolveRepeatItemStatePath,
|
|
3
|
+
import { createStateStore, getByPath, runValidation, resolveAction, nextActionDispatchId, notifyActionDispatch, notifyActionSettle, executeAction, isDevtoolsActive, subscribeDevtoolsActive, evaluateVisibility, resolveElementProps, resolveBindings, resolveActionParam, createDirectiveRegistry, resolveRepeatStatePath, resolveRepeatItemStatePath, removeByPath, addByPath, setByPath, SPEC_DATA_PART_TYPE, nestedToFlat, createMixedStreamParser, defineSchema } from '@json-render/core';
|
|
4
4
|
export { createStateStore, nestedToFlat } from '@json-render/core';
|
|
5
5
|
import { flattenToPointers } from '@json-render/core/store-utils';
|
|
6
6
|
import { NgComponentOutlet } from '@angular/common';
|
|
@@ -863,12 +863,17 @@ class JrElement {
|
|
|
863
863
|
const el = this.rawElement();
|
|
864
864
|
if (!el)
|
|
865
865
|
return undefined;
|
|
866
|
-
return {
|
|
866
|
+
return {
|
|
867
|
+
...el,
|
|
868
|
+
props: resolveElementProps(el.props ?? {}, this.resolutionCtx()),
|
|
869
|
+
};
|
|
867
870
|
}, ...(ngDevMode ? [{ debugName: "resolvedElement" }] : /* istanbul ignore next */ []));
|
|
868
871
|
/** Two-way binding paths ($bindState / $bindItem) by prop name. */
|
|
869
872
|
bindings = computed(() => {
|
|
870
873
|
const el = this.rawElement();
|
|
871
|
-
return el
|
|
874
|
+
return el
|
|
875
|
+
? resolveBindings(el.props ?? {}, this.resolutionCtx())
|
|
876
|
+
: undefined;
|
|
872
877
|
}, ...(ngDevMode ? [{ debugName: "bindings" }] : /* istanbul ignore next */ []));
|
|
873
878
|
entry = computed(() => {
|
|
874
879
|
const el = this.rawElement();
|
|
@@ -1405,36 +1410,52 @@ function createStoreSetState(store) {
|
|
|
1405
1410
|
}
|
|
1406
1411
|
|
|
1407
1412
|
/**
|
|
1408
|
-
*
|
|
1413
|
+
* Split a JSON Pointer into its segments, undoing RFC 6901's escapes.
|
|
1414
|
+
*
|
|
1415
|
+
* `@json-render/core` parses pointers the same way for `setByPath` and
|
|
1416
|
+
* friends but does not export the helper, so {@link copyAlongPath} carries
|
|
1417
|
+
* its own copy. The two must agree on where a path descends, or a write would
|
|
1418
|
+
* land on a node that was never copied.
|
|
1409
1419
|
*/
|
|
1410
|
-
function
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1420
|
+
function parseJsonPointer(path) {
|
|
1421
|
+
const raw = path.startsWith('/') ? path.slice(1).split('/') : path.split('/');
|
|
1422
|
+
return raw.map((token) => token.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Replace every node `path` descends through with a shallow copy of itself.
|
|
1426
|
+
*
|
|
1427
|
+
* `root` must already be private to the caller. Afterwards a mutating write
|
|
1428
|
+
* along that same path — `setByPath`, `addByPath`, `removeByPath` — only ever
|
|
1429
|
+
* touches nodes nobody else is holding, while everything off the path stays
|
|
1430
|
+
* shared. That keeps a patch proportional to the depth of its path rather than
|
|
1431
|
+
* to the size of the spec, which matters when a stream applies hundreds of
|
|
1432
|
+
* them.
|
|
1433
|
+
*
|
|
1434
|
+
* Descent stops at anything that is not an object: the writers overwrite such
|
|
1435
|
+
* a node outright, and one that does not exist yet is created fresh, so in
|
|
1436
|
+
* neither case is there anything of the caller's left to protect.
|
|
1437
|
+
*/
|
|
1438
|
+
function copyAlongPath(root, path) {
|
|
1439
|
+
const segments = parseJsonPointer(path);
|
|
1440
|
+
let current = root;
|
|
1441
|
+
// The final segment is written, not descended into, so it needs no copy.
|
|
1442
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
1443
|
+
const segment = segments[i];
|
|
1444
|
+
const child = current[segment];
|
|
1445
|
+
if (child === null || typeof child !== 'object')
|
|
1446
|
+
return;
|
|
1447
|
+
const copy = Array.isArray(child)
|
|
1448
|
+
? [...child]
|
|
1449
|
+
: { ...child };
|
|
1450
|
+
current[segment] = copy;
|
|
1451
|
+
current = copy;
|
|
1432
1452
|
}
|
|
1433
1453
|
}
|
|
1434
1454
|
/**
|
|
1435
|
-
*
|
|
1455
|
+
* Write a value at a spec path, copying every node on the way so the caller's
|
|
1456
|
+
* previous spec keeps its own contents.
|
|
1436
1457
|
*/
|
|
1437
|
-
function
|
|
1458
|
+
function writeSpecValue(newSpec, path, value, write) {
|
|
1438
1459
|
if (path === '/root') {
|
|
1439
1460
|
newSpec.root = value;
|
|
1440
1461
|
return;
|
|
@@ -1447,7 +1468,9 @@ function setSpecValue(newSpec, path, value) {
|
|
|
1447
1468
|
if (!newSpec.state)
|
|
1448
1469
|
newSpec.state = {};
|
|
1449
1470
|
const statePath = path.slice('/state'.length); // e.g. "/posts"
|
|
1450
|
-
|
|
1471
|
+
const state = newSpec.state;
|
|
1472
|
+
copyAlongPath(state, statePath);
|
|
1473
|
+
write(state, statePath, value);
|
|
1451
1474
|
return;
|
|
1452
1475
|
}
|
|
1453
1476
|
if (path.startsWith('/elements/')) {
|
|
@@ -1457,21 +1480,32 @@ function setSpecValue(newSpec, path, value) {
|
|
|
1457
1480
|
return;
|
|
1458
1481
|
if (pathParts.length === 1) {
|
|
1459
1482
|
newSpec.elements[elementKey] = value;
|
|
1483
|
+
return;
|
|
1460
1484
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1485
|
+
// A prop path whose element is not there is dropped rather than
|
|
1486
|
+
// auto-created: the generic writers would happily conjure an element with
|
|
1487
|
+
// props and no `type`, which renders as nothing and warns about an unknown
|
|
1488
|
+
// component. A malformed patch should lose its own write, not add a broken
|
|
1489
|
+
// element to the tree.
|
|
1490
|
+
const element = newSpec.elements[elementKey];
|
|
1491
|
+
if (!element)
|
|
1492
|
+
return;
|
|
1493
|
+
const propPath = '/' + pathParts.slice(1).join('/');
|
|
1494
|
+
const newElement = { ...element };
|
|
1495
|
+
copyAlongPath(newElement, propPath);
|
|
1496
|
+
write(newElement, propPath, value);
|
|
1497
|
+
newSpec.elements[elementKey] = newElement;
|
|
1498
|
+
return;
|
|
1470
1499
|
}
|
|
1500
|
+
// Any other top-level path is applied verbatim. The spec grammar is
|
|
1501
|
+
// upstream's and grows without this renderer: dropping what we do not
|
|
1502
|
+
// recognise would silently discard a field a newer `@json-render/core`
|
|
1503
|
+
// understands.
|
|
1504
|
+
const root = newSpec;
|
|
1505
|
+
copyAlongPath(root, path);
|
|
1506
|
+
write(root, path, value);
|
|
1471
1507
|
}
|
|
1472
|
-
/**
|
|
1473
|
-
* Remove a value at a spec path.
|
|
1474
|
-
*/
|
|
1508
|
+
/** Remove the value at a spec path, with the same copy-on-write discipline. */
|
|
1475
1509
|
function removeSpecValue(newSpec, path) {
|
|
1476
1510
|
if (path === '/state') {
|
|
1477
1511
|
delete newSpec.state;
|
|
@@ -1479,7 +1513,9 @@ function removeSpecValue(newSpec, path) {
|
|
|
1479
1513
|
}
|
|
1480
1514
|
if (path.startsWith('/state/') && newSpec.state) {
|
|
1481
1515
|
const statePath = path.slice('/state'.length);
|
|
1482
|
-
|
|
1516
|
+
const state = newSpec.state;
|
|
1517
|
+
copyAlongPath(state, statePath);
|
|
1518
|
+
removeByPath(state, statePath);
|
|
1483
1519
|
return;
|
|
1484
1520
|
}
|
|
1485
1521
|
if (path.startsWith('/elements/')) {
|
|
@@ -1488,38 +1524,38 @@ function removeSpecValue(newSpec, path) {
|
|
|
1488
1524
|
if (!elementKey)
|
|
1489
1525
|
return;
|
|
1490
1526
|
if (pathParts.length === 1) {
|
|
1491
|
-
const { [elementKey]:
|
|
1527
|
+
const { [elementKey]: _removed, ...rest } = newSpec.elements;
|
|
1492
1528
|
newSpec.elements = rest;
|
|
1529
|
+
return;
|
|
1493
1530
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
/**
|
|
1506
|
-
* Get a value at a spec path.
|
|
1507
|
-
*/
|
|
1508
|
-
function getSpecValue(spec, path) {
|
|
1509
|
-
if (path === '/root')
|
|
1510
|
-
return spec.root;
|
|
1511
|
-
if (path === '/state')
|
|
1512
|
-
return spec.state;
|
|
1513
|
-
if (path.startsWith('/state/') && spec.state) {
|
|
1514
|
-
const statePath = path.slice('/state'.length);
|
|
1515
|
-
return getByPath(spec.state, statePath);
|
|
1531
|
+
const element = newSpec.elements[elementKey];
|
|
1532
|
+
if (!element)
|
|
1533
|
+
return;
|
|
1534
|
+
const propPath = '/' + pathParts.slice(1).join('/');
|
|
1535
|
+
const newElement = { ...element };
|
|
1536
|
+
copyAlongPath(newElement, propPath);
|
|
1537
|
+
removeByPath(newElement, propPath);
|
|
1538
|
+
newSpec.elements[elementKey] = newElement;
|
|
1539
|
+
return;
|
|
1516
1540
|
}
|
|
1517
|
-
|
|
1541
|
+
const root = newSpec;
|
|
1542
|
+
copyAlongPath(root, path);
|
|
1543
|
+
removeByPath(root, path);
|
|
1518
1544
|
}
|
|
1519
1545
|
/**
|
|
1520
|
-
* Apply an RFC 6902 JSON patch to
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
1546
|
+
* Apply an RFC 6902 JSON patch to a spec, returning a new spec object and
|
|
1547
|
+
* sharing every subtree the patch did not touch.
|
|
1548
|
+
*
|
|
1549
|
+
* This is the only patch engine in the package: `injectUIStream`,
|
|
1550
|
+
* `injectChatUI` and `buildSpecFromParts` all route through it, so the same
|
|
1551
|
+
* stream produces the same spec whichever one an app reached for. The write
|
|
1552
|
+
* primitives come from `@json-render/core`, so `add` splices into an array
|
|
1553
|
+
* where `replace` overwrites, exactly as the other renderers do.
|
|
1554
|
+
*
|
|
1555
|
+
* One deliberate deviation from RFC 6902: a failing `test` op is a no-op
|
|
1556
|
+
* rather than an abort. These patches come off a model's output, where the
|
|
1557
|
+
* established behaviour — see the line parser — is to drop what does not make
|
|
1558
|
+
* sense and keep rendering rather than kill a generation mid-flight.
|
|
1523
1559
|
*/
|
|
1524
1560
|
function applyPatch(spec, patch) {
|
|
1525
1561
|
const newSpec = {
|
|
@@ -1528,9 +1564,12 @@ function applyPatch(spec, patch) {
|
|
|
1528
1564
|
...(spec.state ? { state: { ...spec.state } } : {}),
|
|
1529
1565
|
};
|
|
1530
1566
|
switch (patch.op) {
|
|
1531
|
-
case 'add':
|
|
1567
|
+
case 'add': {
|
|
1568
|
+
writeSpecValue(newSpec, patch.path, patch.value, addByPath);
|
|
1569
|
+
break;
|
|
1570
|
+
}
|
|
1532
1571
|
case 'replace': {
|
|
1533
|
-
|
|
1572
|
+
writeSpecValue(newSpec, patch.path, patch.value, setByPath);
|
|
1534
1573
|
break;
|
|
1535
1574
|
}
|
|
1536
1575
|
case 'remove': {
|
|
@@ -1540,167 +1579,29 @@ function applyPatch(spec, patch) {
|
|
|
1540
1579
|
case 'move': {
|
|
1541
1580
|
if (!patch.from)
|
|
1542
1581
|
break;
|
|
1543
|
-
const moveValue =
|
|
1582
|
+
const moveValue = getByPath(newSpec, patch.from);
|
|
1544
1583
|
removeSpecValue(newSpec, patch.from);
|
|
1545
|
-
|
|
1584
|
+
writeSpecValue(newSpec, patch.path, moveValue, addByPath);
|
|
1546
1585
|
break;
|
|
1547
1586
|
}
|
|
1548
1587
|
case 'copy': {
|
|
1549
1588
|
if (!patch.from)
|
|
1550
1589
|
break;
|
|
1551
|
-
|
|
1552
|
-
|
|
1590
|
+
// RFC 6902 copies the *value*. Handing over the live reference instead
|
|
1591
|
+
// aliases source and target, and a `path` that descends into its own
|
|
1592
|
+
// `from` then makes the spec self-referential — which the next request
|
|
1593
|
+
// discovers when it tries to serialise it into the body.
|
|
1594
|
+
const copyValue = structuredClone(getByPath(newSpec, patch.from));
|
|
1595
|
+
writeSpecValue(newSpec, patch.path, copyValue, addByPath);
|
|
1553
1596
|
break;
|
|
1554
1597
|
}
|
|
1555
1598
|
case 'test': {
|
|
1556
|
-
// test is a no-op for rendering purposes (validation only)
|
|
1557
1599
|
break;
|
|
1558
1600
|
}
|
|
1559
1601
|
}
|
|
1560
1602
|
return newSpec;
|
|
1561
1603
|
}
|
|
1562
|
-
|
|
1563
|
-
* Streaming UI generation. POSTs `{ prompt, context, currentSpec }` to the
|
|
1564
|
-
* endpoint and progressively applies the returned JSONL patch stream to the
|
|
1565
|
-
* `spec` signal, so partial UIs render as they arrive.
|
|
1566
|
-
*
|
|
1567
|
-
* Must be called in an injection context (aborts in-flight requests on
|
|
1568
|
-
* destroy).
|
|
1569
|
-
*
|
|
1570
|
-
* @example
|
|
1571
|
-
* ```ts
|
|
1572
|
-
* export class GeneratePage {
|
|
1573
|
-
* readonly ui = injectUIStream({ api: '/api/generate' });
|
|
1574
|
-
* }
|
|
1575
|
-
* // template:
|
|
1576
|
-
* // <json-render [spec]="ui.spec()" [registry]="registry" [loading]="ui.isStreaming()" />
|
|
1577
|
-
* ```
|
|
1578
|
-
*/
|
|
1579
|
-
function injectUIStream(options) {
|
|
1580
|
-
const spec = signal(null, ...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
|
|
1581
|
-
const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
|
|
1582
|
-
const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1583
|
-
const usage = signal(null, ...(ngDevMode ? [{ debugName: "usage" }] : /* istanbul ignore next */ []));
|
|
1584
|
-
const rawLines = signal([], ...(ngDevMode ? [{ debugName: "rawLines" }] : /* istanbul ignore next */ []));
|
|
1585
|
-
let abortController = null;
|
|
1586
|
-
inject(DestroyRef).onDestroy(() => {
|
|
1587
|
-
abortController?.abort();
|
|
1588
|
-
});
|
|
1589
|
-
const clear = () => {
|
|
1590
|
-
spec.set(null);
|
|
1591
|
-
error.set(null);
|
|
1592
|
-
};
|
|
1593
|
-
const send = async (prompt, context) => {
|
|
1594
|
-
// Abort any existing request
|
|
1595
|
-
abortController?.abort();
|
|
1596
|
-
const controller = new AbortController();
|
|
1597
|
-
abortController = controller;
|
|
1598
|
-
// A superseded request must not write to the shared signals on its way
|
|
1599
|
-
// out: its `finally` would otherwise clear `isStreaming` while the request
|
|
1600
|
-
// that replaced it is still streaming.
|
|
1601
|
-
const isCurrent = () => abortController === controller;
|
|
1602
|
-
isStreaming.set(true);
|
|
1603
|
-
error.set(null);
|
|
1604
|
-
usage.set(null);
|
|
1605
|
-
rawLines.set([]);
|
|
1606
|
-
// Start with previous spec if provided, otherwise empty spec
|
|
1607
|
-
const previousSpec = context?.['previousSpec'];
|
|
1608
|
-
let currentSpec = previousSpec && previousSpec.root
|
|
1609
|
-
? { ...previousSpec, elements: { ...previousSpec.elements } }
|
|
1610
|
-
: { root: '', elements: {} };
|
|
1611
|
-
spec.set(currentSpec);
|
|
1612
|
-
const handleLine = (trimmed) => {
|
|
1613
|
-
const result = parseLine(trimmed);
|
|
1614
|
-
if (!result)
|
|
1615
|
-
return;
|
|
1616
|
-
if (result.type === 'usage') {
|
|
1617
|
-
usage.set(result.usage);
|
|
1618
|
-
}
|
|
1619
|
-
else {
|
|
1620
|
-
rawLines.update((prev) => [...prev, trimmed]);
|
|
1621
|
-
currentSpec = applyPatch(currentSpec, result.patch);
|
|
1622
|
-
spec.set(currentSpec);
|
|
1623
|
-
}
|
|
1624
|
-
};
|
|
1625
|
-
try {
|
|
1626
|
-
const response = await fetch(options.api, {
|
|
1627
|
-
method: 'POST',
|
|
1628
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1629
|
-
body: JSON.stringify({
|
|
1630
|
-
prompt,
|
|
1631
|
-
context,
|
|
1632
|
-
currentSpec,
|
|
1633
|
-
}),
|
|
1634
|
-
signal: controller.signal,
|
|
1635
|
-
});
|
|
1636
|
-
if (!response.ok) {
|
|
1637
|
-
// Try to parse JSON error response for better error messages
|
|
1638
|
-
let errorMessage = `HTTP error: ${response.status}`;
|
|
1639
|
-
try {
|
|
1640
|
-
const errorData = await response.json();
|
|
1641
|
-
if (errorData.message) {
|
|
1642
|
-
errorMessage = errorData.message;
|
|
1643
|
-
}
|
|
1644
|
-
else if (errorData.error) {
|
|
1645
|
-
errorMessage = errorData.error;
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
catch {
|
|
1649
|
-
// Ignore JSON parsing errors, use default message
|
|
1650
|
-
}
|
|
1651
|
-
throw new Error(errorMessage);
|
|
1652
|
-
}
|
|
1653
|
-
const reader = response.body?.getReader();
|
|
1654
|
-
if (!reader) {
|
|
1655
|
-
throw new Error('No response body');
|
|
1656
|
-
}
|
|
1657
|
-
const decoder = new TextDecoder();
|
|
1658
|
-
let buffer = '';
|
|
1659
|
-
while (true) {
|
|
1660
|
-
const { done, value } = await reader.read();
|
|
1661
|
-
if (done)
|
|
1662
|
-
break;
|
|
1663
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1664
|
-
// Process complete lines
|
|
1665
|
-
const lines = buffer.split('\n');
|
|
1666
|
-
buffer = lines.pop() ?? '';
|
|
1667
|
-
for (const line of lines) {
|
|
1668
|
-
const trimmed = line.trim();
|
|
1669
|
-
if (!trimmed)
|
|
1670
|
-
continue;
|
|
1671
|
-
handleLine(trimmed);
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
// Process any remaining buffer
|
|
1675
|
-
if (buffer.trim()) {
|
|
1676
|
-
handleLine(buffer.trim());
|
|
1677
|
-
}
|
|
1678
|
-
options.onComplete?.(currentSpec);
|
|
1679
|
-
}
|
|
1680
|
-
catch (err) {
|
|
1681
|
-
if (err.name === 'AbortError' || !isCurrent()) {
|
|
1682
|
-
return;
|
|
1683
|
-
}
|
|
1684
|
-
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
|
1685
|
-
error.set(resolvedError);
|
|
1686
|
-
options.onError?.(resolvedError);
|
|
1687
|
-
}
|
|
1688
|
-
finally {
|
|
1689
|
-
if (isCurrent()) {
|
|
1690
|
-
isStreaming.set(false);
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
};
|
|
1694
|
-
return {
|
|
1695
|
-
spec: spec.asReadonly(),
|
|
1696
|
-
isStreaming: isStreaming.asReadonly(),
|
|
1697
|
-
error: error.asReadonly(),
|
|
1698
|
-
usage: usage.asReadonly(),
|
|
1699
|
-
rawLines: rawLines.asReadonly(),
|
|
1700
|
-
send,
|
|
1701
|
-
clear,
|
|
1702
|
-
};
|
|
1703
|
-
}
|
|
1604
|
+
|
|
1704
1605
|
/**
|
|
1705
1606
|
* Convert a flat element list to a Spec.
|
|
1706
1607
|
* Input elements use key/parentKey to establish identity and relationships.
|
|
@@ -1708,6 +1609,8 @@ function injectUIStream(options) {
|
|
|
1708
1609
|
* and parent-child relationships are expressed through children arrays.
|
|
1709
1610
|
*/
|
|
1710
1611
|
function flatToTree(elements) {
|
|
1612
|
+
// The map is what gives every element its children array, so the second
|
|
1613
|
+
// pass can push into one without checking for it.
|
|
1711
1614
|
const elementMap = {};
|
|
1712
1615
|
let root = '';
|
|
1713
1616
|
// First pass: add all elements to map
|
|
@@ -1724,9 +1627,6 @@ function flatToTree(elements) {
|
|
|
1724
1627
|
if (element.parentKey) {
|
|
1725
1628
|
const parent = elementMap[element.parentKey];
|
|
1726
1629
|
if (parent) {
|
|
1727
|
-
if (!parent.children) {
|
|
1728
|
-
parent.children = [];
|
|
1729
|
-
}
|
|
1730
1630
|
parent.children.push(element.key);
|
|
1731
1631
|
}
|
|
1732
1632
|
}
|
|
@@ -1760,7 +1660,7 @@ function isSpecDataPart(data) {
|
|
|
1760
1660
|
* parts are present.
|
|
1761
1661
|
*/
|
|
1762
1662
|
function buildSpecFromParts(parts) {
|
|
1763
|
-
|
|
1663
|
+
let spec = { root: '', elements: {} };
|
|
1764
1664
|
let hasSpec = false;
|
|
1765
1665
|
for (const part of parts) {
|
|
1766
1666
|
if (part.type === SPEC_DATA_PART_TYPE) {
|
|
@@ -1769,16 +1669,15 @@ function buildSpecFromParts(parts) {
|
|
|
1769
1669
|
const payload = part.data;
|
|
1770
1670
|
if (payload.type === 'patch') {
|
|
1771
1671
|
hasSpec = true;
|
|
1772
|
-
|
|
1672
|
+
spec = applyPatch(spec, payload.patch);
|
|
1773
1673
|
}
|
|
1774
1674
|
else if (payload.type === 'flat') {
|
|
1775
1675
|
hasSpec = true;
|
|
1776
|
-
|
|
1676
|
+
spec = { ...spec, ...payload.spec };
|
|
1777
1677
|
}
|
|
1778
1678
|
else if (payload.type === 'nested') {
|
|
1779
1679
|
hasSpec = true;
|
|
1780
|
-
|
|
1781
|
-
Object.assign(spec, flat);
|
|
1680
|
+
spec = { ...spec, ...nestedToFlat(payload.spec) };
|
|
1782
1681
|
}
|
|
1783
1682
|
}
|
|
1784
1683
|
}
|
|
@@ -1822,9 +1721,258 @@ function jsonRenderMessage(parts) {
|
|
|
1822
1721
|
}),
|
|
1823
1722
|
};
|
|
1824
1723
|
}
|
|
1724
|
+
|
|
1725
|
+
function createStreamSession() {
|
|
1726
|
+
let current = null;
|
|
1727
|
+
return {
|
|
1728
|
+
begin() {
|
|
1729
|
+
current?.abort();
|
|
1730
|
+
const controller = new AbortController();
|
|
1731
|
+
current = controller;
|
|
1732
|
+
const isCurrent = () => current === controller;
|
|
1733
|
+
return {
|
|
1734
|
+
signal: controller.signal,
|
|
1735
|
+
isCurrent,
|
|
1736
|
+
commit(write) {
|
|
1737
|
+
if (isCurrent())
|
|
1738
|
+
write();
|
|
1739
|
+
},
|
|
1740
|
+
};
|
|
1741
|
+
},
|
|
1742
|
+
cancel() {
|
|
1743
|
+
current?.abort();
|
|
1744
|
+
// Cleared, not just aborted: with no current controller the in-flight
|
|
1745
|
+
// request's gate closes too, so a stop() cannot be undone a moment later
|
|
1746
|
+
// by the request it just cancelled.
|
|
1747
|
+
current = null;
|
|
1748
|
+
},
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1751
|
+
/** True for the rejection `fetch` and a stream reader raise on abort. */
|
|
1752
|
+
function isAbortError(error) {
|
|
1753
|
+
return error?.name === 'AbortError';
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Buffer decoded text and hand back whole lines, dropping blank ones. What is
|
|
1757
|
+
* left when the stream ends is flushed as a final line, since a model's last
|
|
1758
|
+
* line often arrives without its newline.
|
|
1759
|
+
*/
|
|
1760
|
+
function createLineConsumer(onLine) {
|
|
1761
|
+
let buffer = '';
|
|
1762
|
+
return {
|
|
1763
|
+
push(text) {
|
|
1764
|
+
buffer += text;
|
|
1765
|
+
const lines = buffer.split('\n');
|
|
1766
|
+
buffer = lines.pop() ?? '';
|
|
1767
|
+
for (const line of lines) {
|
|
1768
|
+
const trimmed = line.trim();
|
|
1769
|
+
if (trimmed)
|
|
1770
|
+
onLine(trimmed);
|
|
1771
|
+
}
|
|
1772
|
+
},
|
|
1773
|
+
flush() {
|
|
1774
|
+
const trimmed = buffer.trim();
|
|
1775
|
+
buffer = '';
|
|
1776
|
+
if (trimmed)
|
|
1777
|
+
onLine(trimmed);
|
|
1778
|
+
},
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Mine an error response for something better to show than its status code.
|
|
1783
|
+
*/
|
|
1784
|
+
async function readErrorMessage(response) {
|
|
1785
|
+
try {
|
|
1786
|
+
const body = await response.json();
|
|
1787
|
+
if (body.message)
|
|
1788
|
+
return String(body.message);
|
|
1789
|
+
if (body.error)
|
|
1790
|
+
return String(body.error);
|
|
1791
|
+
}
|
|
1792
|
+
catch {
|
|
1793
|
+
// A non-JSON error body is ordinary; the status line still says enough.
|
|
1794
|
+
}
|
|
1795
|
+
return `HTTP error: ${response.status}`;
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* POST `body` and feed the streamed response to `consumer` until it ends.
|
|
1799
|
+
*
|
|
1800
|
+
* Both hooks share this so the request lifecycle — error mining, the reader
|
|
1801
|
+
* loop, the decoder's final flush — has one implementation to get right
|
|
1802
|
+
* rather than one per hook that has to be fixed twice.
|
|
1803
|
+
*/
|
|
1804
|
+
async function streamRequest(request, consumer) {
|
|
1805
|
+
// Bound, because a bare `globalThis.fetch` called as a plain function is
|
|
1806
|
+
// an illegal invocation in the browser.
|
|
1807
|
+
const doFetch = request.fetch ?? globalThis.fetch.bind(globalThis);
|
|
1808
|
+
const response = await doFetch(request.api, {
|
|
1809
|
+
method: 'POST',
|
|
1810
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1811
|
+
body: JSON.stringify(request.body),
|
|
1812
|
+
signal: request.signal,
|
|
1813
|
+
});
|
|
1814
|
+
if (!response.ok) {
|
|
1815
|
+
throw new Error(await readErrorMessage(response));
|
|
1816
|
+
}
|
|
1817
|
+
const reader = response.body?.getReader();
|
|
1818
|
+
if (!reader) {
|
|
1819
|
+
throw new Error('No response body');
|
|
1820
|
+
}
|
|
1821
|
+
const decoder = new TextDecoder();
|
|
1822
|
+
while (true) {
|
|
1823
|
+
const { done, value } = await reader.read();
|
|
1824
|
+
if (done)
|
|
1825
|
+
break;
|
|
1826
|
+
consumer.push(decoder.decode(value, { stream: true }));
|
|
1827
|
+
}
|
|
1828
|
+
// Without the final, non-streaming decode a multi-byte character split
|
|
1829
|
+
// across the last chunk is dropped instead of surfacing.
|
|
1830
|
+
const tail = decoder.decode();
|
|
1831
|
+
if (tail)
|
|
1832
|
+
consumer.push(tail);
|
|
1833
|
+
consumer.flush();
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/**
|
|
1837
|
+
* Parse a single JSON line (patch or metadata). Anything that is not one of
|
|
1838
|
+
* the two is dropped: half-written lines and stray prose are ordinary in a
|
|
1839
|
+
* model's output, and aborting the generation over one would lose the UI
|
|
1840
|
+
* built so far.
|
|
1841
|
+
*/
|
|
1842
|
+
function parseLine(line) {
|
|
1843
|
+
try {
|
|
1844
|
+
const trimmed = line.trim();
|
|
1845
|
+
if (!trimmed || trimmed.startsWith('//')) {
|
|
1846
|
+
return null;
|
|
1847
|
+
}
|
|
1848
|
+
const parsed = JSON.parse(trimmed);
|
|
1849
|
+
// Check for usage metadata
|
|
1850
|
+
if (parsed.__meta === 'usage') {
|
|
1851
|
+
return {
|
|
1852
|
+
type: 'usage',
|
|
1853
|
+
usage: {
|
|
1854
|
+
promptTokens: parsed.promptTokens ?? 0,
|
|
1855
|
+
completionTokens: parsed.completionTokens ?? 0,
|
|
1856
|
+
totalTokens: parsed.totalTokens ?? 0,
|
|
1857
|
+
},
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
// Parseable JSON is not yet a patch: without this, a stray object would be
|
|
1861
|
+
// recorded as a raw line and handed to the engine as an opless patch.
|
|
1862
|
+
if (typeof parsed?.op !== 'string' || typeof parsed?.path !== 'string') {
|
|
1863
|
+
return null;
|
|
1864
|
+
}
|
|
1865
|
+
return { type: 'patch', patch: parsed };
|
|
1866
|
+
}
|
|
1867
|
+
catch {
|
|
1868
|
+
return null;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Streaming UI generation. POSTs `{ prompt, context, currentSpec }` to the
|
|
1873
|
+
* endpoint and progressively applies the returned JSONL patch stream to the
|
|
1874
|
+
* `spec` signal, so partial UIs render as they arrive.
|
|
1875
|
+
*
|
|
1876
|
+
* Must be called in an injection context (aborts in-flight requests on
|
|
1877
|
+
* destroy).
|
|
1878
|
+
*
|
|
1879
|
+
* @example
|
|
1880
|
+
* ```ts
|
|
1881
|
+
* export class GeneratePage {
|
|
1882
|
+
* readonly ui = injectUIStream({ api: '/api/generate' });
|
|
1883
|
+
* }
|
|
1884
|
+
* // template:
|
|
1885
|
+
* // <json-render [spec]="ui.spec()" [registry]="registry" [loading]="ui.isStreaming()" />
|
|
1886
|
+
* ```
|
|
1887
|
+
*/
|
|
1888
|
+
function injectUIStream(options) {
|
|
1889
|
+
const spec = signal(null, ...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
|
|
1890
|
+
const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
|
|
1891
|
+
const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1892
|
+
const usage = signal(null, ...(ngDevMode ? [{ debugName: "usage" }] : /* istanbul ignore next */ []));
|
|
1893
|
+
const rawLines = signal([], ...(ngDevMode ? [{ debugName: "rawLines" }] : /* istanbul ignore next */ []));
|
|
1894
|
+
const session = createStreamSession();
|
|
1895
|
+
inject(DestroyRef).onDestroy(() => session.cancel());
|
|
1896
|
+
const stop = () => {
|
|
1897
|
+
session.cancel();
|
|
1898
|
+
isStreaming.set(false);
|
|
1899
|
+
};
|
|
1900
|
+
const clear = () => {
|
|
1901
|
+
// Aborting first is what makes this true: without it the in-flight
|
|
1902
|
+
// request puts its spec straight back on the next patch it applies.
|
|
1903
|
+
stop();
|
|
1904
|
+
spec.set(null);
|
|
1905
|
+
error.set(null);
|
|
1906
|
+
usage.set(null);
|
|
1907
|
+
rawLines.set([]);
|
|
1908
|
+
};
|
|
1909
|
+
const send = async (prompt, sendOptions) => {
|
|
1910
|
+
const gate = session.begin();
|
|
1911
|
+
isStreaming.set(true);
|
|
1912
|
+
error.set(null);
|
|
1913
|
+
usage.set(null);
|
|
1914
|
+
rawLines.set([]);
|
|
1915
|
+
const previousSpec = sendOptions?.previousSpec;
|
|
1916
|
+
let currentSpec = previousSpec && previousSpec.root
|
|
1917
|
+
? { ...previousSpec, elements: { ...previousSpec.elements } }
|
|
1918
|
+
: { root: '', elements: {} };
|
|
1919
|
+
spec.set(currentSpec);
|
|
1920
|
+
const handleLine = (line) => {
|
|
1921
|
+
const parsed = parseLine(line);
|
|
1922
|
+
if (!parsed)
|
|
1923
|
+
return;
|
|
1924
|
+
gate.commit(() => {
|
|
1925
|
+
if (parsed.type === 'usage') {
|
|
1926
|
+
usage.set(parsed.usage);
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
rawLines.update((prev) => [...prev, line]);
|
|
1930
|
+
currentSpec = applyPatch(currentSpec, parsed.patch);
|
|
1931
|
+
spec.set(currentSpec);
|
|
1932
|
+
});
|
|
1933
|
+
};
|
|
1934
|
+
try {
|
|
1935
|
+
await streamRequest({
|
|
1936
|
+
api: options.api,
|
|
1937
|
+
body: {
|
|
1938
|
+
prompt,
|
|
1939
|
+
context: sendOptions?.context,
|
|
1940
|
+
currentSpec,
|
|
1941
|
+
},
|
|
1942
|
+
fetch: options.fetch,
|
|
1943
|
+
signal: gate.signal,
|
|
1944
|
+
}, createLineConsumer(handleLine));
|
|
1945
|
+
gate.commit(() => options.onComplete?.(currentSpec));
|
|
1946
|
+
}
|
|
1947
|
+
catch (err) {
|
|
1948
|
+
if (isAbortError(err))
|
|
1949
|
+
return;
|
|
1950
|
+
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
|
1951
|
+
gate.commit(() => {
|
|
1952
|
+
error.set(resolvedError);
|
|
1953
|
+
options.onError?.(resolvedError);
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
finally {
|
|
1957
|
+
gate.commit(() => isStreaming.set(false));
|
|
1958
|
+
}
|
|
1959
|
+
};
|
|
1960
|
+
return {
|
|
1961
|
+
spec: spec.asReadonly(),
|
|
1962
|
+
isStreaming: isStreaming.asReadonly(),
|
|
1963
|
+
error: error.asReadonly(),
|
|
1964
|
+
usage: usage.asReadonly(),
|
|
1965
|
+
rawLines: rawLines.asReadonly(),
|
|
1966
|
+
send,
|
|
1967
|
+
stop,
|
|
1968
|
+
clear,
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1825
1972
|
let chatMessageIdCounter = 0;
|
|
1826
1973
|
function generateChatId() {
|
|
1827
|
-
if (typeof crypto !== 'undefined' &&
|
|
1974
|
+
if (typeof crypto !== 'undefined' &&
|
|
1975
|
+
typeof crypto.randomUUID === 'function') {
|
|
1828
1976
|
return crypto.randomUUID();
|
|
1829
1977
|
}
|
|
1830
1978
|
chatMessageIdCounter += 1;
|
|
@@ -1842,24 +1990,21 @@ function injectChatUI(options) {
|
|
|
1842
1990
|
const messages = signal([], ...(ngDevMode ? [{ debugName: "messages" }] : /* istanbul ignore next */ []));
|
|
1843
1991
|
const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
|
|
1844
1992
|
const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
|
|
1845
|
-
|
|
1846
|
-
inject(DestroyRef).onDestroy(() =>
|
|
1847
|
-
|
|
1848
|
-
|
|
1993
|
+
const session = createStreamSession();
|
|
1994
|
+
inject(DestroyRef).onDestroy(() => session.cancel());
|
|
1995
|
+
const stop = () => {
|
|
1996
|
+
session.cancel();
|
|
1997
|
+
isStreaming.set(false);
|
|
1998
|
+
};
|
|
1849
1999
|
const clear = () => {
|
|
2000
|
+
stop();
|
|
1850
2001
|
messages.set([]);
|
|
1851
2002
|
error.set(null);
|
|
1852
2003
|
};
|
|
1853
2004
|
const send = async (text) => {
|
|
1854
2005
|
if (!text.trim())
|
|
1855
2006
|
return;
|
|
1856
|
-
|
|
1857
|
-
abortController?.abort();
|
|
1858
|
-
const controller = new AbortController();
|
|
1859
|
-
abortController = controller;
|
|
1860
|
-
// See injectUIStream: a superseded request must not clear `isStreaming`
|
|
1861
|
-
// out from under the request that replaced it.
|
|
1862
|
-
const isCurrent = () => abortController === controller;
|
|
2007
|
+
const gate = session.begin();
|
|
1863
2008
|
const userMessage = {
|
|
1864
2009
|
id: generateChatId(),
|
|
1865
2010
|
role: 'user',
|
|
@@ -1873,10 +2018,25 @@ function injectChatUI(options) {
|
|
|
1873
2018
|
text: '',
|
|
1874
2019
|
spec: null,
|
|
1875
2020
|
};
|
|
1876
|
-
|
|
2021
|
+
/**
|
|
2022
|
+
* Drop this turn's assistant bubble if it never got any text.
|
|
2023
|
+
*
|
|
2024
|
+
* Deliberately outside the gate: the edit is scoped to this request's own
|
|
2025
|
+
* message id, so it is safe even once a newer request has appended its
|
|
2026
|
+
* turn — and a superseded request is exactly when it is needed, since an
|
|
2027
|
+
* empty bubble left behind renders as a blank reply and is serialised
|
|
2028
|
+
* into every later turn's history.
|
|
2029
|
+
*/
|
|
2030
|
+
const dropEmptyPlaceholder = () => {
|
|
2031
|
+
messages.update((prev) => prev.filter((m) => m.id !== assistantId || m.text.length > 0));
|
|
2032
|
+
};
|
|
2033
|
+
// Built before the new turn is appended, so it carries the conversation so
|
|
2034
|
+
// far. Assistant turns with no text are left out: they say nothing to the
|
|
2035
|
+
// model, some providers reject empty content outright, and a turn being
|
|
2036
|
+
// superseded right now may still have its placeholder in the list.
|
|
1877
2037
|
const historyForApi = [
|
|
1878
2038
|
...messages()
|
|
1879
|
-
.filter((m) => m.
|
|
2039
|
+
.filter((m) => m.role !== 'assistant' || m.text.length > 0)
|
|
1880
2040
|
.map((m) => ({
|
|
1881
2041
|
role: m.role,
|
|
1882
2042
|
content: m.text,
|
|
@@ -1889,84 +2049,54 @@ function injectChatUI(options) {
|
|
|
1889
2049
|
error.set(null);
|
|
1890
2050
|
// Mutable state for accumulating the assistant response
|
|
1891
2051
|
let accumulatedText = '';
|
|
1892
|
-
|
|
2052
|
+
let currentSpec = { root: '', elements: {} };
|
|
1893
2053
|
let hasSpec = false;
|
|
1894
|
-
const
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
2054
|
+
const parser = createMixedStreamParser({
|
|
2055
|
+
onPatch(patch) {
|
|
2056
|
+
hasSpec = true;
|
|
2057
|
+
// applyPatch returns a fresh spec that shares what the patch did not
|
|
2058
|
+
// touch, so the message can hold it directly — no snapshot copy.
|
|
2059
|
+
currentSpec = applyPatch(currentSpec, patch);
|
|
2060
|
+
const snapshot = currentSpec;
|
|
2061
|
+
gate.commit(() => messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, spec: snapshot } : m)));
|
|
2062
|
+
},
|
|
2063
|
+
onText(line) {
|
|
2064
|
+
accumulatedText += (accumulatedText ? '\n' : '') + line;
|
|
2065
|
+
const snapshot = accumulatedText;
|
|
2066
|
+
gate.commit(() => messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, text: snapshot } : m)));
|
|
2067
|
+
},
|
|
1898
2068
|
});
|
|
1899
2069
|
try {
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
signal:
|
|
1905
|
-
});
|
|
1906
|
-
if (!response.ok) {
|
|
1907
|
-
let errorMessage = `HTTP error: ${response.status}`;
|
|
1908
|
-
try {
|
|
1909
|
-
const errorData = await response.json();
|
|
1910
|
-
if (errorData.message) {
|
|
1911
|
-
errorMessage = errorData.message;
|
|
1912
|
-
}
|
|
1913
|
-
else if (errorData.error) {
|
|
1914
|
-
errorMessage = errorData.error;
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
catch {
|
|
1918
|
-
// Ignore JSON parsing errors
|
|
1919
|
-
}
|
|
1920
|
-
throw new Error(errorMessage);
|
|
1921
|
-
}
|
|
1922
|
-
const reader = response.body?.getReader();
|
|
1923
|
-
if (!reader) {
|
|
1924
|
-
throw new Error('No response body');
|
|
1925
|
-
}
|
|
1926
|
-
const decoder = new TextDecoder();
|
|
1927
|
-
// Use createMixedStreamParser to classify lines
|
|
1928
|
-
const parser = createMixedStreamParser({
|
|
1929
|
-
onPatch(patch) {
|
|
1930
|
-
hasSpec = true;
|
|
1931
|
-
applySpecPatch(currentSpec, patch);
|
|
1932
|
-
const snapshot = snapshotSpec();
|
|
1933
|
-
messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, spec: snapshot } : m));
|
|
1934
|
-
},
|
|
1935
|
-
onText(line) {
|
|
1936
|
-
accumulatedText += (accumulatedText ? '\n' : '') + line;
|
|
1937
|
-
messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, text: accumulatedText } : m));
|
|
1938
|
-
},
|
|
1939
|
-
});
|
|
1940
|
-
while (true) {
|
|
1941
|
-
const { done, value } = await reader.read();
|
|
1942
|
-
if (done)
|
|
1943
|
-
break;
|
|
1944
|
-
parser.push(decoder.decode(value, { stream: true }));
|
|
1945
|
-
}
|
|
1946
|
-
parser.flush();
|
|
1947
|
-
// Build final message for onComplete callback
|
|
2070
|
+
await streamRequest({
|
|
2071
|
+
api: options.api,
|
|
2072
|
+
body: { messages: historyForApi },
|
|
2073
|
+
fetch: options.fetch,
|
|
2074
|
+
signal: gate.signal,
|
|
2075
|
+
}, parser);
|
|
1948
2076
|
const finalMessage = {
|
|
1949
2077
|
id: assistantId,
|
|
1950
2078
|
role: 'assistant',
|
|
1951
2079
|
text: accumulatedText,
|
|
1952
|
-
spec: hasSpec ?
|
|
2080
|
+
spec: hasSpec ? currentSpec : null,
|
|
1953
2081
|
};
|
|
1954
|
-
options.onComplete?.(finalMessage);
|
|
2082
|
+
gate.commit(() => options.onComplete?.(finalMessage));
|
|
1955
2083
|
}
|
|
1956
2084
|
catch (err) {
|
|
1957
|
-
|
|
2085
|
+
// Unconditional: however this turn ended, its own bubble goes with it if
|
|
2086
|
+
// nothing was ever said into it. A superseded request usually unwinds by
|
|
2087
|
+
// abort, but one whose stream failed for its own reason after being
|
|
2088
|
+
// replaced would otherwise leave the placeholder behind.
|
|
2089
|
+
dropEmptyPlaceholder();
|
|
2090
|
+
if (isAbortError(err))
|
|
1958
2091
|
return;
|
|
1959
|
-
}
|
|
1960
2092
|
const resolvedError = err instanceof Error ? err : new Error(String(err));
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
2093
|
+
gate.commit(() => {
|
|
2094
|
+
error.set(resolvedError);
|
|
2095
|
+
options.onError?.(resolvedError);
|
|
2096
|
+
});
|
|
1965
2097
|
}
|
|
1966
2098
|
finally {
|
|
1967
|
-
|
|
1968
|
-
isStreaming.set(false);
|
|
1969
|
-
}
|
|
2099
|
+
gate.commit(() => isStreaming.set(false));
|
|
1970
2100
|
}
|
|
1971
2101
|
};
|
|
1972
2102
|
return {
|
|
@@ -1974,10 +2104,20 @@ function injectChatUI(options) {
|
|
|
1974
2104
|
isStreaming: isStreaming.asReadonly(),
|
|
1975
2105
|
error: error.asReadonly(),
|
|
1976
2106
|
send,
|
|
2107
|
+
stop,
|
|
1977
2108
|
clear,
|
|
1978
2109
|
};
|
|
1979
2110
|
}
|
|
1980
2111
|
|
|
2112
|
+
/**
|
|
2113
|
+
* Streaming: applying a model's output to a spec as it arrives.
|
|
2114
|
+
*
|
|
2115
|
+
* - `patch` — the immutable RFC 6902 engine every entry point applies through.
|
|
2116
|
+
* - `transport` — one streamed request lifecycle, shared by both hooks.
|
|
2117
|
+
* - `parts` — adapters for the AI SDK's message parts.
|
|
2118
|
+
* - `ui-stream` / `chat-ui` — the two hooks, composed from the above.
|
|
2119
|
+
*/
|
|
2120
|
+
|
|
1981
2121
|
/**
|
|
1982
2122
|
* The schema for ngx-json-render.
|
|
1983
2123
|
*
|