@wix/himalaya-cli 0.889.0 → 0.891.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 +59 -4
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-comments.mjs +20 -7
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-genie-chat.mjs +42 -2
- package/dist/cli.mjs +63 -17
- package/guides/sdk-reference.json +29 -5
- package/package.json +2 -2
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +197 -32
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-widgets.ts +181 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +197 -32
- package/templates/community-club-owner/tier5-src/_shared/aria-widgets.ts +181 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +197 -32
- package/templates/events-meetups-owner/tier5-src/_shared/aria-widgets.ts +181 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +197 -32
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-widgets.ts +181 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +197 -32
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widgets.ts +181 -0
- package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +197 -32
- package/templates/owner-lite/tier5-src/_shared/aria-widgets.ts +181 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +197 -32
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-widgets.ts +181 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +197 -32
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widgets.ts +181 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +197 -32
- package/templates/restaurant-owner/tier5-src/_shared/aria-widgets.ts +181 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +197 -32
- package/templates/retail-store-owner/tier5-src/_shared/aria-widgets.ts +181 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +197 -32
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-widgets.ts +181 -0
|
@@ -9992,7 +9992,7 @@
|
|
|
9992
9992
|
"kind": "function",
|
|
9993
9993
|
"jsdoc": "",
|
|
9994
9994
|
"decl": "export async function listBlogComments(postId: string): Promise<WixBlogCommentRow[]>;",
|
|
9995
|
-
"http": "GET /comments/v1/comments/list-by-resource"
|
|
9995
|
+
"http": "GET /comments/v1/comments/list-by-resource?{…}"
|
|
9996
9996
|
}
|
|
9997
9997
|
]
|
|
9998
9998
|
},
|
|
@@ -16643,7 +16643,7 @@
|
|
|
16643
16643
|
"name": "GenieChatError",
|
|
16644
16644
|
"kind": "interface",
|
|
16645
16645
|
"jsdoc": "/** Thrown by every endpoint. `kind` separates \"the host never installed the\n * wixApi bridge\" (a wiring bug — retrying will not help) from a transport or\n * HTTP failure (worth a retry / a user-visible error line).\n *\n * A tagged Error, NOT a subclass: Tier-5 bundles are built for a target that\n * refuses `class` syntax, so every module a worker can import has to stay\n * class-free. */",
|
|
16646
|
-
"decl": "export interface GenieChatError extends Error {\n kind: \"no-bridge\" | \"request\";\n cause?: unknown;\n}"
|
|
16646
|
+
"decl": "export interface GenieChatError extends Error {\n kind: \"no-bridge\" | \"request\";\n cause?: unknown;\n /** Set ONLY when a send failed at step 2, after step 1 had already stored the\n * owner's turn. Its presence is the signal that retrying the whole send would\n * duplicate that turn, and that `triggerAssistantMessage` with this id is the\n * correct retry instead. Absent on every other failure, including a step-1\n * failure, where nothing was stored and a plain retry is right. */\n storedUserMessageId?: string;\n}"
|
|
16647
16647
|
},
|
|
16648
16648
|
{
|
|
16649
16649
|
"name": "genieChatError",
|
|
@@ -16717,11 +16717,23 @@
|
|
|
16717
16717
|
"jsdoc": "",
|
|
16718
16718
|
"decl": "export interface GetConversationMessagesResponse {\n messages?: ConversationMessage[];\n conversationMessages?: ConversationMessage[];\n}"
|
|
16719
16719
|
},
|
|
16720
|
+
{
|
|
16721
|
+
"name": "UserMessageMetadata",
|
|
16722
|
+
"kind": "interface",
|
|
16723
|
+
"jsdoc": "/** Context the dashboard attaches to the TRIGGER call. Every field is optional —\n * Aria answers without any of it (measured) — but `clientMetaSiteId` tells the\n * agent which business it is acting for, and the captured dashboard payload\n * also carries the current page so answers can reference what the user sees. */",
|
|
16724
|
+
"decl": "export interface UserMessageMetadata {\n userAgent?: string;\n pageUrl?: string;\n /** A short natural-language SUMMARY of the current page, despite the name —\n * the dashboard sends prose here, not markup. */\n pageHtml?: string;\n additionalMetadata?: Array<{ key: string; value: string; excludeFromPrompt?: boolean }>;\n channel?: string;\n}"
|
|
16725
|
+
},
|
|
16720
16726
|
{
|
|
16721
16727
|
"name": "SendMessageOptions",
|
|
16722
16728
|
"kind": "interface",
|
|
16723
|
-
"jsdoc": "/** Optional wire flags on `sendMessage`.
|
|
16724
|
-
"decl": "export interface SendMessageOptions {\n communicationMode?: CommunicationMode;\n enableRealtimeEvents?: boolean;\n}"
|
|
16729
|
+
"jsdoc": "/** Optional wire flags on `sendMessage`. `communicationMode` /\n * `enableRealtimeEvents` were in the original captured payload; only POLLING is\n * verified end-to-end. The rest were added from the 2026-09-19 re-capture. */",
|
|
16730
|
+
"decl": "export interface SendMessageOptions {\n communicationMode?: CommunicationMode;\n enableRealtimeEvents?: boolean;\n metadata?: UserMessageMetadata;\n /** The dashboard sends `true`. Optional: verified to answer either way. */\n enforceAccountScopedConversations?: boolean;\n /** Tag the turn with the same origin the conversation was created under. */\n origin?: string;\n}"
|
|
16731
|
+
},
|
|
16732
|
+
{
|
|
16733
|
+
"name": "ConversationUserMessageResponse",
|
|
16734
|
+
"kind": "interface",
|
|
16735
|
+
"jsdoc": "",
|
|
16736
|
+
"decl": "export interface ConversationUserMessageResponse { userMessage?: ConversationMessage }"
|
|
16725
16737
|
},
|
|
16726
16738
|
{
|
|
16727
16739
|
"name": "createConversation",
|
|
@@ -16735,12 +16747,24 @@
|
|
|
16735
16747
|
"jsdoc": "/** List the tenant's conversations (optionally filtered by origin). */",
|
|
16736
16748
|
"decl": "export function getConversations(origin?: string): Promise<GetConversationsResponse>;"
|
|
16737
16749
|
},
|
|
16750
|
+
{
|
|
16751
|
+
"name": "conversationUserMessage",
|
|
16752
|
+
"kind": "function",
|
|
16753
|
+
"jsdoc": "/** Step 1 of sending a turn: PERSIST the user's message on the conversation.\n * Returns the stored message, whose `id` is what actually triggers the agent in\n * step 2. Exported because a caller that wants to render the user's bubble from\n * the server's own copy can await this before triggering. */",
|
|
16754
|
+
"decl": "export function conversationUserMessage(\n conversationId: string,\n content: string,\n opts: SendMessageOptions = {},\n): Promise<ConversationUserMessageResponse>;"
|
|
16755
|
+
},
|
|
16738
16756
|
{
|
|
16739
16757
|
"name": "sendMessage",
|
|
16740
16758
|
"kind": "function",
|
|
16741
|
-
"jsdoc": "/** Post a user
|
|
16759
|
+
"jsdoc": "/** Post a user turn (POLLING mode by default). The agent may take a few seconds\n * — poll getConversationMessages for the ASSISTANT reply. The immediate\n * response may carry placeholder `assistantMessages` (often empty content).\n *\n * TWO calls, not one. `/user-message` carries NO message content: it names a\n * `userMessageId` that `/conversation-user-message` has already stored. This\n * replaced the old single-call shape (`{message:{content,role}}`) some time\n * after the 2026-06-04 capture, and the failure mode gave nothing away — every\n * call still returned 200, the agent still ran, and every reply was the\n * service's own \"I didn't manage to create a proper answer\" fallback with an\n * empty `functionCalls`, because the turn it was asked to answer did not exist.\n * Re-verified live 2026-09-19 against www.wixapis.com with a User\n * signed-instance; `origin`, `metadata` and `enforceAccountScopedConversations`\n * were each measured to be optional, so only the two-step shape is required.\n *\n * The third argument accepts the bare CommunicationMode for backwards\n * compatibility, or an options object. */",
|
|
16742
16760
|
"decl": "export function sendMessage(\n conversationId: string,\n content: string,\n modeOrOptions: CommunicationMode | SendMessageOptions = \"POLLING\",\n): Promise<SendMessageResponse>;"
|
|
16743
16761
|
},
|
|
16762
|
+
{
|
|
16763
|
+
"name": "triggerAssistantMessage",
|
|
16764
|
+
"kind": "function",
|
|
16765
|
+
"jsdoc": "/** Step 2 of sending a turn on its own: ask the agent to answer a user message\n * that `conversationUserMessage` has ALREADY stored.\n *\n * Exported because it is the only safe retry when step 2 is the half that\n * failed — see `sendMessage`, which attaches the id to its error for exactly\n * this call. Carries no content: the turn it names is the content. */",
|
|
16766
|
+
"decl": "export function triggerAssistantMessage(\n conversationId: string,\n userMessageId: string,\n opts: SendMessageOptions = {},\n): Promise<SendMessageResponse>;"
|
|
16767
|
+
},
|
|
16744
16768
|
{
|
|
16745
16769
|
"name": "getConversationMessages",
|
|
16746
16770
|
"kind": "function",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.891.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": [
|
|
@@ -64,5 +64,5 @@
|
|
|
64
64
|
"artifactId": "himalaya-cli"
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
|
-
"falconPackageHash": "
|
|
67
|
+
"falconPackageHash": "1043e5fb989a028cf5dffffc6f8a9aef5487b1e6464b630da5270431"
|
|
68
68
|
}
|
|
@@ -34,9 +34,11 @@ import {
|
|
|
34
34
|
messageText,
|
|
35
35
|
messagesOf,
|
|
36
36
|
sendMessage,
|
|
37
|
+
triggerAssistantMessage,
|
|
37
38
|
type ConversationMessage,
|
|
38
39
|
type ConversationSummary,
|
|
39
40
|
} from "@himalaya/wix-genie-chat";
|
|
41
|
+
import { proseOfParts, scanEnvelopes } from "./aria-widgets.js";
|
|
40
42
|
|
|
41
43
|
// ---------------------------------------------------------------------------
|
|
42
44
|
// Shapes
|
|
@@ -420,20 +422,35 @@ export function plainBubble(id: string, role: string, who: string, content: stri
|
|
|
420
422
|
}
|
|
421
423
|
|
|
422
424
|
/** Server transcript → bubbles. USER text is literal; ASSISTANT replies are
|
|
423
|
-
* markdown, so they are normalized and their links lifted into slots.
|
|
425
|
+
* markdown, so they are normalized and their links lifted into slots.
|
|
426
|
+
*
|
|
427
|
+
* ORDER IS LOAD-BEARING. An assistant reply may carry inline widget envelopes
|
|
428
|
+
* (`<<{"widget":…}>>`), and they are scanned out FIRST, because `stripMarkdown`
|
|
429
|
+
* applied to a payload eats the underscores inside its JSON keys — which is how
|
|
430
|
+
* `SINGLE_SELECT_WIDGET` used to reach the screen as `SINGLESELECTWIDGET`, both
|
|
431
|
+
* corrupted and rendered as raw text. Everything downstream — the stripper, the
|
|
432
|
+
* link scan — sees the PROSE only, so a markdown link that exists inside a
|
|
433
|
+
* payload never becomes one of the bubble's three link rows either.
|
|
434
|
+
*
|
|
435
|
+
* The widgets themselves are dropped here; rendering them is #2605 onward. A
|
|
436
|
+
* reply that was nothing but a widget therefore has empty prose and is filtered
|
|
437
|
+
* out below, which is deliberate: an empty bubble is a visible defect. */
|
|
424
438
|
export function toBubbles(msgs: ConversationMessage[], assistantName: string, youName = "You"): AriaBubble[] {
|
|
425
439
|
return msgs
|
|
426
440
|
.map((m, i) => {
|
|
427
441
|
const raw = messageText(m);
|
|
428
442
|
const role = m.role || "ASSISTANT";
|
|
443
|
+
// Only assistant turns carry envelopes; a user's own text is literal, and
|
|
444
|
+
// scanning it would let an owner who typed `<<…>>` erase their own message.
|
|
445
|
+
const prose = role === "USER" ? raw : proseOfParts(scanEnvelopes(raw, 0, true).parts);
|
|
429
446
|
return {
|
|
430
447
|
id: m.id || `m${i}`,
|
|
431
448
|
role,
|
|
432
449
|
who: role === "USER" ? youName : assistantName,
|
|
433
|
-
content: role === "USER" ?
|
|
450
|
+
content: role === "USER" ? prose : stripMarkdown(prose),
|
|
434
451
|
notUser: role !== "USER",
|
|
435
452
|
notAria: role === "USER",
|
|
436
|
-
...linkFieldsOf(
|
|
453
|
+
...linkFieldsOf(prose, role),
|
|
437
454
|
};
|
|
438
455
|
})
|
|
439
456
|
.filter((b) => b.content && b.content.trim());
|
|
@@ -491,6 +508,34 @@ export function countRole(bubbles: AriaBubble[], role: string): number {
|
|
|
491
508
|
return n;
|
|
492
509
|
}
|
|
493
510
|
|
|
511
|
+
/** How many turns of one role the SERVER holds — the mark the turn is judged
|
|
512
|
+
* against. Counted over RAW messages, gated on the raw text being non-empty.
|
|
513
|
+
*
|
|
514
|
+
* Both halves are load-bearing, and they pull in opposite directions:
|
|
515
|
+
*
|
|
516
|
+
* - The emptiness gate is why this is not a plain `.length`. The service
|
|
517
|
+
* creates an assistant row before it has written anything into it, and an
|
|
518
|
+
* empty placeholder must not read as the answer that ended the turn. That is
|
|
519
|
+
* the reason `countRole` counted bubbles in the first place.
|
|
520
|
+
* - Counting RAW messages rather than bubbles is what this function adds, and
|
|
521
|
+
* it is a bug fix rather than a tidy-up. An assistant reply that is nothing
|
|
522
|
+
* but a widget envelope (`<<{"widget":…}>>`) is invisible to a bubble count:
|
|
523
|
+
* `toBubbles` lifts the envelope out, the prose left behind is empty, and the
|
|
524
|
+
* bubble is dropped so no blank row paints. `tick` then never saw the
|
|
525
|
+
* assistant turn arrive, so the screen sat in `sending` at the FAST cadence
|
|
526
|
+
* indefinitely — one request a second, forever, with the typing line stuck
|
|
527
|
+
* on. A widget-only reply is a real answer; it just has nothing to say in
|
|
528
|
+
* prose, and its raw text is non-empty, which is exactly what separates it
|
|
529
|
+
* from the placeholder above. */
|
|
530
|
+
export function countTurns(msgs: ConversationMessage[], role: string): number {
|
|
531
|
+
let n = 0;
|
|
532
|
+
for (const m of msgs) {
|
|
533
|
+
if ((m.role || "ASSISTANT") !== role) continue;
|
|
534
|
+
if (messageText(m).trim()) n++;
|
|
535
|
+
}
|
|
536
|
+
return n;
|
|
537
|
+
}
|
|
538
|
+
|
|
494
539
|
// ---------------------------------------------------------------------------
|
|
495
540
|
// Local recent-conversation list (himalaya.storage)
|
|
496
541
|
// ---------------------------------------------------------------------------
|
|
@@ -637,14 +682,30 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
637
682
|
* passed in — the only place a concurrent tap is visible is here. */
|
|
638
683
|
let inFlightSend: string | null = null;
|
|
639
684
|
|
|
685
|
+
/** A turn the server STORED but never got asked to answer.
|
|
686
|
+
*
|
|
687
|
+
* Sending is two calls: persist, then trigger. When the second fails, the
|
|
688
|
+
* owner's message is already on the server — so the obvious recovery, tapping
|
|
689
|
+
* Send again, posts a SECOND copy of it and can have the agent answer both.
|
|
690
|
+
* Remembering the stored id turns that retry into a trigger of the turn that
|
|
691
|
+
* already exists.
|
|
692
|
+
*
|
|
693
|
+
* A latch rather than state, for the same reason `inFlightSend` is one: it is
|
|
694
|
+
* a transient recovery hint about one failed attempt, not something that
|
|
695
|
+
* should survive a reload or be persisted with the transcript. Keyed by
|
|
696
|
+
* conversation AND content so it can only ever re-trigger the exact turn it
|
|
697
|
+
* was recorded for. */
|
|
698
|
+
let stalledTurn: { convId: string; content: string; userMessageId: string } | null = null;
|
|
699
|
+
|
|
640
700
|
/** Refuse a send that is already on its way. State half + latch half. */
|
|
641
701
|
const duplicate = (s: AriaChatState, content: string): boolean =>
|
|
642
702
|
isDuplicateSend(s, content) || inFlightSend === content;
|
|
643
703
|
|
|
644
|
-
/** Fold a freshly read server transcript into the observed-count marks.
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
704
|
+
/** Fold a freshly read server transcript into the observed-count marks.
|
|
705
|
+
* Takes the RAW messages, not the bubbles — see `countTurns`. */
|
|
706
|
+
const observed = (msgs: ConversationMessage[]) => ({
|
|
707
|
+
_srvUsers: countTurns(msgs, "USER"),
|
|
708
|
+
_srvAssistants: countTurns(msgs, "ASSISTANT"),
|
|
648
709
|
});
|
|
649
710
|
|
|
650
711
|
const greetingOnly = (): AriaBubble[] => [plainBubble(GREETING_ID, "ASSISTANT", assistant(), res(o.greeting))];
|
|
@@ -692,16 +753,22 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
692
753
|
* deliberately. */
|
|
693
754
|
async function busyLoop(s: AriaChatState, convId: string, optimistic: AriaBubble[]): Promise<AriaChatState> {
|
|
694
755
|
let server: AriaBubble[] = optimistic;
|
|
756
|
+
let seen: ConversationMessage[] = [];
|
|
695
757
|
for (let n = 0; n < BUSY_LOOP_ROUNDS; n++) {
|
|
696
758
|
const gm = await getConversationMessages(convId);
|
|
697
|
-
const
|
|
759
|
+
const raw = messagesOf(gm);
|
|
760
|
+
const bubbles = toBubbles(raw, assistant(), you());
|
|
698
761
|
if (bubbles.length) server = bubbles;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
762
|
+
seen = raw;
|
|
763
|
+
// The turn is over when the SERVER holds an assistant answer — which a
|
|
764
|
+
// widget-only reply is, even though it leaves no bubble to inspect. Asking
|
|
765
|
+
// the last BUBBLE stalled this loop on exactly that reply, the same way it
|
|
766
|
+
// stalled `tick`.
|
|
767
|
+
if (countTurns(raw, "ASSISTANT") > s._replyAfter) {
|
|
768
|
+
return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw), ok: false };
|
|
702
769
|
}
|
|
703
770
|
}
|
|
704
|
-
return { ...s, status: "ready", _convId: convId, messages: server, input: "", statusText: slowText(), _pending: "", ...observed(
|
|
771
|
+
return { ...s, status: "ready", _convId: convId, messages: server, input: "", statusText: slowText(), _pending: "", ...observed(seen), ok: false };
|
|
705
772
|
}
|
|
706
773
|
|
|
707
774
|
/** Send + wait, by whichever transport this consumer configured. */
|
|
@@ -727,13 +794,47 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
727
794
|
// it never received.
|
|
728
795
|
let convId = s._convId;
|
|
729
796
|
let site = s._siteId;
|
|
797
|
+
// The stored id this attempt is RE-triggering, if it is one. Declared out
|
|
798
|
+
// here because the catch needs it: `triggerAssistantMessage` rejects with a
|
|
799
|
+
// plain transport error and no `storedUserMessageId` — only `sendMessage`
|
|
800
|
+
// attaches that, because only `sendMessage` is the call that stored the turn.
|
|
801
|
+
// Without this, a retry that failed AGAIN cleared the latch and the attempt
|
|
802
|
+
// after it fell back to a full send, duplicating the very turn the latch
|
|
803
|
+
// exists to protect.
|
|
804
|
+
let retriedId = "";
|
|
730
805
|
try {
|
|
731
806
|
const ensured = await ensureConversation(s);
|
|
732
807
|
convId = ensured.convId;
|
|
733
808
|
site = ensured.site;
|
|
734
809
|
const created = ensured.created;
|
|
735
810
|
if (convId) remember(s, site, convId, content);
|
|
736
|
-
|
|
811
|
+
// A retry of a turn the server already stored triggers THAT turn rather
|
|
812
|
+
// than posting the same words twice — see `stalledTurn`. Anything else is
|
|
813
|
+
// an ordinary two-call send.
|
|
814
|
+
//
|
|
815
|
+
// The SAME origin the conversation was created under (line above and
|
|
816
|
+
// `createConversation`). Sending without it defaulted the turn to
|
|
817
|
+
// `ARIA_ORIGIN`, so a consumer that configured its own origin created the
|
|
818
|
+
// conversation under one name and posted into it under another.
|
|
819
|
+
const stalled = stalledTurn && stalledTurn.convId === convId && stalledTurn.content === content
|
|
820
|
+
? stalledTurn
|
|
821
|
+
: null;
|
|
822
|
+
if (stalled) {
|
|
823
|
+
retriedId = stalled.userMessageId;
|
|
824
|
+
await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
|
|
825
|
+
} else {
|
|
826
|
+
await sendMessage(convId, content, { origin });
|
|
827
|
+
}
|
|
828
|
+
// Delivered — whatever was stalled is answered or superseded.
|
|
829
|
+
//
|
|
830
|
+
// `retriedId` is cleared with it, and that is not redundant: the legacy
|
|
831
|
+
// `busyLoop` consumer keeps reading the transcript AFTER this point, still
|
|
832
|
+
// inside this `try`. A failure there is a failure to READ, not to deliver
|
|
833
|
+
// — the turn has been triggered — so leaving `retriedId` set let the catch
|
|
834
|
+
// below rebuild a latch for a turn already being answered, and the next
|
|
835
|
+
// identical submission triggered it a second time.
|
|
836
|
+
stalledTurn = null;
|
|
837
|
+
retriedId = "";
|
|
737
838
|
// A conversation minted just now starts empty, so the marks taken above
|
|
738
839
|
// from the PREVIOUS conversation's counts would be ahead of it and its
|
|
739
840
|
// first reply would never read as new — the same stranded "typing…" this
|
|
@@ -752,6 +853,18 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
752
853
|
return busyLoop({ ...sent, _convId: convId, _siteId: site }, convId, optimistic);
|
|
753
854
|
} catch (e) {
|
|
754
855
|
inFlightSend = null;
|
|
856
|
+
// A send that failed AFTER the turn was stored hands back the id it stored
|
|
857
|
+
// under. Remember it, so the owner tapping Send again triggers that turn
|
|
858
|
+
// instead of posting their message a second time. Any other failure clears
|
|
859
|
+
// the latch: nothing is stored, so a plain retry is the right recovery and
|
|
860
|
+
// a stale id would re-trigger a turn the owner has moved on from.
|
|
861
|
+
// Two ways this attempt can leave a turn stored and unanswered: a SEND
|
|
862
|
+
// whose trigger failed (the id arrives on the error), or a RE-TRIGGER of
|
|
863
|
+
// an already-stored turn that failed again (no id on the error, because
|
|
864
|
+
// nothing new was stored — the one we were retrying is still the answer).
|
|
865
|
+
const storedId = (e as { storedUserMessageId?: unknown })?.storedUserMessageId;
|
|
866
|
+
const keptId = typeof storedId === "string" && storedId ? storedId : retriedId;
|
|
867
|
+
stalledTurn = convId && keptId ? { convId, content, userMessageId: keptId } : null;
|
|
755
868
|
return {
|
|
756
869
|
...base, status: "ready", _pending: "",
|
|
757
870
|
// `_siteId` moves with the id or neither does: a conversation remembered
|
|
@@ -786,10 +899,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
786
899
|
if (resumeId) {
|
|
787
900
|
try {
|
|
788
901
|
const gm = await getConversationMessages(resumeId);
|
|
789
|
-
const
|
|
902
|
+
const raw = messagesOf(gm);
|
|
903
|
+
const bubbles = toBubbles(raw, assistant(), you());
|
|
790
904
|
const resumed = bubbles.length ? bubbles : greetingOnly();
|
|
791
905
|
return { ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "",
|
|
792
|
-
messages: resumed, ...observed(
|
|
906
|
+
messages: resumed, ...observed(raw), prompts: promptsFor(resumed, promptList()) };
|
|
793
907
|
} catch { /* expired or unreachable — fall through to a fresh one */ }
|
|
794
908
|
}
|
|
795
909
|
// Opening a screen runs `load` more than once — the bundle self-kicks at
|
|
@@ -884,20 +998,32 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
884
998
|
const wasFast = s.status === "sending";
|
|
885
999
|
try {
|
|
886
1000
|
const gm = await getConversationMessages(s._convId);
|
|
887
|
-
const
|
|
888
|
-
|
|
1001
|
+
const raw = messagesOf(gm);
|
|
1002
|
+
const server = toBubbles(raw, assistant(), you());
|
|
1003
|
+
// A transcript that renders to nothing is not necessarily an empty one:
|
|
1004
|
+
// a reply made entirely of widgets leaves no bubble behind. So the bail
|
|
1005
|
+
// is on the RAW transcript — bailing on the bubbles is what let such a
|
|
1006
|
+
// reply go unnoticed and pinned the ladder fast forever.
|
|
1007
|
+
if (!raw.length) return s;
|
|
889
1008
|
const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you());
|
|
1009
|
+
// …but still never PAINT nothing over something. `messages` is only
|
|
1010
|
+
// adopted when it has content: a transcript that renders empty leaves
|
|
1011
|
+
// the screen as it was (the greeting, or the last good turn) while the
|
|
1012
|
+
// counts below still register the answer.
|
|
1013
|
+
const painted = messages.length ? messages : s.messages;
|
|
890
1014
|
// The turn is over when the server holds an assistant turn it did not
|
|
891
1015
|
// hold when we sent. Deliberately independent of `pending`: whether our
|
|
892
1016
|
// own words came back is a rendering detail, not the answer arriving.
|
|
893
|
-
|
|
1017
|
+
// Counted over RAW messages, so a widget-only answer still ends the turn
|
|
1018
|
+
// instead of pinning the ladder to its fast rung forever (`countTurns`).
|
|
1019
|
+
const replied = countTurns(raw, "ASSISTANT") > s._replyAfter;
|
|
894
1020
|
const next: AriaChatState = {
|
|
895
1021
|
...s,
|
|
896
1022
|
status: replied ? "ready" : s.status,
|
|
897
|
-
messages,
|
|
1023
|
+
messages: painted,
|
|
898
1024
|
_pending: pending,
|
|
899
|
-
...observed(
|
|
900
|
-
prompts: promptsFor(
|
|
1025
|
+
...observed(raw),
|
|
1026
|
+
prompts: promptsFor(painted, promptList()),
|
|
901
1027
|
statusText: replied ? "" : s.statusText,
|
|
902
1028
|
};
|
|
903
1029
|
// Slow the ladder the moment the turn is over; leave it alone otherwise.
|
|
@@ -932,6 +1058,14 @@ export interface AriaHistoryState {
|
|
|
932
1058
|
items: AriaHistoryRow[];
|
|
933
1059
|
/** Non-empty ONLY when the list is empty (collapses otherwise). */
|
|
934
1060
|
emptyText: string;
|
|
1061
|
+
/** The business the rows in `items` belong to. A kept list is only safe to
|
|
1062
|
+
* keep while this still matches, so it is state rather than a closure — the
|
|
1063
|
+
* chat service tracks its own `_siteId` for the same reason. */
|
|
1064
|
+
_siteId: string;
|
|
1065
|
+
/** The last load could not REACH the server, as opposed to reaching it and
|
|
1066
|
+
* being told there are none. The two are different answers and the list
|
|
1067
|
+
* treats them differently; see `load`. */
|
|
1068
|
+
failed: boolean;
|
|
935
1069
|
}
|
|
936
1070
|
|
|
937
1071
|
/** Worker-emitted strings for the history list. Thunks for the same reason the
|
|
@@ -1033,28 +1167,59 @@ export function createAriaHistory(o: AriaHistoryOptions) {
|
|
|
1033
1167
|
last30: res(o.labels?.last30 ?? STOCK_BUCKETS.last30),
|
|
1034
1168
|
older: res(o.labels?.older ?? STOCK_BUCKETS.older),
|
|
1035
1169
|
});
|
|
1036
|
-
const initialState: AriaHistoryState = {
|
|
1170
|
+
const initialState: AriaHistoryState = {
|
|
1171
|
+
status: "loading", items: [], emptyText: "", _siteId: "", failed: false,
|
|
1172
|
+
};
|
|
1037
1173
|
return {
|
|
1038
1174
|
initialState,
|
|
1039
1175
|
load: async (s: AriaHistoryState): Promise<AriaHistoryState> => {
|
|
1040
1176
|
const now = Date.now();
|
|
1041
|
-
|
|
1177
|
+
const site = siteOf();
|
|
1178
|
+
// `null` means the server was never reached. Distinct from `[]`, which is
|
|
1179
|
+
// the server answering that this business has no conversations — the whole
|
|
1180
|
+
// fix below turns on telling those two apart.
|
|
1181
|
+
let fetched: Array<{ id: string; title: string; ms: number }> | null = null;
|
|
1042
1182
|
try {
|
|
1043
1183
|
const conversations = await getConversations(origin);
|
|
1044
|
-
|
|
1184
|
+
fetched = fromServer(conversations.conversations ?? [], untitled());
|
|
1045
1185
|
} catch {
|
|
1046
|
-
|
|
1186
|
+
fetched = null;
|
|
1047
1187
|
}
|
|
1048
|
-
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1188
|
+
|
|
1189
|
+
// A FAILED fetch must not blank a list that is already on screen. An app
|
|
1190
|
+
// that sets `localHistory: false` has the server list as its ONLY source,
|
|
1191
|
+
// so rebuilding from an empty `entries` turns a network blip into
|
|
1192
|
+
// "No past conversations yet." over a list the owner was just reading.
|
|
1193
|
+
//
|
|
1194
|
+
// Kept ONLY while the rows still belong to the same business. That clause
|
|
1195
|
+
// is the point, not a detail — without it this becomes a way to show one
|
|
1196
|
+
// owner the previous business's conversation titles, which is precisely
|
|
1197
|
+
// the leak `localHistory: false` is set to avoid.
|
|
1198
|
+
if (fetched === null && s.items.length && s._siteId === site) {
|
|
1199
|
+
return { ...s, status: "ready", failed: true };
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// A SUCCESSFUL empty answer is authoritative and DOES clear. The tempting
|
|
1203
|
+
// shortcut is to keep the last good list on ANY empty result, failure or
|
|
1204
|
+
// not — do not. This list is site-scoped, so right after a business switch
|
|
1205
|
+
// the new business truthfully answers "none", and keeping the old rows
|
|
1206
|
+
// would carry the previous business's titles across. A stale-looking list
|
|
1207
|
+
// is not worth a leak.
|
|
1208
|
+
let entries = fetched ?? [];
|
|
1209
|
+
// The local recent list still covers empty-or-unreachable, and is
|
|
1210
|
+
// site-partitioned for the same reason the server list is.
|
|
1053
1211
|
if (!entries.length && o.localHistory !== false) {
|
|
1054
|
-
entries = fromLocal(readHistory(scopedKey(o.historyKey,
|
|
1212
|
+
entries = fromLocal(readHistory(scopedKey(o.historyKey, site)));
|
|
1055
1213
|
}
|
|
1056
1214
|
const items = bucketRows(entries, now, buckets());
|
|
1057
|
-
return {
|
|
1215
|
+
return {
|
|
1216
|
+
...s,
|
|
1217
|
+
status: "ready",
|
|
1218
|
+
items,
|
|
1219
|
+
emptyText: items.length ? "" : emptyText(),
|
|
1220
|
+
_siteId: site,
|
|
1221
|
+
failed: fetched === null,
|
|
1222
|
+
};
|
|
1058
1223
|
},
|
|
1059
1224
|
};
|
|
1060
1225
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Inline widget envelopes in Aria's assistant text.
|
|
2
|
+
//
|
|
3
|
+
// The Wix Genie chat-service embeds interactive widgets inside the message body:
|
|
4
|
+
//
|
|
5
|
+
// Here are two templates.\n\n<<{"widget":"SINGLE_SELECT_WIDGET","data":{…}}>>\n\nWhich?
|
|
6
|
+
//
|
|
7
|
+
// The RN Aria app pulls them out with `/<<(.*?)>>/gs`; the standalone native app
|
|
8
|
+
// scans incrementally. We do the latter, for the reason in `scanEnvelopes` below.
|
|
9
|
+
//
|
|
10
|
+
// WHY THIS IS A MODULE AND NOT A REGEX AT THE CALL SITE
|
|
11
|
+
//
|
|
12
|
+
// Order. `stripMarkdown` must never see a payload: its italic rules (`__x__` -> `x`,
|
|
13
|
+
// `_x_` -> `x`) run across the whole string, so a payload that passes through it
|
|
14
|
+
// comes back with the underscores eaten — `SINGLE_SELECT_WIDGET` arrives as
|
|
15
|
+
// `SINGLESELECTWIDGET`, and every later parser reads a corrupted key. Scanning
|
|
16
|
+
// before stripping, and stripping only the text parts, is the fix.
|
|
17
|
+
//
|
|
18
|
+
// This module is deliberately DEPENDENCY-FREE so it can be imported by the worker
|
|
19
|
+
// bundle and unit-tested without booting anything.
|
|
20
|
+
|
|
21
|
+
/** One widget lifted out of the text, with its payload untouched. */
|
|
22
|
+
export interface WidgetPayload {
|
|
23
|
+
/** The `widget` field, or `""` when the payload could not be read. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** The `data` object re-serialized, or null when absent. Kept as a STRING so the
|
|
26
|
+
* scanner never becomes the thing that decides a payload's shape — that is the
|
|
27
|
+
* normalizer's job, and it can fail without taking the chat down with it. */
|
|
28
|
+
dataJson: string | null;
|
|
29
|
+
/** The `config` object re-serialized, or null when absent. */
|
|
30
|
+
configJson: string | null;
|
|
31
|
+
/** Exactly what sat between `<<` and `>>`. Preserved verbatim for diagnosis. */
|
|
32
|
+
raw: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type WidgetPart =
|
|
36
|
+
| { kind: "text"; text: string }
|
|
37
|
+
| { kind: "widget"; payload: WidgetPayload };
|
|
38
|
+
|
|
39
|
+
export interface ScanResult {
|
|
40
|
+
parts: WidgetPart[];
|
|
41
|
+
/** Where a subsequent scan of the same message should start. Equal to the text
|
|
42
|
+
* length once the turn is complete; otherwise it may point back at an unclosed
|
|
43
|
+
* `<<` (or a bare trailing `<`) that must be re-read when more text arrives. */
|
|
44
|
+
resumeOffset: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const OPEN = "<<";
|
|
48
|
+
const CLOSE = ">>";
|
|
49
|
+
|
|
50
|
+
/** Read the payload between the delimiters. Never throws: an unreadable payload
|
|
51
|
+
* still gets lifted out of the prose, with an empty name for the normalizer to
|
|
52
|
+
* turn into UNKNOWN. Showing raw JSON to an owner is the failure being avoided. */
|
|
53
|
+
function payloadOf(raw: string): WidgetPayload {
|
|
54
|
+
let name = "";
|
|
55
|
+
let dataJson: string | null = null;
|
|
56
|
+
let configJson: string | null = null;
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
59
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
60
|
+
const obj = parsed as Record<string, unknown>;
|
|
61
|
+
if (typeof obj.widget === "string") name = obj.widget;
|
|
62
|
+
if (obj.data !== undefined) dataJson = JSON.stringify(obj.data);
|
|
63
|
+
if (obj.config !== undefined) configJson = JSON.stringify(obj.config);
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Deliberately silent: `name` stays "" and the caller degrades to UNKNOWN.
|
|
67
|
+
}
|
|
68
|
+
return { name, dataJson, configJson, raw };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Index of the `>>` that actually TERMINATES the envelope opened at `start`, or
|
|
73
|
+
* -1 if there is none yet.
|
|
74
|
+
*
|
|
75
|
+
* Not `indexOf`, because the envelope body is JSON and JSON strings may contain
|
|
76
|
+
* anything — including `>>`. A perfectly valid payload such as
|
|
77
|
+
* `{"widget":"CHART","config":{"title":"Q3 >> Q4"}}` was cut at the inner `>>`:
|
|
78
|
+
* `payloadOf` then got truncated, unparseable JSON (degrading the widget to
|
|
79
|
+
* UNKNOWN) AND the remainder — `Q4"}}` — was emitted as prose, printing a
|
|
80
|
+
* fragment of the payload into the chat: the very thing this module exists to
|
|
81
|
+
* prevent.
|
|
82
|
+
*
|
|
83
|
+
* So the scan tracks whether it is inside a JSON string, honouring backslash
|
|
84
|
+
* escapes. Outside a string, the first `>>` wins as before.
|
|
85
|
+
*/
|
|
86
|
+
function closeOf(text: string, start: number): number {
|
|
87
|
+
let inStr = false;
|
|
88
|
+
for (let i = start; i < text.length; i++) {
|
|
89
|
+
const c = text[i];
|
|
90
|
+
if (inStr) {
|
|
91
|
+
// A backslash escapes the next character, `\"` and `\\` included — so
|
|
92
|
+
// skipping one character here is what keeps `"a\\"` from reading as an
|
|
93
|
+
// unterminated string.
|
|
94
|
+
if (c === "\\") { i++; continue; }
|
|
95
|
+
if (c === '"') inStr = false;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (c === '"') { inStr = true; continue; }
|
|
99
|
+
if (c === ">" && text[i + 1] === ">") return i;
|
|
100
|
+
}
|
|
101
|
+
// No string-aware terminator. That is EITHER a genuinely unclosed envelope, or
|
|
102
|
+
// a payload whose JSON is malformed — an unterminated string swallows the rest
|
|
103
|
+
// of the message, `>>` included. Fall back to the naive scan for the second
|
|
104
|
+
// case: a broken envelope still belongs OUT of the prose, degraded to an
|
|
105
|
+
// UNKNOWN widget, rather than printed into the chat as raw JSON. When the text
|
|
106
|
+
// really holds no `>>`, this returns -1 too and the caller's unclosed handling
|
|
107
|
+
// takes over unchanged.
|
|
108
|
+
return text.indexOf(CLOSE, start);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Split `text` into prose and widget parts, starting at `from`.
|
|
113
|
+
*
|
|
114
|
+
* `completed` says whether the assistant's turn has finished. It is the only thing
|
|
115
|
+
* that decides what to do with an unterminated `<<`:
|
|
116
|
+
*
|
|
117
|
+
* - mid-turn — HOLD IT BACK and point `resumeOffset` at the `<<`. A half-arrived
|
|
118
|
+
* envelope must not flash as literal JSON and then vanish when it closes.
|
|
119
|
+
* - complete — it never closed, so it was never an envelope. Emit it as ordinary
|
|
120
|
+
* text rather than swallowing content the assistant actually sent.
|
|
121
|
+
*
|
|
122
|
+
* A bare `<` at the very end is held back mid-turn for the same reason: it may be
|
|
123
|
+
* the first half of `<<`.
|
|
124
|
+
*
|
|
125
|
+
* We poll rather than stream today, so every real call is `(text, 0, true)`. The
|
|
126
|
+
* resumable shape is kept because retrofitting it later is the expensive part, and
|
|
127
|
+
* because the held-back case is invisible under polling and obvious under streaming.
|
|
128
|
+
*/
|
|
129
|
+
export function scanEnvelopes(text: string, from: number, completed: boolean): ScanResult {
|
|
130
|
+
const parts: WidgetPart[] = [];
|
|
131
|
+
const pushText = (s: string): void => {
|
|
132
|
+
if (s) parts.push({ kind: "text", text: s });
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let cursor = Math.min(Math.max(from, 0), text.length);
|
|
136
|
+
|
|
137
|
+
for (;;) {
|
|
138
|
+
const open = text.indexOf(OPEN, cursor);
|
|
139
|
+
if (open < 0) break;
|
|
140
|
+
|
|
141
|
+
const close = closeOf(text, open + OPEN.length);
|
|
142
|
+
if (close < 0) {
|
|
143
|
+
if (completed) {
|
|
144
|
+
// Never closed and never will. It is text.
|
|
145
|
+
pushText(text.slice(cursor));
|
|
146
|
+
return { parts, resumeOffset: text.length };
|
|
147
|
+
}
|
|
148
|
+
// Still arriving: show what precedes it, and re-read from the `<<` next time.
|
|
149
|
+
pushText(text.slice(cursor, open));
|
|
150
|
+
return { parts, resumeOffset: open };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pushText(text.slice(cursor, open));
|
|
154
|
+
parts.push({ kind: "widget", payload: payloadOf(text.slice(open + OPEN.length, close)) });
|
|
155
|
+
cursor = close + CLOSE.length;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// No further `<<`. A lone trailing `<` mid-turn may be the start of one.
|
|
159
|
+
if (!completed && text.endsWith("<")) {
|
|
160
|
+
const held = text.length - 1;
|
|
161
|
+
if (cursor < held) pushText(text.slice(cursor, held));
|
|
162
|
+
return { parts, resumeOffset: held };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
pushText(text.slice(cursor));
|
|
166
|
+
return { parts, resumeOffset: text.length };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The prose of a scan, with every widget removed. The string that reaches a bubble. */
|
|
170
|
+
export function proseOfParts(parts: WidgetPart[]): string {
|
|
171
|
+
let out = "";
|
|
172
|
+
for (const p of parts) if (p.kind === "text") out += p.text;
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Every widget a scan lifted out, in the order they appeared. */
|
|
177
|
+
export function widgetsOfParts(parts: WidgetPart[]): WidgetPayload[] {
|
|
178
|
+
const out: WidgetPayload[] = [];
|
|
179
|
+
for (const p of parts) if (p.kind === "widget") out.push(p.payload);
|
|
180
|
+
return out;
|
|
181
|
+
}
|