@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.mjs
CHANGED
|
@@ -2664,6 +2664,7 @@ import {
|
|
|
2664
2664
|
callDeviceTool,
|
|
2665
2665
|
ensureDeviceToolsLoaded,
|
|
2666
2666
|
listDeviceTools,
|
|
2667
|
+
hasDeviceTool,
|
|
2667
2668
|
PLATFORM_VOICE_CALLER
|
|
2668
2669
|
} from "@ateam-ai-mobile/plugin-runtime";
|
|
2669
2670
|
var CONNECT_TIMEOUT_MS = 3e4;
|
|
@@ -2814,6 +2815,7 @@ var IDLE = {
|
|
|
2814
2815
|
muted: false,
|
|
2815
2816
|
userSpeaking: false,
|
|
2816
2817
|
lastUserSpeechEndedAt: null,
|
|
2818
|
+
lastDeviceTool: null,
|
|
2817
2819
|
mounted: false,
|
|
2818
2820
|
webViewSource: null,
|
|
2819
2821
|
skillSlug: void 0
|
|
@@ -2835,6 +2837,40 @@ var VoiceSessionManager = class {
|
|
|
2835
2837
|
* conversation just because it declared voice.
|
|
2836
2838
|
*/
|
|
2837
2839
|
this.owner = null;
|
|
2840
|
+
/** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
|
|
2841
|
+
* The session is not 'connecting' yet, so a stop() arriving now would otherwise
|
|
2842
|
+
* be an idle no-op — and a start would then mount a call for a screen that has
|
|
2843
|
+
* already gone. A set, not one slot: two concurrent starts must BOTH be
|
|
2844
|
+
* cancellable. stop() cancels every pending start of its caller (the platform:
|
|
2845
|
+
* all of them); another plugin's stop cancels none. */
|
|
2846
|
+
this.pendingStarts = /* @__PURE__ */ new Set();
|
|
2847
|
+
/**
|
|
2848
|
+
* The device-tool names Core ALLOWED for this voice session — Core's sanitized
|
|
2849
|
+
* allowset, delivered to the phone in a `deviceToolAllowset` message.
|
|
2850
|
+
* Authorization gates against THIS (necessary, alongside the registry's can-do).
|
|
2851
|
+
*
|
|
2852
|
+
* WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
|
|
2853
|
+
* (sendDeviceToolList); CORE is the session authority and decides which are
|
|
2854
|
+
* accepted (session.device_tools). Freezing the phone's own proposal would let
|
|
2855
|
+
* a tool Core REJECTED still run locally. So the authz set is populated from
|
|
2856
|
+
* Core's accepted set, never from listDeviceTools(). Until Core's set arrives
|
|
2857
|
+
* this is null and every call FAILS CLOSED. Pinned once per session; the live
|
|
2858
|
+
* registry (which drifts via lazy loads / host registration / tenant changes)
|
|
2859
|
+
* never feeds it.
|
|
2860
|
+
*/
|
|
2861
|
+
this.frozenTools = null;
|
|
2862
|
+
/** The voice `sessionId` the current allowset is BOUND to. Authorization
|
|
2863
|
+
* requires it to equal the active session's id (state.sessionId), so a stale
|
|
2864
|
+
* allowset from a previous/other session is refused. */
|
|
2865
|
+
this.frozenSessionId = null;
|
|
2866
|
+
/** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
|
|
2867
|
+
* plugin's own device tools — not other solutions' tools that happen to be
|
|
2868
|
+
* registered on the same phone. null = every registered tool (a normal call,
|
|
2869
|
+
* or the platform's). Set by every start(); like `owner`, kept after stop.
|
|
2870
|
+
* ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
|
|
2871
|
+
* and the bundleManager registers a plugin's tools under that same id (never
|
|
2872
|
+
* the bundle's own short `id`), so exact equality is the whole check. */
|
|
2873
|
+
this.toolScope = null;
|
|
2838
2874
|
this.injectedTrackHook = INJECT_TRACK_HOOK;
|
|
2839
2875
|
}
|
|
2840
2876
|
/** The platform (built-in sheet, mic indicator, session host) sees and
|
|
@@ -2870,6 +2906,11 @@ var VoiceSessionManager = class {
|
|
|
2870
2906
|
if (!this.canAccess(caller)) throw this.foreignError();
|
|
2871
2907
|
return this.waitForConnect();
|
|
2872
2908
|
}
|
|
2909
|
+
if (opts?.skills === "none" && opts?.skillSlug) {
|
|
2910
|
+
throw new Error("voice: skills:'none' (a clean call) cannot also hand the mic to a skill (skillSlug).");
|
|
2911
|
+
}
|
|
2912
|
+
const ticket = { caller, cancelled: false };
|
|
2913
|
+
this.pendingStarts.add(ticket);
|
|
2873
2914
|
try {
|
|
2874
2915
|
await ensureDeviceToolsLoaded();
|
|
2875
2916
|
} catch (e) {
|
|
@@ -2879,7 +2920,21 @@ var VoiceSessionManager = class {
|
|
|
2879
2920
|
if (tokenNeedsRefresh(getToken2())) await refreshToken();
|
|
2880
2921
|
} catch {
|
|
2881
2922
|
}
|
|
2923
|
+
this.pendingStarts.delete(ticket);
|
|
2924
|
+
if (ticket.cancelled) throw new Error("voice: stopped before the session started");
|
|
2925
|
+
const after = this.getInternal().callState;
|
|
2926
|
+
if (after === "connected") {
|
|
2927
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
2928
|
+
return this.realSnapshot();
|
|
2929
|
+
}
|
|
2930
|
+
if (after === "connecting") {
|
|
2931
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
2932
|
+
return this.waitForConnect();
|
|
2933
|
+
}
|
|
2882
2934
|
this.owner = caller;
|
|
2935
|
+
this.toolScope = opts?.skills === "none" && typeof caller === "string" ? caller : null;
|
|
2936
|
+
this.frozenTools = null;
|
|
2937
|
+
this.frozenSessionId = null;
|
|
2883
2938
|
const skillSlug = opts?.skillSlug;
|
|
2884
2939
|
this.setState({
|
|
2885
2940
|
callState: "connecting",
|
|
@@ -2890,14 +2945,23 @@ var VoiceSessionManager = class {
|
|
|
2890
2945
|
muted: false,
|
|
2891
2946
|
mounted: true,
|
|
2892
2947
|
skillSlug,
|
|
2893
|
-
|
|
2948
|
+
persona: opts?.persona,
|
|
2949
|
+
webViewSource: { uri: this.buildUri(skillSlug, opts?.persona, opts?.skills) }
|
|
2894
2950
|
});
|
|
2895
2951
|
return this.waitForConnect();
|
|
2896
2952
|
}
|
|
2897
2953
|
stop(caller = PLATFORM_VOICE_CALLER) {
|
|
2898
2954
|
if (this.state.callState !== "idle" && !this.canAccess(caller)) throw this.foreignError();
|
|
2955
|
+
for (const t of [...this.pendingStarts]) {
|
|
2956
|
+
if (caller === PLATFORM_VOICE_CALLER || caller === t.caller) {
|
|
2957
|
+
t.cancelled = true;
|
|
2958
|
+
this.pendingStarts.delete(t);
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2899
2961
|
this.webView?.injectJavaScript?.("window.hangUp && window.hangUp(); true;");
|
|
2900
2962
|
this.rejectPending(new Error("voice: session stopped before it connected"));
|
|
2963
|
+
this.frozenTools = null;
|
|
2964
|
+
this.frozenSessionId = null;
|
|
2901
2965
|
this.setState({
|
|
2902
2966
|
...IDLE,
|
|
2903
2967
|
// keep the transcript of the call that just ended for any reader still up
|
|
@@ -2918,10 +2982,9 @@ var VoiceSessionManager = class {
|
|
|
2918
2982
|
}
|
|
2919
2983
|
/**
|
|
2920
2984
|
* Stream a silent observation into the live session — tell the model without
|
|
2921
|
-
* asking it to speak. The cheap
|
|
2922
|
-
*
|
|
2923
|
-
*
|
|
2924
|
-
* plugin API is added. Owner-scoped like every other control.
|
|
2985
|
+
* asking it to speak. The cheap channel behind the gated props.voice.observe();
|
|
2986
|
+
* every item stays in the model's context for the call, so a caller sends only
|
|
2987
|
+
* material changes. Owner-scoped like every other control.
|
|
2925
2988
|
*/
|
|
2926
2989
|
observe(text, caller = PLATFORM_VOICE_CALLER) {
|
|
2927
2990
|
if (this.state.callState !== "connected") {
|
|
@@ -2943,6 +3006,8 @@ var VoiceSessionManager = class {
|
|
|
2943
3006
|
listening: !!s.userPartial,
|
|
2944
3007
|
userSpeaking: s.userSpeaking,
|
|
2945
3008
|
lastUserSpeechEndedAt: s.lastUserSpeechEndedAt,
|
|
3009
|
+
lastDeviceTool: s.lastDeviceTool,
|
|
3010
|
+
authorizedSessionId: this.frozenSessionId,
|
|
2946
3011
|
lastError: s.error
|
|
2947
3012
|
};
|
|
2948
3013
|
}
|
|
@@ -2955,6 +3020,8 @@ var VoiceSessionManager = class {
|
|
|
2955
3020
|
listening: false,
|
|
2956
3021
|
userSpeaking: false,
|
|
2957
3022
|
lastUserSpeechEndedAt: null,
|
|
3023
|
+
lastDeviceTool: null,
|
|
3024
|
+
authorizedSessionId: null,
|
|
2958
3025
|
lastError: null
|
|
2959
3026
|
};
|
|
2960
3027
|
}
|
|
@@ -2971,12 +3038,15 @@ var VoiceSessionManager = class {
|
|
|
2971
3038
|
return this.canAccess(caller) ? this.state.transcript.slice() : [];
|
|
2972
3039
|
}
|
|
2973
3040
|
// ── WebView plumbing ─────────────────────────────────────────
|
|
2974
|
-
buildUri(skillSlug) {
|
|
3041
|
+
buildUri(skillSlug, persona, skills) {
|
|
2975
3042
|
const token = getToken2();
|
|
2976
3043
|
const tenant = getTenant5();
|
|
2977
3044
|
const actorId = getUser2()?.actorId || "";
|
|
2978
3045
|
const skillParam = skillSlug ? `&skillSlug=${encodeURIComponent(skillSlug)}` : "";
|
|
2979
|
-
|
|
3046
|
+
const p = persona && (persona.name || persona.style) ? { ...persona.name ? { name: persona.name } : {}, ...persona.style ? { style: persona.style } : {} } : null;
|
|
3047
|
+
const personaParam = p ? `&persona=${encodeURIComponent(JSON.stringify(p))}` : "";
|
|
3048
|
+
const skillsParam = skills === "none" ? "&skills=none" : "";
|
|
3049
|
+
return `${getVoiceApiUrl2()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || "")}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}${personaParam}${skillsParam}`;
|
|
2980
3050
|
}
|
|
2981
3051
|
/** Handle a postMessage from the WebView. `event` is the RN WebView event. */
|
|
2982
3052
|
handleMessage(event) {
|
|
@@ -3040,6 +3110,27 @@ var VoiceSessionManager = class {
|
|
|
3040
3110
|
this.sendDeviceToolList(data.correlationId);
|
|
3041
3111
|
break;
|
|
3042
3112
|
}
|
|
3113
|
+
// CORE's ALLOWSET for this session — what the model may ASK for. Core sends
|
|
3114
|
+
// it (even empty, on purpose) at session start. It is a NECESSARY gate, not
|
|
3115
|
+
// the whole story: a call must be in the allowset AND the device must
|
|
3116
|
+
// actually have the tool (the registry check inside callDeviceTool) — Core
|
|
3117
|
+
// says what may be asked, the device decides what it will do.
|
|
3118
|
+
//
|
|
3119
|
+
// Origin-checked (only the voice app may set it) and pinned ONCE per session
|
|
3120
|
+
// (a later message cannot widen it). An EMPTY allowset (arrived) refuses
|
|
3121
|
+
// everything but is distinct from NEVER-arrived (frozenTools stays null);
|
|
3122
|
+
// both fail closed here — we never fall back to the phone's own registry,
|
|
3123
|
+
// which is the seam this closes.
|
|
3124
|
+
case "deviceToolAllowset": {
|
|
3125
|
+
const sender = originOf(event?.nativeEvent?.url);
|
|
3126
|
+
const expected = originOf(getVoiceApiUrl2());
|
|
3127
|
+
if (!sender || !expected || sender !== expected) break;
|
|
3128
|
+
if (Array.isArray(data.tools) && typeof data.sessionId === "string" && data.sessionId !== this.frozenSessionId) {
|
|
3129
|
+
this.frozenTools = new Set(data.tools.filter((n) => typeof n === "string"));
|
|
3130
|
+
this.frozenSessionId = data.sessionId;
|
|
3131
|
+
}
|
|
3132
|
+
break;
|
|
3133
|
+
}
|
|
3043
3134
|
case "device-tool-call": {
|
|
3044
3135
|
const sender = originOf(event?.nativeEvent?.url);
|
|
3045
3136
|
const expected = originOf(getVoiceApiUrl2());
|
|
@@ -3047,14 +3138,64 @@ var VoiceSessionManager = class {
|
|
|
3047
3138
|
console.warn(`[voiceSession] REFUSED device tool call from "${sender || "unknown origin"}" (expected "${expected}")`);
|
|
3048
3139
|
break;
|
|
3049
3140
|
}
|
|
3141
|
+
if (data.name) this.setState({ lastDeviceTool: { name: data.name, at: Date.now() } });
|
|
3142
|
+
if (!this.isToolAuthorized(data.name)) {
|
|
3143
|
+
const notReady = this.frozenTools === null;
|
|
3144
|
+
console.warn(`[voiceSession] REFUSED "${data.name}" \u2014 ${notReady ? "allowset not received yet" : "not authorized for this session"}`);
|
|
3145
|
+
this.postDeviceToolRefusal(data.correlationId, data.name, notReady);
|
|
3146
|
+
break;
|
|
3147
|
+
}
|
|
3050
3148
|
void this.runDeviceTool(data);
|
|
3051
3149
|
break;
|
|
3052
3150
|
}
|
|
3053
3151
|
}
|
|
3054
3152
|
}
|
|
3055
|
-
/**
|
|
3153
|
+
/**
|
|
3154
|
+
* Final authorization = Core's frozen allowset ∩ the current valid local
|
|
3155
|
+
* registry, bound to the ACTIVE session. All must hold:
|
|
3156
|
+
* - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
|
|
3157
|
+
* the active state.sessionId) — a missing or stale/wrong-session allowset
|
|
3158
|
+
* fails closed;
|
|
3159
|
+
* - the tool is IN that allowset (Core approved it); AND
|
|
3160
|
+
* - the tool is STILL registered locally right now (removed-after-approval is
|
|
3161
|
+
* refused; a lazily-added tool Core never approved is refused) — and, on a
|
|
3162
|
+
* clean call, registered by the call's owner.
|
|
3163
|
+
* No fallback to the live registry alone, ever.
|
|
3164
|
+
*/
|
|
3165
|
+
isToolAuthorized(name) {
|
|
3166
|
+
if (typeof name !== "string") return false;
|
|
3167
|
+
if (this.frozenTools === null || this.frozenSessionId === null) return false;
|
|
3168
|
+
if (this.frozenSessionId !== this.state.sessionId) return false;
|
|
3169
|
+
if (!this.frozenTools.has(name)) return false;
|
|
3170
|
+
return hasDeviceTool(name, this.toolScope ?? void 0);
|
|
3171
|
+
}
|
|
3172
|
+
/** Tell the mini-app the call was refused — a stated reason, never a silent drop.
|
|
3173
|
+
* `notReady` distinguishes a transient "allowset not here yet" (retry) from a
|
|
3174
|
+
* final "not allowed in this session". */
|
|
3175
|
+
postDeviceToolRefusal(correlationId, name, notReady) {
|
|
3176
|
+
const payload = notReady ? {
|
|
3177
|
+
correlationId,
|
|
3178
|
+
ok: false,
|
|
3179
|
+
reason: "not_ready",
|
|
3180
|
+
error: "The voice session is still starting up \u2014 its tool allowset has not arrived yet. Try again in a moment."
|
|
3181
|
+
} : {
|
|
3182
|
+
correlationId,
|
|
3183
|
+
ok: false,
|
|
3184
|
+
reason: "not_authorized",
|
|
3185
|
+
error: `"${name}" is not available in this voice session.`
|
|
3186
|
+
};
|
|
3187
|
+
this.webView?.injectJavaScript?.(
|
|
3188
|
+
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
3189
|
+
JSON.stringify(payload)
|
|
3190
|
+
)}); true;`
|
|
3191
|
+
);
|
|
3192
|
+
}
|
|
3193
|
+
/** PROPOSE the phone's available tools — this is an offer to Core, not the
|
|
3194
|
+
* authorization set. Core sanitizes it and returns the accepted set in a
|
|
3195
|
+
* `deviceToolAllowset` message, which is what actually gates execution. */
|
|
3056
3196
|
sendDeviceToolList(correlationId) {
|
|
3057
|
-
const
|
|
3197
|
+
const scope = this.toolScope;
|
|
3198
|
+
const tools = listDeviceTools().filter((t) => scope === null || t.pluginId === scope).map((t) => ({
|
|
3058
3199
|
name: t.name,
|
|
3059
3200
|
description: t.description,
|
|
3060
3201
|
input_schema: t.input_schema
|
|
@@ -3132,6 +3273,10 @@ var VoiceSessionManager = class {
|
|
|
3132
3273
|
this.rejectPending(new Error("voice: reset"));
|
|
3133
3274
|
this.state = { ...IDLE };
|
|
3134
3275
|
this.owner = null;
|
|
3276
|
+
this.toolScope = null;
|
|
3277
|
+
this.pendingStarts.clear();
|
|
3278
|
+
this.frozenTools = null;
|
|
3279
|
+
this.frozenSessionId = null;
|
|
3135
3280
|
this.listeners.clear();
|
|
3136
3281
|
this.webView = null;
|
|
3137
3282
|
}
|