@wix/himalaya-cli 0.940.0 → 0.941.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/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/genie-chat/src/index.d.ts +32 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-genie-chat.mjs +22 -1
- package/dist/cli.mjs +0 -2
- package/guides/sdk-reference.json +19 -1
- package/guides/tier-5-add-a-screen.md +113 -0
- package/package.json +2 -2
- package/templates/beauty-salon-owner/dev/index.ts +57 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +367 -16
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-widgets.ts +45 -0
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
- package/templates/community-club-owner/dev/index.ts +57 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +367 -16
- package/templates/community-club-owner/tier5-src/_shared/aria-widgets.ts +45 -0
- package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
- package/templates/events-meetups-owner/dev/index.ts +57 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +367 -16
- package/templates/events-meetups-owner/tier5-src/_shared/aria-widgets.ts +45 -0
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
- package/templates/gym-fitness-owner/dev/index.ts +57 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +367 -16
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-widgets.ts +45 -0
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
- package/templates/nonprofit-cause-owner/dev/index.ts +57 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +367 -16
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widgets.ts +45 -0
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
- package/templates/owner-lite/dev/index.ts +57 -0
- package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +367 -16
- package/templates/owner-lite/tier5-src/_shared/aria-widgets.ts +45 -0
- package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +15 -1
- package/templates/podcast-creator-owner/dev/index.ts +57 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +367 -16
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-widgets.ts +45 -0
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
- package/templates/portfolio-creative-owner/dev/index.ts +57 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +367 -16
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widgets.ts +45 -0
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
- package/templates/restaurant-owner/dev/index.ts +57 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +367 -16
- package/templates/restaurant-owner/tier5-src/_shared/aria-widgets.ts +45 -0
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
- package/templates/retail-store-owner/dev/index.ts +57 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +367 -16
- package/templates/retail-store-owner/tier5-src/_shared/aria-widgets.ts +45 -0
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
- package/templates/yoga-studio-owner/dev/index.ts +57 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +367 -16
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-widgets.ts +45 -0
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +15 -1
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Aria is a REAL Wix product surface (the Genie chat-service behind the Owner
|
|
4
4
|
// app's Aria tab), so every app that mounts it wants the same conversation
|
|
5
|
-
// machine: greet, send optimistically, wait for the agent,
|
|
6
|
-
//
|
|
5
|
+
// machine: greet, send optimistically, wait for the agent, abandon that wait
|
|
6
|
+
// when the owner has heard enough, normalize the reply, surface its action
|
|
7
|
+
// links, and remember the conversation. Only the trimmings
|
|
7
8
|
// differ per app — the service NAME (which `$service:` paths and `_poll.action`
|
|
8
9
|
// are built from), the greeting copy, the suggested prompts, how a link opens,
|
|
9
10
|
// and the storage namespace.
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
import { navigate, type NavDirective, type PollDirective } from "@himalaya/state";
|
|
27
28
|
import {
|
|
28
29
|
ARIA_ORIGIN,
|
|
30
|
+
abortAssistantMessageGeneration,
|
|
29
31
|
ariaAssistantConfig,
|
|
30
32
|
conversationIdOf,
|
|
31
33
|
createConversation,
|
|
@@ -35,10 +37,11 @@ import {
|
|
|
35
37
|
messagesOf,
|
|
36
38
|
sendMessage,
|
|
37
39
|
triggerAssistantMessage,
|
|
40
|
+
userMessageIdOf,
|
|
38
41
|
type ConversationMessage,
|
|
39
42
|
type ConversationSummary,
|
|
40
43
|
} from "@himalaya/wix-genie-chat";
|
|
41
|
-
import { proseOfParts, scanEnvelopes } from "./aria-widgets.js";
|
|
44
|
+
import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
|
|
42
45
|
|
|
43
46
|
// ---------------------------------------------------------------------------
|
|
44
47
|
// Shapes
|
|
@@ -105,10 +108,37 @@ export interface AriaChatState {
|
|
|
105
108
|
* stores those words under any normalisation this client cannot match left
|
|
106
109
|
* "Aria is typing…" on screen forever, beside the answer. */
|
|
107
110
|
_replyAfter: number;
|
|
111
|
+
/** The id the CURRENT turn's user message was stored under, when the service
|
|
112
|
+
* named one.
|
|
113
|
+
*
|
|
114
|
+
* The turn is over when an assistant message sits AFTER this one in the
|
|
115
|
+
* transcript — which is exact, where a count is not. Counting says "the
|
|
116
|
+
* server holds more assistant turns than it did when we sent", and that is
|
|
117
|
+
* satisfiable by an answer to a DIFFERENT turn: stop a turn, let the agent
|
|
118
|
+
* finish writing anyway, send again before a tick observes it, and the old
|
|
119
|
+
* answer ends the new turn. Ending a turn early clears the typing line and
|
|
120
|
+
* hides Stop while the agent is still writing.
|
|
121
|
+
*
|
|
122
|
+
* Empty when the service named no id, and the count rule is the fallback —
|
|
123
|
+
* the same rule, with the same imprecision, that applied before. */
|
|
124
|
+
_turnId: string;
|
|
108
125
|
/** The last counts actually observed from the server, so the two marks above
|
|
109
126
|
* can be taken at send time without an extra round trip. */
|
|
110
127
|
_srvUsers: number;
|
|
111
128
|
_srvAssistants: number;
|
|
129
|
+
/** The id of the user turn whose reply is being generated — the ONLY handle
|
|
130
|
+
* the abort route accepts. It arrives with the send response and there is no
|
|
131
|
+
* route that looks it up afterwards, so a turn whose send did not name one
|
|
132
|
+
* simply cannot be stopped. Empty whenever nothing is abortable. */
|
|
133
|
+
_abortId: string;
|
|
134
|
+
/** Gate for the descriptor's Stop control, in the polarity the renderers want
|
|
135
|
+
* (a `hiddenPath` HIDES when true). Derived from the turn state after every
|
|
136
|
+
* action rather than maintained by each one — see `gated`.
|
|
137
|
+
*
|
|
138
|
+
* True unless a reply is outstanding AND we hold the handle to abort it: a
|
|
139
|
+
* Stop that cannot stop anything is worse than no Stop, because the owner
|
|
140
|
+
* taps it, the answer arrives anyway, and the control is what they blame. */
|
|
141
|
+
stopHidden: boolean;
|
|
112
142
|
/** Intentionally never true: a `form` submit that returns `ok` makes the host
|
|
113
143
|
* POP the screen. A chat composer must stay put, so the whole surface leaves
|
|
114
144
|
* this false and the bound message list simply re-renders. */
|
|
@@ -155,6 +185,9 @@ export interface AriaChatLabels {
|
|
|
155
185
|
typing?: Resolvable<string>;
|
|
156
186
|
/** Status line when the wait ran out without a reply. */
|
|
157
187
|
slow?: Resolvable<string>;
|
|
188
|
+
/** Status line after the owner stopped a reply. Sits in the NEUTRAL slot:
|
|
189
|
+
* they asked for it, so nothing went wrong. */
|
|
190
|
+
stopped?: Resolvable<string>;
|
|
158
191
|
/** Shown instead of a raw transport error when this HOST cannot reach Wix at
|
|
159
192
|
* all — it carries no Wix app binding to sign the chat-service call with.
|
|
160
193
|
* See `isWixAccountUnreachable`, and note what it is NOT: a signed-out user. */
|
|
@@ -442,7 +475,11 @@ export function toBubbles(msgs: ConversationMessage[], assistantName: string, yo
|
|
|
442
475
|
const role = m.role || "ASSISTANT";
|
|
443
476
|
// Only assistant turns carry envelopes; a user's own text is literal, and
|
|
444
477
|
// scanning it would let an owner who typed `<<…>>` erase their own message.
|
|
445
|
-
|
|
478
|
+
// HIDDEN spans go first: the service marks text as transcript-only that
|
|
479
|
+
// way, and an aborted turn consists of nothing else. Then envelopes, then
|
|
480
|
+
// the markdown stripper — each stage handing on only what a reader should
|
|
481
|
+
// actually see.
|
|
482
|
+
const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(stripHidden(raw), 0, true).parts);
|
|
446
483
|
return {
|
|
447
484
|
id: m.id || `m${i}`,
|
|
448
485
|
role,
|
|
@@ -508,6 +545,51 @@ export function countRole(bubbles: AriaBubble[], role: string): number {
|
|
|
508
545
|
return n;
|
|
509
546
|
}
|
|
510
547
|
|
|
548
|
+
/** Has the turn stored under `turnId` been answered?
|
|
549
|
+
*
|
|
550
|
+
* EXACT, where counting is not. The count rule — "the server holds more
|
|
551
|
+
* assistant turns than it did when we sent" — is satisfiable by an answer to a
|
|
552
|
+
* DIFFERENT turn, and Stop is what makes that reachable: abort a turn, let the
|
|
553
|
+
* agent finish writing it anyway, send again before a tick has observed the
|
|
554
|
+
* late answer, and that answer ends the NEW turn. The typing line clears and
|
|
555
|
+
* Stop disappears while the agent is still writing.
|
|
556
|
+
*
|
|
557
|
+
* Position in the transcript is what settles it: an answer to this turn comes
|
|
558
|
+
* after this turn. An empty `turnId` — a service that named none — falls back
|
|
559
|
+
* to the caller's count rule, which is exactly what applied before. */
|
|
560
|
+
/** The assistant text that is actually ADDRESSED TO THE OWNER.
|
|
561
|
+
*
|
|
562
|
+
* `<HIDDEN>…</HIDDEN>` is how the service leaves notes for itself — including
|
|
563
|
+
* the note it replaces an aborted turn with — and `toBubbles` strips it, so a
|
|
564
|
+
* row made only of it paints nothing. Judging "did the turn get an answer?" on
|
|
565
|
+
* the RAW text therefore ends the turn on a row the owner never sees: after a
|
|
566
|
+
* Stop it erases the "Stopped." line, and mid-turn it clears the typing line and
|
|
567
|
+
* hides Stop while the agent is still writing.
|
|
568
|
+
*
|
|
569
|
+
* A widget envelope (`<<{"widget":…}>>`) is deliberately NOT stripped here. That
|
|
570
|
+
* IS an answer with nothing to say in prose — the case `countTurns` counts raw
|
|
571
|
+
* messages for in the first place — so it must keep reading as non-empty. */
|
|
572
|
+
function answerText(m: ConversationMessage): string {
|
|
573
|
+
return stripHidden(messageText(m)).trim();
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function repliedTo(msgs: ConversationMessage[], turnId: string): boolean | null {
|
|
577
|
+
if (!turnId) return null;
|
|
578
|
+
const at = msgs.findIndex((m) => m.id === turnId);
|
|
579
|
+
// NOT FOUND is "cannot attribute", not "not answered". A service is free to
|
|
580
|
+
// normalise, truncate or simply not keep the owner's turn, and one that drops
|
|
581
|
+
// it would otherwise leave the answer on screen under a permanent "Aria is
|
|
582
|
+
// typing…" with no recovery but leaving the screen — which is the exact defect
|
|
583
|
+
// the count rule was introduced to fix. So: fall back to it.
|
|
584
|
+
if (at < 0) return null;
|
|
585
|
+
for (let i = at + 1; i < msgs.length; i++) {
|
|
586
|
+
const m = msgs[i];
|
|
587
|
+
if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
|
|
588
|
+
if (answerText(m)) return true;
|
|
589
|
+
}
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
|
|
511
593
|
/** How many turns of one role the SERVER holds — the mark the turn is judged
|
|
512
594
|
* against. Counted over RAW messages, gated on the raw text being non-empty.
|
|
513
595
|
*
|
|
@@ -531,7 +613,9 @@ export function countTurns(msgs: ConversationMessage[], role: string): number {
|
|
|
531
613
|
let n = 0;
|
|
532
614
|
for (const m of msgs) {
|
|
533
615
|
if ((m.role || "ASSISTANT") !== role) continue;
|
|
534
|
-
|
|
616
|
+
// USER rows carry no HIDDEN notes, so `answerText` is a no-op on them and
|
|
617
|
+
// the two roles stay counted by one rule.
|
|
618
|
+
if (answerText(m)) n++;
|
|
535
619
|
}
|
|
536
620
|
return n;
|
|
537
621
|
}
|
|
@@ -585,7 +669,7 @@ export function turnAnswered(msgs: ConversationMessage[], userMessageId: string)
|
|
|
585
669
|
// exactly these two, and the two scanners read the same transcript: if they
|
|
586
670
|
// disagree, one of them ends the turn while the other leaves the ladder
|
|
587
671
|
// running at the fast cadence.
|
|
588
|
-
if (role === "ASSISTANT" &&
|
|
672
|
+
if (role === "ASSISTANT" && answerText(m)) return true;
|
|
589
673
|
}
|
|
590
674
|
return false;
|
|
591
675
|
}
|
|
@@ -666,8 +750,21 @@ export interface AriaChatCore {
|
|
|
666
750
|
* draft never reaches the worker cannot be emptied on send — the owner's
|
|
667
751
|
* sent message just sits there, one tap away from being sent twice. */
|
|
668
752
|
typing(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
|
|
669
|
-
|
|
753
|
+
/** Resolves to the next state, or `undefined` to leave the state UNTOUCHED. A
|
|
754
|
+
* tick whose transcript read predates a turn boundary (a Stop, a fresh send,
|
|
755
|
+
* or a new chat) must not write its snapshot back — the runtime merges a
|
|
756
|
+
* returned object over the CURRENT state, so writing a stale in-progress
|
|
757
|
+
* snapshot would resurrect or clobber the turn that boundary just changed.
|
|
758
|
+
* `undefined` SPECIFICALLY — not `null`, not `false`. Only `undefined` is
|
|
759
|
+
* read as "no opinion" by every host; the web host assigns anything else it
|
|
760
|
+
* is handed, so `return null` would store `null` as the service state. */
|
|
761
|
+
tick(s: AriaChatState): Promise<AriaChatState | undefined>;
|
|
670
762
|
openLink(s: AriaChatState, arg?: AriaActionArg): Promise<AriaChatState>;
|
|
763
|
+
/** Abandon the reply being generated for the turn in flight. Resolves to
|
|
764
|
+
* `undefined` to leave the state UNTOUCHED, on `tick`'s terms: a boundary
|
|
765
|
+
* that moved during the abort makes this snapshot stale, and committing it
|
|
766
|
+
* would end a turn the owner has since started. */
|
|
767
|
+
stop(s: AriaChatState): Promise<AriaChatState | undefined>;
|
|
671
768
|
}
|
|
672
769
|
|
|
673
770
|
const POLL_ID = "aria-turn";
|
|
@@ -687,6 +784,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
687
784
|
const you = () => res(o.labels?.you ?? "You");
|
|
688
785
|
const typingText = () => res(o.labels?.typing ?? `${assistant()} is typing…`);
|
|
689
786
|
const slowText = () => res(o.labels?.slow ?? `${assistant()} is taking a while…`);
|
|
787
|
+
const stoppedText = () => res(o.labels?.stopped ?? "Stopped.");
|
|
690
788
|
// States the fact and stops. It deliberately gives no instruction, because
|
|
691
789
|
// there is none the owner could follow: they are signed in, and the thing that
|
|
692
790
|
// is missing is the app's own Wix binding. "Here" is doing real work — it says
|
|
@@ -703,7 +801,8 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
703
801
|
const initialState: AriaChatState = {
|
|
704
802
|
status: "loading", statusText: "", error: "", input: "",
|
|
705
803
|
messages: [], prompts: [], _convId: "", _siteId: "", _pending: "",
|
|
706
|
-
_pendingAfter: 0, _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
|
|
804
|
+
_pendingAfter: 0, _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
|
|
805
|
+
_abortId: "", _turnId: "", stopHidden: true, ok: false,
|
|
707
806
|
};
|
|
708
807
|
|
|
709
808
|
/** One create at a time, per site.
|
|
@@ -759,6 +858,63 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
759
858
|
* `turnAnswered` — rather than anything inferred from local counts. */
|
|
760
859
|
let stalledTurn: { convId: string; content: string; userMessageId: string } | null = null;
|
|
761
860
|
|
|
861
|
+
/** A monotonic turn epoch, bumped by every TURN BOUNDARY: a successful `stop`,
|
|
862
|
+
* the start of a `send`, and a `newChat`.
|
|
863
|
+
*
|
|
864
|
+
* A latch for the same reason `inFlightSend` is one: reducers are pure
|
|
865
|
+
* functions of the state handed to them, so a `tick` that is already awaiting
|
|
866
|
+
* `getConversationMessages` when the turn changes underneath it holds a
|
|
867
|
+
* snapshot from BEFORE that change. The action cannot see that from its own
|
|
868
|
+
* argument; this counter is the only place the overlap is visible.
|
|
869
|
+
*
|
|
870
|
+
* It is not enough to bump on `stop` alone. A Stop followed by a fresh send
|
|
871
|
+
* starts a NEW turn, and the parked tick — still carrying the old turn's
|
|
872
|
+
* snapshot — would then clear the new turn's pending mark and Stop control,
|
|
873
|
+
* leaving it running with nothing to stop it. Bumping on the send too makes
|
|
874
|
+
* the parked tick recognise it is stale and stand down. */
|
|
875
|
+
let turnSeq = 0;
|
|
876
|
+
|
|
877
|
+
/** Turn epochs with an abort in the air, and how many. Covers the window
|
|
878
|
+
* `turnSeq` cannot: `stop` bumps the epoch only AFTER the service accepts
|
|
879
|
+
* the abort (a refused one leaves the turn running), so a tick resolving
|
|
880
|
+
* during the request passes the epoch check, is applied, and is then
|
|
881
|
+
* overwritten by `stop`'s pre-await snapshot — an answer the service already
|
|
882
|
+
* wrote vanishing under "Stopped." until the next slow poll.
|
|
883
|
+
*
|
|
884
|
+
* Discarding such a tick is free: `tick` rebuilds wholesale, so the next one
|
|
885
|
+
* re-reads it in full, on both the accepted and the refused outcome.
|
|
886
|
+
*
|
|
887
|
+
* Keyed by EPOCH, not counted flat: a flat counter also fenced ticks of a
|
|
888
|
+
* turn started after the Stop — which submit deliberately allows — stalling
|
|
889
|
+
* the new turn until the old abort settled. Staleness is relative to the
|
|
890
|
+
* aborted turn alone. A multiset because nothing serialises two overlapping
|
|
891
|
+
* stops on one epoch. */
|
|
892
|
+
const abortingEpochs = new Map<number, number>();
|
|
893
|
+
|
|
894
|
+
/** The composer's draft, and a count of edits to it.
|
|
895
|
+
*
|
|
896
|
+
* `typing` is not a turn boundary, so `turnSeq` never covered it: an owner
|
|
897
|
+
* drafting while `stop`'s abort was in flight had it overwritten by the
|
|
898
|
+
* pre-await snapshot. Returning only the fields `stop` owns is the obvious
|
|
899
|
+
* fix and is WRONG on this platform — a reducer must return a whole state,
|
|
900
|
+
* because the hosts disagree about what a patch means. That is general
|
|
901
|
+
* Tier-5 knowledge rather than Aria's, so it lives in the tier-5 guide
|
|
902
|
+
* (`himi docs tier-5-add-a-screen`, "A reducer must return a WHOLE state").
|
|
903
|
+
*
|
|
904
|
+
* The counter is what keeps `withDraft` honest — see it for why. */
|
|
905
|
+
let draft = "";
|
|
906
|
+
let draftSeq = 0;
|
|
907
|
+
|
|
908
|
+
/** Raise the fence for one abort of `seq`; returns the lowering half. */
|
|
909
|
+
const fenceEpoch = (seq: number): (() => void) => {
|
|
910
|
+
abortingEpochs.set(seq, (abortingEpochs.get(seq) ?? 0) + 1);
|
|
911
|
+
return () => {
|
|
912
|
+
const left = (abortingEpochs.get(seq) ?? 1) - 1;
|
|
913
|
+
if (left > 0) abortingEpochs.set(seq, left);
|
|
914
|
+
else abortingEpochs.delete(seq);
|
|
915
|
+
};
|
|
916
|
+
};
|
|
917
|
+
|
|
762
918
|
/** Refuse a send that is already on its way. State half + latch half. */
|
|
763
919
|
const duplicate = (s: AriaChatState, content: string): boolean =>
|
|
764
920
|
isDuplicateSend(s, content) || inFlightSend === content;
|
|
@@ -826,7 +982,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
826
982
|
// widget-only reply is, even though it leaves no bubble to inspect. Asking
|
|
827
983
|
// the last BUBBLE stalled this loop on exactly that reply, the same way it
|
|
828
984
|
// stalled `tick`.
|
|
829
|
-
if (countTurns(raw, "ASSISTANT") > s._replyAfter) {
|
|
985
|
+
if (repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter) {
|
|
830
986
|
return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw), ok: false };
|
|
831
987
|
}
|
|
832
988
|
}
|
|
@@ -834,12 +990,34 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
834
990
|
}
|
|
835
991
|
|
|
836
992
|
/** Send + wait, by whichever transport this consumer configured. */
|
|
993
|
+
/** Re-apply the composer's draft to a state built from a pre-await snapshot,
|
|
994
|
+
* but ONLY when the owner typed during that await (`since` is the counter
|
|
995
|
+
* read before it).
|
|
996
|
+
*
|
|
997
|
+
* Re-applying whenever the latch merely DIFFERS is wrong twice over: the
|
|
998
|
+
* latch still holds the text of the message just sent, so a Stop moments
|
|
999
|
+
* after a send would put it back in the composer ready to send twice; and
|
|
1000
|
+
* it would make every one of the five places that deliberately set
|
|
1001
|
+
* `input: ""` responsible for clearing a latch. */
|
|
1002
|
+
const withDraft = (next: AriaChatState, since: number): AriaChatState =>
|
|
1003
|
+
draftSeq === since || draft === next.input ? next : { ...next, input: draft };
|
|
1004
|
+
|
|
837
1005
|
async function send(s: AriaChatState, content: string): Promise<AriaChatState> {
|
|
1006
|
+
// A new turn is a turn boundary: any tick still parked on the PREVIOUS
|
|
1007
|
+
// turn's transcript read must stand down rather than write its snapshot over
|
|
1008
|
+
// this one. Bump before the first await so an in-flight tick is already stale
|
|
1009
|
+
// by the time it resolves.
|
|
1010
|
+
turnSeq += 1;
|
|
838
1011
|
// Optimistic first: the owner's message and the typing line are part of the
|
|
839
1012
|
// state this action RETURNS, so on the `_poll` path they paint immediately.
|
|
840
1013
|
const optimistic: AriaBubble[] = [...s.messages, plainBubble(`u${s.messages.length}`, "USER", you(), content)];
|
|
841
1014
|
const base: AriaChatState = {
|
|
842
1015
|
...s, prompts: [], input: "", error: "",
|
|
1016
|
+
// The PREVIOUS turn's handles must not survive into this one: aborting by
|
|
1017
|
+
// a stale id would abandon a reply that has already been read, and waiting
|
|
1018
|
+
// on a stale turn id would look for an answer that already exists. Both
|
|
1019
|
+
// are replaced the moment this send knows its own id.
|
|
1020
|
+
_abortId: "", _turnId: "",
|
|
843
1021
|
statusText: typingText(), messages: optimistic, _pending: content,
|
|
844
1022
|
// Mark the turn against the LAST COUNTS WE SAW, not against our own
|
|
845
1023
|
// message list — the greeting is a local bubble the server never had, so
|
|
@@ -913,12 +1091,34 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
913
1091
|
if (turnAnswered(seen, stalled.userMessageId)) stalled = null;
|
|
914
1092
|
} catch { /* unknown — keep the latch, see above */ }
|
|
915
1093
|
}
|
|
1094
|
+
// The id the turn is STORED under is the only thing the abort route
|
|
1095
|
+
// accepts, and no route hands it back afterwards — so it has to be
|
|
1096
|
+
// captured as the turn is sent, even though everything else about the turn
|
|
1097
|
+
// is read back off the transcript. A service that names none leaves this
|
|
1098
|
+
// turn unstoppable, which `gated` renders as an ABSENT control rather than
|
|
1099
|
+
// a dead one.
|
|
1100
|
+
//
|
|
1101
|
+
// The retry path needs no send to learn it: holding that id is what
|
|
1102
|
+
// `stalledTurn` IS. So a turn recovered from a failed trigger is just as
|
|
1103
|
+
// stoppable as one sent cleanly.
|
|
1104
|
+
let abortId: string;
|
|
916
1105
|
if (stalled) {
|
|
917
1106
|
retriedId = stalled.userMessageId;
|
|
1107
|
+
abortId = stalled.userMessageId;
|
|
918
1108
|
await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
|
|
919
1109
|
} else {
|
|
920
|
-
await sendMessage(convId, content, { origin });
|
|
1110
|
+
abortId = userMessageIdOf(await sendMessage(convId, content, { origin }));
|
|
921
1111
|
}
|
|
1112
|
+
// A SECOND boundary bump, and the load-bearing one. The bump at entry only
|
|
1113
|
+
// retires a tick that was already parked when the owner tapped Send. A tick
|
|
1114
|
+
// dispatched DURING the two round trips above read the post-bump epoch, so
|
|
1115
|
+
// it matches, and it is carrying the PRE-send snapshot — it would resolve
|
|
1116
|
+
// after this send commits and put `status: "ready"`, an empty `_pending`
|
|
1117
|
+
// and an empty `_abortId` back, taking the typing line and the Stop control
|
|
1118
|
+
// with them while the reply is still being generated. That is the very
|
|
1119
|
+
// clobber the epoch exists to prevent, one window over. `stop` bumps after
|
|
1120
|
+
// its await for this reason; so must this.
|
|
1121
|
+
turnSeq += 1;
|
|
922
1122
|
// Delivered — whatever was stalled is answered or superseded.
|
|
923
1123
|
//
|
|
924
1124
|
// `retriedId` is cleared with it, and that is not redundant: the legacy
|
|
@@ -952,9 +1152,15 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
952
1152
|
// first reply would never read as new — the same stranded "typing…" this
|
|
953
1153
|
// turn-marking exists to prevent. Reachable when the owner switches
|
|
954
1154
|
// business and sends without the screen reloading in between.
|
|
955
|
-
const
|
|
1155
|
+
const marked: AriaChatState = created
|
|
956
1156
|
? { ...base, _pendingAfter: 0, _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0 }
|
|
957
1157
|
: base;
|
|
1158
|
+
// `_turnId` and `_abortId` are the same id and NOT the same field.
|
|
1159
|
+
// `_abortId` is "can this still be stopped" and a successful stop clears
|
|
1160
|
+
// it; `_turnId` is "which turn are we waiting on" and has to outlive that,
|
|
1161
|
+
// because that is what tells a late answer to the STOPPED turn apart from
|
|
1162
|
+
// the answer to this one.
|
|
1163
|
+
const sent: AriaChatState = { ...marked, _abortId: abortId, _turnId: abortId };
|
|
958
1164
|
inFlightSend = null;
|
|
959
1165
|
if (o.poll) {
|
|
960
1166
|
// Return NOW. The host owns the clock from here; `tick` merges the
|
|
@@ -965,6 +1171,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
965
1171
|
return busyLoop({ ...sent, _convId: convId, _siteId: site }, convId, optimistic);
|
|
966
1172
|
} catch (e) {
|
|
967
1173
|
inFlightSend = null;
|
|
1174
|
+
// The same second bump as the success path, for the same reason: a tick
|
|
1175
|
+
// dispatched during the awaits above is carrying the pre-send snapshot, and
|
|
1176
|
+
// this failure state is just as much a commit as a delivered turn.
|
|
1177
|
+
turnSeq += 1;
|
|
968
1178
|
// A send that failed AFTER the turn was stored hands back the id it stored
|
|
969
1179
|
// under. Remember it, so the owner tapping Send again triggers that turn
|
|
970
1180
|
// instead of posting their message a second time. Any other failure clears
|
|
@@ -1008,7 +1218,23 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1008
1218
|
}
|
|
1009
1219
|
}
|
|
1010
1220
|
|
|
1011
|
-
|
|
1221
|
+
/** Is a reply outstanding right now? True from the moment `send` returns its
|
|
1222
|
+
* optimistic state until the transcript holds the answer (or the owner stops
|
|
1223
|
+
* waiting). Read from the state alone — a poll tick and a tap both arrive as
|
|
1224
|
+
* ordinary reducer calls, with nothing else in scope to ask. */
|
|
1225
|
+
const turnInFlight = (s: AriaChatState): boolean => s.status === "sending" || !!s._pending;
|
|
1226
|
+
|
|
1227
|
+
/** Recompute the Stop gate from the turn state. Applied to the result of every
|
|
1228
|
+
* action rather than maintained inside each of them: `stopHidden` is a pure
|
|
1229
|
+
* function of two other fields, and eight reducers each remembering to set it
|
|
1230
|
+
* is eight chances for the control to be shown when there is nothing to stop
|
|
1231
|
+
* — or, worse, hidden while a reply the owner wants to abandon runs on. */
|
|
1232
|
+
const gated = (s: AriaChatState): AriaChatState => {
|
|
1233
|
+
const hidden = !(turnInFlight(s) && !!s._abortId);
|
|
1234
|
+
return s.stopHidden === hidden ? s : { ...s, stopHidden: hidden };
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
const impl: AriaChatCore = {
|
|
1012
1238
|
initialState,
|
|
1013
1239
|
|
|
1014
1240
|
load: async (s) => {
|
|
@@ -1076,6 +1302,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1076
1302
|
|
|
1077
1303
|
typing: (s, arg) => {
|
|
1078
1304
|
const next = typeof arg === "string" ? arg : (arg?.value ?? arg?.text ?? arg?.message ?? "");
|
|
1305
|
+
// Recorded even when the value is unchanged, because the counter is about
|
|
1306
|
+
// "did an edit happen", and a keystroke that retypes the same character
|
|
1307
|
+
// still happened.
|
|
1308
|
+
draft = next;
|
|
1309
|
+
draftSeq += 1;
|
|
1079
1310
|
return next === s.input ? s : { ...s, input: next };
|
|
1080
1311
|
},
|
|
1081
1312
|
|
|
@@ -1099,6 +1330,9 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1099
1330
|
|
|
1100
1331
|
newChat: async (s) => {
|
|
1101
1332
|
const site = siteOf();
|
|
1333
|
+
// A fresh conversation is a turn boundary too: a tick parked on the OLD
|
|
1334
|
+
// conversation's transcript must not reconcile it over the new greeting.
|
|
1335
|
+
turnSeq += 1;
|
|
1102
1336
|
// ➕ can land mid-turn, and it abandons that turn: `status` goes back to
|
|
1103
1337
|
// `ready`, so `tick`'s `wasFast` is false from here on and the FAST
|
|
1104
1338
|
// ladder the send armed would never be slowed again. A worker cannot
|
|
@@ -1111,11 +1345,19 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1111
1345
|
// NOT `createOnce`: ➕ means "give me a different conversation", so it
|
|
1112
1346
|
// must never be handed an in-flight one that another caller started.
|
|
1113
1347
|
const res = await createConversation(assistantConfig, origin);
|
|
1348
|
+
// Bump again now the new conversation exists. The bump above only
|
|
1349
|
+
// retires a tick parked before ➕ was tapped; one dispatched DURING the
|
|
1350
|
+
// create matched the epoch and still holds the OLD conversation's view,
|
|
1351
|
+
// so it would resolve after this and merge `_convId`, the old transcript
|
|
1352
|
+
// and the old observed counts back — the fresh chat snapping back to the
|
|
1353
|
+
// previous one, which reads as ➕ having done nothing.
|
|
1354
|
+
turnSeq += 1;
|
|
1114
1355
|
return slow({ ...s, status: "ready", _convId: conversationIdOf(res), _siteId: site, input: "", statusText: "", error: "",
|
|
1115
|
-
_pending: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
|
|
1356
|
+
_pending: "", _abortId: "", _turnId: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
|
|
1116
1357
|
} catch (e) {
|
|
1358
|
+
turnSeq += 1; // see above — a failed create commits state too
|
|
1117
1359
|
return slow({ ...s, status: "ready", _convId: "", _siteId: site, input: "", ...failureSlots(e, unavailableText),
|
|
1118
|
-
_pending: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
|
|
1360
|
+
_pending: "", _abortId: "", _turnId: "", messages: greetingOnly(), ...observed([]), prompts: promptList(), ok: false });
|
|
1119
1361
|
}
|
|
1120
1362
|
},
|
|
1121
1363
|
|
|
@@ -1126,14 +1368,38 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1126
1368
|
tick: async (s) => {
|
|
1127
1369
|
if (!o.poll || !s._convId) return s;
|
|
1128
1370
|
const wasFast = s.status === "sending";
|
|
1371
|
+
// Read BEFORE the await, compared after: see `turnSeq`.
|
|
1372
|
+
const seq = turnSeq;
|
|
1129
1373
|
try {
|
|
1130
1374
|
const gm = await getConversationMessages(s._convId);
|
|
1131
1375
|
const raw = messagesOf(gm);
|
|
1376
|
+
// A turn boundary crossed while this read was in flight — a Stop, a fresh
|
|
1377
|
+
// send, or a new chat. This tick's ENTIRE view is stale: `s` is the
|
|
1378
|
+
// pre-boundary snapshot, and the runtime would merge whatever we return
|
|
1379
|
+
// over the CURRENT (post-boundary) state. Writing back would either
|
|
1380
|
+
// resurrect the turn the owner just stopped, or clobber the new turn a
|
|
1381
|
+
// send just started — clearing its pending mark and Stop control and
|
|
1382
|
+
// leaving it running with nothing to stop it. Stand down entirely: a
|
|
1383
|
+
// `undefined` return is a no-op (and only `undefined` — see the type),
|
|
1384
|
+
// the post-boundary state stands, and the
|
|
1385
|
+
// next tick (under the new epoch) reconciles the transcript cleanly.
|
|
1386
|
+
//
|
|
1387
|
+
// This guard sits BEFORE every other return in the block on purpose. The
|
|
1388
|
+
// empty-transcript bail below (`return s`) and the reconciled write both
|
|
1389
|
+
// hand `s` back to the runtime, so each is a stale snapshot the instant a
|
|
1390
|
+
// boundary has moved on — guarding only the reconciled write, as this did
|
|
1391
|
+
// originally, still let a boundary-crossing tick that read an empty
|
|
1392
|
+
// transcript clobber the new turn.
|
|
1393
|
+
// An abort is in the air FOR THIS EPOCH (see `abortingEpochs`), so
|
|
1394
|
+
// `stop` holds a snapshot predating this read and will overwrite
|
|
1395
|
+
// whatever we write. A tick of a NEWER epoch is not stale, not fenced.
|
|
1396
|
+
if (turnSeq !== seq || abortingEpochs.has(seq)) return undefined;
|
|
1132
1397
|
const server = toBubbles(raw, assistant(), you());
|
|
1133
1398
|
// A transcript that renders to nothing is not necessarily an empty one:
|
|
1134
1399
|
// a reply made entirely of widgets leaves no bubble behind. So the bail
|
|
1135
1400
|
// is on the RAW transcript — bailing on the bubbles is what let such a
|
|
1136
|
-
// reply go unnoticed and pinned the ladder fast forever.
|
|
1401
|
+
// reply go unnoticed and pinned the ladder fast forever. Safe to return
|
|
1402
|
+
// `s` here: the guard above proved this tick is still current.
|
|
1137
1403
|
if (!raw.length) return s;
|
|
1138
1404
|
const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you());
|
|
1139
1405
|
// …but still never PAINT nothing over something. `messages` is only
|
|
@@ -1146,7 +1412,8 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1146
1412
|
// own words came back is a rendering detail, not the answer arriving.
|
|
1147
1413
|
// Counted over RAW messages, so a widget-only answer still ends the turn
|
|
1148
1414
|
// instead of pinning the ladder to its fast rung forever (`countTurns`).
|
|
1149
|
-
|
|
1415
|
+
// Exact when the service named the turn, counted when it did not.
|
|
1416
|
+
const replied = repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter;
|
|
1150
1417
|
const next: AriaChatState = {
|
|
1151
1418
|
...s,
|
|
1152
1419
|
status: replied ? "ready" : s.status,
|
|
@@ -1159,6 +1426,12 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1159
1426
|
// Slow the ladder the moment the turn is over; leave it alone otherwise.
|
|
1160
1427
|
return replied && wasFast ? cadence(next, o.poll.idleMs, o.poll.fastMs) : next;
|
|
1161
1428
|
} catch {
|
|
1429
|
+
// A read that THREW is exactly as stale as one that resolved: if a turn
|
|
1430
|
+
// boundary moved on while it was in flight, returning `s` merges the
|
|
1431
|
+
// pre-boundary snapshot over the current turn — the same clobber the
|
|
1432
|
+
// guard above prevents on the success path. Only a still-current tick
|
|
1433
|
+
// may hold the ladder up by keeping the state as it is.
|
|
1434
|
+
if (turnSeq !== seq || abortingEpochs.has(seq)) return undefined;
|
|
1162
1435
|
return s;
|
|
1163
1436
|
}
|
|
1164
1437
|
},
|
|
@@ -1174,6 +1447,84 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1174
1447
|
if (o.openLink) { try { await o.openLink(url); } catch { /* best-effort */ } }
|
|
1175
1448
|
return s;
|
|
1176
1449
|
},
|
|
1450
|
+
|
|
1451
|
+
// Abandon the reply being generated. Three things happen, and only the first
|
|
1452
|
+
// is a network call:
|
|
1453
|
+
//
|
|
1454
|
+
// 1. The service is told to stop generating, addressed by the id of the
|
|
1455
|
+
// user turn that asked. That is the whole of the server-side effect —
|
|
1456
|
+
// there is no "cancel" for a request already in the air.
|
|
1457
|
+
// 2. The ladder is re-armed SLOW. A worker cannot cancel a poll, so a turn
|
|
1458
|
+
// that ends early has to be slowed the same way `newChat` slows one it
|
|
1459
|
+
// interrupted; without this the screen keeps polling at the fast rate
|
|
1460
|
+
// for as long as it stays open.
|
|
1461
|
+
// 3. The transcript is left ALONE. What the owner stopped is the waiting,
|
|
1462
|
+
// not the conversation: their own message was persisted and stays, and
|
|
1463
|
+
// if the service had already finished the answer it lands on the next
|
|
1464
|
+
// tick and is shown. Hiding a reply the service actually produced would
|
|
1465
|
+
// make the transcript disagree with the server for the rest of the
|
|
1466
|
+
// conversation — and the next `tick`, which rebuilds wholesale, would
|
|
1467
|
+
// bring it back anyway.
|
|
1468
|
+
//
|
|
1469
|
+
// A REFUSED abort leaves the turn exactly as it was — status, pending mark
|
|
1470
|
+
// and fast ladder intact — with the reason in the error slot. Reporting
|
|
1471
|
+
// "Stopped." for a generation that is still running is the one outcome worth
|
|
1472
|
+
// going out of the way to avoid: the answer then arrives after the screen
|
|
1473
|
+
// said it would not.
|
|
1474
|
+
stop: async (s) => {
|
|
1475
|
+
if (!turnInFlight(s) || !s._abortId) return s;
|
|
1476
|
+
const slow = (next: AriaChatState): AriaChatState =>
|
|
1477
|
+
o.poll ? cadence(next, o.poll.idleMs, null) : next;
|
|
1478
|
+
// Read BEFORE the await, compared after, exactly as `tick` does: `stop`
|
|
1479
|
+
// holds a pre-await snapshot too, and a `newChat` or `send` dispatched
|
|
1480
|
+
// during the abort moves the boundary under it.
|
|
1481
|
+
const seq = turnSeq;
|
|
1482
|
+
const typedAt = draftSeq;
|
|
1483
|
+
const unfence = fenceEpoch(seq);
|
|
1484
|
+
// Carried out rather than returned from the catch, so the staleness guard
|
|
1485
|
+
// below runs on the refused path as well as the accepted one.
|
|
1486
|
+
let failure: Partial<AriaChatState> | null = null;
|
|
1487
|
+
try {
|
|
1488
|
+
await abortAssistantMessageGeneration(s._abortId);
|
|
1489
|
+
} catch (e) {
|
|
1490
|
+
const slots = failureSlots(e, unavailableText);
|
|
1491
|
+
// Keep the line that says a reply is coming, because one is. The
|
|
1492
|
+
// ordinary failure path BLANKS `statusText` — right when a send failed
|
|
1493
|
+
// and there is no turn, wrong here, where blanking it would tell the
|
|
1494
|
+
// owner the turn ended while the agent kept writing.
|
|
1495
|
+
failure = { ...slots, statusText: slots.statusText || s.statusText };
|
|
1496
|
+
} finally {
|
|
1497
|
+
// Lowered on BOTH paths, and before either return commits: a refused
|
|
1498
|
+
// abort must leave ticks reporting the still-running turn normally.
|
|
1499
|
+
unfence();
|
|
1500
|
+
}
|
|
1501
|
+
// A boundary moved while the abort was in flight (a send, a new chat,
|
|
1502
|
+
// another stop). Committing `s` over the CURRENT state would end the NEW
|
|
1503
|
+
// turn — `status: "ready"`, empty `_abortId`, the old transcript painted
|
|
1504
|
+
// over it. Stand down, as a stale tick does; the abort already happened
|
|
1505
|
+
// at the service, which is the half that could not be taken back.
|
|
1506
|
+
if (turnSeq !== seq) return undefined;
|
|
1507
|
+
if (failure) return withDraft({ ...s, ...failure }, typedAt);
|
|
1508
|
+
// AFTER the abort is accepted, never before: a refused abort leaves the
|
|
1509
|
+
// turn running, and a tick resolving around it must go on reporting that
|
|
1510
|
+
// turn normally.
|
|
1511
|
+
turnSeq += 1;
|
|
1512
|
+
return slow(withDraft(
|
|
1513
|
+
{ ...s, status: "ready", statusText: stoppedText(), error: "", _pending: "", _abortId: "" }, typedAt));
|
|
1514
|
+
},
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
// Every action's result goes through the Stop gate on its way out.
|
|
1518
|
+
return {
|
|
1519
|
+
initialState,
|
|
1520
|
+
load: (s) => impl.load(s).then(gated),
|
|
1521
|
+
submit: (s, arg) => impl.submit(s, arg).then(gated),
|
|
1522
|
+
submitPrompt: (s, arg) => impl.submitPrompt(s, arg).then(gated),
|
|
1523
|
+
newChat: (s) => impl.newChat(s).then(gated),
|
|
1524
|
+
typing: (s, arg) => gated(impl.typing(s, arg)),
|
|
1525
|
+
tick: (s) => impl.tick(s).then((r) => (r ? gated(r) : r)),
|
|
1526
|
+
openLink: (s, arg) => impl.openLink(s, arg).then(gated),
|
|
1527
|
+
stop: (s) => impl.stop(s).then((r) => (r ? gated(r) : r)),
|
|
1177
1528
|
};
|
|
1178
1529
|
}
|
|
1179
1530
|
|
|
@@ -68,6 +68,51 @@ function payloadOf(raw: string): WidgetPayload {
|
|
|
68
68
|
return { name, dataJson, configJson, raw };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Remove every `<HIDDEN>…</HIDDEN>` span.
|
|
73
|
+
*
|
|
74
|
+
* The chat-service uses it for text that belongs to the TRANSCRIPT but not to
|
|
75
|
+
* the screen — machine-facing notes the agent leaves for itself. Aborting a
|
|
76
|
+
* turn produces exactly one such message and nothing else:
|
|
77
|
+
*
|
|
78
|
+
* <HIDDEN>Request was aborted by user.</HIDDEN>
|
|
79
|
+
*
|
|
80
|
+
* (Observed live: abort the turn, re-read the conversation, and that is the
|
|
81
|
+
* assistant message sitting there.) Painting it would put angle-bracketed
|
|
82
|
+
* internals in front of an owner who had just pressed Stop — the same class of
|
|
83
|
+
* leak as the widget envelope this module already lifts out, so it is removed in
|
|
84
|
+
* the same place and before the same markdown stripper.
|
|
85
|
+
*
|
|
86
|
+
* Tolerant of case and of whitespace in the tags, and of an UNCLOSED opener —
|
|
87
|
+
* which is treated as hiding the remainder, because the alternative is printing
|
|
88
|
+
* something the service explicitly marked as not for display.
|
|
89
|
+
*/
|
|
90
|
+
const HIDDEN_OPEN = /<\s*HIDDEN\s*>/gi;
|
|
91
|
+
const HIDDEN_CLOSE = /<\s*\/\s*HIDDEN\s*>/gi;
|
|
92
|
+
|
|
93
|
+
export function stripHidden(text: string): string {
|
|
94
|
+
if (!text) return text;
|
|
95
|
+
let out = "";
|
|
96
|
+
let cursor = 0;
|
|
97
|
+
for (;;) {
|
|
98
|
+
// Case- AND whitespace-tolerant, as the docstring promises: the service is
|
|
99
|
+
// free to emit `< HIDDEN >` or `</ hidden >`, and matching only the exact
|
|
100
|
+
// literal `<HIDDEN>` let a spaced variant leak the transcript-only note
|
|
101
|
+
// straight onto the screen — the leak this function exists to stop.
|
|
102
|
+
HIDDEN_OPEN.lastIndex = cursor;
|
|
103
|
+
const openM = HIDDEN_OPEN.exec(text);
|
|
104
|
+
if (!openM) break;
|
|
105
|
+
out += text.slice(cursor, openM.index);
|
|
106
|
+
HIDDEN_CLOSE.lastIndex = openM.index + openM[0].length;
|
|
107
|
+
const closeM = HIDDEN_CLOSE.exec(text);
|
|
108
|
+
// An UNCLOSED opener hides the remainder — the alternative is printing
|
|
109
|
+
// something the service explicitly marked as not for display.
|
|
110
|
+
if (!closeM) return out.trim();
|
|
111
|
+
cursor = closeM.index + closeM[0].length;
|
|
112
|
+
}
|
|
113
|
+
return (out + text.slice(cursor)).trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
71
116
|
/**
|
|
72
117
|
* Index of the `>>` that actually TERMINATES the envelope opened at `start`, or
|
|
73
118
|
* -1 if there is none yet.
|