ngx-json-render 0.2.0 → 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';
@@ -1409,33 +1409,6 @@ function createStoreSetState(store) {
1409
1409
  };
1410
1410
  }
1411
1411
 
1412
- /**
1413
- * Parse a single JSON line (patch or metadata).
1414
- */
1415
- function parseLine(line) {
1416
- try {
1417
- const trimmed = line.trim();
1418
- if (!trimmed || trimmed.startsWith('//')) {
1419
- return null;
1420
- }
1421
- const parsed = JSON.parse(trimmed);
1422
- // Check for usage metadata
1423
- if (parsed.__meta === 'usage') {
1424
- return {
1425
- type: 'usage',
1426
- usage: {
1427
- promptTokens: parsed.promptTokens ?? 0,
1428
- completionTokens: parsed.completionTokens ?? 0,
1429
- totalTokens: parsed.totalTokens ?? 0,
1430
- },
1431
- };
1432
- }
1433
- return { type: 'patch', patch: parsed };
1434
- }
1435
- catch {
1436
- return null;
1437
- }
1438
- }
1439
1412
  /**
1440
1413
  * Split a JSON Pointer into its segments, undoing RFC 6901's escapes.
1441
1414
  *
@@ -1452,14 +1425,15 @@ function parseJsonPointer(path) {
1452
1425
  * Replace every node `path` descends through with a shallow copy of itself.
1453
1426
  *
1454
1427
  * `root` must already be private to the caller. Afterwards a mutating write
1455
- * along that same path — `setByPath`, `removeByPath` — only ever touches
1456
- * nodes nobody else is holding, while everything off the path stays shared.
1457
- * That keeps a patch proportional to the depth of its path rather than to the
1458
- * size of the spec, which matters when a stream applies hundreds of them.
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.
1459
1433
  *
1460
- * Descent stops at anything that is not an object: `setByPath` overwrites
1461
- * such a node outright, and one that does not exist yet is created fresh, so
1462
- * in neither case is there anything of the caller's left to protect.
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.
1463
1437
  */
1464
1438
  function copyAlongPath(root, path) {
1465
1439
  const segments = parseJsonPointer(path);
@@ -1478,9 +1452,10 @@ function copyAlongPath(root, path) {
1478
1452
  }
1479
1453
  }
1480
1454
  /**
1481
- * 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.
1482
1457
  */
