@opengeni/react 0.40.0 → 0.42.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/dist/{chunk-OMCFRWHL.js → chunk-UWYTCQWW.js} +1353 -82
- package/dist/chunk-UWYTCQWW.js.map +1 -0
- package/dist/components/composer-transcription-control.d.ts +14 -1
- package/dist/composer.d.ts +3 -1
- package/dist/composer.js +27 -1
- package/dist/hooks/use-voice-input.d.ts +25 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +27 -1
- package/dist/index.js.map +1 -1
- package/dist/voice-recording-owner.d.ts +12 -0
- package/dist/voice-recording-store.d.ts +124 -0
- package/package.json +2 -2
- package/src/components/composer-transcription-control.tsx +130 -18
- package/src/composer.ts +30 -1
- package/src/hooks/use-voice-input.ts +893 -67
- package/src/index.ts +30 -1
- package/src/voice-recording-owner.ts +251 -0
- package/src/voice-recording-store.ts +528 -0
- package/dist/chunk-OMCFRWHL.js.map +0 -1
package/src/index.ts
CHANGED
|
@@ -49,12 +49,41 @@ export {
|
|
|
49
49
|
FILE_ONLY_MESSAGE_TEXT,
|
|
50
50
|
} from "./hooks/use-composer";
|
|
51
51
|
export type { ComposerSendExtras, ComposerState, UseComposerOptions } from "./hooks/use-composer";
|
|
52
|
-
export {
|
|
52
|
+
export {
|
|
53
|
+
VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
|
|
54
|
+
VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS,
|
|
55
|
+
VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
|
|
56
|
+
VOICE_RECORDING_TIMESLICE_MILLISECONDS,
|
|
57
|
+
useVoiceInput,
|
|
58
|
+
} from "./hooks/use-voice-input";
|
|
53
59
|
export type {
|
|
54
60
|
UseVoiceInputOptions,
|
|
55
61
|
UseVoiceInputResult,
|
|
56
62
|
VoiceInputStatus,
|
|
57
63
|
} from "./hooks/use-voice-input";
|
|
64
|
+
export {
|
|
65
|
+
IndexedDbVoiceRecordingStore,
|
|
66
|
+
VoiceRecordingChunkConflictError,
|
|
67
|
+
VoiceRecordingChunkSequenceError,
|
|
68
|
+
VoiceRecordingNotFoundError,
|
|
69
|
+
VoiceRecordingOwnedError,
|
|
70
|
+
VoiceRecordingStorageUnavailableError,
|
|
71
|
+
createVoiceRecordingManifest,
|
|
72
|
+
planVoiceRecordingChunkCommit,
|
|
73
|
+
prepareVoiceRecordingChunk,
|
|
74
|
+
} from "./voice-recording-store";
|
|
75
|
+
export type {
|
|
76
|
+
PersistVoiceRecordingChunkInput,
|
|
77
|
+
PersistVoiceRecordingChunkResult,
|
|
78
|
+
VoiceRecordingCaptureState,
|
|
79
|
+
VoiceRecordingChunk,
|
|
80
|
+
VoiceRecordingChunkUploadState,
|
|
81
|
+
VoiceRecordingFinalizationState,
|
|
82
|
+
VoiceRecordingManifest,
|
|
83
|
+
VoiceRecordingStore,
|
|
84
|
+
VoiceRecordingTranscriptionState,
|
|
85
|
+
VoiceRecordingUploadState,
|
|
86
|
+
} from "./voice-recording-store";
|
|
58
87
|
export { COMPOSER_PAYMENT_REQUIRED_MESSAGE, composerSubmissionErrorMessage } from "./lib/format";
|
|
59
88
|
export {
|
|
60
89
|
INITIAL_TRANSCRIPTION_CONTROL_STATE,
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
export const VOICE_RECORDING_OWNER_SESSION_KEY = "opengeni.voice-recording-owner.v1";
|
|
2
|
+
|
|
3
|
+
const VOICE_RECORDING_OWNER_LOCK_PREFIX = "opengeni.voice-recording-owner:";
|
|
4
|
+
const VOICE_RECORDING_OWNER_CHANNEL = "opengeni.voice-recording-owner.v1";
|
|
5
|
+
const OWNER_LOCK_RELOAD_GRACE_MILLISECONDS = 250;
|
|
6
|
+
const OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS = 5_000;
|
|
7
|
+
const OWNER_BROADCAST_PROBE_MILLISECONDS = 100;
|
|
8
|
+
const OWNER_BROADCAST_RELOAD_ATTEMPTS = 10;
|
|
9
|
+
|
|
10
|
+
export type VoiceRecordingOwnerLease = {
|
|
11
|
+
ownerId: string;
|
|
12
|
+
release: () => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type UnderlyingOwnerLease = VoiceRecordingOwnerLease;
|
|
16
|
+
|
|
17
|
+
type OwnerProbeMessage = {
|
|
18
|
+
type: "voice-recording-owner.probe";
|
|
19
|
+
ownerId: string;
|
|
20
|
+
instanceId: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type OwnerOccupiedMessage = {
|
|
24
|
+
type: "voice-recording-owner.occupied";
|
|
25
|
+
ownerId: string;
|
|
26
|
+
targetInstanceId: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let sharedLeasePromise: Promise<UnderlyingOwnerLease> | null = null;
|
|
30
|
+
let sharedLeaseConsumers = 0;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Acquire one document-scoped owner identity shared by every voice hook in the
|
|
34
|
+
* current document. The session-stored candidate survives reload, while the
|
|
35
|
+
* held lock/handshake prevents opener-created or duplicated tabs from reusing
|
|
36
|
+
* that identity concurrently.
|
|
37
|
+
*/
|
|
38
|
+
export async function acquireDefaultVoiceRecordingOwnerLease(): Promise<VoiceRecordingOwnerLease> {
|
|
39
|
+
sharedLeaseConsumers += 1;
|
|
40
|
+
sharedLeasePromise ??= createUnderlyingOwnerLease();
|
|
41
|
+
let underlying: UnderlyingOwnerLease;
|
|
42
|
+
try {
|
|
43
|
+
underlying = await sharedLeasePromise;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
sharedLeaseConsumers -= 1;
|
|
46
|
+
if (sharedLeaseConsumers === 0) sharedLeasePromise = null;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let released = false;
|
|
51
|
+
return {
|
|
52
|
+
ownerId: underlying.ownerId,
|
|
53
|
+
release: () => {
|
|
54
|
+
if (released) return;
|
|
55
|
+
released = true;
|
|
56
|
+
sharedLeaseConsumers = Math.max(0, sharedLeaseConsumers - 1);
|
|
57
|
+
if (sharedLeaseConsumers === 0) {
|
|
58
|
+
sharedLeasePromise = null;
|
|
59
|
+
underlying.release();
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function createUnderlyingOwnerLease(): Promise<UnderlyingOwnerLease> {
|
|
66
|
+
const candidate = readSessionOwnerId() ?? crypto.randomUUID();
|
|
67
|
+
const reloadNavigation = isReloadNavigation();
|
|
68
|
+
|
|
69
|
+
if (hasWebLocks()) {
|
|
70
|
+
const retained = await tryAcquireWebLock(
|
|
71
|
+
candidate,
|
|
72
|
+
reloadNavigation
|
|
73
|
+
? OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS
|
|
74
|
+
: OWNER_LOCK_RELOAD_GRACE_MILLISECONDS,
|
|
75
|
+
);
|
|
76
|
+
if (retained) {
|
|
77
|
+
writeSessionOwnerId(candidate);
|
|
78
|
+
return retained;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const rotated = crypto.randomUUID();
|
|
82
|
+
writeSessionOwnerId(rotated);
|
|
83
|
+
const acquired = await tryAcquireWebLock(rotated, OWNER_LOCK_RELOAD_GRACE_MILLISECONDS);
|
|
84
|
+
if (acquired) return acquired;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const broadcastLease = await tryAcquireBroadcastLease(
|
|
88
|
+
readSessionOwnerId() ?? candidate,
|
|
89
|
+
reloadNavigation ? OWNER_BROADCAST_RELOAD_ATTEMPTS : 0,
|
|
90
|
+
);
|
|
91
|
+
if (broadcastLease) return broadcastLease;
|
|
92
|
+
|
|
93
|
+
// Without a cross-document coordination primitive, prefer a fresh
|
|
94
|
+
// per-document identity over copied session state. Recovery then waits only
|
|
95
|
+
// for the ordinary stale-owner timeout instead of risking cross-tab access.
|
|
96
|
+
const ownerId = crypto.randomUUID();
|
|
97
|
+
writeSessionOwnerId(ownerId);
|
|
98
|
+
return { ownerId, release: () => undefined };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hasWebLocks(): boolean {
|
|
102
|
+
return (
|
|
103
|
+
typeof navigator !== "undefined" &&
|
|
104
|
+
navigator.locks !== null &&
|
|
105
|
+
navigator.locks !== undefined &&
|
|
106
|
+
typeof navigator.locks.request === "function"
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function tryAcquireWebLock(
|
|
111
|
+
ownerId: string,
|
|
112
|
+
waitMilliseconds: number,
|
|
113
|
+
): Promise<UnderlyingOwnerLease | null> {
|
|
114
|
+
if (!hasWebLocks()) return null;
|
|
115
|
+
|
|
116
|
+
return await new Promise<UnderlyingOwnerLease | null>((resolve) => {
|
|
117
|
+
const controller = new AbortController();
|
|
118
|
+
let settled = false;
|
|
119
|
+
const settle = (lease: UnderlyingOwnerLease | null) => {
|
|
120
|
+
if (settled) return;
|
|
121
|
+
settled = true;
|
|
122
|
+
resolve(lease);
|
|
123
|
+
};
|
|
124
|
+
const timeout = setTimeout(() => controller.abort(), waitMilliseconds);
|
|
125
|
+
|
|
126
|
+
void navigator.locks
|
|
127
|
+
.request(
|
|
128
|
+
`${VOICE_RECORDING_OWNER_LOCK_PREFIX}${ownerId}`,
|
|
129
|
+
{ mode: "exclusive", signal: controller.signal },
|
|
130
|
+
async () => {
|
|
131
|
+
clearTimeout(timeout);
|
|
132
|
+
let releaseLock: (() => void) | null = null;
|
|
133
|
+
const held = new Promise<void>((release) => {
|
|
134
|
+
releaseLock = release;
|
|
135
|
+
});
|
|
136
|
+
settle({
|
|
137
|
+
ownerId,
|
|
138
|
+
release: () => releaseLock?.(),
|
|
139
|
+
});
|
|
140
|
+
await held;
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
.catch(() => {
|
|
144
|
+
clearTimeout(timeout);
|
|
145
|
+
settle(null);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function tryAcquireBroadcastLease(
|
|
151
|
+
initialOwnerId: string,
|
|
152
|
+
retainCandidateAttempts: number,
|
|
153
|
+
): Promise<UnderlyingOwnerLease | null> {
|
|
154
|
+
const BroadcastChannelConstructor =
|
|
155
|
+
typeof window !== "undefined" ? window.BroadcastChannel : undefined;
|
|
156
|
+
if (!BroadcastChannelConstructor) return null;
|
|
157
|
+
|
|
158
|
+
let ownerId = initialOwnerId;
|
|
159
|
+
for (let attempt = 0; attempt < retainCandidateAttempts + 3; attempt += 1) {
|
|
160
|
+
const channel = new BroadcastChannelConstructor(VOICE_RECORDING_OWNER_CHANNEL);
|
|
161
|
+
const instanceId = crypto.randomUUID();
|
|
162
|
+
let occupied = false;
|
|
163
|
+
const onMessage = (event: MessageEvent<unknown>) => {
|
|
164
|
+
const message = ownerCoordinationMessage(event.data);
|
|
165
|
+
if (!message || message.ownerId !== ownerId) return;
|
|
166
|
+
if (message.type === "voice-recording-owner.probe") {
|
|
167
|
+
if (message.instanceId === instanceId) return;
|
|
168
|
+
channel.postMessage({
|
|
169
|
+
type: "voice-recording-owner.occupied",
|
|
170
|
+
ownerId,
|
|
171
|
+
targetInstanceId: message.instanceId,
|
|
172
|
+
} satisfies OwnerOccupiedMessage);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (message.targetInstanceId === instanceId) occupied = true;
|
|
176
|
+
};
|
|
177
|
+
channel.addEventListener("message", onMessage);
|
|
178
|
+
channel.postMessage({
|
|
179
|
+
type: "voice-recording-owner.probe",
|
|
180
|
+
ownerId,
|
|
181
|
+
instanceId,
|
|
182
|
+
} satisfies OwnerProbeMessage);
|
|
183
|
+
await delay(OWNER_BROADCAST_PROBE_MILLISECONDS);
|
|
184
|
+
if (!occupied) {
|
|
185
|
+
writeSessionOwnerId(ownerId);
|
|
186
|
+
return {
|
|
187
|
+
ownerId,
|
|
188
|
+
release: () => {
|
|
189
|
+
channel.removeEventListener("message", onMessage);
|
|
190
|
+
channel.close();
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
channel.removeEventListener("message", onMessage);
|
|
195
|
+
channel.close();
|
|
196
|
+
if (attempt < retainCandidateAttempts) continue;
|
|
197
|
+
ownerId = crypto.randomUUID();
|
|
198
|
+
writeSessionOwnerId(ownerId);
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isReloadNavigation(): boolean {
|
|
204
|
+
if (typeof performance === "undefined") return false;
|
|
205
|
+
return performance.getEntriesByType("navigation").some((entry) => {
|
|
206
|
+
return "type" in entry && entry.type === "reload";
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function ownerCoordinationMessage(value: unknown): OwnerProbeMessage | OwnerOccupiedMessage | null {
|
|
211
|
+
if (!value || typeof value !== "object") return null;
|
|
212
|
+
const candidate = value as Record<string, unknown>;
|
|
213
|
+
if (
|
|
214
|
+
candidate.type === "voice-recording-owner.probe" &&
|
|
215
|
+
typeof candidate.ownerId === "string" &&
|
|
216
|
+
typeof candidate.instanceId === "string"
|
|
217
|
+
) {
|
|
218
|
+
return candidate as OwnerProbeMessage;
|
|
219
|
+
}
|
|
220
|
+
if (
|
|
221
|
+
candidate.type === "voice-recording-owner.occupied" &&
|
|
222
|
+
typeof candidate.ownerId === "string" &&
|
|
223
|
+
typeof candidate.targetInstanceId === "string"
|
|
224
|
+
) {
|
|
225
|
+
return candidate as OwnerOccupiedMessage;
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readSessionOwnerId(): string | null {
|
|
231
|
+
try {
|
|
232
|
+
return typeof window === "undefined"
|
|
233
|
+
? null
|
|
234
|
+
: window.sessionStorage.getItem(VOICE_RECORDING_OWNER_SESSION_KEY);
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function writeSessionOwnerId(ownerId: string): void {
|
|
241
|
+
try {
|
|
242
|
+
window.sessionStorage.setItem(VOICE_RECORDING_OWNER_SESSION_KEY, ownerId);
|
|
243
|
+
} catch {
|
|
244
|
+
// Private/embedded contexts may deny session storage. The held lock or
|
|
245
|
+
// broadcast lease still keeps the in-memory identity document-scoped.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function delay(milliseconds: number): Promise<void> {
|
|
250
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
251
|
+
}
|