@ateam-ai-mobile/ui 0.1.7 → 0.1.9
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 +95 -7
- package/dist/index.d.ts +95 -7
- package/dist/index.js +219 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +220 -36
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/plugin/Plugin.tsx +4 -3
- package/src/voice/voiceSession.ts +307 -42
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;
|
|
@@ -2799,6 +2800,11 @@ var buildObserveScript = (text) => `
|
|
|
2799
2800
|
}
|
|
2800
2801
|
})(${JSON.stringify(text)}); true;
|
|
2801
2802
|
`;
|
|
2803
|
+
var DEVICE_TOOL_ERROR_MAX = 200;
|
|
2804
|
+
function clipError(message) {
|
|
2805
|
+
const chars = Array.from(message);
|
|
2806
|
+
return chars.length > DEVICE_TOOL_ERROR_MAX ? chars.slice(0, DEVICE_TOOL_ERROR_MAX - 1).join("") + "\u2026" : message;
|
|
2807
|
+
}
|
|
2802
2808
|
function originOf(url) {
|
|
2803
2809
|
if (!url) return null;
|
|
2804
2810
|
const m = /^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)/i.exec(url);
|
|
@@ -2814,6 +2820,7 @@ var IDLE = {
|
|
|
2814
2820
|
muted: false,
|
|
2815
2821
|
userSpeaking: false,
|
|
2816
2822
|
lastUserSpeechEndedAt: null,
|
|
2823
|
+
deviceToolCalls: [],
|
|
2817
2824
|
mounted: false,
|
|
2818
2825
|
webViewSource: null,
|
|
2819
2826
|
skillSlug: void 0
|
|
@@ -2835,6 +2842,45 @@ var VoiceSessionManager = class {
|
|
|
2835
2842
|
* conversation just because it declared voice.
|
|
2836
2843
|
*/
|
|
2837
2844
|
this.owner = null;
|
|
2845
|
+
/** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
|
|
2846
|
+
* The session is not 'connecting' yet, so a stop() arriving now would otherwise
|
|
2847
|
+
* be an idle no-op — and a start would then mount a call for a screen that has
|
|
2848
|
+
* already gone. A set, not one slot: two concurrent starts must BOTH be
|
|
2849
|
+
* cancellable. stop() cancels every pending start of its caller (the platform:
|
|
2850
|
+
* all of them); another plugin's stop cancels none. */
|
|
2851
|
+
this.pendingStarts = /* @__PURE__ */ new Set();
|
|
2852
|
+
/**
|
|
2853
|
+
* The device-tool names Core ALLOWED for this voice session — Core's sanitized
|
|
2854
|
+
* allowset, delivered to the phone in a `deviceToolAllowset` message.
|
|
2855
|
+
* Authorization gates against THIS (necessary, alongside the registry's can-do).
|
|
2856
|
+
*
|
|
2857
|
+
* WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
|
|
2858
|
+
* (sendDeviceToolList); CORE is the session authority and decides which are
|
|
2859
|
+
* accepted (session.device_tools). Freezing the phone's own proposal would let
|
|
2860
|
+
* a tool Core REJECTED still run locally. So the authz set is populated from
|
|
2861
|
+
* Core's accepted set, never from listDeviceTools(). Until Core's set arrives
|
|
2862
|
+
* this is null and every call FAILS CLOSED. Pinned once per session; the live
|
|
2863
|
+
* registry (which drifts via lazy loads / host registration / tenant changes)
|
|
2864
|
+
* never feeds it.
|
|
2865
|
+
*/
|
|
2866
|
+
this.frozenTools = null;
|
|
2867
|
+
/** The voice `sessionId` the current allowset is BOUND to. Authorization
|
|
2868
|
+
* requires it to equal the active session's id (state.sessionId), so a stale
|
|
2869
|
+
* allowset from a previous/other session is refused. */
|
|
2870
|
+
this.frozenSessionId = null;
|
|
2871
|
+
/** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
|
|
2872
|
+
* plugin's own device tools — not other solutions' tools that happen to be
|
|
2873
|
+
* registered on the same phone. null = every registered tool (a normal call,
|
|
2874
|
+
* or the platform's). Set by every start(); like `owner`, kept after stop.
|
|
2875
|
+
* ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
|
|
2876
|
+
* and the bundleManager registers a plugin's tools under that same id (never
|
|
2877
|
+
* the bundle's own short `id`), so exact equality is the whole check. */
|
|
2878
|
+
this.toolScope = null;
|
|
2879
|
+
/** Bumped whenever a session begins or ends. A device-tool call remembers the
|
|
2880
|
+
* epoch it arrived in, and its outcome is recorded only if that session is
|
|
2881
|
+
* still the current one — a tool that finishes after hang-up (or after a new
|
|
2882
|
+
* call started) must not show up as the new session's last call. */
|
|
2883
|
+
this.sessionEpoch = 0;
|
|
2838
2884
|
this.injectedTrackHook = INJECT_TRACK_HOOK;
|
|
2839
2885
|
}
|
|
2840
2886
|
/** The platform (built-in sheet, mic indicator, session host) sees and
|
|
@@ -2864,12 +2910,17 @@ var VoiceSessionManager = class {
|
|
|
2864
2910
|
async start(opts, caller = PLATFORM_VOICE_CALLER) {
|
|
2865
2911
|
if (this.state.callState === "connected") {
|
|
2866
2912
|
if (!this.canAccess(caller)) throw this.foreignError();
|
|
2867
|
-
return this.realSnapshot();
|
|
2913
|
+
return this.realSnapshot(caller);
|
|
2868
2914
|
}
|
|
2869
2915
|
if (this.state.callState === "connecting") {
|
|
2870
2916
|
if (!this.canAccess(caller)) throw this.foreignError();
|
|
2871
|
-
return this.waitForConnect();
|
|
2917
|
+
return this.waitForConnect(caller);
|
|
2918
|
+
}
|
|
2919
|
+
if (opts?.skills === "none" && opts?.skillSlug) {
|
|
2920
|
+
throw new Error("voice: skills:'none' (a clean call) cannot also hand the mic to a skill (skillSlug).");
|
|
2872
2921
|
}
|
|
2922
|
+
const ticket = { caller, cancelled: false };
|
|
2923
|
+
this.pendingStarts.add(ticket);
|
|
2873
2924
|
try {
|
|
2874
2925
|
await ensureDeviceToolsLoaded();
|
|
2875
2926
|
} catch (e) {
|
|
@@ -2879,7 +2930,22 @@ var VoiceSessionManager = class {
|
|
|
2879
2930
|
if (tokenNeedsRefresh(getToken2())) await refreshToken();
|
|
2880
2931
|
} catch {
|
|
2881
2932
|
}
|
|
2933
|
+
this.pendingStarts.delete(ticket);
|
|
2934
|
+
if (ticket.cancelled) throw new Error("voice: stopped before the session started");
|
|
2935
|
+
const after = this.getInternal().callState;
|
|
2936
|
+
if (after === "connected") {
|
|
2937
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
2938
|
+
return this.realSnapshot(caller);
|
|
2939
|
+
}
|
|
2940
|
+
if (after === "connecting") {
|
|
2941
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
2942
|
+
return this.waitForConnect(caller);
|
|
2943
|
+
}
|
|
2882
2944
|
this.owner = caller;
|
|
2945
|
+
this.toolScope = opts?.skills === "none" && typeof caller === "string" ? caller : null;
|
|
2946
|
+
this.frozenTools = null;
|
|
2947
|
+
this.frozenSessionId = null;
|
|
2948
|
+
this.sessionEpoch++;
|
|
2883
2949
|
const skillSlug = opts?.skillSlug;
|
|
2884
2950
|
this.setState({
|
|
2885
2951
|
callState: "connecting",
|
|
@@ -2888,16 +2954,29 @@ var VoiceSessionManager = class {
|
|
|
2888
2954
|
aiPartial: "",
|
|
2889
2955
|
userPartial: "",
|
|
2890
2956
|
muted: false,
|
|
2957
|
+
// A session that ended from inside the voice page (not via stop()) left
|
|
2958
|
+
// its calls here; the new session starts with none.
|
|
2959
|
+
deviceToolCalls: [],
|
|
2891
2960
|
mounted: true,
|
|
2892
2961
|
skillSlug,
|
|
2893
|
-
|
|
2962
|
+
persona: opts?.persona,
|
|
2963
|
+
webViewSource: { uri: this.buildUri(skillSlug, opts?.persona, opts?.skills) }
|
|
2894
2964
|
});
|
|
2895
|
-
return this.waitForConnect();
|
|
2965
|
+
return this.waitForConnect(caller);
|
|
2896
2966
|
}
|
|
2897
2967
|
stop(caller = PLATFORM_VOICE_CALLER) {
|
|
2898
2968
|
if (this.state.callState !== "idle" && !this.canAccess(caller)) throw this.foreignError();
|
|
2969
|
+
for (const t of [...this.pendingStarts]) {
|
|
2970
|
+
if (caller === PLATFORM_VOICE_CALLER || caller === t.caller) {
|
|
2971
|
+
t.cancelled = true;
|
|
2972
|
+
this.pendingStarts.delete(t);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2899
2975
|
this.webView?.injectJavaScript?.("window.hangUp && window.hangUp(); true;");
|
|
2900
2976
|
this.rejectPending(new Error("voice: session stopped before it connected"));
|
|
2977
|
+
this.frozenTools = null;
|
|
2978
|
+
this.frozenSessionId = null;
|
|
2979
|
+
this.sessionEpoch++;
|
|
2901
2980
|
this.setState({
|
|
2902
2981
|
...IDLE,
|
|
2903
2982
|
// keep the transcript of the call that just ended for any reader still up
|
|
@@ -2918,10 +2997,9 @@ var VoiceSessionManager = class {
|
|
|
2918
2997
|
}
|
|
2919
2998
|
/**
|
|
2920
2999
|
* 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.
|
|
3000
|
+
* asking it to speak. The cheap channel behind the gated props.voice.observe();
|
|
3001
|
+
* every item stays in the model's context for the call, so a caller sends only
|
|
3002
|
+
* material changes. Owner-scoped like every other control.
|
|
2925
3003
|
*/
|
|
2926
3004
|
observe(text, caller = PLATFORM_VOICE_CALLER) {
|
|
2927
3005
|
if (this.state.callState !== "connected") {
|
|
@@ -2931,9 +3009,11 @@ var VoiceSessionManager = class {
|
|
|
2931
3009
|
this.webView?.injectJavaScript?.(buildObserveScript(text));
|
|
2932
3010
|
}
|
|
2933
3011
|
getSnapshot(caller = PLATFORM_VOICE_CALLER) {
|
|
2934
|
-
return this.canAccess(caller) ? this.realSnapshot() : this.foreignSnapshot();
|
|
3012
|
+
return this.canAccess(caller) ? this.realSnapshot(caller) : this.foreignSnapshot();
|
|
2935
3013
|
}
|
|
2936
|
-
|
|
3014
|
+
/** The snapshot for a caller that may access the session. Everything is the
|
|
3015
|
+
* session's, except `lastDeviceTool`, which is scoped to the caller's tools. */
|
|
3016
|
+
realSnapshot(caller) {
|
|
2937
3017
|
const s = this.state;
|
|
2938
3018
|
return {
|
|
2939
3019
|
state: s.callState,
|
|
@@ -2943,9 +3023,23 @@ var VoiceSessionManager = class {
|
|
|
2943
3023
|
listening: !!s.userPartial,
|
|
2944
3024
|
userSpeaking: s.userSpeaking,
|
|
2945
3025
|
lastUserSpeechEndedAt: s.lastUserSpeechEndedAt,
|
|
3026
|
+
lastDeviceTool: this.lastDeviceToolFor(caller),
|
|
3027
|
+
authorizedSessionId: this.frozenSessionId,
|
|
2946
3028
|
lastError: s.error
|
|
2947
3029
|
};
|
|
2948
3030
|
}
|
|
3031
|
+
/**
|
|
3032
|
+
* The last finished device-tool call THIS caller may see. The platform (the
|
|
3033
|
+
* user's own surface) sees every call. A plugin sees only calls to tools it
|
|
3034
|
+
* registered — even on a session it owns, the model can call another
|
|
3035
|
+
* solution's tool (a normal call offers every registered tool), and that
|
|
3036
|
+
* tool's name and error are not this plugin's to read.
|
|
3037
|
+
*/
|
|
3038
|
+
lastDeviceToolFor(caller) {
|
|
3039
|
+
const calls = this.state.deviceToolCalls;
|
|
3040
|
+
if (caller === PLATFORM_VOICE_CALLER) return calls.length ? calls[calls.length - 1].call : null;
|
|
3041
|
+
return calls.find((r) => r.owner === caller)?.call ?? null;
|
|
3042
|
+
}
|
|
2949
3043
|
foreignSnapshot() {
|
|
2950
3044
|
return {
|
|
2951
3045
|
state: "idle",
|
|
@@ -2955,6 +3049,8 @@ var VoiceSessionManager = class {
|
|
|
2955
3049
|
listening: false,
|
|
2956
3050
|
userSpeaking: false,
|
|
2957
3051
|
lastUserSpeechEndedAt: null,
|
|
3052
|
+
lastDeviceTool: null,
|
|
3053
|
+
authorizedSessionId: null,
|
|
2958
3054
|
lastError: null
|
|
2959
3055
|
};
|
|
2960
3056
|
}
|
|
@@ -2971,12 +3067,15 @@ var VoiceSessionManager = class {
|
|
|
2971
3067
|
return this.canAccess(caller) ? this.state.transcript.slice() : [];
|
|
2972
3068
|
}
|
|
2973
3069
|
// ── WebView plumbing ─────────────────────────────────────────
|
|
2974
|
-
buildUri(skillSlug) {
|
|
3070
|
+
buildUri(skillSlug, persona, skills) {
|
|
2975
3071
|
const token = getToken2();
|
|
2976
3072
|
const tenant = getTenant5();
|
|
2977
3073
|
const actorId = getUser2()?.actorId || "";
|
|
2978
3074
|
const skillParam = skillSlug ? `&skillSlug=${encodeURIComponent(skillSlug)}` : "";
|
|
2979
|
-
|
|
3075
|
+
const p = persona && (persona.name || persona.style) ? { ...persona.name ? { name: persona.name } : {}, ...persona.style ? { style: persona.style } : {} } : null;
|
|
3076
|
+
const personaParam = p ? `&persona=${encodeURIComponent(JSON.stringify(p))}` : "";
|
|
3077
|
+
const skillsParam = skills === "none" ? "&skills=none" : "";
|
|
3078
|
+
return `${getVoiceApiUrl2()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || "")}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}${personaParam}${skillsParam}`;
|
|
2980
3079
|
}
|
|
2981
3080
|
/** Handle a postMessage from the WebView. `event` is the RN WebView event. */
|
|
2982
3081
|
handleMessage(event) {
|
|
@@ -3040,6 +3139,27 @@ var VoiceSessionManager = class {
|
|
|
3040
3139
|
this.sendDeviceToolList(data.correlationId);
|
|
3041
3140
|
break;
|
|
3042
3141
|
}
|
|
3142
|
+
// CORE's ALLOWSET for this session — what the model may ASK for. Core sends
|
|
3143
|
+
// it (even empty, on purpose) at session start. It is a NECESSARY gate, not
|
|
3144
|
+
// the whole story: a call must be in the allowset AND the device must
|
|
3145
|
+
// actually have the tool (the registry check inside callDeviceTool) — Core
|
|
3146
|
+
// says what may be asked, the device decides what it will do.
|
|
3147
|
+
//
|
|
3148
|
+
// Origin-checked (only the voice app may set it) and pinned ONCE per session
|
|
3149
|
+
// (a later message cannot widen it). An EMPTY allowset (arrived) refuses
|
|
3150
|
+
// everything but is distinct from NEVER-arrived (frozenTools stays null);
|
|
3151
|
+
// both fail closed here — we never fall back to the phone's own registry,
|
|
3152
|
+
// which is the seam this closes.
|
|
3153
|
+
case "deviceToolAllowset": {
|
|
3154
|
+
const sender = originOf(event?.nativeEvent?.url);
|
|
3155
|
+
const expected = originOf(getVoiceApiUrl2());
|
|
3156
|
+
if (!sender || !expected || sender !== expected) break;
|
|
3157
|
+
if (Array.isArray(data.tools) && typeof data.sessionId === "string" && data.sessionId !== this.frozenSessionId) {
|
|
3158
|
+
this.frozenTools = new Set(data.tools.filter((n) => typeof n === "string"));
|
|
3159
|
+
this.frozenSessionId = data.sessionId;
|
|
3160
|
+
}
|
|
3161
|
+
break;
|
|
3162
|
+
}
|
|
3043
3163
|
case "device-tool-call": {
|
|
3044
3164
|
const sender = originOf(event?.nativeEvent?.url);
|
|
3045
3165
|
const expected = originOf(getVoiceApiUrl2());
|
|
@@ -3047,40 +3167,100 @@ var VoiceSessionManager = class {
|
|
|
3047
3167
|
console.warn(`[voiceSession] REFUSED device tool call from "${sender || "unknown origin"}" (expected "${expected}")`);
|
|
3048
3168
|
break;
|
|
3049
3169
|
}
|
|
3050
|
-
void this.
|
|
3170
|
+
void this.handleDeviceToolCall(data);
|
|
3051
3171
|
break;
|
|
3052
3172
|
}
|
|
3053
3173
|
}
|
|
3054
3174
|
}
|
|
3055
|
-
/**
|
|
3175
|
+
/**
|
|
3176
|
+
* One device-tool call, arrival to outcome: validate → authorize → run. The
|
|
3177
|
+
* answer goes back to the mini-app (the model reads it) AND becomes
|
|
3178
|
+
* `lastDeviceTool` — the same object, recorded only once it is known, so a
|
|
3179
|
+
* refused call never looks like one that ran.
|
|
3180
|
+
*/
|
|
3181
|
+
async handleDeviceToolCall(data) {
|
|
3182
|
+
const { correlationId, name, args, timeoutMs } = data || {};
|
|
3183
|
+
const at = Date.now();
|
|
3184
|
+
const epoch = this.sessionEpoch;
|
|
3185
|
+
const toolName = typeof name === "string" ? name : "";
|
|
3186
|
+
const sessionOwner = this.owner;
|
|
3187
|
+
const owner = typeof sessionOwner === "string" && toolName && hasDeviceTool(toolName, sessionOwner) ? sessionOwner : null;
|
|
3188
|
+
let answer;
|
|
3189
|
+
if (!toolName) {
|
|
3190
|
+
answer = { ok: false, reason: "invalid", error: "The device tool call named no tool." };
|
|
3191
|
+
} else if (!correlationId) {
|
|
3192
|
+
answer = {
|
|
3193
|
+
ok: false,
|
|
3194
|
+
reason: "invalid",
|
|
3195
|
+
error: `The call to "${toolName}" carried no correlationId, so it could not be answered.`
|
|
3196
|
+
};
|
|
3197
|
+
} else if (!this.isToolAuthorized(toolName)) {
|
|
3198
|
+
const notReady = this.frozenTools === null;
|
|
3199
|
+
console.warn(`[voiceSession] REFUSED "${toolName}" \u2014 ${notReady ? "allowset not received yet" : "not authorized for this session"}`);
|
|
3200
|
+
answer = notReady ? {
|
|
3201
|
+
ok: false,
|
|
3202
|
+
reason: "not_ready",
|
|
3203
|
+
error: "The voice session is still starting up \u2014 its tool allowset has not arrived yet. Try again in a moment."
|
|
3204
|
+
} : { ok: false, reason: "not_authorized", error: `"${toolName}" is not available in this voice session.` };
|
|
3205
|
+
} else {
|
|
3206
|
+
answer = await this.runDeviceTool(toolName, args, timeoutMs);
|
|
3207
|
+
}
|
|
3208
|
+
if (correlationId) this.postDeviceToolResult({ correlationId, ...answer });
|
|
3209
|
+
if (epoch !== this.sessionEpoch) return;
|
|
3210
|
+
const ms = Math.max(0, Date.now() - at);
|
|
3211
|
+
const call = answer.ok ? { name: toolName, at, ms, ok: true, reason: null, error: null } : { name: toolName, at, ms, ok: false, reason: answer.reason, error: clipError(answer.error) };
|
|
3212
|
+
this.setState({
|
|
3213
|
+
deviceToolCalls: [...this.state.deviceToolCalls.filter((r) => r.owner !== owner), { owner, call }]
|
|
3214
|
+
});
|
|
3215
|
+
}
|
|
3216
|
+
/**
|
|
3217
|
+
* Final authorization = Core's frozen allowset ∩ the current valid local
|
|
3218
|
+
* registry, bound to the ACTIVE session. All must hold:
|
|
3219
|
+
* - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
|
|
3220
|
+
* the active state.sessionId) — a missing or stale/wrong-session allowset
|
|
3221
|
+
* fails closed;
|
|
3222
|
+
* - the tool is IN that allowset (Core approved it); AND
|
|
3223
|
+
* - the tool is STILL registered locally right now (removed-after-approval is
|
|
3224
|
+
* refused; a lazily-added tool Core never approved is refused) — and, on a
|
|
3225
|
+
* clean call, registered by the call's owner.
|
|
3226
|
+
* No fallback to the live registry alone, ever.
|
|
3227
|
+
*/
|
|
3228
|
+
isToolAuthorized(name) {
|
|
3229
|
+
if (typeof name !== "string") return false;
|
|
3230
|
+
if (this.frozenTools === null || this.frozenSessionId === null) return false;
|
|
3231
|
+
if (this.frozenSessionId !== this.state.sessionId) return false;
|
|
3232
|
+
if (!this.frozenTools.has(name)) return false;
|
|
3233
|
+
return hasDeviceTool(name, this.toolScope ?? void 0);
|
|
3234
|
+
}
|
|
3235
|
+
/** Answer the mini-app's pending device-tool request (a call's outcome, or the
|
|
3236
|
+
* tool list) — it resolves the request with this payload by correlationId. */
|
|
3237
|
+
postDeviceToolResult(payload) {
|
|
3238
|
+
this.webView?.injectJavaScript?.(
|
|
3239
|
+
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
3240
|
+
JSON.stringify(payload)
|
|
3241
|
+
)}); true;`
|
|
3242
|
+
);
|
|
3243
|
+
}
|
|
3244
|
+
/** PROPOSE the phone's available tools — this is an offer to Core, not the
|
|
3245
|
+
* authorization set. Core sanitizes it and returns the accepted set in a
|
|
3246
|
+
* `deviceToolAllowset` message, which is what actually gates execution. */
|
|
3056
3247
|
sendDeviceToolList(correlationId) {
|
|
3057
|
-
const
|
|
3248
|
+
const scope = this.toolScope;
|
|
3249
|
+
const tools = listDeviceTools().filter((t) => scope === null || t.pluginId === scope).map((t) => ({
|
|
3058
3250
|
name: t.name,
|
|
3059
3251
|
description: t.description,
|
|
3060
3252
|
input_schema: t.input_schema
|
|
3061
3253
|
}));
|
|
3062
|
-
this.
|
|
3063
|
-
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
3064
|
-
JSON.stringify({ correlationId, ok: true, tools })
|
|
3065
|
-
)}); true;`
|
|
3066
|
-
);
|
|
3254
|
+
this.postDeviceToolResult({ correlationId, ok: true, tools });
|
|
3067
3255
|
}
|
|
3068
|
-
/** Run
|
|
3069
|
-
async runDeviceTool(
|
|
3070
|
-
const { correlationId, name, args, timeoutMs } = data || {};
|
|
3071
|
-
if (!correlationId || typeof name !== "string") return;
|
|
3072
|
-
let payload;
|
|
3256
|
+
/** Run an authorized device tool. Never throws: every failure is an answer. */
|
|
3257
|
+
async runDeviceTool(name, args, timeoutMs) {
|
|
3073
3258
|
try {
|
|
3074
3259
|
const outcome = await callDeviceTool(name, args || {}, { timeoutMs });
|
|
3075
|
-
|
|
3260
|
+
return outcome.ok ? { ok: true, result: outcome.result } : { ok: false, reason: outcome.reason, error: outcome.error };
|
|
3076
3261
|
} catch (err) {
|
|
3077
|
-
|
|
3262
|
+
return { ok: false, reason: "failed", error: err?.message || String(err) };
|
|
3078
3263
|
}
|
|
3079
|
-
this.webView?.injectJavaScript?.(
|
|
3080
|
-
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
3081
|
-
JSON.stringify(payload)
|
|
3082
|
-
)}); true;`
|
|
3083
|
-
);
|
|
3084
3264
|
}
|
|
3085
3265
|
// ── internals ────────────────────────────────────────────────
|
|
3086
3266
|
setState(partial) {
|
|
@@ -3100,22 +3280,21 @@ var VoiceSessionManager = class {
|
|
|
3100
3280
|
}
|
|
3101
3281
|
}
|
|
3102
3282
|
}
|
|
3103
|
-
waitForConnect() {
|
|
3283
|
+
waitForConnect(caller) {
|
|
3104
3284
|
return new Promise((resolve, reject) => {
|
|
3105
3285
|
const timer = setTimeout(() => {
|
|
3106
3286
|
this.pending = this.pending.filter((p) => p.timer !== timer);
|
|
3107
3287
|
reject(new Error("voice: timed out waiting to connect (is VoiceSessionHost mounted?)"));
|
|
3108
3288
|
}, CONNECT_TIMEOUT_MS);
|
|
3109
|
-
this.pending.push({ resolve, reject, timer });
|
|
3289
|
+
this.pending.push({ caller, resolve, reject, timer });
|
|
3110
3290
|
});
|
|
3111
3291
|
}
|
|
3112
3292
|
resolvePending() {
|
|
3113
|
-
const snap = this.realSnapshot();
|
|
3114
3293
|
const waiting = this.pending;
|
|
3115
3294
|
this.pending = [];
|
|
3116
3295
|
for (const p of waiting) {
|
|
3117
3296
|
clearTimeout(p.timer);
|
|
3118
|
-
p.resolve(
|
|
3297
|
+
p.resolve(this.realSnapshot(p.caller));
|
|
3119
3298
|
}
|
|
3120
3299
|
}
|
|
3121
3300
|
rejectPending(err) {
|
|
@@ -3132,6 +3311,11 @@ var VoiceSessionManager = class {
|
|
|
3132
3311
|
this.rejectPending(new Error("voice: reset"));
|
|
3133
3312
|
this.state = { ...IDLE };
|
|
3134
3313
|
this.owner = null;
|
|
3314
|
+
this.toolScope = null;
|
|
3315
|
+
this.pendingStarts.clear();
|
|
3316
|
+
this.frozenTools = null;
|
|
3317
|
+
this.frozenSessionId = null;
|
|
3318
|
+
this.sessionEpoch++;
|
|
3135
3319
|
this.listeners.clear();
|
|
3136
3320
|
this.webView = null;
|
|
3137
3321
|
}
|