@ateam-ai-mobile/ui 0.1.7 → 0.1.8
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/index.d.mts +66 -6
- package/dist/index.d.ts +66 -6
- package/dist/index.js +153 -9
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +154 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/plugin/Plugin.tsx +4 -3
- package/src/voice/voiceSession.ts +190 -11
package/dist/index.d.mts
CHANGED
|
@@ -3,7 +3,7 @@ import { Message, JobProgress, UIAction, AdasSession, TenantMembership, HandoffE
|
|
|
3
3
|
import * as React$1 from 'react';
|
|
4
4
|
import React__default, { ReactNode } from 'react';
|
|
5
5
|
import * as _ateam_ai_mobile_plugin_runtime from '@ateam-ai-mobile/plugin-runtime';
|
|
6
|
-
import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
|
|
6
|
+
import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoicePersona, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
|
|
7
7
|
import * as zustand from 'zustand';
|
|
8
8
|
|
|
9
9
|
interface AdasChatScreenProps {
|
|
@@ -394,6 +394,11 @@ interface InternalState {
|
|
|
394
394
|
userSpeaking: boolean;
|
|
395
395
|
/** Epoch ms of the last speaking:false, or null before the first utterance. */
|
|
396
396
|
lastUserSpeechEndedAt: number | null;
|
|
397
|
+
/** The last device-tool call the model made (name + when), or null. */
|
|
398
|
+
lastDeviceTool: {
|
|
399
|
+
name: string;
|
|
400
|
+
at: number;
|
|
401
|
+
} | null;
|
|
397
402
|
/** The WebView should be mounted (a session is wanted / live). */
|
|
398
403
|
mounted: boolean;
|
|
399
404
|
/** The WebView source, computed ONCE at start() so state changes (transcript,
|
|
@@ -402,6 +407,9 @@ interface InternalState {
|
|
|
402
407
|
uri: string;
|
|
403
408
|
} | null;
|
|
404
409
|
skillSlug?: string;
|
|
410
|
+
/** Per-call persona override ({ name, style }) — sent to the voice page on the
|
|
411
|
+
* session URL and declared on POST /session; the server layers it. */
|
|
412
|
+
persona?: VoicePersona;
|
|
405
413
|
}
|
|
406
414
|
declare class VoiceSessionManager implements VoiceSessionProvider {
|
|
407
415
|
private state;
|
|
@@ -419,6 +427,40 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
419
427
|
* conversation just because it declared voice.
|
|
420
428
|
*/
|
|
421
429
|
private owner;
|
|
430
|
+
/** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
|
|
431
|
+
* The session is not 'connecting' yet, so a stop() arriving now would otherwise
|
|
432
|
+
* be an idle no-op — and a start would then mount a call for a screen that has
|
|
433
|
+
* already gone. A set, not one slot: two concurrent starts must BOTH be
|
|
434
|
+
* cancellable. stop() cancels every pending start of its caller (the platform:
|
|
435
|
+
* all of them); another plugin's stop cancels none. */
|
|
436
|
+
private pendingStarts;
|
|
437
|
+
/**
|
|
438
|
+
* The device-tool names Core ALLOWED for this voice session — Core's sanitized
|
|
439
|
+
* allowset, delivered to the phone in a `deviceToolAllowset` message.
|
|
440
|
+
* Authorization gates against THIS (necessary, alongside the registry's can-do).
|
|
441
|
+
*
|
|
442
|
+
* WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
|
|
443
|
+
* (sendDeviceToolList); CORE is the session authority and decides which are
|
|
444
|
+
* accepted (session.device_tools). Freezing the phone's own proposal would let
|
|
445
|
+
* a tool Core REJECTED still run locally. So the authz set is populated from
|
|
446
|
+
* Core's accepted set, never from listDeviceTools(). Until Core's set arrives
|
|
447
|
+
* this is null and every call FAILS CLOSED. Pinned once per session; the live
|
|
448
|
+
* registry (which drifts via lazy loads / host registration / tenant changes)
|
|
449
|
+
* never feeds it.
|
|
450
|
+
*/
|
|
451
|
+
private frozenTools;
|
|
452
|
+
/** The voice `sessionId` the current allowset is BOUND to. Authorization
|
|
453
|
+
* requires it to equal the active session's id (state.sessionId), so a stale
|
|
454
|
+
* allowset from a previous/other session is refused. */
|
|
455
|
+
private frozenSessionId;
|
|
456
|
+
/** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
|
|
457
|
+
* plugin's own device tools — not other solutions' tools that happen to be
|
|
458
|
+
* registered on the same phone. null = every registered tool (a normal call,
|
|
459
|
+
* or the platform's). Set by every start(); like `owner`, kept after stop.
|
|
460
|
+
* ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
|
|
461
|
+
* and the bundleManager registers a plugin's tools under that same id (never
|
|
462
|
+
* the bundle's own short `id`), so exact equality is the whole check. */
|
|
463
|
+
private toolScope;
|
|
422
464
|
/** The platform (built-in sheet, mic indicator, session host) sees and
|
|
423
465
|
* controls any session — it is the user's own surface. A plugin caller may
|
|
424
466
|
* only touch a session it owns. */
|
|
@@ -438,10 +480,9 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
438
480
|
prompt(input: string, caller?: VoiceCaller): void;
|
|
439
481
|
/**
|
|
440
482
|
* Stream a silent observation into the live session — tell the model without
|
|
441
|
-
* asking it to speak. The cheap
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
* plugin API is added. Owner-scoped like every other control.
|
|
483
|
+
* asking it to speak. The cheap channel behind the gated props.voice.observe();
|
|
484
|
+
* every item stays in the model's context for the call, so a caller sends only
|
|
485
|
+
* material changes. Owner-scoped like every other control.
|
|
445
486
|
*/
|
|
446
487
|
observe(text: string, caller?: VoiceCaller): void;
|
|
447
488
|
getSnapshot(caller?: VoiceCaller): VoiceSnapshot;
|
|
@@ -452,7 +493,26 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
452
493
|
private buildUri;
|
|
453
494
|
/** Handle a postMessage from the WebView. `event` is the RN WebView event. */
|
|
454
495
|
handleMessage(event: any): void;
|
|
455
|
-
/**
|
|
496
|
+
/**
|
|
497
|
+
* Final authorization = Core's frozen allowset ∩ the current valid local
|
|
498
|
+
* registry, bound to the ACTIVE session. All must hold:
|
|
499
|
+
* - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
|
|
500
|
+
* the active state.sessionId) — a missing or stale/wrong-session allowset
|
|
501
|
+
* fails closed;
|
|
502
|
+
* - the tool is IN that allowset (Core approved it); AND
|
|
503
|
+
* - the tool is STILL registered locally right now (removed-after-approval is
|
|
504
|
+
* refused; a lazily-added tool Core never approved is refused) — and, on a
|
|
505
|
+
* clean call, registered by the call's owner.
|
|
506
|
+
* No fallback to the live registry alone, ever.
|
|
507
|
+
*/
|
|
508
|
+
private isToolAuthorized;
|
|
509
|
+
/** Tell the mini-app the call was refused — a stated reason, never a silent drop.
|
|
510
|
+
* `notReady` distinguishes a transient "allowset not here yet" (retry) from a
|
|
511
|
+
* final "not allowed in this session". */
|
|
512
|
+
private postDeviceToolRefusal;
|
|
513
|
+
/** PROPOSE the phone's available tools — this is an offer to Core, not the
|
|
514
|
+
* authorization set. Core sanitizes it and returns the accepted set in a
|
|
515
|
+
* `deviceToolAllowset` message, which is what actually gates execution. */
|
|
456
516
|
private sendDeviceToolList;
|
|
457
517
|
/** Run a device tool and post the outcome back into the WebView. */
|
|
458
518
|
private runDeviceTool;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Message, JobProgress, UIAction, AdasSession, TenantMembership, HandoffE
|
|
|
3
3
|
import * as React$1 from 'react';
|
|
4
4
|
import React__default, { ReactNode } from 'react';
|
|
5
5
|
import * as _ateam_ai_mobile_plugin_runtime from '@ateam-ai-mobile/plugin-runtime';
|
|
6
|
-
import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
|
|
6
|
+
import { PluginSurfaceMeta, PlatformPluginManifest, VoiceState, VoiceTranscriptEntry, VoiceSessionProvider, VoicePersona, VoiceStartOptions, VoiceCaller, VoiceSnapshot, VoiceEvent, VoiceSubscription } from '@ateam-ai-mobile/plugin-runtime';
|
|
7
7
|
import * as zustand from 'zustand';
|
|
8
8
|
|
|
9
9
|
interface AdasChatScreenProps {
|
|
@@ -394,6 +394,11 @@ interface InternalState {
|
|
|
394
394
|
userSpeaking: boolean;
|
|
395
395
|
/** Epoch ms of the last speaking:false, or null before the first utterance. */
|
|
396
396
|
lastUserSpeechEndedAt: number | null;
|
|
397
|
+
/** The last device-tool call the model made (name + when), or null. */
|
|
398
|
+
lastDeviceTool: {
|
|
399
|
+
name: string;
|
|
400
|
+
at: number;
|
|
401
|
+
} | null;
|
|
397
402
|
/** The WebView should be mounted (a session is wanted / live). */
|
|
398
403
|
mounted: boolean;
|
|
399
404
|
/** The WebView source, computed ONCE at start() so state changes (transcript,
|
|
@@ -402,6 +407,9 @@ interface InternalState {
|
|
|
402
407
|
uri: string;
|
|
403
408
|
} | null;
|
|
404
409
|
skillSlug?: string;
|
|
410
|
+
/** Per-call persona override ({ name, style }) — sent to the voice page on the
|
|
411
|
+
* session URL and declared on POST /session; the server layers it. */
|
|
412
|
+
persona?: VoicePersona;
|
|
405
413
|
}
|
|
406
414
|
declare class VoiceSessionManager implements VoiceSessionProvider {
|
|
407
415
|
private state;
|
|
@@ -419,6 +427,40 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
419
427
|
* conversation just because it declared voice.
|
|
420
428
|
*/
|
|
421
429
|
private owner;
|
|
430
|
+
/** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
|
|
431
|
+
* The session is not 'connecting' yet, so a stop() arriving now would otherwise
|
|
432
|
+
* be an idle no-op — and a start would then mount a call for a screen that has
|
|
433
|
+
* already gone. A set, not one slot: two concurrent starts must BOTH be
|
|
434
|
+
* cancellable. stop() cancels every pending start of its caller (the platform:
|
|
435
|
+
* all of them); another plugin's stop cancels none. */
|
|
436
|
+
private pendingStarts;
|
|
437
|
+
/**
|
|
438
|
+
* The device-tool names Core ALLOWED for this voice session — Core's sanitized
|
|
439
|
+
* allowset, delivered to the phone in a `deviceToolAllowset` message.
|
|
440
|
+
* Authorization gates against THIS (necessary, alongside the registry's can-do).
|
|
441
|
+
*
|
|
442
|
+
* WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
|
|
443
|
+
* (sendDeviceToolList); CORE is the session authority and decides which are
|
|
444
|
+
* accepted (session.device_tools). Freezing the phone's own proposal would let
|
|
445
|
+
* a tool Core REJECTED still run locally. So the authz set is populated from
|
|
446
|
+
* Core's accepted set, never from listDeviceTools(). Until Core's set arrives
|
|
447
|
+
* this is null and every call FAILS CLOSED. Pinned once per session; the live
|
|
448
|
+
* registry (which drifts via lazy loads / host registration / tenant changes)
|
|
449
|
+
* never feeds it.
|
|
450
|
+
*/
|
|
451
|
+
private frozenTools;
|
|
452
|
+
/** The voice `sessionId` the current allowset is BOUND to. Authorization
|
|
453
|
+
* requires it to equal the active session's id (state.sessionId), so a stale
|
|
454
|
+
* allowset from a previous/other session is refused. */
|
|
455
|
+
private frozenSessionId;
|
|
456
|
+
/** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
|
|
457
|
+
* plugin's own device tools — not other solutions' tools that happen to be
|
|
458
|
+
* registered on the same phone. null = every registered tool (a normal call,
|
|
459
|
+
* or the platform's). Set by every start(); like `owner`, kept after stop.
|
|
460
|
+
* ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
|
|
461
|
+
* and the bundleManager registers a plugin's tools under that same id (never
|
|
462
|
+
* the bundle's own short `id`), so exact equality is the whole check. */
|
|
463
|
+
private toolScope;
|
|
422
464
|
/** The platform (built-in sheet, mic indicator, session host) sees and
|
|
423
465
|
* controls any session — it is the user's own surface. A plugin caller may
|
|
424
466
|
* only touch a session it owns. */
|
|
@@ -438,10 +480,9 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
438
480
|
prompt(input: string, caller?: VoiceCaller): void;
|
|
439
481
|
/**
|
|
440
482
|
* Stream a silent observation into the live session — tell the model without
|
|
441
|
-
* asking it to speak. The cheap
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
* plugin API is added. Owner-scoped like every other control.
|
|
483
|
+
* asking it to speak. The cheap channel behind the gated props.voice.observe();
|
|
484
|
+
* every item stays in the model's context for the call, so a caller sends only
|
|
485
|
+
* material changes. Owner-scoped like every other control.
|
|
445
486
|
*/
|
|
446
487
|
observe(text: string, caller?: VoiceCaller): void;
|
|
447
488
|
getSnapshot(caller?: VoiceCaller): VoiceSnapshot;
|
|
@@ -452,7 +493,26 @@ declare class VoiceSessionManager implements VoiceSessionProvider {
|
|
|
452
493
|
private buildUri;
|
|
453
494
|
/** Handle a postMessage from the WebView. `event` is the RN WebView event. */
|
|
454
495
|
handleMessage(event: any): void;
|
|
455
|
-
/**
|
|
496
|
+
/**
|
|
497
|
+
* Final authorization = Core's frozen allowset ∩ the current valid local
|
|
498
|
+
* registry, bound to the ACTIVE session. All must hold:
|
|
499
|
+
* - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
|
|
500
|
+
* the active state.sessionId) — a missing or stale/wrong-session allowset
|
|
501
|
+
* fails closed;
|
|
502
|
+
* - the tool is IN that allowset (Core approved it); AND
|
|
503
|
+
* - the tool is STILL registered locally right now (removed-after-approval is
|
|
504
|
+
* refused; a lazily-added tool Core never approved is refused) — and, on a
|
|
505
|
+
* clean call, registered by the call's owner.
|
|
506
|
+
* No fallback to the live registry alone, ever.
|
|
507
|
+
*/
|
|
508
|
+
private isToolAuthorized;
|
|
509
|
+
/** Tell the mini-app the call was refused — a stated reason, never a silent drop.
|
|
510
|
+
* `notReady` distinguishes a transient "allowset not here yet" (retry) from a
|
|
511
|
+
* final "not allowed in this session". */
|
|
512
|
+
private postDeviceToolRefusal;
|
|
513
|
+
/** PROPOSE the phone's available tools — this is an offer to Core, not the
|
|
514
|
+
* authorization set. Core sanitizes it and returns the accepted set in a
|
|
515
|
+
* `deviceToolAllowset` message, which is what actually gates execution. */
|
|
456
516
|
private sendDeviceToolList;
|
|
457
517
|
/** Run a device tool and post the outcome back into the WebView. */
|
|
458
518
|
private runDeviceTool;
|
package/dist/index.js
CHANGED
|
@@ -2837,6 +2837,7 @@ var IDLE = {
|
|
|
2837
2837
|
muted: false,
|
|
2838
2838
|
userSpeaking: false,
|
|
2839
2839
|
lastUserSpeechEndedAt: null,
|
|
2840
|
+
lastDeviceTool: null,
|
|
2840
2841
|
mounted: false,
|
|
2841
2842
|
webViewSource: null,
|
|
2842
2843
|
skillSlug: void 0
|
|
@@ -2858,6 +2859,40 @@ var VoiceSessionManager = class {
|
|
|
2858
2859
|
* conversation just because it declared voice.
|
|
2859
2860
|
*/
|
|
2860
2861
|
this.owner = null;
|
|
2862
|
+
/** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
|
|
2863
|
+
* The session is not 'connecting' yet, so a stop() arriving now would otherwise
|
|
2864
|
+
* be an idle no-op — and a start would then mount a call for a screen that has
|
|
2865
|
+
* already gone. A set, not one slot: two concurrent starts must BOTH be
|
|
2866
|
+
* cancellable. stop() cancels every pending start of its caller (the platform:
|
|
2867
|
+
* all of them); another plugin's stop cancels none. */
|
|
2868
|
+
this.pendingStarts = /* @__PURE__ */ new Set();
|
|
2869
|
+
/**
|
|
2870
|
+
* The device-tool names Core ALLOWED for this voice session — Core's sanitized
|
|
2871
|
+
* allowset, delivered to the phone in a `deviceToolAllowset` message.
|
|
2872
|
+
* Authorization gates against THIS (necessary, alongside the registry's can-do).
|
|
2873
|
+
*
|
|
2874
|
+
* WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
|
|
2875
|
+
* (sendDeviceToolList); CORE is the session authority and decides which are
|
|
2876
|
+
* accepted (session.device_tools). Freezing the phone's own proposal would let
|
|
2877
|
+
* a tool Core REJECTED still run locally. So the authz set is populated from
|
|
2878
|
+
* Core's accepted set, never from listDeviceTools(). Until Core's set arrives
|
|
2879
|
+
* this is null and every call FAILS CLOSED. Pinned once per session; the live
|
|
2880
|
+
* registry (which drifts via lazy loads / host registration / tenant changes)
|
|
2881
|
+
* never feeds it.
|
|
2882
|
+
*/
|
|
2883
|
+
this.frozenTools = null;
|
|
2884
|
+
/** The voice `sessionId` the current allowset is BOUND to. Authorization
|
|
2885
|
+
* requires it to equal the active session's id (state.sessionId), so a stale
|
|
2886
|
+
* allowset from a previous/other session is refused. */
|
|
2887
|
+
this.frozenSessionId = null;
|
|
2888
|
+
/** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
|
|
2889
|
+
* plugin's own device tools — not other solutions' tools that happen to be
|
|
2890
|
+
* registered on the same phone. null = every registered tool (a normal call,
|
|
2891
|
+
* or the platform's). Set by every start(); like `owner`, kept after stop.
|
|
2892
|
+
* ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
|
|
2893
|
+
* and the bundleManager registers a plugin's tools under that same id (never
|
|
2894
|
+
* the bundle's own short `id`), so exact equality is the whole check. */
|
|
2895
|
+
this.toolScope = null;
|
|
2861
2896
|
this.injectedTrackHook = INJECT_TRACK_HOOK;
|
|
2862
2897
|
}
|
|
2863
2898
|
/** The platform (built-in sheet, mic indicator, session host) sees and
|
|
@@ -2893,6 +2928,11 @@ var VoiceSessionManager = class {
|
|
|
2893
2928
|
if (!this.canAccess(caller)) throw this.foreignError();
|
|
2894
2929
|
return this.waitForConnect();
|
|
2895
2930
|
}
|
|
2931
|
+
if (opts?.skills === "none" && opts?.skillSlug) {
|
|
2932
|
+
throw new Error("voice: skills:'none' (a clean call) cannot also hand the mic to a skill (skillSlug).");
|
|
2933
|
+
}
|
|
2934
|
+
const ticket = { caller, cancelled: false };
|
|
2935
|
+
this.pendingStarts.add(ticket);
|
|
2896
2936
|
try {
|
|
2897
2937
|
await (0, import_plugin_runtime13.ensureDeviceToolsLoaded)();
|
|
2898
2938
|
} catch (e) {
|
|
@@ -2902,7 +2942,21 @@ var VoiceSessionManager = class {
|
|
|
2902
2942
|
if (tokenNeedsRefresh((0, import_core22.getToken)())) await (0, import_core22.refreshToken)();
|
|
2903
2943
|
} catch {
|
|
2904
2944
|
}
|
|
2945
|
+
this.pendingStarts.delete(ticket);
|
|
2946
|
+
if (ticket.cancelled) throw new Error("voice: stopped before the session started");
|
|
2947
|
+
const after = this.getInternal().callState;
|
|
2948
|
+
if (after === "connected") {
|
|
2949
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
2950
|
+
return this.realSnapshot();
|
|
2951
|
+
}
|
|
2952
|
+
if (after === "connecting") {
|
|
2953
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
2954
|
+
return this.waitForConnect();
|
|
2955
|
+
}
|
|
2905
2956
|
this.owner = caller;
|
|
2957
|
+
this.toolScope = opts?.skills === "none" && typeof caller === "string" ? caller : null;
|
|
2958
|
+
this.frozenTools = null;
|
|
2959
|
+
this.frozenSessionId = null;
|
|
2906
2960
|
const skillSlug = opts?.skillSlug;
|
|
2907
2961
|
this.setState({
|
|
2908
2962
|
callState: "connecting",
|
|
@@ -2913,14 +2967,23 @@ var VoiceSessionManager = class {
|
|
|
2913
2967
|
muted: false,
|
|
2914
2968
|
mounted: true,
|
|
2915
2969
|
skillSlug,
|
|
2916
|
-
|
|
2970
|
+
persona: opts?.persona,
|
|
2971
|
+
webViewSource: { uri: this.buildUri(skillSlug, opts?.persona, opts?.skills) }
|
|
2917
2972
|
});
|
|
2918
2973
|
return this.waitForConnect();
|
|
2919
2974
|
}
|
|
2920
2975
|
stop(caller = import_plugin_runtime13.PLATFORM_VOICE_CALLER) {
|
|
2921
2976
|
if (this.state.callState !== "idle" && !this.canAccess(caller)) throw this.foreignError();
|
|
2977
|
+
for (const t of [...this.pendingStarts]) {
|
|
2978
|
+
if (caller === import_plugin_runtime13.PLATFORM_VOICE_CALLER || caller === t.caller) {
|
|
2979
|
+
t.cancelled = true;
|
|
2980
|
+
this.pendingStarts.delete(t);
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2922
2983
|
this.webView?.injectJavaScript?.("window.hangUp && window.hangUp(); true;");
|
|
2923
2984
|
this.rejectPending(new Error("voice: session stopped before it connected"));
|
|
2985
|
+
this.frozenTools = null;
|
|
2986
|
+
this.frozenSessionId = null;
|
|
2924
2987
|
this.setState({
|
|
2925
2988
|
...IDLE,
|
|
2926
2989
|
// keep the transcript of the call that just ended for any reader still up
|
|
@@ -2941,10 +3004,9 @@ var VoiceSessionManager = class {
|
|
|
2941
3004
|
}
|
|
2942
3005
|
/**
|
|
2943
3006
|
* Stream a silent observation into the live session — tell the model without
|
|
2944
|
-
* asking it to speak. The cheap
|
|
2945
|
-
*
|
|
2946
|
-
*
|
|
2947
|
-
* plugin API is added. Owner-scoped like every other control.
|
|
3007
|
+
* asking it to speak. The cheap channel behind the gated props.voice.observe();
|
|
3008
|
+
* every item stays in the model's context for the call, so a caller sends only
|
|
3009
|
+
* material changes. Owner-scoped like every other control.
|
|
2948
3010
|
*/
|
|
2949
3011
|
observe(text, caller = import_plugin_runtime13.PLATFORM_VOICE_CALLER) {
|
|
2950
3012
|
if (this.state.callState !== "connected") {
|
|
@@ -2966,6 +3028,8 @@ var VoiceSessionManager = class {
|
|
|
2966
3028
|
listening: !!s.userPartial,
|
|
2967
3029
|
userSpeaking: s.userSpeaking,
|
|
2968
3030
|
lastUserSpeechEndedAt: s.lastUserSpeechEndedAt,
|
|
3031
|
+
lastDeviceTool: s.lastDeviceTool,
|
|
3032
|
+
authorizedSessionId: this.frozenSessionId,
|
|
2969
3033
|
lastError: s.error
|
|
2970
3034
|
};
|
|
2971
3035
|
}
|
|
@@ -2978,6 +3042,8 @@ var VoiceSessionManager = class {
|
|
|
2978
3042
|
listening: false,
|
|
2979
3043
|
userSpeaking: false,
|
|
2980
3044
|
lastUserSpeechEndedAt: null,
|
|
3045
|
+
lastDeviceTool: null,
|
|
3046
|
+
authorizedSessionId: null,
|
|
2981
3047
|
lastError: null
|
|
2982
3048
|
};
|
|
2983
3049
|
}
|
|
@@ -2994,12 +3060,15 @@ var VoiceSessionManager = class {
|
|
|
2994
3060
|
return this.canAccess(caller) ? this.state.transcript.slice() : [];
|
|
2995
3061
|
}
|
|
2996
3062
|
// ── WebView plumbing ─────────────────────────────────────────
|
|
2997
|
-
buildUri(skillSlug) {
|
|
3063
|
+
buildUri(skillSlug, persona, skills) {
|
|
2998
3064
|
const token = (0, import_core22.getToken)();
|
|
2999
3065
|
const tenant = (0, import_core22.getTenant)();
|
|
3000
3066
|
const actorId = (0, import_core22.getUser)()?.actorId || "";
|
|
3001
3067
|
const skillParam = skillSlug ? `&skillSlug=${encodeURIComponent(skillSlug)}` : "";
|
|
3002
|
-
|
|
3068
|
+
const p = persona && (persona.name || persona.style) ? { ...persona.name ? { name: persona.name } : {}, ...persona.style ? { style: persona.style } : {} } : null;
|
|
3069
|
+
const personaParam = p ? `&persona=${encodeURIComponent(JSON.stringify(p))}` : "";
|
|
3070
|
+
const skillsParam = skills === "none" ? "&skills=none" : "";
|
|
3071
|
+
return `${(0, import_core22.getVoiceApiUrl)()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || "")}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}${personaParam}${skillsParam}`;
|
|
3003
3072
|
}
|
|
3004
3073
|
/** Handle a postMessage from the WebView. `event` is the RN WebView event. */
|
|
3005
3074
|
handleMessage(event) {
|
|
@@ -3063,6 +3132,27 @@ var VoiceSessionManager = class {
|
|
|
3063
3132
|
this.sendDeviceToolList(data.correlationId);
|
|
3064
3133
|
break;
|
|
3065
3134
|
}
|
|
3135
|
+
// CORE's ALLOWSET for this session — what the model may ASK for. Core sends
|
|
3136
|
+
// it (even empty, on purpose) at session start. It is a NECESSARY gate, not
|
|
3137
|
+
// the whole story: a call must be in the allowset AND the device must
|
|
3138
|
+
// actually have the tool (the registry check inside callDeviceTool) — Core
|
|
3139
|
+
// says what may be asked, the device decides what it will do.
|
|
3140
|
+
//
|
|
3141
|
+
// Origin-checked (only the voice app may set it) and pinned ONCE per session
|
|
3142
|
+
// (a later message cannot widen it). An EMPTY allowset (arrived) refuses
|
|
3143
|
+
// everything but is distinct from NEVER-arrived (frozenTools stays null);
|
|
3144
|
+
// both fail closed here — we never fall back to the phone's own registry,
|
|
3145
|
+
// which is the seam this closes.
|
|
3146
|
+
case "deviceToolAllowset": {
|
|
3147
|
+
const sender = originOf(event?.nativeEvent?.url);
|
|
3148
|
+
const expected = originOf((0, import_core22.getVoiceApiUrl)());
|
|
3149
|
+
if (!sender || !expected || sender !== expected) break;
|
|
3150
|
+
if (Array.isArray(data.tools) && typeof data.sessionId === "string" && data.sessionId !== this.frozenSessionId) {
|
|
3151
|
+
this.frozenTools = new Set(data.tools.filter((n) => typeof n === "string"));
|
|
3152
|
+
this.frozenSessionId = data.sessionId;
|
|
3153
|
+
}
|
|
3154
|
+
break;
|
|
3155
|
+
}
|
|
3066
3156
|
case "device-tool-call": {
|
|
3067
3157
|
const sender = originOf(event?.nativeEvent?.url);
|
|
3068
3158
|
const expected = originOf((0, import_core22.getVoiceApiUrl)());
|
|
@@ -3070,14 +3160,64 @@ var VoiceSessionManager = class {
|
|
|
3070
3160
|
console.warn(`[voiceSession] REFUSED device tool call from "${sender || "unknown origin"}" (expected "${expected}")`);
|
|
3071
3161
|
break;
|
|
3072
3162
|
}
|
|
3163
|
+
if (data.name) this.setState({ lastDeviceTool: { name: data.name, at: Date.now() } });
|
|
3164
|
+
if (!this.isToolAuthorized(data.name)) {
|
|
3165
|
+
const notReady = this.frozenTools === null;
|
|
3166
|
+
console.warn(`[voiceSession] REFUSED "${data.name}" \u2014 ${notReady ? "allowset not received yet" : "not authorized for this session"}`);
|
|
3167
|
+
this.postDeviceToolRefusal(data.correlationId, data.name, notReady);
|
|
3168
|
+
break;
|
|
3169
|
+
}
|
|
3073
3170
|
void this.runDeviceTool(data);
|
|
3074
3171
|
break;
|
|
3075
3172
|
}
|
|
3076
3173
|
}
|
|
3077
3174
|
}
|
|
3078
|
-
/**
|
|
3175
|
+
/**
|
|
3176
|
+
* Final authorization = Core's frozen allowset ∩ the current valid local
|
|
3177
|
+
* registry, bound to the ACTIVE session. All must hold:
|
|
3178
|
+
* - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
|
|
3179
|
+
* the active state.sessionId) — a missing or stale/wrong-session allowset
|
|
3180
|
+
* fails closed;
|
|
3181
|
+
* - the tool is IN that allowset (Core approved it); AND
|
|
3182
|
+
* - the tool is STILL registered locally right now (removed-after-approval is
|
|
3183
|
+
* refused; a lazily-added tool Core never approved is refused) — and, on a
|
|
3184
|
+
* clean call, registered by the call's owner.
|
|
3185
|
+
* No fallback to the live registry alone, ever.
|
|
3186
|
+
*/
|
|
3187
|
+
isToolAuthorized(name) {
|
|
3188
|
+
if (typeof name !== "string") return false;
|
|
3189
|
+
if (this.frozenTools === null || this.frozenSessionId === null) return false;
|
|
3190
|
+
if (this.frozenSessionId !== this.state.sessionId) return false;
|
|
3191
|
+
if (!this.frozenTools.has(name)) return false;
|
|
3192
|
+
return (0, import_plugin_runtime13.hasDeviceTool)(name, this.toolScope ?? void 0);
|
|
3193
|
+
}
|
|
3194
|
+
/** Tell the mini-app the call was refused — a stated reason, never a silent drop.
|
|
3195
|
+
* `notReady` distinguishes a transient "allowset not here yet" (retry) from a
|
|
3196
|
+
* final "not allowed in this session". */
|
|
3197
|
+
postDeviceToolRefusal(correlationId, name, notReady) {
|
|
3198
|
+
const payload = notReady ? {
|
|
3199
|
+
correlationId,
|
|
3200
|
+
ok: false,
|
|
3201
|
+
reason: "not_ready",
|
|
3202
|
+
error: "The voice session is still starting up \u2014 its tool allowset has not arrived yet. Try again in a moment."
|
|
3203
|
+
} : {
|
|
3204
|
+
correlationId,
|
|
3205
|
+
ok: false,
|
|
3206
|
+
reason: "not_authorized",
|
|
3207
|
+
error: `"${name}" is not available in this voice session.`
|
|
3208
|
+
};
|
|
3209
|
+
this.webView?.injectJavaScript?.(
|
|
3210
|
+
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
3211
|
+
JSON.stringify(payload)
|
|
3212
|
+
)}); true;`
|
|
3213
|
+
);
|
|
3214
|
+
}
|
|
3215
|
+
/** PROPOSE the phone's available tools — this is an offer to Core, not the
|
|
3216
|
+
* authorization set. Core sanitizes it and returns the accepted set in a
|
|
3217
|
+
* `deviceToolAllowset` message, which is what actually gates execution. */
|
|
3079
3218
|
sendDeviceToolList(correlationId) {
|
|
3080
|
-
const
|
|
3219
|
+
const scope = this.toolScope;
|
|
3220
|
+
const tools = (0, import_plugin_runtime13.listDeviceTools)().filter((t) => scope === null || t.pluginId === scope).map((t) => ({
|
|
3081
3221
|
name: t.name,
|
|
3082
3222
|
description: t.description,
|
|
3083
3223
|
input_schema: t.input_schema
|
|
@@ -3155,6 +3295,10 @@ var VoiceSessionManager = class {
|
|
|
3155
3295
|
this.rejectPending(new Error("voice: reset"));
|
|
3156
3296
|
this.state = { ...IDLE };
|
|
3157
3297
|
this.owner = null;
|
|
3298
|
+
this.toolScope = null;
|
|
3299
|
+
this.pendingStarts.clear();
|
|
3300
|
+
this.frozenTools = null;
|
|
3301
|
+
this.frozenSessionId = null;
|
|
3158
3302
|
this.listeners.clear();
|
|
3159
3303
|
this.webView = null;
|
|
3160
3304
|
}
|