@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
package/dist/cli.mjs
CHANGED
|
@@ -15932,7 +15932,7 @@ function wixClientIdOf(appConfigJson) {
|
|
|
15932
15932
|
if (typeof clientId !== "string" || !clientId) return { error: "no_client_id" };
|
|
15933
15933
|
return { clientId };
|
|
15934
15934
|
}
|
|
15935
|
-
var WIX_API, ONE_APP_SESSION, OWNER_SCOPED_WIX_HOST, VisitorTokenCache, OwnerInstanceCache, OWNER_SITES_QUERY_PATH, OWNER_SITES_COUNT_PATH, OwnerSitesCache;
|
|
15935
|
+
var WIX_API, ONE_APP_SESSION, OWNER_SCOPED_WIX_HOST, VisitorTokenCache, OWNER_REFRESH_MARGIN_MS, OwnerInstanceCache, OWNER_SITES_QUERY_PATH, OWNER_SITES_COUNT_PATH, OwnerSitesCache;
|
|
15936
15936
|
var init_wix_bridge = __esm({
|
|
15937
15937
|
"../../core/serve-runtime/src/wix-bridge.ts"() {
|
|
15938
15938
|
init_sha256();
|
|
@@ -15969,20 +15969,29 @@ var init_wix_bridge = __esm({
|
|
|
15969
15969
|
this.cache.delete(clientId);
|
|
15970
15970
|
}
|
|
15971
15971
|
};
|
|
15972
|
+
OWNER_REFRESH_MARGIN_MS = 6e4;
|
|
15972
15973
|
OwnerInstanceCache = class {
|
|
15973
15974
|
constructor(fetchImpl = fetch, ttlMs = 5 * 6e4) {
|
|
15974
15975
|
this.fetchImpl = fetchImpl;
|
|
15975
15976
|
this.ttlMs = ttlMs;
|
|
15976
15977
|
}
|
|
15977
15978
|
cache = /* @__PURE__ */ new Map();
|
|
15979
|
+
/** Keyed on the SESSION (`wixSession2`), not the whole cookie header: manage.wix.com's jar churns
|
|
15980
|
+
* (analytics, experiments), and keying on all of it re-minted on nearly every call (#3044). A
|
|
15981
|
+
* header with no session cookie falls back to itself — callers refuse that case before here. */
|
|
15978
15982
|
key(msid, cookieHeader) {
|
|
15983
|
+
const session = /(?:^|;\s*)wixSession2=([^;]*)/.exec(cookieHeader)?.[1] ?? cookieHeader;
|
|
15979
15984
|
return sha256Hex2(`${msid}
|
|
15980
|
-
${
|
|
15985
|
+
${session}`);
|
|
15981
15986
|
}
|
|
15982
15987
|
async instance(msid, cookieHeader, now = Date.now()) {
|
|
15988
|
+
return (await this.entry(msid, cookieHeader, now)).instance;
|
|
15989
|
+
}
|
|
15990
|
+
/** The instance and the time this cache stops trusting it (#3044: the web host caches until then). */
|
|
15991
|
+
async entry(msid, cookieHeader, now = Date.now()) {
|
|
15983
15992
|
const key2 = this.key(msid, cookieHeader);
|
|
15984
15993
|
const hit = this.cache.get(key2);
|
|
15985
|
-
if (hit && hit.expiresAt > now) return hit.instance;
|
|
15994
|
+
if (hit && hit.expiresAt - OWNER_REFRESH_MARGIN_MS > now) return { instance: hit.instance, expiresAt: hit.expiresAt };
|
|
15986
15995
|
const res = await this.fetchImpl(`${ONE_APP_SESSION}/${msid}/signed-instance?signed_instance_type=User`, {
|
|
15987
15996
|
method: "GET",
|
|
15988
15997
|
headers: { Cookie: cookieHeader }
|
|
@@ -15995,8 +16004,9 @@ ${cookieHeader}`);
|
|
|
15995
16004
|
}
|
|
15996
16005
|
if (res.status < 200 || res.status >= 300 || !j.signedInstance)
|
|
15997
16006
|
throw new Error(`owner mint failed (${res.status})`);
|
|
15998
|
-
|
|
15999
|
-
|
|
16007
|
+
const entry = { instance: j.signedInstance, expiresAt: now + this.ttlMs };
|
|
16008
|
+
this.cache.set(key2, entry);
|
|
16009
|
+
return { ...entry };
|
|
16000
16010
|
}
|
|
16001
16011
|
/** Drop a cached instance (e.g. after a 401 from wixapis) so the next call re-mints. */
|
|
16002
16012
|
invalidate(msid, cookieHeader) {
|
|
@@ -18857,6 +18867,7 @@ function shouldRevealError(args) {
|
|
|
18857
18867
|
var EMAIL_RE, URL_HOST_RE, INTEGER_RE, NUMBER_RE;
|
|
18858
18868
|
var init_validation = __esm({
|
|
18859
18869
|
"../../core/ts/src/validation.ts"() {
|
|
18870
|
+
"use strict";
|
|
18860
18871
|
EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/;
|
|
18861
18872
|
URL_HOST_RE = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*(:[0-9]+)?$/;
|
|
18862
18873
|
INTEGER_RE = /^[+-]?[0-9]+$/;
|
|
@@ -22281,6 +22292,7 @@ function validateWorkerSubtree(raw, slotId, policy, actionCatalog) {
|
|
|
22281
22292
|
var WORKER_SUBTREE_CEILINGS, ID_PART, ACTION_ID;
|
|
22282
22293
|
var init_worker_subtree = __esm({
|
|
22283
22294
|
"../../core/ts/src/worker-subtree.ts"() {
|
|
22295
|
+
"use strict";
|
|
22284
22296
|
init_worker_subtree_safe_types_generated();
|
|
22285
22297
|
WORKER_SUBTREE_CEILINGS = Object.freeze({
|
|
22286
22298
|
maxEncodedBytes: 65536,
|
|
@@ -43027,6 +43039,22 @@ ${releaseId}`;
|
|
|
43027
43039
|
}
|
|
43028
43040
|
});
|
|
43029
43041
|
}
|
|
43042
|
+
if (path === "/v1/owner/instance") {
|
|
43043
|
+
if (opts.webwixBridge === false) return json3(503, { error: "webwix_bridge_disabled" });
|
|
43044
|
+
if (!webwixOwnerRungOn()) return json3(503, { error: "webwix_owner_disabled" });
|
|
43045
|
+
const msid = url.searchParams.get("msid") ?? "";
|
|
43046
|
+
if (!isSafeMsid(msid)) return json3(400, { error: "webwix_owner_bad_msid" });
|
|
43047
|
+
const cookie = request.headers.get("cookie") ?? "";
|
|
43048
|
+
if (!/wixSession2=/.test(cookie)) return json3(401, { error: "webwix_owner_no_session" });
|
|
43049
|
+
try {
|
|
43050
|
+
const entry = await webwixOwnerInstances.entry(msid, cookie);
|
|
43051
|
+
return json3(200, { ...entry, expiresInMs: Math.max(0, entry.expiresAt - Date.now()) }, { "cache-control": "no-store" });
|
|
43052
|
+
} catch (e) {
|
|
43053
|
+
const msg = String(e?.message ?? e);
|
|
43054
|
+
if (/owner mint failed \(5\d\d\)/.test(msg) || !msg.includes("owner mint failed")) return json3(502, { error: "webwix_bridge_upstream_failed" });
|
|
43055
|
+
return json3(403, { error: "webwix_owner_mint_failed" });
|
|
43056
|
+
}
|
|
43057
|
+
}
|
|
43030
43058
|
if (path === "/v1/owner/sites") {
|
|
43031
43059
|
if (!webwixOwnerRungOn()) return json3(503, { error: "webwix_owner_disabled" });
|
|
43032
43060
|
const cookie = request.headers.get("cookie") ?? "";
|
|
@@ -43791,19 +43819,19 @@ var init_preview_assets_generated = __esm({
|
|
|
43791
43819
|
"../../packages/serve/src/preview-assets.generated.ts"() {
|
|
43792
43820
|
init_preview_assets_format();
|
|
43793
43821
|
PREVIEW_ASSET_MANIFEST = [
|
|
43794
|
-
{ key: "assets/index-
|
|
43795
|
-
{ key: "fonts/WixMadeforDisplay-Bold.ttf", contentType: "font/ttf", offset:
|
|
43796
|
-
{ key: "fonts/WixMadeforDisplay-ExtraBold.ttf", contentType: "font/ttf", offset:
|
|
43797
|
-
{ key: "fonts/WixMadeforDisplay-Medium.ttf", contentType: "font/ttf", offset:
|
|
43798
|
-
{ key: "fonts/WixMadeforDisplay-Regular.ttf", contentType: "font/ttf", offset:
|
|
43799
|
-
{ key: "fonts/WixMadeforDisplay-SemiBold.ttf", contentType: "font/ttf", offset:
|
|
43800
|
-
{ key: "fonts/WixMadeforText-Bold.ttf", contentType: "font/ttf", offset:
|
|
43801
|
-
{ key: "fonts/WixMadeforText-ExtraBold.ttf", contentType: "font/ttf", offset:
|
|
43802
|
-
{ key: "fonts/WixMadeforText-Medium.ttf", contentType: "font/ttf", offset:
|
|
43803
|
-
{ key: "fonts/WixMadeforText-Regular.ttf", contentType: "font/ttf", offset:
|
|
43804
|
-
{ key: "fonts/WixMadeforText-SemiBold.ttf", contentType: "font/ttf", offset:
|
|
43805
|
-
{ key: "index.html", contentType: "text/html; charset=utf-8", offset:
|
|
43806
|
-
{ key: "t5-frame.html", contentType: "text/html; charset=utf-8", offset:
|
|
43822
|
+
{ key: "assets/index-DKlP06hp.js", contentType: "application/javascript; charset=utf-8", offset: 8, length: 997374, sha256: "9dd7ca28e467c7715af80eb404682bcd75804fee6941c8ae30cacdb542385854" },
|
|
43823
|
+
{ key: "fonts/WixMadeforDisplay-Bold.ttf", contentType: "font/ttf", offset: 997382, length: 90924, sha256: "ec4c7d63772d980d6e147b2df6215fd2737e4b04cebe0cf7e11dc695fb63cdcf" },
|
|
43824
|
+
{ key: "fonts/WixMadeforDisplay-ExtraBold.ttf", contentType: "font/ttf", offset: 1088306, length: 90984, sha256: "bf4c16a2774edd38a921edf3099469866cd74c094fd596f3fa4e81f54a04dfaa" },
|
|
43825
|
+
{ key: "fonts/WixMadeforDisplay-Medium.ttf", contentType: "font/ttf", offset: 1179290, length: 90980, sha256: "368faca44fb9f7207e4af784e94750b1ee59c1098b8689e0f4a918926f244fb5" },
|
|
43826
|
+
{ key: "fonts/WixMadeforDisplay-Regular.ttf", contentType: "font/ttf", offset: 1270270, length: 90872, sha256: "97ed8f1a6772abe26e3d8af5cecfc4b02faa94497d585b0398b5053b739a70c1" },
|
|
43827
|
+
{ key: "fonts/WixMadeforDisplay-SemiBold.ttf", contentType: "font/ttf", offset: 1361142, length: 90980, sha256: "642feab8a48ddf6c7a89d7c2a7c0edfa0e6e773f671287e460e222703563191e" },
|
|
43828
|
+
{ key: "fonts/WixMadeforText-Bold.ttf", contentType: "font/ttf", offset: 1452122, length: 108636, sha256: "37c179b4fefb63ea9637322b84583a2df53aa226c150294eea1675c14b1b8e29" },
|
|
43829
|
+
{ key: "fonts/WixMadeforText-ExtraBold.ttf", contentType: "font/ttf", offset: 1560758, length: 109084, sha256: "62e2fe4718129f36ecdb0ae4de385bd6b81ecc876146447167c6c140fad699a9" },
|
|
43830
|
+
{ key: "fonts/WixMadeforText-Medium.ttf", contentType: "font/ttf", offset: 1669842, length: 109048, sha256: "4ecf270136af928612c76fe4ebade91d8156975e5ea4c0cf9225501dd1788e02" },
|
|
43831
|
+
{ key: "fonts/WixMadeforText-Regular.ttf", contentType: "font/ttf", offset: 1778890, length: 107700, sha256: "dee56f09fd3efc127c1ea95557edc74110c1460a46287da36e0af25efd341ef5" },
|
|
43832
|
+
{ key: "fonts/WixMadeforText-SemiBold.ttf", contentType: "font/ttf", offset: 1886590, length: 88108, sha256: "dced97a77624bc5f8b1ae65850360908297f4588e09feb6f22792d29dc7e765b" },
|
|
43833
|
+
{ key: "index.html", contentType: "text/html; charset=utf-8", offset: 1974698, length: 4448, sha256: "a21d7d8d7acdce79ccd442b4137814a17739998360e5b11fc74958c55b77ce30" },
|
|
43834
|
+
{ key: "t5-frame.html", contentType: "text/html; charset=utf-8", offset: 1979146, length: 13784, sha256: "cfb9465254fab84f0e084bb4542c282758ea29774b5f172840cc0b9e4194ef89" }
|
|
43807
43835
|
];
|
|
43808
43836
|
}
|
|
43809
43837
|
});
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.989.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
|
|
6
6
|
"keywords": [
|
|
@@ -65,5 +65,5 @@
|
|
|
65
65
|
"artifactId": "himalaya-cli"
|
|
66
66
|
}
|
|
67
67
|
},
|
|
68
|
-
"falconPackageHash": "
|
|
68
|
+
"falconPackageHash": "67a573e97c057c3bf277ed30655a7855e28d5c899c59b41eac1f3c85"
|
|
69
69
|
}
|
|
@@ -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
|
+
}
|