@wix/himalaya-cli 0.1306.0 → 0.1307.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.
Files changed (50) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/wix-gateway.rules.d.ts +5 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/genie-chat/src/index.d.ts +63 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-genie-chat.mjs +81 -0
  4. package/dist/cli.mjs +116 -3
  5. package/guides/sdk-reference.json +36 -0
  6. package/package.json +2 -2
  7. package/templates/beauty-salon-owner/dev/index.ts +15 -27
  8. package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +293 -12
  9. package/templates/beauty-salon-owner/tier5-src/_shared/aria-stream.ts +165 -0
  10. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +26 -2
  11. package/templates/community-club-owner/dev/index.ts +15 -27
  12. package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +293 -12
  13. package/templates/community-club-owner/tier5-src/_shared/aria-stream.ts +165 -0
  14. package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +26 -2
  15. package/templates/events-meetups-owner/dev/index.ts +15 -27
  16. package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +293 -12
  17. package/templates/events-meetups-owner/tier5-src/_shared/aria-stream.ts +165 -0
  18. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +26 -2
  19. package/templates/gym-fitness-owner/dev/index.ts +15 -27
  20. package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +293 -12
  21. package/templates/gym-fitness-owner/tier5-src/_shared/aria-stream.ts +165 -0
  22. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +26 -2
  23. package/templates/nonprofit-cause-owner/dev/index.ts +15 -27
  24. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +293 -12
  25. package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-stream.ts +165 -0
  26. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +26 -2
  27. package/templates/owner-lite/dev/index.ts +15 -27
  28. package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +293 -12
  29. package/templates/owner-lite/tier5-src/_shared/aria-stream.ts +165 -0
  30. package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +26 -2
  31. package/templates/podcast-creator-owner/dev/index.ts +15 -27
  32. package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +293 -12
  33. package/templates/podcast-creator-owner/tier5-src/_shared/aria-stream.ts +165 -0
  34. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +26 -2
  35. package/templates/portfolio-creative-owner/dev/index.ts +15 -27
  36. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +293 -12
  37. package/templates/portfolio-creative-owner/tier5-src/_shared/aria-stream.ts +165 -0
  38. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +26 -2
  39. package/templates/restaurant-owner/dev/index.ts +15 -27
  40. package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +293 -12
  41. package/templates/restaurant-owner/tier5-src/_shared/aria-stream.ts +165 -0
  42. package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +26 -2
  43. package/templates/retail-store-owner/dev/index.ts +15 -27
  44. package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +293 -12
  45. package/templates/retail-store-owner/tier5-src/_shared/aria-stream.ts +165 -0
  46. package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +26 -2
  47. package/templates/yoga-studio-owner/dev/index.ts +15 -27
  48. package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +293 -12
  49. package/templates/yoga-studio-owner/tier5-src/_shared/aria-stream.ts +165 -0
  50. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +26 -2
