@wix/himalaya-cli 0.1014.0 → 0.1016.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/builder.d.ts +67 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/index.d.ts +45 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/define-app.d.ts +18 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +7 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/genie-chat/src/index.d.ts +51 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/core.mjs +21 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +79 -6
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-genie-chat.mjs +23 -0
- package/dist/cli.mjs +1974 -919
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/component-reference.json +1 -1
- package/guides/component-reference.md +25 -9
- package/guides/designing-himi-screens.md +6 -4
- package/guides/headless-loop.md +6 -1
- package/guides/sdk-reference.json +47 -5
- package/guides/tier-5-add-a-screen.md +40 -13
- package/guides/wix-api-usage.md +9 -0
- package/package.json +2 -2
- package/templates/beauty-salon/dev/index.ts +252 -7
- package/templates/beauty-salon/tier5-src/himi-booking-bookings/index.ts +5 -2
- package/templates/beauty-salon-owner/config.ts +1 -1
- package/templates/beauty-salon-owner/dev/index.ts +419 -2
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-chat.ts +226 -9
- package/templates/beauty-salon-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
- package/templates/branded-lite/dev/index.ts +487 -14
- package/templates/branded-lite/tier5-src/himi-booking-bookings/index.ts +5 -2
- package/templates/community-club/dev/index.ts +196 -13
- package/templates/community-club-owner/config.ts +1 -1
- package/templates/community-club-owner/dev/index.ts +402 -2
- package/templates/community-club-owner/tier5-src/_shared/aria-chat.ts +226 -9
- package/templates/community-club-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
- package/templates/community-club-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
- package/templates/events-meetups/dev/index.ts +196 -13
- package/templates/events-meetups-owner/config.ts +1 -1
- package/templates/events-meetups-owner/dev/index.ts +348 -2
- package/templates/events-meetups-owner/tier5-src/_shared/aria-chat.ts +226 -9
- package/templates/events-meetups-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
- package/templates/gym-fitness/dev/index.ts +324 -12
- package/templates/gym-fitness/tier5-src/himi-booking-bookings/index.ts +5 -2
- package/templates/gym-fitness-owner/config.ts +1 -1
- package/templates/gym-fitness-owner/dev/index.ts +440 -2
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-chat.ts +226 -9
- package/templates/gym-fitness-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
- package/templates/himi-chat/dev/index.ts +2 -2
- package/templates/nonprofit-cause/dev/index.ts +212 -13
- package/templates/nonprofit-cause-owner/config.ts +1 -1
- package/templates/nonprofit-cause-owner/dev/index.ts +386 -2
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-chat.ts +226 -9
- package/templates/nonprofit-cause-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
- package/templates/owner-lite/config.ts +1 -1
- package/templates/owner-lite/dev/index.ts +446 -2
- package/templates/owner-lite/tier5-src/_shared/aria-chat.ts +226 -9
- package/templates/owner-lite/tier5-src/_shared/aria-client-tools.ts +234 -0
- package/templates/owner-lite/tier5-src/himi-owner-lite-aria/index.ts +40 -0
- package/templates/podcast-creator/dev/index.ts +206 -13
- package/templates/podcast-creator-owner/config.ts +1 -1
- package/templates/podcast-creator-owner/dev/index.ts +402 -2
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-chat.ts +226 -9
- package/templates/podcast-creator-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
- package/templates/portfolio-creative/dev/index.ts +249 -11
- package/templates/portfolio-creative-owner/config.ts +1 -1
- package/templates/portfolio-creative-owner/dev/index.ts +419 -2
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-chat.ts +226 -9
- package/templates/portfolio-creative-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
- package/templates/restaurant/dev/index.ts +219 -11
- package/templates/restaurant-owner/config.ts +1 -1
- package/templates/restaurant-owner/dev/index.ts +440 -2
- package/templates/restaurant-owner/tier5-src/_shared/aria-chat.ts +226 -9
- package/templates/restaurant-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
- package/templates/retail-store/dev/index.ts +159 -6
- package/templates/retail-store-owner/config.ts +1 -1
- package/templates/retail-store-owner/dev/index.ts +381 -2
- package/templates/retail-store-owner/tier5-src/_shared/aria-chat.ts +226 -9
- package/templates/retail-store-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
- package/templates/yoga-studio/dev/index.ts +284 -12
- package/templates/yoga-studio/tier5-src/himi-booking-bookings/index.ts +5 -2
- package/templates/yoga-studio-owner/config.ts +1 -1
- package/templates/yoga-studio-owner/dev/index.ts +386 -2
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-chat.ts +226 -9
- package/templates/yoga-studio-owner/tier5-src/_shared/aria-client-tools.ts +234 -0
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-aria/index.ts +40 -0
|
@@ -31,16 +31,20 @@ import {
|
|
|
31
31
|
ariaAssistantConfig,
|
|
32
32
|
conversationIdOf,
|
|
33
33
|
createConversation,
|
|
34
|
+
functionCallsOf,
|
|
34
35
|
getConversationMessages,
|
|
35
36
|
getConversations,
|
|
36
37
|
messageText,
|
|
37
38
|
messagesOf,
|
|
39
|
+
sendFunctionCallResults,
|
|
38
40
|
sendMessage,
|
|
39
41
|
triggerAssistantMessage,
|
|
40
42
|
userMessageIdOf,
|
|
41
43
|
type ConversationMessage,
|
|
42
44
|
type ConversationSummary,
|
|
45
|
+
type SendMessageResponse,
|
|
43
46
|
} from "@himalaya/wix-genie-chat";
|
|
47
|
+
import { runClientToolExchange, type AriaClientTools } from "./aria-client-tools.js";
|
|
44
48
|
import { proseOfParts, scanEnvelopes, stripHidden } from "./aria-widgets.js";
|
|
45
49
|
import { isWebUrl, stripMarkdown } from "./aria-widget-model.js";
|
|
46
50
|
import { NO_WIDGET, activeWidgetOf, asyncAdvanced, type SubtreeSlot, type WidgetOptionRow, type WidgetView }
|
|
@@ -61,6 +65,11 @@ import { LOCAL_ID_PREFIX, createRowMemo, mintLocalId, transcriptKeys, type RowMe
|
|
|
61
65
|
* so both sides import this. */
|
|
62
66
|
export const ARIA_LINK_SLOTS = 3;
|
|
63
67
|
|
|
68
|
+
/** The longest consent question put in front of the owner. It is model output
|
|
69
|
+
* shown in a modal, so an agent that writes an essay must not be able to push
|
|
70
|
+
* the buttons off a small screen. */
|
|
71
|
+
export const CONSENT_MAX_CHARS = 600;
|
|
72
|
+
|
|
64
73
|
/** One rendered message. Every bubble carries ALL link slots — a `$item.` path
|
|
65
74
|
* that is missing on some rows does not substitute, so the fields are always
|
|
66
75
|
* present and `linkNHidden` collapses the unused ones. */
|
|
@@ -232,6 +241,10 @@ export interface AriaChatLabels {
|
|
|
232
241
|
* all — it carries no Wix app binding to sign the chat-service call with.
|
|
233
242
|
* See `isWixAccountUnreachable`, and note what it is NOT: a signed-out user. */
|
|
234
243
|
unavailable?: Resolvable<string>;
|
|
244
|
+
/** Shown when a client-tool exchange broke down (see `clientTools`) and the
|
|
245
|
+
* turn cannot finish. The underlying reason is a protocol fault the owner can
|
|
246
|
+
* do nothing about, so it is not what they are shown. */
|
|
247
|
+
toolFailed?: Resolvable<string>;
|
|
235
248
|
}
|
|
236
249
|
|
|
237
250
|
export interface AriaChatOptions {
|
|
@@ -248,6 +261,15 @@ export interface AriaChatOptions {
|
|
|
248
261
|
* it renders an ASYNC_TOOL's opening message and never advances it, which is
|
|
249
262
|
* strictly better than showing a spinner that can never stop. */
|
|
250
263
|
asyncJobState?: (jobId: string) => Promise<string>;
|
|
264
|
+
/** Run the client tools the agent asks for — above all, ask the OWNER before
|
|
265
|
+
* it acts on their account (`aria-client-tools.ts` says why that is a security
|
|
266
|
+
* boundary and what "asking" must and must not mean).
|
|
267
|
+
*
|
|
268
|
+
* Optional, and omitting it is safe: every call is then answered "not found",
|
|
269
|
+
* which unblocks the agent without anyone having agreed to anything. What is
|
|
270
|
+
* NOT safe is leaving a call unanswered — the agent waits for the result, so
|
|
271
|
+
* the turn would never end. */
|
|
272
|
+
clientTools?: AriaClientTools;
|
|
251
273
|
greeting: Resolvable<string>;
|
|
252
274
|
prompts: Resolvable<AriaPromptChip[]>;
|
|
253
275
|
labels?: AriaChatLabels;
|
|
@@ -588,6 +610,16 @@ export function toBubbles(
|
|
|
588
610
|
return out;
|
|
589
611
|
}
|
|
590
612
|
|
|
613
|
+
/** What of an assistant row a READER should see: HIDDEN notes out first (the
|
|
614
|
+
* service's own, transcript-only), then widget envelopes, leaving the prose.
|
|
615
|
+
* One function because two readers need it — the bubble and the consent
|
|
616
|
+
* question — and a cleaning stage added to one and not the other is raw markup
|
|
617
|
+
* in front of the owner. `closed` is false for a row still being written, whose
|
|
618
|
+
* trailing unterminated envelope must stay hidden. */
|
|
619
|
+
export function assistantProse(raw: string, closed = true): string {
|
|
620
|
+
return proseOfParts(scanEnvelopes(stripHidden(raw), 0, closed).parts);
|
|
621
|
+
}
|
|
622
|
+
|
|
591
623
|
/** Bubbles are built from one message's raw text; `null` is "paints nothing". */
|
|
592
624
|
export type BubbleMemo = RowMemo<AriaBubble | null>;
|
|
593
625
|
export const createBubbleMemo = (): BubbleMemo => createRowMemo<AriaBubble | null>();
|
|
@@ -602,7 +634,7 @@ function bubbleOf(id: string, role: string, who: string, raw: string, writing: b
|
|
|
602
634
|
// A row the service is still writing is scanned as STREAMING: an
|
|
603
635
|
// envelope whose `>>` has not arrived yet is held back rather than
|
|
604
636
|
// painted as raw JSON until the row finishes.
|
|
605
|
-
const prose = role === "USER" ? raw :
|
|
637
|
+
const prose = role === "USER" ? raw : assistantProse(raw, !writing);
|
|
606
638
|
const content = role === "USER" ? prose : stripMarkdown(prose);
|
|
607
639
|
if (!content || !content.trim()) return null;
|
|
608
640
|
return {
|
|
@@ -1037,6 +1069,7 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1037
1069
|
const unavailableText = () => res(
|
|
1038
1070
|
o.labels?.unavailable ?? `${assistant()} can't reach your Wix account from here.`,
|
|
1039
1071
|
);
|
|
1072
|
+
const toolFailedText = () => res(o.labels?.toolFailed ?? `${assistant()} couldn't finish that. Try again.`);
|
|
1040
1073
|
const promptList = () => res(o.prompts);
|
|
1041
1074
|
const siteOf = o.siteId ?? (() => "");
|
|
1042
1075
|
const resumeOf = o.resumeConversationId ?? inputConversationId;
|
|
@@ -1181,6 +1214,164 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1181
1214
|
let draft = "";
|
|
1182
1215
|
let draftSeq = 0;
|
|
1183
1216
|
|
|
1217
|
+
/** The client-tool exchange the current turn is in the middle of, if any.
|
|
1218
|
+
*
|
|
1219
|
+
* While it runs, the agent is WAITING ON US — for the owner's consent, say —
|
|
1220
|
+
* so the turn is still in flight whatever the transcript shows, and nothing
|
|
1221
|
+
* may read it as over: not a note the agent wrote before asking, not a count
|
|
1222
|
+
* that happens to have moved. `tick` and `busyLoop` both hold the turn open on
|
|
1223
|
+
* this.
|
|
1224
|
+
*
|
|
1225
|
+
* A latch rather than state for the reason `inFlightSend` is one: the exchange
|
|
1226
|
+
* outlives the action that started it (see `startToolExchange`), so no state
|
|
1227
|
+
* that action returned can describe it. Keyed by conversation and turn epoch,
|
|
1228
|
+
* so it only ever holds the turn it belongs to. */
|
|
1229
|
+
let toolExchange: { convId: string; seq: number; done: Promise<void> } | null = null;
|
|
1230
|
+
/** The reply the owner is owed once an exchange has been answered: the
|
|
1231
|
+
* assistant row the last results post opened. Until it is written, an
|
|
1232
|
+
* earlier row — a note the agent wrote before asking — must not read as the
|
|
1233
|
+
* turn's answer; it predates the owner's consent. */
|
|
1234
|
+
let toolAnswer: { convId: string; seq: number; messageId: string } | null = null;
|
|
1235
|
+
/** What the transcript held just before this turn's LAST results post: the
|
|
1236
|
+
* assistant rows that already existed, and how many of them were finished
|
|
1237
|
+
* answers (`null` when that read failed). Every one of those predates the
|
|
1238
|
+
* owner's consent, so none can be the reply to it.
|
|
1239
|
+
*
|
|
1240
|
+
* The fallback for when the results post names no row (`toolAnswer` unset)
|
|
1241
|
+
* or names one the transcript does not hold. Without it the ordinary rule
|
|
1242
|
+
* runs, and it reads a note the agent wrote BEFORE asking as the answer:
|
|
1243
|
+
* typing and Stop clear while the approved operation is still running, and
|
|
1244
|
+
* a second turn can start on top of it. */
|
|
1245
|
+
let toolPosted: { convId: string; seq: number; ids: Set<string>; settled: number | null } | null = null;
|
|
1246
|
+
/** An exchange that FAILED, waiting for the next transcript read to end its
|
|
1247
|
+
* turn. Same keying: a failure is only ever reported against its own turn. */
|
|
1248
|
+
let toolFailure: { convId: string; seq: number; error: unknown } | null = null;
|
|
1249
|
+
|
|
1250
|
+
const exchanging = (convId: string): boolean =>
|
|
1251
|
+
!!toolExchange && toolExchange.convId === convId && toolExchange.seq === turnSeq;
|
|
1252
|
+
|
|
1253
|
+
/** Has the reply owed after an exchange landed? `null` when this turn had no
|
|
1254
|
+
* exchange, so the ordinary rule decides.
|
|
1255
|
+
*
|
|
1256
|
+
* The row the last results post opened decides when the transcript holds
|
|
1257
|
+
* it. Otherwise — no row named, or the service stored the reply under
|
|
1258
|
+
* another id — the answer is a finished assistant row that was NOT there
|
|
1259
|
+
* before the post (`toolPosted`), never one that was. */
|
|
1260
|
+
const toolReplied = (convId: string, msgs: ConversationMessage[]): boolean | null => {
|
|
1261
|
+
const a = toolAnswer;
|
|
1262
|
+
if (a && a.convId === convId && a.seq === turnSeq) {
|
|
1263
|
+
const row = msgs.find((m) => m.id === a.messageId);
|
|
1264
|
+
if (row) return !!answerText(row) && !stillWriting(row);
|
|
1265
|
+
}
|
|
1266
|
+
const p = toolPosted;
|
|
1267
|
+
if (!p || p.convId !== convId || p.seq !== turnSeq) return null;
|
|
1268
|
+
let unkeyed = false;
|
|
1269
|
+
for (const m of msgs) {
|
|
1270
|
+
if ((m.role || "ASSISTANT") !== "ASSISTANT") continue;
|
|
1271
|
+
if (!m.id) { unkeyed = true; continue; }
|
|
1272
|
+
if (!p.ids.has(m.id) && answerText(m) && !stillWriting(m)) return true;
|
|
1273
|
+
}
|
|
1274
|
+
// Rows without an id cannot be told apart, so only a count can say one is
|
|
1275
|
+
// new — and only when the count before the post was read.
|
|
1276
|
+
return unkeyed && p.settled !== null ? countSettledAnswers(msgs) > p.settled : false;
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
/** The assistant rows in the transcript right now — `toolPosted`'s input.
|
|
1280
|
+
* Never rejects: a failed read is reported as `null`. */
|
|
1281
|
+
const assistantRowsNow = async (convId: string): Promise<{ ids: string[]; settled: number } | null> => {
|
|
1282
|
+
try {
|
|
1283
|
+
const raw = messagesOf(await getConversationMessages(convId));
|
|
1284
|
+
const ids = raw.filter((m) => (m.role || "ASSISTANT") === "ASSISTANT" && m.id).map((m) => m.id as string);
|
|
1285
|
+
return { ids, settled: countSettledAnswers(raw) };
|
|
1286
|
+
} catch {
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
/** The failure of THIS conversation's current turn, consumed on read. */
|
|
1292
|
+
const takeToolFailure = (convId: string): unknown => {
|
|
1293
|
+
const f = toolFailure;
|
|
1294
|
+
if (!f || f.convId !== convId || f.seq !== turnSeq) return null;
|
|
1295
|
+
toolFailure = null;
|
|
1296
|
+
return f.error ?? new Error("client tools failed");
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
/** How a broken exchange reads on screen. The Wix-unreachable refusals keep
|
|
1300
|
+
* their own neutral sentence; anything else is a protocol fault the owner can
|
|
1301
|
+
* do nothing about, so they get a plain one rather than its text. */
|
|
1302
|
+
const toolFailureSlots = (e: unknown): { error: string; statusText: string } =>
|
|
1303
|
+
isWixAccountUnreachable(e) ? failureSlots(e, unavailableText) : { error: toolFailedText(), statusText: "" };
|
|
1304
|
+
|
|
1305
|
+
/** The agent's consent question as the owner should read it: its envelopes,
|
|
1306
|
+
* HIDDEN notes and markdown gone — the same stages a bubble goes through — and
|
|
1307
|
+
* capped, because it is model output and lands in a modal. */
|
|
1308
|
+
const consentBody = (raw: string): string => {
|
|
1309
|
+
const prose = stripMarkdown(assistantProse(raw)).trim();
|
|
1310
|
+
// Cut by code POINT, not UTF-16 unit, so an emoji at the boundary is never
|
|
1311
|
+
// split into a lone surrogate.
|
|
1312
|
+
const chars = Array.from(prose);
|
|
1313
|
+
return chars.length > CONSENT_MAX_CHARS ? `${chars.slice(0, CONSENT_MAX_CHARS - 1).join("").trimEnd()}…` : prose;
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
/** The host's tools, with the question cleaned on its way to the owner. */
|
|
1317
|
+
const toolHost = (): AriaClientTools | undefined => {
|
|
1318
|
+
const t = o.clientTools;
|
|
1319
|
+
if (!t) return undefined;
|
|
1320
|
+
return { confirm: (ask) => t.confirm({ message: consentBody(ask.message) }), userAgent: t.userAgent };
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
/** Answer the client tools a send's response asked for, WITHOUT holding the
|
|
1324
|
+
* send open.
|
|
1325
|
+
*
|
|
1326
|
+
* Detached on purpose. On the `_poll` path the owner's own bubble and the
|
|
1327
|
+
* typing line paint when `send` RETURNS — so awaiting a consent inside it would
|
|
1328
|
+
* put the question on screen over a chat that does not yet show what the owner
|
|
1329
|
+
* asked. Instead the send commits, the ladder runs, and the exchange holds the
|
|
1330
|
+
* turn open (`exchanging`) until it is done.
|
|
1331
|
+
*
|
|
1332
|
+
* The consent presenter is modal, so nothing on the Aria screen — Stop, ➕,
|
|
1333
|
+
* the composer — can be reached while the owner is being asked. The epoch
|
|
1334
|
+
* check still guards the result: if the turn has moved on regardless, the
|
|
1335
|
+
* answer is not sent (`runClientToolExchange`, "abandoned"). */
|
|
1336
|
+
function startToolExchange(convId: string, first: SendMessageResponse): void {
|
|
1337
|
+
if (!functionCallsOf(first).length) return;
|
|
1338
|
+
const seq = turnSeq;
|
|
1339
|
+
const current = () => turnSeq === seq;
|
|
1340
|
+
const mine: { convId: string; seq: number; done: Promise<void> } = { convId, seq, done: Promise.resolve() };
|
|
1341
|
+
toolExchange = mine;
|
|
1342
|
+
// Rows the service handed back while it was still ASKING are notes written
|
|
1343
|
+
// before the owner answered. Kept even when the pre-post read fails, which
|
|
1344
|
+
// is when they are the only record of what predates the result.
|
|
1345
|
+
const asked = new Set<string>();
|
|
1346
|
+
const noteRows = (res: SendMessageResponse) => {
|
|
1347
|
+
for (const r of res.assistantMessages ?? []) if (r.id) asked.add(r.id);
|
|
1348
|
+
};
|
|
1349
|
+
noteRows(first);
|
|
1350
|
+
mine.done = runClientToolExchange(first, {
|
|
1351
|
+
host: toolHost(),
|
|
1352
|
+
callsOf: functionCallsOf,
|
|
1353
|
+
beforeSend: async () => {
|
|
1354
|
+
const now = await assistantRowsNow(convId);
|
|
1355
|
+
if (!current()) return;
|
|
1356
|
+
toolPosted = { convId, seq, ids: new Set([...asked, ...(now?.ids ?? [])]), settled: now ? now.settled : null };
|
|
1357
|
+
},
|
|
1358
|
+
send: async (results) => {
|
|
1359
|
+
const res = await sendFunctionCallResults(convId, results, { origin });
|
|
1360
|
+
if (functionCallsOf(res).length) noteRows(res);
|
|
1361
|
+
return res;
|
|
1362
|
+
},
|
|
1363
|
+
stillCurrent: current,
|
|
1364
|
+
onFinal: (res) => {
|
|
1365
|
+
const rows = res.assistantMessages ?? [];
|
|
1366
|
+
const id = rows[rows.length - 1]?.id;
|
|
1367
|
+
if (id && current()) toolAnswer = { convId, seq, messageId: id };
|
|
1368
|
+
},
|
|
1369
|
+
}).then(
|
|
1370
|
+
() => undefined,
|
|
1371
|
+
(error: unknown) => { if (current()) toolFailure = { convId, seq, error }; },
|
|
1372
|
+
).then(() => { if (toolExchange === mine) toolExchange = null; });
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1184
1375
|
/** Raise the fence for one abort of `seq`; returns the lowering half. */
|
|
1185
1376
|
const fenceEpoch = (seq: number): (() => void) => {
|
|
1186
1377
|
abortingEpochs.set(seq, (abortingEpochs.get(seq) ?? 0) + 1);
|
|
@@ -1301,11 +1492,20 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1301
1492
|
const bubbles = toBubbles(raw, assistant(), you(), bubbleMemo);
|
|
1302
1493
|
if (bubbles.length) server = bubbles;
|
|
1303
1494
|
seen = raw;
|
|
1495
|
+
const failed = takeToolFailure(convId);
|
|
1496
|
+
if (failed) {
|
|
1497
|
+
return { ...s, status: "ready", _convId: convId, messages: server, input: "", _pending: "", _abortId: "", ...observed(raw, s.widget), ok: false, ...toolFailureSlots(failed) };
|
|
1498
|
+
}
|
|
1499
|
+
// Not over while the agent is waiting on a client tool from us — and this
|
|
1500
|
+
// loop has no clock of its own, so re-reading meanwhile would spend its
|
|
1501
|
+
// rounds in a burst. Wait for the exchange, then read again.
|
|
1502
|
+
const pendingExchange = exchanging(convId) ? toolExchange : null;
|
|
1503
|
+
if (pendingExchange) { await pendingExchange.done; n--; continue; }
|
|
1304
1504
|
// The turn is over when the SERVER holds an assistant answer — which a
|
|
1305
1505
|
// widget-only reply is, even though it leaves no bubble to inspect. Asking
|
|
1306
1506
|
// the last BUBBLE stalled this loop on exactly that reply, the same way it
|
|
1307
1507
|
// stalled `tick`.
|
|
1308
|
-
if (repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter) {
|
|
1508
|
+
if (toolReplied(convId, raw) ?? repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter) {
|
|
1309
1509
|
return { ...s, status: "ready", _convId: convId, messages: bubbles, prompts: [], input: "", statusText: "", error: "", _pending: "", ...observed(raw, s.widget), ok: false };
|
|
1310
1510
|
}
|
|
1311
1511
|
}
|
|
@@ -1452,13 +1652,19 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1452
1652
|
// The retry path needs no send to learn it: holding that id is what
|
|
1453
1653
|
// `stalledTurn` IS. So a turn recovered from a failed trigger is just as
|
|
1454
1654
|
// stoppable as one sent cleanly.
|
|
1655
|
+
//
|
|
1656
|
+
// The response is kept, not just its id: it is also where the agent asks
|
|
1657
|
+
// for client tools (`startToolExchange`), on the retry path as much as on
|
|
1658
|
+
// a clean send.
|
|
1455
1659
|
let abortId: string;
|
|
1660
|
+
let answer: SendMessageResponse;
|
|
1456
1661
|
if (stalled) {
|
|
1457
1662
|
retriedId = stalled.userMessageId;
|
|
1458
1663
|
abortId = stalled.userMessageId;
|
|
1459
|
-
await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
|
|
1664
|
+
answer = await triggerAssistantMessage(convId, stalled.userMessageId, { origin });
|
|
1460
1665
|
} else {
|
|
1461
|
-
|
|
1666
|
+
answer = await sendMessage(convId, content, { origin });
|
|
1667
|
+
abortId = userMessageIdOf(answer);
|
|
1462
1668
|
}
|
|
1463
1669
|
// A SECOND boundary bump, and the load-bearing one. The bump at entry only
|
|
1464
1670
|
// retires a tick that was already parked when the owner tapped Send. A tick
|
|
@@ -1513,6 +1719,8 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1513
1719
|
// the answer to this one.
|
|
1514
1720
|
const sent: AriaChatState = { ...marked, _abortId: abortId, _turnId: abortId };
|
|
1515
1721
|
inFlightSend = null;
|
|
1722
|
+
// After the second bump, so the exchange is keyed to THIS turn's epoch.
|
|
1723
|
+
startToolExchange(convId, answer);
|
|
1516
1724
|
if (o.poll) {
|
|
1517
1725
|
// Return NOW. The host owns the clock from here; `tick` merges the
|
|
1518
1726
|
// reply in when it lands.
|
|
@@ -1935,22 +2143,31 @@ export function createAriaChat(o: AriaChatOptions): AriaChatCore {
|
|
|
1935
2143
|
// Counted over RAW messages, so a widget-only answer still ends the turn
|
|
1936
2144
|
// instead of pinning the ladder to its fast rung forever (`countTurns`).
|
|
1937
2145
|
// Exact when the service named the turn, counted when it did not.
|
|
1938
|
-
|
|
2146
|
+
//
|
|
2147
|
+
// …and never while the agent is waiting on a client tool from us: it has
|
|
2148
|
+
// asked, say, for the owner's consent and will write nothing more until it
|
|
2149
|
+
// has the answer, so whatever the transcript shows the turn is still ours
|
|
2150
|
+
// to finish. An exchange that FAILED ends it instead, with a line saying so.
|
|
2151
|
+
const failed = takeToolFailure(s._convId);
|
|
2152
|
+
const replied = !failed && !exchanging(s._convId)
|
|
2153
|
+
&& (toolReplied(s._convId, raw) ?? repliedTo(raw, s._turnId) ?? countSettledAnswers(raw) > s._replyAfter);
|
|
2154
|
+
const ended = replied || !!failed;
|
|
1939
2155
|
const next: AriaChatState = {
|
|
1940
2156
|
...s,
|
|
1941
|
-
status:
|
|
2157
|
+
status: ended ? "ready" : s.status,
|
|
1942
2158
|
messages: painted,
|
|
1943
|
-
_pending: pending,
|
|
2159
|
+
_pending: failed ? "" : pending,
|
|
1944
2160
|
...observed(raw, s.widget),
|
|
1945
2161
|
prompts: promptsFor(painted, promptList()),
|
|
1946
|
-
statusText:
|
|
2162
|
+
statusText: ended ? "" : s.statusText,
|
|
2163
|
+
...(failed ? { _abortId: "", ...toolFailureSlots(failed) } : {}),
|
|
1947
2164
|
};
|
|
1948
2165
|
// The async job rides this same tick — see `withJobProgress`. Done
|
|
1949
2166
|
// AFTER the transcript rebuild, so a widget that has just been retired
|
|
1950
2167
|
// by a new answer is never polled for.
|
|
1951
2168
|
const withJob = { ...next, widget: await withJobProgress(next.widget) };
|
|
1952
2169
|
// Slow the ladder the moment the turn is over; leave it alone otherwise.
|
|
1953
|
-
return
|
|
2170
|
+
return ended && wasFast ? cadence(withJob, o.poll.idleMs, o.poll.fastMs) : withJob;
|
|
1954
2171
|
} catch {
|
|
1955
2172
|
// A read that THREW is exactly as stale as one that resolved: if a turn
|
|
1956
2173
|
// boundary moved on while it was in flight, returning `s` merges the
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Client tools for the Wix "Aria" assistant: the calls the agent makes on the
|
|
2
|
+
// CLIENT, and the consent gate in front of the one that matters.
|
|
3
|
+
//
|
|
4
|
+
// The chat-service can answer a turn with `functionCalls` instead of an assistant
|
|
5
|
+
// message. The agent is then blocked: it writes nothing more until the client
|
|
6
|
+
// sends a result back (`sendFunctionCallResults`), and a client that ignores the
|
|
7
|
+
// call leaves "Aria is typing…" on screen for good. So every call gets an answer
|
|
8
|
+
// — even one this client cannot run, which is answered "not found" in the exact
|
|
9
|
+
// words the service already reads from the other two clients.
|
|
10
|
+
//
|
|
11
|
+
// Two built-ins exist, both taken from the native Aria app and the React Native
|
|
12
|
+
// dashboard assistant, which agree on them to the letter:
|
|
13
|
+
//
|
|
14
|
+
// - `consentWithMessage` — "may I do this?". The agent asks BEFORE acting on the
|
|
15
|
+
// owner's account (sending a campaign, changing a product), and the owner's
|
|
16
|
+
// answer goes back as `userConsent`. THIS IS A SECURITY BOUNDARY, not a
|
|
17
|
+
// feature: the text of the question is model output, and the model's input
|
|
18
|
+
// includes content the owner does not control (a review, an enquiry, a product
|
|
19
|
+
// description) — so a prompt-injected agent can ask for anything. The answer
|
|
20
|
+
// is the only thing standing between that and the account. Hence:
|
|
21
|
+
// * approval needs an explicit tap. Anything else — a dismissal, a host with
|
|
22
|
+
// nothing to ask with, a presenter that threw — is NOT consent; the first
|
|
23
|
+
// is answered as a refusal and the others as a failure, never as a yes.
|
|
24
|
+
// * NO remembered consent. The native app can auto-approve a `consentKey`
|
|
25
|
+
// it has seen approved in the same conversation. That is a convenience,
|
|
26
|
+
// and one that turns a single yes into a standing grant the agent can
|
|
27
|
+
// re-use on a different action under the same key. It has to be argued for
|
|
28
|
+
// on its own; it is not inherited here.
|
|
29
|
+
// * the BUTTONS are ours. The agent may suggest `approveText`/`denyText`, and
|
|
30
|
+
// they are ignored: an approve button labelled "Cancel" is how a model that
|
|
31
|
+
// cannot forge the tap gets the owner to make it.
|
|
32
|
+
// - `getClientSideKnowledge` — "what are you running on?". Read-only; answered
|
|
33
|
+
// with a user-agent string and the call's own arguments echoed back.
|
|
34
|
+
//
|
|
35
|
+
// This file is copied verbatim into every generated starter app, so it explains
|
|
36
|
+
// itself without pointing at anything only this repo has.
|
|
37
|
+
|
|
38
|
+
// Types only: this module has no runtime dependency, so it can be unit-tested
|
|
39
|
+
// without a bundler. The one decoder it needs is handed in (`callsOf`).
|
|
40
|
+
import type { FunctionCall, FunctionCallResult, SendMessageResponse } from "@himalaya/wix-genie-chat";
|
|
41
|
+
|
|
42
|
+
/** A service that keeps asking is a loop, not a conversation. Ten is the cap
|
|
43
|
+
* both native clients use: ten results sent, and an eleventh call fails the
|
|
44
|
+
* turn rather than being answered. */
|
|
45
|
+
export const CLIENT_TOOL_MAX_ROUNDS = 10;
|
|
46
|
+
|
|
47
|
+
export const CONSENT_TOOL = "consentWithMessage";
|
|
48
|
+
export const KNOWLEDGE_TOOL = "getClientSideKnowledge";
|
|
49
|
+
|
|
50
|
+
/** A function call with its arguments decoded. */
|
|
51
|
+
export interface ClientToolCall {
|
|
52
|
+
id?: string;
|
|
53
|
+
name?: string;
|
|
54
|
+
/** Which tool this is: `toolSequence.currentStep` when the call is a step of a
|
|
55
|
+
* sequence, otherwise its name. */
|
|
56
|
+
toolId: string;
|
|
57
|
+
/** Every argument EXCEPT `toolSequence`, which is routing, not payload. */
|
|
58
|
+
params: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What the host must provide to run the built-ins. Omit it and every call is
|
|
62
|
+
* answered "not found" — honest, and it unblocks the agent. */
|
|
63
|
+
export interface AriaClientTools {
|
|
64
|
+
/** Ask the owner whether the agent may go ahead, and resolve with the answer.
|
|
65
|
+
*
|
|
66
|
+
* Resolve `true` ONLY for an explicit approval. A dismissal is a refusal. A
|
|
67
|
+
* rejection (nothing to present with, a presenter already busy) is reported to
|
|
68
|
+
* the agent as a failure — it is never read as consent.
|
|
69
|
+
*
|
|
70
|
+
* `message` is the agent's question, already reduced to plain text. It can be
|
|
71
|
+
* empty; the host supplies its own fallback. */
|
|
72
|
+
confirm: (ask: { message: string }) => Promise<boolean>;
|
|
73
|
+
/** Reported by `getClientSideKnowledge`. */
|
|
74
|
+
userAgent?: () => string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
78
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Decode a call's `arguments`, or `null` when they are not a JSON object.
|
|
82
|
+
*
|
|
83
|
+
* `arguments` is a JSON-ENCODED string on the wire. An object is accepted too,
|
|
84
|
+
* in case a gateway ever decodes it on the way through; anything else — an
|
|
85
|
+
* array, a number, malformed text — is not a call this client can run, and the
|
|
86
|
+
* caller answers it as a failure rather than guessing. */
|
|
87
|
+
export function parseClientToolCall(call: FunctionCall): ClientToolCall | null {
|
|
88
|
+
// The service's array is not ours to trust element by element: a `null` in it
|
|
89
|
+
// must be answered, not thrown on.
|
|
90
|
+
if (!call || typeof call !== "object" || Array.isArray(call)) return null;
|
|
91
|
+
const raw = (call as { arguments?: unknown }).arguments;
|
|
92
|
+
let args: unknown;
|
|
93
|
+
if (raw === undefined || raw === null || raw === "") args = {};
|
|
94
|
+
else if (typeof raw === "string") {
|
|
95
|
+
try { args = JSON.parse(raw); } catch { return null; }
|
|
96
|
+
} else args = raw;
|
|
97
|
+
if (!isPlainObject(args)) return null;
|
|
98
|
+
const { toolSequence, ...params } = args;
|
|
99
|
+
let sequence: unknown = toolSequence;
|
|
100
|
+
if (typeof sequence === "string") {
|
|
101
|
+
try { sequence = JSON.parse(sequence); } catch { sequence = undefined; }
|
|
102
|
+
}
|
|
103
|
+
const step = isPlainObject(sequence) && typeof sequence.currentStep === "string" ? sequence.currentStep : "";
|
|
104
|
+
return { id: call.id, name: call.name, toolId: step || call.name || "", params };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `{id, name}` copied from the call, omitting what the call did not carry. */
|
|
108
|
+
function addressOf(call: { id?: string; name?: string }): FunctionCallResult {
|
|
109
|
+
return {
|
|
110
|
+
...(call.id === undefined ? {} : { id: call.id }),
|
|
111
|
+
...(call.name === undefined ? {} : { name: call.name }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The literal text the service already reads from the other clients. */
|
|
116
|
+
export function notFoundResult(call: ClientToolCall): FunctionCallResult {
|
|
117
|
+
return { ...addressOf(call), success: false, content: `tool '${call.name || call.toolId}' not found and cannot be executed` };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function failedResult(call: { id?: string; name?: string }, message: string): FunctionCallResult {
|
|
121
|
+
return { ...addressOf(call), success: false, content: `tool '${call.name ?? ""}' execution failed: ${message}` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The owner's answer. A refusal is still a SUCCESSFUL tool run — the tool's job
|
|
125
|
+
* was to ask, and it did; the model replies to the refusal. */
|
|
126
|
+
export function consentResult(call: ClientToolCall, approved: boolean): FunctionCallResult {
|
|
127
|
+
return { ...addressOf(call), success: true, widgetResponse: JSON.stringify({ ...call.params, userConsent: approved }) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function knowledgeResult(call: ClientToolCall, userAgent: string): FunctionCallResult {
|
|
131
|
+
return { ...addressOf(call), success: true, content: "", widgetResponse: JSON.stringify({ ...call.params, userAgent }) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Answer ONE call. Never rejects: every outcome, including a host that threw,
|
|
135
|
+
* becomes a result the agent can read, because an unanswered call is a turn
|
|
136
|
+
* that never ends. */
|
|
137
|
+
export async function answerClientTool(call: FunctionCall, host: AriaClientTools | undefined): Promise<FunctionCallResult> {
|
|
138
|
+
const parsed = parseClientToolCall(call);
|
|
139
|
+
if (!parsed) return failedResult(call && typeof call === "object" ? call : {}, "arguments are not a JSON object");
|
|
140
|
+
if (!host) return notFoundResult(parsed);
|
|
141
|
+
if (parsed.toolId === CONSENT_TOOL) {
|
|
142
|
+
const message = typeof parsed.params.message === "string" ? parsed.params.message : "";
|
|
143
|
+
let approved: boolean;
|
|
144
|
+
try {
|
|
145
|
+
// Strictly `true`. A host that resolves with something truthy but not
|
|
146
|
+
// `true` — a `{confirmed:false}` object passed straight through, the string
|
|
147
|
+
// "false" — has not reported an approval, and reading it as one would be
|
|
148
|
+
// the single worst bug this file could have.
|
|
149
|
+
approved = (await host.confirm({ message })) === true;
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return failedResult(parsed, `the owner could not be asked (${String((e as Error)?.message ?? e)})`);
|
|
152
|
+
}
|
|
153
|
+
return consentResult(parsed, approved);
|
|
154
|
+
}
|
|
155
|
+
if (parsed.toolId === KNOWLEDGE_TOOL) {
|
|
156
|
+
let userAgent = "";
|
|
157
|
+
try { userAgent = host.userAgent?.() ?? ""; } catch { /* report none rather than leave the call unanswered */ }
|
|
158
|
+
return knowledgeResult(parsed, userAgent);
|
|
159
|
+
}
|
|
160
|
+
return notFoundResult(parsed);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Tagged, not subclassed: worker bundles are built for a target without `class`. */
|
|
164
|
+
export interface ClientToolError extends Error { kind: "client-tools" }
|
|
165
|
+
|
|
166
|
+
function clientToolError(message: string): ClientToolError {
|
|
167
|
+
const e = new Error(message) as ClientToolError;
|
|
168
|
+
e.name = "ClientToolError";
|
|
169
|
+
e.kind = "client-tools";
|
|
170
|
+
return e;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type ClientToolOutcome =
|
|
174
|
+
/** The response asked for nothing. */
|
|
175
|
+
| "none"
|
|
176
|
+
/** Every call was answered, and the last response asked for nothing more. */
|
|
177
|
+
| "answered"
|
|
178
|
+
/** The turn moved on while a call was being answered, so its result was never
|
|
179
|
+
* sent. An approval given for a turn the owner has since left must not reach
|
|
180
|
+
* the agent, which would act on it in a conversation nobody is watching. */
|
|
181
|
+
| "abandoned";
|
|
182
|
+
|
|
183
|
+
export interface ClientToolExchange {
|
|
184
|
+
host?: AriaClientTools;
|
|
185
|
+
/** The calls a response is waiting on — `functionCallsOf` from the chat client. */
|
|
186
|
+
callsOf: (res: SendMessageResponse) => FunctionCall[];
|
|
187
|
+
/** Send one round of results; resolves with the service's next response. */
|
|
188
|
+
send: (results: FunctionCallResult[]) => Promise<SendMessageResponse>;
|
|
189
|
+
/** Is the turn these calls belong to still the current one? Asked after each
|
|
190
|
+
* answer and before its result is sent. */
|
|
191
|
+
stillCurrent: () => boolean;
|
|
192
|
+
maxRounds?: number;
|
|
193
|
+
/** Handed the response that ended the exchange (the one asking for nothing
|
|
194
|
+
* more). Its assistant row is the reply the owner is now owed. */
|
|
195
|
+
onFinal?: (res: SendMessageResponse) => void;
|
|
196
|
+
/** Run after a call is answered and before its result is sent — the last
|
|
197
|
+
* moment at which everything in the transcript provably predates the result.
|
|
198
|
+
* Must not reject. The turn is asked about again once it resolves, so a turn
|
|
199
|
+
* that moved on while it ran is abandoned rather than answered. */
|
|
200
|
+
beforeSend?: () => Promise<void>;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Drive the whole exchange that `first` opens, one call at a time.
|
|
204
|
+
*
|
|
205
|
+
* Rejects — failing the turn — on the two responses the other clients also
|
|
206
|
+
* refuse: more than one call at once (the contract is one, and there is no
|
|
207
|
+
* telling in which order an agent meant several to be asked), and a loop that
|
|
208
|
+
* exceeds `maxRounds`. A failed send rejects too. */
|
|
209
|
+
export async function runClientToolExchange(first: SendMessageResponse, x: ClientToolExchange): Promise<ClientToolOutcome> {
|
|
210
|
+
const max = x.maxRounds ?? CLIENT_TOOL_MAX_ROUNDS;
|
|
211
|
+
let current = first;
|
|
212
|
+
let rounds = 0;
|
|
213
|
+
for (;;) {
|
|
214
|
+
const calls = x.callsOf(current);
|
|
215
|
+
if (!calls.length) {
|
|
216
|
+
if (rounds) x.onFinal?.(current);
|
|
217
|
+
return rounds ? "answered" : "none";
|
|
218
|
+
}
|
|
219
|
+
// Asked BEFORE presenting anything, as well as after: between two rounds the
|
|
220
|
+
// modal is down and Stop is reachable, and a second question must not be put
|
|
221
|
+
// to the owner for a turn they have already stopped or left.
|
|
222
|
+
if (!x.stillCurrent()) return "abandoned";
|
|
223
|
+
if (calls.length > 1) throw clientToolError(`the assistant asked for ${calls.length} client tools at once`);
|
|
224
|
+
if (rounds >= max) throw clientToolError(`the assistant asked for more than ${max} client tools in one turn`);
|
|
225
|
+
const result = await answerClientTool(calls[0], x.host);
|
|
226
|
+
if (!x.stillCurrent()) return "abandoned";
|
|
227
|
+
if (x.beforeSend) {
|
|
228
|
+
await x.beforeSend();
|
|
229
|
+
if (!x.stillCurrent()) return "abandoned";
|
|
230
|
+
}
|
|
231
|
+
current = await x.send([result]);
|
|
232
|
+
rounds += 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -20,10 +20,14 @@
|
|
|
20
20
|
// targets are baked into the descriptor from the app contract, so a vertical
|
|
21
21
|
// this site has not installed can never be navigated to.
|
|
22
22
|
// 4. Copy as configuration: every user-facing string goes through copyParam.
|
|
23
|
+
// 5. Consent. When the agent asks before acting on the owner's account, the
|
|
24
|
+
// question is put to the owner in a real modal (`@himalaya/ui` alert) and
|
|
25
|
+
// their tap is the answer — see `confirmWithOwner`.
|
|
23
26
|
|
|
24
27
|
import { defineService } from "@himalaya/state";
|
|
25
28
|
import { open } from "@himalaya/open-url";
|
|
26
29
|
import { writeText } from "@himalaya/clipboard";
|
|
30
|
+
import { alert } from "@himalaya/ui";
|
|
27
31
|
import {
|
|
28
32
|
activeSiteId,
|
|
29
33
|
createAriaChat,
|
|
@@ -132,6 +136,40 @@ export function screenForUrl(url: string, allowed: Set<string>): string {
|
|
|
132
136
|
return best;
|
|
133
137
|
}
|
|
134
138
|
|
|
139
|
+
/** Put the agent's consent question to the owner, and return their answer.
|
|
140
|
+
*
|
|
141
|
+
* A real MODAL, not a card in the chat: it has to cover the screen, because a
|
|
142
|
+
* card leaves the composer, Stop and ➕ live beside a question the agent is
|
|
143
|
+
* blocked on, and an answer tapped on a card that has scrolled away is how an
|
|
144
|
+
* owner approves something they never read. `@himalaya/ui` presents the same
|
|
145
|
+
* token-themed alert the platform uses everywhere, on iOS, Android and web.
|
|
146
|
+
*
|
|
147
|
+
* The body is the agent's words (already plain text, and capped, by the core).
|
|
148
|
+
* The title and both buttons are OURS: the agent may suggest button labels and
|
|
149
|
+
* they are ignored, because a model that cannot tap Confirm can still try to
|
|
150
|
+
* get it labelled "Cancel".
|
|
151
|
+
*
|
|
152
|
+
* `confirmed` is only true for a tap on Confirm. A dismissal answers false — a
|
|
153
|
+
* refusal. A host with no presenter REJECTS (`no_ui_host`), which the core
|
|
154
|
+
* reports to the agent as "could not ask" — never as a yes. */
|
|
155
|
+
async function confirmWithOwner(ask: { message: string }): Promise<boolean> {
|
|
156
|
+
const { confirmed } = await alert({
|
|
157
|
+
title: copyParam("ownerAria.consent.title", "Confirmation required"),
|
|
158
|
+
message: ask.message || copyParam("ownerAria.consent.body", "Approve to continue"),
|
|
159
|
+
confirmLabel: copyParam("ownerAria.consent.approve", "Confirm"),
|
|
160
|
+
cancelLabel: copyParam("ownerAria.consent.deny", "Deny"),
|
|
161
|
+
});
|
|
162
|
+
return confirmed === true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** What `getClientSideKnowledge` reports. The browser's own string where there
|
|
166
|
+
* is one (the web host); the native worker runtimes have no `navigator`, and
|
|
167
|
+
* the app's name is then the honest answer rather than an invented UA. */
|
|
168
|
+
function userAgent(): string {
|
|
169
|
+
const nav = (globalThis as { navigator?: { userAgent?: unknown } }).navigator;
|
|
170
|
+
return typeof nav?.userAgent === "string" && nav.userAgent ? nav.userAgent : "himi-owner-lite";
|
|
171
|
+
}
|
|
172
|
+
|
|
135
173
|
const core = createAriaChat({
|
|
136
174
|
// Mount the generic card for an unrecognised widget kind. This screen is
|
|
137
175
|
// listed in `workerSubtreeScreens`; a screen that is not would have the node
|
|
@@ -161,7 +199,9 @@ const core = createAriaChat({
|
|
|
161
199
|
// English. Says no "sign in": the owner already is, and the missing piece is
|
|
162
200
|
// the app's binding, so there is no action for them to take.
|
|
163
201
|
unavailable: () => copyParam("ownerAria.unavailable", "Aria can't reach your Wix account from here."),
|
|
202
|
+
toolFailed: () => copyParam("ownerAria.toolFailed", "Aria couldn't finish that. Try again."),
|
|
164
203
|
},
|
|
204
|
+
clientTools: { confirm: confirmWithOwner, userAgent },
|
|
165
205
|
historyKey: "ol.aria.history.v1",
|
|
166
206
|
// No local title cache in THIS app. __HIMI_APP_NAME__ is multi-account and
|
|
167
207
|
// multi-site: `himalaya.storage` is app-wide, survives the account-scoped
|
|
@@ -1263,7 +1263,7 @@ const SCREENS = runtimeSetting({
|
|
|
1263
1263
|
"package": "himalaya",
|
|
1264
1264
|
"type": "Button",
|
|
1265
1265
|
"props": {
|
|
1266
|
-
"title": "",
|
|
1266
|
+
"title": "Send",
|
|
1267
1267
|
"iconOnly": true,
|
|
1268
1268
|
"leadingIcon": "arrow_up",
|
|
1269
1269
|
"style": "primary",
|
|
@@ -1285,7 +1285,7 @@ const SCREENS = runtimeSetting({
|
|
|
1285
1285
|
"package": "himalaya",
|
|
1286
1286
|
"type": "Button",
|
|
1287
1287
|
"props": {
|
|
1288
|
-
"title": "",
|
|
1288
|
+
"title": "Stop",
|
|
1289
1289
|
"iconOnly": true,
|
|
1290
1290
|
"leadingIcon": "stop",
|
|
1291
1291
|
"style": "primary",
|