chenbhao commited on
Commit
49cd9e0
·
1 Parent(s): 76f081f

feat: ai friend companion on desktop

Browse files
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 }