@peers-app/peers-ui 0.21.7 → 0.23.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/dist/components/access-level-field.d.ts +10 -0
- package/dist/components/access-level-field.js +28 -0
- package/dist/components/access-level-field.test.d.ts +1 -0
- package/dist/components/access-level-field.test.js +28 -0
- package/dist/components/message-logs/message-logs.d.ts +2 -1
- package/dist/components/message-logs/message-logs.js +44 -2
- package/dist/components/message-logs/tool-access-approval-card.d.ts +13 -0
- package/dist/components/message-logs/tool-access-approval-card.js +81 -0
- package/dist/components/message-logs/tool-access-approval-card.test.d.ts +1 -0
- package/dist/components/message-logs/tool-access-approval-card.test.js +102 -0
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -2
- package/dist/screens/assistants/assistant-info.d.ts +1 -0
- package/dist/screens/assistants/assistant-info.js +3 -1
- package/dist/screens/assistants/assistant-list.d.ts +1 -0
- package/dist/screens/assistants/assistant-list.js +8 -1
- package/dist/screens/packages/package-versions.js +1 -1
- package/dist/screens/settings/settings-page.js +0 -5
- package/dist/screens/tools/tool-access-ipc.d.ts +5 -0
- package/dist/screens/tools/tool-access-ipc.js +65 -0
- package/dist/screens/tools/tool-access-ipc.test.d.ts +1 -0
- package/dist/screens/tools/tool-access-ipc.test.js +40 -0
- package/dist/screens/tools/tool-info.d.ts +1 -0
- package/dist/screens/tools/tool-info.js +3 -1
- package/package.json +3 -3
- package/src/components/access-level-field.test.tsx +32 -0
- package/src/components/access-level-field.tsx +68 -0
- package/src/components/message-logs/message-logs.tsx +58 -1
- package/src/components/message-logs/tool-access-approval-card.test.tsx +127 -0
- package/src/components/message-logs/tool-access-approval-card.tsx +178 -0
- package/src/index.tsx +0 -2
- package/src/screens/assistants/assistant-info.tsx +4 -0
- package/src/screens/assistants/assistant-list.tsx +29 -2
- package/src/screens/packages/package-versions.tsx +1 -0
- package/src/screens/settings/settings-page.tsx +0 -5
- package/src/screens/tools/tool-access-ipc.test.ts +55 -0
- package/src/screens/tools/tool-access-ipc.ts +88 -0
- package/src/screens/tools/tool-info.tsx +4 -0
- package/tsconfig.json +1 -0
- package/dist/components/voice-indicator.d.ts +0 -15
- package/dist/components/voice-indicator.js +0 -276
- package/dist/components/voice-playback.d.ts +0 -15
- package/dist/components/voice-playback.js +0 -86
- package/dist/components/voice-subscribe-events.d.ts +0 -28
- package/dist/components/voice-subscribe-events.js +0 -2
- package/dist/screens/settings/voice-settings-agent.d.ts +0 -2
- package/dist/screens/settings/voice-settings-agent.js +0 -39
- package/dist/screens/settings/voice-settings-api-keys.d.ts +0 -13
- package/dist/screens/settings/voice-settings-api-keys.js +0 -8
- package/dist/screens/settings/voice-settings-output.d.ts +0 -16
- package/dist/screens/settings/voice-settings-output.js +0 -7
- package/dist/screens/settings/voice-settings-providers.d.ts +0 -9
- package/dist/screens/settings/voice-settings-providers.js +0 -6
- package/dist/screens/settings/voice-settings-types.d.ts +0 -33
- package/dist/screens/settings/voice-settings-types.js +0 -55
- package/dist/screens/settings/voice-settings-wake-word.d.ts +0 -9
- package/dist/screens/settings/voice-settings-wake-word.js +0 -8
- package/dist/screens/settings/voice-settings.d.ts +0 -10
- package/dist/screens/settings/voice-settings.js +0 -173
- package/src/components/voice-indicator.tsx +0 -353
- package/src/components/voice-playback.tsx +0 -97
- package/src/components/voice-subscribe-events.ts +0 -16
- package/src/screens/settings/voice-settings-agent.tsx +0 -73
- package/src/screens/settings/voice-settings-api-keys.tsx +0 -91
- package/src/screens/settings/voice-settings-output.tsx +0 -126
- package/src/screens/settings/voice-settings-providers.tsx +0 -49
- package/src/screens/settings/voice-settings-types.ts +0 -94
- package/src/screens/settings/voice-settings-wake-word.tsx +0 -94
- package/src/screens/settings/voice-settings.tsx +0 -326
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Voice Indicator Component
|
|
3
|
-
*
|
|
4
|
-
* Floating UI element showing voice input state.
|
|
5
|
-
* Supports manual recording trigger via click.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { rpcServerCalls, subscribe } from "@peers-app/peers-sdk";
|
|
9
|
-
import React, { useCallback, useEffect, useState } from "react";
|
|
10
|
-
import type {
|
|
11
|
-
VoiceErrorPayload,
|
|
12
|
-
VoicePlayAudioPayload,
|
|
13
|
-
VoiceSpeakPayload,
|
|
14
|
-
VoiceStatePayload,
|
|
15
|
-
VoiceSubscribeEvent,
|
|
16
|
-
VoiceTranscriptionPayload,
|
|
17
|
-
VoiceVolumePayload,
|
|
18
|
-
} from "./voice-subscribe-events";
|
|
19
|
-
|
|
20
|
-
type VoiceState = "disabled" | "idle" | "listening" | "recording" | "processing" | "speaking";
|
|
21
|
-
|
|
22
|
-
interface VoiceIndicatorProps {
|
|
23
|
-
/** Position of the indicator */
|
|
24
|
-
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
|
25
|
-
/** Show expanded view with transcription */
|
|
26
|
-
showTranscription?: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export const VoiceIndicator: React.FC<VoiceIndicatorProps> = ({
|
|
30
|
-
position = "bottom-right",
|
|
31
|
-
showTranscription = true,
|
|
32
|
-
}) => {
|
|
33
|
-
const [state, setState] = useState<VoiceState>("disabled");
|
|
34
|
-
const [transcription, setTranscription] = useState<string>("");
|
|
35
|
-
const [volumeLevel, setVolumeLevel] = useState<number>(0);
|
|
36
|
-
const [error, setError] = useState<string | null>(null);
|
|
37
|
-
const [isExpanded, setIsExpanded] = useState(false);
|
|
38
|
-
|
|
39
|
-
// Track current audio element for cloud TTS so we can stop it
|
|
40
|
-
const currentAudioRef = React.useRef<HTMLAudioElement | null>(null);
|
|
41
|
-
|
|
42
|
-
// Subscribe to voice events
|
|
43
|
-
useEffect(() => {
|
|
44
|
-
const subscriptions = [
|
|
45
|
-
subscribe("voice:stateChanged", (event: VoiceSubscribeEvent<VoiceStatePayload>) => {
|
|
46
|
-
setState(event.data.state as VoiceState);
|
|
47
|
-
if (event.data.state === "idle") {
|
|
48
|
-
setTranscription("");
|
|
49
|
-
setError(null);
|
|
50
|
-
}
|
|
51
|
-
if (event.data.state === "recording") {
|
|
52
|
-
setIsExpanded(true);
|
|
53
|
-
}
|
|
54
|
-
}),
|
|
55
|
-
subscribe("voice:transcription", (event: VoiceSubscribeEvent<VoiceTranscriptionPayload>) => {
|
|
56
|
-
setTranscription(event.data.text);
|
|
57
|
-
}),
|
|
58
|
-
subscribe("voice:volumeLevel", (event: VoiceSubscribeEvent<VoiceVolumePayload>) => {
|
|
59
|
-
setVolumeLevel(event.data.level);
|
|
60
|
-
}),
|
|
61
|
-
subscribe("voice:error", (event: VoiceSubscribeEvent<VoiceErrorPayload>) => {
|
|
62
|
-
setError(event.data.message);
|
|
63
|
-
}),
|
|
64
|
-
];
|
|
65
|
-
|
|
66
|
-
// Load initial state
|
|
67
|
-
rpcServerCalls
|
|
68
|
-
.voiceGetState()
|
|
69
|
-
.then(({ state }) => {
|
|
70
|
-
setState(state);
|
|
71
|
-
})
|
|
72
|
-
.catch(() => {
|
|
73
|
-
// Voice service may not be initialized yet
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
return () => {
|
|
77
|
-
for (const sub of subscriptions) {
|
|
78
|
-
sub.unsubscribe();
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
}, []);
|
|
82
|
-
|
|
83
|
-
// Handle browser TTS events
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
const speakHandler = subscribe(
|
|
86
|
-
"voice:speakText",
|
|
87
|
-
(event: VoiceSubscribeEvent<VoiceSpeakPayload>) => {
|
|
88
|
-
const { text, voice, rate } = event.data;
|
|
89
|
-
if ("speechSynthesis" in window) {
|
|
90
|
-
const utterance = new SpeechSynthesisUtterance(text);
|
|
91
|
-
if (voice) {
|
|
92
|
-
const voices = speechSynthesis.getVoices();
|
|
93
|
-
const selectedVoice = voices.find((v) => v.name === voice);
|
|
94
|
-
if (selectedVoice) utterance.voice = selectedVoice;
|
|
95
|
-
}
|
|
96
|
-
if (rate) utterance.rate = rate;
|
|
97
|
-
// Notify server when speech ends
|
|
98
|
-
utterance.onend = () => {
|
|
99
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
100
|
-
};
|
|
101
|
-
utterance.onerror = () => {
|
|
102
|
-
// Still notify on error so we don't hang
|
|
103
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
104
|
-
};
|
|
105
|
-
speechSynthesis.speak(utterance);
|
|
106
|
-
} else {
|
|
107
|
-
// No speech synthesis available, notify immediately
|
|
108
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
109
|
-
}
|
|
110
|
-
},
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
const stopHandler = subscribe("voice:stopSpeaking", () => {
|
|
114
|
-
// Stop browser TTS
|
|
115
|
-
if ("speechSynthesis" in window) {
|
|
116
|
-
speechSynthesis.cancel();
|
|
117
|
-
}
|
|
118
|
-
// Stop cloud TTS audio if playing
|
|
119
|
-
if (currentAudioRef.current) {
|
|
120
|
-
currentAudioRef.current.pause();
|
|
121
|
-
currentAudioRef.current = null;
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
const playHandler = subscribe(
|
|
126
|
-
"voice:playAudio",
|
|
127
|
-
async (event: VoiceSubscribeEvent<VoicePlayAudioPayload>) => {
|
|
128
|
-
const { audioBase64, mimeType } = event.data;
|
|
129
|
-
try {
|
|
130
|
-
const audioData = Uint8Array.from(atob(audioBase64), (c) => c.charCodeAt(0));
|
|
131
|
-
const blob = new Blob([audioData], { type: mimeType });
|
|
132
|
-
const url = URL.createObjectURL(blob);
|
|
133
|
-
const audio = new Audio(url);
|
|
134
|
-
|
|
135
|
-
// Track audio element so we can stop it
|
|
136
|
-
currentAudioRef.current = audio;
|
|
137
|
-
|
|
138
|
-
audio.onended = () => {
|
|
139
|
-
URL.revokeObjectURL(url);
|
|
140
|
-
currentAudioRef.current = null;
|
|
141
|
-
// Notify server that audio playback has completed
|
|
142
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
143
|
-
};
|
|
144
|
-
await audio.play();
|
|
145
|
-
} catch (e) {
|
|
146
|
-
console.error("Failed to play audio:", e);
|
|
147
|
-
currentAudioRef.current = null;
|
|
148
|
-
// Still notify on error so we don't hang
|
|
149
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
return () => {
|
|
155
|
-
speakHandler.unsubscribe();
|
|
156
|
-
stopHandler.unsubscribe();
|
|
157
|
-
playHandler.unsubscribe();
|
|
158
|
-
};
|
|
159
|
-
}, []);
|
|
160
|
-
|
|
161
|
-
const handleClick = useCallback(async () => {
|
|
162
|
-
try {
|
|
163
|
-
if (state === "disabled") {
|
|
164
|
-
// Open settings
|
|
165
|
-
window.location.hash = "#/settings/voice";
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (state === "recording") {
|
|
170
|
-
await rpcServerCalls.voiceStopRecording();
|
|
171
|
-
} else if (state === "speaking") {
|
|
172
|
-
await rpcServerCalls.voiceStopPlayback();
|
|
173
|
-
} else if (state === "idle" || state === "listening") {
|
|
174
|
-
await rpcServerCalls.voiceStartRecording();
|
|
175
|
-
}
|
|
176
|
-
} catch (e) {
|
|
177
|
-
console.error("Voice action failed:", e);
|
|
178
|
-
}
|
|
179
|
-
}, [state]);
|
|
180
|
-
|
|
181
|
-
const getIcon = (): string => {
|
|
182
|
-
switch (state) {
|
|
183
|
-
case "disabled":
|
|
184
|
-
return "bi-mic-mute";
|
|
185
|
-
case "idle":
|
|
186
|
-
return "bi-mic";
|
|
187
|
-
case "listening":
|
|
188
|
-
return "bi-ear";
|
|
189
|
-
case "recording":
|
|
190
|
-
return "bi-mic-fill";
|
|
191
|
-
case "processing":
|
|
192
|
-
return "bi-hourglass-split";
|
|
193
|
-
case "speaking":
|
|
194
|
-
return "bi-volume-up-fill";
|
|
195
|
-
default:
|
|
196
|
-
return "bi-mic";
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
const getStateText = (): string => {
|
|
201
|
-
switch (state) {
|
|
202
|
-
case "disabled":
|
|
203
|
-
return "Voice disabled";
|
|
204
|
-
case "idle":
|
|
205
|
-
return 'Say "Porcupine" or click';
|
|
206
|
-
case "listening":
|
|
207
|
-
return "Listening...";
|
|
208
|
-
case "recording":
|
|
209
|
-
return "Recording...";
|
|
210
|
-
case "processing":
|
|
211
|
-
return "Processing...";
|
|
212
|
-
case "speaking":
|
|
213
|
-
return "Speaking... (click to stop)";
|
|
214
|
-
default:
|
|
215
|
-
return "";
|
|
216
|
-
}
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
const getButtonClass = (): string => {
|
|
220
|
-
const base = "btn rounded-circle shadow";
|
|
221
|
-
switch (state) {
|
|
222
|
-
case "disabled":
|
|
223
|
-
return `${base} btn-secondary`;
|
|
224
|
-
case "idle":
|
|
225
|
-
return `${base} btn-outline-primary`;
|
|
226
|
-
case "listening":
|
|
227
|
-
return `${base} btn-info`;
|
|
228
|
-
case "recording":
|
|
229
|
-
return `${base} btn-danger`;
|
|
230
|
-
case "processing":
|
|
231
|
-
return `${base} btn-warning`;
|
|
232
|
-
case "speaking":
|
|
233
|
-
return `${base} btn-success`;
|
|
234
|
-
default:
|
|
235
|
-
return `${base} btn-secondary`;
|
|
236
|
-
}
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
const positionStyles: React.CSSProperties = {
|
|
240
|
-
position: "fixed",
|
|
241
|
-
zIndex: 1050,
|
|
242
|
-
...(position.includes("bottom") ? { bottom: "20px" } : { top: "70px" }),
|
|
243
|
-
...(position.includes("right") ? { right: "20px" } : { left: "20px" }),
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
// Don't render if disabled and not showing
|
|
247
|
-
// Actually, let's always show so users can click to enable
|
|
248
|
-
|
|
249
|
-
return (
|
|
250
|
-
<div style={positionStyles}>
|
|
251
|
-
{/* Expanded panel */}
|
|
252
|
-
{isExpanded && state !== "disabled" && (
|
|
253
|
-
<div
|
|
254
|
-
className="card shadow mb-2"
|
|
255
|
-
style={{
|
|
256
|
-
width: "280px",
|
|
257
|
-
backgroundColor: "var(--bs-body-bg)",
|
|
258
|
-
}}
|
|
259
|
-
>
|
|
260
|
-
<div className="card-body py-2 px-3">
|
|
261
|
-
<div className="d-flex justify-content-between align-items-center mb-2">
|
|
262
|
-
<small className="text-muted">{getStateText()}</small>
|
|
263
|
-
<button className="btn btn-sm btn-link p-0" onClick={() => setIsExpanded(false)}>
|
|
264
|
-
<i className="bi bi-x"></i>
|
|
265
|
-
</button>
|
|
266
|
-
</div>
|
|
267
|
-
|
|
268
|
-
{/* Volume meter */}
|
|
269
|
-
{state === "recording" && (
|
|
270
|
-
<div className="progress mb-2" style={{ height: "4px" }}>
|
|
271
|
-
<div
|
|
272
|
-
className="progress-bar bg-danger"
|
|
273
|
-
style={{ width: `${Math.min(100, volumeLevel * 500)}%` }}
|
|
274
|
-
></div>
|
|
275
|
-
</div>
|
|
276
|
-
)}
|
|
277
|
-
|
|
278
|
-
{/* Transcription */}
|
|
279
|
-
{showTranscription && transcription && (
|
|
280
|
-
<div className="small">
|
|
281
|
-
<i className="bi bi-quote me-1"></i>
|
|
282
|
-
{transcription}
|
|
283
|
-
</div>
|
|
284
|
-
)}
|
|
285
|
-
|
|
286
|
-
{/* Error */}
|
|
287
|
-
{error && (
|
|
288
|
-
<div className="small text-danger">
|
|
289
|
-
<i className="bi bi-exclamation-triangle me-1"></i>
|
|
290
|
-
{error}
|
|
291
|
-
</div>
|
|
292
|
-
)}
|
|
293
|
-
</div>
|
|
294
|
-
</div>
|
|
295
|
-
)}
|
|
296
|
-
|
|
297
|
-
{/* Main button */}
|
|
298
|
-
<button
|
|
299
|
-
className={getButtonClass()}
|
|
300
|
-
style={{
|
|
301
|
-
width: "56px",
|
|
302
|
-
height: "56px",
|
|
303
|
-
fontSize: "24px",
|
|
304
|
-
transition: "all 0.2s ease",
|
|
305
|
-
}}
|
|
306
|
-
onClick={handleClick}
|
|
307
|
-
title={getStateText()}
|
|
308
|
-
>
|
|
309
|
-
<i className={`bi ${getIcon()}`}></i>
|
|
310
|
-
|
|
311
|
-
{/* Pulsing animation for listening/recording */}
|
|
312
|
-
{(state === "listening" || state === "recording") && (
|
|
313
|
-
<span
|
|
314
|
-
className="position-absolute"
|
|
315
|
-
style={{
|
|
316
|
-
top: 0,
|
|
317
|
-
left: 0,
|
|
318
|
-
right: 0,
|
|
319
|
-
bottom: 0,
|
|
320
|
-
borderRadius: "50%",
|
|
321
|
-
border: "2px solid currentColor",
|
|
322
|
-
animation: "pulse 1.5s ease-out infinite",
|
|
323
|
-
}}
|
|
324
|
-
></span>
|
|
325
|
-
)}
|
|
326
|
-
|
|
327
|
-
{/* Processing spinner */}
|
|
328
|
-
{state === "processing" && (
|
|
329
|
-
<span
|
|
330
|
-
className="spinner-border spinner-border-sm position-absolute"
|
|
331
|
-
style={{ top: "4px", right: "4px" }}
|
|
332
|
-
></span>
|
|
333
|
-
)}
|
|
334
|
-
</button>
|
|
335
|
-
|
|
336
|
-
{/* CSS for pulse animation */}
|
|
337
|
-
<style>{`
|
|
338
|
-
@keyframes pulse {
|
|
339
|
-
0% {
|
|
340
|
-
transform: scale(1);
|
|
341
|
-
opacity: 1;
|
|
342
|
-
}
|
|
343
|
-
100% {
|
|
344
|
-
transform: scale(1.5);
|
|
345
|
-
opacity: 0;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
`}</style>
|
|
349
|
-
</div>
|
|
350
|
-
);
|
|
351
|
-
};
|
|
352
|
-
|
|
353
|
-
export default VoiceIndicator;
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Voice Playback Component
|
|
3
|
-
*
|
|
4
|
-
* Headless (renders nothing) component that hosts the browser-side voice TTS
|
|
5
|
-
* playback handlers. The voice server (peers-electron `tts-service`) emits
|
|
6
|
-
* `voice:speakText` / `voice:playAudio` / `voice:stopSpeaking` events and relies
|
|
7
|
-
* on a mounted renderer to actually produce sound and report completion via
|
|
8
|
-
* `voiceNotifyPlaybackComplete`. Mount this once near the app root so voice
|
|
9
|
-
* output works regardless of which (if any) voice UI is visible.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { rpcServerCalls, subscribe } from "@peers-app/peers-sdk";
|
|
13
|
-
import { useEffect, useRef } from "react";
|
|
14
|
-
import type {
|
|
15
|
-
VoicePlayAudioPayload,
|
|
16
|
-
VoiceSpeakPayload,
|
|
17
|
-
VoiceSubscribeEvent,
|
|
18
|
-
} from "./voice-subscribe-events";
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Subscribes to voice TTS events and plays them in the browser. Renders nothing.
|
|
22
|
-
*/
|
|
23
|
-
export const VoicePlayback: React.FC = () => {
|
|
24
|
-
// Track the current cloud-TTS audio element so it can be stopped on demand.
|
|
25
|
-
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
|
|
26
|
-
|
|
27
|
-
useEffect(() => {
|
|
28
|
-
const speakHandler = subscribe(
|
|
29
|
-
"voice:speakText",
|
|
30
|
-
(event: VoiceSubscribeEvent<VoiceSpeakPayload>) => {
|
|
31
|
-
const { text, voice, rate } = event.data;
|
|
32
|
-
if ("speechSynthesis" in window) {
|
|
33
|
-
const utterance = new SpeechSynthesisUtterance(text);
|
|
34
|
-
if (voice) {
|
|
35
|
-
const voices = speechSynthesis.getVoices();
|
|
36
|
-
const selectedVoice = voices.find((v) => v.name === voice);
|
|
37
|
-
if (selectedVoice) utterance.voice = selectedVoice;
|
|
38
|
-
}
|
|
39
|
-
if (rate) utterance.rate = rate;
|
|
40
|
-
utterance.onend = () => {
|
|
41
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
42
|
-
};
|
|
43
|
-
utterance.onerror = () => {
|
|
44
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
45
|
-
};
|
|
46
|
-
speechSynthesis.speak(utterance);
|
|
47
|
-
} else {
|
|
48
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
const stopHandler = subscribe("voice:stopSpeaking", () => {
|
|
54
|
-
if ("speechSynthesis" in window) {
|
|
55
|
-
speechSynthesis.cancel();
|
|
56
|
-
}
|
|
57
|
-
if (currentAudioRef.current) {
|
|
58
|
-
currentAudioRef.current.pause();
|
|
59
|
-
currentAudioRef.current = null;
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const playHandler = subscribe(
|
|
64
|
-
"voice:playAudio",
|
|
65
|
-
async (event: VoiceSubscribeEvent<VoicePlayAudioPayload>) => {
|
|
66
|
-
const { audioBase64, mimeType } = event.data;
|
|
67
|
-
try {
|
|
68
|
-
const audioData = Uint8Array.from(atob(audioBase64), (c) => c.charCodeAt(0));
|
|
69
|
-
const blob = new Blob([audioData], { type: mimeType });
|
|
70
|
-
const url = URL.createObjectURL(blob);
|
|
71
|
-
const audio = new Audio(url);
|
|
72
|
-
currentAudioRef.current = audio;
|
|
73
|
-
audio.onended = () => {
|
|
74
|
-
URL.revokeObjectURL(url);
|
|
75
|
-
currentAudioRef.current = null;
|
|
76
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
77
|
-
};
|
|
78
|
-
await audio.play();
|
|
79
|
-
} catch (e) {
|
|
80
|
-
console.error("Failed to play audio:", e);
|
|
81
|
-
currentAudioRef.current = null;
|
|
82
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
83
|
-
}
|
|
84
|
-
},
|
|
85
|
-
);
|
|
86
|
-
|
|
87
|
-
return () => {
|
|
88
|
-
speakHandler.unsubscribe();
|
|
89
|
-
stopHandler.unsubscribe();
|
|
90
|
-
playHandler.unsubscribe();
|
|
91
|
-
};
|
|
92
|
-
}, []);
|
|
93
|
-
|
|
94
|
-
return null;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
export default VoicePlayback;
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/** Shape passed to `subscribe` handlers for voice / chat IPC events */
|
|
2
|
-
export type VoiceSubscribeEvent<T> = { data: T };
|
|
3
|
-
|
|
4
|
-
export type VoiceStatePayload = { state: string };
|
|
5
|
-
|
|
6
|
-
export type VoiceTranscriptionPayload = { text: string };
|
|
7
|
-
|
|
8
|
-
export type VoiceVolumePayload = { level: number };
|
|
9
|
-
|
|
10
|
-
export type VoiceErrorPayload = { message: string };
|
|
11
|
-
|
|
12
|
-
export type VoiceThreadPayload = { threadId: string };
|
|
13
|
-
|
|
14
|
-
export type VoiceSpeakPayload = { text: string; voice?: string; rate?: number };
|
|
15
|
-
|
|
16
|
-
export type VoicePlayAudioPayload = { audioBase64: string; mimeType: string };
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { Assistants, type IAssistant, userVar } from "@peers-app/peers-sdk";
|
|
2
|
-
import type React from "react";
|
|
3
|
-
import { useCallback, useEffect, useState } from "react";
|
|
4
|
-
|
|
5
|
-
const VOICE_HUB_AGENT_ASSISTANT_ID = "00mh0wlipkdbeaw8vhagent01";
|
|
6
|
-
|
|
7
|
-
// Must match the pvar names in peers-core/src/data/voice-agent-config.ts and
|
|
8
|
-
// peers-electron/src/server/voice/voice-agent-config.ts — pvar instances are
|
|
9
|
-
// singletons keyed by name+scope so all three share the same underlying value.
|
|
10
|
-
const voiceAgentAssistantIdPvar = userVar<string | undefined>("voice:agentAssistantId", {
|
|
11
|
-
defaultValue: VOICE_HUB_AGENT_ASSISTANT_ID,
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
export const VoiceSettingsAgent: React.FC = () => {
|
|
15
|
-
const [assistants, setAssistants] = useState<IAssistant[]>([]);
|
|
16
|
-
const [selectedId, setSelectedId] = useState<string>(VOICE_HUB_AGENT_ASSISTANT_ID);
|
|
17
|
-
const [saving, setSaving] = useState(false);
|
|
18
|
-
const [saved, setSaved] = useState(false);
|
|
19
|
-
|
|
20
|
-
useEffect(() => {
|
|
21
|
-
const load = async () => {
|
|
22
|
-
await voiceAgentAssistantIdPvar.loadingPromise;
|
|
23
|
-
setSelectedId(voiceAgentAssistantIdPvar() ?? VOICE_HUB_AGENT_ASSISTANT_ID);
|
|
24
|
-
|
|
25
|
-
const all = await Assistants().list({});
|
|
26
|
-
setAssistants(all);
|
|
27
|
-
};
|
|
28
|
-
load();
|
|
29
|
-
}, []);
|
|
30
|
-
|
|
31
|
-
const handleSave = useCallback(async () => {
|
|
32
|
-
setSaving(true);
|
|
33
|
-
setSaved(false);
|
|
34
|
-
await voiceAgentAssistantIdPvar.loadingPromise;
|
|
35
|
-
voiceAgentAssistantIdPvar(selectedId || undefined);
|
|
36
|
-
setSaving(false);
|
|
37
|
-
setSaved(true);
|
|
38
|
-
setTimeout(() => setSaved(false), 2000);
|
|
39
|
-
}, [selectedId]);
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<div>
|
|
43
|
-
<h6>Voice Agent</h6>
|
|
44
|
-
<p className="text-muted small mb-2">
|
|
45
|
-
When you give a voice command that requires action (e.g. "add milk to the shopping list"),
|
|
46
|
-
it is dispatched to this assistant as a background task.
|
|
47
|
-
</p>
|
|
48
|
-
<div className="d-flex align-items-center gap-2">
|
|
49
|
-
<select
|
|
50
|
-
className="form-select form-select-sm"
|
|
51
|
-
value={selectedId}
|
|
52
|
-
onChange={(e) => setSelectedId(e.target.value)}
|
|
53
|
-
disabled={saving}
|
|
54
|
-
>
|
|
55
|
-
{assistants.map((a) => (
|
|
56
|
-
<option key={a.assistantId} value={a.assistantId}>
|
|
57
|
-
{a.name}
|
|
58
|
-
{a.assistantId === VOICE_HUB_AGENT_ASSISTANT_ID ? " (default)" : ""}
|
|
59
|
-
</option>
|
|
60
|
-
))}
|
|
61
|
-
</select>
|
|
62
|
-
<button
|
|
63
|
-
className="btn btn-sm btn-primary"
|
|
64
|
-
onClick={handleSave}
|
|
65
|
-
disabled={saving}
|
|
66
|
-
style={{ whiteSpace: "nowrap" }}
|
|
67
|
-
>
|
|
68
|
-
{saving ? "Saving…" : saved ? "Saved!" : "Save"}
|
|
69
|
-
</button>
|
|
70
|
-
</div>
|
|
71
|
-
</div>
|
|
72
|
-
);
|
|
73
|
-
};
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import type React from "react";
|
|
2
|
-
import { Tooltip } from "../../components/tooltip";
|
|
3
|
-
import {
|
|
4
|
-
PICOVOICE_KEY_ERROR_MESSAGES,
|
|
5
|
-
type PicovoiceKeyError,
|
|
6
|
-
type VoiceSettings,
|
|
7
|
-
} from "./voice-settings-types";
|
|
8
|
-
|
|
9
|
-
interface ApiKeysProps {
|
|
10
|
-
settings: VoiceSettings;
|
|
11
|
-
picovoiceKey: string;
|
|
12
|
-
openaiKey: string;
|
|
13
|
-
keyError: PicovoiceKeyError | null;
|
|
14
|
-
onPicovoiceKeyChange: (value: string) => void;
|
|
15
|
-
onOpenaiKeyChange: (value: string) => void;
|
|
16
|
-
onDismissKeyError: () => void;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export const VoiceSettingsApiKeys: React.FC<ApiKeysProps> = ({
|
|
20
|
-
settings,
|
|
21
|
-
picovoiceKey,
|
|
22
|
-
openaiKey,
|
|
23
|
-
keyError,
|
|
24
|
-
onPicovoiceKeyChange,
|
|
25
|
-
onOpenaiKeyChange,
|
|
26
|
-
onDismissKeyError,
|
|
27
|
-
}) => (
|
|
28
|
-
<>
|
|
29
|
-
<div className="mb-4">
|
|
30
|
-
<label className="form-label">
|
|
31
|
-
Picovoice Access Key
|
|
32
|
-
<Tooltip markdownContent="Required for wake word detection. Get a free key at [console.picovoice.ai](https://console.picovoice.ai)" />
|
|
33
|
-
{settings.picovoiceAccessKeySet && <span className="badge bg-success ms-2">Set</span>}
|
|
34
|
-
</label>
|
|
35
|
-
<input
|
|
36
|
-
type="password"
|
|
37
|
-
className="form-control"
|
|
38
|
-
value={picovoiceKey}
|
|
39
|
-
onChange={(e) => onPicovoiceKeyChange(e.target.value)}
|
|
40
|
-
placeholder={
|
|
41
|
-
settings.picovoiceAccessKeySet ? "••••••••••••••••" : "Enter your Picovoice access key"
|
|
42
|
-
}
|
|
43
|
-
/>
|
|
44
|
-
<small className="text-muted">
|
|
45
|
-
<a href="https://console.picovoice.ai" target="_blank" rel="noopener noreferrer">
|
|
46
|
-
Get a free access key
|
|
47
|
-
</a>
|
|
48
|
-
{settings.picovoiceAccessKeySet && " (leave blank to keep existing key)"}
|
|
49
|
-
</small>
|
|
50
|
-
</div>
|
|
51
|
-
|
|
52
|
-
{keyError && (
|
|
53
|
-
<div className="alert alert-warning alert-dismissible mb-4" role="alert">
|
|
54
|
-
<div className="d-flex align-items-start gap-2">
|
|
55
|
-
<i className="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
|
56
|
-
<div>
|
|
57
|
-
<strong>{PICOVOICE_KEY_ERROR_MESSAGES[keyError].heading}</strong>
|
|
58
|
-
<div className="mt-1">{PICOVOICE_KEY_ERROR_MESSAGES[keyError].body}</div>
|
|
59
|
-
<a
|
|
60
|
-
href="https://console.picovoice.ai"
|
|
61
|
-
target="_blank"
|
|
62
|
-
rel="noopener noreferrer"
|
|
63
|
-
className="alert-link mt-1 d-inline-block"
|
|
64
|
-
>
|
|
65
|
-
Open console.picovoice.ai
|
|
66
|
-
</a>
|
|
67
|
-
</div>
|
|
68
|
-
</div>
|
|
69
|
-
<button type="button" className="btn-close" onClick={onDismissKeyError}></button>
|
|
70
|
-
</div>
|
|
71
|
-
)}
|
|
72
|
-
|
|
73
|
-
<div className="mb-3">
|
|
74
|
-
<label className="form-label">
|
|
75
|
-
OpenAI API Key
|
|
76
|
-
<Tooltip markdownContent="Required for cloud STT (Whisper) and cloud TTS. Leave empty to use local/browser alternatives." />
|
|
77
|
-
{settings.openaiApiKeySet && <span className="badge bg-success ms-2">Set</span>}
|
|
78
|
-
</label>
|
|
79
|
-
<input
|
|
80
|
-
type="password"
|
|
81
|
-
className="form-control"
|
|
82
|
-
value={openaiKey}
|
|
83
|
-
onChange={(e) => onOpenaiKeyChange(e.target.value)}
|
|
84
|
-
placeholder={settings.openaiApiKeySet ? "••••••••••••••••" : "sk-..."}
|
|
85
|
-
/>
|
|
86
|
-
{settings.openaiApiKeySet && (
|
|
87
|
-
<small className="text-muted">Leave blank to keep existing key</small>
|
|
88
|
-
)}
|
|
89
|
-
</div>
|
|
90
|
-
</>
|
|
91
|
-
);
|