feat: ai friend companion on desktop
Browse files- desktop/src/components/companion/CompanionControls.tsx +113 -0
- desktop/src/components/companion/CompanionTranscript.tsx +84 -0
- desktop/src/components/companion/CompanionVideoPanel.tsx +94 -0
- desktop/src/components/layout/ContentRouter.tsx +3 -0
- desktop/src/components/layout/Sidebar.tsx +16 -1
- desktop/src/hooks/useCompanionAudio.ts +145 -0
- desktop/src/hooks/useCompanionWebSocket.ts +162 -0
- desktop/src/hooks/useMicrophone.ts +96 -0
- desktop/src/hooks/useWebcam.ts +103 -0
- desktop/src/i18n/locales/en.ts +21 -0
- desktop/src/i18n/locales/zh.ts +21 -0
- desktop/src/pages/Companion.tsx +123 -0
- desktop/src/stores/companionStore.ts +65 -0
- desktop/src/stores/tabStore.ts +5 -4
- desktop/src/types/companion.ts +20 -0
desktop/src/components/companion/CompanionControls.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useTranslation } from '../../i18n'
|
| 2 |
+
import type { CompanionStatus } from '../../types/companion'
|
| 3 |
+
|
| 4 |
+
interface CompanionControlsProps {
|
| 5 |
+
status: CompanionStatus
|
| 6 |
+
micEnabled: boolean
|
| 7 |
+
cameraEnabled: boolean
|
| 8 |
+
generating: boolean
|
| 9 |
+
onToggleMic: () => void
|
| 10 |
+
onToggleCamera: () => void
|
| 11 |
+
onConnect: () => void
|
| 12 |
+
onDisconnect: () => void
|
| 13 |
+
onStop: () => void
|
| 14 |
+
onResumeAudio: () => void
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function CompanionControls({
|
| 18 |
+
status,
|
| 19 |
+
micEnabled,
|
| 20 |
+
cameraEnabled,
|
| 21 |
+
generating,
|
| 22 |
+
onToggleMic,
|
| 23 |
+
onToggleCamera,
|
| 24 |
+
onConnect,
|
| 25 |
+
onDisconnect,
|
| 26 |
+
onStop,
|
| 27 |
+
onResumeAudio,
|
| 28 |
+
}: CompanionControlsProps) {
|
| 29 |
+
const t = useTranslation()
|
| 30 |
+
const isConnected = status === 'connected'
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="companion-controls absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-3 px-6 py-3 rounded-2xl bg-black/50 backdrop-blur-md border border-white/10">
|
| 34 |
+
{/* Mic toggle */}
|
| 35 |
+
<button
|
| 36 |
+
type="button"
|
| 37 |
+
onClick={onToggleMic}
|
| 38 |
+
disabled={!isConnected}
|
| 39 |
+
className={`flex h-10 w-10 items-center justify-center rounded-xl transition-all ${
|
| 40 |
+
micEnabled
|
| 41 |
+
? 'bg-white/10 text-white hover:bg-white/20'
|
| 42 |
+
: 'bg-red-500/20 text-red-400'
|
| 43 |
+
} disabled:opacity-40`}
|
| 44 |
+
title={micEnabled ? t('companion.micOn') : t('companion.micOff')}
|
| 45 |
+
>
|
| 46 |
+
<span className="material-symbols-outlined text-xl">
|
| 47 |
+
{micEnabled ? 'mic' : 'mic_off'}
|
| 48 |
+
</span>
|
| 49 |
+
</button>
|
| 50 |
+
|
| 51 |
+
{/* Camera toggle */}
|
| 52 |
+
<button
|
| 53 |
+
type="button"
|
| 54 |
+
onClick={onToggleCamera}
|
| 55 |
+
disabled={!isConnected}
|
| 56 |
+
className={`flex h-10 w-10 items-center justify-center rounded-xl transition-all ${
|
| 57 |
+
cameraEnabled
|
| 58 |
+
? 'bg-white/10 text-white hover:bg-white/20'
|
| 59 |
+
: 'bg-red-500/20 text-red-400'
|
| 60 |
+
} disabled:opacity-40`}
|
| 61 |
+
title={cameraEnabled ? t('companion.cameraOn') : t('companion.cameraOff')}
|
| 62 |
+
>
|
| 63 |
+
<span className="material-symbols-outlined text-xl">
|
| 64 |
+
{cameraEnabled ? 'videocam' : 'videocam_off'}
|
| 65 |
+
</span>
|
| 66 |
+
</button>
|
| 67 |
+
|
| 68 |
+
<div className="w-px h-8 bg-white/10" />
|
| 69 |
+
|
| 70 |
+
{/* Stop generation */}
|
| 71 |
+
{generating && (
|
| 72 |
+
<button
|
| 73 |
+
type="button"
|
| 74 |
+
onClick={onStop}
|
| 75 |
+
className="flex h-10 w-10 items-center justify-center rounded-xl bg-red-500/30 text-red-400 hover:bg-red-500/50 transition-all"
|
| 76 |
+
title="Stop"
|
| 77 |
+
>
|
| 78 |
+
<span className="material-symbols-outlined text-xl">stop</span>
|
| 79 |
+
</button>
|
| 80 |
+
)}
|
| 81 |
+
|
| 82 |
+
{/* Connect / Disconnect */}
|
| 83 |
+
{isConnected ? (
|
| 84 |
+
<button
|
| 85 |
+
type="button"
|
| 86 |
+
onClick={() => {
|
| 87 |
+
onDisconnect()
|
| 88 |
+
}}
|
| 89 |
+
className="flex h-10 items-center gap-2 rounded-xl bg-red-500/20 px-4 text-red-400 hover:bg-red-500/30 transition-all text-sm font-medium"
|
| 90 |
+
>
|
| 91 |
+
<span className="material-symbols-outlined text-lg">link_off</span>
|
| 92 |
+
{t('companion.disconnect')}
|
| 93 |
+
</button>
|
| 94 |
+
) : (
|
| 95 |
+
<button
|
| 96 |
+
type="button"
|
| 97 |
+
onClick={() => {
|
| 98 |
+
onConnect()
|
| 99 |
+
// Resume audio context on user gesture
|
| 100 |
+
onResumeAudio()
|
| 101 |
+
}}
|
| 102 |
+
disabled={status === 'connecting'}
|
| 103 |
+
className="flex h-10 items-center gap-2 rounded-xl bg-purple-600/30 px-4 text-purple-300 hover:bg-purple-600/50 transition-all text-sm font-medium disabled:opacity-40"
|
| 104 |
+
>
|
| 105 |
+
<span className="material-symbols-outlined text-lg">
|
| 106 |
+
{status === 'connecting' ? 'hourglass_top' : 'link'}
|
| 107 |
+
</span>
|
| 108 |
+
{status === 'connecting' ? t('companion.status.connecting') : t('companion.connect')}
|
| 109 |
+
</button>
|
| 110 |
+
)}
|
| 111 |
+
</div>
|
| 112 |
+
)
|
| 113 |
+
}
|
desktop/src/components/companion/CompanionTranscript.tsx
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useEffect, useState, useCallback } from 'react'
|
| 2 |
+
import { useTranslation } from '../../i18n'
|
| 3 |
+
|
| 4 |
+
interface CompanionTranscriptProps {
|
| 5 |
+
transcript: string
|
| 6 |
+
fullTranscript: string
|
| 7 |
+
onSendText: (text: string) => void
|
| 8 |
+
disabled?: boolean
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function CompanionTranscript({ transcript, fullTranscript, onSendText, disabled }: CompanionTranscriptProps) {
|
| 12 |
+
const scrollRef = useRef<HTMLDivElement>(null)
|
| 13 |
+
const [inputValue, setInputValue] = useState('')
|
| 14 |
+
const t = useTranslation()
|
| 15 |
+
|
| 16 |
+
useEffect(() => {
|
| 17 |
+
if (scrollRef.current) {
|
| 18 |
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
| 19 |
+
}
|
| 20 |
+
}, [transcript, fullTranscript])
|
| 21 |
+
|
| 22 |
+
const handleSend = useCallback(() => {
|
| 23 |
+
const text = inputValue.trim()
|
| 24 |
+
if (!text) return
|
| 25 |
+
onSendText(text)
|
| 26 |
+
setInputValue('')
|
| 27 |
+
}, [inputValue, onSendText])
|
| 28 |
+
|
| 29 |
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
| 30 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 31 |
+
e.preventDefault()
|
| 32 |
+
handleSend()
|
| 33 |
+
}
|
| 34 |
+
}, [handleSend])
|
| 35 |
+
|
| 36 |
+
return (
|
| 37 |
+
<div className="companion-transcript absolute bottom-20 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4">
|
| 38 |
+
{/* Transcript display */}
|
| 39 |
+
<div
|
| 40 |
+
ref={scrollRef}
|
| 41 |
+
className="max-h-[150px] overflow-y-auto rounded-2xl bg-black/40 backdrop-blur-md border border-white/10 p-4 mb-2 scrollbar-thin"
|
| 42 |
+
>
|
| 43 |
+
{fullTranscript && (
|
| 44 |
+
<div className="text-white/40 text-xs mb-2 whitespace-pre-wrap leading-relaxed">
|
| 45 |
+
{fullTranscript}
|
| 46 |
+
</div>
|
| 47 |
+
)}
|
| 48 |
+
{transcript && (
|
| 49 |
+
<div className="text-white/90 text-sm font-medium leading-relaxed">
|
| 50 |
+
{transcript}
|
| 51 |
+
<span className="inline-block w-1.5 h-4 ml-0.5 bg-purple-400 animate-pulse" />
|
| 52 |
+
</div>
|
| 53 |
+
)}
|
| 54 |
+
{!transcript && !fullTranscript && (
|
| 55 |
+
<div className="text-white/30 text-sm text-center py-2">
|
| 56 |
+
{t('companion.transcriptPlaceholder')}
|
| 57 |
+
</div>
|
| 58 |
+
)}
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
{/* Text input row */}
|
| 62 |
+
<div className="flex items-center gap-2 rounded-2xl bg-black/40 backdrop-blur-md border border-white/10 px-4 py-2">
|
| 63 |
+
<input
|
| 64 |
+
type="text"
|
| 65 |
+
value={inputValue}
|
| 66 |
+
onChange={(e) => setInputValue(e.target.value)}
|
| 67 |
+
onKeyDown={handleKeyDown}
|
| 68 |
+
placeholder={t('companion.inputPlaceholder')}
|
| 69 |
+
disabled={disabled}
|
| 70 |
+
className="flex-1 bg-transparent text-sm text-white/90 placeholder-white/30 outline-none border-none disabled:opacity-40"
|
| 71 |
+
/>
|
| 72 |
+
<button
|
| 73 |
+
type="button"
|
| 74 |
+
onClick={handleSend}
|
| 75 |
+
disabled={disabled || !inputValue.trim()}
|
| 76 |
+
className="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-600/40 text-purple-300 hover:bg-purple-600/60 transition-all disabled:opacity-30"
|
| 77 |
+
title={t('companion.send')}
|
| 78 |
+
>
|
| 79 |
+
<span className="material-symbols-outlined text-lg">send</span>
|
| 80 |
+
</button>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
)
|
| 84 |
+
}
|
desktop/src/components/companion/CompanionVideoPanel.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from 'react'
|
| 2 |
+
import { useTranslation } from '../../i18n'
|
| 3 |
+
import type { CompanionStatus } from '../../types/companion'
|
| 4 |
+
|
| 5 |
+
interface CompanionVideoPanelProps {
|
| 6 |
+
webcamStream: MediaStream | null
|
| 7 |
+
speaking: boolean
|
| 8 |
+
generating: boolean
|
| 9 |
+
status: CompanionStatus
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function CompanionVideoPanel({
|
| 13 |
+
webcamStream,
|
| 14 |
+
speaking,
|
| 15 |
+
generating,
|
| 16 |
+
status,
|
| 17 |
+
}: CompanionVideoPanelProps) {
|
| 18 |
+
const videoRef = useRef<HTMLVideoElement>(null)
|
| 19 |
+
const t = useTranslation()
|
| 20 |
+
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
if (videoRef.current && webcamStream) {
|
| 23 |
+
videoRef.current.srcObject = webcamStream
|
| 24 |
+
}
|
| 25 |
+
}, [webcamStream])
|
| 26 |
+
|
| 27 |
+
const statusLabel = generating
|
| 28 |
+
? t('companion.speaking')
|
| 29 |
+
: speaking
|
| 30 |
+
? t('companion.listening')
|
| 31 |
+
: status === 'connected'
|
| 32 |
+
? t('companion.alwaysHere')
|
| 33 |
+
: t('companion.status.disconnected')
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="companion-video-panel absolute inset-0 overflow-hidden">
|
| 37 |
+
{/* Background gradient */}
|
| 38 |
+
<div
|
| 39 |
+
className={`absolute inset-0 transition-all duration-700 ${
|
| 40 |
+
generating
|
| 41 |
+
? 'bg-gradient-to-br from-indigo-950 via-purple-900 to-slate-950'
|
| 42 |
+
: 'bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900'
|
| 43 |
+
}`}
|
| 44 |
+
/>
|
| 45 |
+
|
| 46 |
+
{/* AI Presence indicator */}
|
| 47 |
+
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-4">
|
| 48 |
+
<div
|
| 49 |
+
className={`w-32 h-32 rounded-full transition-all duration-500 flex items-center justify-center ${
|
| 50 |
+
generating
|
| 51 |
+
? 'bg-purple-600/30 shadow-[0_0_60px_rgba(147,51,234,0.4)] animate-pulse'
|
| 52 |
+
: speaking
|
| 53 |
+
? 'bg-blue-500/20 shadow-[0_0_30px_rgba(59,130,246,0.3)]'
|
| 54 |
+
: 'bg-gray-600/10'
|
| 55 |
+
}`}
|
| 56 |
+
>
|
| 57 |
+
<span className="material-symbols-outlined text-5xl text-white/60">
|
| 58 |
+
{generating ? 'record_voice_over' : 'psychology'}
|
| 59 |
+
</span>
|
| 60 |
+
</div>
|
| 61 |
+
<span className="text-white/40 text-sm font-medium">{statusLabel}</span>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
{/* Webcam PiP overlay */}
|
| 65 |
+
{webcamStream && (
|
| 66 |
+
<div className="absolute bottom-6 right-6 w-[180px] h-[240px] rounded-2xl overflow-hidden border-2 border-white/20 shadow-2xl bg-black">
|
| 67 |
+
<video
|
| 68 |
+
ref={videoRef}
|
| 69 |
+
autoPlay
|
| 70 |
+
muted
|
| 71 |
+
playsInline
|
| 72 |
+
className="h-full w-full object-cover scale-x-[-1]"
|
| 73 |
+
/>
|
| 74 |
+
</div>
|
| 75 |
+
)}
|
| 76 |
+
|
| 77 |
+
{/* Connection status badge */}
|
| 78 |
+
<div className="absolute top-4 right-4 flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/40 backdrop-blur-sm">
|
| 79 |
+
<span
|
| 80 |
+
className={`w-2 h-2 rounded-full ${
|
| 81 |
+
status === 'connected'
|
| 82 |
+
? 'bg-green-500'
|
| 83 |
+
: status === 'connecting'
|
| 84 |
+
? 'bg-yellow-500 animate-pulse'
|
| 85 |
+
: status === 'error'
|
| 86 |
+
? 'bg-red-500'
|
| 87 |
+
: 'bg-gray-500'
|
| 88 |
+
}`}
|
| 89 |
+
/>
|
| 90 |
+
<span className="text-white/60 text-xs">{status}</span>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
)
|
| 94 |
+
}
|
desktop/src/components/layout/ContentRouter.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { ActiveSession } from '../../pages/ActiveSession'
|
|
| 5 |
import { ScheduledTasks } from '../../pages/ScheduledTasks'
|
| 6 |
import { Settings } from '../../pages/Settings'
|
| 7 |
import { TerminalSettings } from '../../pages/TerminalSettings'
|
|
|
|
| 8 |
|
| 9 |
export function ContentRouter() {
|
| 10 |
const activeTabId = useTabStore((s) => s.activeTabId)
|
|
@@ -19,6 +20,8 @@ export function ContentRouter() {
|
|
| 19 |
page = <Settings />
|
| 20 |
} else if (activeTabType === 'scheduled') {
|
| 21 |
page = <ScheduledTasks />
|
|
|
|
|
|
|
| 22 |
} else if (activeTabType !== 'terminal') {
|
| 23 |
page = <ActiveSession />
|
| 24 |
}
|
|
|
|
| 5 |
import { ScheduledTasks } from '../../pages/ScheduledTasks'
|
| 6 |
import { Settings } from '../../pages/Settings'
|
| 7 |
import { TerminalSettings } from '../../pages/TerminalSettings'
|
| 8 |
+
import { Companion } from '../../pages/Companion'
|
| 9 |
|
| 10 |
export function ContentRouter() {
|
| 11 |
const activeTabId = useTabStore((s) => s.activeTabId)
|
|
|
|
| 20 |
page = <Settings />
|
| 21 |
} else if (activeTabType === 'scheduled') {
|
| 22 |
page = <ScheduledTasks />
|
| 23 |
+
} else if (activeTabType === 'companion') {
|
| 24 |
+
page = <Companion />
|
| 25 |
} else if (activeTabType !== 'terminal') {
|
| 26 |
page = <ActiveSession />
|
| 27 |
}
|
desktop/src/components/layout/Sidebar.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { useUIStore } from '../../stores/uiStore'
|
|
| 5 |
import { useTranslation, type TranslationKey } from '../../i18n'
|
| 6 |
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
| 7 |
import type { SessionListItem } from '../../types/session'
|
| 8 |
-
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore'
|
| 9 |
import { useChatStore } from '../../stores/chatStore'
|
| 10 |
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
| 11 |
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
|
|
@@ -698,6 +698,21 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
|
|
| 698 |
{t('sidebar.scheduled')}
|
| 699 |
</NavItem>
|
| 700 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 701 |
</div>
|
| 702 |
|
| 703 |
{expanded ? (
|
|
|
|
| 5 |
import { useTranslation, type TranslationKey } from '../../i18n'
|
| 6 |
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
| 7 |
import type { SessionListItem } from '../../types/session'
|
| 8 |
+
import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, COMPANION_TAB_ID } from '../../stores/tabStore'
|
| 9 |
import { useChatStore } from '../../stores/chatStore'
|
| 10 |
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
| 11 |
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
|
|
|
|
| 698 |
{t('sidebar.scheduled')}
|
| 699 |
</NavItem>
|
| 700 |
)}
|
| 701 |
+
{!isMobile && (
|
| 702 |
+
<NavItem
|
| 703 |
+
active={activeTabId === COMPANION_TAB_ID}
|
| 704 |
+
collapsed={!expanded}
|
| 705 |
+
label={t('sidebar.companion')}
|
| 706 |
+
touchFriendly={isMobile}
|
| 707 |
+
onClick={() => {
|
| 708 |
+
useTabStore.getState().openTab(COMPANION_TAB_ID, t('sidebar.companion'), 'companion')
|
| 709 |
+
closeMobileDrawer()
|
| 710 |
+
}}
|
| 711 |
+
icon={<span className="material-symbols-outlined text-[18px]">videocam</span>}
|
| 712 |
+
>
|
| 713 |
+
{t('sidebar.companion')}
|
| 714 |
+
</NavItem>
|
| 715 |
+
)}
|
| 716 |
</div>
|
| 717 |
|
| 718 |
{expanded ? (
|
desktop/src/hooks/useCompanionAudio.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useCallback, useEffect } from 'react'
|
| 2 |
+
|
| 3 |
+
function pcmToWav(pcm: ArrayBuffer): Blob {
|
| 4 |
+
const int16 = new Int16Array(pcm)
|
| 5 |
+
const numSamples = int16.length
|
| 6 |
+
const sampleRate = 24000
|
| 7 |
+
const numChannels = 1
|
| 8 |
+
const bitsPerSample = 16
|
| 9 |
+
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8
|
| 10 |
+
const blockAlign = (numChannels * bitsPerSample) / 8
|
| 11 |
+
const dataSize = numSamples * blockAlign
|
| 12 |
+
const headerSize = 44
|
| 13 |
+
const totalSize = headerSize + dataSize
|
| 14 |
+
|
| 15 |
+
const buf = new ArrayBuffer(totalSize)
|
| 16 |
+
const view = new DataView(buf)
|
| 17 |
+
|
| 18 |
+
const w = (offset: number, str: string) => {
|
| 19 |
+
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i))
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
w(0, 'RIFF')
|
| 23 |
+
view.setUint32(4, totalSize - 8, true)
|
| 24 |
+
w(8, 'WAVE')
|
| 25 |
+
w(12, 'fmt ')
|
| 26 |
+
view.setUint32(16, 16, true)
|
| 27 |
+
view.setUint16(20, 1, true) // PCM
|
| 28 |
+
view.setUint16(22, numChannels, true)
|
| 29 |
+
view.setUint32(24, sampleRate, true)
|
| 30 |
+
view.setUint32(28, byteRate, true)
|
| 31 |
+
view.setUint16(32, blockAlign, true)
|
| 32 |
+
view.setUint16(34, bitsPerSample, true)
|
| 33 |
+
w(36, 'data')
|
| 34 |
+
view.setUint32(40, dataSize, true)
|
| 35 |
+
|
| 36 |
+
new Int16Array(buf, headerSize, numSamples).set(int16)
|
| 37 |
+
return new Blob([buf], { type: 'audio/wav' })
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export function useCompanionAudio() {
|
| 41 |
+
const audioRef = useRef<HTMLAudioElement | null>(null)
|
| 42 |
+
const queueRef = useRef<Blob[]>([])
|
| 43 |
+
const playingRef = useRef(false)
|
| 44 |
+
const urlRef = useRef<string | null>(null)
|
| 45 |
+
|
| 46 |
+
const revokeUrl = useCallback(() => {
|
| 47 |
+
if (urlRef.current) {
|
| 48 |
+
URL.revokeObjectURL(urlRef.current)
|
| 49 |
+
urlRef.current = null
|
| 50 |
+
}
|
| 51 |
+
}, [])
|
| 52 |
+
|
| 53 |
+
const playNext = useCallback(() => {
|
| 54 |
+
if (playingRef.current) return
|
| 55 |
+
const queue = queueRef.current
|
| 56 |
+
if (queue.length === 0) return
|
| 57 |
+
|
| 58 |
+
const blob = queue.shift()!
|
| 59 |
+
const url = URL.createObjectURL(blob)
|
| 60 |
+
const audio = audioRef.current
|
| 61 |
+
if (!audio) return
|
| 62 |
+
|
| 63 |
+
revokeUrl()
|
| 64 |
+
urlRef.current = url
|
| 65 |
+
audio.src = url
|
| 66 |
+
audio.play().then(() => {
|
| 67 |
+
playingRef.current = true
|
| 68 |
+
}).catch(() => {
|
| 69 |
+
playingRef.current = false
|
| 70 |
+
revokeUrl()
|
| 71 |
+
playNext()
|
| 72 |
+
})
|
| 73 |
+
}, [revokeUrl])
|
| 74 |
+
|
| 75 |
+
useEffect(() => {
|
| 76 |
+
const audio = new Audio()
|
| 77 |
+
audioRef.current = audio
|
| 78 |
+
audio.style.display = 'none'
|
| 79 |
+
document.body.appendChild(audio)
|
| 80 |
+
|
| 81 |
+
audio.onended = () => {
|
| 82 |
+
playingRef.current = false
|
| 83 |
+
playNext()
|
| 84 |
+
}
|
| 85 |
+
audio.onerror = () => {
|
| 86 |
+
playingRef.current = false
|
| 87 |
+
playNext()
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
return () => {
|
| 91 |
+
audio.pause()
|
| 92 |
+
revokeUrl()
|
| 93 |
+
audio.src = ''
|
| 94 |
+
if (audio.parentNode) {
|
| 95 |
+
audio.parentNode.removeChild(audio)
|
| 96 |
+
}
|
| 97 |
+
audioRef.current = null
|
| 98 |
+
queueRef.current = []
|
| 99 |
+
playingRef.current = false
|
| 100 |
+
}
|
| 101 |
+
}, [revokeUrl])
|
| 102 |
+
|
| 103 |
+
const enqueueAudio = useCallback(
|
| 104 |
+
(pcmData: ArrayBuffer) => {
|
| 105 |
+
const wav = pcmToWav(pcmData)
|
| 106 |
+
queueRef.current.push(wav)
|
| 107 |
+
if (!playingRef.current) {
|
| 108 |
+
playNext()
|
| 109 |
+
}
|
| 110 |
+
},
|
| 111 |
+
[playNext],
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
const stopPlayback = useCallback(() => {
|
| 115 |
+
const audio = audioRef.current
|
| 116 |
+
if (audio) {
|
| 117 |
+
audio.pause()
|
| 118 |
+
revokeUrl()
|
| 119 |
+
audio.src = ''
|
| 120 |
+
}
|
| 121 |
+
queueRef.current = []
|
| 122 |
+
playingRef.current = false
|
| 123 |
+
}, [revokeUrl])
|
| 124 |
+
|
| 125 |
+
const resume = useCallback(() => {
|
| 126 |
+
const audio = audioRef.current
|
| 127 |
+
if (!audio) return
|
| 128 |
+
|
| 129 |
+
// Unlock audio with a silent WAV
|
| 130 |
+
const silent = new Int16Array(1)
|
| 131 |
+
silent[0] = 0
|
| 132 |
+
const wav = pcmToWav(silent.buffer)
|
| 133 |
+
const url = URL.createObjectURL(wav)
|
| 134 |
+
revokeUrl()
|
| 135 |
+
urlRef.current = url
|
| 136 |
+
audio.src = url
|
| 137 |
+
audio.play().catch(() => {})
|
| 138 |
+
}, [revokeUrl])
|
| 139 |
+
|
| 140 |
+
return {
|
| 141 |
+
enqueueAudio,
|
| 142 |
+
stopPlayback,
|
| 143 |
+
resume,
|
| 144 |
+
}
|
| 145 |
+
}
|
desktop/src/hooks/useCompanionWebSocket.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useCallback, useEffect } from 'react'
|
| 2 |
+
import { useCompanionStore } from '../stores/companionStore'
|
| 3 |
+
|
| 4 |
+
interface Callbacks {
|
| 5 |
+
onAudioReceived: (data: ArrayBuffer) => void
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export function useCompanionWebSocket(callbacks: Callbacks) {
|
| 9 |
+
const wsRef = useRef<WebSocket | null>(null)
|
| 10 |
+
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
| 11 |
+
const reconnectAttemptRef = useRef(0)
|
| 12 |
+
const callbacksRef = useRef(callbacks)
|
| 13 |
+
const manualDisconnectRef = useRef(false)
|
| 14 |
+
const serverUrl = useCompanionStore((s) => s.serverUrl)
|
| 15 |
+
const status = useCompanionStore((s) => s.status)
|
| 16 |
+
const setStatus = useCompanionStore((s) => s.setStatus)
|
| 17 |
+
const setSpeaking = useCompanionStore((s) => s.setSpeaking)
|
| 18 |
+
const setGenerating = useCompanionStore((s) => s.setGenerating)
|
| 19 |
+
const appendTranscript = useCompanionStore((s) => s.appendTranscript)
|
| 20 |
+
const appendFullTranscript = useCompanionStore((s) => s.appendFullTranscript)
|
| 21 |
+
const resetTranscript = useCompanionStore((s) => s.resetTranscript)
|
| 22 |
+
const setError = useCompanionStore((s) => s.setError)
|
| 23 |
+
|
| 24 |
+
// Keep callbacks ref current
|
| 25 |
+
callbacksRef.current = callbacks
|
| 26 |
+
|
| 27 |
+
const cleanup = useCallback(() => {
|
| 28 |
+
if (reconnectTimerRef.current) {
|
| 29 |
+
clearTimeout(reconnectTimerRef.current)
|
| 30 |
+
reconnectTimerRef.current = null
|
| 31 |
+
}
|
| 32 |
+
if (wsRef.current) {
|
| 33 |
+
wsRef.current.onopen = null
|
| 34 |
+
wsRef.current.onclose = null
|
| 35 |
+
wsRef.current.onmessage = null
|
| 36 |
+
wsRef.current.onerror = null
|
| 37 |
+
wsRef.current.close()
|
| 38 |
+
wsRef.current = null
|
| 39 |
+
}
|
| 40 |
+
}, [])
|
| 41 |
+
|
| 42 |
+
const connect = useCallback(() => {
|
| 43 |
+
manualDisconnectRef.current = false
|
| 44 |
+
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
|
| 45 |
+
return
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
cleanup()
|
| 49 |
+
setStatus('connecting')
|
| 50 |
+
setError(null)
|
| 51 |
+
resetTranscript()
|
| 52 |
+
reconnectAttemptRef.current = 0
|
| 53 |
+
|
| 54 |
+
const ws = new WebSocket(serverUrl)
|
| 55 |
+
ws.binaryType = 'arraybuffer'
|
| 56 |
+
wsRef.current = ws
|
| 57 |
+
|
| 58 |
+
ws.onopen = () => {
|
| 59 |
+
reconnectAttemptRef.current = 0
|
| 60 |
+
setStatus('connected')
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
ws.onclose = () => {
|
| 64 |
+
if (!manualDisconnectRef.current) {
|
| 65 |
+
// Auto reconnect with exponential backoff
|
| 66 |
+
const attempt = reconnectAttemptRef.current
|
| 67 |
+
const delay = Math.min(1000 * Math.pow(2, attempt), 30000)
|
| 68 |
+
reconnectAttemptRef.current = attempt + 1
|
| 69 |
+
reconnectTimerRef.current = setTimeout(() => {
|
| 70 |
+
connect()
|
| 71 |
+
}, delay)
|
| 72 |
+
}
|
| 73 |
+
setStatus('disconnected')
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
ws.onerror = () => {
|
| 77 |
+
setError('WebSocket connection error')
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
ws.onmessage = (event) => {
|
| 81 |
+
if (event.data instanceof ArrayBuffer) {
|
| 82 |
+
// Binary frame = PCM audio
|
| 83 |
+
callbacksRef.current.onAudioReceived(event.data)
|
| 84 |
+
} else {
|
| 85 |
+
// Text frame = JSON control message
|
| 86 |
+
try {
|
| 87 |
+
const msg = JSON.parse(event.data)
|
| 88 |
+
switch (msg.type) {
|
| 89 |
+
case 'vad':
|
| 90 |
+
setSpeaking(msg.speaking)
|
| 91 |
+
break
|
| 92 |
+
case 'generating':
|
| 93 |
+
setGenerating(true)
|
| 94 |
+
resetTranscript()
|
| 95 |
+
break
|
| 96 |
+
case 'text':
|
| 97 |
+
appendTranscript(msg.content)
|
| 98 |
+
break
|
| 99 |
+
case 'done':
|
| 100 |
+
appendFullTranscript(
|
| 101 |
+
useCompanionStore.getState().transcript
|
| 102 |
+
)
|
| 103 |
+
setGenerating(false)
|
| 104 |
+
setSpeaking(false)
|
| 105 |
+
break
|
| 106 |
+
}
|
| 107 |
+
} catch {
|
| 108 |
+
// Ignore malformed messages
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
}, [serverUrl, cleanup, setStatus, setError, resetTranscript, setSpeaking, setGenerating, appendTranscript, appendFullTranscript])
|
| 113 |
+
|
| 114 |
+
const disconnect = useCallback(() => {
|
| 115 |
+
manualDisconnectRef.current = true
|
| 116 |
+
cleanup()
|
| 117 |
+
setStatus('disconnected')
|
| 118 |
+
setError(null)
|
| 119 |
+
}, [cleanup, setStatus])
|
| 120 |
+
|
| 121 |
+
const sendFrame = useCallback((base64Jpeg: string) => {
|
| 122 |
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
| 123 |
+
wsRef.current.send(JSON.stringify({ type: 'frame', data: base64Jpeg }))
|
| 124 |
+
}
|
| 125 |
+
}, [])
|
| 126 |
+
|
| 127 |
+
const sendAudio = useCallback((buffer: ArrayBuffer) => {
|
| 128 |
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
| 129 |
+
wsRef.current.send(buffer)
|
| 130 |
+
}
|
| 131 |
+
}, [])
|
| 132 |
+
|
| 133 |
+
const sendStop = useCallback(() => {
|
| 134 |
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
| 135 |
+
wsRef.current.send(JSON.stringify({ type: 'stop' }))
|
| 136 |
+
}
|
| 137 |
+
}, [])
|
| 138 |
+
|
| 139 |
+
const sendText = useCallback((text: string) => {
|
| 140 |
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
| 141 |
+
wsRef.current.send(JSON.stringify({ type: 'text', content: text }))
|
| 142 |
+
}
|
| 143 |
+
}, [])
|
| 144 |
+
|
| 145 |
+
// Cleanup on unmount
|
| 146 |
+
useEffect(() => {
|
| 147 |
+
return () => {
|
| 148 |
+
manualDisconnectRef.current = true
|
| 149 |
+
cleanup()
|
| 150 |
+
}
|
| 151 |
+
}, [cleanup])
|
| 152 |
+
|
| 153 |
+
return {
|
| 154 |
+
connect,
|
| 155 |
+
disconnect,
|
| 156 |
+
sendFrame,
|
| 157 |
+
sendAudio,
|
| 158 |
+
sendStop,
|
| 159 |
+
sendText,
|
| 160 |
+
connected: status === 'connected',
|
| 161 |
+
}
|
| 162 |
+
}
|
desktop/src/hooks/useMicrophone.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useCallback, useEffect } from 'react'
|
| 2 |
+
|
| 3 |
+
export function useMicrophone(
|
| 4 |
+
enabled: boolean,
|
| 5 |
+
onAudioChunk: (buffer: ArrayBuffer) => void,
|
| 6 |
+
) {
|
| 7 |
+
const [stream, setStream] = useState<MediaStream | null>(null)
|
| 8 |
+
const [error, setError] = useState<string | null>(null)
|
| 9 |
+
const streamRef = useRef<MediaStream | null>(null)
|
| 10 |
+
const audioContextRef = useRef<AudioContext | null>(null)
|
| 11 |
+
const processorRef = useRef<ScriptProcessorNode | null>(null)
|
| 12 |
+
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null)
|
| 13 |
+
|
| 14 |
+
const startMic = useCallback(async () => {
|
| 15 |
+
try {
|
| 16 |
+
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
| 17 |
+
audio: {
|
| 18 |
+
echoCancellation: true,
|
| 19 |
+
noiseSuppression: true,
|
| 20 |
+
sampleRate: { ideal: 16000 },
|
| 21 |
+
},
|
| 22 |
+
})
|
| 23 |
+
streamRef.current = mediaStream
|
| 24 |
+
setStream(mediaStream)
|
| 25 |
+
setError(null)
|
| 26 |
+
|
| 27 |
+
// Set up audio processing pipeline
|
| 28 |
+
const audioContext = new AudioContext({ sampleRate: 16000 })
|
| 29 |
+
audioContextRef.current = audioContext
|
| 30 |
+
|
| 31 |
+
const source = audioContext.createMediaStreamSource(mediaStream)
|
| 32 |
+
sourceRef.current = source
|
| 33 |
+
|
| 34 |
+
// Use ScriptProcessorNode for PCM data access
|
| 35 |
+
const bufferSize = 2048
|
| 36 |
+
const processor = audioContext.createScriptProcessor(bufferSize, 1, 1)
|
| 37 |
+
processorRef.current = processor
|
| 38 |
+
|
| 39 |
+
processor.onaudioprocess = (event) => {
|
| 40 |
+
const inputData = event.inputBuffer.getChannelData(0) // Float32
|
| 41 |
+
// Convert Float32 to Int16 PCM
|
| 42 |
+
const int16 = new Int16Array(inputData.length)
|
| 43 |
+
for (let i = 0; i < inputData.length; i++) {
|
| 44 |
+
const sample = inputData[i] ?? 0
|
| 45 |
+
const s = Math.max(-1, Math.min(1, sample))
|
| 46 |
+
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff
|
| 47 |
+
}
|
| 48 |
+
onAudioChunk(int16.buffer)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
source.connect(processor)
|
| 52 |
+
processor.connect(audioContext.destination)
|
| 53 |
+
} catch (err) {
|
| 54 |
+
const message = err instanceof Error ? err.message : String(err)
|
| 55 |
+
setError(`Microphone access denied: ${message}`)
|
| 56 |
+
}
|
| 57 |
+
}, [onAudioChunk])
|
| 58 |
+
|
| 59 |
+
const stopMic = useCallback(() => {
|
| 60 |
+
if (processorRef.current) {
|
| 61 |
+
processorRef.current.disconnect()
|
| 62 |
+
processorRef.current = null
|
| 63 |
+
}
|
| 64 |
+
if (sourceRef.current) {
|
| 65 |
+
sourceRef.current.disconnect()
|
| 66 |
+
sourceRef.current = null
|
| 67 |
+
}
|
| 68 |
+
if (audioContextRef.current) {
|
| 69 |
+
void audioContextRef.current.close()
|
| 70 |
+
audioContextRef.current = null
|
| 71 |
+
}
|
| 72 |
+
if (streamRef.current) {
|
| 73 |
+
streamRef.current.getTracks().forEach((track) => track.stop())
|
| 74 |
+
streamRef.current = null
|
| 75 |
+
}
|
| 76 |
+
setStream(null)
|
| 77 |
+
}, [])
|
| 78 |
+
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
if (enabled) {
|
| 81 |
+
void startMic()
|
| 82 |
+
} else {
|
| 83 |
+
stopMic()
|
| 84 |
+
}
|
| 85 |
+
return () => {
|
| 86 |
+
stopMic()
|
| 87 |
+
}
|
| 88 |
+
}, [enabled, startMic, stopMic])
|
| 89 |
+
|
| 90 |
+
return {
|
| 91 |
+
stream,
|
| 92 |
+
error,
|
| 93 |
+
startMic,
|
| 94 |
+
stopMic,
|
| 95 |
+
}
|
| 96 |
+
}
|
desktop/src/hooks/useWebcam.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useCallback, useEffect } from 'react'
|
| 2 |
+
|
| 3 |
+
export function useWebcam(enabled: boolean) {
|
| 4 |
+
const [stream, setStream] = useState<MediaStream | null>(null)
|
| 5 |
+
const [error, setError] = useState<string | null>(null)
|
| 6 |
+
const streamRef = useRef<MediaStream | null>(null)
|
| 7 |
+
const videoRef = useRef<HTMLVideoElement | null>(null)
|
| 8 |
+
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
| 9 |
+
|
| 10 |
+
const startCamera = useCallback(async () => {
|
| 11 |
+
try {
|
| 12 |
+
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
| 13 |
+
video: {
|
| 14 |
+
width: { ideal: 640 },
|
| 15 |
+
height: { ideal: 480 },
|
| 16 |
+
facingMode: 'user',
|
| 17 |
+
},
|
| 18 |
+
})
|
| 19 |
+
streamRef.current = mediaStream
|
| 20 |
+
setStream(mediaStream)
|
| 21 |
+
setError(null)
|
| 22 |
+
|
| 23 |
+
// Create hidden video and canvas elements for frame capture
|
| 24 |
+
const video = document.createElement('video')
|
| 25 |
+
video.srcObject = mediaStream
|
| 26 |
+
video.playsInline = true
|
| 27 |
+
video.muted = true
|
| 28 |
+
video.autoplay = true
|
| 29 |
+
videoRef.current = video
|
| 30 |
+
|
| 31 |
+
const canvas = document.createElement('canvas')
|
| 32 |
+
canvas.width = 256
|
| 33 |
+
canvas.height = 256
|
| 34 |
+
canvasRef.current = canvas
|
| 35 |
+
|
| 36 |
+
await video.play()
|
| 37 |
+
} catch (err) {
|
| 38 |
+
const message = err instanceof Error ? err.message : String(err)
|
| 39 |
+
setError(`Camera access denied: ${message}`)
|
| 40 |
+
}
|
| 41 |
+
}, [])
|
| 42 |
+
|
| 43 |
+
const stopCamera = useCallback(() => {
|
| 44 |
+
if (streamRef.current) {
|
| 45 |
+
streamRef.current.getTracks().forEach((track) => track.stop())
|
| 46 |
+
streamRef.current = null
|
| 47 |
+
}
|
| 48 |
+
videoRef.current = null
|
| 49 |
+
canvasRef.current = null
|
| 50 |
+
setStream(null)
|
| 51 |
+
}, [])
|
| 52 |
+
|
| 53 |
+
const captureFrame = useCallback(async (): Promise<string | null> => {
|
| 54 |
+
const video = videoRef.current
|
| 55 |
+
const canvas = canvasRef.current
|
| 56 |
+
if (!video || !canvas || video.readyState < 2) return null
|
| 57 |
+
|
| 58 |
+
const ctx = canvas.getContext('2d')
|
| 59 |
+
if (!ctx) return null
|
| 60 |
+
|
| 61 |
+
ctx.drawImage(video, 0, 0, 256, 256)
|
| 62 |
+
|
| 63 |
+
return new Promise((resolve) => {
|
| 64 |
+
canvas.toBlob(
|
| 65 |
+
(blob) => {
|
| 66 |
+
if (!blob) {
|
| 67 |
+
resolve(null)
|
| 68 |
+
return
|
| 69 |
+
}
|
| 70 |
+
const reader = new FileReader()
|
| 71 |
+
reader.onloadend = () => {
|
| 72 |
+
const result = reader.result as string
|
| 73 |
+
// Remove data:image/jpeg;base64, prefix
|
| 74 |
+
resolve(result.split(',')[1] ?? null)
|
| 75 |
+
}
|
| 76 |
+
reader.onerror = () => resolve(null)
|
| 77 |
+
reader.readAsDataURL(blob)
|
| 78 |
+
},
|
| 79 |
+
'image/jpeg',
|
| 80 |
+
0.7,
|
| 81 |
+
)
|
| 82 |
+
})
|
| 83 |
+
}, [])
|
| 84 |
+
|
| 85 |
+
useEffect(() => {
|
| 86 |
+
if (enabled) {
|
| 87 |
+
void startCamera()
|
| 88 |
+
} else {
|
| 89 |
+
stopCamera()
|
| 90 |
+
}
|
| 91 |
+
return () => {
|
| 92 |
+
stopCamera()
|
| 93 |
+
}
|
| 94 |
+
}, [enabled, startCamera, stopCamera])
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
stream,
|
| 98 |
+
error,
|
| 99 |
+
captureFrame,
|
| 100 |
+
startCamera,
|
| 101 |
+
stopCamera,
|
| 102 |
+
}
|
| 103 |
+
}
|
desktop/src/i18n/locales/en.ts
CHANGED
|
@@ -58,6 +58,27 @@ export const en = {
|
|
| 58 |
'sidebar.worktree': 'worktree',
|
| 59 |
'sidebar.sessionRunning': 'Session running',
|
| 60 |
'sidebar.missingDir': 'missing dir',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
'sidebar.confirmDelete': 'Delete this session? This cannot be undone.',
|
| 62 |
'sidebar.batchManage': 'Batch manage',
|
| 63 |
'sidebar.batchSelectedCount': '{count} selected',
|
|
|
|
| 58 |
'sidebar.worktree': 'worktree',
|
| 59 |
'sidebar.sessionRunning': 'Session running',
|
| 60 |
'sidebar.missingDir': 'missing dir',
|
| 61 |
+
'sidebar.companion': 'AI Companion',
|
| 62 |
+
|
| 63 |
+
// ─── Companion ──────────────────────────────────────
|
| 64 |
+
'companion.connect': 'Connect',
|
| 65 |
+
'companion.disconnect': 'Disconnect',
|
| 66 |
+
'companion.micOn': 'Mute',
|
| 67 |
+
'companion.micOff': 'Unmute',
|
| 68 |
+
'companion.cameraOn': 'Turn Camera Off',
|
| 69 |
+
'companion.cameraOff': 'Turn Camera On',
|
| 70 |
+
'companion.status.connected': 'Connected',
|
| 71 |
+
'companion.status.disconnected': 'Disconnected',
|
| 72 |
+
'companion.status.connecting': 'Connecting...',
|
| 73 |
+
'companion.status.error': 'Connection Error',
|
| 74 |
+
'companion.transcriptPlaceholder': 'AI responses will appear here...',
|
| 75 |
+
'companion.serverUrl': 'Server URL',
|
| 76 |
+
'companion.voiceName': 'Voice',
|
| 77 |
+
'companion.alwaysHere': 'Always here',
|
| 78 |
+
'companion.listening': 'Listening...',
|
| 79 |
+
'companion.speaking': 'Speaking...',
|
| 80 |
+
'companion.inputPlaceholder': 'Type a message...',
|
| 81 |
+
'companion.send': 'Send',
|
| 82 |
'sidebar.confirmDelete': 'Delete this session? This cannot be undone.',
|
| 83 |
'sidebar.batchManage': 'Batch manage',
|
| 84 |
'sidebar.batchSelectedCount': '{count} selected',
|
desktop/src/i18n/locales/zh.ts
CHANGED
|
@@ -60,6 +60,27 @@ export const zh: Record<TranslationKey, string> = {
|
|
| 60 |
'sidebar.worktree': 'worktree',
|
| 61 |
'sidebar.sessionRunning': '会话运行中',
|
| 62 |
'sidebar.missingDir': '目录缺失',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
'sidebar.confirmDelete': '确定要删除这个会话吗?此操作不可撤销。',
|
| 64 |
'sidebar.batchManage': '批量管理',
|
| 65 |
'sidebar.batchSelectedCount': '已选 {count} 个',
|
|
|
|
| 60 |
'sidebar.worktree': 'worktree',
|
| 61 |
'sidebar.sessionRunning': '会话运行中',
|
| 62 |
'sidebar.missingDir': '目录缺失',
|
| 63 |
+
'sidebar.companion': 'AI 伴侣',
|
| 64 |
+
|
| 65 |
+
// ─── Companion ──────────────────────────────────────
|
| 66 |
+
'companion.connect': '连接',
|
| 67 |
+
'companion.disconnect': '断开',
|
| 68 |
+
'companion.micOn': '静音',
|
| 69 |
+
'companion.micOff': '取消静音',
|
| 70 |
+
'companion.cameraOn': '关闭摄像头',
|
| 71 |
+
'companion.cameraOff': '打开摄像头',
|
| 72 |
+
'companion.status.connected': '已连接',
|
| 73 |
+
'companion.status.disconnected': '未连接',
|
| 74 |
+
'companion.status.connecting': '连接中...',
|
| 75 |
+
'companion.status.error': '连接错误',
|
| 76 |
+
'companion.transcriptPlaceholder': 'AI 回复将显示在这里...',
|
| 77 |
+
'companion.serverUrl': '服务器地址',
|
| 78 |
+
'companion.voiceName': '音色',
|
| 79 |
+
'companion.alwaysHere': '随时待命',
|
| 80 |
+
'companion.listening': '聆听中...',
|
| 81 |
+
'companion.speaking': '说话中...',
|
| 82 |
+
'companion.inputPlaceholder': '输入消息...',
|
| 83 |
+
'companion.send': '发送',
|
| 84 |
'sidebar.confirmDelete': '确定要删除这个会话吗?此操作不可撤销。',
|
| 85 |
'sidebar.batchManage': '批量管理',
|
| 86 |
'sidebar.batchSelectedCount': '已选 {count} 个',
|
desktop/src/pages/Companion.tsx
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useCallback } from 'react'
|
| 2 |
+
import { useCompanionStore } from '../stores/companionStore'
|
| 3 |
+
import { useCompanionWebSocket } from '../hooks/useCompanionWebSocket'
|
| 4 |
+
import { useWebcam } from '../hooks/useWebcam'
|
| 5 |
+
import { useMicrophone } from '../hooks/useMicrophone'
|
| 6 |
+
import { useCompanionAudio } from '../hooks/useCompanionAudio'
|
| 7 |
+
import { CompanionVideoPanel } from '../components/companion/CompanionVideoPanel'
|
| 8 |
+
import { CompanionTranscript } from '../components/companion/CompanionTranscript'
|
| 9 |
+
import { CompanionControls } from '../components/companion/CompanionControls'
|
| 10 |
+
|
| 11 |
+
export function Companion() {
|
| 12 |
+
const status = useCompanionStore((s) => s.status)
|
| 13 |
+
const speaking = useCompanionStore((s) => s.speaking)
|
| 14 |
+
const generating = useCompanionStore((s) => s.generating)
|
| 15 |
+
const transcript = useCompanionStore((s) => s.transcript)
|
| 16 |
+
const fullTranscript = useCompanionStore((s) => s.fullTranscript)
|
| 17 |
+
const micEnabled = useCompanionStore((s) => s.micEnabled)
|
| 18 |
+
const cameraEnabled = useCompanionStore((s) => s.cameraEnabled)
|
| 19 |
+
const error = useCompanionStore((s) => s.error)
|
| 20 |
+
const setMicEnabled = useCompanionStore((s) => s.setMicEnabled)
|
| 21 |
+
const setCameraEnabled = useCompanionStore((s) => s.setCameraEnabled)
|
| 22 |
+
const reset = useCompanionStore((s) => s.reset)
|
| 23 |
+
const frameIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
| 24 |
+
|
| 25 |
+
const companionAudio = useCompanionAudio()
|
| 26 |
+
const onAudioReceived = useCallback(
|
| 27 |
+
(data: ArrayBuffer) => {
|
| 28 |
+
companionAudio.enqueueAudio(data)
|
| 29 |
+
},
|
| 30 |
+
[companionAudio],
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
const ws = useCompanionWebSocket({ onAudioReceived })
|
| 34 |
+
const webcam = useWebcam(ws.connected && cameraEnabled)
|
| 35 |
+
useMicrophone(ws.connected && micEnabled, ws.sendAudio)
|
| 36 |
+
|
| 37 |
+
// Periodic frame capture when connected and camera enabled
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
if (ws.connected && cameraEnabled) {
|
| 40 |
+
frameIntervalRef.current = setInterval(async () => {
|
| 41 |
+
const frame = await webcam.captureFrame()
|
| 42 |
+
if (frame) {
|
| 43 |
+
ws.sendFrame(frame)
|
| 44 |
+
}
|
| 45 |
+
}, 1000)
|
| 46 |
+
} else {
|
| 47 |
+
if (frameIntervalRef.current) {
|
| 48 |
+
clearInterval(frameIntervalRef.current)
|
| 49 |
+
frameIntervalRef.current = null
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
return () => {
|
| 53 |
+
if (frameIntervalRef.current) {
|
| 54 |
+
clearInterval(frameIntervalRef.current)
|
| 55 |
+
frameIntervalRef.current = null
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
}, [ws.connected, cameraEnabled, webcam, ws])
|
| 59 |
+
|
| 60 |
+
const handleSendText = useCallback(
|
| 61 |
+
(text: string) => {
|
| 62 |
+
ws.sendText(text)
|
| 63 |
+
},
|
| 64 |
+
[ws],
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
// Resume audio context on user interaction
|
| 68 |
+
const handleResumeAudio = useCallback(() => {
|
| 69 |
+
companionAudio.resume()
|
| 70 |
+
}, [companionAudio])
|
| 71 |
+
|
| 72 |
+
const handleToggleMic = useCallback(() => {
|
| 73 |
+
setMicEnabled(!micEnabled)
|
| 74 |
+
}, [micEnabled, setMicEnabled])
|
| 75 |
+
|
| 76 |
+
const handleToggleCamera = useCallback(() => {
|
| 77 |
+
setCameraEnabled(!cameraEnabled)
|
| 78 |
+
}, [cameraEnabled, setCameraEnabled])
|
| 79 |
+
|
| 80 |
+
return (
|
| 81 |
+
<div className="relative h-full w-full overflow-hidden bg-black">
|
| 82 |
+
{/* Video background + PiP */}
|
| 83 |
+
<CompanionVideoPanel
|
| 84 |
+
webcamStream={webcam.stream}
|
| 85 |
+
speaking={speaking}
|
| 86 |
+
generating={generating}
|
| 87 |
+
status={status}
|
| 88 |
+
/>
|
| 89 |
+
|
| 90 |
+
{/* Error overlay */}
|
| 91 |
+
{error && (
|
| 92 |
+
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl bg-red-500/20 border border-red-500/30 text-red-300 text-sm">
|
| 93 |
+
{error}
|
| 94 |
+
</div>
|
| 95 |
+
)}
|
| 96 |
+
|
| 97 |
+
{/* Transcript + text input */}
|
| 98 |
+
<CompanionTranscript
|
| 99 |
+
transcript={transcript}
|
| 100 |
+
fullTranscript={fullTranscript}
|
| 101 |
+
onSendText={handleSendText}
|
| 102 |
+
disabled={status !== 'connected'}
|
| 103 |
+
/>
|
| 104 |
+
|
| 105 |
+
{/* Controls */}
|
| 106 |
+
<CompanionControls
|
| 107 |
+
status={status}
|
| 108 |
+
micEnabled={micEnabled}
|
| 109 |
+
cameraEnabled={cameraEnabled}
|
| 110 |
+
generating={generating}
|
| 111 |
+
onToggleMic={handleToggleMic}
|
| 112 |
+
onToggleCamera={handleToggleCamera}
|
| 113 |
+
onConnect={ws.connect}
|
| 114 |
+
onDisconnect={() => {
|
| 115 |
+
ws.disconnect()
|
| 116 |
+
reset()
|
| 117 |
+
}}
|
| 118 |
+
onStop={ws.sendStop}
|
| 119 |
+
onResumeAudio={handleResumeAudio}
|
| 120 |
+
/>
|
| 121 |
+
</div>
|
| 122 |
+
)
|
| 123 |
+
}
|
desktop/src/stores/companionStore.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { create } from 'zustand'
|
| 2 |
+
import type { CompanionStatus } from '../types/companion'
|
| 3 |
+
|
| 4 |
+
interface CompanionStore {
|
| 5 |
+
status: CompanionStatus
|
| 6 |
+
serverUrl: string
|
| 7 |
+
voiceName: string
|
| 8 |
+
speaking: boolean
|
| 9 |
+
generating: boolean
|
| 10 |
+
transcript: string
|
| 11 |
+
fullTranscript: string
|
| 12 |
+
micEnabled: boolean
|
| 13 |
+
cameraEnabled: boolean
|
| 14 |
+
error: string | null
|
| 15 |
+
|
| 16 |
+
setServerUrl: (url: string) => void
|
| 17 |
+
setVoiceName: (name: string) => void
|
| 18 |
+
setStatus: (status: CompanionStatus) => void
|
| 19 |
+
setSpeaking: (speaking: boolean) => void
|
| 20 |
+
setGenerating: (generating: boolean) => void
|
| 21 |
+
appendTranscript: (text: string) => void
|
| 22 |
+
appendFullTranscript: (text: string) => void
|
| 23 |
+
resetTranscript: () => void
|
| 24 |
+
setMicEnabled: (enabled: boolean) => void
|
| 25 |
+
setCameraEnabled: (enabled: boolean) => void
|
| 26 |
+
setError: (error: string | null) => void
|
| 27 |
+
reset: () => void
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const DEFAULT_SERVER_URL = 'ws://127.0.0.1:8889/ws/companion'
|
| 31 |
+
|
| 32 |
+
export const useCompanionStore = create<CompanionStore>((set) => ({
|
| 33 |
+
status: 'disconnected',
|
| 34 |
+
serverUrl: DEFAULT_SERVER_URL,
|
| 35 |
+
voiceName: 'default',
|
| 36 |
+
speaking: false,
|
| 37 |
+
generating: false,
|
| 38 |
+
transcript: '',
|
| 39 |
+
fullTranscript: '',
|
| 40 |
+
micEnabled: true,
|
| 41 |
+
cameraEnabled: true,
|
| 42 |
+
error: null,
|
| 43 |
+
|
| 44 |
+
setServerUrl: (url) => set({ serverUrl: url }),
|
| 45 |
+
setVoiceName: (name) => set({ voiceName: name }),
|
| 46 |
+
setStatus: (status) => set({ status, error: status === 'error' ? undefined : null }),
|
| 47 |
+
setSpeaking: (speaking) => set({ speaking }),
|
| 48 |
+
setGenerating: (generating) => set({ generating }),
|
| 49 |
+
appendTranscript: (text) =>
|
| 50 |
+
set((s) => ({ transcript: s.transcript + text })),
|
| 51 |
+
appendFullTranscript: (text) =>
|
| 52 |
+
set((s) => ({ fullTranscript: s.fullTranscript + text + '\n' })),
|
| 53 |
+
resetTranscript: () => set({ transcript: '' }),
|
| 54 |
+
setMicEnabled: (enabled) => set({ micEnabled: enabled }),
|
| 55 |
+
setCameraEnabled: (enabled) => set({ cameraEnabled: enabled }),
|
| 56 |
+
setError: (error) => set({ error }),
|
| 57 |
+
reset: () =>
|
| 58 |
+
set({
|
| 59 |
+
status: 'disconnected',
|
| 60 |
+
speaking: false,
|
| 61 |
+
generating: false,
|
| 62 |
+
transcript: '',
|
| 63 |
+
error: null,
|
| 64 |
+
}),
|
| 65 |
+
}))
|
desktop/src/stores/tabStore.ts
CHANGED
|
@@ -6,8 +6,9 @@ const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
|
|
| 6 |
export const SETTINGS_TAB_ID = '__settings__'
|
| 7 |
export const SCHEDULED_TAB_ID = '__scheduled__'
|
| 8 |
export const TERMINAL_TAB_PREFIX = '__terminal__'
|
|
|
|
| 9 |
|
| 10 |
-
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal'
|
| 11 |
|
| 12 |
export type Tab = {
|
| 13 |
sessionId: string
|
|
@@ -152,7 +153,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
|
| 152 |
|
| 153 |
saveTabs: () => {
|
| 154 |
const { tabs, activeTabId } = get()
|
| 155 |
-
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal')
|
| 156 |
const data: TabPersistence = {
|
| 157 |
openTabs: persistableTabs.map((t) => ({ sessionId: t.sessionId, title: t.title, type: t.type })),
|
| 158 |
activeTabId: activeTabId && persistableTabs.some((tab) => tab.sessionId === activeTabId)
|
|
@@ -182,13 +183,13 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
|
| 182 |
const validTabs: Tab[] = data.openTabs
|
| 183 |
.filter((t) => {
|
| 184 |
// Special tabs are always valid
|
| 185 |
-
if (t.type === 'settings' || t.type === 'scheduled') return true
|
| 186 |
if (t.type === 'terminal') return false
|
| 187 |
// Session tabs must exist on server
|
| 188 |
return existingIds.has(t.sessionId)
|
| 189 |
})
|
| 190 |
.map((t) => {
|
| 191 |
-
if (t.type === 'settings' || t.type === 'scheduled') {
|
| 192 |
return { sessionId: t.sessionId, title: t.title, type: t.type, status: 'idle' as const }
|
| 193 |
}
|
| 194 |
return {
|
|
|
|
| 6 |
export const SETTINGS_TAB_ID = '__settings__'
|
| 7 |
export const SCHEDULED_TAB_ID = '__scheduled__'
|
| 8 |
export const TERMINAL_TAB_PREFIX = '__terminal__'
|
| 9 |
+
export const COMPANION_TAB_ID = '__companion__'
|
| 10 |
|
| 11 |
+
export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' | 'companion'
|
| 12 |
|
| 13 |
export type Tab = {
|
| 14 |
sessionId: string
|
|
|
|
| 153 |
|
| 154 |
saveTabs: () => {
|
| 155 |
const { tabs, activeTabId } = get()
|
| 156 |
+
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal' && tab.type !== 'companion')
|
| 157 |
const data: TabPersistence = {
|
| 158 |
openTabs: persistableTabs.map((t) => ({ sessionId: t.sessionId, title: t.title, type: t.type })),
|
| 159 |
activeTabId: activeTabId && persistableTabs.some((tab) => tab.sessionId === activeTabId)
|
|
|
|
| 183 |
const validTabs: Tab[] = data.openTabs
|
| 184 |
.filter((t) => {
|
| 185 |
// Special tabs are always valid
|
| 186 |
+
if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'companion') return true
|
| 187 |
if (t.type === 'terminal') return false
|
| 188 |
// Session tabs must exist on server
|
| 189 |
return existingIds.has(t.sessionId)
|
| 190 |
})
|
| 191 |
.map((t) => {
|
| 192 |
+
if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'companion') {
|
| 193 |
return { sessionId: t.sessionId, title: t.title, type: t.type, status: 'idle' as const }
|
| 194 |
}
|
| 195 |
return {
|
desktop/src/types/companion.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type CompanionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
| 2 |
+
|
| 3 |
+
export type CompanionConfig = {
|
| 4 |
+
serverUrl: string
|
| 5 |
+
voiceName: string
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
// Server -> Client (text frame)
|
| 9 |
+
export type CompanionServerMessage =
|
| 10 |
+
| { type: 'vad'; speaking: boolean }
|
| 11 |
+
| { type: 'generating' }
|
| 12 |
+
| { type: 'text'; content: string }
|
| 13 |
+
| { type: 'done'; interrupted: boolean }
|
| 14 |
+
|
| 15 |
+
// Client -> Server (text frame)
|
| 16 |
+
export type CompanionClientMessage =
|
| 17 |
+
| { type: 'frame'; data: string }
|
| 18 |
+
| { type: 'stop' }
|
| 19 |
+
| { type: 'end' }
|
| 20 |
+
| { type: 'context'; voice?: string }
|