@@ -34,6 +34,8 @@ import { alert } from "@himalaya/ui";
34
34
  import {
35
35
  activeSiteId,
36
36
  createAriaChat,
37
+ authoredRealtimeUrl,
38
+ stashRealtimeUrl,
37
39
  type AriaActionArg,
38
40
  type AriaChatState,
39
41
  type AriaDictationResult,
@@ -53,6 +55,10 @@ const SERVICE = "himiOwnerLiteAria";
53
55
  // clone's blocking loop, which paints nothing for the length of a turn.
54
56
  const FAST_MS = 2000;
55
57
  const IDLE_MS = 15000;
58
+ // While the realtime stream is delivering a reply, the transcript is only read to
59
+ // CONFIRM the turn ended — the words come from the stream — so the ladder runs at
60
+ // this slower rate. A whole interval with no frame drops it back to FAST_MS.
61
+ const STREAM_MS = 6000;
56
62
 
57
63
  function prompts(): AriaPromptChip[] {
58
64
  return [
@@ -250,7 +256,13 @@ const core = createAriaChat({
250
256
  const screenId = screenForUrl(url, allowedTargets());
251
257
  return screenId ? { screenId } : null;
252
258
  },
253
- poll: { action: `${SERVICE}.tick`, fastMs: FAST_MS, idleMs: IDLE_MS },
259
+ poll: { action: `${SERVICE}.tick`, fastMs: FAST_MS, idleMs: IDLE_MS, streamMs: STREAM_MS },
260
+ // The realtime stream: the reply's words as they are written, plus the quick
261
+ // replies that follow it. Only where the screen was built with a stream url
262
+ // (authored onAppear load params); everywhere else — and wherever the host cannot
263
+ // open it — the turn is polled exactly as before. The screen must declare
264
+ // "stream" for the host to open it at all.
265
+ stream: { url: authoredRealtimeUrl },
254
266
  // Composer dictation, on the platform recognizer: SFSpeechRecognizer,
255
267
  // Android SpeechRecognizer, Web Speech. No audio crosses the bridge — only the
256
268
  // final transcript. An empty language means the device's own locale, which is
@@ -319,6 +331,12 @@ const svc = defineService<AriaChatState, {
319
331
  finishDictation: (s: AriaChatState) => AriaChatState;
320
332
  // `undefined` for a result whose session is no longer the live one — see the core.
321
333
  dictated: (s: AriaChatState, arg?: AriaDictationResult) => AriaChatState | undefined;
334
+ // The realtime stream's callbacks, named by the `_stream` directive the core
335
+ // returns from a send — never by the descriptor. `undefined` from
336
+ // `streamEvent` for the same reason `tick` has it.
337
+ streamEvent: (s: AriaChatState, event?: unknown) => Promise<AriaChatState | undefined>;
338
+ streamEnded: (s: AriaChatState) => AriaChatState;
339
+ streamFailed: (s: AriaChatState, err?: unknown) => AriaChatState;
322
340
  mentionOpen: (s: AriaChatState) => Promise<AriaChatState | undefined>;
323
341
  mentionClose: (s: AriaChatState) => AriaChatState;
324
342
  mentionBack: (s: AriaChatState) => AriaChatState;
@@ -334,7 +352,7 @@ const svc = defineService<AriaChatState, {
334
352
  state: core.initialState,
335
353
  actions: {
336
354
  // `load` is the onAppear dispatch, so it is where the copy pack arrives.
337
- load: (s, arg) => { stashCopy(arg); stashTargets(arg); return core.load(s); },
355
+ load: (s, arg) => { stashCopy(arg); stashTargets(arg); stashRealtimeUrl(arg); return core.load(s); },
338
356
  submit: core.submit,
339
357
  submitPrompt: core.submitPrompt,
340
358
  newChat: core.newChat,
@@ -391,6 +409,12 @@ const svc = defineService<AriaChatState, {
391
409
  attachmentSettled: core.attachmentSettled,
392
410
  removeAttachment: core.removeAttachment,
393
411
  retryAttachment: core.retryAttachment,
412
+ // Dispatched by the HOST as the stream delivers, closes or fails. A name
413
+ // the entry does not export is a callback the host silently drops, and the
414
+ // turn would then be polled with nobody noticing the stream was ignored.
415
+ streamEvent: core.streamEvent,
416
+ streamEnded: core.streamEnded,
417
+ streamFailed: core.streamFailed,
394
418
  },
395
419
  });
396
420
 
@@ -56523,7 +56523,8 @@ const SCREENS = runtimeSetting({
56523
56523
  "props": {
56524
56524
  "text": "$item.content",
56525
56525
  "style": "body",
56526
- "maxLines": 50
56526
+ "maxLines": 50,
56527
+ "animateReveal": true
56527
56528
  }
56528
56529
  },
56529
56530
  "modifiers": {
@@ -57031,7 +57032,7 @@ const SCREENS = runtimeSetting({
57031
57032
  }
57032
57033
  },
57033
57034
  {
57034
- "id": "himi_owner_lite_aria.stop",
57035
+ "id": "",
57035
57036
  "body": {
57036
57037
  "package": "himalaya",
57037
57038
  "type": "HStack",
@@ -57047,30 +57048,19 @@ const SCREENS = runtimeSetting({
57047
57048
  }
57048
57049
  },
57049
57050
  {
57050
- "id": "",
57051
- "body": {
57052
- "package": "himalaya",
57053
- "type": "Icon",
57054
- "props": {
57055
- "name": "close",
57056
- "size": 14,
57057
- "tint": "accentPrimary"
57058
- }
57059
- },
57060
- "modifiers": {
57061
- "accessibilityRole": "decorative"
57062
- }
57063
- },
57064
- {
57065
- "id": "",
57051
+ "id": "himi_owner_lite_aria.stop",
57066
57052
  "body": {
57067
57053
  "package": "himalaya",
57068
- "type": "Text",
57054
+ "type": "Button",
57069
57055
  "props": {
57070
- "text": "Stop",
57071
- "style": "footnote",
57072
- "color": "accentPrimary",
57073
- "maxLines": 1
57056
+ "title": "Stop",
57057
+ "style": "link",
57058
+ "size": "sm",
57059
+ "leadingIcon": "close",
57060
+ "fullWidth": false,
57061
+ "action": {
57062
+ "actionId": "tier5.himiOwnerLiteAria.stop"
57063
+ }
57074
57064
  }
57075
57065
  }
57076
57066
  }
@@ -57084,9 +57074,6 @@ const SCREENS = runtimeSetting({
57084
57074
  "top": 4,
57085
57075
  "bottom": 0
57086
57076
  },
57087
- "onTap": {
57088
- "actionId": "tier5.himiOwnerLiteAria.stop"
57089
- },
57090
57077
  "hiddenPath": "$service:himiOwnerLiteAria.stopHidden"
57091
57078
  }
57092
57079
  },
@@ -60814,7 +60801,8 @@ const SCREENS = runtimeSetting({
60814
60801
  "ui",
60815
60802
  "speech",
60816
60803
  "camera",
60817
- "presigned_upload"
60804
+ "presigned_upload",
60805
+ "stream"
60818
60806
  ],
60819
60807
  "minBinaryVersion": "0.1.0",
60820
60808
  "bundleVersion": "0.0.1"
@@ -24,7 +24,7 @@
24
24
  // This file is copied verbatim into every generated starter app, so it explains
25
25
  // itself without pointing at anything only this repo has.
26
26
 
27
- import { navigate, type NavDirective, type PollDirective } from "@himalaya/state";
27
+ import { navigate, type NavDirective, type PollDirective, type StreamDirective } from "@himalaya/state";
28
28
  import {
29
29
  ARIA_ORIGIN,
30
30
  abortAssistantMessageGeneration,
@@ -90,6 +90,7 @@ import { MENTION_PAGE_SIZE, allowlisted, emptyMentionModel, mentionView,
90
90
  type MentionItemRow, type MentionLabels, type MentionModel, type MentionProvider, type MentionView } from "./aria-mentions.js";
91
91
  import { isTransportFailure, ownerTransportFailureText } from "./owner-load-error.js";
92
92
  import { LOCAL_ID_PREFIX, createRowMemo, mintLocalId, transcriptKeys, type RowMemo } from "./aria-hot-path.js";
93
+ import { createLiveReply } from "./aria-stream.js";
93
94
  import { calendarDay, formatClock, formatDateMedium, formatMonthDay } from "./engine-intl.js";
94
95
 
95
96
  // ---------------------------------------------------------------------------
@@ -280,13 +281,35 @@ export interface AriaChatState {
280
281
  attachments: AriaAttachmentChip[];
281
282
  /** Collapses the composer's attachment strip while it is empty. */
282
283
  attachmentsHidden: boolean;
283
- /** Transient host directives — a poll cadence to install, and a navigation to
284
- * perform. The runtime strips both before storing the state, so they are
285
- * never persisted and never re-fire when the state is spread. */
284
+ /** The quick replies the service suggested after the LATEST turn — short
285
+ * answers the owner can send with one tap. They arrive only on the realtime
286
+ * stream, a moment after the reply finishes, so a screen with no stream never
287
+ * has any. A projection like `attachments`: painted on the way out of every
288
+ * action, and empty again the moment a new turn is sent. */
289
+ quickReplies: AriaQuickReply[];
290
+ /** Collapses a quick-reply strip while there is nothing to offer. */
291
+ quickRepliesHidden: boolean;
292
+ /** Where the realtime stream for the current turn is: `off` (no stream was
293
+ * opened — the screen has none configured), `open` (asked for, nothing
294
+ * heard yet), `live` (frames are arriving) or `down` (it closed or failed;
295
+ * the poll is carrying the turn). Diagnostic — nothing binds it. */
296
+ _realtime: AriaRealtime;
297
+ /** Transient host directives — a poll cadence to install, a stream to open,
298
+ * and a navigation to perform. The runtime strips all three before storing
299
+ * the state, so they are never persisted and never re-fire when the state is
300
+ * spread. */
286
301
  _poll?: PollDirective;
302
+ _stream?: StreamDirective;
287
303
  _nav?: NavDirective;
288
304
  }
289
305
 
306
+ /** One suggested reply, flattened for a `$item.` row: `label` is the chip,
307
+ * `reply` the text a tap sends, `id` a stable row id within the strip. */
308
+ export interface AriaQuickReply { id: string; label: string; reply: string; }
309
+
310
+ /** See `AriaChatState._realtime`. */
311
+ export type AriaRealtime = "off" | "open" | "live" | "down";
312
+
290
313
  /** Cadence for the reply ladder. Absent ⇒ the legacy in-action busy loop. */
291
314
  export interface AriaPollOptions {
292
315
  /** Fully-qualified `"<service>.tick"`. A name that does not resolve to a real
@@ -300,6 +323,26 @@ export interface AriaPollOptions {
300
323
  * re-armed slower instead — a visible Aria screen keeps ONE low-rate GET
301
324
  * alive until the screen is backgrounded, pooled away or torn down. */
302
325
  idleMs: number;
326
+ /** While the realtime stream is DELIVERING the reply. The words come from the
327
+ * stream then, and the transcript is only read to confirm the turn ended, so
328
+ * the ladder can run slower than `fastMs`. A whole interval with no frame is a
329
+ * stalled stream, and the ladder drops back to `fastMs`. Absent ⇒ the ladder
330
+ * stays at `fastMs` throughout. */
331
+ streamMs?: number;
332
+ }
333
+
334
+ /** The realtime stream (the `_stream` directive). Omit it and the
335
+ * conversation machine polls exactly as it always has. */
336
+ export interface AriaStreamOptions {
337
+ /** Base url of an SSE stream that re-emits the chat-service's side-effect
338
+ * frames, one `data:` event per frame (see `decodeSideEffect` in the
339
+ * genie-chat client). Read at SEND time; an empty string means "no stream
340
+ * here", and the turn is polled. The worker appends `conversationId` and, when
341
+ * the service named the turn, `userMessageId`. */
342
+ url: () => string;
343
+ /** Ask the host to attach its Wix credentials to the open request using the platform's
344
+ * `authorize`). Only for a url that should receive them. */
345
+ authorize?: boolean;
303
346
  }
304
347
 
305
348
  /** A value or a thunk for it. User-facing copy is read through `copyParam` at
@@ -506,6 +549,10 @@ export interface AriaChatOptions {
506
549
  * authored `himi.open` gets — and a miss falls through to `openLink`. */
507
550
  resolveEntity?: (url: string) => NavDirective | null;
508
551
  poll?: AriaPollOptions;
552
+ /** The realtime stream. Meaningful only with `poll`: the stream paints the
553
+ * reply as it is written, and the poll stays the thing that ends the turn —
554
+ * and the whole of it wherever no stream can be opened. */
555
+ stream?: AriaStreamOptions;
509
556
  /** Mirror conversation titles into `himalaya.storage` as an offline fallback
510
557
  * for the history screen. Default true (the clone's behaviour).
511
558
  *
@@ -619,8 +666,7 @@ const PLAIN_HOST = /^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*|\[[0-9a-f:.]+\])$/;
619
666
  * treats `\` exactly like `/`. Without that, `https://evil.example\@wix.com/`
620
667
  * reads as `wix.com` here while every browser opens `evil.example` with the
621
668
  * path `/@wix.com/`. That is exactly the lie this disclosure exists to
622
- * prevent, so the separator set has to match the URL standard's rather than
623
- * RFC 3986's.
669
+ * prevent, so the separators have to match what browsers use when parsing web addresses.
624
670
  *
625
671
  * A string that survives parsing but is not a plain hostname returns "", which
626
672
  * callers must treat as untrusted rather than as "there is no host here". */
@@ -1237,6 +1283,16 @@ export function inputConversationId(): string {
1237
1283
  return (input?.conversationId ?? input?.id ?? "").trim();
1238
1284
  }
1239
1285
 
1286
+ // Authored onAppear parameters supply the endpoint. Navigation input is untrusted:
1287
+ // a deep link must never choose where the assistant sends turn identifiers.
1288
+ let realtimeUrl = "";
1289
+ export function stashRealtimeUrl(arg: unknown): void {
1290
+ const v = arg && typeof arg === "object" ? (arg as Record<string, unknown>).ariaRealtimeUrl : undefined;
1291
+ const url = typeof v === "string" ? v.trim() : "";
1292
+ realtimeUrl = /^https?:\/\/[^\s]+\/v1\/aria\/realtime$/i.test(url) ? url : "";
1293
+ }
1294
+ export function authoredRealtimeUrl(): string { return realtimeUrl; }
1295
+
1240
1296
  // ---------------------------------------------------------------------------
1241
1297
  // The chat core
1242
1298
  // ---------------------------------------------------------------------------
@@ -1362,9 +1418,24 @@ export interface AriaChatCore {
1362
1418
  removeAttachment(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1363
1419
  /** Re-send a failed chip's bytes from the copy it already holds. */
1364
1420
  retryAttachment(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
1421
+ /** One event of the realtime stream, dispatched by the HOST (the `_stream`
1422
+ * directive's `onEvent`). Never named by a descriptor. Resolves to
1423
+ * `undefined` — leave the state untouched — when the transcript read a
1424
+ * completion triggers went stale across a turn boundary, on `tick`'s terms. */
1425
+ streamEvent(s: AriaChatState, event?: unknown): Promise<AriaChatState | undefined>;
1426
+ /** The stream closed (`onDone`) or failed (`onError`). Either way the poll
1427
+ * carries the rest of the turn, at the full rate. */
1428
+ streamEnded(s: AriaChatState): AriaChatState;
1429
+ streamFailed(s: AriaChatState, err?: unknown): AriaChatState;
1365
1430
  }
1366
1431
 
1367
1432
  const POLL_ID = "aria-turn";
1433
+ /** The one realtime stream a screen holds. A new turn opens it again under the
1434
+ * SAME id, which is what closes the previous turn's: the host supersedes a
1435
+ * stream by id, and the worker has no other way to close one. */
1436
+ const STREAM_ID = "aria-realtime";
1437
+ /** Prefix of the id of a reply bubble that so far exists only on the stream. */
1438
+ const LIVE_ID_PREFIX = "live-";
1368
1439
  /** What Regenerate sends. Ordinary user text, matching the native Aria app —
1369
1440
  * the chat-service has no retry verb, and inventing one here would be a
1370
1441
  * protocol we then had to keep. */
@@ -1549,6 +1620,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
1549
1620
  listening: false, micHidden: !o.dictation, listeningHidden: true,
1550
1621
  widget: NO_WIDGET, mention: mentionViewNow,
1551
1622
  attachments: [], attachmentsHidden: true,
1623
+ quickReplies: [], quickRepliesHidden: true, _realtime: "off",
1552
1624
  };
1553
1625
 
1554
1626
  /** The composer's photos — the ONE copy. See `AriaChatState.attachments` for
@@ -2187,6 +2259,111 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2187
2259
  return { ...s, _poll: { id: POLL_ID, intervalMs: next, action: o.poll.action, immediate: false } };
2188
2260
  };
2189
2261
 
2262
+ // -------------------------------------------------------------------------
2263
+ // The realtime stream
2264
+ // -------------------------------------------------------------------------
2265
+ //
2266
+ // The stream paints the reply while it is being written; the poll confirms it
2267
+ // and ends the turn. Both views of the reply are kept, and which one is shown
2268
+ // is decided on the way out of every action (`withLive`, from `gated`), not by
2269
+ // the reducer that happened to run: a tick that read its snapshot before a
2270
+ // chunk arrived would otherwise paint the shorter text back over the longer.
2271
+ // That is the same latch-and-paint pattern the mention picker and the
2272
+ // attachment chips use, for the same reason.
2273
+
2274
+ /** What the stream has said about the current turn. Not persisted: a worker
2275
+ * that is torn down mid-turn comes back to the poll alone. */
2276
+ const live = createLiveReply();
2277
+ /** See `AriaChatState._realtime`. A latch, painted by `gated`. */
2278
+ let realtime: AriaRealtime = "off";
2279
+ /** The ladder is running at `poll.streamMs` because the stream is delivering.
2280
+ * Cleared when the stream stalls, fails or closes, or the turn ends. */
2281
+ let slowed = false;
2282
+ /** The live bubble last built, so a stream delivering at 20-30 frames a second
2283
+ * rebuilds it only when its text actually changed — the same rule the
2284
+ * transcript memo follows (`aria-hot-path.ts` rule 1). */
2285
+ let liveBubble: { key: string; bubble: AriaBubble | null } | null = null;
2286
+ /** The quick replies last painted, so an unchanged list keeps its identity and
2287
+ * a reducer that changed nothing still returns the SAME state object. */
2288
+ let paintedReplies: AriaQuickReply[] = [];
2289
+
2290
+ /** Ask the host to open the stream for a turn that was just sent, and reset
2291
+ * the live model to it. `turnId` may be empty (a service that named no id);
2292
+ * every frame of the conversation is then taken as this turn's. */
2293
+ const openRealtime = (s: AriaChatState, convId: string, turnId: string): AriaChatState => {
2294
+ live.reset(convId, turnId);
2295
+ slowed = false;
2296
+ liveBubble = null;
2297
+ const base = (o.stream?.url() ?? "").trim();
2298
+ if (!o.stream || !o.poll || !base || !convId) {
2299
+ realtime = "off";
2300
+ return s;
2301
+ }
2302
+ realtime = "open";
2303
+ const query = `conversationId=${encodeURIComponent(convId)}${turnId ? `&userMessageId=${encodeURIComponent(turnId)}` : ""}`;
2304
+ const request: StreamDirective["request"] & { authorize?: boolean } = {
2305
+ method: "GET",
2306
+ url: `${base}${base.includes("?") ? "&" : "?"}${query}`,
2307
+ headers: { Accept: "text/event-stream" },
2308
+ ...(o.stream.authorize ? { authorize: true } : {}),
2309
+ };
2310
+ return { ...s, _stream: { id: STREAM_ID, request, onEvent: "streamEvent", onDone: "streamEnded", onError: "streamFailed" } };
2311
+ };
2312
+
2313
+ /** Does the live model describe the turn `s` is showing? */
2314
+ const liveIsFor = (s: AriaChatState): boolean =>
2315
+ realtime !== "off" && !!s._convId && live.conversationId === s._convId && live.turnId === s._turnId;
2316
+
2317
+ /** The reply as the stream has it, painted over the transcript's view of it.
2318
+ *
2319
+ * Only while the turn is in flight: once the transcript has the finished
2320
+ * answer the turn ends and the transcript is shown as it is. The live text
2321
+ * replaces every reply row after the turn's own message — the transcript's
2322
+ * draft of the same reply — but only when it is at least as long, so a
2323
+ * stream that joined late never paints LESS than the poll already showed.
2324
+ * The bubble takes over the draft row's id when there is one, so the row
2325
+ * keeps its identity from the first word to the confirmed answer. */
2326
+ const withLive = (s: AriaChatState): AriaChatState => {
2327
+ if (s.status !== "sending" || !liveIsFor(s)) return s;
2328
+ let out = s;
2329
+ const settledNow = live.settled();
2330
+ const text = live.text();
2331
+ let at = -1;
2332
+ for (let i = s.messages.length - 1; i >= 0; i--) if (s.messages[i].role === "USER") { at = i; break; }
2333
+ if (text && at >= 0) {
2334
+ const tail = s.messages.slice(at + 1);
2335
+ let shown = 0;
2336
+ for (const b of tail) shown += b.content.length;
2337
+ const id = tail[0]?.id ?? `${LIVE_ID_PREFIX}${s._turnId || "turn"}`;
2338
+ const key = `${id}\u0000${settledNow ? "" : "w"}\u0000${text}`;
2339
+ if (!liveBubble || liveBubble.key !== key) {
2340
+ liveBubble = { key, bubble: bubbleOf(id, "ASSISTANT", assistant(), text, !settledNow) };
2341
+ }
2342
+ const b = liveBubble.bubble;
2343
+ if (b && b.content.length >= shown && !(tail.length === 1 && tail[0] === b)) {
2344
+ out = { ...out, messages: [...s.messages.slice(0, at + 1), b] };
2345
+ }
2346
+ }
2347
+ // The model has finished writing; the server is still closing the turn.
2348
+ // "Typing…" under a finished reply would be a lie, so the line clears — but
2349
+ // only that line: a notice the owner still has to act on stays.
2350
+ if (settledNow && out.statusText && out.statusText === typingText()) out = { ...out, statusText: "" };
2351
+ // Another decision can start before the transcript closes the same turn.
2352
+ // Restore typing only when the line is free; keep actionable owner notices.
2353
+ if (!settledNow && !out.statusText) out = { ...out, statusText: typingText() };
2354
+ return out;
2355
+ };
2356
+
2357
+ /** The current turn's quick replies, as rows. */
2358
+ const repliesFor = (s: AriaChatState): AriaQuickReply[] => {
2359
+ const offered = !!s._turnId && live.turnId === s._turnId && live.conversationId === s._convId
2360
+ ? live.quickReplies() : [];
2361
+ const same = offered.length === paintedReplies.length
2362
+ && offered.every((r, i) => paintedReplies[i].label === r.label && paintedReplies[i].reply === r.reply);
2363
+ if (!same) paintedReplies = offered.map((r, i) => ({ id: `qr${i + 1}`, label: r.label, reply: r.reply }));
2364
+ return paintedReplies;
2365
+ };
2366
+
2190
2367
  /** Ensure a conversation exists for the ACTIVE site, creating one when there
2191
2368
  * is none or when the site changed under us. */
2192
2369
  async function ensureConversation(s: AriaChatState): Promise<{ convId: string; site: string; created: boolean }> {
@@ -2264,6 +2441,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2264
2441
  // this one. Bump before the first await so an in-flight tick is already stale
2265
2442
  // by the time it resolves.
2266
2443
  turnSeq += 1;
2444
+ // …and the previous turn's live reply and quick replies are over too. The
2445
+ // new turn's stream, if any, is asked for once the send has named the turn.
2446
+ live.reset("", "");
2447
+ realtime = "off";
2448
+ slowed = false;
2267
2449
  // The picks above the composer ride WITH this turn, as references beside
2268
2450
  // the text — never as tokens inside it. Read once, here: a chip the owner
2269
2451
  // adds while this send is in the air belongs to the NEXT turn, and only the
@@ -2494,8 +2676,13 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2494
2676
  if (o.poll) {
2495
2677
  // Return NOW. The host owns the clock from here; `tick` merges the
2496
2678
  // reply in when it lands.
2679
+ // …and, where a realtime stream is configured, ask for it too: the
2680
+ // reply's words then arrive as they are written instead of on the
2681
+ // next tick. The ladder is armed either way — the stream paints, the
2682
+ // poll confirms, and wherever the stream cannot open, the poll is all
2683
+ // there is.
2497
2684
  const armed: AriaChatState = { ...sent, status: "sending", _convId: convId, _siteId: site };
2498
- return cadence(armed, o.poll.fastMs, null);
2685
+ return cadence(openRealtime(armed, convId, abortId), o.poll.fastMs, null);
2499
2686
  }
2500
2687
  return busyLoop({ ...sent, _convId: convId, _siteId: site }, convId, optimistic);
2501
2688
  } catch (e) {
@@ -2593,9 +2780,14 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2593
2780
  const { _stream: _a, _nav: _b, _poll: _c, _error: _d, ...rest } = r;
2594
2781
  return rest as AriaChatState;
2595
2782
  };
2596
- const gated = (s: AriaChatState): AriaChatState => {
2783
+ const gated = (input: AriaChatState): AriaChatState => {
2597
2784
  mentionSite();
2598
- const hidden = !(turnInFlight(s) && !!s._abortId);
2785
+ // The live reply first, so every rule below sees what will be painted.
2786
+ const s = withLive(input);
2787
+ // Stop goes once the stream says the model has finished writing: there is
2788
+ // nothing left for an abort to save, and the turn only waits on the server
2789
+ // to close it.
2790
+ const hidden = !(turnInFlight(s) && !!s._abortId) || (liveIsFor(s) && live.settled());
2599
2791
  // The reply actions follow the same rule for the same reason: a derived
2600
2792
  // flag, recomputed on every action's way out, so no reducer can forget it.
2601
2793
  // `_pending` rather than `turnInFlight` — see `regenerate` for why `status`
@@ -2624,6 +2816,11 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2624
2816
  ? actioned
2625
2817
  : { ...actioned, attachments: chips, attachmentsHidden };
2626
2818
  if (out.mention !== mentionViewNow) out = { ...out, mention: mentionViewNow };
2819
+ const quickReplies = repliesFor(out);
2820
+ if (out.quickReplies !== quickReplies || out.quickRepliesHidden !== (quickReplies.length === 0)) {
2821
+ out = { ...out, quickReplies, quickRepliesHidden: quickReplies.length === 0 };
2822
+ }
2823
+ if (out._realtime !== realtime) out = { ...out, _realtime: realtime };
2627
2824
  // Every state the runtime commits passes through here (an `undefined`
2628
2825
  // result commits nothing and skips it), so this is the newest committed
2629
2826
  // state — what `copyMessage` puts its note on when other actions landed
@@ -2907,6 +3104,13 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2907
3104
  // A fresh conversation is a turn boundary too: a tick parked on the OLD
2908
3105
  // conversation's transcript must not reconcile it over the new greeting.
2909
3106
  turnSeq += 1;
3107
+ // The old conversation's live reply and quick replies go with it. Its
3108
+ // stream stays open until the next turn supersedes it (a worker cannot
3109
+ // close one), and every frame it still delivers is for a conversation
3110
+ // the model no longer folds.
3111
+ live.reset("", "");
3112
+ realtime = "off";
3113
+ slowed = false;
2910
3114
  // A fresh chat drops its drafts, the picks included — the same way it
2911
3115
  // empties the composer. The provider list is kept: it belongs to the
2912
3116
  // business, not to the conversation.
@@ -2985,7 +3189,13 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
2985
3189
  // is on the RAW transcript — bailing on the bubbles is what let such a
2986
3190
  // reply go unnoticed and pinned the ladder fast forever. Safe to return
2987
3191
  // `s` here: the guard above proved this tick is still current.
2988
- if (!raw.length) return s;
3192
+ if (!raw.length) {
3193
+ if (slowed && live.takeActivity() === 0) {
3194
+ slowed = false;
3195
+ return cadence(s, o.poll.fastMs, o.poll.streamMs ?? null);
3196
+ }
3197
+ return s;
3198
+ }
2989
3199
  const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you(), s._pendingId, s._pendingAttach ?? [], s._pendingRefs);
2990
3200
  // …but still never PAINT nothing over something. `messages` is only
2991
3201
  // adopted when it has content: a transcript that renders empty leaves
@@ -3015,7 +3225,8 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
3015
3225
  ...observed(raw, s.widget),
3016
3226
  prompts: promptsFor(painted, promptList()),
3017
3227
  statusText: ended ? "" : s.statusText,
3018
- ...(failed ? { _abortId: "", ...toolFailureSlots(failed) } : {}),
3228
+ ...(ended ? { _abortId: "" } : {}),
3229
+ ...(failed ? toolFailureSlots(failed) : {}),
3019
3230
  };
3020
3231
  // The async job rides this same tick — see `withJobProgress`. Done
3021
3232
  // AFTER the transcript rebuild, so a widget that has just been retired
@@ -3024,7 +3235,21 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
3024
3235
  const withItems = await withWidgetItems(withJob);
3025
3236
  if (turnSeq !== seq || abortingEpochs.has(seq)) return undefined;
3026
3237
  // Slow the ladder the moment the turn is over; leave it alone otherwise.
3027
- return ended && wasFast ? cadence(withItems, o.poll.idleMs, o.poll.fastMs) : withItems;
3238
+ if (ended && wasFast) {
3239
+ slowed = false;
3240
+ return cadence(withItems, o.poll.idleMs, o.poll.fastMs);
3241
+ }
3242
+ // The ladder runs slow only while the stream proves it is delivering.
3243
+ // A whole slow interval in which no frame changed anything is a stalled
3244
+ // stream — or a finished reply waiting on the server — and either way the
3245
+ // transcript is all there is, so it goes back to being read at the full
3246
+ // rate. No clock needed: this tick IS the interval.
3247
+ if (slowed && live.takeActivity() === 0) {
3248
+ slowed = false;
3249
+ return cadence(withItems, o.poll.fastMs, o.poll.streamMs ?? null);
3250
+ }
3251
+ return withItems;
3252
+
3028
3253
  } catch {
3029
3254
  // A read that THREW is exactly as stale as one that resolved: if a turn
3030
3255
  // boundary moved on while it was in flight, returning `s` merges the
@@ -3664,8 +3889,61 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
3664
3889
  regenerating = false;
3665
3890
  }
3666
3891
  },
3892
+
3893
+ // The realtime stream's three callbacks. The host dispatches them one at a
3894
+ // time, in arrival order, through the same queue as every other action.
3895
+ streamEvent: async (s, event) => {
3896
+ const change = live.fold(event);
3897
+ // A frame that changed nothing commits nothing — at 20-30 frames a second
3898
+ // a commit per frame would be a re-render per frame for no reason.
3899
+ if (change === "none") return undefined;
3900
+ // The stream has proved itself for this turn.
3901
+ if (realtime === "open") realtime = "live";
3902
+ let next = s;
3903
+ // Words are arriving, so the transcript only has to CONFIRM the turn now,
3904
+ // and the ladder can slow to the confirmation rate. Once per turn; `tick`
3905
+ // speeds it back up the first time a whole slow interval passes with no
3906
+ // frame (`takeActivity`), which the baseline taken here makes exact.
3907
+ if (o.poll?.streamMs && !slowed && turnInFlight(s) && (change === "text" || change === "completed")) {
3908
+ slowed = true;
3909
+ live.takeActivity();
3910
+ next = cadence(next, o.poll.streamMs, o.poll.fastMs);
3911
+ }
3912
+ // A completed reply, or the service saying the transcript changed, is a
3913
+ // HINT that the turn can be confirmed — never the confirmation. Read the
3914
+ // transcript now rather than at the next tick; only what it says ends the
3915
+ // turn. `tick` holds every guard a read needs (the turn epoch, an abort in
3916
+ // the air), including the `undefined` that leaves the state untouched when
3917
+ // a boundary moved on while the read was out.
3918
+ if ((change === "completed" && live.settled()) || change === "confirm") {
3919
+ if (!turnInFlight(s)) return next;
3920
+ const confirmed = await impl.tick(next);
3921
+ if (!confirmed) return undefined;
3922
+ // A `_poll` the tick did not replace still has to reach the host.
3923
+ return next._poll && !confirmed._poll ? { ...confirmed, _poll: next._poll } : confirmed;
3924
+ }
3925
+ return next;
3926
+ },
3927
+ streamEnded: (s) => {
3928
+ realtime = "down";
3929
+ return restoreLadder(s);
3930
+ },
3931
+ streamFailed: (s) => {
3932
+ realtime = "down";
3933
+ return restoreLadder(s);
3934
+ },
3667
3935
  };
3668
3936
 
3937
+ /** The stream is gone for the rest of this turn, so if the ladder was slowed
3938
+ * for it, it goes back to the full rate. Nothing re-opens the stream until the
3939
+ * next turn: re-asking on every tick would hammer an endpoint that just
3940
+ * refused, and the poll alone already carries a turn to the end. */
3941
+ function restoreLadder(s: AriaChatState): AriaChatState {
3942
+ if (!slowed || !o.poll) return s;
3943
+ slowed = false;
3944
+ return turnInFlight(s) ? cadence(s, o.poll.fastMs, o.poll.streamMs ?? null) : s;
3945
+ }
3946
+
3669
3947
  // Every action's result goes through the Stop gate on its way out.
3670
3948
  return {
3671
3949
  initialState,
@@ -3711,6 +3989,9 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
3711
3989
  attachmentSettled: (s, arg) => gated(impl.attachmentSettled(s, arg)),
3712
3990
  removeAttachment: (s, arg) => gated(impl.removeAttachment(s, arg)),
3713
3991
  retryAttachment: (s, arg) => gated(impl.retryAttachment(s, arg)),
3992
+ streamEvent: (s, event) => impl.streamEvent(s, event).then((r) => (r ? gated(r) : r)),
3993
+ streamEnded: (s) => gated(impl.streamEnded(s)),
3994
+ streamFailed: (s, err) => gated(impl.streamFailed(s, err)),
3714
3995
  };
3715
3996
  }
3716
3997