@wix/himalaya-cli 0.1030.0 → 0.1032.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/core/dev-server/src/app-config.d.ts +7 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/wix-gateway.d.ts +32 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/wix-gateway.rules.d.ts +2 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/define-app.d.ts +7 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/genie-chat/src/index.d.ts +64 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-genie-chat.mjs +41 -1
- package/dist/cli.mjs +386 -132
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/mobile-ux.md +3 -2
- package/guides/qa-builds.md +22 -13
- package/guides/sdk-reference.json +50 -2
- package/package.json +2 -2
- package/templates/beauty-salon/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/beauty-salon-owner/config.ts +1 -1
- package/templates/beauty-salon-owner/dev/index.ts +247 -9
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-attachments.ts +179 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +405 -20
- package/templates/beauty-salon-owner/tier5-src/_shared/owner-bookings.ts +20 -0
- package/templates/beauty-salon-owner/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
- package/templates/branded-lite/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/community-club/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/community-club-owner/config.ts +1 -1
- package/templates/community-club-owner/dev/index.ts +247 -9
- package/templates/community-club-owner/tier5-src/_shared/aria-attachments.ts +179 -0
- package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +405 -20
- package/templates/community-club-owner/tier5-src/_shared/owner-bookings.ts +20 -0
- package/templates/community-club-owner/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
- package/templates/community-club-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
- package/templates/community-club-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
- package/templates/events-meetups/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/events-meetups-owner/config.ts +1 -1
- package/templates/events-meetups-owner/dev/index.ts +247 -9
- package/templates/events-meetups-owner/tier5-src/_shared/aria-attachments.ts +179 -0
- package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +405 -20
- package/templates/events-meetups-owner/tier5-src/_shared/owner-bookings.ts +20 -0
- package/templates/events-meetups-owner/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
- package/templates/gym-fitness/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/gym-fitness-owner/config.ts +1 -1
- package/templates/gym-fitness-owner/dev/index.ts +247 -9
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-attachments.ts +179 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +405 -20
- package/templates/gym-fitness-owner/tier5-src/_shared/owner-bookings.ts +20 -0
- package/templates/gym-fitness-owner/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
- package/templates/himi-chat/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/nonprofit-cause/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/nonprofit-cause-owner/config.ts +1 -1
- package/templates/nonprofit-cause-owner/dev/index.ts +247 -9
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-attachments.ts +179 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +405 -20
- package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-bookings.ts +20 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
- package/templates/owner-lite/config.ts +1 -1
- package/templates/owner-lite/dev/index.ts +247 -9
- package/templates/owner-lite/tier5-src/_shared/aria-attachments.ts +179 -0
- package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +405 -20
- package/templates/owner-lite/tier5-src/_shared/owner-bookings.ts +20 -0
- package/templates/owner-lite/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +40 -1
- package/templates/owner-lite/tier5-src/himi-owner-lite-home/index.ts +2 -12
- package/templates/owner-lite/tier5-src/himi-owner-lite-manage/index.ts +10 -4
- package/templates/podcast-creator/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/podcast-creator-owner/config.ts +1 -1
- package/templates/podcast-creator-owner/dev/index.ts +247 -9
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-attachments.ts +179 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +405 -20
- package/templates/podcast-creator-owner/tier5-src/_shared/owner-bookings.ts +20 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
- package/templates/portfolio-creative/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/portfolio-creative-owner/config.ts +1 -1
- package/templates/portfolio-creative-owner/dev/index.ts +247 -9
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-attachments.ts +179 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +405 -20
- package/templates/portfolio-creative-owner/tier5-src/_shared/owner-bookings.ts +20 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
- package/templates/restaurant/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/restaurant-owner/config.ts +1 -1
- package/templates/restaurant-owner/dev/index.ts +247 -9
- package/templates/restaurant-owner/tier5-src/_shared/aria-attachments.ts +179 -0
- package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +405 -20
- package/templates/restaurant-owner/tier5-src/_shared/owner-bookings.ts +20 -0
- package/templates/restaurant-owner/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
- package/templates/retail-store/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/retail-store-owner/config.ts +1 -1
- package/templates/retail-store-owner/dev/index.ts +247 -9
- package/templates/retail-store-owner/tier5-src/_shared/aria-attachments.ts +179 -0
- package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +405 -20
- package/templates/retail-store-owner/tier5-src/_shared/owner-bookings.ts +20 -0
- package/templates/retail-store-owner/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
- package/templates/surface-showcase/dev/index.ts +1 -1
- package/templates/yoga-studio/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/yoga-studio-owner/config.ts +1 -1
- package/templates/yoga-studio-owner/dev/index.ts +247 -9
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-attachments.ts +179 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +405 -20
- package/templates/yoga-studio-owner/tier5-src/_shared/owner-bookings.ts +20 -0
- package/templates/yoga-studio-owner/tier5-src/_shared/owner-load-error.ts +7 -1
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -1
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-home/index.ts +2 -12
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-manage/index.ts +10 -4
|
@@ -29,8 +29,11 @@ import {
|
|
|
29
29
|
ARIA_ORIGIN,
|
|
30
30
|
abortAssistantMessageGeneration,
|
|
31
31
|
ariaAssistantConfig,
|
|
32
|
+
attachmentsOf,
|
|
32
33
|
conversationIdOf,
|
|
33
34
|
createConversation,
|
|
35
|
+
createFileUpload,
|
|
36
|
+
genieUploadRef,
|
|
34
37
|
functionCallsOf,
|
|
35
38
|
getConversationMessages,
|
|
36
39
|
getConversations,
|
|
@@ -39,11 +42,26 @@ import {
|
|
|
39
42
|
sendFunctionCallResults,
|
|
40
43
|
sendMessage,
|
|
41
44
|
triggerAssistantMessage,
|
|
45
|
+
uploadTargetUrl,
|
|
42
46
|
userMessageIdOf,
|
|
47
|
+
type Attachment,
|
|
43
48
|
type ConversationMessage,
|
|
44
49
|
type ConversationSummary,
|
|
45
50
|
type SendMessageResponse,
|
|
46
51
|
} from "@himalaya/wix-genie-chat";
|
|
52
|
+
import {
|
|
53
|
+
ARIA_MAX_ATTACHMENTS,
|
|
54
|
+
ARIA_MAX_IMAGE_BYTES,
|
|
55
|
+
attachmentLine,
|
|
56
|
+
chipIn,
|
|
57
|
+
imageFileName,
|
|
58
|
+
normalizeMime,
|
|
59
|
+
parseDataUri,
|
|
60
|
+
refusalFor,
|
|
61
|
+
sendBlocker,
|
|
62
|
+
type AriaAttachmentChip,
|
|
63
|
+
type AriaAttachmentOutcome,
|
|
64
|
+
} from "./aria-attachments.js";
|
|
47
65
|
import { runClientToolExchange, type AriaClientTools } from "./aria-client-tools.js";
|
|
48
66
|
import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
|
|
49
67
|
import { isWebUrl, stripMarkdown } from "./aria-widget-model.js";
|
|
@@ -91,6 +109,11 @@ export interface AriaBubble {
|
|
|
91
109
|
link1Label: string; link1Url: string; link1Hidden: boolean;
|
|
92
110
|
link2Label: string; link2Url: string; link2Hidden: boolean;
|
|
93
111
|
link3Label: string; link3Url: string; link3Hidden: boolean;
|
|
112
|
+
/** The files attached to this turn, as one line ("📎 a.jpg, b.png"), and its
|
|
113
|
+
* collapse flag. Present on EVERY bubble for the same reason the link slots
|
|
114
|
+
* are: a row missing the field does not substitute. */
|
|
115
|
+
attachText: string;
|
|
116
|
+
attachHidden: boolean;
|
|
94
117
|
}
|
|
95
118
|
|
|
96
119
|
/** An empty-state suggestion card: emoji + title + hint, and the text that is
|
|
@@ -127,6 +150,10 @@ export interface AriaChatState {
|
|
|
127
150
|
/** Text of the optimistic user bubble awaiting reconciliation with the server
|
|
128
151
|
* transcript. Empty when nothing is in flight. */
|
|
129
152
|
_pending: string;
|
|
153
|
+
/** The file names the in-flight message was sent with, so the optimistic
|
|
154
|
+
* bubble keeps its attachment line across every tick until the server's own
|
|
155
|
+
* copy of the turn replaces it. */
|
|
156
|
+
_pendingAttach: string[];
|
|
130
157
|
/** Server USER-turn COUNT at the moment the in-flight message was sent. The
|
|
131
158
|
* optimistic bubble is dropped once the server holds more than this.
|
|
132
159
|
*
|
|
@@ -185,6 +212,17 @@ export interface AriaChatState {
|
|
|
185
212
|
* every time the transcript is, which is what makes "one active widget"
|
|
186
213
|
* true by construction rather than by remembering to clear it. */
|
|
187
214
|
widget: WidgetView;
|
|
215
|
+
/** The photos waiting in the composer to go with the next message.
|
|
216
|
+
*
|
|
217
|
+
* A PROJECTION, not a field the reducers maintain: the worker holds the chips
|
|
218
|
+
* in one place and every action's result is stamped with them on the way out
|
|
219
|
+
* (`gated`). An upload finishes on its own clock, so its outcome arrives while
|
|
220
|
+
* other actions are mid-await holding older snapshots; a field each reducer
|
|
221
|
+
* spread along would let any of those snapshots put a finished upload back to
|
|
222
|
+
* "Uploading…", or drop a photo picked during a send. */
|
|
223
|
+
attachments: AriaAttachmentChip[];
|
|
224
|
+
/** Collapses the composer's attachment strip while it is empty. */
|
|
225
|
+
attachmentsHidden: boolean;
|
|
188
226
|
/** Transient host directives — a poll cadence to install, and a navigation to
|
|
189
227
|
* perform. The runtime strips both before storing the state, so they are
|
|
190
228
|
* never persisted and never re-fire when the state is spread. */
|
|
@@ -246,13 +284,53 @@ export interface AriaChatLabels {
|
|
|
246
284
|
* all — it carries no Wix app binding to sign the chat-service call with.
|
|
247
285
|
* See `isWixAccountUnreachable`, and note what it is NOT: a signed-out user. */
|
|
248
286
|
unavailable?: Resolvable<string>;
|
|
287
|
+
/** Attachment copy — see `AriaChatOptions.attachments`. Chip states: */
|
|
288
|
+
attachUploading?: Resolvable<string>;
|
|
289
|
+
attachFailed?: Resolvable<string>;
|
|
290
|
+
/** Refusals, shown in the neutral status line. Nothing went wrong, so none of
|
|
291
|
+
* them is red; each one says what to do instead. */
|
|
292
|
+
attachTooMany?: (max: number) => string;
|
|
293
|
+
attachUnsupported?: Resolvable<string>;
|
|
294
|
+
attachTooLarge?: (maxMb: number) => string;
|
|
295
|
+
/** Send refused while a photo is still uploading, or failed to. */
|
|
296
|
+
attachWaitUploading?: Resolvable<string>;
|
|
297
|
+
attachWaitFailed?: Resolvable<string>;
|
|
298
|
+
/** Send refused with photos and no words. The service requires message text
|
|
299
|
+
* on every turn, so a photo alone is not a sendable message. */
|
|
300
|
+
attachNeedsText?: Resolvable<string>;
|
|
249
301
|
/** Shown when a client-tool exchange broke down (see `clientTools`) and the
|
|
250
302
|
* turn cannot finish. The underlying reason is a protocol fault the owner can
|
|
251
303
|
* do nothing about, so it is not what they are shown. */
|
|
252
304
|
toolFailed?: Resolvable<string>;
|
|
253
305
|
}
|
|
254
306
|
|
|
307
|
+
/** What the composer needs from the device to attach a photo. Supplied by the
|
|
308
|
+
* app's adapter, because both are capabilities the SCREEN has to declare — the
|
|
309
|
+
* shared core imports neither, so a consumer without attachments (the clone)
|
|
310
|
+
* keeps a declared-capability list with no camera and no upload in it.
|
|
311
|
+
*
|
|
312
|
+
* Both ports must REJECT (or resolve not-ok) on failure. A port that swallows
|
|
313
|
+
* an error and resolves as if it had worked would put a photo on the message
|
|
314
|
+
* that the service never received. */
|
|
315
|
+
export interface AriaAttachmentPorts {
|
|
316
|
+
/** Let the owner pick (or take) one photo. Resolves the image as a `data:`
|
|
317
|
+
* URI, or null when they cancelled. */
|
|
318
|
+
pick(source: "library" | "camera"): Promise<{ dataUri: string; name?: string; mimeType?: string } | null>;
|
|
319
|
+
/** PUT `base64` to a pre-signed url with `contentType`, carrying NO user
|
|
320
|
+
* credentials — the url authorizes itself. */
|
|
321
|
+
put(url: string, base64: string, contentType: string): Promise<{ ok: boolean; status: number }>;
|
|
322
|
+
/** Hand an upload's outcome back to the service as a NEW action
|
|
323
|
+
* (`attachmentSettled`). A fresh dispatch rather than a return value, because
|
|
324
|
+
* the upload outlives the action that started it — which must return at once
|
|
325
|
+
* so the chip paints while the bytes are still moving. */
|
|
326
|
+
settled(outcome: AriaAttachmentOutcome): void;
|
|
327
|
+
}
|
|
328
|
+
|
|
255
329
|
export interface AriaChatOptions {
|
|
330
|
+
/** Photo attachments in the composer. Omit it and the attach actions are
|
|
331
|
+
* inert — which is also what the descriptor does, since it paints no attach
|
|
332
|
+
* control unless the app passes the matching actions. */
|
|
333
|
+
attachments?: AriaAttachmentPorts;
|
|
256
334
|
/** Mount the generic card for an unrecognised kind in this `WorkerSubtree`
|
|
257
335
|
* slot.
|
|
258
336
|
*
|
|
@@ -328,7 +406,7 @@ export interface AriaChatOptions {
|
|
|
328
406
|
* its new BOOLEAN there on iOS and Android, and the native marshaller merges
|
|
329
407
|
* args over params — so anything reading it must cope with a non-string.
|
|
330
408
|
* `optionValue` is the key a caller should use when it means the option. */
|
|
331
|
-
export type AriaActionArg = string | { message?: string; text?: string; value?: unknown; optionValue?: string; id?: string; url?: string; intent?: string } | undefined;
|
|
409
|
+
export type AriaActionArg = string | { message?: string; text?: string; value?: unknown; optionValue?: string; id?: string; url?: string; intent?: string; source?: string; ok?: unknown; ref?: string } | undefined;
|
|
332
410
|
|
|
333
411
|
/** The first of these fields that is actually a non-empty string.
|
|
334
412
|
*
|
|
@@ -580,9 +658,24 @@ export function withSpoken(bubbles: AriaBubble[]): AriaBubble[] {
|
|
|
580
658
|
: bubbles.map((b) => (typeof b.spoken === "string" ? b : { ...b, spoken: spokenOf(b.who, b.content) }));
|
|
581
659
|
}
|
|
582
660
|
|
|
583
|
-
/** A
|
|
584
|
-
|
|
585
|
-
|
|
661
|
+
/** A bubble's words and its attachment line, with its collapse flag.
|
|
662
|
+
*
|
|
663
|
+
* A turn that is ONLY attachments carries the line as its content instead of
|
|
664
|
+
* beside it: a screen that draws the owner's words in a padded bubble would
|
|
665
|
+
* otherwise paint an empty bubble above the file names, and the row's
|
|
666
|
+
* `spoken` label, built from the content, would announce the author and
|
|
667
|
+
* nothing else. */
|
|
668
|
+
function withAttachments(content: string, names: readonly string[]): { content: string; attachText: string; attachHidden: boolean } {
|
|
669
|
+
const attachText = attachmentLine(names);
|
|
670
|
+
if (attachText && !content.trim()) return { content: attachText, attachText: "", attachHidden: true };
|
|
671
|
+
return { content, attachText, attachHidden: !attachText };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** A greeting or optimistic bubble: no links, all slots defaulted. `attached`
|
|
675
|
+
* names the files an optimistic USER bubble is being sent with. */
|
|
676
|
+
export function plainBubble(id: string, role: string, who: string, content: string, attached: readonly string[] = []): AriaBubble {
|
|
677
|
+
const a = withAttachments(content, attached);
|
|
678
|
+
return { id, role, who, notUser: role !== "USER", notAria: role === "USER", spoken: spokenOf(who, a.content), ...linkFieldsOf("", role), ...a };
|
|
586
679
|
}
|
|
587
680
|
|
|
588
681
|
/** Server transcript → bubbles. USER text is literal; ASSISTANT replies are
|
|
@@ -619,8 +712,16 @@ export function toBubbles(
|
|
|
619
712
|
// can keep exactly the same text and still render differently (a held
|
|
620
713
|
// back envelope is released), so text alone would serve the stale bubble.
|
|
621
714
|
const writing = role !== "USER" && stillWriting(m);
|
|
622
|
-
|
|
623
|
-
|
|
715
|
+
// The OWNER's attachments only. An assistant row carrying the field is
|
|
716
|
+
// not something the service is known to send, and naming files on it
|
|
717
|
+
// would claim Aria sent them.
|
|
718
|
+
const attached = role === "USER" ? attachmentsOf(m).map((a) => a.fileName) : [];
|
|
719
|
+
const build = () => bubbleOf(id, role, who, raw, writing, attached);
|
|
720
|
+
// Attachments are part of the key: they are not in the text, so a row
|
|
721
|
+
// keyed on text alone could serve a bubble naming the wrong files.
|
|
722
|
+
const b = memo
|
|
723
|
+
? memo.row(id, raw, `${role}\u0000${who}\u0000${writing ? "w" : ""}\u0000${attached.join("\u0001")}`, build)
|
|
724
|
+
: build();
|
|
624
725
|
if (b) out.push(b);
|
|
625
726
|
}
|
|
626
727
|
} finally {
|
|
@@ -643,7 +744,9 @@ export function assistantProse(raw: string, closed = true): string {
|
|
|
643
744
|
export type BubbleMemo = RowMemo<AriaBubble | null>;
|
|
644
745
|
export const createBubbleMemo = (): BubbleMemo => createRowMemo<AriaBubble | null>();
|
|
645
746
|
|
|
646
|
-
function bubbleOf(
|
|
747
|
+
function bubbleOf(
|
|
748
|
+
id: string, role: string, who: string, raw: string, writing: boolean, attached: readonly string[] = [],
|
|
749
|
+
): AriaBubble | null {
|
|
647
750
|
// Only assistant turns carry envelopes; a user's own text is literal, and
|
|
648
751
|
// scanning it would let an owner who typed `<<…>>` erase their own message.
|
|
649
752
|
// HIDDEN spans go first: the service marks text as transcript-only that
|
|
@@ -655,16 +758,20 @@ function bubbleOf(id: string, role: string, who: string, raw: string, writing: b
|
|
|
655
758
|
// painted as raw JSON until the row finishes.
|
|
656
759
|
const prose = role === "USER" ? raw : assistantProse(raw, !writing);
|
|
657
760
|
const content = role === "USER" ? prose : stripMarkdown(prose);
|
|
658
|
-
|
|
761
|
+
// A turn that is only attachments still happened, and dropping it would hide
|
|
762
|
+
// what the owner sent: `withAttachments` makes the file line its content.
|
|
763
|
+
// Empty prose AND no attachments is the placeholder row.
|
|
764
|
+
const a = withAttachments(content ?? "", attached);
|
|
765
|
+
if (!a.content.trim()) return null;
|
|
659
766
|
return {
|
|
660
767
|
id,
|
|
661
768
|
role,
|
|
662
769
|
who,
|
|
663
|
-
content,
|
|
664
770
|
notUser: role !== "USER",
|
|
665
771
|
notAria: role === "USER",
|
|
666
|
-
spoken: spokenOf(who, content),
|
|
772
|
+
spoken: spokenOf(who, a.content),
|
|
667
773
|
...linkFieldsOf(prose, role),
|
|
774
|
+
...a,
|
|
668
775
|
};
|
|
669
776
|
}
|
|
670
777
|
|
|
@@ -715,13 +822,14 @@ export function argText(arg: AriaActionArg): string {
|
|
|
715
822
|
* vanish mid-turn. */
|
|
716
823
|
export function reconcile(
|
|
717
824
|
server: AriaBubble[], pending: string, pendingAfter: number, youName = "You", pendingId = "",
|
|
825
|
+
pendingAttached: readonly string[] = [],
|
|
718
826
|
): { messages: AriaBubble[]; pending: string } {
|
|
719
827
|
if (!pending) return { messages: server, pending: "" };
|
|
720
828
|
if (countRole(server, "USER") > pendingAfter) return { messages: server, pending: "" };
|
|
721
829
|
// `pendingId` is empty only for state persisted before ids were minted; a
|
|
722
830
|
// fixed fallback still keeps that one row stable across ticks.
|
|
723
831
|
const id = pendingId || `${LOCAL_ID_PREFIX}pending`;
|
|
724
|
-
return { messages: [...server, plainBubble(id, "USER", youName, pending)], pending };
|
|
832
|
+
return { messages: [...server, plainBubble(id, "USER", youName, pending, pendingAttached)], pending };
|
|
725
833
|
}
|
|
726
834
|
|
|
727
835
|
/** How many turns of one role the transcript holds. Counted over BUBBLES rather
|
|
@@ -1054,6 +1162,19 @@ export interface AriaChatCore {
|
|
|
1054
1162
|
widgetPick(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
|
|
1055
1163
|
/** Answer one QUESTIONNAIRE question. */
|
|
1056
1164
|
widgetAnswer(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
|
|
1165
|
+
/** Pick (or take) a photo and start uploading it. Returns as soon as the chip
|
|
1166
|
+
* exists — the upload reports back through `attachmentSettled`. The source is
|
|
1167
|
+
* the arg (`"camera"`, or `{ source: "camera" }`); anything else is the
|
|
1168
|
+
* library. */
|
|
1169
|
+
attach(s: AriaChatState, arg?: AriaActionArg): Promise<AriaChatState>;
|
|
1170
|
+
/** An upload's outcome, dispatched by the upload itself. A chip that has
|
|
1171
|
+
* since been removed or sent is not resurrected. */
|
|
1172
|
+
attachmentSettled(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
|
|
1173
|
+
/** Drop one chip. The upload, if still running, is left to finish — there is
|
|
1174
|
+
* no cancel on the transfer — and its outcome is then ignored. */
|
|
1175
|
+
removeAttachment(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
|
|
1176
|
+
/** Re-send a failed chip's bytes from the copy it already holds. */
|
|
1177
|
+
retryAttachment(s: AriaChatState, arg?: AriaActionArg): AriaChatState;
|
|
1057
1178
|
}
|
|
1058
1179
|
|
|
1059
1180
|
const POLL_ID = "aria-turn";
|
|
@@ -1089,6 +1210,28 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1089
1210
|
const unavailableText = () => res(
|
|
1090
1211
|
o.labels?.unavailable ?? `${assistant()} can't reach your Wix account from here.`,
|
|
1091
1212
|
);
|
|
1213
|
+
const attachUploadingText = () => res(o.labels?.attachUploading ?? "Uploading…");
|
|
1214
|
+
const attachFailedText = () => res(o.labels?.attachFailed ?? "Couldn't upload");
|
|
1215
|
+
const chipLabels = () => ({ uploading: attachUploadingText(), failed: attachFailedText() });
|
|
1216
|
+
const refusalText = (why: "type" | "size" | "count"): string =>
|
|
1217
|
+
why === "count"
|
|
1218
|
+
? o.labels?.attachTooMany?.(ARIA_MAX_ATTACHMENTS) ?? `You can attach up to ${ARIA_MAX_ATTACHMENTS} photos.`
|
|
1219
|
+
: why === "size"
|
|
1220
|
+
? o.labels?.attachTooLarge?.(ARIA_MAX_IMAGE_BYTES / (1024 * 1024))
|
|
1221
|
+
?? `That photo is too large to attach (up to ${ARIA_MAX_IMAGE_BYTES / (1024 * 1024)} MB).`
|
|
1222
|
+
: res(o.labels?.attachUnsupported ?? "That file type can't be attached.");
|
|
1223
|
+
const waitUploadingText = () => res(o.labels?.attachWaitUploading ?? "Wait for your photo to finish uploading.");
|
|
1224
|
+
const waitFailedText = () => res(o.labels?.attachWaitFailed ?? "Retry or remove the photo that didn't upload.");
|
|
1225
|
+
const needsTextText = () => res(o.labels?.attachNeedsText ?? "Add a message to send with your photo.");
|
|
1226
|
+
/** Clear the status line if what it says is an attachment notice, and only
|
|
1227
|
+
* then. Every notice is advice about the composer's photos, so once the owner
|
|
1228
|
+
* acts on them it is stale — but the same line also carries "Aria is
|
|
1229
|
+
* typing…", which a photo edit must never erase. */
|
|
1230
|
+
const withoutNotice = <T extends { statusText: string }>(s: T): T => {
|
|
1231
|
+
const notices = [waitUploadingText(), waitFailedText(), needsTextText(),
|
|
1232
|
+
refusalText("type"), refusalText("size"), refusalText("count")];
|
|
1233
|
+
return s.statusText && notices.includes(s.statusText) ? { ...s, statusText: "" } : s;
|
|
1234
|
+
};
|
|
1092
1235
|
const toolFailedText = () => res(o.labels?.toolFailed ?? `${assistant()} couldn't finish that. Try again.`);
|
|
1093
1236
|
const promptList = () => res(o.prompts);
|
|
1094
1237
|
const siteOf = o.siteId ?? (() => "");
|
|
@@ -1098,10 +1241,98 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1098
1241
|
|
|
1099
1242
|
const initialState: AriaChatState = {
|
|
1100
1243
|
status: "loading", statusText: "", error: "", input: "", copyNote: "", replyActionsHidden: true,
|
|
1101
|
-
messages: [], prompts: [], _convId: "", _siteId: "", _pending: "",
|
|
1244
|
+
messages: [], prompts: [], _convId: "", _siteId: "", _pending: "", _pendingAttach: [],
|
|
1102
1245
|
_pendingAfter: 0, _pendingId: "", _replyAfter: 0, _srvUsers: 0, _srvAssistants: 0,
|
|
1103
1246
|
_abortId: "", _turnId: "", stopHidden: true, ok: false,
|
|
1104
1247
|
widget: NO_WIDGET,
|
|
1248
|
+
attachments: [], attachmentsHidden: true,
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
/** The composer's photos — the ONE copy. See `AriaChatState.attachments` for
|
|
1252
|
+
* why the state's field is only ever a projection of this. */
|
|
1253
|
+
let chips: AriaAttachmentChip[] = [];
|
|
1254
|
+
/** Whether `chips` has been seeded from a rehydrated state yet. A cold start
|
|
1255
|
+
* hands the first action a PERSISTED state whose chips this worker has never
|
|
1256
|
+
* seen; the latch starts empty and would otherwise wipe them. */
|
|
1257
|
+
let chipsSeeded = false;
|
|
1258
|
+
/** Mints chip ids. Never derived from the file: two photos named
|
|
1259
|
+
* `IMG_0001.jpg` are two chips. */
|
|
1260
|
+
let chipSeq = 0;
|
|
1261
|
+
/** Each chip's full image as a `data:` URI, keyed by chip id: the bytes an
|
|
1262
|
+
* upload, or a retry, sends. Kept HERE and not in the chip, because every chip
|
|
1263
|
+
* is projected into the service state, which rides every web frame, and the
|
|
1264
|
+
* contained web runtime refuses a frame over 4 MiB. With up to 10 photos of up
|
|
1265
|
+
* to 8 MB each, the chips alone would exceed that, and the action would fail
|
|
1266
|
+
* before anything rendered. Pruned to the live chips in `gated`. */
|
|
1267
|
+
const chipBytes = new Map<string, string>();
|
|
1268
|
+
/** A picked image this small doubles as its own chip thumbnail; a larger one
|
|
1269
|
+
* shows the chip without a preview. Ten of these stay well under a frame. */
|
|
1270
|
+
const THUMB_INLINE_MAX = 200_000;
|
|
1271
|
+
|
|
1272
|
+
const seedChips = (s: AriaChatState): void => {
|
|
1273
|
+
if (chipsSeeded) return;
|
|
1274
|
+
chipsSeeded = true;
|
|
1275
|
+
const persisted = Array.isArray(s.attachments) ? s.attachments : [];
|
|
1276
|
+
// An upload does not survive the process that ran it, so a chip persisted
|
|
1277
|
+
// mid-upload would say "Uploading…" forever. It still holds its bytes, so it
|
|
1278
|
+
// comes back as FAILED — with the retry control — rather than being dropped.
|
|
1279
|
+
chips = persisted
|
|
1280
|
+
.filter((c) => c && typeof c.id === "string")
|
|
1281
|
+
.map((c) => (c.state === "uploading" ? chipIn(c, "failed", chipLabels()) : c));
|
|
1282
|
+
for (const c of chips) {
|
|
1283
|
+
const n = Number(/^att-(\d+)$/.exec(c.id)?.[1] ?? 0);
|
|
1284
|
+
if (n > chipSeq) chipSeq = n;
|
|
1285
|
+
// A rehydrated chip's bytes are whatever it kept inline. A large photo kept
|
|
1286
|
+
// none, so its retry fails honestly rather than sending nothing.
|
|
1287
|
+
if (c.thumbUrl && !chipBytes.has(c.id)) chipBytes.set(c.id, c.thumbUrl);
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
/** Is Send refused while photos are pending, and what does the owner see?
|
|
1292
|
+
* Shared by a typed send and a suggested-prompt tap, so neither can send past
|
|
1293
|
+
* a photo that is still uploading or failed, or drop the photos off a turn. */
|
|
1294
|
+
const attachmentRefusal = (s: AriaChatState, content: string): AriaChatState | null => {
|
|
1295
|
+
if (!chips.length) return null;
|
|
1296
|
+
const blocker = sendBlocker(chips);
|
|
1297
|
+
if (blocker === "uploading") return { ...s, statusText: waitUploadingText(), error: "" };
|
|
1298
|
+
if (blocker === "failed") return { ...s, statusText: waitFailedText(), error: "" };
|
|
1299
|
+
if (!content) return { ...s, statusText: needsTextText(), error: "" };
|
|
1300
|
+
return null;
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
/** Keep a "wait for your photo" notice true to the chips as they are NOW. An
|
|
1304
|
+
* upload that FAILED while Send was held on "Wait for your photo to finish
|
|
1305
|
+
* uploading" must say to retry or remove it instead, and one that landed,
|
|
1306
|
+
* with nothing else pending, clears it. Any other status line (the typing
|
|
1307
|
+
* line above all) is left alone. */
|
|
1308
|
+
const refreshWaitNotice = (s: AriaChatState): AriaChatState => {
|
|
1309
|
+
const waiting = [waitUploadingText(), waitFailedText()];
|
|
1310
|
+
if (!s.statusText || !waiting.includes(s.statusText)) return s;
|
|
1311
|
+
const blocker = sendBlocker(chips);
|
|
1312
|
+
const next = blocker === "uploading" ? waitUploadingText() : blocker === "failed" ? waitFailedText() : "";
|
|
1313
|
+
return next === s.statusText ? s : { ...s, statusText: next };
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
/** Send one chip's bytes: mint the upload, PUT the bytes, report back. Detached
|
|
1317
|
+
* on purpose — see `AriaAttachmentPorts.settled`. Any failure, at either
|
|
1318
|
+
* step, reports `ok: false`; nothing is retried behind the owner's back. */
|
|
1319
|
+
const startUpload = (chip: AriaAttachmentChip): void => {
|
|
1320
|
+
const ports = o.attachments;
|
|
1321
|
+
if (!ports) return;
|
|
1322
|
+
const run = async (): Promise<AriaAttachmentOutcome> => {
|
|
1323
|
+
const parsed = parseDataUri(chipBytes.get(chip.id) ?? chip.thumbUrl);
|
|
1324
|
+
if (!parsed) return { id: chip.id, ok: false };
|
|
1325
|
+
const minted = await createFileUpload(chip.name, chip.mimeType);
|
|
1326
|
+
const id = (minted?.upload?.id ?? "").trim();
|
|
1327
|
+
const target = uploadTargetUrl(minted?.upload?.uploadUrl ?? "", chip.name);
|
|
1328
|
+
if (!id || !target) return { id: chip.id, ok: false };
|
|
1329
|
+
const sent = await ports.put(target, parsed.base64, chip.mimeType);
|
|
1330
|
+
return sent?.ok ? { id: chip.id, ok: true, ref: genieUploadRef(id) } : { id: chip.id, ok: false };
|
|
1331
|
+
};
|
|
1332
|
+
run().then(
|
|
1333
|
+
(outcome) => ports.settled(outcome),
|
|
1334
|
+
() => ports.settled({ id: chip.id, ok: false }),
|
|
1335
|
+
);
|
|
1105
1336
|
};
|
|
1106
1337
|
|
|
1107
1338
|
/** One create at a time, per site.
|
|
@@ -1545,12 +1776,17 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1545
1776
|
const withDraft = (next: AriaChatState, since: number): AriaChatState =>
|
|
1546
1777
|
draftSeq === since || draft === next.input ? next : { ...next, input: draft };
|
|
1547
1778
|
|
|
1548
|
-
async function send(s: AriaChatState, content: string): Promise<AriaChatState> {
|
|
1779
|
+
async function send(s: AriaChatState, content: string, attached: readonly AriaAttachmentChip[] = []): Promise<AriaChatState> {
|
|
1549
1780
|
// A new turn is a turn boundary: any tick still parked on the PREVIOUS
|
|
1550
1781
|
// turn's transcript read must stand down rather than write its snapshot over
|
|
1551
1782
|
// this one. Bump before the first await so an in-flight tick is already stale
|
|
1552
1783
|
// by the time it resolves.
|
|
1553
1784
|
turnSeq += 1;
|
|
1785
|
+
// Only chips whose bytes have LANDED travel; `submit` refuses to get here
|
|
1786
|
+
// with any other kind, and this filter is the second lock on that door.
|
|
1787
|
+
const riding = attached.filter((c) => c.state === "ready" && !!c.ref);
|
|
1788
|
+
const attachments: Attachment[] = riding.map((c) => ({ fileName: c.name, mimeType: c.mimeType, url: c.ref }));
|
|
1789
|
+
const attachedNames = riding.map((c) => c.name);
|
|
1554
1790
|
// Optimistic first: the owner's message and the typing line are part of the
|
|
1555
1791
|
// state this action RETURNS, so on the `_poll` path they paint immediately.
|
|
1556
1792
|
// Its id is the LOCAL SEND ID, minted here and carried as `_pendingId`, the
|
|
@@ -1572,7 +1808,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1572
1808
|
localId = minted.id;
|
|
1573
1809
|
}
|
|
1574
1810
|
const optimistic: AriaBubble[] = [
|
|
1575
|
-
...s.messages.filter((b) => b.id !== localId), plainBubble(localId, "USER", you(), content),
|
|
1811
|
+
...s.messages.filter((b) => b.id !== localId), plainBubble(localId, "USER", you(), content, attachedNames),
|
|
1576
1812
|
];
|
|
1577
1813
|
const base: AriaChatState = {
|
|
1578
1814
|
// `copyNote: ""` — a Copy confirmation belongs to the moment, and a new
|
|
@@ -1584,7 +1820,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1584
1820
|
// on a stale turn id would look for an answer that already exists. Both
|
|
1585
1821
|
// are replaced the moment this send knows its own id.
|
|
1586
1822
|
_abortId: "", _turnId: "",
|
|
1587
|
-
statusText: typingText(), messages: optimistic, _pending: content, _pendingId: localId,
|
|
1823
|
+
statusText: typingText(), messages: optimistic, _pending: content, _pendingId: localId, _pendingAttach: attachedNames,
|
|
1588
1824
|
// Mark the turn against the LAST COUNTS WE SAW, not against our own
|
|
1589
1825
|
// message list — the greeting is a local bubble the server never had, so
|
|
1590
1826
|
// counting `messages` would mark the turn one assistant reply ahead and
|
|
@@ -1683,9 +1919,21 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1683
1919
|
abortId = stalled.userMessageId;
|
|
1684
1920
|
answer = await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
|
|
1685
1921
|
} else {
|
|
1686
|
-
answer = await sendMessage(convId, content, {
|
|
1922
|
+
answer = await sendMessage(convId, content, {
|
|
1923
|
+
origin,
|
|
1924
|
+
// Omitted rather than `[]` when there are none, so a plain text turn
|
|
1925
|
+
// is byte-for-byte what it was before attachments existed.
|
|
1926
|
+
...(attachments.length ? { attachments } : {}),
|
|
1927
|
+
});
|
|
1687
1928
|
abortId = userMessageIdOf(answer);
|
|
1688
1929
|
}
|
|
1930
|
+
// The turn is stored WITH its photos, so they leave the composer — by id,
|
|
1931
|
+
// against the live latch, because a photo picked while this send was in
|
|
1932
|
+
// the air is not part of it and must stay.
|
|
1933
|
+
if (riding.length) {
|
|
1934
|
+
const gone = new Set(riding.map((c) => c.id));
|
|
1935
|
+
chips = chips.filter((c) => !gone.has(c.id));
|
|
1936
|
+
}
|
|
1689
1937
|
// A SECOND boundary bump, and the load-bearing one. The bump at entry only
|
|
1690
1938
|
// retires a tick that was already parked when the owner tapped Send. A tick
|
|
1691
1939
|
// dispatched DURING the two round trips above read the post-bump epoch, so
|
|
@@ -1844,9 +2092,19 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1844
2092
|
// `_pending` rather than `turnInFlight` — see `regenerate` for why `status`
|
|
1845
2093
|
// is not trusted to have been cleared.
|
|
1846
2094
|
const replyActionsHidden = !(lastReply(s) && !s._pending);
|
|
1847
|
-
const
|
|
2095
|
+
const actioned = s.stopHidden === hidden && s.replyActionsHidden === replyActionsHidden
|
|
1848
2096
|
? s
|
|
1849
2097
|
: { ...s, stopHidden: hidden, replyActionsHidden };
|
|
2098
|
+
// The attachment projection rides the same exit, for the same reason: one
|
|
2099
|
+
// place that every action's result passes through, so no reducer can hand
|
|
2100
|
+
// back a stale copy of the chips. Seeded first, so the very first action of
|
|
2101
|
+
// a cold start stamps the PERSISTED chips rather than an empty latch.
|
|
2102
|
+
seedChips(actioned);
|
|
2103
|
+
for (const id of [...chipBytes.keys()]) if (!chips.some((c) => c.id === id)) chipBytes.delete(id);
|
|
2104
|
+
const attachmentsHidden = chips.length === 0;
|
|
2105
|
+
const out = actioned.attachments === chips && actioned.attachmentsHidden === attachmentsHidden
|
|
2106
|
+
? actioned
|
|
2107
|
+
: { ...actioned, attachments: chips, attachmentsHidden };
|
|
1850
2108
|
// Every state the runtime commits passes through here (an `undefined`
|
|
1851
2109
|
// result commits nothing and skips it), so this is the newest committed
|
|
1852
2110
|
// state — what `copyMessage` puts its note on when other actions landed
|
|
@@ -1858,6 +2116,21 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1858
2116
|
return out;
|
|
1859
2117
|
};
|
|
1860
2118
|
|
|
2119
|
+
/** Which chip an action names. Accepts the bare id and the object form, as
|
|
2120
|
+
* every other action here does. */
|
|
2121
|
+
const chipIdOf = (arg?: AriaActionArg): string =>
|
|
2122
|
+
typeof arg === "string" ? arg.trim() : firstString(arg?.id, arg?.optionValue, arg?.value);
|
|
2123
|
+
|
|
2124
|
+
/** Replace one chip in the latch. Returns false when it no longer exists —
|
|
2125
|
+
* removed, sent, or discarded by ➕ — which every caller treats as "nothing to
|
|
2126
|
+
* do" rather than bringing it back. */
|
|
2127
|
+
const replaceChip = (id: string, next: (c: AriaAttachmentChip) => AriaAttachmentChip): boolean => {
|
|
2128
|
+
const at = chips.findIndex((c) => c.id === id);
|
|
2129
|
+
if (at < 0) return false;
|
|
2130
|
+
chips = [...chips.slice(0, at), next(chips[at]), ...chips.slice(at + 1)];
|
|
2131
|
+
return true;
|
|
2132
|
+
};
|
|
2133
|
+
|
|
1861
2134
|
/** Ask the service how an async job is getting on.
|
|
1862
2135
|
*
|
|
1863
2136
|
* Folded into the existing `tick` rather than armed as a SECOND `_poll`, and
|
|
@@ -1973,7 +2246,12 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1973
2246
|
initialState,
|
|
1974
2247
|
|
|
1975
2248
|
load: async (s) => {
|
|
2249
|
+
seedChips(s);
|
|
1976
2250
|
const site = siteOf();
|
|
2251
|
+
// An upload is minted under the business whose signed instance asked for
|
|
2252
|
+
// it, exactly as a conversation is. Photos picked for one business must not
|
|
2253
|
+
// ride a message to another, so a switch drops them with the conversation.
|
|
2254
|
+
if (s._siteId && s._siteId !== site) chips = [];
|
|
1977
2255
|
// Every branch below that puts a DIFFERENT conversation on screen abandons
|
|
1978
2256
|
// whatever turn was in flight — a business switch mid-turn does exactly
|
|
1979
2257
|
// that — so it re-arms the ladder slow, as `newChat` does. Otherwise the
|
|
@@ -2062,9 +2340,14 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
2062
2340
|
|
|
2063
2341
|
submit: async (s, arg) => {
|
|
2064
2342
|
const content = argText(arg) || (s.input ?? "").trim();
|
|
2343
|
+
// Read from the latch, never from `s`: see `AriaChatState.attachments`.
|
|
2344
|
+
// Each refusal leaves the draft AND the photos exactly as they were — the
|
|
2345
|
+
// owner fixes the one thing named and taps Send again.
|
|
2346
|
+
const refused = attachmentRefusal(s, content);
|
|
2347
|
+
if (refused) return refused;
|
|
2065
2348
|
if (!content) return { ...s, statusText: "" };
|
|
2066
2349
|
if (duplicate(s, content)) return s;
|
|
2067
|
-
return send(s, content);
|
|
2350
|
+
return send(s, content, chips);
|
|
2068
2351
|
},
|
|
2069
2352
|
|
|
2070
2353
|
// A suggested-prompt chip submits its own text through the SAME path a typed
|
|
@@ -2074,8 +2357,13 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
2074
2357
|
submitPrompt: async (s, arg) => {
|
|
2075
2358
|
const msg = argText(arg);
|
|
2076
2359
|
if (!msg) return s;
|
|
2360
|
+
// Staged photos go with the prompt, under the same rules as Send: a chip
|
|
2361
|
+
// that bypassed them would send the words and strand the photos in the
|
|
2362
|
+
// composer, or send past one that has not finished uploading.
|
|
2363
|
+
const refused = attachmentRefusal(s, msg);
|
|
2364
|
+
if (refused) return refused;
|
|
2077
2365
|
if (duplicate(s, msg)) return s;
|
|
2078
|
-
return send(s, msg);
|
|
2366
|
+
return send(s, msg, chips);
|
|
2079
2367
|
},
|
|
2080
2368
|
|
|
2081
2369
|
newChat: async (s) => {
|
|
@@ -2083,6 +2371,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
2083
2371
|
// A fresh conversation is a turn boundary too: a tick parked on the OLD
|
|
2084
2372
|
// conversation's transcript must not reconcile it over the new greeting.
|
|
2085
2373
|
turnSeq += 1;
|
|
2374
|
+
// ➕ discards the draft (`input: ""` below), and the photos are part of the
|
|
2375
|
+
// draft. An upload still running settles into nothing: its chip is gone.
|
|
2376
|
+
chipsSeeded = true;
|
|
2377
|
+
chips = [];
|
|
2086
2378
|
// ➕ can land mid-turn, and it abandons that turn: `status` goes back to
|
|
2087
2379
|
// `ready`, so `tick`'s `wasFast` is false from here on and the FAST
|
|
2088
2380
|
// ladder the send armed would never be slowed again. A worker cannot
|
|
@@ -2151,7 +2443,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
2151
2443
|
// reply go unnoticed and pinned the ladder fast forever. Safe to return
|
|
2152
2444
|
// `s` here: the guard above proved this tick is still current.
|
|
2153
2445
|
if (!raw.length) return s;
|
|
2154
|
-
const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you(), s._pendingId);
|
|
2446
|
+
const { messages, pending } = reconcile(server, s._pending, s._pendingAfter, you(), s._pendingId, s._pendingAttach ?? []);
|
|
2155
2447
|
// …but still never PAINT nothing over something. `messages` is only
|
|
2156
2448
|
// adopted when it has content: a transcript that renders empty leaves
|
|
2157
2449
|
// the screen as it was (the greeting, or the last good turn) while the
|
|
@@ -2371,6 +2663,95 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
2371
2663
|
return s;
|
|
2372
2664
|
},
|
|
2373
2665
|
|
|
2666
|
+
attach: async (s, arg) => {
|
|
2667
|
+
const ports = o.attachments;
|
|
2668
|
+
if (!ports) return s;
|
|
2669
|
+
seedChips(s);
|
|
2670
|
+
// Refused BEFORE the picker opens when there is no room: presenting a
|
|
2671
|
+
// picker whose result will be thrown away wastes the owner's time.
|
|
2672
|
+
if (chips.length >= ARIA_MAX_ATTACHMENTS) return { ...s, statusText: refusalText("count"), error: "" };
|
|
2673
|
+
const raw = typeof arg === "string" ? arg : firstString(arg?.source, arg?.value);
|
|
2674
|
+
const source = raw.trim().toLowerCase() === "camera" ? "camera" : "library";
|
|
2675
|
+
// The picker is open for as long as the owner takes, and a poll, a reply or
|
|
2676
|
+
// another action can commit a NEWER state meanwhile. Returning `{ ...s }`
|
|
2677
|
+
// afterwards would put this pre-picker snapshot back over it: an older
|
|
2678
|
+
// transcript, a `_pending` already answered, a reply that just landed
|
|
2679
|
+
// hidden again. So the result is built on the newest committed state, the
|
|
2680
|
+
// same way `copyMessage` puts its note there (see `gated`).
|
|
2681
|
+
const at = commits;
|
|
2682
|
+
const latest = (): AriaChatState => (commits !== at && committed ? committed : s);
|
|
2683
|
+
let picked: Awaited<ReturnType<AriaAttachmentPorts["pick"]>>;
|
|
2684
|
+
try {
|
|
2685
|
+
picked = await ports.pick(source);
|
|
2686
|
+
} catch {
|
|
2687
|
+
// A picker that could not open is not the owner's fault and there is no
|
|
2688
|
+
// file to talk about; say nothing rather than something misleading.
|
|
2689
|
+
return latest();
|
|
2690
|
+
}
|
|
2691
|
+
if (!picked || !picked.dataUri) return latest(); // cancelled
|
|
2692
|
+
const parsed = parseDataUri(picked.dataUri);
|
|
2693
|
+
const mimeType = normalizeMime(picked.mimeType || parsed?.mimeType || "");
|
|
2694
|
+
// Re-checked after the await: other picks may have landed meanwhile.
|
|
2695
|
+
const refused = refusalFor(parsed, mimeType, chips.length);
|
|
2696
|
+
if (refused) return { ...latest(), statusText: refusalText(refused), error: "" };
|
|
2697
|
+
chipSeq += 1;
|
|
2698
|
+
const id = `att-${chipSeq}`;
|
|
2699
|
+
chipBytes.set(id, picked.dataUri);
|
|
2700
|
+
const chip = chipIn({
|
|
2701
|
+
id,
|
|
2702
|
+
name: imageFileName(mimeType, chipSeq, picked.name),
|
|
2703
|
+
mimeType,
|
|
2704
|
+
// Only a small image rides the state as its own thumbnail; see `chipBytes`.
|
|
2705
|
+
thumbUrl: picked.dataUri.length <= THUMB_INLINE_MAX ? picked.dataUri : "",
|
|
2706
|
+
}, "uploading", chipLabels());
|
|
2707
|
+
chips = [...chips, chip];
|
|
2708
|
+
startUpload(chip);
|
|
2709
|
+
// Clear a refusal left over from an earlier pick: this one worked. The
|
|
2710
|
+
// typing line is not touched, so picking mid-turn does not hide it.
|
|
2711
|
+
return { ...withoutNotice(latest()), error: "" };
|
|
2712
|
+
},
|
|
2713
|
+
|
|
2714
|
+
attachmentSettled: (s, arg) => {
|
|
2715
|
+
const id = chipIdOf(arg);
|
|
2716
|
+
if (!id) return s;
|
|
2717
|
+
const ok = typeof arg === "object" && !!arg && (arg.ok === true || arg.ok === "true");
|
|
2718
|
+
const ref = typeof arg === "object" && arg ? (arg.ref ?? "").trim() : "";
|
|
2719
|
+
// Only an UPLOADING chip can settle. A retry re-mints the chip's id (see
|
|
2720
|
+
// `retryAttachment`), so a late answer from an earlier attempt names an id
|
|
2721
|
+
// that no longer exists and cannot decide the attempt now running.
|
|
2722
|
+
const current = chips.find((c) => c.id === id);
|
|
2723
|
+
if (!current || current.state !== "uploading") return s;
|
|
2724
|
+
replaceChip(id, (c) => chipIn(c, ok && ref ? "ready" : "failed", chipLabels(), ref));
|
|
2725
|
+
// A "wait for your photo" notice follows the chips: a failure turns
|
|
2726
|
+
// "…finish uploading" into "retry or remove…", and a success clears it
|
|
2727
|
+
// once nothing else is pending.
|
|
2728
|
+
return refreshWaitNotice(s);
|
|
2729
|
+
},
|
|
2730
|
+
|
|
2731
|
+
removeAttachment: (s, arg) => {
|
|
2732
|
+
const id = chipIdOf(arg);
|
|
2733
|
+
if (!id || !chips.some((c) => c.id === id)) return s;
|
|
2734
|
+
chips = chips.filter((c) => c.id !== id);
|
|
2735
|
+
return refreshWaitNotice(withoutNotice(s));
|
|
2736
|
+
},
|
|
2737
|
+
|
|
2738
|
+
retryAttachment: (s, arg) => {
|
|
2739
|
+
const id = chipIdOf(arg);
|
|
2740
|
+
const current = chips.find((c) => c.id === id);
|
|
2741
|
+
if (!current || current.state !== "failed") return s;
|
|
2742
|
+
// A NEW id for the new attempt, in the same place in the strip. Keeping the
|
|
2743
|
+
// old one would let the first attempt's outcome, if it is still in the air,
|
|
2744
|
+
// settle this one.
|
|
2745
|
+
chipSeq += 1;
|
|
2746
|
+
const again = chipIn({ ...current, id: `att-${chipSeq}` }, "uploading", chipLabels());
|
|
2747
|
+
// The bytes follow the chip to its new id; the old entry is pruned in `gated`.
|
|
2748
|
+
const bytes = chipBytes.get(id);
|
|
2749
|
+
if (bytes) chipBytes.set(again.id, bytes);
|
|
2750
|
+
replaceChip(id, () => again);
|
|
2751
|
+
startUpload(again);
|
|
2752
|
+
return withoutNotice(s);
|
|
2753
|
+
},
|
|
2754
|
+
|
|
2374
2755
|
// Abandon the reply being generated. Three things happen, and only the first
|
|
2375
2756
|
// is a network call:
|
|
2376
2757
|
//
|
|
@@ -2533,6 +2914,10 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
2533
2914
|
widgetRespond: (s, arg) => impl.widgetRespond(s, arg).then(gated),
|
|
2534
2915
|
widgetPrimary: (s) => impl.widgetPrimary(s).then(gated),
|
|
2535
2916
|
widgetSecondary: (s) => impl.widgetSecondary(s).then(gated),
|
|
2917
|
+
attach: (s, arg) => impl.attach(s, arg).then(gated),
|
|
2918
|
+
attachmentSettled: (s, arg) => gated(impl.attachmentSettled(s, arg)),
|
|
2919
|
+
removeAttachment: (s, arg) => gated(impl.removeAttachment(s, arg)),
|
|
2920
|
+
retryAttachment: (s, arg) => gated(impl.retryAttachment(s, arg)),
|
|
2536
2921
|
};
|
|
2537
2922
|
}
|
|
2538
2923
|
|