@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.
@@ -0,0 +1,12 @@
1
+ export declare const VOICE_RECORDING_OWNER_SESSION_KEY = "opengeni.voice-recording-owner.v1";
2
+ export type VoiceRecordingOwnerLease = {
3
+ ownerId: string;
4
+ release: () => void;
5
+ };
6
+ /**
7
+ * Acquire one document-scoped owner identity shared by every voice hook in the
8
+ * current document. The session-stored candidate survives reload, while the
9
+ * held lock/handshake prevents opener-created or duplicated tabs from reusing
10
+ * that identity concurrently.
11
+ */
12
+ export declare function acquireDefaultVoiceRecordingOwnerLease(): Promise<VoiceRecordingOwnerLease>;
@@ -0,0 +1,124 @@
1
+ export type VoiceRecordingCaptureState = "capturing" | "stopped" | "discarded";
2
+ export type VoiceRecordingUploadState = "pending" | "syncing" | "retrying" | "complete";
3
+ export type VoiceRecordingTranscriptionState = "pending" | "transcribing" | "retrying" | "complete";
4
+ export type VoiceRecordingFinalizationState = "pending" | "transcript-ready" | "handed-off";
5
+ export type VoiceRecordingChunkUploadState = "pending" | "syncing" | "complete";
6
+ export type VoiceRecordingManifest = {
7
+ version: 1;
8
+ recordingId: string;
9
+ workspaceId: string;
10
+ createdAt: string;
11
+ updatedAt: string;
12
+ mimeType: string;
13
+ codec: string | null;
14
+ captureState: VoiceRecordingCaptureState;
15
+ uploadState: VoiceRecordingUploadState;
16
+ transcriptionState: VoiceRecordingTranscriptionState;
17
+ finalizationState: VoiceRecordingFinalizationState;
18
+ /** Tab/process lease. A different owner may take over only after this heartbeat is stale. */
19
+ ownerId: string | null;
20
+ ownerHeartbeatAt: string | null;
21
+ /** Authoritative provider result, persisted before any composer draft mutation. */
22
+ transcriptText: string | null;
23
+ nextChunkNumber: number;
24
+ chunkCount: number;
25
+ totalBytes: number;
26
+ totalDurationMilliseconds: number;
27
+ };
28
+ export type VoiceRecordingChunk = {
29
+ recordingId: string;
30
+ chunkNumber: number;
31
+ capturedAt: string;
32
+ startMilliseconds: number;
33
+ durationMilliseconds: number;
34
+ mimeType: string;
35
+ codec: string | null;
36
+ byteLength: number;
37
+ sha256: string;
38
+ uploadState: VoiceRecordingChunkUploadState;
39
+ audio: Blob;
40
+ };
41
+ export type PersistVoiceRecordingChunkInput = {
42
+ recordingId: string;
43
+ ownerId?: string | undefined;
44
+ chunkNumber: number;
45
+ capturedAt: string;
46
+ startMilliseconds: number;
47
+ durationMilliseconds: number;
48
+ mimeType: string;
49
+ audio: Blob;
50
+ };
51
+ export type PersistVoiceRecordingChunkResult = {
52
+ manifest: VoiceRecordingManifest;
53
+ chunk: VoiceRecordingChunk;
54
+ deduplicated: boolean;
55
+ };
56
+ export interface VoiceRecordingStore {
57
+ createManifest(manifest: VoiceRecordingManifest): Promise<void>;
58
+ getManifest(recordingId: string): Promise<VoiceRecordingManifest | null>;
59
+ listRecoverableManifests(workspaceId: string, ownership?: {
60
+ ownerId: string;
61
+ staleBefore: string;
62
+ }): Promise<VoiceRecordingManifest[]>;
63
+ claimManifest(recordingId: string, ownerId: string, claimedAt: string, staleBefore: string): Promise<VoiceRecordingManifest>;
64
+ listChunks(recordingId: string): Promise<VoiceRecordingChunk[]>;
65
+ persistChunk(input: PersistVoiceRecordingChunkInput): Promise<PersistVoiceRecordingChunkResult>;
66
+ updateManifest(recordingId: string, update: Partial<Pick<VoiceRecordingManifest, "captureState" | "uploadState" | "transcriptionState" | "finalizationState" | "ownerId" | "ownerHeartbeatAt" | "transcriptText">>, updatedAt: string, ownerId?: string | undefined): Promise<VoiceRecordingManifest>;
67
+ discard(recordingId: string, ownerId?: string | undefined): Promise<void>;
68
+ cleanupHandedOffManifests(ownership: {
69
+ ownerId: string;
70
+ staleBefore: string;
71
+ }): Promise<number>;
72
+ close(): Promise<void>;
73
+ }
74
+ export declare class VoiceRecordingStorageUnavailableError extends Error {
75
+ constructor();
76
+ }
77
+ export declare class VoiceRecordingNotFoundError extends Error {
78
+ constructor(recordingId: string);
79
+ }
80
+ export declare class VoiceRecordingChunkConflictError extends Error {
81
+ constructor(recordingId: string, chunkNumber: number);
82
+ }
83
+ export declare class VoiceRecordingChunkSequenceError extends Error {
84
+ constructor(expected: number, received: number);
85
+ }
86
+ export declare class VoiceRecordingOwnedError extends Error {
87
+ constructor(recordingId: string);
88
+ }
89
+ export declare function createVoiceRecordingManifest(input: {
90
+ recordingId: string;
91
+ workspaceId: string;
92
+ mimeType: string;
93
+ createdAt: string;
94
+ ownerId?: string | null | undefined;
95
+ }): VoiceRecordingManifest;
96
+ export declare function prepareVoiceRecordingChunk(input: PersistVoiceRecordingChunkInput): Promise<VoiceRecordingChunk>;
97
+ export declare function planVoiceRecordingChunkCommit(input: {
98
+ manifest: VoiceRecordingManifest;
99
+ chunk: VoiceRecordingChunk;
100
+ existingChunk: VoiceRecordingChunk | null;
101
+ }): PersistVoiceRecordingChunkResult;
102
+ export declare class IndexedDbVoiceRecordingStore implements VoiceRecordingStore {
103
+ private readonly database;
104
+ constructor(options?: {
105
+ indexedDB?: IDBFactory | null;
106
+ databaseName?: string;
107
+ });
108
+ createManifest(manifest: VoiceRecordingManifest): Promise<void>;
109
+ getManifest(recordingId: string): Promise<VoiceRecordingManifest | null>;
110
+ listRecoverableManifests(workspaceId: string, ownership?: {
111
+ ownerId: string;
112
+ staleBefore: string;
113
+ }): Promise<VoiceRecordingManifest[]>;
114
+ claimManifest(recordingId: string, ownerId: string, claimedAt: string, staleBefore: string): Promise<VoiceRecordingManifest>;
115
+ listChunks(recordingId: string): Promise<VoiceRecordingChunk[]>;
116
+ persistChunk(input: PersistVoiceRecordingChunkInput): Promise<PersistVoiceRecordingChunkResult>;
117
+ updateManifest(recordingId: string, update: Partial<Pick<VoiceRecordingManifest, "captureState" | "uploadState" | "transcriptionState" | "finalizationState" | "ownerId" | "ownerHeartbeatAt" | "transcriptText">>, updatedAt: string, ownerId?: string | undefined): Promise<VoiceRecordingManifest>;
118
+ discard(recordingId: string, ownerId?: string | undefined): Promise<void>;
119
+ cleanupHandedOffManifests(ownership: {
120
+ ownerId: string;
121
+ staleBefore: string;
122
+ }): Promise<number>;
123
+ close(): Promise<void>;
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/react",
3
- "version": "0.40.0",
3
+ "version": "0.42.1",
4
4
  "description": "React hooks and styled components for OpenGeni: live session streaming, chat composer, message timeline, session status, and fleet views — token-themed (CSS variables), dark-first, built on Tailwind v4 + Radix + Motion.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -75,7 +75,7 @@
75
75
  "@dnd-kit/utilities": "3.2.2",
76
76
  "@fontsource-variable/noto-sans-arabic": "5.2.10",
77
77
  "@fontsource-variable/noto-sans-jp": "5.2.10",
78
- "@opengeni/sdk": "^0.40.0",
78
+ "@opengeni/sdk": "^0.42.1",
79
79
  "clsx": "^2.1.1",
80
80
  "lucide-react": "^1.8.0",
81
81
  "motion": "^12.0.0",
@@ -4,11 +4,20 @@ import type {
4
4
  TranscriptionAdapter,
5
5
  WorkspaceTranscriptionPolicy,
6
6
  } from "@opengeni/sdk";
7
- import { LoaderCircleIcon, MicIcon, SquareIcon, XIcon } from "lucide-react";
7
+ import {
8
+ ClipboardPasteIcon,
9
+ LoaderCircleIcon,
10
+ MicIcon,
11
+ RefreshCwIcon,
12
+ SquareIcon,
13
+ Trash2Icon,
14
+ XIcon,
15
+ } from "lucide-react";
8
16
  import { AnimatePresence, motion } from "motion/react";
9
17
  import { useEffect, useState, type MouseEvent, type ReactElement } from "react";
10
18
  import { cn } from "../lib/cn";
11
19
  import { useVoiceInput } from "../hooks/use-voice-input";
20
+ import type { VoiceRecordingStore } from "../voice-recording-store";
12
21
  import { useChatComposer } from "./composer";
13
22
  import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
14
23
 
@@ -28,7 +37,12 @@ export type ComposerTranscriptionMessages = {
28
37
  retry: string;
29
38
  requestingPermission: string;
30
39
  recording: string;
40
+ saving: string;
31
41
  transcribing: string;
42
+ recovered: string;
43
+ recoveredTranscript: string;
44
+ insertRecoveredTranscript: string;
45
+ discardRecovered: string;
32
46
  unavailableDisabled: string;
33
47
  unavailable: string;
34
48
  errorPermissionDenied: string;
@@ -36,6 +50,9 @@ export type ComposerTranscriptionMessages = {
36
50
  errorUnavailable: string;
37
51
  errorTooLarge: string;
38
52
  errorInvalidAudio: string;
53
+ errorStorageUnavailable: string;
54
+ errorRetryable: string;
55
+ errorHandoffUncertain: string;
39
56
  errorUnknown: string;
40
57
  };
41
58
 
@@ -46,7 +63,12 @@ const defaultMessages: ComposerTranscriptionMessages = {
46
63
  retry: "Retry voice input",
47
64
  requestingPermission: "Requesting microphone…",
48
65
  recording: "Recording. Press Escape to cancel.",
66
+ saving: "Saving audio locally…",
49
67
  transcribing: "Transcribing…",
68
+ recovered: "Recording recovered and saved locally.",
69
+ recoveredTranscript: "Transcript saved locally. Check your draft before inserting.",
70
+ insertRecoveredTranscript: "Insert saved transcript",
71
+ discardRecovered: "Discard saved recording",
50
72
  unavailableDisabled: "Voice input is unavailable while the composer is disabled.",
51
73
  unavailable: "Voice input is unavailable for this workspace.",
52
74
  errorPermissionDenied: "Microphone permission was denied. Your draft was not changed.",
@@ -54,6 +76,9 @@ const defaultMessages: ComposerTranscriptionMessages = {
54
76
  errorUnavailable: "Voice input is not configured.",
55
77
  errorTooLarge: "Recording is too large. Try a shorter message.",
56
78
  errorInvalidAudio: "The recording could not be read. Try again.",
79
+ errorStorageUnavailable: "Voice input stopped because audio could not be saved safely.",
80
+ errorRetryable: "Recording is saved locally. Retry transcription when ready.",
81
+ errorHandoffUncertain: "Transcript is saved. Check your draft before inserting it again.",
57
82
  errorUnknown: "Voice input could not start. Try again.",
58
83
  };
59
84
 
@@ -72,6 +97,10 @@ export type ComposerTranscriptionControlProps = {
72
97
  onDiagnostic?: unknown;
73
98
  messages?: Partial<ComposerTranscriptionMessages> | undefined;
74
99
  className?: string | undefined;
100
+ /** Test/embed seam. Production defaults to origin-scoped IndexedDB. */
101
+ createRecordingStore?: (() => VoiceRecordingStore) | undefined;
102
+ /** Test/embed seam. Production acquires a coordinated browser-document owner lease. */
103
+ createOwnerId?: (() => string) | undefined;
75
104
  };
76
105
 
77
106
  const WAVEFORM_BARS = 18;
@@ -88,6 +117,8 @@ export function ComposerTranscriptionControl({
88
117
  workspaceEnabled = false,
89
118
  messages: overrides,
90
119
  className,
120
+ createRecordingStore,
121
+ createOwnerId,
91
122
  }: ComposerTranscriptionControlProps) {
92
123
  const composer = useChatComposer();
93
124
  const messages = { ...defaultMessages, ...overrides };
@@ -100,15 +131,26 @@ export function ComposerTranscriptionControl({
100
131
  setValue: composer.setValue,
101
132
  focusInput: composer.focusInput,
102
133
  disabled: composer.disabled,
134
+ createRecordingStore,
135
+ createOwnerId,
103
136
  });
104
137
  const { status } = transcription;
105
138
  const active =
106
- status === "requesting-permission" || status === "recording" || status === "transcribing";
139
+ status === "requesting-permission" ||
140
+ status === "recording" ||
141
+ status === "saving" ||
142
+ status === "transcribing";
143
+ const recoverable =
144
+ transcription.hasRecoverableRecording &&
145
+ (status === "recovered" || status === "transcript-ready" || status === "error");
146
+ const savedTranscript = status === "transcript-ready" && transcription.savedTranscript !== null;
107
147
  const unavailableMessage = composer.disabled
108
148
  ? messages.unavailableDisabled
109
149
  : !capability?.available || !workspaceEnabled
110
150
  ? messages.unavailable
111
- : null;
151
+ : !transcription.available
152
+ ? messages.errorStorageUnavailable
153
+ : null;
112
154
  const idleLabel = unavailableMessage ?? (status === "error" ? messages.retry : messages.start);
113
155
  const errorMessage = transcription.error
114
156
  ? transcriptionErrorMessage(transcription.error, messages)
@@ -118,11 +160,17 @@ export function ComposerTranscriptionControl({
118
160
  ? messages.requestingPermission
119
161
  : status === "recording"
120
162
  ? messages.recording
121
- : status === "transcribing"
122
- ? messages.transcribing
123
- : status === "error"
124
- ? (errorMessage ?? messages.errorUnknown)
125
- : unavailableMessage;
163
+ : status === "saving"
164
+ ? messages.saving
165
+ : status === "transcribing"
166
+ ? messages.transcribing
167
+ : status === "recovered"
168
+ ? messages.recovered
169
+ : status === "transcript-ready"
170
+ ? messages.recoveredTranscript
171
+ : status === "error"
172
+ ? (errorMessage ?? messages.errorUnknown)
173
+ : unavailableMessage;
126
174
 
127
175
  function start(event: MouseEvent<HTMLButtonElement>) {
128
176
  if (unavailableMessage) {
@@ -138,16 +186,72 @@ export function ComposerTranscriptionControl({
138
186
  data-transcription-status={status}
139
187
  >
140
188
  <AnimatePresence mode="popLayout" initial={false}>
141
- {active ? (
189
+ {recoverable ? (
142
190
  <motion.span
143
- key={status === "transcribing" ? "transcribing" : "capture"}
191
+ key="recovered"
192
+ initial={{ opacity: 0, scale: 0.96 }}
193
+ animate={{ opacity: 1, scale: 1 }}
194
+ exit={{ opacity: 0, scale: 0.96 }}
195
+ transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
196
+ className={cn(
197
+ "inline-flex h-8 min-w-0 items-center gap-1 rounded-og-md border border-og-border/80",
198
+ "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11",
199
+ )}
200
+ >
201
+ <span className="max-w-44 truncate text-og-xs text-og-fg-muted max-sm:max-w-28">
202
+ {savedTranscript
203
+ ? (errorMessage ?? messages.recoveredTranscript)
204
+ : status === "error"
205
+ ? (errorMessage ?? messages.errorRetryable)
206
+ : messages.recovered}
207
+ </span>
208
+ <Tip tip={savedTranscript ? messages.insertRecoveredTranscript : messages.retry}>
209
+ <button
210
+ type="button"
211
+ onClick={() =>
212
+ savedTranscript
213
+ ? void transcription.insertSavedTranscript()
214
+ : transcription.retry()
215
+ }
216
+ aria-label={savedTranscript ? messages.insertRecoveredTranscript : messages.retry}
217
+ className={cn(
218
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
219
+ "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
220
+ "hover:bg-og-fg-muted pointer-coarse:size-11",
221
+ )}
222
+ >
223
+ {savedTranscript ? (
224
+ <ClipboardPasteIcon className="size-3.5" />
225
+ ) : (
226
+ <RefreshCwIcon className="size-3.5" />
227
+ )}
228
+ </button>
229
+ </Tip>
230
+ <Tip tip={messages.discardRecovered}>
231
+ <button
232
+ type="button"
233
+ onClick={() => void transcription.discard()}
234
+ aria-label={messages.discardRecovered}
235
+ className={cn(
236
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
237
+ "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
238
+ "hover:bg-og-surface-3 hover:text-og-status-failed pointer-coarse:size-11",
239
+ )}
240
+ >
241
+ <Trash2Icon className="size-3.5" />
242
+ </button>
243
+ </Tip>
244
+ </motion.span>
245
+ ) : active ? (
246
+ <motion.span
247
+ key={status === "transcribing" || status === "saving" ? "processing" : "capture"}
144
248
  initial={{ opacity: 0, scale: 0.96 }}
145
249
  animate={{ opacity: 1, scale: 1 }}
146
250
  exit={{ opacity: 0, scale: 0.96 }}
147
251
  transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
148
252
  className={cn(
149
253
  "inline-flex h-8 items-center gap-1 rounded-og-md border border-og-border/80",
150
- "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-9",
254
+ "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11",
151
255
  )}
152
256
  >
153
257
  {status === "requesting-permission" ? (
@@ -165,7 +269,7 @@ export function ComposerTranscriptionControl({
165
269
  ) : null}
166
270
  <VoiceWaveform
167
271
  stream={status === "recording" ? transcription.stream : null}
168
- mode={status === "transcribing" ? "transcribing" : "recording"}
272
+ mode={status === "recording" ? "recording" : "transcribing"}
169
273
  />
170
274
  </span>
171
275
  )}
@@ -180,7 +284,7 @@ export function ComposerTranscriptionControl({
180
284
  className={cn(
181
285
  "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
182
286
  "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
183
- "hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-9",
287
+ "hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-11",
184
288
  )}
185
289
  >
186
290
  <XIcon className="size-3.5" />
@@ -194,16 +298,16 @@ export function ComposerTranscriptionControl({
194
298
  className={cn(
195
299
  "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
196
300
  "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
197
- "hover:bg-og-fg-muted pointer-coarse:size-9",
301
+ "hover:bg-og-fg-muted pointer-coarse:size-11",
198
302
  )}
199
303
  >
200
304
  <SquareIcon className="size-2.5 fill-current" />
201
305
  </button>
202
306
  </Tip>
203
307
  </>
204
- ) : status === "transcribing" ? (
308
+ ) : status === "transcribing" || status === "saving" ? (
205
309
  <span className="og-shimmer-text px-1.5 text-og-xs font-medium whitespace-nowrap">
206
- {messages.transcribing}
310
+ {status === "saving" ? messages.saving : messages.transcribing}
207
311
  </span>
208
312
  ) : (
209
313
  <span className="px-1.5 text-og-xs text-og-fg-muted whitespace-nowrap">
@@ -224,7 +328,7 @@ export function ComposerTranscriptionControl({
224
328
  aria-pressed={false}
225
329
  aria-disabled={unavailableMessage !== null}
226
330
  className={cn(
227
- "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-9",
331
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-11",
228
332
  "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
229
333
  unavailableMessage
230
334
  ? "cursor-not-allowed opacity-45"
@@ -236,7 +340,7 @@ export function ComposerTranscriptionControl({
236
340
  </Tip>
237
341
  )}
238
342
  </AnimatePresence>
239
- {status === "error" && errorMessage ? (
343
+ {status === "error" && errorMessage && !recoverable ? (
240
344
  <Tip tip={errorMessage}>
241
345
  <span
242
346
  aria-hidden="true"
@@ -364,6 +468,14 @@ function transcriptionErrorMessage(code: string, messages: ComposerTranscription
364
468
  return messages.errorTooLarge;
365
469
  case "invalid_audio":
366
470
  return messages.errorInvalidAudio;
471
+ case "storage_unavailable":
472
+ return messages.errorStorageUnavailable;
473
+ case "network":
474
+ case "provider":
475
+ case "timeout":
476
+ return messages.errorRetryable;
477
+ case "handoff_uncertain":
478
+ return messages.errorHandoffUncertain;
367
479
  case "unknown":
368
480
  return messages.errorUnknown;
369
481
  default:
package/src/composer.ts CHANGED
@@ -71,9 +71,38 @@ export type {
71
71
  ComposerTranscriptionControlProps,
72
72
  ComposerTranscriptionMessages,
73
73
  } from "./components/composer-transcription-control";
74
- export { useVoiceInput } from "./hooks/use-voice-input";
74
+ export {
75
+ VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
76
+ VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS,
77
+ VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
78
+ VOICE_RECORDING_TIMESLICE_MILLISECONDS,
79
+ useVoiceInput,
80
+ } from "./hooks/use-voice-input";
75
81
  export type {
76
82
  UseVoiceInputOptions,
77
83
  UseVoiceInputResult,
78
84
  VoiceInputStatus,
79
85
  } from "./hooks/use-voice-input";
86
+ export {
87
+ IndexedDbVoiceRecordingStore,
88
+ VoiceRecordingChunkConflictError,
89
+ VoiceRecordingChunkSequenceError,
90
+ VoiceRecordingNotFoundError,
91
+ VoiceRecordingOwnedError,
92
+ VoiceRecordingStorageUnavailableError,
93
+ createVoiceRecordingManifest,
94
+ planVoiceRecordingChunkCommit,
95
+ prepareVoiceRecordingChunk,
96
+ } from "./voice-recording-store";
97
+ export type {
98
+ PersistVoiceRecordingChunkInput,
99
+ PersistVoiceRecordingChunkResult,
100
+ VoiceRecordingCaptureState,
101
+ VoiceRecordingChunk,
102
+ VoiceRecordingChunkUploadState,
103
+ VoiceRecordingFinalizationState,
104
+ VoiceRecordingManifest,
105
+ VoiceRecordingStore,
106
+ VoiceRecordingTranscriptionState,
107
+ VoiceRecordingUploadState,
108
+ } from "./voice-recording-store";