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.
@@ -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, setByPath, removeByPath, SPEC_DATA_PART_TYPE, applySpecPatch, nestedToFlat, createMixedStreamParser, defineSchema } from '@json-render/core';
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 { ...el, props: resolveElementProps(el.props ?? {}, this.resolutionCtx()) };
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 ? resolveBindings(el.props ?? {}, this.resolutionCtx()) : undefined;
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
- * Parse a single JSON line (patch or metadata).
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 parseLine(line) {
1411
- try {
1412
- const trimmed = line.trim();
1413
- if (!trimmed || trimmed.startsWith('//')) {
1414
- return null;
1415
- }
1416
- const parsed = JSON.parse(trimmed);
1417
- // Check for usage metadata
1418
- if (parsed.__meta === 'usage') {
1419
- return {
1420
- type: 'usage',
1421
- usage: {
1422
- promptTokens: parsed.promptTokens ?? 0,
1423
- completionTokens: parsed.completionTokens ?? 0,
1424
- totalTokens: parsed.totalTokens ?? 0,
1425
- },
1426
- };
1427
- }
1428
- return { type: 'patch', patch: parsed };
1429
- }
1430
- catch {
1431
- return null;
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
- * Set a value at a spec path (for add/replace operations).
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 setSpecValue(newSpec, path, value) {
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
- setByPath(newSpec.state, statePath, value);
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
- else {
1462
- const element = newSpec.elements[elementKey];
1463
- if (element) {
1464
- const propPath = '/' + pathParts.slice(1).join('/');
1465
- const newElement = { ...element };
1466
- setByPath(newElement, propPath, value);
1467
- newSpec.elements[elementKey] = newElement;
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
- removeByPath(newSpec.state, statePath);
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]: _, ...rest } = newSpec.elements;
1527
+ const { [elementKey]: _removed, ...rest } = newSpec.elements;
1492
1528
  newSpec.elements = rest;
1529
+ return;
1493
1530
  }
1494
- else {
1495
- const element = newSpec.elements[elementKey];
1496
- if (element) {
1497
- const propPath = '/' + pathParts.slice(1).join('/');
1498
- const newElement = { ...element };
1499
- removeByPath(newElement, propPath);
1500
- newSpec.elements[elementKey] = newElement;
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
- return getByPath(spec, path);
1541
+ const root = newSpec;
1542
+ copyAlongPath(root, path);
1543
+ removeByPath(root, path);
1518
1544
  }
1519
1545
  /**
1520
- * Apply an RFC 6902 JSON patch to the current spec, returning a new spec
1521
- * object (structural sharing for untouched elements).
1522
- * Supports add, remove, replace, move, copy, and test operations.
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
- setSpecValue(newSpec, patch.path, patch.value);
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 = getSpecValue(newSpec, patch.from);
1582
+ const moveValue = getByPath(newSpec, patch.from);
1544
1583
  removeSpecValue(newSpec, patch.from);
1545
- setSpecValue(newSpec, patch.path, moveValue);
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
- const copyValue = getSpecValue(newSpec, patch.from);
1552
- setSpecValue(newSpec, patch.path, copyValue);
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
- const spec = { root: '', elements: {} };
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
- applySpecPatch(spec, payload.patch);
1672
+ spec = applyPatch(spec, payload.patch);
1773
1673
  }
1774
1674
  else if (payload.type === 'flat') {
1775
1675
  hasSpec = true;
1776
- Object.assign(spec, payload.spec);
1676
+ spec = { ...spec, ...payload.spec };
1777
1677
  }
1778
1678
  else if (payload.type === 'nested') {
1779
1679
  hasSpec = true;
1780
- const flat = nestedToFlat(payload.spec);
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' && typeof crypto.randomUUID === 'function') {
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
- let abortController = null;
1846
- inject(DestroyRef).onDestroy(() => {
1847
- abortController?.abort();
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
- // Abort any existing request
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
- // Build messages array for the API (full conversation history + new message).
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.id !== userMessage.id && m.id !== assistantId)
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
- const currentSpec = { root: '', elements: {} };
2052
+ let currentSpec = { root: '', elements: {} };
1893
2053
  let hasSpec = false;
1894
- const snapshotSpec = () => ({
1895
- root: currentSpec.root,
1896
- elements: { ...currentSpec.elements },
1897
- ...(currentSpec.state ? { state: { ...currentSpec.state } } : {}),
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
- const response = await fetch(options.api, {
1901
- method: 'POST',
1902
- headers: { 'Content-Type': 'application/json' },
1903
- body: JSON.stringify({ messages: historyForApi }),
1904
- signal: controller.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 ? snapshotSpec() : null,
2080
+ spec: hasSpec ? currentSpec : null,
1953
2081
  };
1954
- options.onComplete?.(finalMessage);
2082
+ gate.commit(() => options.onComplete?.(finalMessage));
1955
2083
  }
1956
2084
  catch (err) {
1957
- if (err.name === 'AbortError' || !isCurrent()) {
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
- error.set(resolvedError);
1962
- // Remove empty assistant message on error
1963
- messages.update((prev) => prev.filter((m) => m.id !== assistantId || m.text.length > 0));
1964
- options.onError?.(resolvedError);
2093
+ gate.commit(() => {
2094
+ error.set(resolvedError);
2095
+ options.onError?.(resolvedError);
2096
+ });
1965
2097
  }
1966
2098
  finally {
1967
- if (isCurrent()) {
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
  *