@wix/himalaya-cli 0.987.0 → 0.989.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +46 -18
- package/dist/preview-assets.generated.bin +0 -0
- package/package.json +2 -2
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +138 -44
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +138 -44
- package/templates/community-club-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +138 -44
- package/templates/events-meetups-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +138 -44
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +138 -44
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
- package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +138 -44
- package/templates/owner-lite/tier5-src/_shared/aria-hot-path.ts +167 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +138 -44
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +138 -44
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +138 -44
- package/templates/restaurant-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +138 -44
- package/templates/retail-store-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +138 -44
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-hot-path.ts +167 -0
|
@@ -46,6 +46,7 @@ import { isWebUrl } from "./aria-widget-model.js";
|
|
|
46
46
|
import { NO_WIDGET, activeWidgetOf, asyncAdvanced, type SubtreeSlot, type WidgetOptionRow, type WidgetView }
|
|
47
47
|
from "./aria-widget-view.js";
|
|
48
48
|
import { substituteSelection } from "./aria-widget-model.js";
|
|
49
|
+
import { LOCAL_ID_PREFIX, createRowMemo, mintLocalId, transcriptKeys, type RowMemo } from "./aria-hot-path.js";
|
|
49
50
|
|
|
50
51
|
// ---------------------------------------------------------------------------
|
|
51
52
|
// Shapes
|
|
@@ -119,6 +120,9 @@ export interface AriaChatState {
|
|
|
119
120
|
* question twice found the FIRST copy already in the transcript, dropped the
|
|
120
121
|
* second bubble off the screen, and declared the turn finished. */
|
|
121
122
|
_pendingAfter: number;
|
|
123
|
+
/** The bubble id of `_pending` while the server has not echoed it — minted
|
|
124
|
+
* once per send (`mintLocalId`) so every tick re-appends the SAME row. */
|
|
125
|
+
_pendingId: string;
|
|
122
126
|
/** Server ASSISTANT-turn count at that same moment. The turn is over when the
|
|
123
127
|
* server holds more than this — read from the transcript ALONE. Gating it on
|
|
124
128
|
* the user's message having been echoed back instead meant a service that
|
|
@@ -573,33 +577,63 @@ export function plainBubble(id: string, role: string, who: string, content: stri
|
|
|
573
577
|
*
|
|
574
578
|
* The widgets themselves are dropped here; rendering them is #2605 onward. A
|
|
575
579
|
* reply that was nothing but a widget therefore has empty prose and is filtered
|
|
576
|
-
* out below, which is deliberate: an empty bubble is a visible defect.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
580
|
+
* out below, which is deliberate: an empty bubble is a visible defect.
|
|
581
|
+
*
|
|
582
|
+
* Pass a `memo` on a path that re-reads the transcript (every poll does): a
|
|
583
|
+
* message whose id, text and writing state are unchanged since the last read
|
|
584
|
+
* reuses the bubble built then instead of running the stages above again — see
|
|
585
|
+
* `aria-hot-path.ts` rule 1. Without one, every bubble is built fresh; the two
|
|
586
|
+
* are equivalent. */
|
|
587
|
+
export function toBubbles(
|
|
588
|
+
msgs: ConversationMessage[], assistantName: string, youName = "You", memo?: BubbleMemo,
|
|
589
|
+
): AriaBubble[] {
|
|
590
|
+
const ids = transcriptKeys(msgs);
|
|
591
|
+
const out: AriaBubble[] = [];
|
|
592
|
+
try {
|
|
593
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
594
|
+
const m = msgs[i];
|
|
595
|
+
const role = m.role || "ASSISTANT", raw = messageText(m), id = ids[i];
|
|
596
|
+
const who = role === "USER" ? youName : assistantName;
|
|
597
|
+
// Part of the memo key, not just an input: a row that FINISHES writing
|
|
598
|
+
// can keep exactly the same text and still render differently (a held
|
|
599
|
+
// back envelope is released), so text alone would serve the stale bubble.
|
|
600
|
+
const writing = role !== "USER" && stillWriting(m);
|
|
601
|
+
const build = () => bubbleOf(id, role, who, raw, writing);
|
|
602
|
+
const b = memo ? memo.row(id, raw, `${role}\u0000${who}\u0000${writing ? "w" : ""}`, build) : build();
|
|
603
|
+
if (b) out.push(b);
|
|
604
|
+
}
|
|
605
|
+
} finally {
|
|
606
|
+
memo?.sweep();
|
|
607
|
+
}
|
|
608
|
+
return out;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Bubbles are built from one message's raw text; `null` is "paints nothing". */
|
|
612
|
+
export type BubbleMemo = RowMemo<AriaBubble | null>;
|
|
613
|
+
export const createBubbleMemo = (): BubbleMemo => createRowMemo<AriaBubble | null>();
|
|
614
|
+
|
|
615
|
+
function bubbleOf(id: string, role: string, who: string, raw: string, writing: boolean): AriaBubble | null {
|
|
616
|
+
// Only assistant turns carry envelopes; a user's own text is literal, and
|
|
617
|
+
// scanning it would let an owner who typed `<<…>>` erase their own message.
|
|
618
|
+
// HIDDEN spans go first: the service marks text as transcript-only that
|
|
619
|
+
// way, and an aborted turn consists of nothing else. Then envelopes, then
|
|
620
|
+
// the markdown stripper — each stage handing on only what a reader should
|
|
621
|
+
// actually see.
|
|
622
|
+
// A row the service is still writing is scanned as STREAMING: an
|
|
623
|
+
// envelope whose `>>` has not arrived yet is held back rather than
|
|
624
|
+
// painted as raw JSON until the row finishes.
|
|
625
|
+
const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, !writing).parts);
|
|
626
|
+
const content = role === "USER" ? prose : stripMarkdown(prose);
|
|
627
|
+
if (!content || !content.trim()) return null;
|
|
628
|
+
return {
|
|
629
|
+
id,
|
|
630
|
+
role,
|
|
631
|
+
who,
|
|
632
|
+
content,
|
|
633
|
+
notUser: role !== "USER",
|
|
634
|
+
notAria: role === "USER",
|
|
635
|
+
...linkFieldsOf(prose, role),
|
|
636
|
+
};
|
|
603
637
|
}
|
|
604
638
|
|
|
605
639
|
/** The reply the Copy / Regenerate row acts on: the LAST bubble, when it is an
|
|
@@ -648,11 +682,14 @@ export function argText(arg: AriaActionArg): string {
|
|
|
648
682
|
* when we sent. Until then it is appended so the owner's message does not
|
|
649
683
|
* vanish mid-turn. */
|
|
650
684
|
export function reconcile(
|
|
651
|
-
server: AriaBubble[], pending: string, pendingAfter: number, youName = "You",
|
|
685
|
+
server: AriaBubble[], pending: string, pendingAfter: number, youName = "You", pendingId = "",
|
|
652
686
|
): { messages: AriaBubble[]; pending: string } {
|
|
653
687
|
if (!pending) return { messages: server, pending: "" };
|
|
654
688
|
if (countRole(server, "USER") > pendingAfter) return { messages: server, pending: "" };
|
|
655
|
-
|
|
689
|
+
// `pendingId` is empty only for state persisted before ids were minted; a
|
|
690
|
+
// fixed fallback still keeps that one row stable across ticks.
|
|
691
|
+
const id = pendingId || `${LOCAL_ID_PREFIX}pending`;
|
|
692
|
+
return { messages: [...server, plainBubble(id, "USER", youName, pending)], pending };
|
|
656
693
|
}
|
|
657
694
|
|
|
658
695
|
/** How many turns of one role the transcript holds. Counted over BUBBLES rather
|
|
@@ -1029,7 +1066,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1029
1066
|
const initialState: AriaChatState = {
|
|
1030
1067
|
status: "loading", statusText: "", error: "", input: "", copyNote: "", replyActionsHidden: true,
|
|
1031
1068
|
messages: [], prompts: [], _convId: "", _siteId: "", _pending: "",
|
|
1032
|
-
_pendingAfter: 0, _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
|
|
1069
|
+
_pendingAfter: 0, _pendingId: "", _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
|
|
1033
1070
|
_abortId: "", _turnId: "", stopHidden: true, ok: false,
|
|
1034
1071
|
widget: NO_WIDGET,
|
|
1035
1072
|
};
|
|
@@ -1047,6 +1084,17 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1047
1084
|
* in-flight conversation. The latch is cleared once settled, so a failed
|
|
1048
1085
|
* create is retried rather than remembered. */
|
|
1049
1086
|
let creating: { site: string; promise: Promise<string> } | null = null;
|
|
1087
|
+
|
|
1088
|
+
/** Bubbles built on the previous transcript read, reused while their message
|
|
1089
|
+
* is unchanged — every poll re-reads the WHOLE transcript, and re-deriving
|
|
1090
|
+
* every bubble on each one is the cost `aria-hot-path.ts` rule 1 removes.
|
|
1091
|
+
* Bounded by the latest read: rows it did not contain are forgotten. */
|
|
1092
|
+
const bubbleMemo = createBubbleMemo();
|
|
1093
|
+
|
|
1094
|
+
/** Last number `mintLocalId` handed out. Not state: `mintLocalId` skips every
|
|
1095
|
+
* id already on screen, so a cold start resetting it cannot collide. */
|
|
1096
|
+
let localSeq = 0;
|
|
1097
|
+
|
|
1050
1098
|
function createOnce(site: string): Promise<string> {
|
|
1051
1099
|
if (creating && creating.site === site) return creating.promise;
|
|
1052
1100
|
const promise = createConversation(assistantConfig, origin)
|
|
@@ -1270,7 +1318,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1270
1318
|
for (let n = 0; n < BUSY_LOOP_ROUNDS; n++) {
|
|
1271
1319
|
const gm = await getConversationMessages(convId);
|
|
1272
1320
|
const raw = messagesOf(gm);
|
|
1273
|
-
const bubbles = toBubbles(raw, assistant(), you());
|
|
1321
|
+
const bubbles = toBubbles(raw, assistant(), you(), bubbleMemo);
|
|
1274
1322
|
if (bubbles.length) server = bubbles;
|
|
1275
1323
|
seen = raw;
|
|
1276
1324
|
// The turn is over when the SERVER holds an assistant answer — which a
|
|
@@ -1305,7 +1353,27 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1305
1353
|
turnSeq += 1;
|
|
1306
1354
|
// Optimistic first: the owner's message and the typing line are part of the
|
|
1307
1355
|
// state this action RETURNS, so on the `_poll` path they paint immediately.
|
|
1308
|
-
|
|
1356
|
+
// Its id is the LOCAL SEND ID, minted here and carried as `_pendingId`, the
|
|
1357
|
+
// one `reconcile` re-appends it under on every tick, so the row keeps one
|
|
1358
|
+
// identity until the server's copy lands.
|
|
1359
|
+
//
|
|
1360
|
+
// A local bubble already on screen with these exact words is this message's
|
|
1361
|
+
// FAILED attempt (an identical PENDING one is refused as a duplicate), so a
|
|
1362
|
+
// retry takes over its id and replaces it rather than painting the same
|
|
1363
|
+
// words twice. Matched on the words themselves — never on a hash of them.
|
|
1364
|
+
const failedCopy = s.messages.find((b) =>
|
|
1365
|
+
b.role === "USER" && b.id.startsWith(LOCAL_ID_PREFIX) && b.content === content);
|
|
1366
|
+
let localId: string;
|
|
1367
|
+
if (failedCopy) {
|
|
1368
|
+
localId = failedCopy.id;
|
|
1369
|
+
} else {
|
|
1370
|
+
const minted = mintLocalId(localSeq, (id) => s.messages.some((b) => b.id === id));
|
|
1371
|
+
localSeq = minted.seq;
|
|
1372
|
+
localId = minted.id;
|
|
1373
|
+
}
|
|
1374
|
+
const optimistic: AriaBubble[] = [
|
|
1375
|
+
...s.messages.filter((b) => b.id !== localId), plainBubble(localId, "USER", you(), content),
|
|
1376
|
+
];
|
|
1309
1377
|
const base: AriaChatState = {
|
|
1310
1378
|
// `copyNote: ""` — a Copy confirmation belongs to the moment, and a new
|
|
1311
1379
|
// turn ends it. Left in place it would sit under the next reply saying
|
|
@@ -1316,7 +1384,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1316
1384
|
// on a stale turn id would look for an answer that already exists. Both
|
|
1317
1385
|
// are replaced the moment this send knows its own id.
|
|
1318
1386
|
_abortId: "", _turnId: "",
|
|
1319
|
-
statusText: typingText(), messages: optimistic, _pending: content,
|
|
1387
|
+
statusText: typingText(), messages: optimistic, _pending: content, _pendingId: localId,
|
|
1320
1388
|
// Mark the turn against the LAST COUNTS WE SAW, not against our own
|
|
1321
1389
|
// message list — the greeting is a local bubble the server never had, so
|
|
1322
1390
|
// counting `messages` would mark the turn one assistant reply ahead and
|
|
@@ -1508,15 +1576,33 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1508
1576
|
// this send was working on and is still the one we captured; otherwise it
|
|
1509
1577
|
// belongs to another turn and must survive.
|
|
1510
1578
|
: (supersedes && stalledTurn === latch ? null : stalledTurn);
|
|
1579
|
+
// A FOLLOW-UP failing does not end the turn before it. Asking again while
|
|
1580
|
+
// an answer is still being written is supported (`isDuplicateSend`), so
|
|
1581
|
+
// the earlier turn can be in flight right now — and marking the screen
|
|
1582
|
+
// `ready` here threw it away: its Stop went, and `tick` (which only slows
|
|
1583
|
+
// the ladder on a turn it saw END) never slowed it, so the fast rate ran
|
|
1584
|
+
// for as long as the screen stayed open (aria-hot-path.ts rule 4). Only in
|
|
1585
|
+
// the same conversation: a turn in another one is not this screen's.
|
|
1586
|
+
const keepsEarlier = turnInFlight(s) && convId === s._convId;
|
|
1587
|
+
const earlier: Partial<AriaChatState> = keepsEarlier
|
|
1588
|
+
? { status: s.status, _pending: s._pending, _pendingId: s._pendingId, _pendingAfter: s._pendingAfter,
|
|
1589
|
+
_replyAfter: s._replyAfter, _abortId: s._abortId, _turnId: s._turnId }
|
|
1590
|
+
: { status: "ready", _pending: "" };
|
|
1591
|
+
const slots = failureSlots(e, unavailableText);
|
|
1511
1592
|
return {
|
|
1512
|
-
...base,
|
|
1593
|
+
...base, ...earlier,
|
|
1513
1594
|
// `_siteId` moves with the id or neither does: a conversation remembered
|
|
1514
1595
|
// without the site it belongs to reads as another business's on the next
|
|
1515
1596
|
// send, and gets replaced instead of retried.
|
|
1516
1597
|
_convId: convId, _siteId: convId === s._convId ? s._siteId : site,
|
|
1517
1598
|
// Sets BOTH error and statusText, so it must come after the `statusText: ""`
|
|
1518
1599
|
// this object used to carry — otherwise the literal would blank the sentence.
|
|
1519
|
-
...
|
|
1600
|
+
...slots,
|
|
1601
|
+
// …except that a kept turn keeps its own status line. A real failure
|
|
1602
|
+
// puts its sentence in `error` and blanks `statusText`, which here would
|
|
1603
|
+
// leave Stop on screen beside no "Aria is typing…" until the answer
|
|
1604
|
+
// landed. A boundary refusal's neutral sentence still wins.
|
|
1605
|
+
...(keepsEarlier ? { statusText: slots.statusText || s.statusText } : {}),
|
|
1520
1606
|
};
|
|
1521
1607
|
}
|
|
1522
1608
|
}
|
|
@@ -1680,6 +1766,14 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1680
1766
|
|
|
1681
1767
|
load: async (s) => {
|
|
1682
1768
|
const site = siteOf();
|
|
1769
|
+
// Every branch below that puts a DIFFERENT conversation on screen abandons
|
|
1770
|
+
// whatever turn was in flight — a business switch mid-turn does exactly
|
|
1771
|
+
// that — so it re-arms the ladder slow, as `newChat` does. Otherwise the
|
|
1772
|
+
// fast rate the send armed is never slowed: `tick` only slows on a turn it
|
|
1773
|
+
// saw end (aria-hot-path.ts rule 4).
|
|
1774
|
+
const wasMidTurn = !!s._pending || s.status === "sending";
|
|
1775
|
+
const leaving = (next: AriaChatState): AriaChatState =>
|
|
1776
|
+
wasMidTurn && o.poll ? cadence(next, o.poll.idleMs, null) : next;
|
|
1683
1777
|
// Resume: the history screen routes here with a conversationId. Only
|
|
1684
1778
|
// honour it for the site it belongs to — a stale id from another business
|
|
1685
1779
|
// must not be re-opened.
|
|
@@ -1697,13 +1791,13 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1697
1791
|
try {
|
|
1698
1792
|
const gm = await getConversationMessages(resumeId);
|
|
1699
1793
|
const raw = messagesOf(gm);
|
|
1700
|
-
const bubbles = toBubbles(raw, assistant(), you());
|
|
1794
|
+
const bubbles = toBubbles(raw, assistant(), you(), bubbleMemo);
|
|
1701
1795
|
const resumed = bubbles.length ? bubbles : greetingOnly();
|
|
1702
1796
|
// `copyNote: ""` — a resumed transcript ends in a reply, so the row is
|
|
1703
1797
|
// shown at once; a note carried over would say "Copied" beside an
|
|
1704
1798
|
// answer from a different conversation that nobody copied.
|
|
1705
|
-
return { ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "", copyNote: "",
|
|
1706
|
-
messages: resumed, ...observed(raw, s.widget), prompts: promptsFor(resumed, promptList()) };
|
|
1799
|
+
return leaving({ ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "", copyNote: "",
|
|
1800
|
+
messages: resumed, ...observed(raw, s.widget), prompts: promptsFor(resumed, promptList()) });
|
|
1707
1801
|
} catch { /* expired or unreachable — fall through to a fresh one */ }
|
|
1708
1802
|
}
|
|
1709
1803
|
// Opening a screen runs `load` more than once — the bundle self-kicks at
|
|
@@ -1735,12 +1829,12 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1735
1829
|
return { ...s, status: "ready", messages, prompts: promptsFor(messages, promptList()) };
|
|
1736
1830
|
}
|
|
1737
1831
|
try {
|
|
1738
|
-
return { ...s, status: "ready", _convId: await createOnce(site), _siteId: site, _pending: "", statusText: "",
|
|
1739
|
-
messages: greetingOnly(), ...observed([]), prompts: promptList() };
|
|
1832
|
+
return leaving({ ...s, status: "ready", _convId: await createOnce(site), _siteId: site, _pending: "", statusText: "",
|
|
1833
|
+
messages: greetingOnly(), ...observed([]), prompts: promptList() });
|
|
1740
1834
|
} catch (e) {
|
|
1741
1835
|
// Even if create fails, show the greeting; submit retries the create.
|
|
1742
|
-
return { ...s, status: "ready", _convId: "", _siteId: site, _pending: "", ...failureSlots(e, unavailableText),
|
|
1743
|
-
messages: greetingOnly(), ...observed([]), prompts: promptList() };
|
|
1836
|
+
return leaving({ ...s, status: "ready", _convId: "", _siteId: site, _pending: "", ...failureSlots(e, unavailableText),
|
|
1837
|
+
messages: greetingOnly(), ...observed([]), prompts: promptList() });
|
|
1744
1838
|
}
|
|
1745
1839
|
},
|
|
1746
1840
|
|
|
@@ -1842,14 +1936,14 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1842
1936
|
// `stop` holds a snapshot predating this read and will overwrite
|
|
1843
1937
|
// whatever we write. A tick of a NEWER epoch is not stale, not fenced.
|
|
1844
1938
|
if (turnSeq !== seq || abortingEpochs.has(seq)) return undefined;
|
|
1845
|
-
const server = toBubbles(raw, assistant(), you());
|
|
1939
|
+
const server = toBubbles(raw, assistant(), you(), bubbleMemo);
|
|
1846
1940
|
// A transcript that renders to nothing is not necessarily an empty one:
|
|
1847
1941
|
// a reply made entirely of widgets leaves no bubble behind. So the bail
|
|
1848
1942
|
// is on the RAW transcript — bailing on the bubbles is what let such a
|
|
1849
1943
|
// reply go unnoticed and pinned the ladder fast forever. Safe to return
|
|
1850
1944
|
// `s` here: the guard above proved this tick is still current.
|
|
1851
1945
|
if (!raw.length) return s;
|
|
1852
|
-
const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you());
|
|
1946
|
+
const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you(), s._pendingId);
|
|
1853
1947
|
// …but still never PAINT nothing over something. `messages` is only
|
|
1854
1948
|
// adopted when it has content: a transcript that renders empty leaves
|
|
1855
1949
|
// the screen as it was (the greeting, or the last good turn) while the
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// The Aria transcript's hot path — the rules it is written to, and the two
|
|
2
|
+
// primitives that make them hold.
|
|
3
|
+
//
|
|
4
|
+
// Adapted from `native-apps/PERFORMANCE.md` in wix-private/aria-native-apps, which
|
|
5
|
+
// states eleven rules, each "a class of shipped regression". Most of them govern
|
|
6
|
+
// 30 Hz streaming, which this app does not do yet (#2612), and adopting all eleven
|
|
7
|
+
// for a POLLED chat would be cargo-culting. These four apply now (#2623):
|
|
8
|
+
//
|
|
9
|
+
// 1. NEVER RE-DERIVE THE WHOLE TRANSCRIPT PER TICK. Every poll re-reads the full
|
|
10
|
+
// transcript, and turning a message into a bubble is the expensive step —
|
|
11
|
+
// the envelope scan, the markdown stripper (a dozen regex passes) and the
|
|
12
|
+
// link scan. So a bubble is built once per (identity, content) and reused
|
|
13
|
+
// while its message is unchanged: a poll that brings nothing new builds
|
|
14
|
+
// nothing. `createRowMemo`.
|
|
15
|
+
// Measured per poll over an unchanged transcript of mixed-markdown replies
|
|
16
|
+
// (bubbles + both turn counts), median, QuickJS — the no-JIT floor a device
|
|
17
|
+
// worker realm runs at:
|
|
18
|
+
// messages 20 100 500
|
|
19
|
+
// before 3 ms 16 ms 79 ms
|
|
20
|
+
// after 0 ms 1 ms 6 ms (≈5 ms of it is `countTurns`)
|
|
21
|
+
// Node's JIT shows the same ratio: 2.4 ms → 0.21 ms at 500.
|
|
22
|
+
//
|
|
23
|
+
// 2. A PER-TICK IDENTITY IS A CACHE FLUSH. The memo is keyed by the MESSAGE's
|
|
24
|
+
// identity, never by anything minted per read. A message that is still
|
|
25
|
+
// growing — the streaming case — keeps its id, so it overwrites its own slot
|
|
26
|
+
// in place and every settled row stays built. The memo's bound is the
|
|
27
|
+
// transcript itself (rows the latest read did not contain are dropped), so
|
|
28
|
+
// there is no LRU for a churning key to evict settled entries out of.
|
|
29
|
+
//
|
|
30
|
+
// 3. TRANSCRIPT IDENTITY IS THE SERVER MESSAGE ID — or the LOCAL SEND ID until
|
|
31
|
+
// the server acknowledges the message. Never an array index: an index names
|
|
32
|
+
// a POSITION, and the moment anything before it is dropped (an empty
|
|
33
|
+
// placeholder, a hidden-only row, a windowed read) the same index names a
|
|
34
|
+
// different message. The bubble id is also the row's stable test id
|
|
35
|
+
// (`<screen>.msg_<id>`, invariant 27). `transcriptKeys`, `mintLocalId`.
|
|
36
|
+
//
|
|
37
|
+
// 4. EVERY REPEATING CLOCK HAS AN OWNER AND STOPS ON EVERY EXIT PATH. The only
|
|
38
|
+
// clock here is the reply ladder, `_poll` id `aria-turn`, and its owner is
|
|
39
|
+
// the HOST's PollScheduler — a worker has no timers, and `PollDirective` has
|
|
40
|
+
// no stop verb. So, stated explicitly rather than implied:
|
|
41
|
+
// - one id, ever: no second `_poll` is armed (async-job progress rides the
|
|
42
|
+
// same ladder for exactly this reason — see `readJobState`);
|
|
43
|
+
// - every path that ENDS or ABANDONS a turn re-arms it at the IDLE rate
|
|
44
|
+
// (reply, Stop, ➕ mid-turn, a load that leaves the turn behind),
|
|
45
|
+
// because slowing is the only lever a worker holds;
|
|
46
|
+
// - a path that does NOT end the turn must not pretend to. A follow-up
|
|
47
|
+
// whose send failed used to discard the turn still in flight — marking
|
|
48
|
+
// the screen idle while the first answer was still being written — so
|
|
49
|
+
// `tick` never saw the turn end and the fast rate ran for as long as
|
|
50
|
+
// the screen stayed open (30 reads a minute instead of 4);
|
|
51
|
+
// - the host stops it on background, pooling and teardown (poll-parity).
|
|
52
|
+
// What it cannot do yet is stop while the screen is visible and idle: that
|
|
53
|
+
// needs a stop verb on the directive across every host arm.
|
|
54
|
+
//
|
|
55
|
+
// Rules for streaming (tail-only parsing, publish coalescing, frame-clock
|
|
56
|
+
// drivers) and the native app's device budgets (cold start, frame time, hangs)
|
|
57
|
+
// are deliberately NOT adopted here: nothing in this repo measures them for
|
|
58
|
+
// Aria, and a budget nobody measures is decoration. Each rule above is pinned by
|
|
59
|
+
// a test instead — `core/dev-server/tests/aria-hot-path.test.ts`.
|
|
60
|
+
//
|
|
61
|
+
// Dependency-free on purpose: the tests import this file directly.
|
|
62
|
+
|
|
63
|
+
/** Prefix of every id minted for a bubble the server has not echoed yet. */
|
|
64
|
+
export const LOCAL_ID_PREFIX = "local-";
|
|
65
|
+
|
|
66
|
+
/** Mint the id for the bubble of a message the owner just sent.
|
|
67
|
+
*
|
|
68
|
+
* MINTED once per send and then carried in state (`_pendingId`), so the
|
|
69
|
+
* optimistic bubble `send` paints and the one `reconcile` re-appends on every
|
|
70
|
+
* tick are the SAME row until the server's copy replaces it. It used to be
|
|
71
|
+
* `u<list length>` in one and `pending-<server length>` in the other — two ids
|
|
72
|
+
* for one message, both positions.
|
|
73
|
+
*
|
|
74
|
+
* Deliberately NOT derived from the text. A 32-bit hash of it collides —
|
|
75
|
+
* different words, one id — and then sending one message silently replaced
|
|
76
|
+
* another's failed bubble and took over its identity. A retry of the SAME send
|
|
77
|
+
* reuses its failed copy's id instead (see `send`).
|
|
78
|
+
*
|
|
79
|
+
* `taken` is every id on screen, so a counter that a cold start reset can never
|
|
80
|
+
* mint an id a persisted bubble still carries. */
|
|
81
|
+
export function mintLocalId(seq: number, taken: (id: string) => boolean): { id: string; seq: number } {
|
|
82
|
+
let n = seq;
|
|
83
|
+
let id: string;
|
|
84
|
+
do { id = `${LOCAL_ID_PREFIX}${++n}`; } while (taken(id));
|
|
85
|
+
return { id, seq: n };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One transcript row, as far as identity is concerned. */
|
|
89
|
+
export interface IdentityRow {
|
|
90
|
+
id?: unknown;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A usable server id: a non-empty string, or a number (kept as its string). */
|
|
94
|
+
function serverIdOf(id: unknown): string {
|
|
95
|
+
if (typeof id === "string") return id;
|
|
96
|
+
if (typeof id === "number" && Number.isFinite(id)) return String(id);
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A stable key per row: the server id when there is one.
|
|
101
|
+
*
|
|
102
|
+
* A row with no id is keyed RELATIVE TO THE NEAREST SERVER ID BEFORE IT —
|
|
103
|
+
* `after-<id>-<k>` for the k-th id-less row since that anchor, `head-<k>` before
|
|
104
|
+
* any. Never by its content, and never by its absolute position:
|
|
105
|
+
* - content would re-key a row that is still being written on every read, the
|
|
106
|
+
* exact case where identity matters most;
|
|
107
|
+
* - an absolute index re-keys every later row the moment anything before it
|
|
108
|
+
* is dropped (a placeholder, a hidden-only row, a windowed read).
|
|
109
|
+
* What remains unstable is narrow and stated: dropping an id-less row between
|
|
110
|
+
* the same anchor and this one shifts `k`, and the `head-` rows have no anchor
|
|
111
|
+
* to hold them. The service names its rows; this is the fallback. */
|
|
112
|
+
export function transcriptKeys(rows: ReadonlyArray<IdentityRow>): string[] {
|
|
113
|
+
let anchor = "";
|
|
114
|
+
let k = 0;
|
|
115
|
+
return rows.map((r) => {
|
|
116
|
+
const id = serverIdOf(r.id);
|
|
117
|
+
if (id) { anchor = id; k = 0; return id; }
|
|
118
|
+
return anchor ? `after-${anchor}-${k++}` : `head-${k++}`;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Per-row derivation memo for a transcript that is re-read wholesale.
|
|
123
|
+
*
|
|
124
|
+
* Use: call `row` once per message of one read, then `sweep`. A row is rebuilt
|
|
125
|
+
* only when its key is new or its `input` / `variant` changed.
|
|
126
|
+
*
|
|
127
|
+
* BOUND: the rows of the latest completed read — `sweep` forgets every key that
|
|
128
|
+
* read did not touch. So the memo never outgrows the transcript on screen, and
|
|
129
|
+
* a conversation switch drops the previous one's rows at the first read.
|
|
130
|
+
*
|
|
131
|
+
* Values are SHARED between reads. Callers must treat them as immutable, which
|
|
132
|
+
* the transcript already is: every reducer here replaces `messages`, none
|
|
133
|
+
* mutates a bubble in place. */
|
|
134
|
+
export interface RowMemo<V> {
|
|
135
|
+
row(key: string, input: string, variant: string, build: () => V): V;
|
|
136
|
+
sweep(): void;
|
|
137
|
+
/** How many times `build` ran since creation — what a count test pins. */
|
|
138
|
+
readonly builds: number;
|
|
139
|
+
/** Rows currently held. */
|
|
140
|
+
readonly size: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createRowMemo<V>(): RowMemo<V> {
|
|
144
|
+
interface Entry { input: string; variant: string; value: V }
|
|
145
|
+
let live = new Map<string, Entry>();
|
|
146
|
+
let next = new Map<string, Entry>();
|
|
147
|
+
let builds = 0;
|
|
148
|
+
return {
|
|
149
|
+
row(key, input, variant, build) {
|
|
150
|
+
const e = next.get(key) ?? live.get(key);
|
|
151
|
+
if (e && e.input === input && e.variant === variant) {
|
|
152
|
+
next.set(key, e);
|
|
153
|
+
return e.value;
|
|
154
|
+
}
|
|
155
|
+
builds++;
|
|
156
|
+
const value = build();
|
|
157
|
+
next.set(key, { input, variant, value });
|
|
158
|
+
return value;
|
|
159
|
+
},
|
|
160
|
+
sweep() {
|
|
161
|
+
live = next;
|
|
162
|
+
next = new Map();
|
|
163
|
+
},
|
|
164
|
+
get builds() { return builds; },
|
|
165
|
+
get size() { return live.size; },
|
|
166
|
+
};
|
|
167
|
+
}
|