@peers-app/peers-ui 0.19.10 → 0.19.13
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/README.md +7 -2
- package/dist/components/group-switcher.js +3 -4
- package/dist/components/voice-playback.d.ts +15 -0
- package/dist/components/voice-playback.js +86 -0
- package/dist/components/voice-subscribe-events.d.ts +0 -4
- package/dist/index.d.ts +10 -2
- package/dist/index.js +23 -2
- package/dist/operator-console/operator-console-state.d.ts +49 -0
- package/dist/operator-console/operator-console-state.js +97 -0
- package/dist/operator-console/operator-console.d.ts +32 -0
- package/dist/operator-console/operator-console.js +88 -0
- package/dist/root-layout/global-overlays.d.ts +37 -0
- package/dist/root-layout/global-overlays.js +37 -0
- package/dist/root-layout/layout-picker.d.ts +9 -0
- package/dist/root-layout/layout-picker.js +22 -0
- package/dist/root-layout/layouts/full-screen-layout.d.ts +12 -0
- package/dist/root-layout/layouts/full-screen-layout.js +60 -0
- package/dist/root-layout/layouts/tabs-with-console-layout.d.ts +20 -0
- package/dist/root-layout/layouts/tabs-with-console-layout.js +158 -0
- package/dist/root-layout/root-layout-host.d.ts +19 -0
- package/dist/root-layout/root-layout-host.js +88 -0
- package/dist/root-layout/root-layout-registry.d.ts +15 -0
- package/dist/root-layout/root-layout-registry.js +50 -0
- package/dist/root-layout/root-layout-state.d.ts +23 -0
- package/dist/root-layout/root-layout-state.js +54 -0
- package/dist/root-layout/root-layout.d.ts +30 -0
- package/dist/root-layout/root-layout.js +12 -0
- package/dist/screens/network-viewer/connection-troubleshooter.js +7 -11
- package/dist/screens/settings/settings-page.js +2 -1
- package/dist/screens/setup-user.js +2 -7
- package/dist/tabs-layout/tabs-layout.d.ts +48 -4
- package/dist/tabs-layout/tabs-layout.js +27 -53
- package/dist/tabs-layout/tabs-state.d.ts +10 -0
- package/dist/tabs-layout/tabs-state.js +33 -1
- package/package.json +3 -3
- package/src/components/group-switcher.tsx +5 -7
- package/src/components/voice-playback.tsx +97 -0
- package/src/components/voice-subscribe-events.ts +0 -4
- package/src/index.tsx +24 -2
- package/src/operator-console/operator-console-state.ts +119 -0
- package/src/operator-console/operator-console.tsx +233 -0
- package/src/root-layout/global-overlays.ts +50 -0
- package/src/root-layout/layout-picker.tsx +47 -0
- package/src/root-layout/layouts/full-screen-layout.tsx +144 -0
- package/src/root-layout/layouts/tabs-with-console-layout.tsx +260 -0
- package/src/root-layout/root-layout-host.tsx +113 -0
- package/src/root-layout/root-layout-registry.ts +51 -0
- package/src/root-layout/root-layout-state.ts +55 -0
- package/src/root-layout/root-layout.ts +32 -0
- package/src/screens/network-viewer/connection-troubleshooter.tsx +10 -14
- package/src/screens/settings/settings-page.tsx +7 -1
- package/src/screens/setup-user.tsx +2 -9
- package/src/tabs-layout/tabs-layout.tsx +28 -74
- package/src/tabs-layout/tabs-state.ts +31 -0
- package/dist/components/chat-overlay.d.ts +0 -14
- package/dist/components/chat-overlay.js +0 -477
- package/src/components/chat-overlay.tsx +0 -854
|
@@ -1,854 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chat Overlay Component
|
|
3
|
-
*
|
|
4
|
-
* Floating UI element combining voice input with chat functionality.
|
|
5
|
-
* Shows minimized voice button that expands to full chat overlay.
|
|
6
|
-
* Supports both voice and text input in the same thread.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
deviceVar,
|
|
11
|
-
groupUserVar,
|
|
12
|
-
type IMessage,
|
|
13
|
-
Messages,
|
|
14
|
-
newid,
|
|
15
|
-
type PersistentVar,
|
|
16
|
-
rpcServerCalls,
|
|
17
|
-
subscribe,
|
|
18
|
-
userVar,
|
|
19
|
-
} from "@peers-app/peers-sdk";
|
|
20
|
-
import { sortBy } from "lodash";
|
|
21
|
-
import type React from "react";
|
|
22
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
23
|
-
import { me } from "../globals";
|
|
24
|
-
import { MessageCompose } from "./messages/message-compose";
|
|
25
|
-
import { MessageDisplay } from "./messages/message-display";
|
|
26
|
-
import type {
|
|
27
|
-
ChatOpenWithMessagePayload,
|
|
28
|
-
VoiceErrorPayload,
|
|
29
|
-
VoicePlayAudioPayload,
|
|
30
|
-
VoiceSpeakPayload,
|
|
31
|
-
VoiceStatePayload,
|
|
32
|
-
VoiceSubscribeEvent,
|
|
33
|
-
VoiceThreadPayload,
|
|
34
|
-
VoiceTranscriptionPayload,
|
|
35
|
-
VoiceVolumePayload,
|
|
36
|
-
} from "./voice-subscribe-events";
|
|
37
|
-
|
|
38
|
-
type VoiceState = "disabled" | "idle" | "listening" | "recording" | "processing" | "speaking";
|
|
39
|
-
|
|
40
|
-
interface ChatOverlayProps {
|
|
41
|
-
/** Position of the overlay */
|
|
42
|
-
position?: "bottom-right" | "bottom-left";
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Voice settings interface (must match voice-settings.tsx)
|
|
46
|
-
interface VoiceSettingsData {
|
|
47
|
-
enabled: boolean;
|
|
48
|
-
voiceOutputEnabled?: boolean; // TTS responses. Defaults to true when voice input enabled.
|
|
49
|
-
wakeWord: string;
|
|
50
|
-
wakeWordSensitivity: number;
|
|
51
|
-
speechSensitivity: number;
|
|
52
|
-
sttProvider: string;
|
|
53
|
-
ttsProvider: string;
|
|
54
|
-
ttsVoice?: string;
|
|
55
|
-
ttsSpeed: number;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const DEFAULT_VOICE_SETTINGS: VoiceSettingsData = {
|
|
59
|
-
enabled: false,
|
|
60
|
-
voiceOutputEnabled: true,
|
|
61
|
-
wakeWord: "COMPUTER",
|
|
62
|
-
wakeWordSensitivity: 0.5,
|
|
63
|
-
speechSensitivity: 0.3,
|
|
64
|
-
sttProvider: "auto",
|
|
65
|
-
ttsProvider: "browser",
|
|
66
|
-
ttsSpeed: 1.0,
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
// Persistent var for tracking the chat overlay thread (synced across devices)
|
|
70
|
-
let chatOverlayThreadPvar: PersistentVar<string | null> | null = null;
|
|
71
|
-
|
|
72
|
-
function getChatOverlayThreadPvar(): PersistentVar<string | null> {
|
|
73
|
-
if (!chatOverlayThreadPvar) {
|
|
74
|
-
chatOverlayThreadPvar = groupUserVar<string | null>("chatOverlayThreadId", {
|
|
75
|
-
defaultValue: null,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
return chatOverlayThreadPvar;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const voiceHubActive = deviceVar<boolean>("voiceHub:active", { defaultValue: false });
|
|
82
|
-
|
|
83
|
-
// Voice settings pvar (shared with voice-settings.tsx and voice-service.ts)
|
|
84
|
-
const voiceSettingsPvar = userVar<VoiceSettingsData>("voiceSettings", {
|
|
85
|
-
defaultValue: DEFAULT_VOICE_SETTINGS,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// Position pvar for overlay placement
|
|
89
|
-
interface OverlayPosition {
|
|
90
|
-
x: number; // distance from right edge
|
|
91
|
-
y: number; // distance from bottom edge
|
|
92
|
-
}
|
|
93
|
-
const DEFAULT_POSITION: OverlayPosition = { x: 20, y: 20 };
|
|
94
|
-
const overlayPositionPvar = deviceVar<OverlayPosition>("chatOverlayPosition", {
|
|
95
|
-
defaultValue: DEFAULT_POSITION,
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
export const ChatOverlay: React.FC<ChatOverlayProps> = ({
|
|
99
|
-
position: _position = "bottom-right",
|
|
100
|
-
}) => {
|
|
101
|
-
const [voiceState, setVoiceState] = useState<VoiceState>("disabled");
|
|
102
|
-
const [_transcription, setTranscription] = useState<string>("");
|
|
103
|
-
const [volumeLevel, setVolumeLevel] = useState<number>(0);
|
|
104
|
-
const [error, setError] = useState<string | null>(null);
|
|
105
|
-
const [isExpanded, setIsExpanded] = useState(false);
|
|
106
|
-
const [showSettings, setShowSettings] = useState(false);
|
|
107
|
-
const [threadId, setThreadId] = useState<string | null>(null);
|
|
108
|
-
const [messages, setMessages] = useState<IMessage[]>([]);
|
|
109
|
-
const [parentMessage, setParentMessage] = useState<IMessage | null>(null);
|
|
110
|
-
const [voiceSettings, setVoiceSettings] = useState<VoiceSettingsData>(DEFAULT_VOICE_SETTINGS);
|
|
111
|
-
const [overlayPosition, setOverlayPosition] = useState<OverlayPosition>(DEFAULT_POSITION);
|
|
112
|
-
const [isDragging, setIsDragging] = useState(false);
|
|
113
|
-
const [pendingInitialMessage, setPendingInitialMessage] = useState<string | undefined>(undefined);
|
|
114
|
-
|
|
115
|
-
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
116
|
-
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
|
117
|
-
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
|
|
118
|
-
const dragStartRef = useRef<{
|
|
119
|
-
mouseX: number;
|
|
120
|
-
mouseY: number;
|
|
121
|
-
posX: number;
|
|
122
|
-
posY: number;
|
|
123
|
-
} | null>(null);
|
|
124
|
-
|
|
125
|
-
const scrollToBottom = useCallback(() => {
|
|
126
|
-
setTimeout(() => {
|
|
127
|
-
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
128
|
-
}, 100);
|
|
129
|
-
}, []);
|
|
130
|
-
|
|
131
|
-
// Load thread from pvar on mount
|
|
132
|
-
useEffect(() => {
|
|
133
|
-
const pvar = getChatOverlayThreadPvar();
|
|
134
|
-
pvar.loadingPromise.then(() => {
|
|
135
|
-
const savedThreadId = pvar();
|
|
136
|
-
if (savedThreadId) {
|
|
137
|
-
setThreadId(savedThreadId);
|
|
138
|
-
// Also sync to voice service
|
|
139
|
-
rpcServerCalls.voiceSetThreadId?.(savedThreadId).catch(() => {});
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// Subscribe to pvar changes (from other devices)
|
|
144
|
-
const sub = pvar.subscribe((newThreadId) => {
|
|
145
|
-
if (newThreadId !== threadId) {
|
|
146
|
-
setThreadId(newThreadId);
|
|
147
|
-
rpcServerCalls.voiceSetThreadId?.(newThreadId).catch(() => {});
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
return () => sub.dispose();
|
|
152
|
-
}, [threadId]);
|
|
153
|
-
|
|
154
|
-
// Load voice settings from pvar
|
|
155
|
-
useEffect(() => {
|
|
156
|
-
voiceSettingsPvar.loadingPromise.then(() => {
|
|
157
|
-
const saved = voiceSettingsPvar();
|
|
158
|
-
if (saved) {
|
|
159
|
-
setVoiceSettings(saved);
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// Subscribe to settings changes
|
|
164
|
-
const sub = voiceSettingsPvar.subscribe((newSettings) => {
|
|
165
|
-
if (newSettings) {
|
|
166
|
-
setVoiceSettings(newSettings);
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
return () => sub.dispose();
|
|
171
|
-
}, []);
|
|
172
|
-
|
|
173
|
-
// Load overlay position from pvar
|
|
174
|
-
useEffect(() => {
|
|
175
|
-
overlayPositionPvar.loadingPromise.then(() => {
|
|
176
|
-
const saved = overlayPositionPvar();
|
|
177
|
-
if (saved) {
|
|
178
|
-
setOverlayPosition(saved);
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
}, []);
|
|
182
|
-
|
|
183
|
-
// Drag handlers
|
|
184
|
-
const handleDragStart = useCallback(
|
|
185
|
-
(e: React.MouseEvent) => {
|
|
186
|
-
// Only start drag on middle-click or when holding shift
|
|
187
|
-
if (e.button === 1 || e.shiftKey) {
|
|
188
|
-
e.preventDefault();
|
|
189
|
-
setIsDragging(true);
|
|
190
|
-
dragStartRef.current = {
|
|
191
|
-
mouseX: e.clientX,
|
|
192
|
-
mouseY: e.clientY,
|
|
193
|
-
posX: overlayPosition.x,
|
|
194
|
-
posY: overlayPosition.y,
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
},
|
|
198
|
-
[overlayPosition],
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
useEffect(() => {
|
|
202
|
-
if (!isDragging) return;
|
|
203
|
-
|
|
204
|
-
const handleMouseMove = (e: MouseEvent) => {
|
|
205
|
-
if (!dragStartRef.current) return;
|
|
206
|
-
|
|
207
|
-
const deltaX = dragStartRef.current.mouseX - e.clientX;
|
|
208
|
-
const deltaY = dragStartRef.current.mouseY - e.clientY;
|
|
209
|
-
|
|
210
|
-
const newX = Math.max(
|
|
211
|
-
10,
|
|
212
|
-
Math.min(window.innerWidth - 80, dragStartRef.current.posX + deltaX),
|
|
213
|
-
);
|
|
214
|
-
const newY = Math.max(
|
|
215
|
-
10,
|
|
216
|
-
Math.min(window.innerHeight - 80, dragStartRef.current.posY + deltaY),
|
|
217
|
-
);
|
|
218
|
-
|
|
219
|
-
setOverlayPosition({ x: newX, y: newY });
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
const handleMouseUp = () => {
|
|
223
|
-
setIsDragging(false);
|
|
224
|
-
dragStartRef.current = null;
|
|
225
|
-
// Save position
|
|
226
|
-
overlayPositionPvar(overlayPosition);
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
document.addEventListener("mousemove", handleMouseMove);
|
|
230
|
-
document.addEventListener("mouseup", handleMouseUp);
|
|
231
|
-
|
|
232
|
-
return () => {
|
|
233
|
-
document.removeEventListener("mousemove", handleMouseMove);
|
|
234
|
-
document.removeEventListener("mouseup", handleMouseUp);
|
|
235
|
-
};
|
|
236
|
-
}, [isDragging, overlayPosition]);
|
|
237
|
-
|
|
238
|
-
// Scroll to bottom when expanded or messages change
|
|
239
|
-
useEffect(() => {
|
|
240
|
-
if (isExpanded && messagesContainerRef.current) {
|
|
241
|
-
// Use setTimeout to ensure DOM has updated
|
|
242
|
-
setTimeout(() => {
|
|
243
|
-
if (messagesContainerRef.current) {
|
|
244
|
-
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
|
245
|
-
}
|
|
246
|
-
}, 50);
|
|
247
|
-
}
|
|
248
|
-
}, [isExpanded]);
|
|
249
|
-
|
|
250
|
-
// Subscribe to voice events
|
|
251
|
-
useEffect(() => {
|
|
252
|
-
const subscriptions = [
|
|
253
|
-
subscribe(
|
|
254
|
-
"chat:openWithMessage",
|
|
255
|
-
(event: VoiceSubscribeEvent<ChatOpenWithMessagePayload>) => {
|
|
256
|
-
const { message } = event.data || {};
|
|
257
|
-
if (message) {
|
|
258
|
-
const text = typeof message === "string" ? message : message.message;
|
|
259
|
-
setPendingInitialMessage(text);
|
|
260
|
-
}
|
|
261
|
-
setIsExpanded(true);
|
|
262
|
-
setShowSettings(false);
|
|
263
|
-
},
|
|
264
|
-
),
|
|
265
|
-
subscribe("voice:stateChanged", (event: VoiceSubscribeEvent<VoiceStatePayload>) => {
|
|
266
|
-
setVoiceState(event.data.state as VoiceState);
|
|
267
|
-
if (event.data.state === "idle") {
|
|
268
|
-
setTranscription("");
|
|
269
|
-
setError(null);
|
|
270
|
-
}
|
|
271
|
-
if (event.data.state === "recording" && !voiceHubActive()) {
|
|
272
|
-
setIsExpanded(true);
|
|
273
|
-
}
|
|
274
|
-
}),
|
|
275
|
-
subscribe("voice:transcription", (event: VoiceSubscribeEvent<VoiceTranscriptionPayload>) => {
|
|
276
|
-
setTranscription(event.data.text);
|
|
277
|
-
}),
|
|
278
|
-
subscribe("voice:volumeLevel", (event: VoiceSubscribeEvent<VoiceVolumePayload>) => {
|
|
279
|
-
setVolumeLevel(event.data.level);
|
|
280
|
-
}),
|
|
281
|
-
subscribe("voice:error", (event: VoiceSubscribeEvent<VoiceErrorPayload>) => {
|
|
282
|
-
setError(event.data.message);
|
|
283
|
-
}),
|
|
284
|
-
subscribe("voice:threadChanged", (event: VoiceSubscribeEvent<VoiceThreadPayload>) => {
|
|
285
|
-
const newThreadId = event.data.threadId;
|
|
286
|
-
setThreadId(newThreadId);
|
|
287
|
-
// Sync to pvar
|
|
288
|
-
const pvar = getChatOverlayThreadPvar();
|
|
289
|
-
pvar(newThreadId);
|
|
290
|
-
}),
|
|
291
|
-
];
|
|
292
|
-
|
|
293
|
-
// Load initial state
|
|
294
|
-
rpcServerCalls
|
|
295
|
-
.voiceGetState()
|
|
296
|
-
.then(({ state }) => {
|
|
297
|
-
setVoiceState(state);
|
|
298
|
-
})
|
|
299
|
-
.catch(() => {});
|
|
300
|
-
|
|
301
|
-
// Load initial thread from voice service
|
|
302
|
-
rpcServerCalls
|
|
303
|
-
.voiceGetThreadId?.()
|
|
304
|
-
.then((id) => {
|
|
305
|
-
if (id) {
|
|
306
|
-
setThreadId(id);
|
|
307
|
-
const pvar = getChatOverlayThreadPvar();
|
|
308
|
-
pvar(id);
|
|
309
|
-
}
|
|
310
|
-
})
|
|
311
|
-
.catch(() => {});
|
|
312
|
-
|
|
313
|
-
return () => {
|
|
314
|
-
for (const sub of subscriptions) {
|
|
315
|
-
sub.unsubscribe();
|
|
316
|
-
}
|
|
317
|
-
};
|
|
318
|
-
}, []);
|
|
319
|
-
|
|
320
|
-
// Handle browser TTS events
|
|
321
|
-
useEffect(() => {
|
|
322
|
-
const speakHandler = subscribe(
|
|
323
|
-
"voice:speakText",
|
|
324
|
-
(event: VoiceSubscribeEvent<VoiceSpeakPayload>) => {
|
|
325
|
-
const { text, voice, rate } = event.data;
|
|
326
|
-
if ("speechSynthesis" in window) {
|
|
327
|
-
const utterance = new SpeechSynthesisUtterance(text);
|
|
328
|
-
if (voice) {
|
|
329
|
-
const voices = speechSynthesis.getVoices();
|
|
330
|
-
const selectedVoice = voices.find((v) => v.name === voice);
|
|
331
|
-
if (selectedVoice) utterance.voice = selectedVoice;
|
|
332
|
-
}
|
|
333
|
-
if (rate) utterance.rate = rate;
|
|
334
|
-
utterance.onend = () => {
|
|
335
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
336
|
-
};
|
|
337
|
-
utterance.onerror = () => {
|
|
338
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
339
|
-
};
|
|
340
|
-
speechSynthesis.speak(utterance);
|
|
341
|
-
} else {
|
|
342
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
343
|
-
}
|
|
344
|
-
},
|
|
345
|
-
);
|
|
346
|
-
|
|
347
|
-
const stopHandler = subscribe("voice:stopSpeaking", () => {
|
|
348
|
-
if ("speechSynthesis" in window) {
|
|
349
|
-
speechSynthesis.cancel();
|
|
350
|
-
}
|
|
351
|
-
if (currentAudioRef.current) {
|
|
352
|
-
currentAudioRef.current.pause();
|
|
353
|
-
currentAudioRef.current = null;
|
|
354
|
-
}
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
const playHandler = subscribe(
|
|
358
|
-
"voice:playAudio",
|
|
359
|
-
async (event: VoiceSubscribeEvent<VoicePlayAudioPayload>) => {
|
|
360
|
-
const { audioBase64, mimeType } = event.data;
|
|
361
|
-
try {
|
|
362
|
-
const audioData = Uint8Array.from(atob(audioBase64), (c) => c.charCodeAt(0));
|
|
363
|
-
const blob = new Blob([audioData], { type: mimeType });
|
|
364
|
-
const url = URL.createObjectURL(blob);
|
|
365
|
-
const audio = new Audio(url);
|
|
366
|
-
currentAudioRef.current = audio;
|
|
367
|
-
audio.onended = () => {
|
|
368
|
-
URL.revokeObjectURL(url);
|
|
369
|
-
currentAudioRef.current = null;
|
|
370
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
371
|
-
};
|
|
372
|
-
await audio.play();
|
|
373
|
-
} catch (e) {
|
|
374
|
-
console.error("Failed to play audio:", e);
|
|
375
|
-
currentAudioRef.current = null;
|
|
376
|
-
rpcServerCalls.voiceNotifyPlaybackComplete?.().catch(() => {});
|
|
377
|
-
}
|
|
378
|
-
},
|
|
379
|
-
);
|
|
380
|
-
|
|
381
|
-
return () => {
|
|
382
|
-
speakHandler.unsubscribe();
|
|
383
|
-
stopHandler.unsubscribe();
|
|
384
|
-
playHandler.unsubscribe();
|
|
385
|
-
};
|
|
386
|
-
}, []);
|
|
387
|
-
|
|
388
|
-
// Load messages when threadId changes
|
|
389
|
-
useEffect(() => {
|
|
390
|
-
if (!threadId) {
|
|
391
|
-
setMessages([]);
|
|
392
|
-
setParentMessage(null);
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const currentThreadId = threadId;
|
|
397
|
-
|
|
398
|
-
async function loadMessages() {
|
|
399
|
-
const parent = await Messages().get(currentThreadId);
|
|
400
|
-
setParentMessage(parent || null);
|
|
401
|
-
|
|
402
|
-
const msgs = await Messages().list(
|
|
403
|
-
{ messageParentId: currentThreadId },
|
|
404
|
-
{ sortBy: ["createdAt", "messageId"] },
|
|
405
|
-
);
|
|
406
|
-
setMessages(msgs);
|
|
407
|
-
scrollToBottom();
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
void loadMessages();
|
|
411
|
-
|
|
412
|
-
// Subscribe to message changes
|
|
413
|
-
const sub = Messages().dataChanged.subscribe((evt) => {
|
|
414
|
-
if (
|
|
415
|
-
evt.dataObject.messageParentId === currentThreadId ||
|
|
416
|
-
evt.dataObject.messageId === currentThreadId
|
|
417
|
-
) {
|
|
418
|
-
void loadMessages();
|
|
419
|
-
}
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
return () => {
|
|
423
|
-
sub.unsubscribe();
|
|
424
|
-
};
|
|
425
|
-
}, [threadId, scrollToBottom]);
|
|
426
|
-
|
|
427
|
-
const updateVoiceSetting = useCallback(
|
|
428
|
-
<K extends keyof VoiceSettingsData>(key: K, value: VoiceSettingsData[K]) => {
|
|
429
|
-
const newSettings = { ...voiceSettings, [key]: value };
|
|
430
|
-
setVoiceSettings(newSettings);
|
|
431
|
-
voiceSettingsPvar(newSettings);
|
|
432
|
-
},
|
|
433
|
-
[voiceSettings],
|
|
434
|
-
);
|
|
435
|
-
|
|
436
|
-
const handleVoiceClick = useCallback(async () => {
|
|
437
|
-
try {
|
|
438
|
-
if (voiceState === "disabled") {
|
|
439
|
-
window.location.hash = "#settings?tab=voice";
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
if (voiceState === "recording") {
|
|
444
|
-
await rpcServerCalls.voiceStopRecording();
|
|
445
|
-
} else if (voiceState === "speaking") {
|
|
446
|
-
await rpcServerCalls.voiceStopPlayback();
|
|
447
|
-
} else if (voiceState === "idle" || voiceState === "listening") {
|
|
448
|
-
await rpcServerCalls.voiceStartRecording();
|
|
449
|
-
}
|
|
450
|
-
} catch (e) {
|
|
451
|
-
console.error("Voice action failed:", e);
|
|
452
|
-
}
|
|
453
|
-
}, [voiceState]);
|
|
454
|
-
|
|
455
|
-
const startNewThread = useCallback(() => {
|
|
456
|
-
setThreadId(null);
|
|
457
|
-
setMessages([]);
|
|
458
|
-
setParentMessage(null);
|
|
459
|
-
// Clear in voice service and pvar
|
|
460
|
-
rpcServerCalls.voiceSetThreadId?.(null).catch(() => {});
|
|
461
|
-
getChatOverlayThreadPvar()(null);
|
|
462
|
-
}, []);
|
|
463
|
-
|
|
464
|
-
const handleMessageSubmit = useCallback(
|
|
465
|
-
async (message: IMessage) => {
|
|
466
|
-
// Notify voice service of text activity (resets inactivity timer)
|
|
467
|
-
rpcServerCalls.voiceNotifyTextActivity?.().catch(() => {});
|
|
468
|
-
|
|
469
|
-
// Clear any pending initial message that was pre-filled
|
|
470
|
-
setPendingInitialMessage(undefined);
|
|
471
|
-
|
|
472
|
-
// If no thread yet, this message becomes the thread root
|
|
473
|
-
if (!threadId) {
|
|
474
|
-
message.messageParentId = undefined;
|
|
475
|
-
await Messages().save(message);
|
|
476
|
-
setThreadId(message.messageId);
|
|
477
|
-
// Sync to voice service and pvar
|
|
478
|
-
rpcServerCalls.voiceSetThreadId?.(message.messageId).catch(() => {});
|
|
479
|
-
getChatOverlayThreadPvar()(message.messageId);
|
|
480
|
-
} else {
|
|
481
|
-
message.messageParentId = threadId;
|
|
482
|
-
await Messages().save(message);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Add to local state immediately for responsiveness
|
|
486
|
-
setMessages((prev) => sortBy([...prev, message], "createdAt"));
|
|
487
|
-
scrollToBottom();
|
|
488
|
-
},
|
|
489
|
-
[threadId, scrollToBottom],
|
|
490
|
-
);
|
|
491
|
-
|
|
492
|
-
const getVoiceIcon = (): string => {
|
|
493
|
-
switch (voiceState) {
|
|
494
|
-
case "disabled":
|
|
495
|
-
return "bi-mic-mute";
|
|
496
|
-
case "idle":
|
|
497
|
-
return "bi-mic";
|
|
498
|
-
case "listening":
|
|
499
|
-
return "bi-ear";
|
|
500
|
-
case "recording":
|
|
501
|
-
return "bi-mic-fill";
|
|
502
|
-
case "processing":
|
|
503
|
-
return "bi-hourglass-split";
|
|
504
|
-
case "speaking":
|
|
505
|
-
return "bi-volume-up-fill";
|
|
506
|
-
default:
|
|
507
|
-
return "bi-mic";
|
|
508
|
-
}
|
|
509
|
-
};
|
|
510
|
-
|
|
511
|
-
const getStateText = (): string => {
|
|
512
|
-
switch (voiceState) {
|
|
513
|
-
case "disabled":
|
|
514
|
-
return "Voice disabled";
|
|
515
|
-
case "idle":
|
|
516
|
-
return "Say wake word or click";
|
|
517
|
-
case "listening":
|
|
518
|
-
return "Listening...";
|
|
519
|
-
case "recording":
|
|
520
|
-
return "Recording...";
|
|
521
|
-
case "processing":
|
|
522
|
-
return "Processing...";
|
|
523
|
-
case "speaking":
|
|
524
|
-
return "Speaking... (click to stop)";
|
|
525
|
-
default:
|
|
526
|
-
return "";
|
|
527
|
-
}
|
|
528
|
-
};
|
|
529
|
-
|
|
530
|
-
const getButtonClass = (): string => {
|
|
531
|
-
const base = "btn rounded-circle shadow";
|
|
532
|
-
switch (voiceState) {
|
|
533
|
-
case "disabled":
|
|
534
|
-
return `${base} btn-secondary`;
|
|
535
|
-
case "idle":
|
|
536
|
-
return `${base} btn-outline-primary`;
|
|
537
|
-
case "listening":
|
|
538
|
-
return `${base} btn-info`;
|
|
539
|
-
case "recording":
|
|
540
|
-
return `${base} btn-danger`;
|
|
541
|
-
case "processing":
|
|
542
|
-
return `${base} btn-warning`;
|
|
543
|
-
case "speaking":
|
|
544
|
-
return `${base} btn-success`;
|
|
545
|
-
default:
|
|
546
|
-
return `${base} btn-secondary`;
|
|
547
|
-
}
|
|
548
|
-
};
|
|
549
|
-
|
|
550
|
-
const positionStyles: React.CSSProperties = {
|
|
551
|
-
position: "fixed",
|
|
552
|
-
zIndex: 1050,
|
|
553
|
-
bottom: `${overlayPosition.y}px`,
|
|
554
|
-
right: `${overlayPosition.x}px`,
|
|
555
|
-
cursor: isDragging ? "grabbing" : undefined,
|
|
556
|
-
};
|
|
557
|
-
|
|
558
|
-
return (
|
|
559
|
-
<div style={positionStyles}>
|
|
560
|
-
{/* Expanded chat panel */}
|
|
561
|
-
{isExpanded && (
|
|
562
|
-
<div
|
|
563
|
-
className="card shadow mb-2"
|
|
564
|
-
style={{
|
|
565
|
-
width: "400px",
|
|
566
|
-
maxHeight: "70vh",
|
|
567
|
-
backgroundColor: "var(--bs-body-bg)",
|
|
568
|
-
display: "flex",
|
|
569
|
-
flexDirection: "column",
|
|
570
|
-
}}
|
|
571
|
-
>
|
|
572
|
-
{/* Header */}
|
|
573
|
-
<div className="card-header py-2 px-3 d-flex justify-content-between align-items-center">
|
|
574
|
-
<div className="d-flex align-items-center gap-2">
|
|
575
|
-
<button
|
|
576
|
-
className="btn btn-sm btn-outline-secondary"
|
|
577
|
-
onClick={startNewThread}
|
|
578
|
-
title="Start new thread"
|
|
579
|
-
>
|
|
580
|
-
<i className="bi bi-plus-lg"></i>
|
|
581
|
-
</button>
|
|
582
|
-
<button
|
|
583
|
-
className={`btn btn-sm ${showSettings ? "btn-secondary" : "btn-outline-secondary"}`}
|
|
584
|
-
onClick={() => setShowSettings(!showSettings)}
|
|
585
|
-
title="Voice settings"
|
|
586
|
-
>
|
|
587
|
-
<i className="bi bi-gear"></i>
|
|
588
|
-
</button>
|
|
589
|
-
<small className="text-muted">
|
|
590
|
-
{showSettings ? "Settings" : threadId ? "Thread" : "New conversation"}
|
|
591
|
-
</small>
|
|
592
|
-
</div>
|
|
593
|
-
<button className="btn btn-sm btn-link p-0" onClick={() => setIsExpanded(false)}>
|
|
594
|
-
<i className="bi bi-chevron-down"></i>
|
|
595
|
-
</button>
|
|
596
|
-
</div>
|
|
597
|
-
|
|
598
|
-
{/* Settings panel */}
|
|
599
|
-
{showSettings && (
|
|
600
|
-
<div className="card-body p-3" style={{ maxHeight: "50vh", overflowY: "auto" }}>
|
|
601
|
-
{/* Voice Input Enabled Toggle */}
|
|
602
|
-
<div className="d-flex align-items-center justify-content-between mb-2">
|
|
603
|
-
<label className="form-label mb-0">Voice Input</label>
|
|
604
|
-
<div className="form-check form-switch mb-0">
|
|
605
|
-
<input
|
|
606
|
-
className="form-check-input"
|
|
607
|
-
type="checkbox"
|
|
608
|
-
id="voiceEnabledToggle"
|
|
609
|
-
checked={voiceSettings.enabled}
|
|
610
|
-
onChange={(e) => updateVoiceSetting("enabled", e.target.checked)}
|
|
611
|
-
role="switch"
|
|
612
|
-
/>
|
|
613
|
-
<label className="form-check-label" htmlFor="voiceEnabledToggle">
|
|
614
|
-
{voiceSettings.enabled ? "On" : "Off"}
|
|
615
|
-
</label>
|
|
616
|
-
</div>
|
|
617
|
-
</div>
|
|
618
|
-
|
|
619
|
-
{/* Voice Output Enabled Toggle - only when input is enabled */}
|
|
620
|
-
{voiceSettings.enabled && (
|
|
621
|
-
<div className="d-flex align-items-center justify-content-between mb-3 ms-2">
|
|
622
|
-
<label className="form-label mb-0 small text-muted">Voice Output</label>
|
|
623
|
-
<div className="form-check form-switch mb-0">
|
|
624
|
-
<input
|
|
625
|
-
className="form-check-input"
|
|
626
|
-
type="checkbox"
|
|
627
|
-
id="voiceOutputEnabledToggle"
|
|
628
|
-
checked={voiceSettings.voiceOutputEnabled !== false}
|
|
629
|
-
onChange={(e) => updateVoiceSetting("voiceOutputEnabled", e.target.checked)}
|
|
630
|
-
role="switch"
|
|
631
|
-
/>
|
|
632
|
-
<label className="form-check-label" htmlFor="voiceOutputEnabledToggle">
|
|
633
|
-
{voiceSettings.voiceOutputEnabled !== false ? "On" : "Off"}
|
|
634
|
-
</label>
|
|
635
|
-
</div>
|
|
636
|
-
</div>
|
|
637
|
-
)}
|
|
638
|
-
|
|
639
|
-
{/* Wake Word Sensitivity */}
|
|
640
|
-
<div className="mb-3">
|
|
641
|
-
<label className="form-label small mb-1">
|
|
642
|
-
Wake Word Sensitivity: {voiceSettings.wakeWordSensitivity.toFixed(2)}
|
|
643
|
-
</label>
|
|
644
|
-
<input
|
|
645
|
-
type="range"
|
|
646
|
-
className="form-range"
|
|
647
|
-
min="0"
|
|
648
|
-
max="1"
|
|
649
|
-
step="0.05"
|
|
650
|
-
value={voiceSettings.wakeWordSensitivity}
|
|
651
|
-
onChange={(e) =>
|
|
652
|
-
updateVoiceSetting("wakeWordSensitivity", parseFloat(e.target.value))
|
|
653
|
-
}
|
|
654
|
-
/>
|
|
655
|
-
<div className="d-flex justify-content-between">
|
|
656
|
-
<small className="text-muted" style={{ fontSize: "0.7rem" }}>
|
|
657
|
-
Less sensitive
|
|
658
|
-
</small>
|
|
659
|
-
<small className="text-muted" style={{ fontSize: "0.7rem" }}>
|
|
660
|
-
More sensitive
|
|
661
|
-
</small>
|
|
662
|
-
</div>
|
|
663
|
-
</div>
|
|
664
|
-
|
|
665
|
-
{/* Speech Detection Sensitivity */}
|
|
666
|
-
<div className="mb-2">
|
|
667
|
-
<label className="form-label small mb-1">
|
|
668
|
-
Speech Detection: {voiceSettings.speechSensitivity.toFixed(2)}
|
|
669
|
-
</label>
|
|
670
|
-
<input
|
|
671
|
-
type="range"
|
|
672
|
-
className="form-range"
|
|
673
|
-
min="0"
|
|
674
|
-
max="1"
|
|
675
|
-
step="0.05"
|
|
676
|
-
value={voiceSettings.speechSensitivity}
|
|
677
|
-
onChange={(e) =>
|
|
678
|
-
updateVoiceSetting("speechSensitivity", parseFloat(e.target.value))
|
|
679
|
-
}
|
|
680
|
-
/>
|
|
681
|
-
<div className="d-flex justify-content-between">
|
|
682
|
-
<small className="text-muted" style={{ fontSize: "0.7rem" }}>
|
|
683
|
-
Quiet (louder speech)
|
|
684
|
-
</small>
|
|
685
|
-
<small className="text-muted" style={{ fontSize: "0.7rem" }}>
|
|
686
|
-
Noisy (softer speech)
|
|
687
|
-
</small>
|
|
688
|
-
</div>
|
|
689
|
-
</div>
|
|
690
|
-
|
|
691
|
-
<div className="text-center mt-3">
|
|
692
|
-
<a href="#settings?tab=voice" className="small text-muted">
|
|
693
|
-
More voice settings <i className="bi bi-arrow-right"></i>
|
|
694
|
-
</a>
|
|
695
|
-
</div>
|
|
696
|
-
</div>
|
|
697
|
-
)}
|
|
698
|
-
|
|
699
|
-
{/* Messages area */}
|
|
700
|
-
{!showSettings && (
|
|
701
|
-
<div
|
|
702
|
-
ref={messagesContainerRef}
|
|
703
|
-
className="card-body p-2"
|
|
704
|
-
style={{
|
|
705
|
-
flex: 1,
|
|
706
|
-
overflowY: "auto",
|
|
707
|
-
maxHeight: "40vh",
|
|
708
|
-
minHeight: "100px",
|
|
709
|
-
}}
|
|
710
|
-
>
|
|
711
|
-
{/* Parent message if exists */}
|
|
712
|
-
{parentMessage && (
|
|
713
|
-
<>
|
|
714
|
-
<MessageDisplay message={parentMessage} isThreadParent />
|
|
715
|
-
<hr className="my-2" />
|
|
716
|
-
</>
|
|
717
|
-
)}
|
|
718
|
-
|
|
719
|
-
{/* Thread messages */}
|
|
720
|
-
{messages.map((message, index) => (
|
|
721
|
-
<MessageDisplay
|
|
722
|
-
key={message.messageId}
|
|
723
|
-
message={message}
|
|
724
|
-
messageAbove={messages[index - 1]}
|
|
725
|
-
/>
|
|
726
|
-
))}
|
|
727
|
-
|
|
728
|
-
{/* Error display */}
|
|
729
|
-
{error && (
|
|
730
|
-
<div className="alert alert-danger py-1 px-2 small">
|
|
731
|
-
<i className="bi bi-exclamation-triangle me-1"></i>
|
|
732
|
-
{error}
|
|
733
|
-
</div>
|
|
734
|
-
)}
|
|
735
|
-
|
|
736
|
-
<div ref={messagesEndRef} />
|
|
737
|
-
</div>
|
|
738
|
-
)}
|
|
739
|
-
|
|
740
|
-
{/* Voice status bar - only show when not in settings */}
|
|
741
|
-
{!showSettings && voiceState !== "disabled" && voiceState !== "idle" && (
|
|
742
|
-
<div className="px-3 py-1 border-top">
|
|
743
|
-
<div className="d-flex align-items-center gap-2">
|
|
744
|
-
<small className="text-muted">{getStateText()}</small>
|
|
745
|
-
{voiceState === "recording" && (
|
|
746
|
-
<div className="progress flex-grow-1" style={{ height: "4px" }}>
|
|
747
|
-
<div
|
|
748
|
-
className="progress-bar bg-danger"
|
|
749
|
-
style={{ width: `${Math.min(100, volumeLevel * 500)}%` }}
|
|
750
|
-
/>
|
|
751
|
-
</div>
|
|
752
|
-
)}
|
|
753
|
-
</div>
|
|
754
|
-
</div>
|
|
755
|
-
)}
|
|
756
|
-
|
|
757
|
-
{/* Compose area - only show when not in settings */}
|
|
758
|
-
{!showSettings && (
|
|
759
|
-
<div className="card-footer p-2">
|
|
760
|
-
<MessageCompose
|
|
761
|
-
channelId={me?.userId || "default"}
|
|
762
|
-
threadId={threadId || newid()}
|
|
763
|
-
onMessageSubmit={handleMessageSubmit}
|
|
764
|
-
initialMessage={pendingInitialMessage}
|
|
765
|
-
key={
|
|
766
|
-
pendingInitialMessage ? `pre-${pendingInitialMessage.slice(0, 20)}` : "default"
|
|
767
|
-
}
|
|
768
|
-
/>
|
|
769
|
-
</div>
|
|
770
|
-
)}
|
|
771
|
-
</div>
|
|
772
|
-
)}
|
|
773
|
-
|
|
774
|
-
{/* Floating button row */}
|
|
775
|
-
<div
|
|
776
|
-
className="d-flex gap-2 justify-content-end"
|
|
777
|
-
onMouseDown={handleDragStart}
|
|
778
|
-
style={{ userSelect: isDragging ? "none" : undefined }}
|
|
779
|
-
>
|
|
780
|
-
{/* Voice button - only show when voice is enabled and chat is expanded */}
|
|
781
|
-
{voiceSettings.enabled && isExpanded && (
|
|
782
|
-
<button
|
|
783
|
-
className={getButtonClass()}
|
|
784
|
-
style={{
|
|
785
|
-
width: "56px",
|
|
786
|
-
height: "56px",
|
|
787
|
-
fontSize: "24px",
|
|
788
|
-
transition: "all 0.2s ease",
|
|
789
|
-
position: "relative",
|
|
790
|
-
}}
|
|
791
|
-
onClick={handleVoiceClick}
|
|
792
|
-
title={getStateText()}
|
|
793
|
-
>
|
|
794
|
-
<i className={`bi ${getVoiceIcon()}`}></i>
|
|
795
|
-
|
|
796
|
-
{/* Pulsing animation for listening/recording */}
|
|
797
|
-
{(voiceState === "listening" || voiceState === "recording") && (
|
|
798
|
-
<span
|
|
799
|
-
className="position-absolute"
|
|
800
|
-
style={{
|
|
801
|
-
top: 0,
|
|
802
|
-
left: 0,
|
|
803
|
-
right: 0,
|
|
804
|
-
bottom: 0,
|
|
805
|
-
borderRadius: "50%",
|
|
806
|
-
border: "2px solid currentColor",
|
|
807
|
-
animation: "pulse 1.5s ease-out infinite",
|
|
808
|
-
}}
|
|
809
|
-
/>
|
|
810
|
-
)}
|
|
811
|
-
|
|
812
|
-
{/* Processing spinner */}
|
|
813
|
-
{voiceState === "processing" && (
|
|
814
|
-
<span
|
|
815
|
-
className="spinner-border spinner-border-sm position-absolute"
|
|
816
|
-
style={{ top: "4px", right: "4px" }}
|
|
817
|
-
/>
|
|
818
|
-
)}
|
|
819
|
-
</button>
|
|
820
|
-
)}
|
|
821
|
-
|
|
822
|
-
{/* Chat expand/collapse button - always visible, stays on right */}
|
|
823
|
-
<button
|
|
824
|
-
className={`btn rounded-circle shadow ${isExpanded ? "btn-primary" : "btn-light"}`}
|
|
825
|
-
style={{
|
|
826
|
-
width: "56px",
|
|
827
|
-
height: "56px",
|
|
828
|
-
fontSize: "20px",
|
|
829
|
-
}}
|
|
830
|
-
onClick={() => setIsExpanded(!isExpanded)}
|
|
831
|
-
title={isExpanded ? "Close chat (Shift+drag to move)" : "Open chat (Shift+drag to move)"}
|
|
832
|
-
>
|
|
833
|
-
<i className={`bi ${isExpanded ? "bi-chat-dots-fill" : "bi-chat-dots"}`}></i>
|
|
834
|
-
</button>
|
|
835
|
-
</div>
|
|
836
|
-
|
|
837
|
-
{/* CSS for pulse animation */}
|
|
838
|
-
<style>{`
|
|
839
|
-
@keyframes pulse {
|
|
840
|
-
0% {
|
|
841
|
-
transform: scale(1);
|
|
842
|
-
opacity: 1;
|
|
843
|
-
}
|
|
844
|
-
100% {
|
|
845
|
-
transform: scale(1.5);
|
|
846
|
-
opacity: 0;
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
`}</style>
|
|
850
|
-
</div>
|
|
851
|
-
);
|
|
852
|
-
};
|
|
853
|
-
|
|
854
|
-
export default ChatOverlay;
|