@voxdiscover/voiceserver-react 0.1.0
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/LICENSE +21 -0
- package/README.md +418 -0
- package/dist/index.cjs +343 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +334 -0
- package/dist/index.d.ts +334 -0
- package/dist/index.js +319 -0
- package/dist/index.js.map +1 -0
- package/package.json +78 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
var voiceserver = require('@voxdiscover/voiceserver');
|
|
5
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
6
|
+
|
|
7
|
+
// src/VoiceAgentProvider.tsx
|
|
8
|
+
var VoiceAgentContext = react.createContext(
|
|
9
|
+
null
|
|
10
|
+
);
|
|
11
|
+
function VoiceAgentProvider({
|
|
12
|
+
children,
|
|
13
|
+
token,
|
|
14
|
+
baseUrl,
|
|
15
|
+
reconnection,
|
|
16
|
+
onConnect,
|
|
17
|
+
onDisconnect,
|
|
18
|
+
onError
|
|
19
|
+
}) {
|
|
20
|
+
const agentRef = react.useRef(null);
|
|
21
|
+
if (!agentRef.current) {
|
|
22
|
+
agentRef.current = new voiceserver.VoiceAgent({ token, baseUrl, reconnection });
|
|
23
|
+
if (onConnect) {
|
|
24
|
+
agentRef.current.on("connection:state", (state) => {
|
|
25
|
+
if (state === "connected") {
|
|
26
|
+
onConnect();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (onDisconnect) {
|
|
31
|
+
agentRef.current.on("connection:state", (state) => {
|
|
32
|
+
if (state === "disconnected") {
|
|
33
|
+
onDisconnect();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (onError) {
|
|
38
|
+
agentRef.current.on("connection:error", onError);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
react.useEffect(() => {
|
|
42
|
+
return () => {
|
|
43
|
+
if (agentRef.current) {
|
|
44
|
+
agentRef.current.disconnect().catch((err) => {
|
|
45
|
+
console.error("Failed to disconnect on unmount:", err);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}, []);
|
|
50
|
+
return /* @__PURE__ */ jsxRuntime.jsx(VoiceAgentContext.Provider, { value: { agent: agentRef.current, token }, children });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/utils/storage.ts
|
|
54
|
+
function getSessionStorage(key) {
|
|
55
|
+
if (typeof window === "undefined") {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return sessionStorage.getItem(key);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error("Failed to read from sessionStorage:", err);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function setSessionStorage(key, value) {
|
|
66
|
+
if (typeof window === "undefined") {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
sessionStorage.setItem(key, value);
|
|
71
|
+
return true;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error("Failed to write to sessionStorage:", err);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function removeSessionStorage(key) {
|
|
78
|
+
if (typeof window === "undefined") {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
sessionStorage.removeItem(key);
|
|
83
|
+
return true;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error("Failed to remove from sessionStorage:", err);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function getSessionStorageJSON(key) {
|
|
90
|
+
const value = getSessionStorage(key);
|
|
91
|
+
if (!value) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(value);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error("Failed to parse sessionStorage JSON:", err);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function setSessionStorageJSON(key, value) {
|
|
102
|
+
try {
|
|
103
|
+
const json = JSON.stringify(value);
|
|
104
|
+
return setSessionStorage(key, json);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error("Failed to stringify value for sessionStorage:", err);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/hooks/useTranscriptPersistence.ts
|
|
112
|
+
function useTranscriptPersistence(sessionId, transcripts, setTranscripts) {
|
|
113
|
+
react.useEffect(() => {
|
|
114
|
+
if (!sessionId) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const storageKey = `voice-agent-transcripts-${sessionId}`;
|
|
119
|
+
const stored = getSessionStorageJSON(storageKey);
|
|
120
|
+
if (stored && Array.isArray(stored)) {
|
|
121
|
+
setTranscripts(stored);
|
|
122
|
+
}
|
|
123
|
+
} catch (err) {
|
|
124
|
+
console.error("Failed to load persisted transcripts:", err);
|
|
125
|
+
}
|
|
126
|
+
}, [sessionId]);
|
|
127
|
+
react.useEffect(() => {
|
|
128
|
+
if (!sessionId || transcripts.length === 0) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const storageKey = `voice-agent-transcripts-${sessionId}`;
|
|
133
|
+
const success = setSessionStorageJSON(storageKey, transcripts);
|
|
134
|
+
if (!success) {
|
|
135
|
+
console.warn(
|
|
136
|
+
"Failed to persist transcripts (storage quota may be exceeded)"
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error("Failed to persist transcripts:", err);
|
|
141
|
+
}
|
|
142
|
+
}, [sessionId, transcripts]);
|
|
143
|
+
react.useEffect(() => {
|
|
144
|
+
return () => {
|
|
145
|
+
if (sessionId) {
|
|
146
|
+
try {
|
|
147
|
+
const storageKey = `voice-agent-transcripts-${sessionId}`;
|
|
148
|
+
removeSessionStorage(storageKey);
|
|
149
|
+
} catch {
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}, [sessionId]);
|
|
154
|
+
}
|
|
155
|
+
function useConnectionRetry(connectFn, maxAttempts = 5) {
|
|
156
|
+
const [retryState, setRetryState] = react.useState({
|
|
157
|
+
isRetrying: false,
|
|
158
|
+
attempt: 0,
|
|
159
|
+
maxAttempts,
|
|
160
|
+
nextRetryIn: 0
|
|
161
|
+
});
|
|
162
|
+
const intervalRef = react.useRef(null);
|
|
163
|
+
const retry = react.useCallback(async () => {
|
|
164
|
+
if (retryState.attempt >= maxAttempts) {
|
|
165
|
+
console.warn("Max retry attempts reached");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const attempt = retryState.attempt + 1;
|
|
169
|
+
const delay = Math.min(1e3 * Math.pow(2, attempt - 1), 1e4);
|
|
170
|
+
setRetryState({
|
|
171
|
+
isRetrying: true,
|
|
172
|
+
attempt,
|
|
173
|
+
maxAttempts,
|
|
174
|
+
nextRetryIn: delay
|
|
175
|
+
});
|
|
176
|
+
intervalRef.current = setInterval(() => {
|
|
177
|
+
setRetryState((prev) => ({
|
|
178
|
+
...prev,
|
|
179
|
+
nextRetryIn: Math.max(0, prev.nextRetryIn - 100)
|
|
180
|
+
}));
|
|
181
|
+
}, 100);
|
|
182
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
183
|
+
if (intervalRef.current) {
|
|
184
|
+
clearInterval(intervalRef.current);
|
|
185
|
+
intervalRef.current = null;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
await connectFn();
|
|
189
|
+
setRetryState({
|
|
190
|
+
isRetrying: false,
|
|
191
|
+
attempt: 0,
|
|
192
|
+
maxAttempts,
|
|
193
|
+
nextRetryIn: 0
|
|
194
|
+
});
|
|
195
|
+
} catch (err) {
|
|
196
|
+
setRetryState((prev) => ({
|
|
197
|
+
...prev,
|
|
198
|
+
isRetrying: false
|
|
199
|
+
}));
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
}, [connectFn, maxAttempts, retryState.attempt]);
|
|
203
|
+
return {
|
|
204
|
+
retryState,
|
|
205
|
+
retry
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/utils/token.ts
|
|
210
|
+
function extractSessionId(token) {
|
|
211
|
+
const parts = token.split(".");
|
|
212
|
+
if (parts.length !== 3) {
|
|
213
|
+
throw new Error("Malformed session token");
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
217
|
+
if (!payload.session_id) {
|
|
218
|
+
throw new Error("Token missing session_id");
|
|
219
|
+
}
|
|
220
|
+
return payload.session_id;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err instanceof Error && err.message.includes("session_id")) {
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
throw new Error("Failed to decode session token");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/useVoiceAgent.ts
|
|
230
|
+
function useVoiceAgent() {
|
|
231
|
+
const context = react.useContext(VoiceAgentContext);
|
|
232
|
+
if (!context) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
"useVoiceAgent must be used within VoiceAgentProvider. Wrap your component tree with <VoiceAgentProvider>."
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const { agent, token } = context;
|
|
238
|
+
if (!agent) {
|
|
239
|
+
throw new Error("VoiceAgent instance not initialized");
|
|
240
|
+
}
|
|
241
|
+
const sessionId = extractSessionId(token);
|
|
242
|
+
const callState = react.useSyncExternalStore(
|
|
243
|
+
react.useCallback(
|
|
244
|
+
(callback) => {
|
|
245
|
+
const handler = () => callback();
|
|
246
|
+
agent.on("connection:state", handler);
|
|
247
|
+
return () => agent.off("connection:state", handler);
|
|
248
|
+
},
|
|
249
|
+
[agent]
|
|
250
|
+
),
|
|
251
|
+
() => agent.state
|
|
252
|
+
);
|
|
253
|
+
const [transcripts, setTranscripts] = react.useState([]);
|
|
254
|
+
const [error, setError] = react.useState(null);
|
|
255
|
+
useTranscriptPersistence(sessionId, transcripts, setTranscripts);
|
|
256
|
+
const { retryState, retry: retryConnect } = useConnectionRetry(
|
|
257
|
+
async () => agent.connect(),
|
|
258
|
+
5
|
|
259
|
+
);
|
|
260
|
+
react.useEffect(() => {
|
|
261
|
+
const handleTranscript = (data) => {
|
|
262
|
+
setTranscripts((prev) => [...prev, data]);
|
|
263
|
+
};
|
|
264
|
+
const handleError = (err) => {
|
|
265
|
+
setError(err);
|
|
266
|
+
};
|
|
267
|
+
agent.on("transcript:final", handleTranscript);
|
|
268
|
+
agent.on("connection:error", handleError);
|
|
269
|
+
return () => {
|
|
270
|
+
agent.off("transcript:final", handleTranscript);
|
|
271
|
+
agent.off("connection:error", handleError);
|
|
272
|
+
};
|
|
273
|
+
}, [agent]);
|
|
274
|
+
react.useEffect(() => {
|
|
275
|
+
if (callState === "connected" && error) {
|
|
276
|
+
setError(null);
|
|
277
|
+
}
|
|
278
|
+
}, [callState, error]);
|
|
279
|
+
const connect = react.useCallback(async () => {
|
|
280
|
+
try {
|
|
281
|
+
await agent.connect();
|
|
282
|
+
setError(null);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
setError(err);
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
}, [agent]);
|
|
288
|
+
const disconnect = react.useCallback(async () => {
|
|
289
|
+
await agent.disconnect();
|
|
290
|
+
}, [agent]);
|
|
291
|
+
const mute = react.useCallback(() => {
|
|
292
|
+
agent.mute();
|
|
293
|
+
}, [agent]);
|
|
294
|
+
const unmute = react.useCallback(() => {
|
|
295
|
+
agent.unmute();
|
|
296
|
+
}, [agent]);
|
|
297
|
+
const isConnected = callState === "connected";
|
|
298
|
+
const isConnecting = callState === "connecting";
|
|
299
|
+
const isReconnecting = callState === "reconnecting";
|
|
300
|
+
return {
|
|
301
|
+
agent,
|
|
302
|
+
callState,
|
|
303
|
+
transcripts,
|
|
304
|
+
error,
|
|
305
|
+
isConnected,
|
|
306
|
+
isConnecting,
|
|
307
|
+
isReconnecting,
|
|
308
|
+
retryState,
|
|
309
|
+
connect,
|
|
310
|
+
disconnect,
|
|
311
|
+
mute,
|
|
312
|
+
unmute,
|
|
313
|
+
retryConnect
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
Object.defineProperty(exports, "ReconnectionManager", {
|
|
318
|
+
enumerable: true,
|
|
319
|
+
get: function () { return voiceserver.ReconnectionManager; }
|
|
320
|
+
});
|
|
321
|
+
Object.defineProperty(exports, "VoiceAgent", {
|
|
322
|
+
enumerable: true,
|
|
323
|
+
get: function () { return voiceserver.VoiceAgent; }
|
|
324
|
+
});
|
|
325
|
+
exports.VoiceAgentContext = VoiceAgentContext;
|
|
326
|
+
exports.VoiceAgentProvider = VoiceAgentProvider;
|
|
327
|
+
exports.extractSessionId = extractSessionId;
|
|
328
|
+
exports.getSessionStorage = getSessionStorage;
|
|
329
|
+
exports.getSessionStorageJSON = getSessionStorageJSON;
|
|
330
|
+
exports.removeSessionStorage = removeSessionStorage;
|
|
331
|
+
exports.setSessionStorage = setSessionStorage;
|
|
332
|
+
exports.setSessionStorageJSON = setSessionStorageJSON;
|
|
333
|
+
exports.useConnectionRetry = useConnectionRetry;
|
|
334
|
+
exports.useTranscriptPersistence = useTranscriptPersistence;
|
|
335
|
+
exports.useVoiceAgent = useVoiceAgent;
|
|
336
|
+
Object.keys(voiceserver).forEach(function (k) {
|
|
337
|
+
if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
|
|
338
|
+
enumerable: true,
|
|
339
|
+
get: function () { return voiceserver[k]; }
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
//# sourceMappingURL=index.cjs.map
|
|
343
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/VoiceAgentContext.ts","../src/VoiceAgentProvider.tsx","../src/utils/storage.ts","../src/hooks/useTranscriptPersistence.ts","../src/hooks/useConnectionRetry.ts","../src/utils/token.ts","../src/useVoiceAgent.ts"],"names":["createContext","useRef","VoiceAgent","useEffect","jsx","useState","useCallback","useContext","useSyncExternalStore"],"mappings":";;;;;;;AAuBO,IAAM,iBAAA,GAAoBA,mBAAA;AAAA,EAC/B;AACF;ACgBO,SAAS,kBAAA,CAAmB;AAAA,EACjC,QAAA;AAAA,EACA,KAAA;AAAA,EACA,OAAA;AAAA,EACA,YAAA;AAAA,EACA,SAAA;AAAA,EACA,YAAA;AAAA,EACA;AACF,CAAA,EAA4B;AAG1B,EAAA,MAAM,QAAA,GAAWC,aAA0B,IAAI,CAAA;AAI/C,EAAA,IAAI,CAAC,SAAS,OAAA,EAAS;AACrB,IAAA,QAAA,CAAS,UAAU,IAAIC,sBAAA,CAAW,EAAE,KAAA,EAAO,OAAA,EAAS,cAAc,CAAA;AAGlE,IAAA,IAAI,SAAA,EAAW;AACb,MAAA,QAAA,CAAS,OAAA,CAAQ,EAAA,CAAG,kBAAA,EAAoB,CAAC,KAAA,KAAU;AACjD,QAAA,IAAI,UAAU,WAAA,EAAa;AACzB,UAAA,SAAA,EAAU;AAAA,QACZ;AAAA,MACF,CAAC,CAAA;AAAA,IACH;AAEA,IAAA,IAAI,YAAA,EAAc;AAChB,MAAA,QAAA,CAAS,OAAA,CAAQ,EAAA,CAAG,kBAAA,EAAoB,CAAC,KAAA,KAAU;AACjD,QAAA,IAAI,UAAU,cAAA,EAAgB;AAC5B,UAAA,YAAA,EAAa;AAAA,QACf;AAAA,MACF,CAAC,CAAA;AAAA,IACH;AAEA,IAAA,IAAI,OAAA,EAAS;AACX,MAAA,QAAA,CAAS,OAAA,CAAQ,EAAA,CAAG,kBAAA,EAAoB,OAAO,CAAA;AAAA,IACjD;AAAA,EACF;AAIA,EAAAC,eAAA,CAAU,MAAM;AACd,IAAA,OAAO,MAAM;AACX,MAAA,IAAI,SAAS,OAAA,EAAS;AACpB,QAAA,QAAA,CAAS,OAAA,CAAQ,UAAA,EAAW,CAAE,KAAA,CAAM,CAAC,GAAA,KAAQ;AAC3C,UAAA,OAAA,CAAQ,KAAA,CAAM,oCAAoC,GAAG,CAAA;AAAA,QACvD,CAAC,CAAA;AAAA,MACH;AAAA,IACF,CAAA;AAAA,EACF,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,uBACEC,cAAA,CAAC,iBAAA,CAAkB,QAAA,EAAlB,EAA2B,KAAA,EAAO,EAAE,KAAA,EAAO,QAAA,CAAS,OAAA,EAAS,KAAA,EAAM,EACjE,QAAA,EACH,CAAA;AAEJ;;;ACxEO,SAAS,kBAAkB,GAAA,EAA4B;AAE5D,EAAA,IAAI,OAAO,WAAW,WAAA,EAAa;AACjC,IAAA,OAAO,IAAA;AAAA,EACT;AAEA,EAAA,IAAI;AACF,IAAA,OAAO,cAAA,CAAe,QAAQ,GAAG,CAAA;AAAA,EACnC,SAAS,GAAA,EAAK;AAEZ,IAAA,OAAA,CAAQ,KAAA,CAAM,uCAAuC,GAAG,CAAA;AACxD,IAAA,OAAO,IAAA;AAAA,EACT;AACF;AAkBO,SAAS,iBAAA,CAAkB,KAAa,KAAA,EAAwB;AAErE,EAAA,IAAI,OAAO,WAAW,WAAA,EAAa;AACjC,IAAA,OAAO,KAAA;AAAA,EACT;AAEA,EAAA,IAAI;AACF,IAAA,cAAA,CAAe,OAAA,CAAQ,KAAK,KAAK,CAAA;AACjC,IAAA,OAAO,IAAA;AAAA,EACT,SAAS,GAAA,EAAK;AAEZ,IAAA,OAAA,CAAQ,KAAA,CAAM,sCAAsC,GAAG,CAAA;AACvD,IAAA,OAAO,KAAA;AAAA,EACT;AACF;AAcO,SAAS,qBAAqB,GAAA,EAAsB;AAEzD,EAAA,IAAI,OAAO,WAAW,WAAA,EAAa;AACjC,IAAA,OAAO,KAAA;AAAA,EACT;AAEA,EAAA,IAAI;AACF,IAAA,cAAA,CAAe,WAAW,GAAG,CAAA;AAC7B,IAAA,OAAO,IAAA;AAAA,EACT,SAAS,GAAA,EAAK;AACZ,IAAA,OAAA,CAAQ,KAAA,CAAM,yCAAyC,GAAG,CAAA;AAC1D,IAAA,OAAO,KAAA;AAAA,EACT;AACF;AAiBO,SAAS,sBAAyB,GAAA,EAAuB;AAC9D,EAAA,MAAM,KAAA,GAAQ,kBAAkB,GAAG,CAAA;AACnC,EAAA,IAAI,CAAC,KAAA,EAAO;AACV,IAAA,OAAO,IAAA;AAAA,EACT;AAEA,EAAA,IAAI;AACF,IAAA,OAAO,IAAA,CAAK,MAAM,KAAK,CAAA;AAAA,EACzB,SAAS,GAAA,EAAK;AACZ,IAAA,OAAA,CAAQ,KAAA,CAAM,wCAAwC,GAAG,CAAA;AACzD,IAAA,OAAO,IAAA;AAAA,EACT;AACF;AAkBO,SAAS,qBAAA,CAAyB,KAAa,KAAA,EAAmB;AACvE,EAAA,IAAI;AACF,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,SAAA,CAAU,KAAK,CAAA;AACjC,IAAA,OAAO,iBAAA,CAAkB,KAAK,IAAI,CAAA;AAAA,EACpC,SAAS,GAAA,EAAK;AACZ,IAAA,OAAA,CAAQ,KAAA,CAAM,iDAAiD,GAAG,CAAA;AAClE,IAAA,OAAO,KAAA;AAAA,EACT;AACF;;;ACzHO,SAAS,wBAAA,CACd,SAAA,EACA,WAAA,EACA,cAAA,EACM;AAEN,EAAAD,gBAAU,MAAM;AACd,IAAA,IAAI,CAAC,SAAA,EAAW;AACd,MAAA;AAAA,IACF;AAEA,IAAA,IAAI;AACF,MAAA,MAAM,UAAA,GAAa,2BAA2B,SAAS,CAAA,CAAA;AACvD,MAAA,MAAM,MAAA,GAAS,sBAAwC,UAAU,CAAA;AAEjE,MAAA,IAAI,MAAA,IAAU,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,EAAG;AACnC,QAAA,cAAA,CAAe,MAAM,CAAA;AAAA,MACvB;AAAA,IACF,SAAS,GAAA,EAAK;AAEZ,MAAA,OAAA,CAAQ,KAAA,CAAM,yCAAyC,GAAG,CAAA;AAAA,IAC5D;AAAA,EAEF,CAAA,EAAG,CAAC,SAAS,CAAC,CAAA;AAGd,EAAAA,gBAAU,MAAM;AACd,IAAA,IAAI,CAAC,SAAA,IAAa,WAAA,CAAY,MAAA,KAAW,CAAA,EAAG;AAC1C,MAAA;AAAA,IACF;AAEA,IAAA,IAAI;AACF,MAAA,MAAM,UAAA,GAAa,2BAA2B,SAAS,CAAA,CAAA;AACvD,MAAA,MAAM,OAAA,GAAU,qBAAA,CAAsB,UAAA,EAAY,WAAW,CAAA;AAE7D,MAAA,IAAI,CAAC,OAAA,EAAS;AAEZ,QAAA,OAAA,CAAQ,IAAA;AAAA,UACN;AAAA,SACF;AAAA,MACF;AAAA,IACF,SAAS,GAAA,EAAK;AACZ,MAAA,OAAA,CAAQ,KAAA,CAAM,kCAAkC,GAAG,CAAA;AAAA,IACrD;AAAA,EACF,CAAA,EAAG,CAAC,SAAA,EAAW,WAAW,CAAC,CAAA;AAG3B,EAAAA,gBAAU,MAAM;AACd,IAAA,OAAO,MAAM;AACX,MAAA,IAAI,SAAA,EAAW;AACb,QAAA,IAAI;AACF,UAAA,MAAM,UAAA,GAAa,2BAA2B,SAAS,CAAA,CAAA;AACvD,UAAA,oBAAA,CAAqB,UAAU,CAAA;AAAA,QACjC,CAAA,CAAA,MAAQ;AAAA,QAER;AAAA,MACF;AAAA,IACF,CAAA;AAAA,EACF,CAAA,EAAG,CAAC,SAAS,CAAC,CAAA;AAChB;AC5CO,SAAS,kBAAA,CACd,SAAA,EACA,WAAA,GAAsB,CAAA,EAItB;AACA,EAAA,MAAM,CAAC,UAAA,EAAY,aAAa,CAAA,GAAIE,cAAA,CAAqB;AAAA,IACvD,UAAA,EAAY,KAAA;AAAA,IACZ,OAAA,EAAS,CAAA;AAAA,IACT,WAAA;AAAA,IACA,WAAA,EAAa;AAAA,GACd,CAAA;AAGD,EAAA,MAAM,WAAA,GAAcJ,aAA8B,IAAI,CAAA;AAMtD,EAAA,MAAM,KAAA,GAAQK,kBAAY,YAAY;AAEpC,IAAA,IAAI,UAAA,CAAW,WAAW,WAAA,EAAa;AACrC,MAAA,OAAA,CAAQ,KAAK,4BAA4B,CAAA;AACzC,MAAA;AAAA,IACF;AAEA,IAAA,MAAM,OAAA,GAAU,WAAW,OAAA,GAAU,CAAA;AAGrC,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,GAAA,GAAO,IAAA,CAAK,IAAI,CAAA,EAAG,OAAA,GAAU,CAAC,CAAA,EAAG,GAAK,CAAA;AAE7D,IAAA,aAAA,CAAc;AAAA,MACZ,UAAA,EAAY,IAAA;AAAA,MACZ,OAAA;AAAA,MACA,WAAA;AAAA,MACA,WAAA,EAAa;AAAA,KACd,CAAA;AAGD,IAAA,WAAA,CAAY,OAAA,GAAU,YAAY,MAAM;AACtC,MAAA,aAAA,CAAc,CAAC,IAAA,MAAU;AAAA,QACvB,GAAG,IAAA;AAAA,QACH,aAAa,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,cAAc,GAAG;AAAA,OACjD,CAAE,CAAA;AAAA,IACJ,GAAG,GAAG,CAAA;AAGN,IAAA,MAAM,IAAI,OAAA,CAAQ,CAAC,YAAY,UAAA,CAAW,OAAA,EAAS,KAAK,CAAC,CAAA;AAGzD,IAAA,IAAI,YAAY,OAAA,EAAS;AACvB,MAAA,aAAA,CAAc,YAAY,OAAO,CAAA;AACjC,MAAA,WAAA,CAAY,OAAA,GAAU,IAAA;AAAA,IACxB;AAEA,IAAA,IAAI;AAEF,MAAA,MAAM,SAAA,EAAU;AAGhB,MAAA,aAAA,CAAc;AAAA,QACZ,UAAA,EAAY,KAAA;AAAA,QACZ,OAAA,EAAS,CAAA;AAAA,QACT,WAAA;AAAA,QACA,WAAA,EAAa;AAAA,OACd,CAAA;AAAA,IACH,SAAS,GAAA,EAAK;AAEZ,MAAA,aAAA,CAAc,CAAC,IAAA,MAAU;AAAA,QACvB,GAAG,IAAA;AAAA,QACH,UAAA,EAAY;AAAA,OACd,CAAE,CAAA;AAGF,MAAA,MAAM,GAAA;AAAA,IACR;AAAA,EACF,GAAG,CAAC,SAAA,EAAW,WAAA,EAAa,UAAA,CAAW,OAAO,CAAC,CAAA;AAE/C,EAAA,OAAO;AAAA,IACL,UAAA;AAAA,IACA;AAAA,GACF;AACF;;;AChHO,SAAS,iBAAiB,KAAA,EAAuB;AACtD,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,KAAA,CAAM,GAAG,CAAA;AAC7B,EAAA,IAAI,KAAA,CAAM,WAAW,CAAA,EAAG;AACtB,IAAA,MAAM,IAAI,MAAM,yBAAyB,CAAA;AAAA,EAC3C;AAEA,EAAA,IAAI;AACF,IAAA,MAAM,UAAU,IAAA,CAAK,KAAA,CAAM,KAAK,KAAA,CAAM,CAAC,CAAC,CAAC,CAAA;AACzC,IAAA,IAAI,CAAC,QAAQ,UAAA,EAAY;AACvB,MAAA,MAAM,IAAI,MAAM,0BAA0B,CAAA;AAAA,IAC5C;AACA,IAAA,OAAO,OAAA,CAAQ,UAAA;AAAA,EACjB,SAAS,GAAA,EAAK;AACZ,IAAA,IAAI,eAAe,KAAA,IAAS,GAAA,CAAI,OAAA,CAAQ,QAAA,CAAS,YAAY,CAAA,EAAG;AAC9D,MAAA,MAAM,GAAA;AAAA,IACR;AACA,IAAA,MAAM,IAAI,MAAM,gCAAgC,CAAA;AAAA,EAClD;AACF;;;AC6DO,SAAS,aAAA,GAAqC;AACnD,EAAA,MAAM,OAAA,GAAUC,iBAAW,iBAAiB,CAAA;AAG5C,EAAA,IAAI,CAAC,OAAA,EAAS;AACZ,IAAA,MAAM,IAAI,KAAA;AAAA,MACR;AAAA,KAEF;AAAA,EACF;AAEA,EAAA,MAAM,EAAE,KAAA,EAAO,KAAA,EAAM,GAAI,OAAA;AACzB,EAAA,IAAI,CAAC,KAAA,EAAO;AACV,IAAA,MAAM,IAAI,MAAM,qCAAqC,CAAA;AAAA,EACvD;AAGA,EAAA,MAAM,SAAA,GAAY,iBAAiB,KAAK,CAAA;AAIxC,EAAA,MAAM,SAAA,GAAYC,0BAAA;AAAA,IAChBF,iBAAAA;AAAA,MACE,CAAC,QAAA,KAAa;AACZ,QAAA,MAAM,OAAA,GAAU,MAAM,QAAA,EAAS;AAC/B,QAAA,KAAA,CAAM,EAAA,CAAG,oBAAoB,OAAO,CAAA;AACpC,QAAA,OAAO,MAAM,KAAA,CAAM,GAAA,CAAI,kBAAA,EAAoB,OAAO,CAAA;AAAA,MACpD,CAAA;AAAA,MACA,CAAC,KAAK;AAAA,KACR;AAAA,IACA,MAAM,KAAA,CAAM;AAAA,GACd;AAGA,EAAA,MAAM,CAAC,WAAA,EAAa,cAAc,CAAA,GAAID,cAAAA,CAA2B,EAAE,CAAA;AAGnE,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,CAAA,GAAIA,eAAuB,IAAI,CAAA;AAGrD,EAAA,wBAAA,CAAyB,SAAA,EAAW,aAAa,cAAc,CAAA;AAG/D,EAAA,MAAM,EAAE,UAAA,EAAY,KAAA,EAAO,YAAA,EAAa,GAAI,kBAAA;AAAA,IAC1C,YAAY,MAAM,OAAA,EAAQ;AAAA,IAC1B;AAAA,GACF;AAGA,EAAAF,gBAAU,MAAM;AAEd,IAAA,MAAM,gBAAA,GAAmB,CAAC,IAAA,KAAyB;AACjD,MAAA,cAAA,CAAe,CAAC,IAAA,KAAS,CAAC,GAAG,IAAA,EAAM,IAAI,CAAC,CAAA;AAAA,IAC1C,CAAA;AAGA,IAAA,MAAM,WAAA,GAAc,CAAC,GAAA,KAAe;AAClC,MAAA,QAAA,CAAS,GAAG,CAAA;AAAA,IACd,CAAA;AAEA,IAAA,KAAA,CAAM,EAAA,CAAG,oBAAoB,gBAAgB,CAAA;AAC7C,IAAA,KAAA,CAAM,EAAA,CAAG,oBAAoB,WAAW,CAAA;AAGxC,IAAA,OAAO,MAAM;AACX,MAAA,KAAA,CAAM,GAAA,CAAI,oBAAoB,gBAAgB,CAAA;AAC9C,MAAA,KAAA,CAAM,GAAA,CAAI,oBAAoB,WAAW,CAAA;AAAA,IAC3C,CAAA;AAAA,EACF,CAAA,EAAG,CAAC,KAAK,CAAC,CAAA;AAGV,EAAAA,gBAAU,MAAM;AACd,IAAA,IAAI,SAAA,KAAc,eAAe,KAAA,EAAO;AACtC,MAAA,QAAA,CAAS,IAAI,CAAA;AAAA,IACf;AAAA,EACF,CAAA,EAAG,CAAC,SAAA,EAAW,KAAK,CAAC,CAAA;AAIrB,EAAA,MAAM,OAAA,GAAUG,kBAAY,YAAY;AACtC,IAAA,IAAI;AACF,MAAA,MAAM,MAAM,OAAA,EAAQ;AACpB,MAAA,QAAA,CAAS,IAAI,CAAA;AAAA,IACf,SAAS,GAAA,EAAK;AACZ,MAAA,QAAA,CAAS,GAAY,CAAA;AACrB,MAAA,MAAM,GAAA;AAAA,IACR;AAAA,EACF,CAAA,EAAG,CAAC,KAAK,CAAC,CAAA;AAEV,EAAA,MAAM,UAAA,GAAaA,kBAAY,YAAY;AACzC,IAAA,MAAM,MAAM,UAAA,EAAW;AAAA,EACzB,CAAA,EAAG,CAAC,KAAK,CAAC,CAAA;AAEV,EAAA,MAAM,IAAA,GAAOA,kBAAY,MAAM;AAC7B,IAAA,KAAA,CAAM,IAAA,EAAK;AAAA,EACb,CAAA,EAAG,CAAC,KAAK,CAAC,CAAA;AAEV,EAAA,MAAM,MAAA,GAASA,kBAAY,MAAM;AAC/B,IAAA,KAAA,CAAM,MAAA,EAAO;AAAA,EACf,CAAA,EAAG,CAAC,KAAK,CAAC,CAAA;AAGV,EAAA,MAAM,cAAc,SAAA,KAAc,WAAA;AAClC,EAAA,MAAM,eAAe,SAAA,KAAc,YAAA;AACnC,EAAA,MAAM,iBAAiB,SAAA,KAAc,cAAA;AAGrC,EAAA,OAAO;AAAA,IACL,KAAA;AAAA,IACA,SAAA;AAAA,IACA,WAAA;AAAA,IACA,KAAA;AAAA,IACA,WAAA;AAAA,IACA,YAAA;AAAA,IACA,cAAA;AAAA,IACA,UAAA;AAAA,IACA,OAAA;AAAA,IACA,UAAA;AAAA,IACA,IAAA;AAAA,IACA,MAAA;AAAA,IACA;AAAA,GACF;AACF","file":"index.cjs","sourcesContent":["import { createContext } from 'react';\nimport type { VoiceAgent } from '@voxdiscover/voiceserver';\n\n/**\n * Context value containing VoiceAgent instance and token.\n * Per user decision: Context only holds agent instance, not UI state.\n * Token included for session ID extraction (transcript persistence).\n */\nexport interface VoiceAgentContextValue {\n agent: VoiceAgent | null;\n token: string;\n}\n\n/**\n * React context for VoiceAgent instance.\n * Provides stable reference to agent across component tree.\n *\n * @example\n * ```tsx\n * const context = useContext(VoiceAgentContext);\n * if (!context?.agent) throw new Error('No agent available');\n * ```\n */\nexport const VoiceAgentContext = createContext<VoiceAgentContextValue | null>(\n null\n);\n","import { useRef, useEffect, type ReactNode } from 'react';\nimport { VoiceAgent, type VoiceAgentConfig } from '@voxdiscover/voiceserver';\nimport { VoiceAgentContext } from './VoiceAgentContext.js';\n\n/**\n * Props for VoiceAgentProvider component.\n * Per user decision: Config object pattern for flexibility.\n */\nexport interface VoiceAgentProviderProps extends VoiceAgentConfig {\n children: ReactNode;\n /** Callback when connection is established */\n onConnect?: () => void;\n /** Callback when connection is closed */\n onDisconnect?: () => void;\n /** Callback for connection errors */\n onError?: (error: Error) => void;\n}\n\n/**\n * Context provider for VoiceAgent instance.\n *\n * Per user decisions:\n * - Creates VoiceAgent instance but waits for explicit connect() call (manual connect pattern)\n * - Auto-disconnects and cleans up WebRTC resources on unmount\n * - Provides stable agent reference via useRef (prevents re-render cascades)\n *\n * @example\n * ```tsx\n * function App() {\n * return (\n * <VoiceAgentProvider\n * token={sessionToken}\n * onConnect={() => console.log('Connected!')}\n * onError={(err) => console.error('Error:', err)}\n * >\n * <VoiceChat />\n * </VoiceAgentProvider>\n * );\n * }\n * ```\n */\nexport function VoiceAgentProvider({\n children,\n token,\n baseUrl,\n reconnection,\n onConnect,\n onDisconnect,\n onError,\n}: VoiceAgentProviderProps) {\n // Per RESEARCH.md Pattern 1 and Pitfall 2:\n // Use useRef for agent instance to prevent re-renders on context value changes\n const agentRef = useRef<VoiceAgent | null>(null);\n\n // Initialize agent once\n // Per user decision: Create instance in provider but don't auto-connect\n if (!agentRef.current) {\n agentRef.current = new VoiceAgent({ token, baseUrl, reconnection });\n\n // Register lifecycle callbacks from props\n if (onConnect) {\n agentRef.current.on('connection:state', (state) => {\n if (state === 'connected') {\n onConnect();\n }\n });\n }\n\n if (onDisconnect) {\n agentRef.current.on('connection:state', (state) => {\n if (state === 'disconnected') {\n onDisconnect();\n }\n });\n }\n\n if (onError) {\n agentRef.current.on('connection:error', onError);\n }\n }\n\n // Per user decision: Auto-cleanup on unmount\n // Per RESEARCH.md Pitfall 1: Always return cleanup function\n useEffect(() => {\n return () => {\n if (agentRef.current) {\n agentRef.current.disconnect().catch((err) => {\n console.error('Failed to disconnect on unmount:', err);\n });\n }\n };\n }, []);\n\n return (\n <VoiceAgentContext.Provider value={{ agent: agentRef.current, token }}>\n {children}\n </VoiceAgentContext.Provider>\n );\n}\n","/**\n * SSR-safe sessionStorage utilities.\n *\n * Per RESEARCH.md Pitfall 4:\n * - Guard with typeof window !== 'undefined' check\n * - Wrap in try-catch (storage can be disabled or full)\n * - Return null/false on error or SSR\n *\n * @packageDocumentation\n */\n\n/**\n * Get item from sessionStorage in SSR-safe manner.\n * Returns null if running in SSR, storage disabled, or key doesn't exist.\n *\n * @param key - Storage key\n * @returns Value or null\n *\n * @example\n * ```typescript\n * const transcripts = getSessionStorage('voice-agent-transcripts');\n * if (transcripts) {\n * // Use transcripts\n * }\n * ```\n */\nexport function getSessionStorage(key: string): string | null {\n // Per RESEARCH.md Pitfall 4: Guard SSR\n if (typeof window === 'undefined') {\n return null;\n }\n\n try {\n return sessionStorage.getItem(key);\n } catch (err) {\n // Storage disabled, quota exceeded, or other error\n console.error('Failed to read from sessionStorage:', err);\n return null;\n }\n}\n\n/**\n * Set item in sessionStorage in SSR-safe manner.\n * Returns true on success, false on failure (SSR, quota exceeded, disabled).\n *\n * @param key - Storage key\n * @param value - Value to store\n * @returns Success flag\n *\n * @example\n * ```typescript\n * const success = setSessionStorage('voice-agent-transcripts', JSON.stringify(transcripts));\n * if (!success) {\n * console.warn('Failed to persist transcripts');\n * }\n * ```\n */\nexport function setSessionStorage(key: string, value: string): boolean {\n // Per RESEARCH.md Pitfall 4: Guard SSR\n if (typeof window === 'undefined') {\n return false;\n }\n\n try {\n sessionStorage.setItem(key, value);\n return true;\n } catch (err) {\n // Storage quota exceeded, disabled, or other error\n console.error('Failed to write to sessionStorage:', err);\n return false;\n }\n}\n\n/**\n * Remove item from sessionStorage in SSR-safe manner.\n * Returns true on success, false on failure (SSR, disabled).\n *\n * @param key - Storage key\n * @returns Success flag\n *\n * @example\n * ```typescript\n * removeSessionStorage('voice-agent-transcripts-session-123');\n * ```\n */\nexport function removeSessionStorage(key: string): boolean {\n // Per RESEARCH.md Pitfall 4: Guard SSR\n if (typeof window === 'undefined') {\n return false;\n }\n\n try {\n sessionStorage.removeItem(key);\n return true;\n } catch (err) {\n console.error('Failed to remove from sessionStorage:', err);\n return false;\n }\n}\n\n/**\n * Get and parse JSON from sessionStorage.\n * Returns null if parsing fails, SSR, or key doesn't exist.\n *\n * @param key - Storage key\n * @returns Parsed value or null\n *\n * @example\n * ```typescript\n * const transcripts = getSessionStorageJSON<TranscriptData[]>('voice-agent-transcripts');\n * if (transcripts) {\n * // Use typed transcripts array\n * }\n * ```\n */\nexport function getSessionStorageJSON<T>(key: string): T | null {\n const value = getSessionStorage(key);\n if (!value) {\n return null;\n }\n\n try {\n return JSON.parse(value) as T;\n } catch (err) {\n console.error('Failed to parse sessionStorage JSON:', err);\n return null;\n }\n}\n\n/**\n * Stringify and store JSON in sessionStorage.\n * Returns true on success, false on failure (SSR, quota, parse error).\n *\n * @param key - Storage key\n * @param value - Value to stringify and store\n * @returns Success flag\n *\n * @example\n * ```typescript\n * const success = setSessionStorageJSON('voice-agent-transcripts', transcripts);\n * if (!success) {\n * console.warn('Failed to persist transcripts');\n * }\n * ```\n */\nexport function setSessionStorageJSON<T>(key: string, value: T): boolean {\n try {\n const json = JSON.stringify(value);\n return setSessionStorage(key, json);\n } catch (err) {\n console.error('Failed to stringify value for sessionStorage:', err);\n return false;\n }\n}\n","import { useEffect, type Dispatch, type SetStateAction } from 'react';\nimport type { TranscriptData } from '@voxdiscover/voiceserver';\nimport {\n getSessionStorageJSON,\n setSessionStorageJSON,\n removeSessionStorage,\n} from '../utils/storage.js';\n\n/**\n * Hook to persist transcripts to sessionStorage.\n *\n * Per RESEARCH.md Pattern 3 and user decision:\n * - Load persisted transcripts on mount\n * - Save transcripts to sessionStorage on change\n * - Clear on unmount (sessionStorage auto-clears on tab close)\n * - Handle errors silently (log to console, don't crash)\n * - No limits on transcript array size (browser quota is the limit)\n *\n * Per RESEARCH.md Pitfall 4:\n * - Uses SSR-safe storage utilities\n * - Won't crash in Next.js/Remix server-side rendering\n *\n * @param sessionId - Session ID to namespace storage key\n * @param transcripts - Current transcripts array\n * @param setTranscripts - State setter to restore transcripts\n *\n * @example\n * ```typescript\n * const [transcripts, setTranscripts] = useState<TranscriptData[]>([]);\n * useTranscriptPersistence(sessionId, transcripts, setTranscripts);\n * ```\n */\nexport function useTranscriptPersistence(\n sessionId: string,\n transcripts: TranscriptData[],\n setTranscripts: Dispatch<SetStateAction<TranscriptData[]>>\n): void {\n // Load persisted transcripts on mount\n useEffect(() => {\n if (!sessionId) {\n return; // No session ID yet\n }\n\n try {\n const storageKey = `voice-agent-transcripts-${sessionId}`;\n const stored = getSessionStorageJSON<TranscriptData[]>(storageKey);\n\n if (stored && Array.isArray(stored)) {\n setTranscripts(stored);\n }\n } catch (err) {\n // Per user decision: handle errors silently\n console.error('Failed to load persisted transcripts:', err);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [sessionId]); // Only run on sessionId change, not on setTranscripts change\n\n // Save transcripts on change\n useEffect(() => {\n if (!sessionId || transcripts.length === 0) {\n return; // Nothing to save yet\n }\n\n try {\n const storageKey = `voice-agent-transcripts-${sessionId}`;\n const success = setSessionStorageJSON(storageKey, transcripts);\n\n if (!success) {\n // Per user decision: quota exceeded error logged but doesn't interrupt UX\n console.warn(\n 'Failed to persist transcripts (storage quota may be exceeded)'\n );\n }\n } catch (err) {\n console.error('Failed to persist transcripts:', err);\n }\n }, [sessionId, transcripts]);\n\n // Clear on unmount (optional - sessionStorage auto-clears on tab close)\n useEffect(() => {\n return () => {\n if (sessionId) {\n try {\n const storageKey = `voice-agent-transcripts-${sessionId}`;\n removeSessionStorage(storageKey);\n } catch {\n // Ignore cleanup errors\n }\n }\n };\n }, [sessionId]);\n}\n","import { useState, useCallback, useRef } from 'react';\n\n/**\n * Retry state exposed for UI feedback.\n * Per user decision: UI can display attempt count and countdown timer.\n */\nexport interface RetryState {\n /** Whether a retry is currently in progress */\n isRetrying: boolean;\n /** Current retry attempt number (0-based) */\n attempt: number;\n /** Maximum retry attempts configured */\n maxAttempts: number;\n /** Milliseconds until next retry attempt */\n nextRetryIn: number;\n}\n\n/**\n * Hook providing automatic retry logic with exponential backoff.\n *\n * Per RESEARCH.md Pattern 4 and user decision:\n * - Exponential backoff: delay = min(1000 * 2^(attempt-1), 10000)\n * - Countdown timer updates every 100ms for UI feedback\n * - Reset retry state on success\n * - Keep attempt count on failure for next retry\n *\n * @param connectFn - Async function to retry (typically agent.connect)\n * @param maxAttempts - Maximum retry attempts (default: 5)\n * @returns Retry state and retry function\n *\n * @example\n * ```typescript\n * const { retryState, retry } = useConnectionRetry(\n * async () => agent.connect(),\n * 5\n * );\n *\n * // Show retry UI\n * if (retryState.isRetrying) {\n * return <div>Retrying... ({retryState.attempt}/{retryState.maxAttempts})\n * Next attempt in {Math.ceil(retryState.nextRetryIn / 1000)}s</div>\n * }\n *\n * // Manual retry\n * <button onClick={retry}>Retry Connection</button>\n * ```\n */\nexport function useConnectionRetry(\n connectFn: () => Promise<void>,\n maxAttempts: number = 5\n): {\n retryState: RetryState;\n retry: () => Promise<void>;\n} {\n const [retryState, setRetryState] = useState<RetryState>({\n isRetrying: false,\n attempt: 0,\n maxAttempts,\n nextRetryIn: 0,\n });\n\n // Track interval for cleanup\n const intervalRef = useRef<NodeJS.Timeout | null>(null);\n\n /**\n * Retry function with exponential backoff.\n * Per RESEARCH.md Pattern 4: Cap delay at 10s, update countdown every 100ms.\n */\n const retry = useCallback(async () => {\n // Check if max attempts reached\n if (retryState.attempt >= maxAttempts) {\n console.warn('Max retry attempts reached');\n return;\n }\n\n const attempt = retryState.attempt + 1;\n\n // Calculate exponential backoff delay (cap at 10s)\n const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);\n\n setRetryState({\n isRetrying: true,\n attempt,\n maxAttempts,\n nextRetryIn: delay,\n });\n\n // Start countdown interval for UI feedback (update every 100ms)\n intervalRef.current = setInterval(() => {\n setRetryState((prev) => ({\n ...prev,\n nextRetryIn: Math.max(0, prev.nextRetryIn - 100),\n }));\n }, 100);\n\n // Wait for delay\n await new Promise((resolve) => setTimeout(resolve, delay));\n\n // Clear interval\n if (intervalRef.current) {\n clearInterval(intervalRef.current);\n intervalRef.current = null;\n }\n\n try {\n // Attempt connection\n await connectFn();\n\n // Success - reset retry state\n setRetryState({\n isRetrying: false,\n attempt: 0,\n maxAttempts,\n nextRetryIn: 0,\n });\n } catch (err) {\n // Failure - keep attempt count, allow next retry\n setRetryState((prev) => ({\n ...prev,\n isRetrying: false,\n }));\n\n // Re-throw for caller to handle\n throw err;\n }\n }, [connectFn, maxAttempts, retryState.attempt]);\n\n return {\n retryState,\n retry,\n };\n}\n","/**\n * Simple JWT token decoder for extracting session ID.\n * Per plan: Client-side JWT decoding (backend already validated).\n */\n\n/**\n * Extract session ID from JWT session token.\n * Uses atob() for browser compatibility.\n *\n * @param token - JWT session token\n * @returns Session ID string\n * @throws {Error} If token is malformed\n *\n * @example\n * ```typescript\n * const sessionId = extractSessionId(sessionToken);\n * // Use sessionId for storage key\n * ```\n */\nexport function extractSessionId(token: string): string {\n const parts = token.split('.');\n if (parts.length !== 3) {\n throw new Error('Malformed session token');\n }\n\n try {\n const payload = JSON.parse(atob(parts[1]));\n if (!payload.session_id) {\n throw new Error('Token missing session_id');\n }\n return payload.session_id;\n } catch (err) {\n if (err instanceof Error && err.message.includes('session_id')) {\n throw err;\n }\n throw new Error('Failed to decode session token');\n }\n}\n","import {\n useContext,\n useState,\n useEffect,\n useCallback,\n useSyncExternalStore,\n} from 'react';\nimport { VoiceAgentContext } from './VoiceAgentContext.js';\nimport type {\n VoiceAgent,\n ConnectionState,\n TranscriptData,\n} from '@voxdiscover/voiceserver';\nimport { useTranscriptPersistence } from './hooks/useTranscriptPersistence.js';\nimport { useConnectionRetry, type RetryState } from './hooks/useConnectionRetry.js';\nimport { extractSessionId } from './utils/token.js';\n\n/**\n * Return type for useVoiceAgent hook.\n * Per user decision: Flat return structure with all properties at top level.\n */\nexport interface UseVoiceAgentReturn {\n /** Raw VoiceAgent instance for advanced use cases */\n agent: VoiceAgent;\n /** Current connection state */\n callState: ConnectionState;\n /** All transcripts accumulated during session */\n transcripts: TranscriptData[];\n /** Current error, if any (auto-cleared on success) */\n error: Error | null;\n /** Derived: true if callState === 'connected' */\n isConnected: boolean;\n /** Derived: true if callState === 'connecting' */\n isConnecting: boolean;\n /** Derived: true if callState === 'reconnecting' */\n isReconnecting: boolean;\n /** Retry state for UI feedback (attempt count, countdown) */\n retryState: RetryState;\n /** Connect to voice session */\n connect: () => Promise<void>;\n /** Disconnect from voice session */\n disconnect: () => Promise<void>;\n /** Mute microphone */\n mute: () => void;\n /** Unmute microphone */\n unmute: () => void;\n /** Manual retry connection (with exponential backoff) */\n retryConnect: () => Promise<void>;\n}\n\n/**\n * Comprehensive hook for VoiceAgent functionality.\n *\n * Per user decision:\n * - Single comprehensive hook returning everything (not multiple focused hooks)\n * - Flat return structure with all properties at top level\n * - Includes raw agent instance for advanced use cases\n * - Unlimited transcript accumulation in memory during session\n * - Auto-clear errors when next action succeeds\n *\n * Per RESEARCH.md patterns:\n * - Uses useSyncExternalStore for connection state (concurrent-safe)\n * - Uses useState for transcripts and errors\n * - Proper cleanup in useEffect to prevent memory leaks\n * - useCallback for wrapped methods\n *\n * @throws {Error} When used outside VoiceAgentProvider\n *\n * @example\n * ```tsx\n * function VoiceChat() {\n * const {\n * connect,\n * disconnect,\n * mute,\n * unmute,\n * callState,\n * transcripts,\n * error,\n * isConnected,\n * } = useVoiceAgent();\n *\n * return (\n * <div>\n * <button onClick={connect} disabled={isConnected}>\n * {isConnected ? 'Connected' : 'Connect'}\n * </button>\n * {error && <div>Error: {error.message}</div>}\n * <div>\n * {transcripts.map((t, i) => (\n * <p key={i}>{t.speaker}: {t.text}</p>\n * ))}\n * </div>\n * </div>\n * );\n * }\n * ```\n */\nexport function useVoiceAgent(): UseVoiceAgentReturn {\n const context = useContext(VoiceAgentContext);\n\n // Per Claude's discretion: throw if used outside provider (fail early)\n if (!context) {\n throw new Error(\n 'useVoiceAgent must be used within VoiceAgentProvider. ' +\n 'Wrap your component tree with <VoiceAgentProvider>.'\n );\n }\n\n const { agent, token } = context;\n if (!agent) {\n throw new Error('VoiceAgent instance not initialized');\n }\n\n // Extract session ID from token for persistence\n const sessionId = extractSessionId(token);\n\n // Per RESEARCH.md Pattern 2 and Pitfall 3:\n // Use useSyncExternalStore for external subscriptions (concurrent-safe in React 18+)\n const callState = useSyncExternalStore(\n useCallback(\n (callback) => {\n const handler = () => callback();\n agent.on('connection:state', handler);\n return () => agent.off('connection:state', handler);\n },\n [agent]\n ),\n () => agent.state\n );\n\n // Per user decision: unlimited transcript accumulation\n const [transcripts, setTranscripts] = useState<TranscriptData[]>([]);\n\n // Per user decision: auto-clear errors on success\n const [error, setError] = useState<Error | null>(null);\n\n // Per plan Task 3: Integrate transcript persistence hook\n useTranscriptPersistence(sessionId, transcripts, setTranscripts);\n\n // Per plan Task 3: Integrate retry logic hook\n const { retryState, retry: retryConnect } = useConnectionRetry(\n async () => agent.connect(),\n 5\n );\n\n // Per RESEARCH.md Pitfall 1: Always return cleanup function to prevent memory leaks\n useEffect(() => {\n // Subscribe to transcript:final events\n const handleTranscript = (data: TranscriptData) => {\n setTranscripts((prev) => [...prev, data]);\n };\n\n // Subscribe to connection:error events\n const handleError = (err: Error) => {\n setError(err);\n };\n\n agent.on('transcript:final', handleTranscript);\n agent.on('connection:error', handleError);\n\n // Cleanup: remove all listeners\n return () => {\n agent.off('transcript:final', handleTranscript);\n agent.off('connection:error', handleError);\n };\n }, [agent]);\n\n // Per user decision: auto-clear errors on success\n useEffect(() => {\n if (callState === 'connected' && error) {\n setError(null);\n }\n }, [callState, error]);\n\n // Wrapped methods with useCallback\n // Per RESEARCH.md Pitfall 5: Use functional updates to avoid stale closures\n const connect = useCallback(async () => {\n try {\n await agent.connect();\n setError(null); // Clear previous errors on success\n } catch (err) {\n setError(err as Error);\n throw err; // Re-throw for caller to handle\n }\n }, [agent]);\n\n const disconnect = useCallback(async () => {\n await agent.disconnect();\n }, [agent]);\n\n const mute = useCallback(() => {\n agent.mute();\n }, [agent]);\n\n const unmute = useCallback(() => {\n agent.unmute();\n }, [agent]);\n\n // Derive computed booleans from callState\n const isConnected = callState === 'connected';\n const isConnecting = callState === 'connecting';\n const isReconnecting = callState === 'reconnecting';\n\n // Per user decision: flat return structure with raw agent\n return {\n agent,\n callState,\n transcripts,\n error,\n isConnected,\n isConnecting,\n isReconnecting,\n retryState,\n connect,\n disconnect,\n mute,\n unmute,\n retryConnect,\n };\n}\n"]}
|