@wix/himalaya-cli 0.960.0 → 0.962.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 +1 -0
- package/package.json +2 -2
- package/templates/beauty-salon-owner/config.ts +1 -1
- package/templates/beauty-salon-owner/dev/index.ts +3075 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/community-club-owner/config.ts +1 -1
- package/templates/community-club-owner/dev/index.ts +3075 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/community-club-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/events-meetups-owner/config.ts +1 -1
- package/templates/events-meetups-owner/dev/index.ts +3075 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/events-meetups-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/gym-fitness-owner/config.ts +1 -1
- package/templates/gym-fitness-owner/dev/index.ts +3075 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/nonprofit-cause-owner/config.ts +1 -1
- package/templates/nonprofit-cause-owner/dev/index.ts +3075 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/owner-lite/config.ts +1 -1
- package/templates/owner-lite/dev/index.ts +3075 -0
- package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/owner-lite/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/owner-lite/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/owner-lite/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/owner-lite/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/podcast-creator-owner/config.ts +1 -1
- package/templates/podcast-creator-owner/dev/index.ts +3075 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/portfolio-creative-owner/config.ts +1 -1
- package/templates/portfolio-creative-owner/dev/index.ts +3075 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/restaurant-owner/config.ts +1 -1
- package/templates/restaurant-owner/dev/index.ts +3075 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/restaurant-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/retail-store-owner/config.ts +1 -1
- package/templates/retail-store-owner/dev/index.ts +3075 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/retail-store-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
- package/templates/yoga-studio-owner/config.ts +1 -1
- package/templates/yoga-studio-owner/dev/index.ts +3075 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +466 -9
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-generic-widget.ts +251 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-kinds.ts +332 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-model.ts +331 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-widget-view.ts +722 -0
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +29 -0
|
@@ -42,6 +42,10 @@ import {
|
|
|
42
42
|
type ConversationSummary,
|
|
43
43
|
} from "@himalaya/wix-genie-chat";
|
|
44
44
|
import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
|
|
45
|
+
import { isWebUrl } from "./aria-widget-model.js";
|
|
46
|
+
import { NO_WIDGET, activeWidgetOf, asyncAdvanced, type SubtreeSlot, type WidgetOptionRow, type WidgetView }
|
|
47
|
+
from "./aria-widget-view.js";
|
|
48
|
+
import { substituteSelection } from "./aria-widget-model.js";
|
|
45
49
|
|
|
46
50
|
// ---------------------------------------------------------------------------
|
|
47
51
|
// Shapes
|
|
@@ -143,6 +147,13 @@ export interface AriaChatState {
|
|
|
143
147
|
* POP the screen. A chat composer must stay put, so the whole surface leaves
|
|
144
148
|
* this false and the bound message list simply re-renders. */
|
|
145
149
|
ok: boolean;
|
|
150
|
+
/** The response widget the assistant is currently asking with, flattened for
|
|
151
|
+
* binding (`aria-widget-view.ts`). `kind: "NONE"` collapses the region.
|
|
152
|
+
*
|
|
153
|
+
* Derived, never accumulated: it is recomputed from the server transcript
|
|
154
|
+
* every time the transcript is, which is what makes "one active widget"
|
|
155
|
+
* true by construction rather than by remembering to clear it. */
|
|
156
|
+
widget: WidgetView;
|
|
146
157
|
/** Transient host directives — a poll cadence to install, and a navigation to
|
|
147
158
|
* perform. The runtime strips both before storing the state, so they are
|
|
148
159
|
* never persisted and never re-fire when the state is spread. */
|
|
@@ -188,6 +199,12 @@ export interface AriaChatLabels {
|
|
|
188
199
|
/** Status line after the owner stopped a reply. Sits in the NEUTRAL slot:
|
|
189
200
|
* they asked for it, so nothing went wrong. */
|
|
190
201
|
stopped?: Resolvable<string>;
|
|
202
|
+
/** Shown under a picker once the owner has chosen as many options as the
|
|
203
|
+
* service allows. Takes the limit, because "pick up to 3" is the whole of
|
|
204
|
+
* the information. */
|
|
205
|
+
selectionLimit?: (max: number) => string;
|
|
206
|
+
/** Shown under a questionnaire while a required answer is missing. */
|
|
207
|
+
answersIncomplete?: string;
|
|
191
208
|
/** Shown instead of a raw transport error when this HOST cannot reach Wix at
|
|
192
209
|
* all — it carries no Wix app binding to sign the chat-service call with.
|
|
193
210
|
* See `isWixAccountUnreachable`, and note what it is NOT: a signed-out user. */
|
|
@@ -195,6 +212,19 @@ export interface AriaChatLabels {
|
|
|
195
212
|
}
|
|
196
213
|
|
|
197
214
|
export interface AriaChatOptions {
|
|
215
|
+
/** Mount the generic card for an unrecognised kind in this `WorkerSubtree`
|
|
216
|
+
* slot.
|
|
217
|
+
*
|
|
218
|
+
* Omit it and an unrecognised kind degrades to prose, which is what a
|
|
219
|
+
* screen NOT listed in `workerSubtreeScreens` must keep doing — the
|
|
220
|
+
* descriptor validator rejects the node otherwise. */
|
|
221
|
+
subtree?: SubtreeSlot;
|
|
222
|
+
|
|
223
|
+
/** Ask the chat-service for an async job's state — one of `initialized`,
|
|
224
|
+
* `processing`, `finished`, `failed`. Optional: a host that does not supply
|
|
225
|
+
* it renders an ASYNC_TOOL's opening message and never advances it, which is
|
|
226
|
+
* strictly better than showing a spinner that can never stop. */
|
|
227
|
+
asyncJobState?: (jobId: string) => Promise<string>;
|
|
198
228
|
greeting: Resolvable<string>;
|
|
199
229
|
prompts: Resolvable<AriaPromptChip[]>;
|
|
200
230
|
labels?: AriaChatLabels;
|
|
@@ -237,7 +267,39 @@ export interface AriaChatOptions {
|
|
|
237
267
|
* action passes a POSITIONAL string (`id: "$item.text"` baked into the
|
|
238
268
|
* descriptor); the standard `tier5.<svc>.<action>` path passes an object. One
|
|
239
269
|
* normalizer rather than two forks of every action. */
|
|
240
|
-
|
|
270
|
+
/** `value` is typed `unknown` deliberately: `SelectionControl.onChange` puts
|
|
271
|
+
* its new BOOLEAN there on iOS and Android, and the native marshaller merges
|
|
272
|
+
* args over params — so anything reading it must cope with a non-string.
|
|
273
|
+
* `optionValue` is the key a caller should use when it means the option. */
|
|
274
|
+
export type AriaActionArg = string | { message?: string; text?: string; value?: unknown; optionValue?: string; id?: string; url?: string; intent?: string } | undefined;
|
|
275
|
+
|
|
276
|
+
/** The first of these fields that is actually a non-empty string.
|
|
277
|
+
*
|
|
278
|
+
* `value` is `unknown` because `SelectionControl.onChange` puts its new
|
|
279
|
+
* BOOLEAN there on iOS and Android and the marshaller merges args over
|
|
280
|
+
* params. Every reader therefore has to narrow — `.trim()` on a boolean
|
|
281
|
+
* throws, which is how a mis-wired toggle took the whole action down rather
|
|
282
|
+
* than merely selecting the wrong row. One helper so there is one place to
|
|
283
|
+
* get that right, instead of five `?? ""` chains that each look fine. */
|
|
284
|
+
export function firstString(...candidates: unknown[]): string {
|
|
285
|
+
for (const c of candidates) {
|
|
286
|
+
if (typeof c === "string" && c.trim()) return c.trim();
|
|
287
|
+
}
|
|
288
|
+
return "";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The first candidate that is a string, KEPT VERBATIM.
|
|
292
|
+
*
|
|
293
|
+
* The trimming sibling above is right for an option value, a query or an
|
|
294
|
+
* answer — all of which are identifiers or searches where surrounding space
|
|
295
|
+
* is noise. It is wrong for anything the owner is still typing: the composer
|
|
296
|
+
* reducer runs on every keystroke, so trimming there eats the space between
|
|
297
|
+
* two words the instant it is typed and turns "hello world" into
|
|
298
|
+
* "helloworld". Only submission normalizes chat text. */
|
|
299
|
+
export function firstStringRaw(...candidates: unknown[]): string {
|
|
300
|
+
for (const c of candidates) if (typeof c === "string") return c;
|
|
301
|
+
return "";
|
|
302
|
+
}
|
|
241
303
|
|
|
242
304
|
// ---------------------------------------------------------------------------
|
|
243
305
|
// Pure helpers (exported for unit tests)
|
|
@@ -378,6 +440,24 @@ export function isWixHost(host: string): boolean {
|
|
|
378
440
|
return host === "wix.com" || host.endsWith(".wix.com") || host === "wixsite.com" || host.endsWith(".wixsite.com");
|
|
379
441
|
}
|
|
380
442
|
|
|
443
|
+
/** Hosts an option PICTURE may be loaded from.
|
|
444
|
+
*
|
|
445
|
+
* Deliberately a different list from `isWixHost`, which answers "may a LINK
|
|
446
|
+
* label stay silent about where it goes". `static.wixstatic.com` is where
|
|
447
|
+
* every Wix-hosted image actually lives, so judging pictures by the link list
|
|
448
|
+
* blanked the images the service really sends — the catalogue's MULTI_SELECT
|
|
449
|
+
* thumbnails among them.
|
|
450
|
+
*
|
|
451
|
+
* The two lists must stay separate rather than merge: a CDN is a fine place
|
|
452
|
+
* to fetch a picture from and a poor thing to let a button's label imply. */
|
|
453
|
+
export function isWixMediaHost(host: string): boolean {
|
|
454
|
+
if (!PLAIN_HOST.test(host)) return false;
|
|
455
|
+
return isWixHost(host)
|
|
456
|
+
|| host === "wixstatic.com" || host.endsWith(".wixstatic.com")
|
|
457
|
+
|| host === "wixmp.com" || host.endsWith(".wixmp.com")
|
|
458
|
+
|| host === "wix-pages.com" || host.endsWith(".wix-pages.com");
|
|
459
|
+
}
|
|
460
|
+
|
|
381
461
|
/** Shown in place of a host when an `http(s)` link's host cannot be
|
|
382
462
|
* established. Deliberately not domain-shaped: it has to read as "this could
|
|
383
463
|
* not be checked", never as a site the owner might think they recognize.
|
|
@@ -517,7 +597,7 @@ export function isDuplicateSend(s: { _pending: string }, content: string): boole
|
|
|
517
597
|
export function argText(arg: AriaActionArg): string {
|
|
518
598
|
if (typeof arg === "string") return arg.trim();
|
|
519
599
|
if (!arg) return "";
|
|
520
|
-
return (arg.message
|
|
600
|
+
return firstString(arg.message, arg.text, arg.value, arg.intent, arg.id);
|
|
521
601
|
}
|
|
522
602
|
|
|
523
603
|
/** Reconcile the optimistic bubble against a freshly fetched server transcript.
|
|
@@ -765,6 +845,38 @@ export interface AriaChatCore {
|
|
|
765
845
|
* that moved during the abort makes this snapshot stale, and committing it
|
|
766
846
|
* would end a turn the owner has since started. */
|
|
767
847
|
stop(s: AriaChatState): Promise<AriaChatState | undefined>;
|
|
848
|
+
/** Tapping the active widget's primary / secondary footer button.
|
|
849
|
+
*
|
|
850
|
+
* The url is read from STATE rather than bound into the action's params.
|
|
851
|
+
* `$item.` is the binding form an action param is built for; the widget
|
|
852
|
+
* region is `$service:`-bound and has no item, and the worker already holds
|
|
853
|
+
* the value anyway — so the descriptor names the action and nothing else,
|
|
854
|
+
* which is also one less place for a model-supplied url to travel through. */
|
|
855
|
+
widgetPrimary(s: AriaChatState): Promise<AriaChatState>;
|
|
856
|
+
widgetSecondary(s: AriaChatState): Promise<AriaChatState>;
|
|
857
|
+
/** Toggle one option of a MULTI_SELECT-style widget. Synchronous: it is a
|
|
858
|
+
* state edit, and making it async would put a turn boundary where there is
|
|
859
|
+
* no network call. */
|
|
860
|
+
widgetToggle(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
|
|
861
|
+
/** Choose exactly one, replacing whatever was chosen (RADIO_BUTTON). */
|
|
862
|
+
widgetSelectOne(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
|
|
863
|
+
/** Send a widget button's `respondText`, with the selection substituted in.
|
|
864
|
+
* Reuses the composer's send — there is deliberately no second send path. */
|
|
865
|
+
widgetRespond(s: AriaChatState, arg?: AriaActionArg): Promise<AriaChatState>;
|
|
866
|
+
/** AUTOCOMPLETE typing. Filters in the worker — there is no autocomplete
|
|
867
|
+
* component to filter for us. */
|
|
868
|
+
widgetQuery(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
|
|
869
|
+
/** Pick a suggestion, honouring the payload's own `selectionType`.
|
|
870
|
+
*
|
|
871
|
+
* A descriptor cannot branch on a value, and the two reducers mean
|
|
872
|
+
* opposite things — `widgetSelectOne` REPLACES the selection — so wiring
|
|
873
|
+
* the row to either one directly is wrong for half the payloads. Tapping a
|
|
874
|
+
* second suggestion under `selectionType: "multi"` silently dropped the
|
|
875
|
+
* first. The mode is known where the payload is, so the choice belongs
|
|
876
|
+
* here. */
|
|
877
|
+
widgetPick(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
|
|
878
|
+
/** Answer one QUESTIONNAIRE question. */
|
|
879
|
+
widgetAnswer(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
|
|
768
880
|
}
|
|
769
881
|
|
|
770
882
|
const POLL_ID = "aria-turn";
|
|
@@ -785,6 +897,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
785
897
|
const typingText = () => res(o.labels?.typing ?? `${assistant()} is typing…`);
|
|
786
898
|
const slowText = () => res(o.labels?.slow ?? `${assistant()} is taking a while…`);
|
|
787
899
|
const stoppedText = () => res(o.labels?.stopped ?? "Stopped.");
|
|
900
|
+
const answersIncompleteText = () =>
|
|
901
|
+
o.labels?.answersIncomplete ?? "Answer the required questions to continue.";
|
|
902
|
+
const limitReachedText = (max: number) =>
|
|
903
|
+
o.labels?.selectionLimit?.(max) ?? `You can choose up to ${max}.`;
|
|
788
904
|
// States the fact and stops. It deliberately gives no instruction, because
|
|
789
905
|
// there is none the owner could follow: they are signed in, and the thing that
|
|
790
906
|
// is missing is the app's own Wix binding. "Here" is doing real work — it says
|
|
@@ -803,6 +919,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
803
919
|
messages: [], prompts: [], _convId: "", _siteId: "", _pending: "",
|
|
804
920
|
_pendingAfter: 0, _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
|
|
805
921
|
_abortId: "", _turnId: "", stopHidden: true, ok: false,
|
|
922
|
+
widget: NO_WIDGET,
|
|
806
923
|
};
|
|
807
924
|
|
|
808
925
|
/** One create at a time, per site.
|
|
@@ -835,6 +952,21 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
835
952
|
* passed in — the only place a concurrent tap is visible is here. */
|
|
836
953
|
let inFlightSend: string | null = null;
|
|
837
954
|
|
|
955
|
+
/** A widget send is in the air.
|
|
956
|
+
*
|
|
957
|
+
* A LATCH, not the `submitting` field beside it, and the distinction is the
|
|
958
|
+
* whole point: a flag set on the state an action RETURNS does not exist
|
|
959
|
+
* until that action resolves, so a second dispatch arriving during the await
|
|
960
|
+
* reads the state from before and sails past. Measured — two different
|
|
961
|
+
* option buttons tapped in the same tick both started a turn.
|
|
962
|
+
*
|
|
963
|
+
* `inFlightSend` next door guards the composer the same way, but keyed by
|
|
964
|
+
* CONTENT: two taps of one option are identical text and it catches them.
|
|
965
|
+
* Two taps of DIFFERENT options are different text, so only this catches
|
|
966
|
+
* those. `submitting` stays on the state because the descriptor needs
|
|
967
|
+
* something to disable a button with; it is the shadow, not the guard. */
|
|
968
|
+
let widgetSending = false;
|
|
969
|
+
|
|
838
970
|
/** A turn the server STORED but never got asked to answer.
|
|
839
971
|
*
|
|
840
972
|
* Sending is two calls: persist, then trigger. When the second fails, the
|
|
@@ -921,9 +1053,53 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
921
1053
|
|
|
922
1054
|
/** Fold a freshly read server transcript into the observed-count marks.
|
|
923
1055
|
* Takes the RAW messages, not the bubbles — see `countTurns`. */
|
|
924
|
-
|
|
1056
|
+
/** Carry the owner's SELECTIONS across a rebuild.
|
|
1057
|
+
*
|
|
1058
|
+
* `observed` runs on every transcript rebuild, including __HIMI_APP_NAME__'s idle
|
|
1059
|
+
* poll — so a widget that is still on screen was re-derived from the
|
|
1060
|
+
* payload, and the payload only knows `isPreselected`. An owner could tick
|
|
1061
|
+
* three boxes, wait for the poll, and watch them silently revert before
|
|
1062
|
+
* reaching Continue.
|
|
1063
|
+
*
|
|
1064
|
+
* Reconciled only when it is plainly the SAME ask: same kind, same option
|
|
1065
|
+
* values in the same order. Anything else is a new question and starts
|
|
1066
|
+
* from what the service sent. */
|
|
1067
|
+
const carrySelection = (next: WidgetView, prev?: WidgetView): WidgetView => {
|
|
1068
|
+
if (!prev || prev.hidden || next.hidden) return next;
|
|
1069
|
+
if (prev.kind !== next.kind) return next;
|
|
1070
|
+
if (prev.options.length !== next.options.length) return next;
|
|
1071
|
+
for (let i = 0; i < next.options.length; i++) {
|
|
1072
|
+
if (prev.options[i].value !== next.options[i].value) return next;
|
|
1073
|
+
}
|
|
1074
|
+
if (!prev.options.some((o, i) => o.selected !== next.options[i].selected)) return next;
|
|
1075
|
+
const options = next.options.map((o, i) => ({ ...o, selected: prev.options[i].selected }));
|
|
1076
|
+
return reselect({ ...next, options }, options);
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
const observed = (msgs: ConversationMessage[], prev?: WidgetView) => ({
|
|
925
1080
|
_srvUsers: countTurns(msgs, "USER"),
|
|
926
1081
|
_srvAssistants: countTurns(msgs, "ASSISTANT"),
|
|
1082
|
+
// Derived HERE, with the counts, because this is the one expression every
|
|
1083
|
+
// transcript rebuild already spreads — load, resume, tick and both newChat
|
|
1084
|
+
// arms. That is what makes the widget impossible to leave stale: a rebuild
|
|
1085
|
+
// that forgot to clear it would also have to have forgotten the counts,
|
|
1086
|
+
// which the reply-detection depends on. `observed([])` (new chat) yields
|
|
1087
|
+
// NO_WIDGET for free.
|
|
1088
|
+
// Content is normalized HERE, not in the view: `messageText` decodes the
|
|
1089
|
+
// `{ text: … }` shape the service also sends (treated as empty before,
|
|
1090
|
+
// so those widgets never appeared at all), and `stripHidden` removes
|
|
1091
|
+
// `<HIDDEN>` spans — which are transcript-only, and were promoting hidden
|
|
1092
|
+
// envelopes into live interactive cards. The transcript path already did
|
|
1093
|
+
// both; the widget path did neither.
|
|
1094
|
+
widget: carrySelection(
|
|
1095
|
+
activeWidgetOf(
|
|
1096
|
+
msgs.map((m) => ({ role: m.role, content: stripHidden(messageText(m)) })),
|
|
1097
|
+
disclosedLabel,
|
|
1098
|
+
o.subtree,
|
|
1099
|
+
(url) => isWixMediaHost(hostOf(url)),
|
|
1100
|
+
),
|
|
1101
|
+
prev,
|
|
1102
|
+
),
|
|
927
1103
|
});
|
|
928
1104
|
|
|
929
1105
|
const greetingOnly = (): AriaBubble[] => [plainBubble(GREETING_ID, "ASSISTANT", assistant(), res(o.greeting))];
|
|
@@ -983,10 +1159,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
983
1159
|
// the last BUBBLE stalled this loop on exactly that reply, the same way it
|
|
984
1160
|
// stalled `tick`.
|
|
985
1161
|
if (repliedTo(raw, s._turnId) ?? countTurns(raw, "ASSISTANT") > s._replyAfter) {
|
|
986
|
-
return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw), ok: false };
|
|
1162
|
+
return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw, s.widget), ok: false };
|
|
987
1163
|
}
|
|
988
1164
|
}
|
|
989
|
-
return { ...s, status: "ready", _convId: convId, messages: server, input: "", statusText: slowText(), _pending: "", ...observed(seen), ok: false };
|
|
1165
|
+
return { ...s, status: "ready", _convId: convId, messages: server, input: "", statusText: slowText(), _pending: "", ...observed(seen, s.widget), ok: false };
|
|
990
1166
|
}
|
|
991
1167
|
|
|
992
1168
|
/** Send + wait, by whichever transport this consumer configured. */
|
|
@@ -1024,6 +1200,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1024
1200
|
// counting `messages` would mark the turn one assistant reply ahead and
|
|
1025
1201
|
// the real answer would never register as new.
|
|
1026
1202
|
_pendingAfter: s._srvUsers, _replyAfter: s._srvAssistants, ok: false,
|
|
1203
|
+
// A new question retires the previous one's controls IMMEDIATELY, rather
|
|
1204
|
+
// than at the next tick. Leaving them up for a second or two invites a
|
|
1205
|
+
// tap that answers a question the owner has already moved past — the same
|
|
1206
|
+
// reason a scrollback widget keeps its prose and loses its controls.
|
|
1207
|
+
widget: NO_WIDGET,
|
|
1027
1208
|
};
|
|
1028
1209
|
inFlightSend = content;
|
|
1029
1210
|
// Held outside the try so a FAILED send still reports the conversation it
|
|
@@ -1234,6 +1415,117 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1234
1415
|
return s.stopHidden === hidden ? s : { ...s, stopHidden: hidden };
|
|
1235
1416
|
};
|
|
1236
1417
|
|
|
1418
|
+
/** Ask the service how an async job is getting on.
|
|
1419
|
+
*
|
|
1420
|
+
* Folded into the existing `tick` rather than armed as a SECOND `_poll`, and
|
|
1421
|
+
* that is a constraint rather than a preference: `PollDirective` has no stop
|
|
1422
|
+
* verb, so a second timer could be started and never cancelled — it would
|
|
1423
|
+
* outlive the widget, the turn and the conversation, and keep asking about a
|
|
1424
|
+
* job nobody is looking at. Riding the chat's own ladder means the job stops
|
|
1425
|
+
* being polled exactly when the widget stops existing, which is the
|
|
1426
|
+
* behaviour the acceptance list asks for and the only one this directive can
|
|
1427
|
+
* actually deliver.
|
|
1428
|
+
*
|
|
1429
|
+
* It also costs nothing extra: the ladder is already fast while a turn is in
|
|
1430
|
+
* flight, which is when a job is running. */
|
|
1431
|
+
const readJobState = async (jobId: string): Promise<string> => {
|
|
1432
|
+
if (!o.asyncJobState) return "";
|
|
1433
|
+
try {
|
|
1434
|
+
return (await o.asyncJobState(jobId)) || "";
|
|
1435
|
+
} catch {
|
|
1436
|
+
// A failed read is not a failed job. Saying "failed" on one dropped
|
|
1437
|
+
// request would tell the owner their work was lost when it is still
|
|
1438
|
+
// running; the next tick asks again.
|
|
1439
|
+
return "";
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
/** Advance the active ASYNC_TOOL widget, if there is one with a job to ask
|
|
1444
|
+
* about. Returns the widget unchanged in every other case. */
|
|
1445
|
+
const withJobProgress = async (w: WidgetView): Promise<WidgetView> => {
|
|
1446
|
+
if (w.kind !== "ASYNC_TOOL" || !w.asyncJobId || !w.asyncRunning) return w;
|
|
1447
|
+
const state = await readJobState(w.asyncJobId);
|
|
1448
|
+
if (!state || state === w.asyncState) return w;
|
|
1449
|
+
return asyncAdvanced(w, state);
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1452
|
+
/** Open a widget button's destination.
|
|
1453
|
+
*
|
|
1454
|
+
* Deliberately the SAME path a transcript link takes — `resolveEntity` first
|
|
1455
|
+
* so an entity we can render natively becomes a `_nav` (and gets the host's
|
|
1456
|
+
* linkable-target check), external otherwise. A widget url is no more
|
|
1457
|
+
* trusted than a link inside a reply; both are model output, and giving the
|
|
1458
|
+
* widget its own opener would be a second place to forget that.
|
|
1459
|
+
*
|
|
1460
|
+
* A button with no url does nothing rather than erroring: the descriptor
|
|
1461
|
+
* hides those, so reaching here means the state changed under a tap already
|
|
1462
|
+
* in flight. */
|
|
1463
|
+
/** A widget button's destination is MODEL-INFLUENCED, and reachable by
|
|
1464
|
+
* prompt injection through content this owner does not control. The
|
|
1465
|
+
* transcript's own links are already restricted to `http(s)` by the
|
|
1466
|
+
* extractor that finds them; a widget url arrives straight off the payload
|
|
1467
|
+
* and reached the system opener after nothing but a `trim()`.
|
|
1468
|
+
*
|
|
1469
|
+
* That is a bigger gap than it looks: the opener takes ANY scheme, so a
|
|
1470
|
+
* payload could name `tel:`, `sms:`, a `wix://` deep link, or a custom
|
|
1471
|
+
* scheme registered by some other app on the device. Host disclosure
|
|
1472
|
+
* changes the LABEL; it does not stop the navigation.
|
|
1473
|
+
*
|
|
1474
|
+
* So: an in-app entity first (that path never leaves the app), then
|
|
1475
|
+
* `http(s)` only. Anything else is dropped rather than opened. Dropping is
|
|
1476
|
+
* right rather than cautious — there is no widget in the catalogue whose
|
|
1477
|
+
* button needs a non-web scheme, so anything that asks for one is either a
|
|
1478
|
+
* service change we should implement deliberately or an attack. */
|
|
1479
|
+
const openWidgetUrl = async (s: AriaChatState, url: string): Promise<AriaChatState> => {
|
|
1480
|
+
const target = url.trim();
|
|
1481
|
+
if (!target) return s;
|
|
1482
|
+
const entity = o.resolveEntity?.(target) ?? null;
|
|
1483
|
+
if (entity) return navigate(s, entity);
|
|
1484
|
+
if (!isWebUrl(target)) return s;
|
|
1485
|
+
if (o.openLink) { try { await o.openLink(target); } catch { /* best-effort */ } }
|
|
1486
|
+
return s;
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
/** The value an option action names. Accepts the bare string and the object
|
|
1490
|
+
* form, as every other action here does — the two hosts dispatch differently
|
|
1491
|
+
* and a reducer that only understood one silently did nothing on the other. */
|
|
1492
|
+
/** Which option an action names.
|
|
1493
|
+
*
|
|
1494
|
+
* `optionValue` is read BEFORE `value`, and non-strings are ignored, because
|
|
1495
|
+
* `SelectionControl.onChange` puts its new boolean in `args.value` on iOS
|
|
1496
|
+
* and Android and the native marshaller merges args over params. So a
|
|
1497
|
+
* toggle wired through `value` arrived as `true`/`false` on device while
|
|
1498
|
+
* working on web — and `.trim()` on a boolean THROWS, so the row did not
|
|
1499
|
+
* merely mis-select, it took the action down. Web-only testing cannot see
|
|
1500
|
+
* this; the control's own contract is what gives it away. */
|
|
1501
|
+
const optionValue = (arg?: AriaActionArg): string =>
|
|
1502
|
+
typeof arg === "string" ? arg.trim() : firstString(arg?.optionValue, arg?.value, arg?.id, arg?.text);
|
|
1503
|
+
|
|
1504
|
+
/** Re-derive everything that follows from which options are selected.
|
|
1505
|
+
*
|
|
1506
|
+
* One function rather than three call sites, because `limitReached`,
|
|
1507
|
+
* `primaryDisabled` and the helper line are each a pure function of the
|
|
1508
|
+
* selection and the payload — and a toggle that updated two of the three is
|
|
1509
|
+
* the shape of bug that leaves a button enabled over an empty selection. */
|
|
1510
|
+
const reselect = (w: WidgetView, options: WidgetOptionRow[]): WidgetView => {
|
|
1511
|
+
const chosen = options.filter((o) => o.selected).length;
|
|
1512
|
+
const limitReached = w.maxSelected > 0 && chosen >= w.maxSelected;
|
|
1513
|
+
// Only says anything once the owner is actually against the limit. A line
|
|
1514
|
+
// that is always there is noise; one that appears at the moment of refusal
|
|
1515
|
+
// explains the refusal.
|
|
1516
|
+
const limitText = limitReached ? limitReachedText(w.maxSelected) : "";
|
|
1517
|
+
const disabled = w.submitting || (w.requiresSelection && chosen === 0);
|
|
1518
|
+
return {
|
|
1519
|
+
...w,
|
|
1520
|
+
options,
|
|
1521
|
+
limitReached,
|
|
1522
|
+
limitText,
|
|
1523
|
+
limitTextHidden: !limitText,
|
|
1524
|
+
primaryDisabled: disabled,
|
|
1525
|
+
primaryEnabled: !disabled,
|
|
1526
|
+
};
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1237
1529
|
const impl: AriaChatCore = {
|
|
1238
1530
|
initialState,
|
|
1239
1531
|
|
|
@@ -1259,7 +1551,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1259
1551
|
const bubbles = toBubbles(raw, assistant(), you());
|
|
1260
1552
|
const resumed = bubbles.length ? bubbles : greetingOnly();
|
|
1261
1553
|
return { ...s, status: "ready", _convId: resumeId, _siteId: site, _pending: "", statusText: "", error: "",
|
|
1262
|
-
messages: resumed, ...observed(raw), prompts: promptsFor(resumed, promptList()) };
|
|
1554
|
+
messages: resumed, ...observed(raw, s.widget), prompts: promptsFor(resumed, promptList()) };
|
|
1263
1555
|
} catch { /* expired or unreachable — fall through to a fresh one */ }
|
|
1264
1556
|
}
|
|
1265
1557
|
// Opening a screen runs `load` more than once — the bundle self-kicks at
|
|
@@ -1301,7 +1593,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1301
1593
|
},
|
|
1302
1594
|
|
|
1303
1595
|
typing: (s, arg) => {
|
|
1304
|
-
|
|
1596
|
+
// Narrowed but NOT trimmed. `value` may be a boolean — assigning that
|
|
1597
|
+
// to the draft would put "true" in the box — but this runs on every
|
|
1598
|
+
// keystroke, so trimming would delete the space between two words as
|
|
1599
|
+
// soon as the owner typed it.
|
|
1600
|
+
const next = typeof arg === "string" ? arg : firstStringRaw(arg?.value, arg?.text, arg?.message);
|
|
1305
1601
|
// Recorded even when the value is unchanged, because the counter is about
|
|
1306
1602
|
// "did an edit happen", and a keystroke that retypes the same character
|
|
1307
1603
|
// still happened.
|
|
@@ -1419,12 +1715,16 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1419
1715
|
status: replied ? "ready" : s.status,
|
|
1420
1716
|
messages: painted,
|
|
1421
1717
|
_pending: pending,
|
|
1422
|
-
...observed(raw),
|
|
1718
|
+
...observed(raw, s.widget),
|
|
1423
1719
|
prompts: promptsFor(painted, promptList()),
|
|
1424
1720
|
statusText: replied ? "" : s.statusText,
|
|
1425
1721
|
};
|
|
1722
|
+
// The async job rides this same tick — see `withJobProgress`. Done
|
|
1723
|
+
// AFTER the transcript rebuild, so a widget that has just been retired
|
|
1724
|
+
// by a new answer is never polled for.
|
|
1725
|
+
const withJob = { ...next, widget: await withJobProgress(next.widget) };
|
|
1426
1726
|
// Slow the ladder the moment the turn is over; leave it alone otherwise.
|
|
1427
|
-
return replied && wasFast ? cadence(
|
|
1727
|
+
return replied && wasFast ? cadence(withJob, o.poll.idleMs, o.poll.fastMs) : withJob;
|
|
1428
1728
|
} catch {
|
|
1429
1729
|
// A read that THREW is exactly as stale as one that resolved: if a turn
|
|
1430
1730
|
// boundary moved on while it was in flight, returning `s` merges the
|
|
@@ -1439,6 +1739,153 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1439
1739
|
// A tapped action link. An entity we can render natively becomes a `_nav`
|
|
1440
1740
|
// directive (the host applies the same linkable-target check an authored
|
|
1441
1741
|
// `himi.open` gets); anything else opens externally.
|
|
1742
|
+
widgetPick: (s, arg) =>
|
|
1743
|
+
s.widget.selectionType === "multi" ? impl.widgetToggle(s, arg) : impl.widgetSelectOne(s, arg),
|
|
1744
|
+
|
|
1745
|
+
widgetQuery: (s, arg) => {
|
|
1746
|
+
const q = typeof arg === "string" ? arg.trim() : firstString(arg?.value, arg?.text);
|
|
1747
|
+
const w = s.widget;
|
|
1748
|
+
const needle = q.toLowerCase();
|
|
1749
|
+
// Match on everything the owner can SEE. Matching only `title` makes a
|
|
1750
|
+
// subtitle that says "Downtown branch" unsearchable, which reads as the
|
|
1751
|
+
// filter being broken rather than as a deliberate scope.
|
|
1752
|
+
const suggestions = !needle
|
|
1753
|
+
? w.options
|
|
1754
|
+
: w.options.filter((o) =>
|
|
1755
|
+
`${o.title} ${o.subtitle} ${o.text}`.toLowerCase().includes(needle));
|
|
1756
|
+
return {
|
|
1757
|
+
...s,
|
|
1758
|
+
widget: {
|
|
1759
|
+
...w,
|
|
1760
|
+
query: q,
|
|
1761
|
+
suggestions,
|
|
1762
|
+
suggestionsEmpty: suggestions.length === 0,
|
|
1763
|
+
// Typed something and got nothing is a different state from having
|
|
1764
|
+
// typed nothing at all, and says something different to the owner.
|
|
1765
|
+
noMatches: !!needle && suggestions.length === 0,
|
|
1766
|
+
},
|
|
1767
|
+
};
|
|
1768
|
+
},
|
|
1769
|
+
|
|
1770
|
+
widgetAnswer: (s, arg) => {
|
|
1771
|
+
const qid = (typeof arg === "string" ? arg : (arg?.id ?? "")).trim();
|
|
1772
|
+
const answer = typeof arg === "string" ? "" : firstString(arg?.value, arg?.text);
|
|
1773
|
+
if (!qid) return s;
|
|
1774
|
+
const questions = s.widget.questions.map((q) =>
|
|
1775
|
+
q.id === qid
|
|
1776
|
+
? { ...q, answer, answerHidden: !answer, missing: q.required && !answer }
|
|
1777
|
+
: q);
|
|
1778
|
+
if (questions === s.widget.questions) return s;
|
|
1779
|
+
const incomplete = questions.some((q) => q.missing);
|
|
1780
|
+
return {
|
|
1781
|
+
...s,
|
|
1782
|
+
widget: {
|
|
1783
|
+
...s.widget,
|
|
1784
|
+
questions,
|
|
1785
|
+
answersIncomplete: incomplete,
|
|
1786
|
+
// Only says anything while something is actually missing.
|
|
1787
|
+
answersText: incomplete ? answersIncompleteText() : "",
|
|
1788
|
+
answersTextHidden: !incomplete,
|
|
1789
|
+
primaryDisabled: s.widget.submitting || incomplete,
|
|
1790
|
+
primaryEnabled: !(s.widget.submitting || incomplete),
|
|
1791
|
+
},
|
|
1792
|
+
};
|
|
1793
|
+
},
|
|
1794
|
+
|
|
1795
|
+
widgetToggle: (s, arg) => {
|
|
1796
|
+
const value = optionValue(arg);
|
|
1797
|
+
if (!value) return s;
|
|
1798
|
+
const current = s.widget.options;
|
|
1799
|
+
const target = current.find((o) => o.value === value);
|
|
1800
|
+
if (!target) return s;
|
|
1801
|
+
// Turning one OFF is always allowed, limit or not: the limit is a ceiling
|
|
1802
|
+
// on how many may be chosen, never a floor, and refusing to deselect
|
|
1803
|
+
// would trap an owner who hit it.
|
|
1804
|
+
if (!target.selected && s.widget.limitReached) return s;
|
|
1805
|
+
const options = current.map((o) => (o.value === value ? { ...o, selected: !o.selected } : o));
|
|
1806
|
+
return { ...s, widget: reselect(s.widget, options) };
|
|
1807
|
+
},
|
|
1808
|
+
|
|
1809
|
+
widgetSelectOne: (s, arg) => {
|
|
1810
|
+
const value = optionValue(arg);
|
|
1811
|
+
if (!value) return s;
|
|
1812
|
+
if (!s.widget.options.some((o) => o.value === value)) return s;
|
|
1813
|
+
// Replaces rather than accumulates, and the limit does not apply: one is
|
|
1814
|
+
// never more than a ceiling of one or more.
|
|
1815
|
+
const options = s.widget.options.map((o) => ({ ...o, selected: o.value === value }));
|
|
1816
|
+
return { ...s, widget: reselect(s.widget, options) };
|
|
1817
|
+
},
|
|
1818
|
+
|
|
1819
|
+
widgetRespond: async (s, arg) => {
|
|
1820
|
+
// Which button. The three that can send are the widget's two footer
|
|
1821
|
+
// buttons and any one option's own button; an option names itself by
|
|
1822
|
+
// value, the footers by keyword.
|
|
1823
|
+
const which = optionValue(arg) || "primary";
|
|
1824
|
+
const w = s.widget;
|
|
1825
|
+
// A second tap while the first send is in the air. The composer refuses
|
|
1826
|
+
// this through `isDuplicateSend` on the CONTENT; a widget button has to
|
|
1827
|
+
// refuse on the ACTION too, because two taps of the same option produce
|
|
1828
|
+
// identical content and the second would otherwise look like a retry of
|
|
1829
|
+
// a send that had already failed.
|
|
1830
|
+
if (widgetSending) return s;
|
|
1831
|
+
|
|
1832
|
+
const option = w.options.find((o) => o.value === which);
|
|
1833
|
+
const template = option
|
|
1834
|
+
? option.respondText
|
|
1835
|
+
: which === "secondary" ? w.secondaryRespondText : w.primaryRespondText;
|
|
1836
|
+
if (!template) return s;
|
|
1837
|
+
|
|
1838
|
+
// What the owner actually chose. An option's own button answers with
|
|
1839
|
+
// ITSELF even when nothing is ticked — tapping "Select" on a row IS the
|
|
1840
|
+
// selection — while a footer button speaks for the accumulated set.
|
|
1841
|
+
const selected = option && !option.selected
|
|
1842
|
+
? [option.title || option.value]
|
|
1843
|
+
: w.options.filter((o) => o.selected).map((o) => o.title || o.value);
|
|
1844
|
+
const content = substituteSelection(template, selected).trim();
|
|
1845
|
+
if (!content) return s;
|
|
1846
|
+
if (duplicate(s, content)) return s;
|
|
1847
|
+
|
|
1848
|
+
// Mark in-flight BEFORE the await so a second dispatch sees it. The
|
|
1849
|
+
// widget is not cleared here: `send` paints the owner's message
|
|
1850
|
+
// optimistically and the next transcript rebuild derives the widget
|
|
1851
|
+
// afresh, which retires it. Clearing it here as well would be a second
|
|
1852
|
+
// place deciding when a widget ends.
|
|
1853
|
+
const sending: AriaChatState = {
|
|
1854
|
+
...s,
|
|
1855
|
+
widget: { ...w, submitting: true, primaryDisabled: true, primaryEnabled: false },
|
|
1856
|
+
};
|
|
1857
|
+
widgetSending = true;
|
|
1858
|
+
let next: AriaChatState;
|
|
1859
|
+
try {
|
|
1860
|
+
next = await send(sending, content);
|
|
1861
|
+
} finally {
|
|
1862
|
+
widgetSending = false;
|
|
1863
|
+
}
|
|
1864
|
+
// A FAILED send keeps the widget and the selection, so the owner can tap
|
|
1865
|
+
// again rather than lose what they picked. `send` reports failure through
|
|
1866
|
+
// the error slot while leaving the turn un-started.
|
|
1867
|
+
return next.error
|
|
1868
|
+
? { ...next, widget: reselect({ ...w, submitting: false }, w.options) }
|
|
1869
|
+
: next;
|
|
1870
|
+
},
|
|
1871
|
+
|
|
1872
|
+
// A footer button is EITHER a link or a reply, and the view makes it
|
|
1873
|
+
// visible for both: `primaryHidden` is false when there is a label and
|
|
1874
|
+
// either a url or a `respondText`. Opening the url unconditionally
|
|
1875
|
+
// therefore made every response-only footer inert — the whole of
|
|
1876
|
+
// VISUAL_DISPLAY, TODO_PLAN and PREMIUM_UPSELL, which is most of what
|
|
1877
|
+
// these two buttons are for.
|
|
1878
|
+
//
|
|
1879
|
+
// The url wins when both are present: a button that names a destination
|
|
1880
|
+
// should go there, and `respondText` beside it is the fallback the
|
|
1881
|
+
// service sends for clients that cannot open one.
|
|
1882
|
+
widgetPrimary: (s) => s.widget.primaryUrl
|
|
1883
|
+
? openWidgetUrl(s, s.widget.primaryUrl)
|
|
1884
|
+
: impl.widgetRespond(s, { value: "primary" }),
|
|
1885
|
+
widgetSecondary: (s) => s.widget.secondaryUrl
|
|
1886
|
+
? openWidgetUrl(s, s.widget.secondaryUrl)
|
|
1887
|
+
: impl.widgetRespond(s, { value: "secondary" }),
|
|
1888
|
+
|
|
1442
1889
|
openLink: async (s, arg) => {
|
|
1443
1890
|
const url = typeof arg === "string" ? arg.trim() : (arg?.url ?? arg?.id ?? "").trim();
|
|
1444
1891
|
if (!url) return s;
|
|
@@ -1525,6 +1972,16 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1525
1972
|
tick: (s) => impl.tick(s).then((r) => (r ? gated(r) : r)),
|
|
1526
1973
|
openLink: (s, arg) => impl.openLink(s, arg).then(gated),
|
|
1527
1974
|
stop: (s) => impl.stop(s).then((r) => (r ? gated(r) : r)),
|
|
1975
|
+
// Synchronous, like `typing`: a selection edit makes no network call, so
|
|
1976
|
+
// gating it is a plain call rather than a `.then`.
|
|
1977
|
+
widgetToggle: (s, arg) => gated(impl.widgetToggle(s, arg)),
|
|
1978
|
+
widgetSelectOne: (s, arg) => gated(impl.widgetSelectOne(s, arg)),
|
|
1979
|
+
widgetPick: (s, arg) => gated(impl.widgetPick(s, arg)),
|
|
1980
|
+
widgetQuery: (s, arg) => gated(impl.widgetQuery(s, arg)),
|
|
1981
|
+
widgetAnswer: (s, arg) => gated(impl.widgetAnswer(s, arg)),
|
|
1982
|
+
widgetRespond: (s, arg) => impl.widgetRespond(s, arg).then(gated),
|
|
1983
|
+
widgetPrimary: (s) => impl.widgetPrimary(s).then(gated),
|
|
1984
|
+
widgetSecondary: (s) => impl.widgetSecondary(s).then(gated),
|
|
1528
1985
|
};
|
|
1529
1986
|
}
|
|
1530
1987
|
|