@flow-like/widget-sdk 0.1.1
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/README.md +258 -0
- package/package.json +42 -0
- package/src/contract.ts +121 -0
- package/src/define.ts +91 -0
- package/src/geometry.ts +277 -0
- package/src/global.d.ts +10 -0
- package/src/index.ts +44 -0
- package/src/llm-schemas.ts +1385 -0
- package/src/llm.ts +283 -0
- package/src/media.ts +107 -0
- package/src/microphone.ts +133 -0
- package/src/mount.ts +427 -0
- package/src/protocol.ts +178 -0
- package/src/query.ts +58 -0
- package/src/react.ts +34 -0
- package/src/standalone.ts +54 -0
- package/src/theme.ts +74 -0
- package/src/validate.ts +336 -0
- package/src/vanilla.ts +1 -0
package/src/llm.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import type { JsonSchema } from "./contract";
|
|
2
|
+
import { LLM_SCHEMAS } from "./llm-schemas";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Flow-Like model types a widget can exchange with model and agent nodes. The
|
|
6
|
+
* bundler turns an `@llm` type into the contract marker
|
|
7
|
+
* `{"type":"object","x-flow-like-type":"llm","x-llm":"<kind>"}`. Query Widget
|
|
8
|
+
* gives such a pin the exact schema of the native Rust type, so model outputs
|
|
9
|
+
* connect directly, and `validateSchema` checks values against that schema.
|
|
10
|
+
* These TypeScript shapes follow `flow_like_model_provider` serialization.
|
|
11
|
+
*/
|
|
12
|
+
export type LlmKind = keyof typeof LLM_SCHEMAS;
|
|
13
|
+
|
|
14
|
+
export type LlmRole = "system" | "user" | "assistant" | "function" | "tool";
|
|
15
|
+
|
|
16
|
+
export interface LlmImageUrl {
|
|
17
|
+
url: string;
|
|
18
|
+
detail?: string | null;
|
|
19
|
+
media_type?: string | null;
|
|
20
|
+
additional_params?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface LlmMediaMembers {
|
|
24
|
+
media_type?: string | null;
|
|
25
|
+
additional_params?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One ordered text or media part of a message. */
|
|
29
|
+
export type LlmContent =
|
|
30
|
+
| { type: "text"; text: string }
|
|
31
|
+
| { type: "image_url"; image_url: LlmImageUrl }
|
|
32
|
+
| ({ type: "audio_url"; audio_url: string } & LlmMediaMembers)
|
|
33
|
+
| ({ type: "video_url"; video_url: string } & LlmMediaMembers)
|
|
34
|
+
| ({ type: "document_url"; document_url: string } & LlmMediaMembers);
|
|
35
|
+
|
|
36
|
+
export interface LlmUrlCitation {
|
|
37
|
+
end_index: number;
|
|
38
|
+
start_index: number;
|
|
39
|
+
title: string;
|
|
40
|
+
url: string;
|
|
41
|
+
content?: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LlmAnnotation {
|
|
45
|
+
type: string;
|
|
46
|
+
url_citation?: LlmUrlCitation | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LlmToolCall {
|
|
50
|
+
id: string;
|
|
51
|
+
type: string;
|
|
52
|
+
function: { name: string; arguments: string };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LlmHistoryMessage {
|
|
56
|
+
role: LlmRole;
|
|
57
|
+
content: string | LlmContent[];
|
|
58
|
+
name?: string | null;
|
|
59
|
+
tool_calls?: LlmToolCall[] | null;
|
|
60
|
+
tool_call_id?: string | null;
|
|
61
|
+
annotations?: LlmAnnotation[] | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A Flow-Like History: the input of model and agent nodes.
|
|
66
|
+
* @llm History
|
|
67
|
+
*/
|
|
68
|
+
export interface LlmHistory {
|
|
69
|
+
model: string;
|
|
70
|
+
messages: LlmHistoryMessage[];
|
|
71
|
+
preset?: string | null;
|
|
72
|
+
stream?: boolean | null;
|
|
73
|
+
stream_options?: { include_usage: boolean } | null;
|
|
74
|
+
max_completion_tokens?: number | null;
|
|
75
|
+
top_p?: number | null;
|
|
76
|
+
temperature?: number | null;
|
|
77
|
+
thinking?: "off" | "low" | "mid" | "high" | null;
|
|
78
|
+
seed?: number | null;
|
|
79
|
+
presence_penalty?: number | null;
|
|
80
|
+
frequency_penalty?: number | null;
|
|
81
|
+
user?: string | null;
|
|
82
|
+
stop?: string[] | null;
|
|
83
|
+
response_format?: unknown;
|
|
84
|
+
n?: number | null;
|
|
85
|
+
/** Tool definitions; see the native History schema for their parameters. */
|
|
86
|
+
tools?: unknown[] | null;
|
|
87
|
+
tool_choice?: unknown;
|
|
88
|
+
usage?: { include: boolean } | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface LlmUsage {
|
|
92
|
+
completion_tokens: number;
|
|
93
|
+
prompt_tokens: number;
|
|
94
|
+
total_tokens: number;
|
|
95
|
+
cost?: number | null;
|
|
96
|
+
prompt_tokens_details?: Record<string, number | null> | null;
|
|
97
|
+
completion_tokens_details?: Record<string, number | null> | null;
|
|
98
|
+
upstream_inference_cost?: Record<string, number | null> | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface LlmTopLogProbs {
|
|
102
|
+
token: string;
|
|
103
|
+
logprob: number;
|
|
104
|
+
bytes?: number[] | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface LlmTokenLogProbs extends LlmTopLogProbs {
|
|
108
|
+
top_logprobs?: LlmTopLogProbs[] | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface LlmLogProbs {
|
|
112
|
+
content?: LlmTokenLogProbs[] | null;
|
|
113
|
+
refusal?: LlmTokenLogProbs[] | null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface LlmFunctionCall {
|
|
117
|
+
index?: number | null;
|
|
118
|
+
id: string;
|
|
119
|
+
type?: string | null;
|
|
120
|
+
function: { name: string; arguments: string };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface LlmResponseMessage {
|
|
124
|
+
role: string;
|
|
125
|
+
content?: string | null;
|
|
126
|
+
/** Ordered media-aware content; text-only responses use `content`. */
|
|
127
|
+
content_parts?: LlmContent[];
|
|
128
|
+
refusal?: string | null;
|
|
129
|
+
annotations?: LlmAnnotation[] | null;
|
|
130
|
+
audio?: {
|
|
131
|
+
data: string;
|
|
132
|
+
expires_at?: number | null;
|
|
133
|
+
id: string;
|
|
134
|
+
transcript?: string | null;
|
|
135
|
+
} | null;
|
|
136
|
+
reasoning?: string | null;
|
|
137
|
+
tool_calls: LlmFunctionCall[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A Flow-Like Response: the final output of model and agent nodes.
|
|
142
|
+
* @llm Response
|
|
143
|
+
*/
|
|
144
|
+
export interface LlmResponse {
|
|
145
|
+
id?: string | null;
|
|
146
|
+
choices: {
|
|
147
|
+
index: number;
|
|
148
|
+
finish_reason: string;
|
|
149
|
+
message: LlmResponseMessage;
|
|
150
|
+
logprobs?: LlmLogProbs | null;
|
|
151
|
+
}[];
|
|
152
|
+
created?: number | null;
|
|
153
|
+
model?: string | null;
|
|
154
|
+
service_tier?: string | null;
|
|
155
|
+
system_fingerprint?: string | null;
|
|
156
|
+
object?: string | null;
|
|
157
|
+
usage: LlmUsage;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface LlmDeltaFunctionCall {
|
|
161
|
+
index?: number | null;
|
|
162
|
+
id?: string | null;
|
|
163
|
+
type?: string | null;
|
|
164
|
+
function: { name?: string | null; arguments?: string | null };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface LlmDelta {
|
|
168
|
+
role?: string | null;
|
|
169
|
+
content?: string | null;
|
|
170
|
+
content_parts?: LlmContent[] | null;
|
|
171
|
+
tool_calls?: LlmDeltaFunctionCall[] | null;
|
|
172
|
+
refusal?: string | null;
|
|
173
|
+
reasoning?: string | null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A Flow-Like ResponseChunk: one streamed delta from model and agent nodes.
|
|
178
|
+
* @llm ResponseChunk
|
|
179
|
+
*/
|
|
180
|
+
export interface LlmResponseChunk {
|
|
181
|
+
id: string;
|
|
182
|
+
choices: {
|
|
183
|
+
index: number;
|
|
184
|
+
delta?: LlmDelta | null;
|
|
185
|
+
finish_reason?: string | null;
|
|
186
|
+
logprobs?: LlmLogProbs | null;
|
|
187
|
+
}[];
|
|
188
|
+
created?: number | null;
|
|
189
|
+
model?: string | null;
|
|
190
|
+
service_tier?: string | null;
|
|
191
|
+
system_fingerprint?: string | null;
|
|
192
|
+
usage?: LlmUsage | null;
|
|
193
|
+
x_prefill_progress?: number | null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function isLlmKind(value: unknown): value is LlmKind {
|
|
197
|
+
return typeof value === "string" && Object.hasOwn(LLM_SCHEMAS, value);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Keys whose values are data, not schemas, and keys whose values map names to schemas.
|
|
201
|
+
const DATA_KEYS = new Set(["default", "examples", "enum", "const"]);
|
|
202
|
+
const SCHEMA_MAPS = new Set([
|
|
203
|
+
"properties",
|
|
204
|
+
"patternProperties",
|
|
205
|
+
"$defs",
|
|
206
|
+
"definitions",
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
function inline(
|
|
210
|
+
value: unknown,
|
|
211
|
+
defs: Record<string, unknown>,
|
|
212
|
+
stack: string[],
|
|
213
|
+
schemaMap = false,
|
|
214
|
+
): unknown {
|
|
215
|
+
// The validator reads schema objects only; `true` accepts everything.
|
|
216
|
+
if (value === true) return {};
|
|
217
|
+
if (Array.isArray(value))
|
|
218
|
+
return value.map((item) => inline(item, defs, stack));
|
|
219
|
+
if (typeof value !== "object" || value === null) return value;
|
|
220
|
+
if (schemaMap) {
|
|
221
|
+
return Object.fromEntries(
|
|
222
|
+
Object.entries(value).map(([key, entry]) => [
|
|
223
|
+
key,
|
|
224
|
+
inline(entry, defs, stack),
|
|
225
|
+
]),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const {
|
|
229
|
+
$ref,
|
|
230
|
+
$defs: _defs,
|
|
231
|
+
$schema: _schema,
|
|
232
|
+
...rest
|
|
233
|
+
} = value as Record<string, unknown>;
|
|
234
|
+
const result: JsonSchema = {};
|
|
235
|
+
for (const [key, entry] of Object.entries(rest)) {
|
|
236
|
+
result[key] = DATA_KEYS.has(key)
|
|
237
|
+
? entry
|
|
238
|
+
: inline(entry, defs, stack, SCHEMA_MAPS.has(key));
|
|
239
|
+
}
|
|
240
|
+
if (typeof $ref !== "string") return result;
|
|
241
|
+
const name = $ref.startsWith("#/$defs/")
|
|
242
|
+
? $ref.slice("#/$defs/".length)
|
|
243
|
+
: undefined;
|
|
244
|
+
if (name === undefined || !Object.hasOwn(defs, name)) {
|
|
245
|
+
throw new Error(`Unresolvable ${$ref} in the Flow-Like LLM schemas`);
|
|
246
|
+
}
|
|
247
|
+
// Tool parameter schemas nest themselves; below their first level they stay open.
|
|
248
|
+
if (stack.includes(name)) return result;
|
|
249
|
+
return {
|
|
250
|
+
...(inline(defs[name], defs, [...stack, name]) as JsonSchema),
|
|
251
|
+
...result,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const standalone = new Map<LlmKind, JsonSchema>();
|
|
256
|
+
|
|
257
|
+
/** The native schema for a Flow-Like model type with its `$defs` inlined, as `validateSchema` needs. */
|
|
258
|
+
export function llmSchema(kind: LlmKind): JsonSchema {
|
|
259
|
+
let schema = standalone.get(kind);
|
|
260
|
+
if (!schema) {
|
|
261
|
+
const source = LLM_SCHEMAS[kind];
|
|
262
|
+
schema = inline(
|
|
263
|
+
source,
|
|
264
|
+
(source.$defs ?? {}) as Record<string, unknown>,
|
|
265
|
+
[],
|
|
266
|
+
) as JsonSchema;
|
|
267
|
+
standalone.set(kind, schema);
|
|
268
|
+
}
|
|
269
|
+
return schema;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The native schema a contract `llm` marker names, or undefined for other
|
|
274
|
+
* schemas. Throws for a malformed marker so it is never widened.
|
|
275
|
+
*/
|
|
276
|
+
export function markedLlmSchema(schema: JsonSchema): JsonSchema | undefined {
|
|
277
|
+
const marked = schema["x-flow-like-type"] === "llm";
|
|
278
|
+
if (!marked && !("x-llm" in schema)) return undefined;
|
|
279
|
+
const kind = schema["x-llm"];
|
|
280
|
+
if (!marked || !isLlmKind(kind))
|
|
281
|
+
throw new Error("Invalid Flow-Like LLM type marker");
|
|
282
|
+
return llmSchema(kind);
|
|
283
|
+
}
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { atom } from "nanostores";
|
|
2
|
+
import type {
|
|
3
|
+
FlwEnvelope,
|
|
4
|
+
FlwMessageType,
|
|
5
|
+
FlwPayloadMap,
|
|
6
|
+
InitCapabilities,
|
|
7
|
+
} from "./protocol";
|
|
8
|
+
export interface WidgetPublicMediaGrant {
|
|
9
|
+
id: string;
|
|
10
|
+
url: string;
|
|
11
|
+
}
|
|
12
|
+
export interface WidgetMediaState {
|
|
13
|
+
id?: string;
|
|
14
|
+
state: "idle" | "loading" | "playing" | "paused" | "error";
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface MediaPlayPayload {
|
|
18
|
+
requestId: string;
|
|
19
|
+
mediaId: string;
|
|
20
|
+
}
|
|
21
|
+
export interface MediaResultPayload {
|
|
22
|
+
requestId: string;
|
|
23
|
+
ok: boolean;
|
|
24
|
+
}
|
|
25
|
+
export function createWidgetMediaClient(
|
|
26
|
+
post: <T extends FlwMessageType>(type: T, payload: FlwPayloadMap[T]) => void,
|
|
27
|
+
capabilities: () => InitCapabilities,
|
|
28
|
+
) {
|
|
29
|
+
const $media = atom<WidgetMediaState>({ state: "idle" });
|
|
30
|
+
let sequence = 0;
|
|
31
|
+
let disposed = false;
|
|
32
|
+
const pending = new Map<
|
|
33
|
+
string,
|
|
34
|
+
{
|
|
35
|
+
resolve: () => void;
|
|
36
|
+
reject: (error: Error) => void;
|
|
37
|
+
timer: ReturnType<typeof setTimeout>;
|
|
38
|
+
}
|
|
39
|
+
>();
|
|
40
|
+
const settle = (id: string, ok: boolean) => {
|
|
41
|
+
const row = pending.get(id);
|
|
42
|
+
if (!row) return;
|
|
43
|
+
clearTimeout(row.timer);
|
|
44
|
+
pending.delete(id);
|
|
45
|
+
if (ok) row.resolve();
|
|
46
|
+
else
|
|
47
|
+
row.reject(
|
|
48
|
+
new Error("Media playback unavailable; use the host playback control"),
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
$media,
|
|
53
|
+
playMedia(id: string): Promise<void> {
|
|
54
|
+
if (
|
|
55
|
+
disposed ||
|
|
56
|
+
!capabilities().media ||
|
|
57
|
+
!capabilities().mediaIds?.includes(id)
|
|
58
|
+
)
|
|
59
|
+
return Promise.reject(new Error("Media is not granted to this widget"));
|
|
60
|
+
if (pending.size >= 4)
|
|
61
|
+
return Promise.reject(
|
|
62
|
+
new Error("Media playback request already pending"),
|
|
63
|
+
);
|
|
64
|
+
const requestId = `media-${++sequence}`;
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
pending.set(requestId, {
|
|
67
|
+
resolve,
|
|
68
|
+
reject,
|
|
69
|
+
timer: setTimeout(() => settle(requestId, false), 15000),
|
|
70
|
+
});
|
|
71
|
+
post("media:play", { requestId, mediaId: id });
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
pauseMedia() {
|
|
75
|
+
if (!disposed) post("media:pause", {});
|
|
76
|
+
},
|
|
77
|
+
stopMedia() {
|
|
78
|
+
if (!disposed) post("media:stop", {});
|
|
79
|
+
},
|
|
80
|
+
handle(envelope: FlwEnvelope) {
|
|
81
|
+
if (envelope.type === "media:result") {
|
|
82
|
+
const result = envelope.payload as MediaResultPayload;
|
|
83
|
+
if (result && typeof result.requestId === "string")
|
|
84
|
+
settle(result.requestId, result.ok === true);
|
|
85
|
+
} else if (envelope.type === "media:state") {
|
|
86
|
+
const state = envelope.payload as WidgetMediaState;
|
|
87
|
+
if (
|
|
88
|
+
state &&
|
|
89
|
+
["idle", "loading", "playing", "paused", "error"].includes(
|
|
90
|
+
state.state,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
$media.set(state);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
reset() {
|
|
97
|
+
for (const id of pending.keys()) settle(id, false);
|
|
98
|
+
$media.set({ state: "idle" });
|
|
99
|
+
},
|
|
100
|
+
dispose() {
|
|
101
|
+
disposed = true;
|
|
102
|
+
post("media:stop", {});
|
|
103
|
+
for (const id of pending.keys()) settle(id, false);
|
|
104
|
+
$media.set({ state: "idle" });
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FlwEnvelope,
|
|
3
|
+
FlwMessageType,
|
|
4
|
+
FlwPayloadMap,
|
|
5
|
+
InitCapabilities,
|
|
6
|
+
} from "./protocol";
|
|
7
|
+
|
|
8
|
+
export interface WidgetAudioOptions {
|
|
9
|
+
maxDurationMs?: number;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}
|
|
12
|
+
export interface WidgetAudioResult {
|
|
13
|
+
bytes: ArrayBuffer;
|
|
14
|
+
mimeType: string;
|
|
15
|
+
status: number;
|
|
16
|
+
}
|
|
17
|
+
export interface AudioRequestPayload {
|
|
18
|
+
requestId: string;
|
|
19
|
+
maxDurationMs: number;
|
|
20
|
+
}
|
|
21
|
+
export interface MicrophoneResultPayload {
|
|
22
|
+
requestId: string;
|
|
23
|
+
ok: boolean;
|
|
24
|
+
bytes?: ArrayBuffer;
|
|
25
|
+
mimeType?: string;
|
|
26
|
+
status?: number;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface RequestIdPayload {
|
|
30
|
+
requestId: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createWidgetMicrophoneClient(
|
|
34
|
+
post: <T extends FlwMessageType>(type: T, payload: FlwPayloadMap[T]) => void,
|
|
35
|
+
capabilities: () => InitCapabilities,
|
|
36
|
+
) {
|
|
37
|
+
let sequence = 0;
|
|
38
|
+
let disposed = false;
|
|
39
|
+
let audioId: string | undefined;
|
|
40
|
+
const pending = new Map<
|
|
41
|
+
string,
|
|
42
|
+
{
|
|
43
|
+
resolve: (value: WidgetAudioResult) => void;
|
|
44
|
+
reject: (reason: Error) => void;
|
|
45
|
+
cleanup: () => void;
|
|
46
|
+
}
|
|
47
|
+
>();
|
|
48
|
+
function settle(id: string, value: Error | WidgetAudioResult) {
|
|
49
|
+
const entry = pending.get(id);
|
|
50
|
+
if (!entry) return;
|
|
51
|
+
pending.delete(id);
|
|
52
|
+
entry.cleanup();
|
|
53
|
+
if (value instanceof Error) entry.reject(value);
|
|
54
|
+
else entry.resolve(value);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
captureAudio(options: WidgetAudioOptions = {}): Promise<WidgetAudioResult> {
|
|
58
|
+
if (!capabilities().microphone)
|
|
59
|
+
return Promise.reject(
|
|
60
|
+
new Error("Microphone is not granted to this widget"),
|
|
61
|
+
);
|
|
62
|
+
if (audioId)
|
|
63
|
+
return Promise.reject(
|
|
64
|
+
new Error("Microphone capture is already active"),
|
|
65
|
+
);
|
|
66
|
+
const maxDurationMs = Math.max(
|
|
67
|
+
1000,
|
|
68
|
+
Math.min(60_000, options.maxDurationMs ?? 30_000),
|
|
69
|
+
);
|
|
70
|
+
if (!Number.isFinite(maxDurationMs))
|
|
71
|
+
return Promise.reject(new Error("Invalid recording duration"));
|
|
72
|
+
if (disposed) return Promise.reject(new Error("Widget is disposed"));
|
|
73
|
+
const signal = options.signal;
|
|
74
|
+
if (signal?.aborted)
|
|
75
|
+
return Promise.reject(
|
|
76
|
+
new DOMException("Request aborted", "AbortError"),
|
|
77
|
+
);
|
|
78
|
+
const requestId = `microphone-${++sequence}`;
|
|
79
|
+
audioId = requestId;
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const cancel = () => {
|
|
82
|
+
post("microphone:cancel", { requestId });
|
|
83
|
+
settle(requestId, new DOMException("Request aborted", "AbortError"));
|
|
84
|
+
};
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
post("microphone:cancel", { requestId });
|
|
87
|
+
settle(requestId, new Error("Widget request timed out"));
|
|
88
|
+
}, 75_000);
|
|
89
|
+
const cleanup = () => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
signal?.removeEventListener("abort", cancel);
|
|
92
|
+
if (audioId === requestId) audioId = undefined;
|
|
93
|
+
};
|
|
94
|
+
pending.set(requestId, { resolve, reject, cleanup });
|
|
95
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
96
|
+
post("microphone:request", { requestId, maxDurationMs });
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
stopAudioCapture() {
|
|
100
|
+
if (audioId) post("microphone:stop", { requestId: audioId });
|
|
101
|
+
},
|
|
102
|
+
handle(envelope: FlwEnvelope) {
|
|
103
|
+
if (envelope.type !== "microphone:result") return;
|
|
104
|
+
const value = envelope.payload as MicrophoneResultPayload;
|
|
105
|
+
if (!value || typeof value.requestId !== "string") return;
|
|
106
|
+
if (
|
|
107
|
+
value.ok !== true ||
|
|
108
|
+
!(value.bytes instanceof ArrayBuffer) ||
|
|
109
|
+
typeof value.mimeType !== "string" ||
|
|
110
|
+
!Number.isInteger(value.status)
|
|
111
|
+
) {
|
|
112
|
+
settle(value.requestId, new Error("Microphone capture failed"));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
settle(value.requestId, {
|
|
116
|
+
bytes: value.bytes,
|
|
117
|
+
mimeType: value.mimeType,
|
|
118
|
+
status: value.status!,
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
reset() {
|
|
122
|
+
for (const id of [...pending.keys()])
|
|
123
|
+
settle(id, new Error("Widget connection replaced"));
|
|
124
|
+
},
|
|
125
|
+
dispose() {
|
|
126
|
+
disposed = true;
|
|
127
|
+
for (const id of [...pending.keys()]) {
|
|
128
|
+
post("microphone:cancel", { requestId: id });
|
|
129
|
+
settle(id, new Error("Widget is disposed"));
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|