bloby-bot 0.18.15 → 0.19.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/package.json
CHANGED
|
@@ -258,56 +258,74 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
|
|
|
258
258
|
requestAnimationFrame(() => textareaRef.current?.focus());
|
|
259
259
|
};
|
|
260
260
|
|
|
261
|
+
// ── Device detection ──
|
|
262
|
+
const isMobile = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
263
|
+
|
|
264
|
+
// ── Start recording helper (shared by desktop click & mobile hold) ──
|
|
265
|
+
const beginRecording = useCallback(async () => {
|
|
266
|
+
if (!voiceEnabled) return;
|
|
267
|
+
try {
|
|
268
|
+
if (whisperEnabled) {
|
|
269
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
270
|
+
if (!isMobile && !pointerIsDown.current) {
|
|
271
|
+
// Desktop: pointer already released is fine (click), keep going
|
|
272
|
+
} else if (isMobile && !pointerIsDown.current) {
|
|
273
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
streamRef.current = stream;
|
|
277
|
+
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
|
278
|
+
const recorder = new MediaRecorder(stream, { mimeType });
|
|
279
|
+
audioChunksRef.current = [];
|
|
280
|
+
recorder.ondataavailable = (ev) => {
|
|
281
|
+
if (ev.data.size > 0) audioChunksRef.current.push(ev.data);
|
|
282
|
+
};
|
|
283
|
+
mediaRecorderRef.current = recorder;
|
|
284
|
+
recorder.start();
|
|
285
|
+
} else {
|
|
286
|
+
startSpeech(!isMobile); // desktop: continuous=true, mobile: continuous=false
|
|
287
|
+
if (isMobile && !pointerIsDown.current) {
|
|
288
|
+
abortSpeech();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
isHolding.current = true;
|
|
294
|
+
setIsRecording(true);
|
|
295
|
+
setRecordingTime(0);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.error('[InputBar] recording setup failed:', err);
|
|
298
|
+
}
|
|
299
|
+
}, [voiceEnabled, whisperEnabled, startSpeech, abortSpeech]);
|
|
300
|
+
|
|
261
301
|
// ── Mic pointer handlers ──
|
|
262
302
|
const handleMicDown = useCallback((e: RPointerEvent) => {
|
|
263
303
|
e.preventDefault();
|
|
304
|
+
|
|
305
|
+
if (!isMobile) {
|
|
306
|
+
// Desktop: click-to-toggle
|
|
307
|
+
if (isRecording) {
|
|
308
|
+
stopRecording(false);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
pointerIsDown.current = true;
|
|
312
|
+
beginRecording();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Mobile: press-and-hold
|
|
264
317
|
pointerIsDown.current = true;
|
|
265
318
|
startXRef.current = e.clientX;
|
|
266
319
|
dragRef.current = 0;
|
|
267
320
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
268
321
|
|
|
269
|
-
holdTimerRef.current = setTimeout(
|
|
270
|
-
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
try {
|
|
274
|
-
if (whisperEnabled) {
|
|
275
|
-
// Whisper path: need getUserMedia + MediaRecorder for audio capture
|
|
276
|
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
277
|
-
// If user released while permission dialog was showing, clean up and bail
|
|
278
|
-
if (!pointerIsDown.current) {
|
|
279
|
-
stream.getTracks().forEach((t) => t.stop());
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
streamRef.current = stream;
|
|
283
|
-
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
|
284
|
-
const recorder = new MediaRecorder(stream, { mimeType });
|
|
285
|
-
audioChunksRef.current = [];
|
|
286
|
-
recorder.ondataavailable = (ev) => {
|
|
287
|
-
if (ev.data.size > 0) audioChunksRef.current.push(ev.data);
|
|
288
|
-
};
|
|
289
|
-
mediaRecorderRef.current = recorder;
|
|
290
|
-
recorder.start();
|
|
291
|
-
} else {
|
|
292
|
-
// Web Speech path: only SpeechRecognition, no getUserMedia (avoids mic conflict on mobile)
|
|
293
|
-
startSpeech();
|
|
294
|
-
if (!pointerIsDown.current) {
|
|
295
|
-
abortSpeech();
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
isHolding.current = true;
|
|
301
|
-
setIsRecording(true);
|
|
302
|
-
setRecordingTime(0);
|
|
303
|
-
} catch (err) {
|
|
304
|
-
console.error('[InputBar] recording setup failed:', err);
|
|
305
|
-
}
|
|
322
|
+
holdTimerRef.current = setTimeout(() => {
|
|
323
|
+
beginRecording();
|
|
306
324
|
}, 200);
|
|
307
|
-
}, [
|
|
325
|
+
}, [isMobile, isRecording, voiceEnabled, beginRecording, stopRecording]);
|
|
308
326
|
|
|
309
327
|
const handleMicMove = useCallback((e: RPointerEvent) => {
|
|
310
|
-
if (!isHolding.current) return;
|
|
328
|
+
if (!isMobile || !isHolding.current) return;
|
|
311
329
|
const dx = Math.min(0, e.clientX - startXRef.current);
|
|
312
330
|
dragRef.current = dx;
|
|
313
331
|
if (micRef.current) micRef.current.style.transform = `translateX(${dx}px)`;
|
|
@@ -319,16 +337,17 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
|
|
|
319
337
|
stopRecording(true);
|
|
320
338
|
}
|
|
321
339
|
}
|
|
322
|
-
}, [stopRecording]);
|
|
340
|
+
}, [isMobile, stopRecording]);
|
|
323
341
|
|
|
324
342
|
const handleMicUp = useCallback(() => {
|
|
325
343
|
pointerIsDown.current = false;
|
|
326
344
|
if (holdTimerRef.current) { clearTimeout(holdTimerRef.current); holdTimerRef.current = null; }
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
345
|
+
// Desktop: don't stop on release (toggle mode)
|
|
346
|
+
if (!isMobile) return;
|
|
347
|
+
// Mobile: stop on release
|
|
348
|
+
if (!isHolding.current) return;
|
|
330
349
|
stopRecording(false);
|
|
331
|
-
}, [stopRecording]);
|
|
350
|
+
}, [isMobile, stopRecording]);
|
|
332
351
|
|
|
333
352
|
const handleMicCancel = useCallback(() => {
|
|
334
353
|
pointerIsDown.current = false;
|
|
@@ -477,44 +496,70 @@ export default function InputBar({ onSend, onStop, streaming, whisperEnabled, on
|
|
|
477
496
|
</span>
|
|
478
497
|
</div>
|
|
479
498
|
|
|
480
|
-
{/* Pill:
|
|
499
|
+
{/* Pill: content depends on desktop vs mobile */}
|
|
481
500
|
<div className="flex-1 flex items-center bg-muted rounded-full h-12 pl-1 pr-0.5">
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
className="
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
501
|
+
{isMobile ? (
|
|
502
|
+
<>
|
|
503
|
+
<div
|
|
504
|
+
ref={trashRef}
|
|
505
|
+
className="flex items-center justify-center h-9 w-9 shrink-0 rounded-full bg-destructive/10 text-destructive"
|
|
506
|
+
>
|
|
507
|
+
<Trash2 className="h-4 w-4" />
|
|
508
|
+
</div>
|
|
509
|
+
<div className="flex-1 flex justify-center min-w-0">
|
|
510
|
+
<motion.span
|
|
511
|
+
className="text-[13px] whitespace-nowrap select-none font-medium"
|
|
512
|
+
style={{
|
|
513
|
+
backgroundImage: 'linear-gradient(90deg, #999 0%, #999 35%, #fff 50%, #999 65%, #999 100%)',
|
|
514
|
+
backgroundSize: '200% 100%',
|
|
515
|
+
WebkitBackgroundClip: 'text',
|
|
516
|
+
WebkitTextFillColor: 'transparent',
|
|
517
|
+
backgroundClip: 'text',
|
|
518
|
+
}}
|
|
519
|
+
animate={{ backgroundPosition: ['200% center', '-200% center'] }}
|
|
520
|
+
transition={{ duration: 2.5, repeat: Infinity, ease: 'linear' }}
|
|
521
|
+
>
|
|
522
|
+
◄◄ slide to cancel
|
|
523
|
+
</motion.span>
|
|
524
|
+
</div>
|
|
525
|
+
{/* Draggable mic (DOM transform for 60fps) */}
|
|
526
|
+
<div
|
|
527
|
+
ref={micRef}
|
|
528
|
+
className="shrink-0 touch-none select-none will-change-transform"
|
|
529
|
+
style={{ transition: 'none' }}
|
|
530
|
+
onPointerDown={handleMicDown}
|
|
531
|
+
onPointerMove={handleMicMove}
|
|
532
|
+
onPointerUp={handleMicUp}
|
|
533
|
+
onPointerCancel={handleMicCancel}
|
|
534
|
+
>
|
|
535
|
+
<div className="flex items-center justify-center h-11 w-11 rounded-full bg-destructive text-destructive-foreground">
|
|
536
|
+
<Mic className="h-5 w-5" />
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
</>
|
|
540
|
+
) : (
|
|
541
|
+
<>
|
|
542
|
+
<button
|
|
543
|
+
onClick={() => stopRecording(true)}
|
|
544
|
+
className="flex items-center justify-center h-9 w-9 shrink-0 rounded-full bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors"
|
|
545
|
+
>
|
|
546
|
+
<Trash2 className="h-4 w-4" />
|
|
547
|
+
</button>
|
|
548
|
+
<div className="flex-1 flex justify-center min-w-0">
|
|
549
|
+
<span className="text-[13px] whitespace-nowrap select-none font-medium text-muted-foreground">
|
|
550
|
+
Click mic to stop
|
|
551
|
+
</span>
|
|
552
|
+
</div>
|
|
553
|
+
<button
|
|
554
|
+
onClick={() => stopRecording(false)}
|
|
555
|
+
className="shrink-0"
|
|
556
|
+
>
|
|
557
|
+
<div className="flex items-center justify-center h-11 w-11 rounded-full bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors">
|
|
558
|
+
<Square className="h-4 w-4" />
|
|
559
|
+
</div>
|
|
560
|
+
</button>
|
|
561
|
+
</>
|
|
562
|
+
)}
|
|
518
563
|
</div>
|
|
519
564
|
</div>
|
|
520
565
|
</motion.div>
|
|
@@ -38,7 +38,7 @@ export function useSpeechRecognition() {
|
|
|
38
38
|
|
|
39
39
|
const isSupported = useMemo(() => isWebSpeechSupported, []);
|
|
40
40
|
|
|
41
|
-
const start = useCallback(() => {
|
|
41
|
+
const start = useCallback((continuous = false) => {
|
|
42
42
|
const Ctor = getSpeechRecognitionCtor();
|
|
43
43
|
if (!Ctor) {
|
|
44
44
|
return;
|
|
@@ -50,8 +50,8 @@ export function useSpeechRecognition() {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
const recognition = new Ctor();
|
|
53
|
-
recognition.continuous =
|
|
54
|
-
recognition.interimResults = true;
|
|
53
|
+
recognition.continuous = continuous; // desktop: true (click-to-toggle), mobile: false (hold-to-talk)
|
|
54
|
+
recognition.interimResults = true; // get progressive updates while speaking
|
|
55
55
|
recognition.lang = navigator.language || 'en-US';
|
|
56
56
|
|
|
57
57
|
transcriptRef.current = '';
|
|
@@ -156,13 +156,13 @@ Notify your human only if importance is 7+ — otherwise log results silently.
|
|
|
156
156
|
|
|
157
157
|
## Self-Update
|
|
158
158
|
|
|
159
|
-
**Check version:** `cat ~/.bloby/VERSION` (current) vs `npm view bloby version` (latest).
|
|
159
|
+
**Check version:** `cat ~/.bloby/VERSION` (current) vs `npm view bloby-bot version` (latest).
|
|
160
160
|
|
|
161
161
|
**To update:** Create the trigger file `touch .update` — the supervisor runs the update after your turn ends. You will
|
|
162
162
|
NOT die. Finish your turn normally.
|
|
163
163
|
|
|
164
164
|
**On PULSE:** Occasionally check for updates (not every pulse — once every few hours). If a new version exists:
|
|
165
|
-
1. Read release notes: `npm view bloby releaseNotes --json`
|
|
165
|
+
1. Read release notes: `npm view bloby-bot releaseNotes --json`
|
|
166
166
|
2. `touch .update`
|
|
167
167
|
3. Save to daily notes: "Updated from vX to vY" + notable changes to talk with your human later "Btw I updated myself this night"
|
|
168
168
|
|