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.
- package/README.md +25 -2
- package/fesm2022/ngx-json-render.mjs +398 -316
- package/fesm2022/ngx-json-render.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ngx-json-render.d.ts +86 -51
|
@@ -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';
|
|
@@ -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
|
|
1456
|
-
* nodes nobody else is holding, while everything off the path stays
|
|
1457
|
-
* That keeps a patch proportional to the depth of its path rather than
|
|
1458
|
-
* size of the spec, which matters when a stream applies hundreds of
|
|
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:
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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]:
|
|
1527
|
+
const { [elementKey]: _removed, ...rest } = newSpec.elements;
|
|
1543
1528
|
newSpec.elements = rest;
|
|
1529
|
+
return;
|
|
1544
1530
|
}
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
-
|
|
1541
|
+
const root = newSpec;
|
|
1542
|
+
copyAlongPath(root, path);
|
|
1543
|
+
removeByPath(root, path);
|
|
1570
1544
|
}
|
|
1571
1545
|
/**
|
|
1572
|
-
* Apply an RFC 6902 JSON patch to
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
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
|
-
|
|
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 =
|
|
1582
|
+
const moveValue = getByPath(newSpec, patch.from);
|
|
1596
1583
|
removeSpecValue(newSpec, patch.from);
|
|
1597
|
-
|
|
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
|
-
|
|
1604
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1672
|
+
spec = applyPatch(spec, payload.patch);
|
|
1827
1673
|
}
|
|
1828
1674
|
else if (payload.type === 'flat') {
|
|
1829
1675
|
hasSpec = true;
|
|
1830
|
-
|
|
1676
|
+
spec = { ...spec, ...payload.spec };
|
|
1831
1677
|
}
|
|
1832
1678
|
else if (payload.type === 'nested') {
|
|
1833
1679
|
hasSpec = true;
|
|
1834
|
-
|
|
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
|
-
|
|
1901
|
-
inject(DestroyRef).onDestroy(() =>
|
|
1902
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
2052
|
+
let currentSpec = { root: '', elements: {} };
|
|
1948
2053
|
let hasSpec = false;
|
|
1949
|
-
const
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
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
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
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 ?
|
|
2080
|
+
spec: hasSpec ? currentSpec : null,
|
|
2011
2081
|
};
|
|
2012
|
-
options.onComplete?.(finalMessage);
|
|
2082
|
+
gate.commit(() => options.onComplete?.(finalMessage));
|
|
2013
2083
|
}
|
|
2014
2084
|
catch (err) {
|
|
2015
|
-
|
|
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
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2093
|
+
gate.commit(() => {
|
|
2094
|
+
error.set(resolvedError);
|
|
2095
|
+
options.onError?.(resolvedError);
|
|
2096
|
+
});
|
|
2023
2097
|
}
|
|
2024
2098
|
finally {
|
|
2025
|
-
|
|
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
|
*
|