1483
- function setSpecValue(newSpec, path, value) {
1458
+ function writeSpecValue(newSpec, path, value, write) {
1484
1459
  if (path === '/root') {
1485
1460
  newSpec.root = value;
1486
1461
  return;
@@ -1495,7 +1470,7 @@ function setSpecValue(newSpec, path, value) {
1495
1470
  const statePath = path.slice('/state'.length); // e.g. "/posts"
1496
1471
  const state = newSpec.state;
1497
1472
  copyAlongPath(state, statePath);
1498
- setByPath(state, statePath, value);
1473
+ write(state, statePath, value);
1499
1474
  return;
1500
1475
  }
1501
1476
  if (path.startsWith('/elements/')) {
@@ -1505,22 +1480,32 @@ function setSpecValue(newSpec, path, value) {
1505
1480
  return;
1506
1481
  if (pathParts.length === 1) {
1507
1482
  newSpec.elements[elementKey] = value;
1483
+ return;
1508
1484
  }
1509
- else {
1510
- const element = newSpec.elements[elementKey];
1511
- if (element) {
1512
- const propPath = '/' + pathParts.slice(1).join('/');
1513
- const newElement = { ...element };
1514
- copyAlongPath(newElement, propPath);
1515
- setByPath(newElement, propPath, value);
1516
- newSpec.elements[elementKey] = newElement;
1517
- }
1518
- }
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;
1519
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);
1520
1507
  }
1521
- /**
1522
- * Remove a value at a spec path.
1523
- */
1508
+ /** Remove the value at a spec path, with the same copy-on-write discipline. */
1524
1509
  function removeSpecValue(newSpec, path) {
1525
1510
  if (path === '/state') {
1526
1511
  delete newSpec.state;
@@ -1539,39 +1524,38 @@ function removeSpecValue(newSpec, path) {
1539
1524
  if (!elementKey)
1540
1525
  return;
1541
1526
  if (pathParts.length === 1) {
1542
- const { [elementKey]: _, ...rest } = newSpec.elements;
1527
+ const { [elementKey]: _removed, ...rest } = newSpec.elements;
1543
1528
  newSpec.elements = rest;
1529
+ return;
1544
1530
  }
1545
- else {
1546
- const element = newSpec.elements[elementKey];
1547
- if (element) {
1548
- const propPath = '/' + pathParts.slice(1).join('/');
1549
- const newElement = { ...element };
1550
- copyAlongPath(newElement, propPath);
1551
- removeByPath(newElement, propPath);
1552
- newSpec.elements[elementKey] = newElement;
1553
- }
1554
- }
1555
- }
1556
- }
1557
- /**
1558
- * Get a value at a spec path.
1559
- */
1560
- function getSpecValue(spec, path) {
1561
- if (path === '/root')
1562
- return spec.root;
1563
- if (path === '/state')
1564
- return spec.state;
1565
- if (path.startsWith('/state/') && spec.state) {
1566
- const statePath = path.slice('/state'.length);
1567
- 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;
1568
1540
  }
1569
- return getByPath(spec, path);
1541
+ const root = newSpec;
1542
+ copyAlongPath(root, path);
1543
+ removeByPath(root, path);
1570
1544
  }
1571
1545
  /**
1572
- * Apply an RFC 6902 JSON patch to the current spec, returning a new spec
1573
- * object (structural sharing for untouched elements).
1574
- * 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.
1575
1559
  */
1576
1560
  function applyPatch(spec, patch) {
1577
1561
  const newSpec = {
@@ -1580,9 +1564,12 @@ function applyPatch(spec, patch) {
1580
1564
  ...(spec.state ? { state: { ...spec.state } } : {}),
1581
1565
  };
1582
1566
  switch (patch.op) {
1583
- case 'add':
1567
+ case 'add': {
1568
+ writeSpecValue(newSpec, patch.path, patch.value, addByPath);
1569
+ break;
1570
+ }
1584
1571
  case 'replace': {
1585
- setSpecValue(newSpec, patch.path, patch.value);
1572
+ writeSpecValue(newSpec, patch.path, patch.value, setByPath);
1586
1573
  break;
1587
1574
  }
1588
1575
  case 'remove': {
@@ -1592,170 +1579,29 @@ function applyPatch(spec, patch) {
1592
1579
  case 'move': {
1593
1580
  if (!patch.from)
1594
1581
  break;
1595
- const moveValue = getSpecValue(newSpec, patch.from);
1582
+ const moveValue = getByPath(newSpec, patch.from);
1596
1583
  removeSpecValue(newSpec, patch.from);
1597
- setSpecValue(newSpec, patch.path, moveValue);
1584
+ writeSpecValue(newSpec, patch.path, moveValue, addByPath);
1598
1585
  break;
1599
1586
  }
1600
1587
  case 'copy': {
1601
1588
  if (!patch.from)
1602
1589
  break;
1603
- const copyValue = getSpecValue(newSpec, patch.from);
1604
- 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);
1605
1596
  break;
1606
1597
  }
1607
1598
  case 'test': {
1608
- // test is a no-op for rendering purposes (validation only)
1609
1599
  break;
1610
1600
  }
1611
1601
  }
1612
1602
  return newSpec;
1613
1603
  }
1614
- /**
1615
- * Streaming UI generation. POSTs `{ prompt, context, currentSpec }` to the
1616
- * endpoint and progressively applies the returned JSONL patch stream to the
1617
- * `spec` signal, so partial UIs render as they arrive.
1618
- *
1619
- * Must be called in an injection context (aborts in-flight requests on
1620
- * destroy).
1621
- *
1622
- * @example
1623
- * ```ts
1624
- * export class GeneratePage {
1625
- * readonly ui = injectUIStream({ api: '/api/generate' });
1626
- * }
1627
- * // template:
1628
- * // <json-render [spec]="ui.spec()" [registry]="registry" [loading]="ui.isStreaming()" />
1629
- * ```
1630
- */
1631
- function injectUIStream(options) {
1632
- const spec = signal(null, ...(ngDevMode ? [{ debugName: "spec" }] : /* istanbul ignore next */ []));
1633
- const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
1634
- const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
1635
- const usage = signal(null, ...(ngDevMode ? [{ debugName: "usage" }] : /* istanbul ignore next */ []));
1636
- const rawLines = signal([], ...(ngDevMode ? [{ debugName: "rawLines" }] : /* istanbul ignore next */ []));
1637
- let abortController = null;
1638
- inject(DestroyRef).onDestroy(() => {
1639
- abortController?.abort();
1640
- });
1641
- const clear = () => {
1642
- spec.set(null);
1643
- error.set(null);
1644
- };
1645
- const send = async (prompt, context) => {
1646
- // Abort any existing request
1647
- abortController?.abort();
1648
- const controller = new AbortController();
1649
- abortController = controller;
1650
- // A superseded request must not write to the shared signals on its way
1651
- // out: its `finally` would otherwise clear `isStreaming` while the request
1652
- // that replaced it is still streaming.
1653
- const isCurrent = () => abortController === controller;
1654
- isStreaming.set(true);
1655
- error.set(null);
1656
- usage.set(null);
1657
- rawLines.set([]);
1658
- // Start with previous spec if provided, otherwise empty spec
1659
- const previousSpec = context?.['previousSpec'];
1660
- let currentSpec = previousSpec && previousSpec.root
1661
- ? { ...previousSpec, elements: { ...previousSpec.elements } }
1662
- : { root: '', elements: {} };
1663
- spec.set(currentSpec);
1664
- const handleLine = (trimmed) => {
1665
- const result = parseLine(trimmed);
1666
- if (!result)
1667
- return;
1668
- if (result.type === 'usage') {
1669
- usage.set(result.usage);
1670
- }
1671
- else {
1672
- rawLines.update((prev) => [...prev, trimmed]);
1673
- currentSpec = applyPatch(currentSpec, result.patch);
1674
- spec.set(currentSpec);
1675
- }
1676
- };
1677
- // Bound, because a bare `globalThis.fetch` called as a plain function is
1678
- // an illegal invocation in the browser.
1679
- const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
1680
- try {
1681
- const response = await doFetch(options.api, {
1682
- method: 'POST',
1683
- headers: { 'Content-Type': 'application/json' },
1684
- body: JSON.stringify({
1685
- prompt,
1686
- context,
1687
- currentSpec,
1688
- }),
1689
- signal: controller.signal,
1690
- });
1691
- if (!response.ok) {
1692
- // Try to parse JSON error response for better error messages
1693
- let errorMessage = `HTTP error: ${response.status}`;
1694
- try {
1695
- const errorData = await response.json();
1696
- if (errorData.message) {
1697
- errorMessage = errorData.message;
1698
- }
1699
- else if (errorData.error) {
1700
- errorMessage = errorData.error;
1701
- }
1702
- }
1703
- catch {
1704
- // Ignore JSON parsing errors, use default message
1705
- }
1706
- throw new Error(errorMessage);
1707
- }
1708
- const reader = response.body?.getReader();
1709
- if (!reader) {
1710
- throw new Error('No response body');
1711
- }
1712
- const decoder = new TextDecoder();
1713
- let buffer = '';
1714
- while (true) {
1715
- const { done, value } = await reader.read();
1716
- if (done)
1717
- break;
1718
- buffer += decoder.decode(value, { stream: true });
1719
- // Process complete lines
1720
- const lines = buffer.split('\n');
1721
- buffer = lines.pop() ?? '';
1722
- for (const line of lines) {
1723
- const trimmed = line.trim();
1724
- if (!trimmed)
1725
- continue;
1726
- handleLine(trimmed);
1727
- }
1728
- }
1729
- // Process any remaining buffer
1730
- if (buffer.trim()) {
1731
- handleLine(buffer.trim());
1732
- }
1733
- options.onComplete?.(currentSpec);
1734
- }
1735
- catch (err) {
1736
- if (err.name === 'AbortError' || !isCurrent()) {
1737
- return;
1738
- }
1739
- const resolvedError = err instanceof Error ? err : new Error(String(err));
1740
- error.set(resolvedError);
1741
- options.onError?.(resolvedError);
1742
- }
1743
- finally {
1744
- if (isCurrent()) {
1745
- isStreaming.set(false);
1746
- }
1747
- }
1748
- };
1749
- return {
1750
- spec: spec.asReadonly(),
1751
- isStreaming: isStreaming.asReadonly(),
1752
- error: error.asReadonly(),
1753
- usage: usage.asReadonly(),
1754
- rawLines: rawLines.asReadonly(),
1755
- send,
1756
- clear,
1757
- };
1758
- }
1604
+
1759
1605
  /**
1760
1606
  * Convert a flat element list to a Spec.
1761
1607
  * Input elements use key/parentKey to establish identity and relationships.
@@ -1814,7 +1660,7 @@ function isSpecDataPart(data) {
1814
1660
  * parts are present.
1815
1661
  */
1816
1662
  function buildSpecFromParts(parts) {
1817
- const spec = { root: '', elements: {} };
1663
+ let spec = { root: '', elements: {} };
1818
1664
  let hasSpec = false;
1819
1665
  for (const part of parts) {
1820
1666
  if (part.type === SPEC_DATA_PART_TYPE) {
@@ -1823,16 +1669,15 @@ function buildSpecFromParts(parts) {
1823
1669
  const payload = part.data;
1824
1670
  if (payload.type === 'patch') {
1825
1671
  hasSpec = true;
1826
- applySpecPatch(spec, payload.patch);
1672
+ spec = applyPatch(spec, payload.patch);
1827
1673
  }
1828
1674
  else if (payload.type === 'flat') {
1829
1675
  hasSpec = true;
1830
- Object.assign(spec, payload.spec);
1676
+ spec = { ...spec, ...payload.spec };
1831
1677
  }
1832
1678
  else if (payload.type === 'nested') {
1833
1679
  hasSpec = true;
1834
- const flat = nestedToFlat(payload.spec);
1835
- Object.assign(spec, flat);
1680
+ spec = { ...spec, ...nestedToFlat(payload.spec) };
1836
1681
  }
1837
1682
  }
1838
1683
  }
@@ -1876,6 +1721,254 @@ function jsonRenderMessage(parts) {
1876
1721
  }),
1877
1722
  };
1878
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
+
1879
1972
  let chatMessageIdCounter = 0;
1880
1973
  function generateChatId() {
1881
1974
  if (typeof crypto !== 'undefined' &&
@@ -1897,24 +1990,21 @@ function injectChatUI(options) {
1897
1990
  const messages = signal([], ...(ngDevMode ? [{ debugName: "messages" }] : /* istanbul ignore next */ []));
1898
1991
  const isStreaming = signal(false, ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
1899
1992
  const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
1900
- let abortController = null;
1901
- inject(DestroyRef).onDestroy(() => {
1902
- abortController?.abort();
1903
- });
1993
+ const session = createStreamSession();
1994
+ inject(DestroyRef).onDestroy(() => session.cancel());
1995
+ const stop = () => {
1996
+ session.cancel();
1997
+ isStreaming.set(false);
1998
+ };
1904
1999
  const clear = () => {
2000
+ stop();
1905
2001
  messages.set([]);
1906
2002
  error.set(null);
1907
2003
  };
1908
2004
  const send = async (text) => {
1909
2005
  if (!text.trim())
1910
2006
  return;
1911
- // Abort any existing request
1912
- abortController?.abort();
1913
- const controller = new AbortController();
1914
- abortController = controller;
1915
- // See injectUIStream: a superseded request must not clear `isStreaming`
1916
- // out from under the request that replaced it.
1917
- const isCurrent = () => abortController === controller;
2007
+ const gate = session.begin();
1918
2008
  const userMessage = {
1919
2009
  id: generateChatId(),
1920
2010
  role: 'user',
@@ -1928,10 +2018,25 @@ function injectChatUI(options) {
1928
2018
  text: '',
1929
2019
  spec: null,
1930
2020
  };
1931
- // 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.
1932
2037
  const historyForApi = [
1933
2038
  ...messages()
1934
- .filter((m) => m.id !== userMessage.id && m.id !== assistantId)
2039
+ .filter((m) => m.role !== 'assistant' || m.text.length > 0)
1935
2040
  .map((m) => ({
1936
2041
  role: m.role,
1937
2042
  content: m.text,
@@ -1944,87 +2049,54 @@ function injectChatUI(options) {
1944
2049
  error.set(null);
1945
2050
  // Mutable state for accumulating the assistant response
1946
2051
  let accumulatedText = '';
1947
- const currentSpec = { root: '', elements: {} };
2052
+ let currentSpec = { root: '', elements: {} };
1948
2053
  let hasSpec = false;
1949
- const snapshotSpec = () => ({
1950
- root: currentSpec.root,
1951
- elements: { ...currentSpec.elements },
1952
- ...(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
+ },
1953
2068
  });
1954
- // Bound, because a bare `globalThis.fetch` called as a plain function is
1955
- // an illegal invocation in the browser.
1956
- const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
1957
2069
  try {
1958
- const response = await doFetch(options.api, {
1959
- method: 'POST',
1960
- headers: { 'Content-Type': 'application/json' },
1961
- body: JSON.stringify({ messages: historyForApi }),
1962
- signal: controller.signal,
1963
- });
1964
- if (!response.ok) {
1965
- let errorMessage = `HTTP error: ${response.status}`;
1966
- try {
1967
- const errorData = await response.json();
1968
- if (errorData.message) {
1969
- errorMessage = errorData.message;
1970
- }
1971
- else if (errorData.error) {
1972
- errorMessage = errorData.error;
1973
- }
1974
- }
1975
- catch {
1976
- // Ignore JSON parsing errors
1977
- }
1978
- throw new Error(errorMessage);
1979
- }
1980
- const reader = response.body?.getReader();
1981
- if (!reader) {
1982
- throw new Error('No response body');
1983
- }
1984
- const decoder = new TextDecoder();
1985
- // Use createMixedStreamParser to classify lines
1986
- const parser = createMixedStreamParser({
1987
- onPatch(patch) {
1988
- hasSpec = true;
1989
- applySpecPatch(currentSpec, patch);
1990
- const snapshot = snapshotSpec();
1991
- messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, spec: snapshot } : m));
1992
- },
1993
- onText(line) {
1994
- accumulatedText += (accumulatedText ? '\n' : '') + line;
1995
- messages.update((prev) => prev.map((m) => m.id === assistantId ? { ...m, text: accumulatedText } : m));
1996
- },
1997
- });
1998
- while (true) {
1999
- const { done, value } = await reader.read();
2000
- if (done)
2001
- break;
2002
- parser.push(decoder.decode(value, { stream: true }));
2003
- }
2004
- parser.flush();
2005
- // 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);
2006
2076
  const finalMessage = {
2007
2077
  id: assistantId,
2008
2078
  role: 'assistant',
2009
2079
  text: accumulatedText,
2010
- spec: hasSpec ? snapshotSpec() : null,
2080
+ spec: hasSpec ? currentSpec : null,
2011
2081
  };
2012
- options.onComplete?.(finalMessage);
2082
+ gate.commit(() => options.onComplete?.(finalMessage));
2013
2083
  }
2014
2084
  catch (err) {
2015
- 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))
2016
2091
  return;
2017
- }
2018
2092
  const resolvedError = err instanceof Error ? err : new Error(String(err));
2019
- error.set(resolvedError);
2020
- // Remove empty assistant message on error
2021
- messages.update((prev) => prev.filter((m) => m.id !== assistantId || m.text.length > 0));
2022
- options.onError?.(resolvedError);
2093
+ gate.commit(() => {
2094
+ error.set(resolvedError);
2095
+ options.onError?.(resolvedError);
2096
+ });
2023
2097
  }
2024
2098
  finally {
2025
- if (isCurrent()) {
2026
- isStreaming.set(false);
2027
- }
2099
+ gate.commit(() => isStreaming.set(false));
2028
2100
  }
2029
2101
  };
2030
2102
  return {
@@ -2032,10 +2104,20 @@ function injectChatUI(options) {
2032
2104
  isStreaming: isStreaming.asReadonly(),
2033
2105
  error: error.asReadonly(),
2034
2106
  send,
2107
+ stop,
2035
2108
  clear,
2036
2109
  };
2037
2110
  }
2038
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
+
2039
2121
  /**
2040
2122
  * The schema for ngx-json-render.
2041
2123
  *