@opengeni/react 0.41.0 → 0.44.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-SJKT4TKW.js → chunk-23EJ676W.js} +3 -1
- package/dist/chunk-23EJ676W.js.map +1 -0
- package/dist/{chunk-OMCFRWHL.js → chunk-KC27K42G.js} +1443 -101
- package/dist/chunk-KC27K42G.js.map +1 -0
- package/dist/{chunk-KR2SK5GJ.js → chunk-KG5F2OKE.js} +260 -93
- package/dist/chunk-KG5F2OKE.js.map +1 -0
- package/dist/{chunk-4IJCL7YO.js → chunk-LWR4MXSS.js} +4 -1
- package/dist/chunk-LWR4MXSS.js.map +1 -0
- package/dist/{chunk-JALF5FI3.js → chunk-SRFUT2ZU.js} +2 -2
- package/dist/{chunk-WZT5G5OR.js → chunk-U6K24XQD.js} +5 -4
- package/dist/{chunk-WZT5G5OR.js.map → chunk-U6K24XQD.js.map} +1 -1
- package/dist/components/chat-composer.d.ts +5 -1
- package/dist/components/composer-transcription-control.d.ts +16 -1
- package/dist/components/composer.d.ts +6 -4
- package/dist/components/session-chrome.d.ts +1 -1
- package/dist/composer.d.ts +3 -1
- package/dist/composer.js +29 -3
- package/dist/hooks/use-voice-input.d.ts +25 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.js +95 -20
- package/dist/index.js.map +1 -1
- package/dist/model-policy.d.ts +2 -0
- package/dist/model-policy.js +1 -1
- package/dist/realtime/realtime-control.d.ts +29 -1
- package/dist/realtime.d.ts +1 -1
- package/dist/realtime.js +269 -151
- package/dist/realtime.js.map +1 -1
- package/dist/session-ui.js +2 -2
- package/dist/session.js +2 -2
- package/dist/timeline/index.d.ts +1 -1
- package/dist/timeline/parsers.d.ts +13 -8
- 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/chat-composer.tsx +58 -19
- package/src/components/composer-transcription-control.tsx +258 -117
- package/src/components/composer.tsx +78 -14
- package/src/components/model-policy-picker.tsx +7 -2
- package/src/components/session-chrome.tsx +89 -78
- package/src/composer.ts +30 -1
- package/src/hooks/use-voice-input.ts +893 -67
- package/src/index.ts +32 -1
- package/src/model-policy.ts +4 -0
- package/src/realtime/realtime-control.tsx +307 -137
- package/src/realtime.ts +1 -0
- package/src/timeline/index.ts +2 -0
- package/src/timeline/parsers.ts +201 -19
- package/src/timeline/projection.ts +11 -0
- package/src/timeline/tool-renderers.tsx +7 -5
- package/src/timeline/turn-summary.tsx +7 -2
- package/src/voice-recording-owner.ts +251 -0
- package/src/voice-recording-store.ts +528 -0
- package/styles/tokens.css +8 -5
- package/dist/chunk-4IJCL7YO.js.map +0 -1
- package/dist/chunk-KR2SK5GJ.js.map +0 -1
- package/dist/chunk-OMCFRWHL.js.map +0 -1
- package/dist/chunk-SJKT4TKW.js.map +0 -1
- /package/dist/{chunk-JALF5FI3.js.map → chunk-SRFUT2ZU.js.map} +0 -0
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from "./chunk-Q2NCKWTK.js";
|
|
5
5
|
import {
|
|
6
6
|
groupPickerRowsByBillingClass
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-23EJ676W.js";
|
|
8
8
|
import {
|
|
9
9
|
Tooltip,
|
|
10
10
|
TooltipContent,
|
|
@@ -20,6 +20,346 @@ import {
|
|
|
20
20
|
cn
|
|
21
21
|
} from "./chunk-TK7G6XLT.js";
|
|
22
22
|
|
|
23
|
+
// src/voice-recording-store.ts
|
|
24
|
+
var DEFAULT_DATABASE_NAME = "opengeni-voice-recordings-v1";
|
|
25
|
+
var DATABASE_VERSION = 1;
|
|
26
|
+
var MANIFEST_STORE = "recordings";
|
|
27
|
+
var CHUNK_STORE = "chunks";
|
|
28
|
+
var CHUNKS_BY_RECORDING = "by-recording";
|
|
29
|
+
var VoiceRecordingStorageUnavailableError = class extends Error {
|
|
30
|
+
constructor() {
|
|
31
|
+
super("Durable voice recording storage is unavailable.");
|
|
32
|
+
this.name = "VoiceRecordingStorageUnavailableError";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var VoiceRecordingNotFoundError = class extends Error {
|
|
36
|
+
constructor(recordingId) {
|
|
37
|
+
super(`Voice recording ${recordingId} was not found.`);
|
|
38
|
+
this.name = "VoiceRecordingNotFoundError";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var VoiceRecordingChunkConflictError = class extends Error {
|
|
42
|
+
constructor(recordingId, chunkNumber) {
|
|
43
|
+
super(`Voice recording ${recordingId} chunk ${chunkNumber} conflicts with persisted audio.`);
|
|
44
|
+
this.name = "VoiceRecordingChunkConflictError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var VoiceRecordingChunkSequenceError = class extends Error {
|
|
48
|
+
constructor(expected, received) {
|
|
49
|
+
super(`Expected voice recording chunk ${expected}, received ${received}.`);
|
|
50
|
+
this.name = "VoiceRecordingChunkSequenceError";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var VoiceRecordingOwnedError = class extends Error {
|
|
54
|
+
constructor(recordingId) {
|
|
55
|
+
super(`Voice recording ${recordingId} is active in another browser tab.`);
|
|
56
|
+
this.name = "VoiceRecordingOwnedError";
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
function createVoiceRecordingManifest(input) {
|
|
60
|
+
return {
|
|
61
|
+
version: 1,
|
|
62
|
+
recordingId: input.recordingId,
|
|
63
|
+
workspaceId: input.workspaceId,
|
|
64
|
+
createdAt: input.createdAt,
|
|
65
|
+
updatedAt: input.createdAt,
|
|
66
|
+
mimeType: input.mimeType,
|
|
67
|
+
codec: codecForMimeType(input.mimeType),
|
|
68
|
+
captureState: "capturing",
|
|
69
|
+
uploadState: "pending",
|
|
70
|
+
transcriptionState: "pending",
|
|
71
|
+
finalizationState: "pending",
|
|
72
|
+
ownerId: input.ownerId ?? null,
|
|
73
|
+
ownerHeartbeatAt: input.ownerId ? input.createdAt : null,
|
|
74
|
+
transcriptText: null,
|
|
75
|
+
nextChunkNumber: 0,
|
|
76
|
+
chunkCount: 0,
|
|
77
|
+
totalBytes: 0,
|
|
78
|
+
totalDurationMilliseconds: 0
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function prepareVoiceRecordingChunk(input) {
|
|
82
|
+
if (!Number.isSafeInteger(input.chunkNumber) || input.chunkNumber < 0) {
|
|
83
|
+
throw new VoiceRecordingChunkSequenceError(0, input.chunkNumber);
|
|
84
|
+
}
|
|
85
|
+
if (!Number.isFinite(input.startMilliseconds) || input.startMilliseconds < 0) {
|
|
86
|
+
throw new RangeError("Voice recording chunk start must be non-negative.");
|
|
87
|
+
}
|
|
88
|
+
if (!Number.isFinite(input.durationMilliseconds) || input.durationMilliseconds < 0) {
|
|
89
|
+
throw new RangeError("Voice recording chunk duration must be non-negative.");
|
|
90
|
+
}
|
|
91
|
+
const bytes = new Uint8Array(await input.audio.arrayBuffer());
|
|
92
|
+
if (bytes.byteLength === 0) throw new RangeError("Voice recording chunks cannot be empty.");
|
|
93
|
+
return {
|
|
94
|
+
recordingId: input.recordingId,
|
|
95
|
+
chunkNumber: input.chunkNumber,
|
|
96
|
+
capturedAt: input.capturedAt,
|
|
97
|
+
startMilliseconds: Math.round(input.startMilliseconds),
|
|
98
|
+
durationMilliseconds: Math.round(input.durationMilliseconds),
|
|
99
|
+
mimeType: input.mimeType,
|
|
100
|
+
codec: codecForMimeType(input.mimeType),
|
|
101
|
+
byteLength: bytes.byteLength,
|
|
102
|
+
sha256: await sha256Hex(bytes),
|
|
103
|
+
uploadState: "pending",
|
|
104
|
+
audio: input.audio
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function planVoiceRecordingChunkCommit(input) {
|
|
108
|
+
const { manifest, chunk, existingChunk } = input;
|
|
109
|
+
if (manifest.recordingId !== chunk.recordingId) {
|
|
110
|
+
throw new VoiceRecordingChunkConflictError(chunk.recordingId, chunk.chunkNumber);
|
|
111
|
+
}
|
|
112
|
+
if (existingChunk) {
|
|
113
|
+
if (existingChunk.sha256 !== chunk.sha256 || existingChunk.byteLength !== chunk.byteLength || existingChunk.mimeType !== chunk.mimeType) {
|
|
114
|
+
throw new VoiceRecordingChunkConflictError(chunk.recordingId, chunk.chunkNumber);
|
|
115
|
+
}
|
|
116
|
+
return { manifest, chunk: existingChunk, deduplicated: true };
|
|
117
|
+
}
|
|
118
|
+
if (chunk.chunkNumber !== manifest.nextChunkNumber) {
|
|
119
|
+
throw new VoiceRecordingChunkSequenceError(manifest.nextChunkNumber, chunk.chunkNumber);
|
|
120
|
+
}
|
|
121
|
+
const updatedManifest = {
|
|
122
|
+
...manifest,
|
|
123
|
+
updatedAt: chunk.capturedAt,
|
|
124
|
+
ownerHeartbeatAt: manifest.ownerId ? chunk.capturedAt : manifest.ownerHeartbeatAt,
|
|
125
|
+
nextChunkNumber: manifest.nextChunkNumber + 1,
|
|
126
|
+
chunkCount: manifest.chunkCount + 1,
|
|
127
|
+
totalBytes: manifest.totalBytes + chunk.byteLength,
|
|
128
|
+
totalDurationMilliseconds: Math.max(
|
|
129
|
+
manifest.totalDurationMilliseconds,
|
|
130
|
+
chunk.startMilliseconds + chunk.durationMilliseconds
|
|
131
|
+
)
|
|
132
|
+
};
|
|
133
|
+
return { manifest: updatedManifest, chunk, deduplicated: false };
|
|
134
|
+
}
|
|
135
|
+
var IndexedDbVoiceRecordingStore = class {
|
|
136
|
+
database;
|
|
137
|
+
constructor(options) {
|
|
138
|
+
const factory = options && "indexedDB" in options ? options.indexedDB : globalThis.indexedDB;
|
|
139
|
+
if (!factory) throw new VoiceRecordingStorageUnavailableError();
|
|
140
|
+
this.database = openDatabase(factory, options?.databaseName ?? DEFAULT_DATABASE_NAME);
|
|
141
|
+
}
|
|
142
|
+
async createManifest(manifest) {
|
|
143
|
+
const database = await this.database;
|
|
144
|
+
const transaction = database.transaction(MANIFEST_STORE, "readwrite");
|
|
145
|
+
transaction.objectStore(MANIFEST_STORE).add(manifest);
|
|
146
|
+
await transactionComplete(transaction);
|
|
147
|
+
}
|
|
148
|
+
async getManifest(recordingId) {
|
|
149
|
+
const database = await this.database;
|
|
150
|
+
const transaction = database.transaction(MANIFEST_STORE, "readonly");
|
|
151
|
+
const manifest = await requestResult(
|
|
152
|
+
transaction.objectStore(MANIFEST_STORE).get(recordingId)
|
|
153
|
+
);
|
|
154
|
+
await transactionComplete(transaction);
|
|
155
|
+
return manifest ? normalizeManifest(manifest) : null;
|
|
156
|
+
}
|
|
157
|
+
async listRecoverableManifests(workspaceId, ownership) {
|
|
158
|
+
const database = await this.database;
|
|
159
|
+
const transaction = database.transaction(MANIFEST_STORE, "readonly");
|
|
160
|
+
const manifests = await requestResult(
|
|
161
|
+
transaction.objectStore(MANIFEST_STORE).getAll()
|
|
162
|
+
);
|
|
163
|
+
await transactionComplete(transaction);
|
|
164
|
+
return manifests.map(normalizeManifest).filter(
|
|
165
|
+
(manifest) => manifest.workspaceId === workspaceId && manifest.captureState !== "discarded" && manifest.finalizationState !== "handed-off" && manifestAvailableToOwner(manifest, ownership)
|
|
166
|
+
).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
167
|
+
}
|
|
168
|
+
async claimManifest(recordingId, ownerId, claimedAt, staleBefore) {
|
|
169
|
+
const database = await this.database;
|
|
170
|
+
const transaction = database.transaction(MANIFEST_STORE, "readwrite");
|
|
171
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
172
|
+
const stored = await requestResult(
|
|
173
|
+
manifests.get(recordingId)
|
|
174
|
+
);
|
|
175
|
+
if (!stored) {
|
|
176
|
+
transaction.abort();
|
|
177
|
+
throw new VoiceRecordingNotFoundError(recordingId);
|
|
178
|
+
}
|
|
179
|
+
const manifest = normalizeManifest(stored);
|
|
180
|
+
if (manifest.finalizationState === "handed-off" || !manifestAvailableToOwner(manifest, { ownerId, staleBefore })) {
|
|
181
|
+
transaction.abort();
|
|
182
|
+
throw new VoiceRecordingOwnedError(recordingId);
|
|
183
|
+
}
|
|
184
|
+
const updated = {
|
|
185
|
+
...manifest,
|
|
186
|
+
captureState: manifest.captureState === "capturing" ? "stopped" : manifest.captureState,
|
|
187
|
+
ownerId,
|
|
188
|
+
ownerHeartbeatAt: claimedAt,
|
|
189
|
+
updatedAt: claimedAt
|
|
190
|
+
};
|
|
191
|
+
manifests.put(updated);
|
|
192
|
+
await transactionComplete(transaction);
|
|
193
|
+
return updated;
|
|
194
|
+
}
|
|
195
|
+
async listChunks(recordingId) {
|
|
196
|
+
const database = await this.database;
|
|
197
|
+
const transaction = database.transaction(CHUNK_STORE, "readonly");
|
|
198
|
+
const chunks = await requestResult(
|
|
199
|
+
transaction.objectStore(CHUNK_STORE).index(CHUNKS_BY_RECORDING).getAll(recordingId)
|
|
200
|
+
);
|
|
201
|
+
await transactionComplete(transaction);
|
|
202
|
+
return chunks.sort((left, right) => left.chunkNumber - right.chunkNumber);
|
|
203
|
+
}
|
|
204
|
+
async persistChunk(input) {
|
|
205
|
+
const chunk = await prepareVoiceRecordingChunk(input);
|
|
206
|
+
const database = await this.database;
|
|
207
|
+
const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
|
|
208
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
209
|
+
const chunks = transaction.objectStore(CHUNK_STORE);
|
|
210
|
+
const [manifest, existingChunk] = await Promise.all([
|
|
211
|
+
requestResult(manifests.get(input.recordingId)),
|
|
212
|
+
requestResult(
|
|
213
|
+
chunks.get([input.recordingId, input.chunkNumber])
|
|
214
|
+
)
|
|
215
|
+
]);
|
|
216
|
+
if (!manifest) {
|
|
217
|
+
transaction.abort();
|
|
218
|
+
throw new VoiceRecordingNotFoundError(input.recordingId);
|
|
219
|
+
}
|
|
220
|
+
const normalizedManifest = normalizeManifest(manifest);
|
|
221
|
+
assertManifestOwnership(normalizedManifest, input.ownerId);
|
|
222
|
+
const result = planVoiceRecordingChunkCommit({
|
|
223
|
+
manifest: normalizedManifest,
|
|
224
|
+
chunk,
|
|
225
|
+
existingChunk: existingChunk ?? null
|
|
226
|
+
});
|
|
227
|
+
if (!result.deduplicated) {
|
|
228
|
+
chunks.add(result.chunk);
|
|
229
|
+
manifests.put(result.manifest);
|
|
230
|
+
}
|
|
231
|
+
await transactionComplete(transaction);
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
async updateManifest(recordingId, update, updatedAt, ownerId) {
|
|
235
|
+
const database = await this.database;
|
|
236
|
+
const transaction = database.transaction(MANIFEST_STORE, "readwrite");
|
|
237
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
238
|
+
const manifest = await requestResult(
|
|
239
|
+
manifests.get(recordingId)
|
|
240
|
+
);
|
|
241
|
+
if (!manifest) {
|
|
242
|
+
transaction.abort();
|
|
243
|
+
throw new VoiceRecordingNotFoundError(recordingId);
|
|
244
|
+
}
|
|
245
|
+
const normalizedManifest = normalizeManifest(manifest);
|
|
246
|
+
assertManifestOwnership(normalizedManifest, ownerId);
|
|
247
|
+
const updated = { ...normalizedManifest, ...update, updatedAt };
|
|
248
|
+
manifests.put(updated);
|
|
249
|
+
await transactionComplete(transaction);
|
|
250
|
+
return updated;
|
|
251
|
+
}
|
|
252
|
+
async discard(recordingId, ownerId) {
|
|
253
|
+
const database = await this.database;
|
|
254
|
+
const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
|
|
255
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
256
|
+
const stored = await requestResult(
|
|
257
|
+
manifests.get(recordingId)
|
|
258
|
+
);
|
|
259
|
+
if (!stored) {
|
|
260
|
+
transaction.abort();
|
|
261
|
+
throw new VoiceRecordingNotFoundError(recordingId);
|
|
262
|
+
}
|
|
263
|
+
assertManifestOwnership(normalizeManifest(stored), ownerId);
|
|
264
|
+
const chunks = transaction.objectStore(CHUNK_STORE);
|
|
265
|
+
const chunkKeys = await requestResult(
|
|
266
|
+
chunks.index(CHUNKS_BY_RECORDING).getAllKeys(recordingId)
|
|
267
|
+
);
|
|
268
|
+
for (const key of chunkKeys) chunks.delete(key);
|
|
269
|
+
manifests.delete(recordingId);
|
|
270
|
+
await transactionComplete(transaction);
|
|
271
|
+
}
|
|
272
|
+
async cleanupHandedOffManifests(ownership) {
|
|
273
|
+
const database = await this.database;
|
|
274
|
+
const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
|
|
275
|
+
const manifests = transaction.objectStore(MANIFEST_STORE);
|
|
276
|
+
const chunks = transaction.objectStore(CHUNK_STORE);
|
|
277
|
+
const handedOff = (await requestResult(manifests.getAll())).map(normalizeManifest).filter(
|
|
278
|
+
(manifest) => manifest.finalizationState === "handed-off" && manifestAvailableToOwner(manifest, ownership)
|
|
279
|
+
);
|
|
280
|
+
const chunkKeys = await Promise.all(
|
|
281
|
+
handedOff.map(
|
|
282
|
+
(manifest) => requestResult(
|
|
283
|
+
chunks.index(CHUNKS_BY_RECORDING).getAllKeys(manifest.recordingId)
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
handedOff.forEach((manifest, index) => {
|
|
288
|
+
for (const key of chunkKeys[index] ?? []) chunks.delete(key);
|
|
289
|
+
manifests.delete(manifest.recordingId);
|
|
290
|
+
});
|
|
291
|
+
await transactionComplete(transaction);
|
|
292
|
+
return handedOff.length;
|
|
293
|
+
}
|
|
294
|
+
async close() {
|
|
295
|
+
(await this.database).close();
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
function normalizeManifest(manifest) {
|
|
299
|
+
return {
|
|
300
|
+
...manifest,
|
|
301
|
+
ownerId: manifest.ownerId ?? null,
|
|
302
|
+
ownerHeartbeatAt: manifest.ownerHeartbeatAt ?? null,
|
|
303
|
+
transcriptText: manifest.transcriptText ?? null
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function manifestAvailableToOwner(manifest, ownership) {
|
|
307
|
+
if (!manifest.ownerId) return true;
|
|
308
|
+
if (!ownership) return false;
|
|
309
|
+
if (manifest.ownerId === ownership.ownerId) return true;
|
|
310
|
+
return !manifest.ownerHeartbeatAt || manifest.ownerHeartbeatAt <= ownership.staleBefore;
|
|
311
|
+
}
|
|
312
|
+
function assertManifestOwnership(manifest, ownerId) {
|
|
313
|
+
if (manifest.ownerId !== (ownerId ?? null)) {
|
|
314
|
+
throw new VoiceRecordingOwnedError(manifest.recordingId);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function codecForMimeType(mimeType) {
|
|
318
|
+
const match = /(?:^|;)\s*codecs?\s*=\s*"?([^;"]+)/iu.exec(mimeType);
|
|
319
|
+
return match?.[1]?.trim().toLowerCase() ?? null;
|
|
320
|
+
}
|
|
321
|
+
async function sha256Hex(bytes) {
|
|
322
|
+
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer);
|
|
323
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
324
|
+
}
|
|
325
|
+
function openDatabase(factory, name) {
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
const request = factory.open(name, DATABASE_VERSION);
|
|
328
|
+
request.onupgradeneeded = () => {
|
|
329
|
+
const database = request.result;
|
|
330
|
+
if (!database.objectStoreNames.contains(MANIFEST_STORE)) {
|
|
331
|
+
database.createObjectStore(MANIFEST_STORE, { keyPath: "recordingId" });
|
|
332
|
+
}
|
|
333
|
+
if (!database.objectStoreNames.contains(CHUNK_STORE)) {
|
|
334
|
+
const chunks = database.createObjectStore(CHUNK_STORE, {
|
|
335
|
+
keyPath: ["recordingId", "chunkNumber"]
|
|
336
|
+
});
|
|
337
|
+
chunks.createIndex(CHUNKS_BY_RECORDING, "recordingId", { unique: false });
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
request.onsuccess = () => {
|
|
341
|
+
const database = request.result;
|
|
342
|
+
database.onversionchange = () => database.close();
|
|
343
|
+
resolve(database);
|
|
344
|
+
};
|
|
345
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open voice storage."));
|
|
346
|
+
request.onblocked = () => reject(new Error("Voice recording storage upgrade is blocked."));
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
function requestResult(request) {
|
|
350
|
+
return new Promise((resolve, reject) => {
|
|
351
|
+
request.onsuccess = () => resolve(request.result);
|
|
352
|
+
request.onerror = () => reject(request.error ?? new Error("Voice recording storage failed."));
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
function transactionComplete(transaction) {
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
transaction.oncomplete = () => resolve();
|
|
358
|
+
transaction.onabort = () => reject(transaction.error ?? new Error("Voice storage aborted."));
|
|
359
|
+
transaction.onerror = () => reject(transaction.error ?? new Error("Voice storage failed."));
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
23
363
|
// src/hooks/use-transcription.ts
|
|
24
364
|
import {
|
|
25
365
|
authorizeTranscriptionAdapter,
|
|
@@ -590,7 +930,192 @@ function transcriptionPolicyRevision(policy, selection) {
|
|
|
590
930
|
|
|
591
931
|
// src/hooks/use-voice-input.ts
|
|
592
932
|
import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
|
|
933
|
+
|
|
934
|
+
// src/voice-recording-owner.ts
|
|
935
|
+
var VOICE_RECORDING_OWNER_SESSION_KEY = "opengeni.voice-recording-owner.v1";
|
|
936
|
+
var VOICE_RECORDING_OWNER_LOCK_PREFIX = "opengeni.voice-recording-owner:";
|
|
937
|
+
var VOICE_RECORDING_OWNER_CHANNEL = "opengeni.voice-recording-owner.v1";
|
|
938
|
+
var OWNER_LOCK_RELOAD_GRACE_MILLISECONDS = 250;
|
|
939
|
+
var OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS = 5e3;
|
|
940
|
+
var OWNER_BROADCAST_PROBE_MILLISECONDS = 100;
|
|
941
|
+
var OWNER_BROADCAST_RELOAD_ATTEMPTS = 10;
|
|
942
|
+
var sharedLeasePromise = null;
|
|
943
|
+
var sharedLeaseConsumers = 0;
|
|
944
|
+
async function acquireDefaultVoiceRecordingOwnerLease() {
|
|
945
|
+
sharedLeaseConsumers += 1;
|
|
946
|
+
sharedLeasePromise ??= createUnderlyingOwnerLease();
|
|
947
|
+
let underlying;
|
|
948
|
+
try {
|
|
949
|
+
underlying = await sharedLeasePromise;
|
|
950
|
+
} catch (error) {
|
|
951
|
+
sharedLeaseConsumers -= 1;
|
|
952
|
+
if (sharedLeaseConsumers === 0) sharedLeasePromise = null;
|
|
953
|
+
throw error;
|
|
954
|
+
}
|
|
955
|
+
let released = false;
|
|
956
|
+
return {
|
|
957
|
+
ownerId: underlying.ownerId,
|
|
958
|
+
release: () => {
|
|
959
|
+
if (released) return;
|
|
960
|
+
released = true;
|
|
961
|
+
sharedLeaseConsumers = Math.max(0, sharedLeaseConsumers - 1);
|
|
962
|
+
if (sharedLeaseConsumers === 0) {
|
|
963
|
+
sharedLeasePromise = null;
|
|
964
|
+
underlying.release();
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
async function createUnderlyingOwnerLease() {
|
|
970
|
+
const candidate = readSessionOwnerId() ?? crypto.randomUUID();
|
|
971
|
+
const reloadNavigation = isReloadNavigation();
|
|
972
|
+
if (hasWebLocks()) {
|
|
973
|
+
const retained = await tryAcquireWebLock(
|
|
974
|
+
candidate,
|
|
975
|
+
reloadNavigation ? OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS : OWNER_LOCK_RELOAD_GRACE_MILLISECONDS
|
|
976
|
+
);
|
|
977
|
+
if (retained) {
|
|
978
|
+
writeSessionOwnerId(candidate);
|
|
979
|
+
return retained;
|
|
980
|
+
}
|
|
981
|
+
const rotated = crypto.randomUUID();
|
|
982
|
+
writeSessionOwnerId(rotated);
|
|
983
|
+
const acquired = await tryAcquireWebLock(rotated, OWNER_LOCK_RELOAD_GRACE_MILLISECONDS);
|
|
984
|
+
if (acquired) return acquired;
|
|
985
|
+
}
|
|
986
|
+
const broadcastLease = await tryAcquireBroadcastLease(
|
|
987
|
+
readSessionOwnerId() ?? candidate,
|
|
988
|
+
reloadNavigation ? OWNER_BROADCAST_RELOAD_ATTEMPTS : 0
|
|
989
|
+
);
|
|
990
|
+
if (broadcastLease) return broadcastLease;
|
|
991
|
+
const ownerId = crypto.randomUUID();
|
|
992
|
+
writeSessionOwnerId(ownerId);
|
|
993
|
+
return { ownerId, release: () => void 0 };
|
|
994
|
+
}
|
|
995
|
+
function hasWebLocks() {
|
|
996
|
+
return typeof navigator !== "undefined" && navigator.locks !== null && navigator.locks !== void 0 && typeof navigator.locks.request === "function";
|
|
997
|
+
}
|
|
998
|
+
async function tryAcquireWebLock(ownerId, waitMilliseconds) {
|
|
999
|
+
if (!hasWebLocks()) return null;
|
|
1000
|
+
return await new Promise((resolve) => {
|
|
1001
|
+
const controller = new AbortController();
|
|
1002
|
+
let settled = false;
|
|
1003
|
+
const settle = (lease) => {
|
|
1004
|
+
if (settled) return;
|
|
1005
|
+
settled = true;
|
|
1006
|
+
resolve(lease);
|
|
1007
|
+
};
|
|
1008
|
+
const timeout = setTimeout(() => controller.abort(), waitMilliseconds);
|
|
1009
|
+
void navigator.locks.request(
|
|
1010
|
+
`${VOICE_RECORDING_OWNER_LOCK_PREFIX}${ownerId}`,
|
|
1011
|
+
{ mode: "exclusive", signal: controller.signal },
|
|
1012
|
+
async () => {
|
|
1013
|
+
clearTimeout(timeout);
|
|
1014
|
+
let releaseLock = null;
|
|
1015
|
+
const held = new Promise((release) => {
|
|
1016
|
+
releaseLock = release;
|
|
1017
|
+
});
|
|
1018
|
+
settle({
|
|
1019
|
+
ownerId,
|
|
1020
|
+
release: () => releaseLock?.()
|
|
1021
|
+
});
|
|
1022
|
+
await held;
|
|
1023
|
+
}
|
|
1024
|
+
).catch(() => {
|
|
1025
|
+
clearTimeout(timeout);
|
|
1026
|
+
settle(null);
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
async function tryAcquireBroadcastLease(initialOwnerId, retainCandidateAttempts) {
|
|
1031
|
+
const BroadcastChannelConstructor = typeof window !== "undefined" ? window.BroadcastChannel : void 0;
|
|
1032
|
+
if (!BroadcastChannelConstructor) return null;
|
|
1033
|
+
let ownerId = initialOwnerId;
|
|
1034
|
+
for (let attempt = 0; attempt < retainCandidateAttempts + 3; attempt += 1) {
|
|
1035
|
+
const channel = new BroadcastChannelConstructor(VOICE_RECORDING_OWNER_CHANNEL);
|
|
1036
|
+
const instanceId = crypto.randomUUID();
|
|
1037
|
+
let occupied = false;
|
|
1038
|
+
const onMessage = (event) => {
|
|
1039
|
+
const message = ownerCoordinationMessage(event.data);
|
|
1040
|
+
if (!message || message.ownerId !== ownerId) return;
|
|
1041
|
+
if (message.type === "voice-recording-owner.probe") {
|
|
1042
|
+
if (message.instanceId === instanceId) return;
|
|
1043
|
+
channel.postMessage({
|
|
1044
|
+
type: "voice-recording-owner.occupied",
|
|
1045
|
+
ownerId,
|
|
1046
|
+
targetInstanceId: message.instanceId
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (message.targetInstanceId === instanceId) occupied = true;
|
|
1051
|
+
};
|
|
1052
|
+
channel.addEventListener("message", onMessage);
|
|
1053
|
+
channel.postMessage({
|
|
1054
|
+
type: "voice-recording-owner.probe",
|
|
1055
|
+
ownerId,
|
|
1056
|
+
instanceId
|
|
1057
|
+
});
|
|
1058
|
+
await delay(OWNER_BROADCAST_PROBE_MILLISECONDS);
|
|
1059
|
+
if (!occupied) {
|
|
1060
|
+
writeSessionOwnerId(ownerId);
|
|
1061
|
+
return {
|
|
1062
|
+
ownerId,
|
|
1063
|
+
release: () => {
|
|
1064
|
+
channel.removeEventListener("message", onMessage);
|
|
1065
|
+
channel.close();
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
channel.removeEventListener("message", onMessage);
|
|
1070
|
+
channel.close();
|
|
1071
|
+
if (attempt < retainCandidateAttempts) continue;
|
|
1072
|
+
ownerId = crypto.randomUUID();
|
|
1073
|
+
writeSessionOwnerId(ownerId);
|
|
1074
|
+
}
|
|
1075
|
+
return null;
|
|
1076
|
+
}
|
|
1077
|
+
function isReloadNavigation() {
|
|
1078
|
+
if (typeof performance === "undefined") return false;
|
|
1079
|
+
return performance.getEntriesByType("navigation").some((entry) => {
|
|
1080
|
+
return "type" in entry && entry.type === "reload";
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
function ownerCoordinationMessage(value) {
|
|
1084
|
+
if (!value || typeof value !== "object") return null;
|
|
1085
|
+
const candidate = value;
|
|
1086
|
+
if (candidate.type === "voice-recording-owner.probe" && typeof candidate.ownerId === "string" && typeof candidate.instanceId === "string") {
|
|
1087
|
+
return candidate;
|
|
1088
|
+
}
|
|
1089
|
+
if (candidate.type === "voice-recording-owner.occupied" && typeof candidate.ownerId === "string" && typeof candidate.targetInstanceId === "string") {
|
|
1090
|
+
return candidate;
|
|
1091
|
+
}
|
|
1092
|
+
return null;
|
|
1093
|
+
}
|
|
1094
|
+
function readSessionOwnerId() {
|
|
1095
|
+
try {
|
|
1096
|
+
return typeof window === "undefined" ? null : window.sessionStorage.getItem(VOICE_RECORDING_OWNER_SESSION_KEY);
|
|
1097
|
+
} catch {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
function writeSessionOwnerId(ownerId) {
|
|
1102
|
+
try {
|
|
1103
|
+
window.sessionStorage.setItem(VOICE_RECORDING_OWNER_SESSION_KEY, ownerId);
|
|
1104
|
+
} catch {
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
function delay(milliseconds) {
|
|
1108
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// src/hooks/use-voice-input.ts
|
|
1112
|
+
var VOICE_RECORDING_TIMESLICE_MILLISECONDS = 5e3;
|
|
1113
|
+
var VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS = 5e3;
|
|
1114
|
+
var VOICE_RECORDING_OWNER_STALE_MILLISECONDS = 3e4;
|
|
1115
|
+
var VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS = 600;
|
|
593
1116
|
var MIME_PREFERENCES = ["audio/webm;codecs=opus", "audio/mp4", "audio/ogg;codecs=opus"];
|
|
1117
|
+
var createDefaultVoiceRecordingId = () => crypto.randomUUID();
|
|
1118
|
+
var currentDate = () => /* @__PURE__ */ new Date();
|
|
594
1119
|
function useVoiceInput({
|
|
595
1120
|
client,
|
|
596
1121
|
workspaceId,
|
|
@@ -599,120 +1124,738 @@ function useVoiceInput({
|
|
|
599
1124
|
value,
|
|
600
1125
|
setValue,
|
|
601
1126
|
focusInput,
|
|
602
|
-
disabled = false
|
|
1127
|
+
disabled = false,
|
|
1128
|
+
createRecordingStore,
|
|
1129
|
+
createRecordingId = createDefaultVoiceRecordingId,
|
|
1130
|
+
createOwnerId,
|
|
1131
|
+
now = currentDate
|
|
603
1132
|
}) {
|
|
604
1133
|
const [status, setStatus] = useState2("idle");
|
|
605
1134
|
const [error, setError] = useState2(null);
|
|
606
1135
|
const [stream, setStream] = useState2(null);
|
|
1136
|
+
const [recordingId, setRecordingId] = useState2(null);
|
|
1137
|
+
const [durationSeconds, setDurationSeconds] = useState2(0);
|
|
1138
|
+
const [locallySaved, setLocallySaved] = useState2(false);
|
|
1139
|
+
const [storageAvailable, setStorageAvailable] = useState2(
|
|
1140
|
+
() => Boolean(createRecordingStore) || typeof globalThis.indexedDB !== "undefined"
|
|
1141
|
+
);
|
|
1142
|
+
const createRecordingStoreRef = useRef2(createRecordingStore);
|
|
1143
|
+
createRecordingStoreRef.current = createRecordingStore;
|
|
1144
|
+
const nowRef = useRef2(now);
|
|
1145
|
+
nowRef.current = now;
|
|
1146
|
+
const readNow = useCallback2(() => nowRef.current(), []);
|
|
607
1147
|
const generationRef = useRef2(0);
|
|
1148
|
+
const workspaceIdRef = useRef2(workspaceId);
|
|
1149
|
+
const ownerIdRef = useRef2(null);
|
|
1150
|
+
const ownerLeaseRef = useRef2(null);
|
|
1151
|
+
const ownerIdPromiseRef = useRef2(null);
|
|
1152
|
+
const createOwnerIdRef = useRef2(createOwnerId);
|
|
608
1153
|
const recorderRef = useRef2(null);
|
|
609
1154
|
const streamRef = useRef2(null);
|
|
610
1155
|
const timerRef = useRef2(null);
|
|
1156
|
+
const ownerHeartbeatTimerRef = useRef2(null);
|
|
611
1157
|
const controllerRef = useRef2(null);
|
|
612
1158
|
const valueRef = useRef2(value);
|
|
1159
|
+
const storeRef = useRef2(null);
|
|
1160
|
+
const storePromiseRef = useRef2(null);
|
|
1161
|
+
const ownsStoreRef = useRef2(false);
|
|
1162
|
+
const manifestRef = useRef2(null);
|
|
1163
|
+
const persistenceQueueRef = useRef2(Promise.resolve());
|
|
1164
|
+
const persistenceErrorRef = useRef2(null);
|
|
1165
|
+
const captureLimitErrorRef = useRef2(null);
|
|
1166
|
+
const captureSettledRef = useRef2(Promise.resolve());
|
|
1167
|
+
const resolveCaptureSettledRef = useRef2(null);
|
|
1168
|
+
const statusRef = useRef2(status);
|
|
613
1169
|
valueRef.current = value;
|
|
614
|
-
|
|
1170
|
+
statusRef.current = status;
|
|
1171
|
+
workspaceIdRef.current = workspaceId;
|
|
1172
|
+
const ensureOwnerId = useCallback2(async () => {
|
|
1173
|
+
if (ownerIdRef.current) return ownerIdRef.current;
|
|
1174
|
+
ownerIdPromiseRef.current ??= (async () => {
|
|
1175
|
+
const injectedOwnerId = createOwnerIdRef.current?.();
|
|
1176
|
+
if (injectedOwnerId) {
|
|
1177
|
+
ownerIdRef.current = injectedOwnerId;
|
|
1178
|
+
return injectedOwnerId;
|
|
1179
|
+
}
|
|
1180
|
+
const lease = await acquireDefaultVoiceRecordingOwnerLease();
|
|
1181
|
+
ownerLeaseRef.current = lease;
|
|
1182
|
+
ownerIdRef.current = lease.ownerId;
|
|
1183
|
+
return lease.ownerId;
|
|
1184
|
+
})();
|
|
1185
|
+
return await ownerIdPromiseRef.current;
|
|
1186
|
+
}, []);
|
|
1187
|
+
const stopOwnerHeartbeat = useCallback2(() => {
|
|
1188
|
+
if (ownerHeartbeatTimerRef.current) clearInterval(ownerHeartbeatTimerRef.current);
|
|
1189
|
+
ownerHeartbeatTimerRef.current = null;
|
|
1190
|
+
}, []);
|
|
1191
|
+
const clearCaptureRuntime = useCallback2((expectedStream) => {
|
|
1192
|
+
const captureStream = expectedStream ?? streamRef.current;
|
|
1193
|
+
captureStream?.getTracks().forEach((track) => track.stop());
|
|
1194
|
+
if (expectedStream && streamRef.current !== expectedStream) return;
|
|
615
1195
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
616
1196
|
timerRef.current = null;
|
|
617
|
-
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
618
1197
|
streamRef.current = null;
|
|
619
1198
|
recorderRef.current = null;
|
|
620
1199
|
setStream(null);
|
|
621
1200
|
}, []);
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1201
|
+
const clearVisibleRecording = useCallback2(() => {
|
|
1202
|
+
stopOwnerHeartbeat();
|
|
1203
|
+
manifestRef.current = null;
|
|
1204
|
+
setRecordingId(null);
|
|
1205
|
+
setDurationSeconds(0);
|
|
1206
|
+
setLocallySaved(false);
|
|
1207
|
+
}, [stopOwnerHeartbeat]);
|
|
1208
|
+
const ensureStore = useCallback2(async () => {
|
|
1209
|
+
if (storeRef.current) return storeRef.current;
|
|
1210
|
+
if (!storePromiseRef.current) {
|
|
1211
|
+
storePromiseRef.current = Promise.resolve().then(() => {
|
|
1212
|
+
const factory = createRecordingStoreRef.current;
|
|
1213
|
+
const store = factory?.() ?? new IndexedDbVoiceRecordingStore();
|
|
1214
|
+
ownsStoreRef.current = factory === void 0;
|
|
1215
|
+
storeRef.current = store;
|
|
1216
|
+
setStorageAvailable(true);
|
|
1217
|
+
return store;
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
try {
|
|
1221
|
+
return await storePromiseRef.current;
|
|
1222
|
+
} catch (reason) {
|
|
1223
|
+
storePromiseRef.current = null;
|
|
1224
|
+
if (reason instanceof VoiceRecordingStorageUnavailableError) setStorageAvailable(false);
|
|
1225
|
+
throw reason;
|
|
1226
|
+
}
|
|
1227
|
+
}, []);
|
|
1228
|
+
const rememberManifest = useCallback2((manifest) => {
|
|
1229
|
+
manifestRef.current = manifest;
|
|
1230
|
+
setRecordingId(manifest.recordingId);
|
|
1231
|
+
setDurationSeconds(manifest.totalDurationMilliseconds / 1e3);
|
|
1232
|
+
setLocallySaved(manifest.chunkCount > 0);
|
|
1233
|
+
}, []);
|
|
1234
|
+
const beginOwnerHeartbeat = useCallback2(
|
|
1235
|
+
(manifest) => {
|
|
1236
|
+
stopOwnerHeartbeat();
|
|
1237
|
+
ownerHeartbeatTimerRef.current = setInterval(() => {
|
|
1238
|
+
if (manifestRef.current?.recordingId !== manifest.recordingId || manifestRef.current.ownerId !== ownerIdRef.current) {
|
|
1239
|
+
stopOwnerHeartbeat();
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
const updatedAt = readNow().toISOString();
|
|
1243
|
+
void ensureStore().then(
|
|
1244
|
+
(store) => store.updateManifest(
|
|
1245
|
+
manifest.recordingId,
|
|
1246
|
+
{ ownerHeartbeatAt: updatedAt },
|
|
1247
|
+
updatedAt,
|
|
1248
|
+
ownerIdRef.current ?? void 0
|
|
1249
|
+
)
|
|
1250
|
+
).then((updated) => {
|
|
1251
|
+
if (manifestRef.current?.recordingId === updated.recordingId) {
|
|
1252
|
+
manifestRef.current = updated;
|
|
1253
|
+
}
|
|
1254
|
+
}).catch(() => void 0);
|
|
1255
|
+
}, VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS);
|
|
1256
|
+
},
|
|
1257
|
+
[ensureStore, readNow, stopOwnerHeartbeat]
|
|
1258
|
+
);
|
|
1259
|
+
const preserveForRetry = useCallback2(
|
|
1260
|
+
async (manifest, code, generation) => {
|
|
1261
|
+
if (generation !== generationRef.current) return;
|
|
1262
|
+
rememberManifest(manifest);
|
|
1263
|
+
const transcriptReady = manifest.finalizationState === "transcript-ready" && manifest.transcriptText !== null;
|
|
1264
|
+
setStatus(transcriptReady ? "transcript-ready" : code ? "error" : "recovered");
|
|
1265
|
+
setError(transcriptReady ? "handoff_uncertain" : code);
|
|
1266
|
+
focusInput();
|
|
1267
|
+
},
|
|
1268
|
+
[focusInput, rememberManifest]
|
|
1269
|
+
);
|
|
1270
|
+
const updateManifestBestEffort = useCallback2(
|
|
1271
|
+
async (manifest, update) => {
|
|
1272
|
+
try {
|
|
1273
|
+
return await (await ensureStore()).updateManifest(
|
|
1274
|
+
manifest.recordingId,
|
|
1275
|
+
update,
|
|
1276
|
+
readNow().toISOString(),
|
|
1277
|
+
ownerIdRef.current ?? void 0
|
|
1278
|
+
);
|
|
1279
|
+
} catch {
|
|
1280
|
+
return manifest;
|
|
1281
|
+
}
|
|
1282
|
+
},
|
|
1283
|
+
[ensureStore, readNow]
|
|
1284
|
+
);
|
|
1285
|
+
const loadNextRecoverable = useCallback2(
|
|
1286
|
+
async (generation) => {
|
|
1287
|
+
const active = () => generation === generationRef.current && workspaceIdRef.current === workspaceId;
|
|
1288
|
+
const [store, ownerId] = await Promise.all([ensureStore(), ensureOwnerId()]);
|
|
1289
|
+
if (!active()) return;
|
|
1290
|
+
const staleBefore = new Date(
|
|
1291
|
+
readNow().getTime() - VOICE_RECORDING_OWNER_STALE_MILLISECONDS
|
|
1292
|
+
).toISOString();
|
|
1293
|
+
await store.cleanupHandedOffManifests({ ownerId, staleBefore }).catch(() => void 0);
|
|
1294
|
+
if (!active()) return;
|
|
1295
|
+
const manifests = await store.listRecoverableManifests(workspaceId, {
|
|
1296
|
+
ownerId,
|
|
1297
|
+
staleBefore
|
|
1298
|
+
});
|
|
1299
|
+
if (!active()) return;
|
|
1300
|
+
for (const candidate of manifests) {
|
|
1301
|
+
try {
|
|
1302
|
+
const claimedAt = readNow().toISOString();
|
|
1303
|
+
const claimed = await store.claimManifest(
|
|
1304
|
+
candidate.recordingId,
|
|
1305
|
+
ownerId,
|
|
1306
|
+
claimedAt,
|
|
1307
|
+
staleBefore
|
|
1308
|
+
);
|
|
1309
|
+
if (!active()) {
|
|
1310
|
+
await store.updateManifest(
|
|
1311
|
+
claimed.recordingId,
|
|
1312
|
+
{ ownerId: null, ownerHeartbeatAt: null },
|
|
1313
|
+
readNow().toISOString(),
|
|
1314
|
+
ownerId
|
|
1315
|
+
).catch(() => void 0);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
rememberManifest(claimed);
|
|
1319
|
+
beginOwnerHeartbeat(claimed);
|
|
1320
|
+
setStatus(
|
|
1321
|
+
claimed.finalizationState === "transcript-ready" && claimed.transcriptText !== null ? "transcript-ready" : "recovered"
|
|
1322
|
+
);
|
|
1323
|
+
setError(null);
|
|
1324
|
+
return;
|
|
1325
|
+
} catch (reason) {
|
|
1326
|
+
if (reason instanceof VoiceRecordingOwnedError) continue;
|
|
1327
|
+
throw reason;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
if (active() && !manifestRef.current) {
|
|
1331
|
+
setStatus("idle");
|
|
1332
|
+
setError(null);
|
|
1333
|
+
}
|
|
1334
|
+
},
|
|
1335
|
+
[beginOwnerHeartbeat, ensureOwnerId, ensureStore, readNow, rememberManifest, workspaceId]
|
|
1336
|
+
);
|
|
1337
|
+
const finalizePersistedRecording = useCallback2(
|
|
1338
|
+
async (generation) => {
|
|
1339
|
+
const manifest = manifestRef.current;
|
|
1340
|
+
const maxSizeBytes = capability?.maxSizeBytes;
|
|
1341
|
+
if (!manifest || !client || !maxSizeBytes || manifest.workspaceId !== workspaceId) return;
|
|
1342
|
+
const controller = new AbortController();
|
|
1343
|
+
controllerRef.current?.abort();
|
|
1344
|
+
controllerRef.current = controller;
|
|
1345
|
+
const active = () => generation === generationRef.current && workspaceIdRef.current === workspaceId && !controller.signal.aborted && manifestRef.current?.recordingId === manifest.recordingId && manifestRef.current.workspaceId === workspaceId;
|
|
1346
|
+
let transcribing = manifest;
|
|
1347
|
+
try {
|
|
1348
|
+
const store = await ensureStore();
|
|
1349
|
+
if (!active()) return;
|
|
1350
|
+
transcribing = await store.updateManifest(
|
|
1351
|
+
manifest.recordingId,
|
|
1352
|
+
{
|
|
1353
|
+
captureState: "stopped",
|
|
1354
|
+
uploadState: "syncing",
|
|
1355
|
+
transcriptionState: "transcribing",
|
|
1356
|
+
ownerHeartbeatAt: readNow().toISOString()
|
|
1357
|
+
},
|
|
1358
|
+
readNow().toISOString(),
|
|
1359
|
+
ownerIdRef.current ?? void 0
|
|
1360
|
+
);
|
|
1361
|
+
if (!active()) return;
|
|
1362
|
+
rememberManifest(transcribing);
|
|
1363
|
+
setStatus("transcribing");
|
|
1364
|
+
setError(null);
|
|
1365
|
+
if (transcribing.totalBytes > maxSizeBytes) {
|
|
1366
|
+
throw { code: "too_large" };
|
|
1367
|
+
}
|
|
1368
|
+
const chunks = await store.listChunks(transcribing.recordingId);
|
|
1369
|
+
if (!active()) return;
|
|
1370
|
+
if (chunks.length === 0) {
|
|
1371
|
+
const retained = await store.updateManifest(
|
|
1372
|
+
transcribing.recordingId,
|
|
1373
|
+
{ uploadState: "retrying", transcriptionState: "retrying" },
|
|
1374
|
+
readNow().toISOString(),
|
|
1375
|
+
ownerIdRef.current ?? void 0
|
|
1376
|
+
);
|
|
1377
|
+
if (!active()) return;
|
|
1378
|
+
await preserveForRetry(retained, "invalid_audio", generation);
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const audio = new Blob(
|
|
1382
|
+
chunks.sort((left, right) => left.chunkNumber - right.chunkNumber).map((chunk) => chunk.audio),
|
|
1383
|
+
{ type: transcribing.mimeType }
|
|
1384
|
+
);
|
|
1385
|
+
if (!active()) return;
|
|
1386
|
+
if (audio.size > maxSizeBytes) throw { code: "too_large" };
|
|
1387
|
+
const response = await client.transcribeAudio(workspaceId, {
|
|
1388
|
+
audio,
|
|
1389
|
+
mimeType: audio.type,
|
|
1390
|
+
durationSeconds: transcribing.totalDurationMilliseconds / 1e3,
|
|
1391
|
+
signal: controller.signal
|
|
1392
|
+
});
|
|
1393
|
+
if (!active()) return;
|
|
1394
|
+
const ready = await store.updateManifest(
|
|
1395
|
+
transcribing.recordingId,
|
|
1396
|
+
{
|
|
1397
|
+
uploadState: "complete",
|
|
1398
|
+
transcriptionState: "complete",
|
|
1399
|
+
finalizationState: "transcript-ready",
|
|
1400
|
+
transcriptText: response.text,
|
|
1401
|
+
ownerHeartbeatAt: readNow().toISOString()
|
|
1402
|
+
},
|
|
1403
|
+
readNow().toISOString(),
|
|
1404
|
+
ownerIdRef.current ?? void 0
|
|
1405
|
+
);
|
|
1406
|
+
if (!active()) return;
|
|
1407
|
+
rememberManifest(ready);
|
|
1408
|
+
const next = appendFinalTranscript(valueRef.current, response.text);
|
|
1409
|
+
if (next !== valueRef.current) {
|
|
1410
|
+
valueRef.current = next;
|
|
1411
|
+
setValue(next);
|
|
1412
|
+
}
|
|
1413
|
+
let handedOff;
|
|
1414
|
+
try {
|
|
1415
|
+
handedOff = await store.updateManifest(
|
|
1416
|
+
ready.recordingId,
|
|
1417
|
+
{ finalizationState: "handed-off" },
|
|
1418
|
+
readNow().toISOString(),
|
|
1419
|
+
ownerIdRef.current ?? void 0
|
|
1420
|
+
);
|
|
1421
|
+
} catch {
|
|
1422
|
+
if (!active()) return;
|
|
1423
|
+
rememberManifest(ready);
|
|
1424
|
+
setStatus("transcript-ready");
|
|
1425
|
+
setError("handoff_uncertain");
|
|
1426
|
+
focusInput();
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
if (!active()) return;
|
|
1430
|
+
rememberManifest(handedOff);
|
|
1431
|
+
await store.discard(handedOff.recordingId, ownerIdRef.current ?? void 0).catch(() => void 0);
|
|
1432
|
+
if (!active()) return;
|
|
1433
|
+
clearVisibleRecording();
|
|
1434
|
+
setStatus("idle");
|
|
1435
|
+
setError(null);
|
|
1436
|
+
focusInput();
|
|
1437
|
+
await loadNextRecoverable(generation);
|
|
1438
|
+
} catch (reason) {
|
|
1439
|
+
if (!active()) return;
|
|
1440
|
+
const retained = await updateManifestBestEffort(transcribing, {
|
|
1441
|
+
uploadState: "retrying",
|
|
1442
|
+
transcriptionState: "retrying"
|
|
1443
|
+
});
|
|
1444
|
+
if (!active()) return;
|
|
1445
|
+
await preserveForRetry(
|
|
1446
|
+
retained,
|
|
1447
|
+
controller.signal.aborted ? null : errorCode(reason),
|
|
1448
|
+
generation
|
|
1449
|
+
);
|
|
1450
|
+
} finally {
|
|
1451
|
+
if (controllerRef.current === controller) controllerRef.current = null;
|
|
1452
|
+
}
|
|
1453
|
+
},
|
|
1454
|
+
[
|
|
1455
|
+
capability?.maxSizeBytes,
|
|
1456
|
+
clearVisibleRecording,
|
|
1457
|
+
client,
|
|
1458
|
+
ensureStore,
|
|
1459
|
+
focusInput,
|
|
1460
|
+
loadNextRecoverable,
|
|
1461
|
+
readNow,
|
|
1462
|
+
preserveForRetry,
|
|
1463
|
+
rememberManifest,
|
|
1464
|
+
setValue,
|
|
1465
|
+
updateManifestBestEffort,
|
|
1466
|
+
workspaceId
|
|
1467
|
+
]
|
|
1468
|
+
);
|
|
1469
|
+
const stop = useCallback2(() => {
|
|
634
1470
|
const recorder = recorderRef.current;
|
|
635
1471
|
if (!recorder || recorder.state === "inactive") return;
|
|
636
1472
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
637
1473
|
timerRef.current = null;
|
|
638
|
-
setStatus("
|
|
1474
|
+
setStatus("saving");
|
|
639
1475
|
recorder.stop();
|
|
640
|
-
};
|
|
641
|
-
const start = async () => {
|
|
642
|
-
if (disabled || !client || !enabled || !capability?.available || status === "requesting-permission" || status === "recording" || status === "transcribing" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
|
|
1476
|
+
}, []);
|
|
1477
|
+
const start = useCallback2(async () => {
|
|
1478
|
+
if (disabled || !client || !enabled || !capability?.available || manifestRef.current !== null || status === "requesting-permission" || status === "recording" || status === "saving" || status === "transcribing" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
|
|
1479
|
+
return false;
|
|
1480
|
+
}
|
|
1481
|
+
const prerequisiteGeneration = generationRef.current;
|
|
1482
|
+
let store;
|
|
1483
|
+
let ownerId;
|
|
1484
|
+
try {
|
|
1485
|
+
[store, ownerId] = await Promise.all([ensureStore(), ensureOwnerId()]);
|
|
1486
|
+
} catch (reason) {
|
|
1487
|
+
setStatus("error");
|
|
1488
|
+
setError(
|
|
1489
|
+
reason instanceof VoiceRecordingStorageUnavailableError ? "storage_unavailable" : errorCode(reason)
|
|
1490
|
+
);
|
|
1491
|
+
return false;
|
|
1492
|
+
}
|
|
1493
|
+
if (prerequisiteGeneration !== generationRef.current || workspaceIdRef.current !== workspaceId || ownerIdRef.current !== ownerId || manifestRef.current !== null || statusRef.current === "requesting-permission" || statusRef.current === "recording" || statusRef.current === "saving" || statusRef.current === "transcribing") {
|
|
643
1494
|
return false;
|
|
644
1495
|
}
|
|
645
|
-
const voiceClient = client;
|
|
646
1496
|
const generation = ++generationRef.current;
|
|
1497
|
+
const startAttemptIsCurrent = () => generation === generationRef.current && workspaceIdRef.current === workspaceId && ownerIdRef.current === ownerId;
|
|
1498
|
+
let attemptStream = null;
|
|
1499
|
+
let attemptRecorder = null;
|
|
1500
|
+
let attemptPersistenceQueue = Promise.resolve();
|
|
1501
|
+
let attemptPersistenceError = null;
|
|
1502
|
+
let attemptCaptureLimitError = null;
|
|
1503
|
+
const attemptCaptureSettlement = { resolve: null };
|
|
1504
|
+
const attemptManifest = { current: null };
|
|
1505
|
+
let attemptNextChunkNumber = 0;
|
|
1506
|
+
let attemptLastChunkEndMilliseconds = 0;
|
|
1507
|
+
let attemptRecordingStartedAt = 0;
|
|
1508
|
+
const attemptOwnsSharedCapture = () => startAttemptIsCurrent() && attemptStream !== null && streamRef.current === attemptStream && attemptRecorder !== null && recorderRef.current === attemptRecorder && attemptManifest.current !== null && manifestRef.current?.recordingId === attemptManifest.current.recordingId && manifestRef.current.workspaceId === workspaceId && manifestRef.current.ownerId === ownerId;
|
|
647
1509
|
setStatus("requesting-permission");
|
|
648
1510
|
setError(null);
|
|
1511
|
+
let acquiredStream = null;
|
|
1512
|
+
let recorderStarted = false;
|
|
649
1513
|
try {
|
|
650
1514
|
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
651
|
-
|
|
1515
|
+
acquiredStream = mediaStream;
|
|
1516
|
+
attemptStream = mediaStream;
|
|
1517
|
+
if (!startAttemptIsCurrent()) {
|
|
652
1518
|
mediaStream.getTracks().forEach((track) => track.stop());
|
|
653
1519
|
return false;
|
|
654
1520
|
}
|
|
1521
|
+
streamRef.current = mediaStream;
|
|
1522
|
+
setStream(mediaStream);
|
|
655
1523
|
const mimeType = chooseMimeType(capability.acceptedMimeTypes);
|
|
656
1524
|
const recorder = new MediaRecorder(mediaStream, mimeType ? { mimeType } : void 0);
|
|
657
|
-
|
|
658
|
-
const
|
|
659
|
-
|
|
1525
|
+
attemptRecorder = recorder;
|
|
1526
|
+
const createdAt = readNow();
|
|
1527
|
+
const manifest = createVoiceRecordingManifest({
|
|
1528
|
+
recordingId: createRecordingId(),
|
|
1529
|
+
workspaceId,
|
|
1530
|
+
mimeType: recorder.mimeType || mimeType || "audio/webm",
|
|
1531
|
+
createdAt: createdAt.toISOString(),
|
|
1532
|
+
ownerId
|
|
1533
|
+
});
|
|
1534
|
+
attemptManifest.current = manifest;
|
|
1535
|
+
await store.createManifest(manifest);
|
|
1536
|
+
if (!startAttemptIsCurrent()) {
|
|
1537
|
+
await store.discard(manifest.recordingId, ownerId);
|
|
1538
|
+
clearCaptureRuntime(mediaStream);
|
|
1539
|
+
return false;
|
|
1540
|
+
}
|
|
1541
|
+
rememberManifest(manifest);
|
|
1542
|
+
beginOwnerHeartbeat(manifest);
|
|
1543
|
+
persistenceQueueRef.current = attemptPersistenceQueue;
|
|
1544
|
+
persistenceErrorRef.current = null;
|
|
1545
|
+
captureLimitErrorRef.current = null;
|
|
660
1546
|
recorderRef.current = recorder;
|
|
661
|
-
setStream(mediaStream);
|
|
662
1547
|
recorder.ondataavailable = (event) => {
|
|
663
|
-
if (event.data.size
|
|
664
|
-
};
|
|
665
|
-
recorder.onstop = () => {
|
|
666
|
-
clearRuntime();
|
|
667
|
-
if (generation !== generationRef.current) return;
|
|
668
|
-
const audio = new Blob(chunks, { type: recorder.mimeType || mimeType || "audio/webm" });
|
|
669
|
-
if (audio.size === 0) {
|
|
670
|
-
setStatus("error");
|
|
671
|
-
setError("invalid_audio");
|
|
1548
|
+
if (event.data.size === 0 || attemptPersistenceError || attemptCaptureLimitError) {
|
|
672
1549
|
return;
|
|
673
1550
|
}
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
1551
|
+
const chunkNumber = attemptNextChunkNumber++;
|
|
1552
|
+
const elapsed = Math.max(0, readNow().getTime() - attemptRecordingStartedAt);
|
|
1553
|
+
const eventTimecode = Number.isFinite(event.timecode) ? Math.max(0, event.timecode) : 0;
|
|
1554
|
+
const endMilliseconds = Math.max(attemptLastChunkEndMilliseconds, eventTimecode, elapsed);
|
|
1555
|
+
const startMilliseconds = attemptLastChunkEndMilliseconds;
|
|
1556
|
+
const durationMilliseconds = Math.max(0, endMilliseconds - startMilliseconds);
|
|
1557
|
+
attemptLastChunkEndMilliseconds = endMilliseconds;
|
|
1558
|
+
attemptPersistenceQueue = attemptPersistenceQueue.then(async () => {
|
|
1559
|
+
const result = await store.persistChunk({
|
|
1560
|
+
recordingId: manifest.recordingId,
|
|
1561
|
+
ownerId,
|
|
1562
|
+
chunkNumber,
|
|
1563
|
+
capturedAt: readNow().toISOString(),
|
|
1564
|
+
startMilliseconds,
|
|
1565
|
+
durationMilliseconds,
|
|
1566
|
+
mimeType: manifest.mimeType,
|
|
1567
|
+
audio: event.data
|
|
1568
|
+
});
|
|
1569
|
+
if (!startAttemptIsCurrent()) return;
|
|
1570
|
+
rememberManifest(result.manifest);
|
|
1571
|
+
if (result.manifest.totalBytes > capability.maxSizeBytes) {
|
|
1572
|
+
attemptCaptureLimitError = "too_large";
|
|
1573
|
+
if (attemptOwnsSharedCapture()) captureLimitErrorRef.current = "too_large";
|
|
1574
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
687
1575
|
}
|
|
688
|
-
setStatus("idle");
|
|
689
|
-
focusInput();
|
|
690
1576
|
}).catch((reason) => {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
})
|
|
695
|
-
|
|
1577
|
+
attemptPersistenceError = reason;
|
|
1578
|
+
if (attemptOwnsSharedCapture()) persistenceErrorRef.current = reason;
|
|
1579
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
1580
|
+
});
|
|
1581
|
+
if (attemptOwnsSharedCapture()) {
|
|
1582
|
+
persistenceQueueRef.current = attemptPersistenceQueue;
|
|
1583
|
+
}
|
|
1584
|
+
};
|
|
1585
|
+
recorder.onstop = () => {
|
|
1586
|
+
if (attemptOwnsSharedCapture()) {
|
|
1587
|
+
clearCaptureRuntime(mediaStream);
|
|
1588
|
+
} else {
|
|
1589
|
+
mediaStream.getTracks().forEach((track) => track.stop());
|
|
1590
|
+
}
|
|
1591
|
+
void attemptPersistenceQueue.catch((reason) => {
|
|
1592
|
+
attemptPersistenceError = reason;
|
|
1593
|
+
}).then(async () => {
|
|
1594
|
+
const resolveSettled = attemptCaptureSettlement.resolve;
|
|
1595
|
+
attemptCaptureSettlement.resolve = null;
|
|
1596
|
+
resolveSettled?.();
|
|
1597
|
+
if (resolveCaptureSettledRef.current === resolveSettled) {
|
|
1598
|
+
resolveCaptureSettledRef.current = null;
|
|
1599
|
+
}
|
|
1600
|
+
const stoppedCaptureIsCurrent = () => generation === generationRef.current && workspaceIdRef.current === workspaceId && ownerIdRef.current === ownerId && manifestRef.current?.recordingId === manifest.recordingId && manifestRef.current.workspaceId === workspaceId && manifestRef.current.ownerId === ownerId;
|
|
1601
|
+
if (!stoppedCaptureIsCurrent()) return;
|
|
1602
|
+
const current = manifestRef.current;
|
|
1603
|
+
if (!current) return;
|
|
1604
|
+
const stopped = await updateManifestBestEffort(current, { captureState: "stopped" });
|
|
1605
|
+
if (!stoppedCaptureIsCurrent() || stopped.recordingId !== manifest.recordingId || stopped.workspaceId !== workspaceId || stopped.ownerId !== ownerId) {
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
rememberManifest(stopped);
|
|
1609
|
+
if (attemptPersistenceError) {
|
|
1610
|
+
await preserveForRetry(stopped, "storage_unavailable", generation);
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
if (attemptCaptureLimitError) {
|
|
1614
|
+
await preserveForRetry(stopped, attemptCaptureLimitError, generation);
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
await finalizePersistedRecording(generation);
|
|
696
1618
|
});
|
|
697
1619
|
};
|
|
698
|
-
|
|
1620
|
+
captureSettledRef.current = new Promise((resolve) => {
|
|
1621
|
+
attemptCaptureSettlement.resolve = resolve;
|
|
1622
|
+
resolveCaptureSettledRef.current = resolve;
|
|
1623
|
+
});
|
|
1624
|
+
attemptRecordingStartedAt = readNow().getTime();
|
|
1625
|
+
recorder.start(VOICE_RECORDING_TIMESLICE_MILLISECONDS);
|
|
1626
|
+
recorderStarted = true;
|
|
699
1627
|
setStatus("recording");
|
|
700
1628
|
timerRef.current = setTimeout(
|
|
701
|
-
|
|
702
|
-
Math.min(capability.maxDurationSeconds,
|
|
1629
|
+
stop,
|
|
1630
|
+
Math.min(capability.maxDurationSeconds, VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS) * 1e3
|
|
703
1631
|
);
|
|
704
1632
|
return true;
|
|
705
1633
|
} catch (reason) {
|
|
1634
|
+
const resolveSettled = attemptCaptureSettlement.resolve;
|
|
1635
|
+
attemptCaptureSettlement.resolve = null;
|
|
1636
|
+
resolveSettled?.();
|
|
1637
|
+
if (resolveCaptureSettledRef.current === resolveSettled) {
|
|
1638
|
+
resolveCaptureSettledRef.current = null;
|
|
1639
|
+
}
|
|
1640
|
+
if (acquiredStream) {
|
|
1641
|
+
if (startAttemptIsCurrent() && streamRef.current === acquiredStream) {
|
|
1642
|
+
clearCaptureRuntime(acquiredStream);
|
|
1643
|
+
} else {
|
|
1644
|
+
acquiredStream.getTracks().forEach((track) => track.stop());
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
const failedManifest = attemptManifest.current;
|
|
1648
|
+
if (failedManifest && !recorderStarted) {
|
|
1649
|
+
const discarded = await store.discard(failedManifest.recordingId, ownerId).then(() => true).catch(() => false);
|
|
1650
|
+
if (!discarded) {
|
|
1651
|
+
await store.updateManifest(
|
|
1652
|
+
failedManifest.recordingId,
|
|
1653
|
+
{ captureState: "stopped", ownerId: null, ownerHeartbeatAt: null },
|
|
1654
|
+
readNow().toISOString(),
|
|
1655
|
+
ownerId
|
|
1656
|
+
).catch(() => void 0);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
706
1659
|
if (generation !== generationRef.current) return false;
|
|
707
|
-
|
|
1660
|
+
const visibleManifest = manifestRef.current;
|
|
1661
|
+
if (failedManifest && visibleManifest?.recordingId === failedManifest.recordingId) {
|
|
1662
|
+
clearVisibleRecording();
|
|
1663
|
+
}
|
|
708
1664
|
setStatus("error");
|
|
709
|
-
setError(
|
|
1665
|
+
setError(
|
|
1666
|
+
reason instanceof VoiceRecordingStorageUnavailableError ? "storage_unavailable" : errorCode(reason)
|
|
1667
|
+
);
|
|
710
1668
|
return false;
|
|
711
1669
|
}
|
|
712
|
-
}
|
|
1670
|
+
}, [
|
|
1671
|
+
capability,
|
|
1672
|
+
beginOwnerHeartbeat,
|
|
1673
|
+
clearCaptureRuntime,
|
|
1674
|
+
clearVisibleRecording,
|
|
1675
|
+
client,
|
|
1676
|
+
createRecordingId,
|
|
1677
|
+
disabled,
|
|
1678
|
+
enabled,
|
|
1679
|
+
ensureOwnerId,
|
|
1680
|
+
ensureStore,
|
|
1681
|
+
finalizePersistedRecording,
|
|
1682
|
+
readNow,
|
|
1683
|
+
preserveForRetry,
|
|
1684
|
+
rememberManifest,
|
|
1685
|
+
status,
|
|
1686
|
+
stop,
|
|
1687
|
+
updateManifestBestEffort,
|
|
1688
|
+
workspaceId
|
|
1689
|
+
]);
|
|
1690
|
+
const retry = useCallback2(() => {
|
|
1691
|
+
if (!manifestRef.current || manifestRef.current.finalizationState === "transcript-ready" || manifestRef.current.workspaceId !== workspaceId || !client || disabled || !enabled || status === "saving" || status === "transcribing") {
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
const generation = ++generationRef.current;
|
|
1695
|
+
controllerRef.current?.abort();
|
|
1696
|
+
setError(null);
|
|
1697
|
+
void finalizePersistedRecording(generation);
|
|
1698
|
+
}, [client, disabled, enabled, finalizePersistedRecording, status, workspaceId]);
|
|
1699
|
+
const insertSavedTranscript = useCallback2(async () => {
|
|
1700
|
+
const manifest = manifestRef.current;
|
|
1701
|
+
if (!manifest || manifest.workspaceId !== workspaceId || manifest.finalizationState !== "transcript-ready" || manifest.transcriptText === null) {
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
const generation = ++generationRef.current;
|
|
1705
|
+
controllerRef.current?.abort();
|
|
1706
|
+
controllerRef.current = null;
|
|
1707
|
+
const store = await ensureStore();
|
|
1708
|
+
if (generation !== generationRef.current) return;
|
|
1709
|
+
const next = appendFinalTranscript(valueRef.current, manifest.transcriptText);
|
|
1710
|
+
if (next !== valueRef.current) {
|
|
1711
|
+
valueRef.current = next;
|
|
1712
|
+
setValue(next);
|
|
1713
|
+
}
|
|
1714
|
+
let handedOff;
|
|
1715
|
+
try {
|
|
1716
|
+
handedOff = await store.updateManifest(
|
|
1717
|
+
manifest.recordingId,
|
|
1718
|
+
{ finalizationState: "handed-off" },
|
|
1719
|
+
readNow().toISOString(),
|
|
1720
|
+
ownerIdRef.current ?? void 0
|
|
1721
|
+
);
|
|
1722
|
+
} catch {
|
|
1723
|
+
if (generation !== generationRef.current) return;
|
|
1724
|
+
rememberManifest(manifest);
|
|
1725
|
+
setStatus("transcript-ready");
|
|
1726
|
+
setError("handoff_uncertain");
|
|
1727
|
+
focusInput();
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
if (generation !== generationRef.current) return;
|
|
1731
|
+
await store.discard(handedOff.recordingId, ownerIdRef.current ?? void 0).catch(() => void 0);
|
|
1732
|
+
if (generation !== generationRef.current) return;
|
|
1733
|
+
clearVisibleRecording();
|
|
1734
|
+
setStatus("idle");
|
|
1735
|
+
setError(null);
|
|
1736
|
+
focusInput();
|
|
1737
|
+
await loadNextRecoverable(generation);
|
|
1738
|
+
}, [
|
|
1739
|
+
clearVisibleRecording,
|
|
1740
|
+
ensureStore,
|
|
1741
|
+
focusInput,
|
|
1742
|
+
loadNextRecoverable,
|
|
1743
|
+
readNow,
|
|
1744
|
+
rememberManifest,
|
|
1745
|
+
setValue,
|
|
1746
|
+
workspaceId
|
|
1747
|
+
]);
|
|
1748
|
+
const discard = useCallback2(async () => {
|
|
1749
|
+
const manifest = manifestRef.current;
|
|
1750
|
+
const generation = ++generationRef.current;
|
|
1751
|
+
controllerRef.current?.abort();
|
|
1752
|
+
controllerRef.current = null;
|
|
1753
|
+
const recorder = recorderRef.current;
|
|
1754
|
+
let captureSettled = persistenceQueueRef.current;
|
|
1755
|
+
if (recorder && recorder.state !== "inactive") {
|
|
1756
|
+
captureSettled = captureSettledRef.current;
|
|
1757
|
+
recorder.stop();
|
|
1758
|
+
}
|
|
1759
|
+
clearCaptureRuntime();
|
|
1760
|
+
await captureSettled.catch(() => void 0);
|
|
1761
|
+
if (generation !== generationRef.current) return;
|
|
1762
|
+
if (manifest) {
|
|
1763
|
+
try {
|
|
1764
|
+
await (await ensureStore()).discard(manifest.recordingId, ownerIdRef.current ?? void 0);
|
|
1765
|
+
} catch {
|
|
1766
|
+
if (generation !== generationRef.current) return;
|
|
1767
|
+
rememberManifest(manifest);
|
|
1768
|
+
setStatus("error");
|
|
1769
|
+
setError("storage_unavailable");
|
|
1770
|
+
focusInput();
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
clearVisibleRecording();
|
|
1775
|
+
persistenceErrorRef.current = null;
|
|
1776
|
+
captureLimitErrorRef.current = null;
|
|
1777
|
+
setStatus("idle");
|
|
1778
|
+
setError(null);
|
|
1779
|
+
focusInput();
|
|
1780
|
+
await loadNextRecoverable(generation);
|
|
1781
|
+
}, [
|
|
1782
|
+
clearCaptureRuntime,
|
|
1783
|
+
clearVisibleRecording,
|
|
1784
|
+
ensureStore,
|
|
1785
|
+
focusInput,
|
|
1786
|
+
loadNextRecoverable,
|
|
1787
|
+
rememberManifest
|
|
1788
|
+
]);
|
|
1789
|
+
const cancel = useCallback2(() => {
|
|
1790
|
+
if (status === "recording" || status === "requesting-permission") {
|
|
1791
|
+
void discard();
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
if (status === "saving" || status === "transcribing") {
|
|
1795
|
+
const generation = ++generationRef.current;
|
|
1796
|
+
controllerRef.current?.abort();
|
|
1797
|
+
controllerRef.current = null;
|
|
1798
|
+
const captureSettled = status === "saving" ? captureSettledRef.current : Promise.resolve();
|
|
1799
|
+
void captureSettled.then(async () => {
|
|
1800
|
+
const manifest = manifestRef.current;
|
|
1801
|
+
if (!manifest) return;
|
|
1802
|
+
const retained = await updateManifestBestEffort(manifest, {
|
|
1803
|
+
captureState: "stopped",
|
|
1804
|
+
uploadState: "retrying",
|
|
1805
|
+
transcriptionState: "retrying"
|
|
1806
|
+
});
|
|
1807
|
+
await preserveForRetry(retained, null, generation);
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
}, [discard, preserveForRetry, status, updateManifestBestEffort]);
|
|
1811
|
+
useEffect2(() => {
|
|
1812
|
+
const current = manifestRef.current;
|
|
1813
|
+
const generation = ++generationRef.current;
|
|
1814
|
+
if (current && current.workspaceId !== workspaceId) {
|
|
1815
|
+
controllerRef.current?.abort();
|
|
1816
|
+
controllerRef.current = null;
|
|
1817
|
+
const recorder = recorderRef.current;
|
|
1818
|
+
let captureSettled = persistenceQueueRef.current;
|
|
1819
|
+
if (recorder && recorder.state !== "inactive") {
|
|
1820
|
+
captureSettled = captureSettledRef.current;
|
|
1821
|
+
recorder.stop();
|
|
1822
|
+
}
|
|
1823
|
+
clearCaptureRuntime();
|
|
1824
|
+
clearVisibleRecording();
|
|
1825
|
+
setStatus("idle");
|
|
1826
|
+
setError(null);
|
|
1827
|
+
void captureSettled.catch(() => void 0).then(async () => {
|
|
1828
|
+
const store = await ensureStore();
|
|
1829
|
+
const wasProcessing = current.uploadState === "syncing" || current.transcriptionState === "transcribing";
|
|
1830
|
+
await store.updateManifest(
|
|
1831
|
+
current.recordingId,
|
|
1832
|
+
{
|
|
1833
|
+
...current.captureState === "capturing" ? { captureState: "stopped" } : {},
|
|
1834
|
+
...wasProcessing ? { uploadState: "retrying", transcriptionState: "retrying" } : {},
|
|
1835
|
+
ownerId: null,
|
|
1836
|
+
ownerHeartbeatAt: null
|
|
1837
|
+
},
|
|
1838
|
+
readNow().toISOString(),
|
|
1839
|
+
ownerIdRef.current ?? void 0
|
|
1840
|
+
);
|
|
1841
|
+
}).catch(() => void 0);
|
|
1842
|
+
}
|
|
1843
|
+
if (!manifestRef.current) {
|
|
1844
|
+
void loadNextRecoverable(generation).catch((reason) => {
|
|
1845
|
+
if (reason instanceof VoiceRecordingStorageUnavailableError) setStorageAvailable(false);
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
}, [
|
|
1849
|
+
clearCaptureRuntime,
|
|
1850
|
+
clearVisibleRecording,
|
|
1851
|
+
ensureStore,
|
|
1852
|
+
loadNextRecoverable,
|
|
1853
|
+
readNow,
|
|
1854
|
+
workspaceId
|
|
1855
|
+
]);
|
|
713
1856
|
useEffect2(() => {
|
|
714
1857
|
const onKeyDown = (event) => {
|
|
715
|
-
if (event.key === "Escape" && status
|
|
1858
|
+
if (event.key === "Escape" && (status === "requesting-permission" || status === "recording" || status === "saving" || status === "transcribing")) {
|
|
716
1859
|
event.preventDefault();
|
|
717
1860
|
cancel();
|
|
718
1861
|
}
|
|
@@ -724,20 +1867,61 @@ function useVoiceInput({
|
|
|
724
1867
|
() => () => {
|
|
725
1868
|
generationRef.current += 1;
|
|
726
1869
|
controllerRef.current?.abort();
|
|
1870
|
+
controllerRef.current = null;
|
|
1871
|
+
const manifest = manifestRef.current;
|
|
1872
|
+
const ownerReady = ownerIdPromiseRef.current;
|
|
1873
|
+
stopOwnerHeartbeat();
|
|
727
1874
|
const recorder = recorderRef.current;
|
|
728
|
-
|
|
729
|
-
if (recorder && recorder.state !== "inactive")
|
|
1875
|
+
let captureSettled = persistenceQueueRef.current;
|
|
1876
|
+
if (recorder && recorder.state !== "inactive") {
|
|
1877
|
+
captureSettled = captureSettledRef.current;
|
|
1878
|
+
recorder.stop();
|
|
1879
|
+
}
|
|
1880
|
+
clearCaptureRuntime();
|
|
1881
|
+
void captureSettled.catch(() => void 0).then(async () => {
|
|
1882
|
+
const ownerId = ownerIdRef.current ?? (ownerReady ? await ownerReady.catch(() => null) : null);
|
|
1883
|
+
const store = storeRef.current;
|
|
1884
|
+
if (store && manifest && ownerId) {
|
|
1885
|
+
const wasProcessing = statusRef.current === "saving" || statusRef.current === "transcribing";
|
|
1886
|
+
await store.updateManifest(
|
|
1887
|
+
manifest.recordingId,
|
|
1888
|
+
{
|
|
1889
|
+
...manifest.captureState === "capturing" ? { captureState: "stopped" } : {},
|
|
1890
|
+
...wasProcessing ? {
|
|
1891
|
+
uploadState: "retrying",
|
|
1892
|
+
transcriptionState: "retrying"
|
|
1893
|
+
} : {},
|
|
1894
|
+
ownerId: null,
|
|
1895
|
+
ownerHeartbeatAt: null
|
|
1896
|
+
},
|
|
1897
|
+
readNow().toISOString(),
|
|
1898
|
+
ownerId
|
|
1899
|
+
).catch(() => void 0);
|
|
1900
|
+
}
|
|
1901
|
+
if (ownsStoreRef.current) await store?.close();
|
|
1902
|
+
}).catch(() => void 0).finally(() => {
|
|
1903
|
+
ownerLeaseRef.current?.release();
|
|
1904
|
+
ownerLeaseRef.current = null;
|
|
1905
|
+
});
|
|
730
1906
|
},
|
|
731
|
-
[
|
|
1907
|
+
[clearCaptureRuntime, readNow, stopOwnerHeartbeat]
|
|
732
1908
|
);
|
|
733
1909
|
return {
|
|
734
1910
|
status,
|
|
735
1911
|
error,
|
|
736
|
-
available: Boolean(capability?.available && enabled && !disabled),
|
|
1912
|
+
available: Boolean(capability?.available && enabled && !disabled && storageAvailable),
|
|
737
1913
|
stream,
|
|
1914
|
+
recordingId,
|
|
1915
|
+
durationSeconds,
|
|
1916
|
+
locallySaved,
|
|
1917
|
+
hasRecoverableRecording: manifestRef.current !== null && status !== "recording",
|
|
1918
|
+
savedTranscript: manifestRef.current?.finalizationState === "transcript-ready" ? manifestRef.current.transcriptText : null,
|
|
738
1919
|
start,
|
|
739
1920
|
stop,
|
|
740
|
-
|
|
1921
|
+
retry,
|
|
1922
|
+
insertSavedTranscript,
|
|
1923
|
+
cancel,
|
|
1924
|
+
discard
|
|
741
1925
|
};
|
|
742
1926
|
}
|
|
743
1927
|
function chooseMimeType(accepted) {
|
|
@@ -1429,16 +2613,60 @@ var defaultChatComposerMessages = {
|
|
|
1429
2613
|
formatBytes,
|
|
1430
2614
|
formatRelativeTime
|
|
1431
2615
|
};
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
return;
|
|
2616
|
+
var composerHeightMirror = null;
|
|
2617
|
+
function measureComposerContentHeight(textarea) {
|
|
2618
|
+
if (typeof document === "undefined") {
|
|
2619
|
+
return textarea.scrollHeight;
|
|
2620
|
+
}
|
|
2621
|
+
const width = textarea.clientWidth;
|
|
2622
|
+
if (width <= 0) {
|
|
2623
|
+
return textarea.offsetHeight;
|
|
1436
2624
|
}
|
|
2625
|
+
let mirror = composerHeightMirror;
|
|
2626
|
+
if (!mirror) {
|
|
2627
|
+
mirror = document.createElement("textarea");
|
|
2628
|
+
mirror.setAttribute("aria-hidden", "true");
|
|
2629
|
+
mirror.tabIndex = -1;
|
|
2630
|
+
mirror.rows = 1;
|
|
2631
|
+
mirror.style.cssText = "position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;height:auto;min-height:0;max-height:none;overflow:hidden;z-index:-1;";
|
|
2632
|
+
composerHeightMirror = mirror;
|
|
2633
|
+
}
|
|
2634
|
+
const style = getComputedStyle(textarea);
|
|
2635
|
+
mirror.style.width = `${width}px`;
|
|
2636
|
+
mirror.style.boxSizing = style.boxSizing;
|
|
2637
|
+
mirror.style.font = style.font;
|
|
2638
|
+
mirror.style.fontSize = style.fontSize;
|
|
2639
|
+
mirror.style.fontFamily = style.fontFamily;
|
|
2640
|
+
mirror.style.fontWeight = style.fontWeight;
|
|
2641
|
+
mirror.style.fontStyle = style.fontStyle;
|
|
2642
|
+
mirror.style.letterSpacing = style.letterSpacing;
|
|
2643
|
+
mirror.style.lineHeight = style.lineHeight;
|
|
2644
|
+
mirror.style.textTransform = style.textTransform;
|
|
2645
|
+
mirror.style.paddingTop = style.paddingTop;
|
|
2646
|
+
mirror.style.paddingRight = style.paddingRight;
|
|
2647
|
+
mirror.style.paddingBottom = style.paddingBottom;
|
|
2648
|
+
mirror.style.paddingLeft = style.paddingLeft;
|
|
2649
|
+
mirror.style.borderTopWidth = style.borderTopWidth;
|
|
2650
|
+
mirror.style.borderRightWidth = style.borderRightWidth;
|
|
2651
|
+
mirror.style.borderBottomWidth = style.borderBottomWidth;
|
|
2652
|
+
mirror.style.borderLeftWidth = style.borderLeftWidth;
|
|
2653
|
+
mirror.style.borderStyle = style.borderStyle;
|
|
2654
|
+
mirror.style.whiteSpace = style.whiteSpace;
|
|
2655
|
+
mirror.style.wordBreak = style.wordBreak;
|
|
2656
|
+
mirror.style.overflowWrap = style.overflowWrap;
|
|
2657
|
+
mirror.value = textarea.value;
|
|
2658
|
+
if (!mirror.isConnected) {
|
|
2659
|
+
document.documentElement.appendChild(mirror);
|
|
2660
|
+
}
|
|
2661
|
+
return mirror.scrollHeight;
|
|
2662
|
+
}
|
|
2663
|
+
function applyComposerTextareaHeight(textarea, maxPx = 220, measure = measureComposerContentHeight) {
|
|
1437
2664
|
const before = textarea.offsetHeight;
|
|
1438
|
-
|
|
1439
|
-
|
|
2665
|
+
const nextPx = Math.min(
|
|
2666
|
+
textarea.scrollHeight > textarea.clientHeight + 1 ? textarea.scrollHeight : measure(textarea),
|
|
2667
|
+
maxPx
|
|
2668
|
+
);
|
|
1440
2669
|
if (Math.abs(nextPx - before) < 1) {
|
|
1441
|
-
textarea.style.height = `${before}px`;
|
|
1442
2670
|
return;
|
|
1443
2671
|
}
|
|
1444
2672
|
textarea.style.height = `${nextPx}px`;
|
|
@@ -1966,7 +3194,8 @@ var Footer = forwardRef(function ComposerFooter({ className, ...props }, ref) {
|
|
|
1966
3194
|
ref,
|
|
1967
3195
|
className: cn(
|
|
1968
3196
|
"flex items-end gap-1.5 px-2 pb-2 pt-0.5 sm:px-2.5 sm:pb-2.5",
|
|
1969
|
-
|
|
3197
|
+
// Mobile: one control row — never wrap into a second toolbar line.
|
|
3198
|
+
"max-sm:flex-nowrap max-sm:items-center max-sm:gap-1",
|
|
1970
3199
|
className
|
|
1971
3200
|
)
|
|
1972
3201
|
}
|
|
@@ -2006,7 +3235,11 @@ var Actions = forwardRef(function ComposerActions({ className, ...props }, ref)
|
|
|
2006
3235
|
{
|
|
2007
3236
|
...props,
|
|
2008
3237
|
ref,
|
|
2009
|
-
className: cn(
|
|
3238
|
+
className: cn(
|
|
3239
|
+
"ml-auto flex shrink-0 items-center gap-1.5",
|
|
3240
|
+
"max-sm:flex-nowrap max-sm:gap-1",
|
|
3241
|
+
className
|
|
3242
|
+
)
|
|
2010
3243
|
}
|
|
2011
3244
|
);
|
|
2012
3245
|
});
|
|
@@ -2503,7 +3736,15 @@ function HelpPanel({
|
|
|
2503
3736
|
}
|
|
2504
3737
|
|
|
2505
3738
|
// src/components/composer-transcription-control.tsx
|
|
2506
|
-
import {
|
|
3739
|
+
import {
|
|
3740
|
+
ClipboardPasteIcon,
|
|
3741
|
+
LoaderCircleIcon as LoaderCircleIcon2,
|
|
3742
|
+
MicIcon,
|
|
3743
|
+
RefreshCwIcon,
|
|
3744
|
+
SquareIcon,
|
|
3745
|
+
Trash2Icon,
|
|
3746
|
+
XIcon as XIcon2
|
|
3747
|
+
} from "lucide-react";
|
|
2507
3748
|
import { AnimatePresence as AnimatePresence3, motion as motion3 } from "motion/react";
|
|
2508
3749
|
import { useEffect as useEffect4, useState as useState5 } from "react";
|
|
2509
3750
|
import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
@@ -2520,7 +3761,12 @@ var defaultMessages = {
|
|
|
2520
3761
|
retry: "Retry voice input",
|
|
2521
3762
|
requestingPermission: "Requesting microphone\u2026",
|
|
2522
3763
|
recording: "Recording. Press Escape to cancel.",
|
|
3764
|
+
saving: "Saving audio locally\u2026",
|
|
2523
3765
|
transcribing: "Transcribing\u2026",
|
|
3766
|
+
recovered: "Recording recovered and saved locally.",
|
|
3767
|
+
recoveredTranscript: "Transcript saved locally. Check your draft before inserting.",
|
|
3768
|
+
insertRecoveredTranscript: "Insert saved transcript",
|
|
3769
|
+
discardRecovered: "Discard saved recording",
|
|
2524
3770
|
unavailableDisabled: "Voice input is unavailable while the composer is disabled.",
|
|
2525
3771
|
unavailable: "Voice input is unavailable for this workspace.",
|
|
2526
3772
|
errorPermissionDenied: "Microphone permission was denied. Your draft was not changed.",
|
|
@@ -2528,6 +3774,9 @@ var defaultMessages = {
|
|
|
2528
3774
|
errorUnavailable: "Voice input is not configured.",
|
|
2529
3775
|
errorTooLarge: "Recording is too large. Try a shorter message.",
|
|
2530
3776
|
errorInvalidAudio: "The recording could not be read. Try again.",
|
|
3777
|
+
errorStorageUnavailable: "Voice input stopped because audio could not be saved safely.",
|
|
3778
|
+
errorRetryable: "Recording is saved locally. Retry transcription when ready.",
|
|
3779
|
+
errorHandoffUncertain: "Transcript is saved. Check your draft before inserting it again.",
|
|
2531
3780
|
errorUnknown: "Voice input could not start. Try again."
|
|
2532
3781
|
};
|
|
2533
3782
|
var WAVEFORM_BARS = 18;
|
|
@@ -2541,7 +3790,10 @@ function ComposerTranscriptionControl({
|
|
|
2541
3790
|
capability = null,
|
|
2542
3791
|
workspaceEnabled = false,
|
|
2543
3792
|
messages: overrides,
|
|
2544
|
-
className
|
|
3793
|
+
className,
|
|
3794
|
+
createRecordingStore,
|
|
3795
|
+
createOwnerId,
|
|
3796
|
+
suppressed = false
|
|
2545
3797
|
}) {
|
|
2546
3798
|
const composer = useChatComposer();
|
|
2547
3799
|
const messages = { ...defaultMessages, ...overrides };
|
|
@@ -2549,18 +3801,29 @@ function ComposerTranscriptionControl({
|
|
|
2549
3801
|
client,
|
|
2550
3802
|
workspaceId,
|
|
2551
3803
|
capability,
|
|
2552
|
-
enabled: workspaceEnabled,
|
|
3804
|
+
enabled: workspaceEnabled && !suppressed,
|
|
2553
3805
|
value: composer.value,
|
|
2554
3806
|
setValue: composer.setValue,
|
|
2555
3807
|
focusInput: composer.focusInput,
|
|
2556
|
-
disabled: composer.disabled
|
|
3808
|
+
disabled: composer.disabled,
|
|
3809
|
+
createRecordingStore,
|
|
3810
|
+
createOwnerId
|
|
2557
3811
|
});
|
|
2558
3812
|
const { status } = transcription;
|
|
2559
|
-
const
|
|
2560
|
-
|
|
3813
|
+
const cancelTranscription = transcription.cancel;
|
|
3814
|
+
useEffect4(() => {
|
|
3815
|
+
if (!suppressed) return;
|
|
3816
|
+
if (status === "recording" || status === "requesting-permission") {
|
|
3817
|
+
cancelTranscription();
|
|
3818
|
+
}
|
|
3819
|
+
}, [cancelTranscription, status, suppressed]);
|
|
3820
|
+
const active = status === "requesting-permission" || status === "recording" || status === "saving" || status === "transcribing";
|
|
3821
|
+
const recoverable = transcription.hasRecoverableRecording && (status === "recovered" || status === "transcript-ready" || status === "error");
|
|
3822
|
+
const savedTranscript = status === "transcript-ready" && transcription.savedTranscript !== null;
|
|
3823
|
+
const unavailableMessage = composer.disabled ? messages.unavailableDisabled : !capability?.available || !workspaceEnabled ? messages.unavailable : !transcription.available ? messages.errorStorageUnavailable : null;
|
|
2561
3824
|
const idleLabel = unavailableMessage ?? (status === "error" ? messages.retry : messages.start);
|
|
2562
3825
|
const errorMessage3 = transcription.error ? transcriptionErrorMessage(transcription.error, messages) : null;
|
|
2563
|
-
const announcement = status === "requesting-permission" ? messages.requestingPermission : status === "recording" ? messages.recording : status === "transcribing" ? messages.transcribing : status === "error" ? errorMessage3 ?? messages.errorUnknown : unavailableMessage;
|
|
3826
|
+
const announcement = status === "requesting-permission" ? messages.requestingPermission : status === "recording" ? messages.recording : status === "saving" ? messages.saving : status === "transcribing" ? messages.transcribing : status === "recovered" ? messages.recovered : status === "transcript-ready" ? messages.recoveredTranscript : status === "error" ? errorMessage3 ?? messages.errorUnknown : unavailableMessage;
|
|
2564
3827
|
function start(event) {
|
|
2565
3828
|
if (unavailableMessage) {
|
|
2566
3829
|
event.preventDefault();
|
|
@@ -2568,13 +3831,61 @@ function ComposerTranscriptionControl({
|
|
|
2568
3831
|
}
|
|
2569
3832
|
void transcription.start();
|
|
2570
3833
|
}
|
|
2571
|
-
return /* @__PURE__ */
|
|
2572
|
-
|
|
3834
|
+
return /* @__PURE__ */ jsx4(AnimatePresence3, { initial: false, children: suppressed ? null : /* @__PURE__ */ jsx4(
|
|
3835
|
+
motion3.span,
|
|
2573
3836
|
{
|
|
2574
|
-
|
|
3837
|
+
initial: { opacity: 0, width: 0 },
|
|
3838
|
+
animate: { opacity: 1, width: "auto" },
|
|
3839
|
+
exit: { opacity: 0, width: 0 },
|
|
3840
|
+
transition: { duration: 0.36, ease: [0.22, 1, 0.36, 1] },
|
|
3841
|
+
className: cn("inline-flex min-w-0 overflow-hidden", className),
|
|
2575
3842
|
"data-transcription-status": status,
|
|
2576
|
-
children: [
|
|
2577
|
-
/* @__PURE__ */ jsx4(AnimatePresence3, { mode: "popLayout", initial: false, children:
|
|
3843
|
+
children: /* @__PURE__ */ jsxs4("span", { className: "inline-flex min-w-0 items-center gap-1.5", children: [
|
|
3844
|
+
/* @__PURE__ */ jsx4(AnimatePresence3, { mode: "popLayout", initial: false, children: recoverable ? /* @__PURE__ */ jsxs4(
|
|
3845
|
+
motion3.span,
|
|
3846
|
+
{
|
|
3847
|
+
initial: { opacity: 0, scale: 0.96 },
|
|
3848
|
+
animate: { opacity: 1, scale: 1 },
|
|
3849
|
+
exit: { opacity: 0, scale: 0.96 },
|
|
3850
|
+
transition: { duration: 0.16, ease: [0.22, 1, 0.36, 1] },
|
|
3851
|
+
className: cn(
|
|
3852
|
+
"inline-flex h-8 min-w-0 items-center gap-1 rounded-og-md border border-og-border/80",
|
|
3853
|
+
"bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11"
|
|
3854
|
+
),
|
|
3855
|
+
children: [
|
|
3856
|
+
/* @__PURE__ */ jsx4("span", { className: "max-w-44 truncate text-og-xs text-og-fg-muted max-sm:max-w-28", children: savedTranscript ? errorMessage3 ?? messages.recoveredTranscript : status === "error" ? errorMessage3 ?? messages.errorRetryable : messages.recovered }),
|
|
3857
|
+
/* @__PURE__ */ jsx4(Tip, { tip: savedTranscript ? messages.insertRecoveredTranscript : messages.retry, children: /* @__PURE__ */ jsx4(
|
|
3858
|
+
"button",
|
|
3859
|
+
{
|
|
3860
|
+
type: "button",
|
|
3861
|
+
onClick: () => savedTranscript ? void transcription.insertSavedTranscript() : transcription.retry(),
|
|
3862
|
+
"aria-label": savedTranscript ? messages.insertRecoveredTranscript : messages.retry,
|
|
3863
|
+
className: cn(
|
|
3864
|
+
"inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
|
|
3865
|
+
"bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
|
|
3866
|
+
"hover:bg-og-fg-muted pointer-coarse:size-11"
|
|
3867
|
+
),
|
|
3868
|
+
children: savedTranscript ? /* @__PURE__ */ jsx4(ClipboardPasteIcon, { className: "size-3.5" }) : /* @__PURE__ */ jsx4(RefreshCwIcon, { className: "size-3.5" })
|
|
3869
|
+
}
|
|
3870
|
+
) }),
|
|
3871
|
+
/* @__PURE__ */ jsx4(Tip, { tip: messages.discardRecovered, children: /* @__PURE__ */ jsx4(
|
|
3872
|
+
"button",
|
|
3873
|
+
{
|
|
3874
|
+
type: "button",
|
|
3875
|
+
onClick: () => void transcription.discard(),
|
|
3876
|
+
"aria-label": messages.discardRecovered,
|
|
3877
|
+
className: cn(
|
|
3878
|
+
"inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
|
|
3879
|
+
"text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
|
|
3880
|
+
"hover:bg-og-surface-3 hover:text-og-status-failed pointer-coarse:size-11"
|
|
3881
|
+
),
|
|
3882
|
+
children: /* @__PURE__ */ jsx4(Trash2Icon, { className: "size-3.5" })
|
|
3883
|
+
}
|
|
3884
|
+
) })
|
|
3885
|
+
]
|
|
3886
|
+
},
|
|
3887
|
+
"recovered"
|
|
3888
|
+
) : active ? /* @__PURE__ */ jsxs4(
|
|
2578
3889
|
motion3.span,
|
|
2579
3890
|
{
|
|
2580
3891
|
initial: { opacity: 0, scale: 0.96 },
|
|
@@ -2583,7 +3894,7 @@ function ComposerTranscriptionControl({
|
|
|
2583
3894
|
transition: { duration: 0.16, ease: [0.22, 1, 0.36, 1] },
|
|
2584
3895
|
className: cn(
|
|
2585
3896
|
"inline-flex h-8 items-center gap-1 rounded-og-md border border-og-border/80",
|
|
2586
|
-
"bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-
|
|
3897
|
+
"bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11"
|
|
2587
3898
|
),
|
|
2588
3899
|
children: [
|
|
2589
3900
|
status === "requesting-permission" ? /* @__PURE__ */ jsx4(
|
|
@@ -2604,7 +3915,7 @@ function ComposerTranscriptionControl({
|
|
|
2604
3915
|
VoiceWaveform,
|
|
2605
3916
|
{
|
|
2606
3917
|
stream: status === "recording" ? transcription.stream : null,
|
|
2607
|
-
mode: status === "
|
|
3918
|
+
mode: status === "recording" ? "recording" : "transcribing"
|
|
2608
3919
|
}
|
|
2609
3920
|
)
|
|
2610
3921
|
] }),
|
|
@@ -2619,7 +3930,7 @@ function ComposerTranscriptionControl({
|
|
|
2619
3930
|
className: cn(
|
|
2620
3931
|
"inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
|
|
2621
3932
|
"text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
|
|
2622
|
-
"hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-
|
|
3933
|
+
"hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-11"
|
|
2623
3934
|
),
|
|
2624
3935
|
children: /* @__PURE__ */ jsx4(XIcon2, { className: "size-3.5" })
|
|
2625
3936
|
}
|
|
@@ -2633,19 +3944,20 @@ function ComposerTranscriptionControl({
|
|
|
2633
3944
|
className: cn(
|
|
2634
3945
|
"inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
|
|
2635
3946
|
"bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
|
|
2636
|
-
"hover:bg-og-fg-muted pointer-coarse:size-
|
|
3947
|
+
"hover:bg-og-fg-muted pointer-coarse:size-11"
|
|
2637
3948
|
),
|
|
2638
3949
|
children: /* @__PURE__ */ jsx4(SquareIcon, { className: "size-2.5 fill-current" })
|
|
2639
3950
|
}
|
|
2640
3951
|
) })
|
|
2641
|
-
] }) : status === "transcribing" ? /* @__PURE__ */ jsx4("span", { className: "og-shimmer-text px-1.5 text-og-xs font-medium whitespace-nowrap", children: messages.transcribing }) : /* @__PURE__ */ jsx4("span", { className: "px-1.5 text-og-xs text-og-fg-muted whitespace-nowrap", children: messages.requestingPermission })
|
|
3952
|
+
] }) : status === "transcribing" || status === "saving" ? /* @__PURE__ */ jsx4("span", { className: "og-shimmer-text px-1.5 text-og-xs font-medium whitespace-nowrap", children: status === "saving" ? messages.saving : messages.transcribing }) : /* @__PURE__ */ jsx4("span", { className: "px-1.5 text-og-xs text-og-fg-muted whitespace-nowrap", children: messages.requestingPermission })
|
|
2642
3953
|
]
|
|
2643
3954
|
},
|
|
2644
|
-
status === "transcribing" ? "
|
|
3955
|
+
status === "transcribing" || status === "saving" ? "processing" : "capture"
|
|
2645
3956
|
) : /* @__PURE__ */ jsx4(Tip, { tip: idleLabel, children: /* @__PURE__ */ jsx4(
|
|
2646
3957
|
motion3.button,
|
|
2647
3958
|
{
|
|
2648
3959
|
type: "button",
|
|
3960
|
+
"data-og-composer-dictate": true,
|
|
2649
3961
|
initial: { opacity: 0, scale: 0.96 },
|
|
2650
3962
|
animate: { opacity: 1, scale: 1 },
|
|
2651
3963
|
exit: { opacity: 0, scale: 0.96 },
|
|
@@ -2655,14 +3967,14 @@ function ComposerTranscriptionControl({
|
|
|
2655
3967
|
"aria-pressed": false,
|
|
2656
3968
|
"aria-disabled": unavailableMessage !== null,
|
|
2657
3969
|
className: cn(
|
|
2658
|
-
"inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-
|
|
3970
|
+
"inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-11",
|
|
2659
3971
|
"text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
|
|
2660
3972
|
unavailableMessage ? "cursor-not-allowed opacity-45" : "hover:bg-og-surface-2 hover:text-og-fg"
|
|
2661
3973
|
),
|
|
2662
3974
|
children: /* @__PURE__ */ jsx4(MicIcon, { className: "size-4" })
|
|
2663
3975
|
}
|
|
2664
3976
|
) }, "idle") }),
|
|
2665
|
-
status === "error" && errorMessage3 ? /* @__PURE__ */ jsx4(Tip, { tip: errorMessage3, children: /* @__PURE__ */ jsx4(
|
|
3977
|
+
status === "error" && errorMessage3 && !recoverable ? /* @__PURE__ */ jsx4(Tip, { tip: errorMessage3, children: /* @__PURE__ */ jsx4(
|
|
2666
3978
|
"span",
|
|
2667
3979
|
{
|
|
2668
3980
|
"aria-hidden": "true",
|
|
@@ -2670,10 +3982,19 @@ function ComposerTranscriptionControl({
|
|
|
2670
3982
|
children: errorMessage3
|
|
2671
3983
|
}
|
|
2672
3984
|
) }) : null,
|
|
2673
|
-
/* @__PURE__ */ jsx4(
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
3985
|
+
/* @__PURE__ */ jsx4(
|
|
3986
|
+
"span",
|
|
3987
|
+
{
|
|
3988
|
+
className: "sr-only",
|
|
3989
|
+
role: status === "error" ? "alert" : "status",
|
|
3990
|
+
"aria-live": "polite",
|
|
3991
|
+
children: announcement
|
|
3992
|
+
}
|
|
3993
|
+
)
|
|
3994
|
+
] })
|
|
3995
|
+
},
|
|
3996
|
+
"composer-dictate"
|
|
3997
|
+
) });
|
|
2677
3998
|
}
|
|
2678
3999
|
function VoiceWaveform({
|
|
2679
4000
|
stream,
|
|
@@ -2772,6 +4093,14 @@ function transcriptionErrorMessage(code, messages) {
|
|
|
2772
4093
|
return messages.errorTooLarge;
|
|
2773
4094
|
case "invalid_audio":
|
|
2774
4095
|
return messages.errorInvalidAudio;
|
|
4096
|
+
case "storage_unavailable":
|
|
4097
|
+
return messages.errorStorageUnavailable;
|
|
4098
|
+
case "network":
|
|
4099
|
+
case "provider":
|
|
4100
|
+
case "timeout":
|
|
4101
|
+
return messages.errorRetryable;
|
|
4102
|
+
case "handoff_uncertain":
|
|
4103
|
+
return messages.errorHandoffUncertain;
|
|
2775
4104
|
case "unknown":
|
|
2776
4105
|
return messages.errorUnknown;
|
|
2777
4106
|
default:
|
|
@@ -2780,10 +4109,23 @@ function transcriptionErrorMessage(code, messages) {
|
|
|
2780
4109
|
}
|
|
2781
4110
|
|
|
2782
4111
|
export {
|
|
4112
|
+
VoiceRecordingStorageUnavailableError,
|
|
4113
|
+
VoiceRecordingNotFoundError,
|
|
4114
|
+
VoiceRecordingChunkConflictError,
|
|
4115
|
+
VoiceRecordingChunkSequenceError,
|
|
4116
|
+
VoiceRecordingOwnedError,
|
|
4117
|
+
createVoiceRecordingManifest,
|
|
4118
|
+
prepareVoiceRecordingChunk,
|
|
4119
|
+
planVoiceRecordingChunkCommit,
|
|
4120
|
+
IndexedDbVoiceRecordingStore,
|
|
2783
4121
|
INITIAL_TRANSCRIPTION_CONTROL_STATE,
|
|
2784
4122
|
transitionTranscriptionControl,
|
|
2785
4123
|
appendFinalTranscript,
|
|
2786
4124
|
useTranscription,
|
|
4125
|
+
VOICE_RECORDING_TIMESLICE_MILLISECONDS,
|
|
4126
|
+
VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS,
|
|
4127
|
+
VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
|
|
4128
|
+
VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
|
|
2787
4129
|
useVoiceInput,
|
|
2788
4130
|
OPEN_WORKSTREAM_CONTROL_EVENT,
|
|
2789
4131
|
parseCommandLine,
|
|
@@ -2820,4 +4162,4 @@ export {
|
|
|
2820
4162
|
Status,
|
|
2821
4163
|
ComposerTranscriptionControl
|
|
2822
4164
|
};
|
|
2823
|
-
//# sourceMappingURL=chunk-
|
|
4165
|
+
//# sourceMappingURL=chunk-KC27K42G.js.map
|