chenbhao commited on
Commit
d530cd8
·
1 Parent(s): 0855654

feat: inter-process

Browse files

架构对比

之前: 之后:
CLI TUI CLI TUI (单一进程)
└── /friend start ├── FriendService (in-process, cpal)
├── 服务器子进程 :3456 │ ├── STT via cpal
│ ├── chat-service.ts │ ├── enqueue() → 主 CLI
│ │ └── CLI SDK 子进程│ └── SSE → Tauri
│ ├── arecord 子进程 ├── 轻量 Bun.serve (in-process)
│ └── SSE └── Tauri (thin display only)
└── Tauri (getUserMedia)

src/commands/friend/friend.tsx CHANGED
@@ -11,6 +11,8 @@ import {
11
  launchTauri,
12
  stopTauri,
13
  } from '../../friend/tauri-launcher.js'
 
 
14
  import type { LocalJSXCommandOnDone, CommandResultDisplay } from '../../types/command.js'
15
 
16
  const FRIEND_URL = 'http://127.0.0.1:3456/friend/'
@@ -29,12 +31,26 @@ export async function call(
29
 
30
  if (trimmed === 'start') {
31
  updatePrefs({ enabled: true })
 
 
 
 
 
 
 
 
 
 
 
 
32
  launchTauri(logger())
33
  return <FriendStartView onDone={onDone} />
34
  }
35
 
36
  if (trimmed === 'stop') {
37
  updatePrefs({ enabled: false })
 
 
38
  stopTauri(logger())
39
  return <FriendStopView onDone={onDone} />
40
  }
 
11
  launchTauri,
12
  stopTauri,
13
  } from '../../friend/tauri-launcher.js'
14
+ import { startFriendServer, stopFriendServer, getServerPort } from '../../friend/server.js'
15
+ import { friendService } from '../../friend/FriendService.js'
16
  import type { LocalJSXCommandOnDone, CommandResultDisplay } from '../../types/command.js'
17
 
18
  const FRIEND_URL = 'http://127.0.0.1:3456/friend/'
 
31
 
32
  if (trimmed === 'start') {
33
  updatePrefs({ enabled: true })
34
+ // Start in-process HTTP server for friend API and SSE
35
+ try {
36
+ startFriendServer(3456, '127.0.0.1')
37
+ } catch (err) {
38
+ console.warn(`[Friend] HTTP server start failed: ${err}`)
39
+ console.warn('[Friend] The Tauri app may need the main server on port 3456.')
40
+ }
41
+ // FriendService runs in-process; messages enqueue into the CLI queue
42
+ await friendService.start().catch((err) => {
43
+ console.warn(`[Friend] Service start failed: ${err}`)
44
+ })
45
+ // Launch Tauri display window (thin client)
46
  launchTauri(logger())
47
  return <FriendStartView onDone={onDone} />
48
  }
49
 
50
  if (trimmed === 'stop') {
51
  updatePrefs({ enabled: false })
52
+ await friendService.stop().catch(() => {})
53
+ stopFriendServer()
54
  stopTauri(logger())
55
  return <FriendStopView onDone={onDone} />
56
  }
src/components/friend/frontend/App.tsx CHANGED
@@ -75,6 +75,7 @@ export default function App() {
75
  const [screenObserve, setScreenObserve] = useState(false)
76
  const [screenObserveInterval, setScreenObserveInterval] = useState(60)
77
  const [language, setLanguage] = useState<'zh' | 'en'>(() => navigator.language.startsWith('zh') ? 'zh' : 'en')
 
78
  const t = (zh: string, en: string) => language === 'en' ? en : zh
79
  usePassThrough(!settingsOpen && !historyOpen)
80
 
@@ -95,6 +96,7 @@ export default function App() {
95
  if (s.screenObserveInterval !== undefined) setScreenObserveInterval(s.screenObserveInterval)
96
  if (s.currentDance) setCurrentDance(s.currentDance)
97
  if (s.customDancePreset) setCustomDancePreset(s.customDancePreset)
 
98
  if (s.language) {
99
  setLanguage(s.language)
100
  } else {
@@ -369,7 +371,7 @@ export default function App() {
369
  <VRMScene ref={sceneRef} modelPath={modelPath} onTouch={handleTouch} onModelLoaded={uploadVrmScreenshot} />
370
  {!hideMood && <MoodIndicator uiAlign={uiAlign} />}
371
  <TextBubble onMessage={handleVrmMessageWithActivity} enabled={showText} ttsEnabled={ttsEnabled} />
372
- {!hideUI && <ChatInput uiAlign={uiAlign} onHistoryOpen={() => setHistoryOpen(true)} onNewSession={clearContext} language={language} />}
373
  <HistoryPanel
374
  visible={historyOpen}
375
  onClose={() => setHistoryOpen(false)}
@@ -401,6 +403,8 @@ export default function App() {
401
  captureVrmScreenshot={() => sceneRef.current?.captureScreenshot() ?? null}
402
  language={language}
403
  onLanguageChange={(v) => { setLanguage(v); saveSettings({ language: v }) }}
 
 
404
  currentDance={currentDance}
405
  onDanceChange={(id, preset) => {
406
  setCurrentDance(id)
 
75
  const [screenObserve, setScreenObserve] = useState(false)
76
  const [screenObserveInterval, setScreenObserveInterval] = useState(60)
77
  const [language, setLanguage] = useState<'zh' | 'en'>(() => navigator.language.startsWith('zh') ? 'zh' : 'en')
78
+ const [sttProvider, setSttProvider] = useState<'browser' | 'anthropic' | 'local' | 'doubao'>('browser')
79
  const t = (zh: string, en: string) => language === 'en' ? en : zh
80
  usePassThrough(!settingsOpen && !historyOpen)
81
 
 
96
  if (s.screenObserveInterval !== undefined) setScreenObserveInterval(s.screenObserveInterval)
97
  if (s.currentDance) setCurrentDance(s.currentDance)
98
  if (s.customDancePreset) setCustomDancePreset(s.customDancePreset)
99
+ if (s.sttProvider) setSttProvider(s.sttProvider)
100
  if (s.language) {
101
  setLanguage(s.language)
102
  } else {
 
371
  <VRMScene ref={sceneRef} modelPath={modelPath} onTouch={handleTouch} onModelLoaded={uploadVrmScreenshot} />
372
  {!hideMood && <MoodIndicator uiAlign={uiAlign} />}
373
  <TextBubble onMessage={handleVrmMessageWithActivity} enabled={showText} ttsEnabled={ttsEnabled} />
374
+ {!hideUI && <ChatInput uiAlign={uiAlign} onHistoryOpen={() => setHistoryOpen(true)} onNewSession={clearContext} language={language} sttProvider={sttProvider} />}
375
  <HistoryPanel
376
  visible={historyOpen}
377
  onClose={() => setHistoryOpen(false)}
 
403
  captureVrmScreenshot={() => sceneRef.current?.captureScreenshot() ?? null}
404
  language={language}
405
  onLanguageChange={(v) => { setLanguage(v); saveSettings({ language: v }) }}
406
+ sttProvider={sttProvider}
407
+ onSttProviderChange={(v) => { setSttProvider(v); saveSettings({ sttProvider: v }) }}
408
  currentDance={currentDance}
409
  onDanceChange={(id, preset) => {
410
  setCurrentDance(id)
src/components/friend/frontend/components/ChatInput.tsx CHANGED
@@ -1,12 +1,11 @@
1
  import { useState, useRef, useCallback, useEffect } from 'react'
2
  import { MessageCircle, Send, Loader, Mic, ChevronDown, History, SquarePen, Plus, Phone, PhoneOff } from 'lucide-react'
3
  import { FRIEND_API } from '../api'
 
4
 
5
  // Keyframes (claw-input-slide-up, claw-input-slide-down, claw-pulse) are in index.html <style>
6
 
7
- const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
8
-
9
- export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', onHistoryOpen, onNewSession, language = 'zh' }: { visible?: boolean; onActiveChange?: (hasText: boolean) => void; uiAlign?: 'left' | 'right'; onHistoryOpen?: () => void; onNewSession?: () => void; language?: 'zh' | 'en' }) {
10
  const t = (zh: string, en: string) => language === 'en' ? en : zh
11
  const [open, setOpen] = useState(false)
12
  const [text, setText] = useState('')
@@ -16,10 +15,11 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
16
  const [menuOpen, setMenuOpen] = useState(false)
17
  const [voiceCallActive, setVoiceCallActive] = useState(false)
18
  const inputRef = useRef<HTMLInputElement>(null)
19
- const recognitionRef = useRef<any>(null)
20
  const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
21
  const lastSentIndexRef = useRef(0)
22
  const voiceCallActiveRef = useRef(false)
 
 
23
 
24
  const closeBar = useCallback(() => {
25
  if (closing) return
@@ -48,68 +48,48 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
48
  }, [text, sending])
49
 
50
  const startRecording = useCallback(async () => {
51
- // Web Speech API (Tauri native STT not available in web mode)
52
- if (!SpeechRecognition) {
53
- console.error('SpeechRecognition not supported')
54
- return
55
- }
56
-
57
- const recognition = new SpeechRecognition()
58
- recognition.lang = 'zh-CN'
59
- recognition.interimResults = true
60
- recognition.continuous = true
61
-
62
- recognition.onresult = (event: any) => {
63
- let transcript = ''
64
- for (let i = 0; i < event.results.length; i++) {
65
- transcript += event.results[i][0].transcript
66
- }
67
- setText(transcript)
68
- onActiveChange?.(transcript.length > 0)
69
- }
70
-
71
- recognition.onerror = (event: any) => {
72
- console.error('Speech recognition error:', event.error)
73
  setRecording(false)
74
  }
 
75
 
76
- recognition.onend = () => {
 
 
77
  setRecording(false)
78
- setTimeout(() => inputRef.current?.focus(), 50)
79
- }
80
-
81
- recognition.start()
82
- recognitionRef.current = recognition
83
- setRecording(true)
84
- setOpen(true)
85
- }, [])
86
-
87
- const stopRecording = useCallback(() => {
88
- if (recognitionRef.current) {
89
- recognitionRef.current.stop()
90
- recognitionRef.current = null
91
  }
92
  setRecording(false)
93
- }, [])
 
 
 
 
 
 
 
94
 
95
- // 鼠标移出按钮时也停止录音
96
  const handleMouseLeave = useCallback(() => {
97
  if (recording) stopRecording()
98
  }, [recording, stopRecording])
99
 
100
  // --- Voice Call: constants ---
101
- const HARD_PUNCT = /[。!?\.\!\?]$/ // immediate send
102
- const SOFT_PUNCT = /[,、;,;::]$/ // send if long enough
103
- const SOFT_PUNCT_MIN_LEN = 10 // min chars before soft punct triggers send
104
- const MAX_UNSENT_LEN = 30 // force send when accumulated text is this long
105
- const SILENCE_SEND_MS = 1200 // silence fallback timeout
106
- const VAD_RMS_THRESHOLD = 0.015 // RMS below this = silence
107
- const VAD_SILENCE_TIMEOUT_MS = 15_000 // stop STT after 15s silence
108
 
109
  // --- Voice Call: delayed TTS interrupt ---
110
  const interruptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
111
  const scheduleInterrupt = useCallback(() => {
112
- if (interruptTimerRef.current) return // already scheduled
113
  interruptTimerRef.current = setTimeout(() => {
114
  interruptTimerRef.current = null
115
  ;(window as any).__clawInterruptAudio?.()
@@ -119,15 +99,6 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
119
  if (interruptTimerRef.current) { clearTimeout(interruptTimerRef.current); interruptTimerRef.current = null }
120
  }, [])
121
 
122
- // --- Voice Call: refs ---
123
- const vadStreamRef = useRef<MediaStream | null>(null)
124
- const vadContextRef = useRef<AudioContext | null>(null)
125
- const vadAnalyserRef = useRef<AnalyserNode | null>(null)
126
- const vadRafRef = useRef<number>(0)
127
- const vadSpeakingRef = useRef(false)
128
- const vadSilenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
129
- const [vadSpeaking, setVadSpeaking] = useState(false)
130
-
131
  // --- Voice Call: send + mute capture for 3s after send ---
132
  const VOICE_MUTE_AFTER_SEND_MS = 2000
133
  const mutedUntilRef = useRef(0)
@@ -151,7 +122,6 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
151
 
152
  // --- Voice Call: smart sentence segmentation ---
153
  const handleVoiceCallResult = useCallback((fullTranscript: string, isFinal: boolean) => {
154
- // Ignore STT results during post-send mute period
155
  if (Date.now() < mutedUntilRef.current) {
156
  lastSentIndexRef.current = fullTranscript.length
157
  return
@@ -161,14 +131,13 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
161
  setText(unsent)
162
  onActiveChange?.(unsent.length > 0)
163
 
164
- // Reset silence send timer on any result
165
  if (silenceTimerRef.current) { clearTimeout(silenceTimerRef.current); silenceTimerRef.current = null }
166
 
167
  if (isFinal) {
168
  const shouldSend =
169
- HARD_PUNCT.test(unsent) || // hard punctuation
170
- (SOFT_PUNCT.test(unsent) && unsent.length >= SOFT_PUNCT_MIN_LEN) || // soft punct + long enough
171
- unsent.length >= MAX_UNSENT_LEN // word count overflow
172
 
173
  if (shouldSend) {
174
  voiceCallSend(unsent)
@@ -179,7 +148,6 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
179
  }
180
  }
181
 
182
- // Silence fallback: start timer on both partial and final results
183
  if (unsent.trim()) {
184
  silenceTimerRef.current = setTimeout(() => {
185
  if (!voiceCallActiveRef.current) return
@@ -194,67 +162,7 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
194
  }
195
  }, [voiceCallSend, onActiveChange])
196
 
197
- // --- Voice Call: VAD using AnalyserNode ---
198
- const startVad = useCallback(async () => {
199
- try {
200
- const stream = await navigator.mediaDevices.getUserMedia({
201
- audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
202
- })
203
- vadStreamRef.current = stream
204
- const ctx = new AudioContext()
205
- vadContextRef.current = ctx
206
- const source = ctx.createMediaStreamSource(stream)
207
- const analyser = ctx.createAnalyser()
208
- analyser.fftSize = 512
209
- source.connect(analyser)
210
- vadAnalyserRef.current = analyser
211
-
212
- const dataArray = new Float32Array(analyser.fftSize)
213
- let lastSpeechTime = performance.now()
214
-
215
- const check = () => {
216
- if (!voiceCallActiveRef.current) return
217
- analyser.getFloatTimeDomainData(dataArray)
218
- let sum = 0
219
- for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i]
220
- const rms = Math.sqrt(sum / dataArray.length)
221
-
222
- const speaking = rms > VAD_RMS_THRESHOLD
223
- if (speaking) {
224
- lastSpeechTime = performance.now()
225
- if (!vadSpeakingRef.current) {
226
- vadSpeakingRef.current = true
227
- setVadSpeaking(true)
228
- // Interrupt TTS after 1s delay when user starts speaking
229
- scheduleInterrupt()
230
- }
231
- } else if (vadSpeakingRef.current && performance.now() - lastSpeechTime > 500) {
232
- vadSpeakingRef.current = false
233
- cancelInterrupt()
234
- setVadSpeaking(false)
235
- }
236
-
237
- vadRafRef.current = requestAnimationFrame(check)
238
- }
239
- vadRafRef.current = requestAnimationFrame(check)
240
- } catch (err) {
241
- console.error('VAD start failed:', err)
242
- }
243
- }, [])
244
-
245
- const stopVad = useCallback(() => {
246
- if (vadRafRef.current) { cancelAnimationFrame(vadRafRef.current); vadRafRef.current = 0 }
247
- vadStreamRef.current?.getTracks().forEach((t) => t.stop())
248
- vadStreamRef.current = null
249
- vadContextRef.current?.close()
250
- vadContextRef.current = null
251
- vadAnalyserRef.current = null
252
- vadSpeakingRef.current = false
253
- setVadSpeaking(false)
254
- if (vadSilenceTimerRef.current) { clearTimeout(vadSilenceTimerRef.current); vadSilenceTimerRef.current = null }
255
- }, [])
256
-
257
- // --- Voice Call: start ---
258
  const startVoiceCall = useCallback(async () => {
259
  setVoiceCallActive(true)
260
  voiceCallActiveRef.current = true
@@ -262,73 +170,38 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
262
  setText('')
263
  setOpen(true)
264
 
265
- // Start VAD
266
- await startVad()
267
-
268
- if (!SpeechRecognition) {
269
- console.error('SpeechRecognition not supported')
270
- setVoiceCallActive(false)
271
- voiceCallActiveRef.current = false
272
- stopVad()
273
- return
274
- }
275
-
276
- const startWebSTT = () => {
277
- const recognition = new SpeechRecognition()
278
- recognition.lang = 'zh-CN'
279
- recognition.interimResults = true
280
- recognition.continuous = true
281
- recognitionRef.current = recognition
282
-
283
- recognition.onresult = (event: any) => {
284
- let transcript = ''
285
- let latestFinalEnd = 0
286
- for (let i = 0; i < event.results.length; i++) {
287
- transcript += event.results[i][0].transcript
288
- if (event.results[i].isFinal) latestFinalEnd = transcript.length
289
- }
290
- const hasFinal = latestFinalEnd > lastSentIndexRef.current
291
- handleVoiceCallResult(transcript, hasFinal)
292
- }
293
-
294
- recognition.onerror = (event: any) => {
295
- console.error('Voice call STT error:', event.error)
296
- if (event.error === 'no-speech' || event.error === 'aborted') return
297
- }
298
-
299
- recognition.onend = () => {
300
- // Auto-restart if call is still active
301
- if (voiceCallActiveRef.current) {
302
- lastSentIndexRef.current = 0
303
- setTimeout(() => {
304
- if (voiceCallActiveRef.current) startWebSTT()
305
- }, 300)
306
- }
307
- }
308
-
309
- recognition.start()
310
  setRecording(true)
 
311
  }
312
 
313
- startWebSTT()
314
- }, [handleVoiceCallResult, startVad, stopVad])
 
 
 
315
 
316
  // --- Voice Call: end ---
317
  const endVoiceCall = useCallback(() => {
318
  voiceCallActiveRef.current = false
319
  setVoiceCallActive(false)
320
-
321
- // Stop VAD
322
- stopVad()
323
-
324
- // Stop STT
325
- if (recognitionRef.current) {
326
- recognitionRef.current.stop()
327
- recognitionRef.current = null
328
- }
329
  setRecording(false)
330
 
331
- // Clear timers
332
  if (silenceTimerRef.current) { clearTimeout(silenceTimerRef.current); silenceTimerRef.current = null }
333
  cancelInterrupt()
334
  mutedUntilRef.current = 0
@@ -336,7 +209,9 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
336
  setText('')
337
  setOpen(false)
338
  onActiveChange?.(false)
339
- }, [onActiveChange, stopVad, cancelInterrupt])
 
 
340
 
341
  const handleKeyDown = (e: React.KeyboardEvent) => {
342
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -351,16 +226,14 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
351
  setText('')
352
  onActiveChange?.(false)
353
  }
354
-
355
  }
356
 
357
- // 全局快捷键
358
  useEffect(() => {
359
  const onGlobalKeyDown = (e: KeyboardEvent) => {
360
  if (!visible) return
361
  const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
362
 
363
- // Enter: 唤起输入框 / 聚焦输入框
364
  if (e.key === 'Enter' && !e.shiftKey && !inInput) {
365
  e.preventDefault()
366
  if (!open) {
@@ -370,13 +243,11 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
370
  setTimeout(() => inputRef.current?.focus(), 50)
371
  }
372
 
373
- // Escape: 收起输入框
374
  if (e.key === 'Escape' && open && !inInput) {
375
  e.preventDefault()
376
  closeBar()
377
  }
378
 
379
- // F2: 语音通话
380
  if (e.key === 'F2') {
381
  e.preventDefault()
382
  if (voiceCallActive) {
@@ -386,16 +257,9 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
386
  }
387
  }
388
  }
389
- const onGlobalKeyUp = (_e: KeyboardEvent) => {
390
- // F2 keyup no longer needed (voice call is toggle, not push-to-talk)
391
- }
392
  window.addEventListener('keydown', onGlobalKeyDown)
393
- window.addEventListener('keyup', onGlobalKeyUp)
394
- return () => {
395
- window.removeEventListener('keydown', onGlobalKeyDown)
396
- window.removeEventListener('keyup', onGlobalKeyUp)
397
- }
398
- }, [open, visible, recording, voiceCallActive, startRecording, stopRecording, closeBar, startVoiceCall, endVoiceCall])
399
 
400
  if (!visible) return null
401
 
@@ -428,7 +292,6 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
428
  return (
429
  <div style={barStyle}>
430
  <div style={{ flex: 1, position: 'relative' }}>
431
- {/* 左侧 + 号按钮 */}
432
  <button
433
  onClick={() => setMenuOpen((v) => !v)}
434
  style={{ ...inlineBtnLeft, color: menuOpen ? 'rgba(100, 160, 255, 0.9)' : 'rgba(255, 255, 255, 0.45)' }}
@@ -436,7 +299,6 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
436
  >
437
  <Plus size={22} />
438
  </button>
439
- {/* 弹出菜单 */}
440
  {menuOpen && (
441
  <div style={popupMenuStyle}>
442
  <button onClick={() => { setMenuOpen(false); onHistoryOpen?.() }} style={popupItemStyle}>
@@ -456,22 +318,20 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
456
  paddingRight: 80,
457
  display: 'flex',
458
  alignItems: 'center',
459
- borderColor: vadSpeaking ? 'rgba(255, 80, 80, 0.7)' : 'rgba(255, 80, 80, 0.25)',
460
  }}
461
  data-no-passthrough
462
  >
463
  <span style={{ color: text ? '#fff' : 'rgba(255, 255, 255, 0.45)', fontSize: 18, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif' }}>
464
- {text || (vadSpeaking ? t('正在听...', 'Listening...') : t('等待说话...', 'Waiting to speak...'))}
465
-
466
  </span>
467
  </div>
468
- {/* 右侧:麦克风指示 + 挂断 */}
469
  <span
470
  style={{
471
  ...inlineBtnRight,
472
  right: 44,
473
- color: vadSpeaking ? 'rgba(255, 80, 80, 0.9)' : 'rgba(80, 200, 120, 0.9)',
474
- animation: vadSpeaking ? 'claw-pulse 0.8s ease-in-out infinite' : 'none',
475
  transition: 'color 0.2s',
476
  cursor: 'default',
477
  }}
@@ -502,7 +362,6 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
502
  }}
503
  >
504
  <div style={{ flex: 1, position: 'relative' }}>
505
- {/* 左侧 + 号按钮 */}
506
  <button
507
  onClick={() => setMenuOpen((v) => !v)}
508
  style={{ ...inlineBtnLeft, color: menuOpen ? 'rgba(100, 160, 255, 0.9)' : 'rgba(255, 255, 255, 0.45)' }}
@@ -510,7 +369,6 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
510
  >
511
  <Plus size={22} />
512
  </button>
513
- {/* 弹出菜单 */}
514
  {menuOpen && (
515
  <div style={popupMenuStyle}>
516
  <button onClick={() => { setMenuOpen(false); onHistoryOpen?.() }} style={popupItemStyle}>
@@ -536,7 +394,6 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
536
  style={{ ...inputStyle, paddingLeft: 48, paddingRight: 80 }}
537
  autoFocus
538
  />
539
- {/* 右侧:发送/收起 */}
540
  <button
541
  onMouseDown={startRecording}
542
  onMouseUp={stopRecording}
@@ -669,4 +526,3 @@ const popupItemStyle: React.CSSProperties = {
669
  whiteSpace: 'nowrap',
670
  fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
671
  }
672
-
 
1
  import { useState, useRef, useCallback, useEffect } from 'react'
2
  import { MessageCircle, Send, Loader, Mic, ChevronDown, History, SquarePen, Plus, Phone, PhoneOff } from 'lucide-react'
3
  import { FRIEND_API } from '../api'
4
+ import { useServerStt, type SttProvider } from '../hooks/useServerStt'
5
 
6
  // Keyframes (claw-input-slide-up, claw-input-slide-down, claw-pulse) are in index.html <style>
7
 
8
+ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', onHistoryOpen, onNewSession, language = 'zh', sttProvider = 'browser' }: { visible?: boolean; onActiveChange?: (hasText: boolean) => void; uiAlign?: 'left' | 'right'; onHistoryOpen?: () => void; onNewSession?: () => void; language?: 'zh' | 'en'; sttProvider?: SttProvider }) {
 
 
9
  const t = (zh: string, en: string) => language === 'en' ? en : zh
10
  const [open, setOpen] = useState(false)
11
  const [text, setText] = useState('')
 
15
  const [menuOpen, setMenuOpen] = useState(false)
16
  const [voiceCallActive, setVoiceCallActive] = useState(false)
17
  const inputRef = useRef<HTMLInputElement>(null)
 
18
  const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
19
  const lastSentIndexRef = useRef(0)
20
  const voiceCallActiveRef = useRef(false)
21
+ const endVoiceCallRef = useRef<() => void>(() => {})
22
+ const serverStt = useServerStt()
23
 
24
  const closeBar = useCallback(() => {
25
  if (closing) return
 
48
  }, [text, sending])
49
 
50
  const startRecording = useCallback(async () => {
51
+ // All voice capture is server-side via cpal (no getUserMedia needed)
52
+ try {
53
+ await serverStt.startPushToTalk(sttProvider, language === 'en' ? 'en' : 'zh')
54
+ setRecording(true)
55
+ setOpen(true)
56
+ } catch (err) {
57
+ console.error('Server STT start error:', err)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  setRecording(false)
59
  }
60
+ }, [sttProvider, serverStt, language])
61
 
62
+ const stopRecording = useCallback(async () => {
63
+ if (sttProvider === 'browser') {
64
+ // Browser STT may not be available, just stop
65
  setRecording(false)
66
+ return
 
 
 
 
 
 
 
 
 
 
 
 
67
  }
68
  setRecording(false)
69
+ try {
70
+ const text = await serverStt.stopPushToTalk()
71
+ setText(text)
72
+ onActiveChange?.(text.length > 0)
73
+ } catch (err) {
74
+ console.error('Server STT stop error:', err)
75
+ }
76
+ }, [serverStt, sttProvider, onActiveChange])
77
 
 
78
  const handleMouseLeave = useCallback(() => {
79
  if (recording) stopRecording()
80
  }, [recording, stopRecording])
81
 
82
  // --- Voice Call: constants ---
83
+ const HARD_PUNCT = /[。!?\.\!\?]$/
84
+ const SOFT_PUNCT = /[,、;,;::]$/
85
+ const SOFT_PUNCT_MIN_LEN = 10
86
+ const MAX_UNSENT_LEN = 30
87
+ const SILENCE_SEND_MS = 1200
 
 
88
 
89
  // --- Voice Call: delayed TTS interrupt ---
90
  const interruptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
91
  const scheduleInterrupt = useCallback(() => {
92
+ if (interruptTimerRef.current) return
93
  interruptTimerRef.current = setTimeout(() => {
94
  interruptTimerRef.current = null
95
  ;(window as any).__clawInterruptAudio?.()
 
99
  if (interruptTimerRef.current) { clearTimeout(interruptTimerRef.current); interruptTimerRef.current = null }
100
  }, [])
101
 
 
 
 
 
 
 
 
 
 
102
  // --- Voice Call: send + mute capture for 3s after send ---
103
  const VOICE_MUTE_AFTER_SEND_MS = 2000
104
  const mutedUntilRef = useRef(0)
 
122
 
123
  // --- Voice Call: smart sentence segmentation ---
124
  const handleVoiceCallResult = useCallback((fullTranscript: string, isFinal: boolean) => {
 
125
  if (Date.now() < mutedUntilRef.current) {
126
  lastSentIndexRef.current = fullTranscript.length
127
  return
 
131
  setText(unsent)
132
  onActiveChange?.(unsent.length > 0)
133
 
 
134
  if (silenceTimerRef.current) { clearTimeout(silenceTimerRef.current); silenceTimerRef.current = null }
135
 
136
  if (isFinal) {
137
  const shouldSend =
138
+ HARD_PUNCT.test(unsent) ||
139
+ (SOFT_PUNCT.test(unsent) && unsent.length >= SOFT_PUNCT_MIN_LEN) ||
140
+ unsent.length >= MAX_UNSENT_LEN
141
 
142
  if (shouldSend) {
143
  voiceCallSend(unsent)
 
148
  }
149
  }
150
 
 
151
  if (unsent.trim()) {
152
  silenceTimerRef.current = setTimeout(() => {
153
  if (!voiceCallActiveRef.current) return
 
162
  }
163
  }, [voiceCallSend, onActiveChange])
164
 
165
+ // --- Voice Call: start (always server-side, no getUserMedia) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  const startVoiceCall = useCallback(async () => {
167
  setVoiceCallActive(true)
168
  voiceCallActiveRef.current = true
 
170
  setText('')
171
  setOpen(true)
172
 
173
+ // Server STT streaming via HTTP polling
174
+ if (sttProvider !== 'browser') {
175
+ serverStt.startStreaming(
176
+ sttProvider,
177
+ (text, isFinal) => {
178
+ handleVoiceCallResult(text, isFinal)
179
+ },
180
+ (err) => {
181
+ console.error('Server STT error:', err)
182
+ if (voiceCallActiveRef.current) {
183
+ endVoiceCallRef.current()
184
+ }
185
+ },
186
+ language === 'en' ? 'en' : 'zh',
187
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  setRecording(true)
189
+ return
190
  }
191
 
192
+ // Browser STT not available on WebKitGTK — use server STT as fallback
193
+ console.warn('Browser STT not supported, falling back to server STT')
194
+ setVoiceCallActive(false)
195
+ voiceCallActiveRef.current = false
196
+ }, [handleVoiceCallResult, sttProvider, serverStt, language])
197
 
198
  // --- Voice Call: end ---
199
  const endVoiceCall = useCallback(() => {
200
  voiceCallActiveRef.current = false
201
  setVoiceCallActive(false)
202
+ serverStt.stopStreaming()
 
 
 
 
 
 
 
 
203
  setRecording(false)
204
 
 
205
  if (silenceTimerRef.current) { clearTimeout(silenceTimerRef.current); silenceTimerRef.current = null }
206
  cancelInterrupt()
207
  mutedUntilRef.current = 0
 
209
  setText('')
210
  setOpen(false)
211
  onActiveChange?.(false)
212
+ }, [onActiveChange, cancelInterrupt, serverStt])
213
+
214
+ endVoiceCallRef.current = endVoiceCall
215
 
216
  const handleKeyDown = (e: React.KeyboardEvent) => {
217
  if (e.key === 'Enter' && !e.shiftKey) {
 
226
  setText('')
227
  onActiveChange?.(false)
228
  }
 
229
  }
230
 
231
+ // Global keyboard shortcuts
232
  useEffect(() => {
233
  const onGlobalKeyDown = (e: KeyboardEvent) => {
234
  if (!visible) return
235
  const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
236
 
 
237
  if (e.key === 'Enter' && !e.shiftKey && !inInput) {
238
  e.preventDefault()
239
  if (!open) {
 
243
  setTimeout(() => inputRef.current?.focus(), 50)
244
  }
245
 
 
246
  if (e.key === 'Escape' && open && !inInput) {
247
  e.preventDefault()
248
  closeBar()
249
  }
250
 
 
251
  if (e.key === 'F2') {
252
  e.preventDefault()
253
  if (voiceCallActive) {
 
257
  }
258
  }
259
  }
 
 
 
260
  window.addEventListener('keydown', onGlobalKeyDown)
261
+ return () => window.removeEventListener('keydown', onGlobalKeyDown)
262
+ }, [open, visible, voiceCallActive, closeBar, startVoiceCall, endVoiceCall])
 
 
 
 
263
 
264
  if (!visible) return null
265
 
 
292
  return (
293
  <div style={barStyle}>
294
  <div style={{ flex: 1, position: 'relative' }}>
 
295
  <button
296
  onClick={() => setMenuOpen((v) => !v)}
297
  style={{ ...inlineBtnLeft, color: menuOpen ? 'rgba(100, 160, 255, 0.9)' : 'rgba(255, 255, 255, 0.45)' }}
 
299
  >
300
  <Plus size={22} />
301
  </button>
 
302
  {menuOpen && (
303
  <div style={popupMenuStyle}>
304
  <button onClick={() => { setMenuOpen(false); onHistoryOpen?.() }} style={popupItemStyle}>
 
318
  paddingRight: 80,
319
  display: 'flex',
320
  alignItems: 'center',
321
+ borderColor: recording ? 'rgba(255, 80, 80, 0.7)' : 'rgba(255, 80, 80, 0.25)',
322
  }}
323
  data-no-passthrough
324
  >
325
  <span style={{ color: text ? '#fff' : 'rgba(255, 255, 255, 0.45)', fontSize: 18, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif' }}>
326
+ {text || (recording ? t('正在听...', 'Listening...') : t('等待说话...', 'Waiting to speak...'))}
 
327
  </span>
328
  </div>
 
329
  <span
330
  style={{
331
  ...inlineBtnRight,
332
  right: 44,
333
+ color: recording ? 'rgba(255, 80, 80, 0.9)' : 'rgba(80, 200, 120, 0.9)',
334
+ animation: recording ? 'claw-pulse 0.8s ease-in-out infinite' : 'none',
335
  transition: 'color 0.2s',
336
  cursor: 'default',
337
  }}
 
362
  }}
363
  >
364
  <div style={{ flex: 1, position: 'relative' }}>
 
365
  <button
366
  onClick={() => setMenuOpen((v) => !v)}
367
  style={{ ...inlineBtnLeft, color: menuOpen ? 'rgba(100, 160, 255, 0.9)' : 'rgba(255, 255, 255, 0.45)' }}
 
369
  >
370
  <Plus size={22} />
371
  </button>
 
372
  {menuOpen && (
373
  <div style={popupMenuStyle}>
374
  <button onClick={() => { setMenuOpen(false); onHistoryOpen?.() }} style={popupItemStyle}>
 
394
  style={{ ...inputStyle, paddingLeft: 48, paddingRight: 80 }}
395
  autoFocus
396
  />
 
397
  <button
398
  onMouseDown={startRecording}
399
  onMouseUp={stopRecording}
 
526
  whiteSpace: 'nowrap',
527
  fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
528
  }
 
src/components/friend/frontend/components/SettingsPanel.tsx CHANGED
@@ -12,6 +12,8 @@ interface DanceItem {
12
  builtin?: boolean
13
  }
14
 
 
 
15
  interface SettingsPanelProps {
16
  visible: boolean
17
  onClose: () => void
@@ -41,6 +43,8 @@ interface SettingsPanelProps {
41
  onLanguageChange: (v: 'zh' | 'en') => void
42
  currentDance: string
43
  onDanceChange: (id: string, preset?: DancePreset) => void
 
 
44
  }
45
 
46
  type Tab = 'general' | 'voice' | 'model' | 'persona' | 'dance'
@@ -105,6 +109,7 @@ export function SettingsPanel({
105
  captureVrmScreenshot,
106
  language, onLanguageChange,
107
  currentDance, onDanceChange,
 
108
  }: SettingsPanelProps) {
109
  const t = (zh: string, en: string) => language === 'en' ? en : zh
110
 
@@ -487,6 +492,34 @@ export function SettingsPanel({
487
  </div>
488
  )}
489
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  <div style={{ marginTop: 8 }}>
491
  <div style={labelStyle}>{currentProvider === 'qwen' ? t('千问语音', 'Qwen Voice') : t('Edge TTS 语音', 'Edge TTS Voice')}</div>
492
  <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 200, overflowY: 'auto' }}>
 
12
  builtin?: boolean
13
  }
14
 
15
+ type SttProvider = 'browser' | 'anthropic' | 'local' | 'doubao'
16
+
17
  interface SettingsPanelProps {
18
  visible: boolean
19
  onClose: () => void
 
43
  onLanguageChange: (v: 'zh' | 'en') => void
44
  currentDance: string
45
  onDanceChange: (id: string, preset?: DancePreset) => void
46
+ sttProvider?: SttProvider
47
+ onSttProviderChange?: (v: SttProvider) => void
48
  }
49
 
50
  type Tab = 'general' | 'voice' | 'model' | 'persona' | 'dance'
 
109
  captureVrmScreenshot,
110
  language, onLanguageChange,
111
  currentDance, onDanceChange,
112
+ sttProvider = 'browser', onSttProviderChange,
113
  }: SettingsPanelProps) {
114
  const t = (zh: string, en: string) => language === 'en' ? en : zh
115
 
 
492
  </div>
493
  )}
494
 
495
+ <div style={{ marginTop: 8 }}>
496
+ <div style={labelStyle}>{t('语音识别 (STT)', 'Speech Recognition (STT)')}</div>
497
+ <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
498
+ {(['browser', 'anthropic', 'local', 'doubao'] as const).map((p) => (
499
+ <button
500
+ key={p}
501
+ onClick={() => onSttProviderChange?.(p)}
502
+ style={{
503
+ ...smallBtnStyle,
504
+ flex: 1,
505
+ textAlign: 'center',
506
+ padding: '6px 10px',
507
+ fontSize: 12,
508
+ background: p === sttProvider ? 'rgba(100, 160, 255, 0.4)' : 'rgba(255, 255, 255, 0.08)',
509
+ borderColor: p === sttProvider ? 'rgba(100, 160, 255, 0.6)' : 'rgba(255, 255, 255, 0.15)',
510
+ }}
511
+ >
512
+ {{ browser: t('浏览器', 'Browser'), anthropic: 'Anthropic', local: 'Whisper', doubao: 'Doubao' }[p]}
513
+ </button>
514
+ ))}
515
+ </div>
516
+ <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', marginTop: 4 }}>
517
+ {sttProvider === 'browser'
518
+ ? t('使用浏览器内置语音识别(Web Speech API)', 'Use browser built-in speech recognition (Web Speech API)')
519
+ : t('使用服务端语音识别,需要登录对应服务', 'Use server-side speech recognition, requires corresponding service login')}
520
+ </div>
521
+ </div>
522
+
523
  <div style={{ marginTop: 8 }}>
524
  <div style={labelStyle}>{currentProvider === 'qwen' ? t('千问语音', 'Qwen Voice') : t('Edge TTS 语音', 'Edge TTS Voice')}</div>
525
  <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 200, overflowY: 'auto' }}>
src/components/friend/frontend/hooks/useServerStt.ts ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Hook for using server-side STT (Anthropic Voice Stream, Local Whisper, Doubao)
3
+ * in the Friend Tauri app.
4
+ *
5
+ * Audio capture is done server-side via cpal (in-process native addon)
6
+ * so no getUserMedia call is needed on the frontend — this avoids
7
+ * WebKitGTK permission issues on Linux.
8
+ *
9
+ * Two modes:
10
+ * - push-to-talk: POST /voice/start → capture → POST /voice/stop → get text
11
+ * - streaming: POST /voice/start → poll /voice/status → POST /voice/stop
12
+ */
13
+ import { useRef, useCallback, useState } from 'react'
14
+
15
+ const FRIEND_API_BASE = 'http://127.0.0.1:3456/plugins/friend'
16
+
17
+ export type SttProvider = 'browser' | 'anthropic' | 'local' | 'doubao'
18
+
19
+ export function useServerStt() {
20
+ const [connected, setConnected] = useState(false)
21
+
22
+ // ── Push-to-talk ──────────────────────────────────────────────────────
23
+
24
+ /** Start push-to-talk: tell backend to start audio capture via cpal. */
25
+ const startPushToTalk = useCallback(
26
+ async (_provider: SttProvider, _language: string): Promise<void> => {
27
+ const res = await fetch(`${FRIEND_API_BASE}/voice/start`, {
28
+ method: 'POST',
29
+ })
30
+ if (!res.ok) {
31
+ const err = await res.json().catch(() => ({ error: 'Unknown error' }))
32
+ throw new Error(err.error || 'STT start failed')
33
+ }
34
+ setConnected(true)
35
+ },
36
+ [],
37
+ )
38
+
39
+ /** Stop push-to-talk: stop capture and return transcript text. */
40
+ const stopPushToTalk = useCallback(async (): Promise<string> => {
41
+ const res = await fetch(`${FRIEND_API_BASE}/voice/stop`, {
42
+ method: 'POST',
43
+ })
44
+ setConnected(false)
45
+ if (!res.ok) return ''
46
+ const data = await res.json()
47
+ return data.text || ''
48
+ }, [])
49
+
50
+ // ── Streaming (voice call) ────────────────────────────────────────────
51
+
52
+ /**
53
+ * Start streaming STT for voice call mode.
54
+ * Transcripts arrive via polling /voice/status.
55
+ */
56
+ const startStreaming = useCallback(
57
+ (
58
+ _provider: SttProvider,
59
+ onTranscript: (text: string, isFinal: boolean) => void,
60
+ onError: (err: string) => void,
61
+ _language = 'zh',
62
+ ) => {
63
+ // Start capture
64
+ fetch(`${FRIEND_API_BASE}/voice/start`, { method: 'POST' })
65
+ .then((res) => {
66
+ if (!res.ok) throw new Error('STT start failed')
67
+ setConnected(true)
68
+
69
+ // Poll for interim results every 500ms
70
+ const pollId = setInterval(async () => {
71
+ try {
72
+ const statusRes = await fetch(`${FRIEND_API_BASE}/voice/status`, {
73
+ method: 'POST',
74
+ })
75
+ if (!statusRes.ok) return
76
+ const status = await statusRes.json()
77
+ if (status.interimText) {
78
+ onTranscript(status.interimText, false)
79
+ }
80
+ } catch {
81
+ // poll failed, ignore
82
+ }
83
+ }, 500)
84
+
85
+ // Store poll ID for cleanup
86
+ ;(window as any).__friendSttPollId = pollId
87
+ })
88
+ .catch((err) => {
89
+ onError(err.message)
90
+ })
91
+ },
92
+ [],
93
+ )
94
+
95
+ /** Stop streaming STT. */
96
+ const stopStreaming = useCallback(() => {
97
+ // Stop polling
98
+ const pollId = (window as any).__friendSttPollId
99
+ if (pollId) {
100
+ clearInterval(pollId)
101
+ delete (window as any).__friendSttPollId
102
+ }
103
+
104
+ // Stop capture
105
+ fetch(`${FRIEND_API_BASE}/voice/stop`, { method: 'POST' })
106
+ .catch(() => {})
107
+ .finally(() => {
108
+ setConnected(false)
109
+ })
110
+ }, [])
111
+
112
+ return {
113
+ connected,
114
+ startPushToTalk,
115
+ stopPushToTalk,
116
+ startStreaming,
117
+ stopStreaming,
118
+ }
119
+ }
src/friend/FriendService.ts ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * FriendService — in-process VRM companion brain service.
3
+ *
4
+ * Mirror of FeishuService pattern:
5
+ * - Singleton, runs in the main CLI process
6
+ * - subscribe()/subscribeToInbound() for React external store sync
7
+ * - enqueue() with origin tracking for message submission
8
+ * - In-process audio capture via cpal (src/services/voice.ts)
9
+ * - SSE broadcast for VRM display commands
10
+ *
11
+ * Eliminates the need for:
12
+ * - A separate background server subprocess (port 3456)
13
+ * - A CLI SDK subprocess (conversationService.startSession)
14
+ * - Server-side arecord/parecord audio capture
15
+ */
16
+
17
+ import { broadcastToVrm, type VrmBroadcastPayload } from './sse.js';
18
+ import { getPrefs } from './prefs.js';
19
+ import { stripForTts } from './text-utils.js';
20
+ import { edgeTts, qwenTts, registerAudioFile } from './tts.js';
21
+ import { splitSentences } from './text-utils.js';
22
+
23
+ // ── Types ──────────────────────────────────────────────────────────────
24
+
25
+ export type FriendServiceState = {
26
+ status: 'stopped' | 'starting' | 'running' | 'error';
27
+ lastError?: string;
28
+ /** Number of active SSE display clients */
29
+ displayClientCount?: number;
30
+ /** Current capture status (for voice call interim polling) */
31
+ captureStatus?: { capturing: boolean; interimText?: string };
32
+ };
33
+
34
+ type Listener = () => void;
35
+
36
+ /** Inbound event for bridge hook consumption */
37
+ export type FriendInboundEvent = {
38
+ text: string;
39
+ };
40
+
41
+ type InboundListener = (event: FriendInboundEvent) => void;
42
+
43
+ // ── Audio capture types (cpal wrapper) ─────────────────────────────────
44
+
45
+ type AudioCaptureProvider = {
46
+ startRecording(
47
+ onData: (chunk: Buffer) => void,
48
+ onEnd: () => void,
49
+ ): Promise<boolean>;
50
+ stopRecording(): Promise<void>;
51
+ isRecording(): boolean;
52
+ };
53
+
54
+ // ── Service implementation ─────────────────────────────────────────────
55
+
56
+ class FriendService {
57
+ private listeners = new Set<Listener>();
58
+ private inboundListeners = new Set<InboundListener>();
59
+ private state: FriendServiceState = { status: 'stopped' };
60
+ /** Audio capture in progress? */
61
+ private capturing = false;
62
+ /** Accumulated STT text chunks during capture */
63
+ private captureTranscripts: string[] = [];
64
+ /** Interim (non-final) text during active capture */
65
+ private captureInterimText = '';
66
+ /** Resolver for the current stopVoiceCapture() call */
67
+ private captureResolver: ((text: string) => void) | null = null;
68
+ /** Lazy-loaded cpal audio capture module */
69
+ private audioCapture: AudioCaptureProvider | null = null;
70
+ /** Active STT connection (Anthropic/Doubao/Whisper) during capture */
71
+ private sttConnection: { send: (chunk: Buffer) => void; finalize: () => Promise<void>; close: () => void } | null = null;
72
+
73
+ // ── React sync external store interface ──────────────────────────────
74
+
75
+ subscribe(listener: Listener): () => void {
76
+ this.listeners.add(listener);
77
+ return () => this.listeners.delete(listener);
78
+ }
79
+
80
+ subscribeToInbound(listener: InboundListener): () => void {
81
+ this.inboundListeners.add(listener);
82
+ return () => this.inboundListeners.delete(listener);
83
+ }
84
+
85
+ getStateSnapshot(): FriendServiceState {
86
+ return this.state;
87
+ }
88
+
89
+ // ── Lifecycle ────────────────────────────────────────────────────────
90
+
91
+ async start(): Promise<void> {
92
+ if (this.state.status === 'running') return;
93
+
94
+ this.setState({ status: 'starting', lastError: undefined });
95
+
96
+ try {
97
+ // Pre-warm cpal audio module (loaded on first use)
98
+ // The voice service lazy-loads audio-capture-napi, so the first
99
+ // capture will incur the ~1s dlopen penalty regardless.
100
+
101
+ this.setState({ status: 'running' });
102
+ } catch (err) {
103
+ const msg = err instanceof Error ? err.message : String(err);
104
+ this.setState({ status: 'error', lastError: msg });
105
+ throw err;
106
+ }
107
+ }
108
+
109
+ async stop(): Promise<void> {
110
+ // Stop any active capture
111
+ if (this.capturing) {
112
+ await this.stopVoiceCapture().catch(() => {});
113
+ }
114
+
115
+ this.audioCapture = null;
116
+ this.sttConnection = null;
117
+
118
+ if (this.state.status !== 'stopped') {
119
+ this.setState({ status: 'stopped' });
120
+ }
121
+ }
122
+
123
+ // ── Text relay via messageQueueManager.enqueue() ─────────────────────
124
+
125
+ /**
126
+ * Send text through the main CLI conversation.
127
+ * Mirrors FeishuService's enqueue() pattern with origin tracking.
128
+ */
129
+ sendText(text: string): void {
130
+ const trimmed = text.trim();
131
+ if (!trimmed) return;
132
+
133
+ // Notify inbound listeners (bridge hook uses this for turn tracking)
134
+ for (const listener of this.inboundListeners) {
135
+ listener({ text: trimmed });
136
+ }
137
+
138
+ // Dynamically import enqueue to avoid circular deps
139
+ import('../utils/messageQueueManager.js').then(({ enqueue }) => {
140
+ enqueue({
141
+ value: trimmed,
142
+ mode: 'prompt',
143
+ skipSlashCommands: true,
144
+ bridgeOrigin: true,
145
+ origin: { kind: 'channel', server: 'friend' },
146
+ });
147
+ }).catch((err) => {
148
+ console.error('[FriendService] enqueue failed:', err);
149
+ });
150
+ }
151
+
152
+ // ── Voice capture ────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Start in-process voice capture using cpal.
156
+ * Audio is forwarded to the configured STT provider.
157
+ * Returns when capture-started confirmation is received.
158
+ */
159
+ async startVoiceCapture(): Promise<void> {
160
+ if (this.capturing) return;
161
+
162
+ const prefs = getPrefs();
163
+ const provider = prefs.sttProvider || 'browser';
164
+ const language = prefs.sttLanguage || 'zh';
165
+
166
+ this.captureTranscripts = [];
167
+ this.captureInterimText = '';
168
+ this.capturing = true;
169
+
170
+ try {
171
+ // 1. Start STT provider connection
172
+ const conn = await this.startSttConnection(provider, language);
173
+ this.sttConnection = conn;
174
+
175
+ // 2. Load audio capture module (cpal)
176
+ const audio = await this.loadAudioCapture();
177
+
178
+ // 3. Start cpal recording — chunks go to STT
179
+ const ok = await audio.startRecording(
180
+ (chunk: Buffer) => {
181
+ this.sttConnection?.send(chunk);
182
+ },
183
+ () => {
184
+ // Capture ended (user stop or silence detection)
185
+ },
186
+ );
187
+
188
+ if (!ok) {
189
+ // Fallback: try arecord as subprocess
190
+ this.capturing = false;
191
+ throw new Error('Native audio capture unavailable');
192
+ }
193
+ } catch (err) {
194
+ this.capturing = false;
195
+ this.sttConnection?.close();
196
+ this.sttConnection = null;
197
+ throw err;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Stop voice capture and return the accumulated transcript.
203
+ */
204
+ async stopVoiceCapture(): Promise<string> {
205
+ if (!this.capturing) return '';
206
+
207
+ this.capturing = false;
208
+
209
+ // Stop cpal recording
210
+ if (this.audioCapture) {
211
+ await this.audioCapture.stopRecording().catch(() => {});
212
+ }
213
+
214
+ // Finalize STT connection
215
+ const conn = this.sttConnection;
216
+ this.sttConnection = null;
217
+
218
+ if (conn) {
219
+ try {
220
+ await conn.finalize();
221
+ conn.close();
222
+ } catch {
223
+ // ignore finalization errors
224
+ }
225
+ }
226
+
227
+ const transcript = this.captureTranscripts.join('');
228
+ this.captureTranscripts = [];
229
+ this.captureInterimText = '';
230
+ this.setState({ captureStatus: { capturing: false } });
231
+ return transcript;
232
+ }
233
+
234
+ /**
235
+ * Get current capture status (for voice call interim polling).
236
+ */
237
+ getCaptureStatus(): { capturing: boolean; interimText?: string } {
238
+ return this.state.captureStatus ?? { capturing: false };
239
+ }
240
+
241
+ // ── Response broadcast (called by useFriendBridge) ───────────────────
242
+
243
+ /**
244
+ * Broadcast AI response to the VRM display layer via SSE.
245
+ * Generates TTS audio for completed sentences and sends
246
+ * emotion/action commands alongside text.
247
+ */
248
+ async broadcastResponse(text: string): Promise<void> {
249
+ if (!text.trim()) return;
250
+
251
+ const prefs = getPrefs();
252
+
253
+ // Send the text — the frontend TextBubble splits and displays it
254
+ broadcastToVrm({ text });
255
+
256
+ // Generate TTS for the full response if enabled
257
+ if (prefs.ttsEnabled) {
258
+ try {
259
+ const audioUrl = await this.generateTts(text);
260
+ if (audioUrl) {
261
+ broadcastToVrm({ audioUrl, sendFirstTts: true });
262
+ }
263
+ } catch (err) {
264
+ console.warn('[FriendService] TTS generation failed:', err);
265
+ }
266
+ }
267
+
268
+ // Signal reply done
269
+ broadcastToVrm({ replyDone: true });
270
+ }
271
+
272
+ /**
273
+ * Broadcast VRM emotion/action command.
274
+ */
275
+ broadcastVrm(payload: VrmBroadcastPayload): void {
276
+ broadcastToVrm(payload);
277
+ }
278
+
279
+ // ── Private: STT connection factory ──────────────────────────────────
280
+
281
+ private async startSttConnection(
282
+ provider: string,
283
+ language: string,
284
+ ): Promise<{ send: (chunk: Buffer) => void; finalize: () => Promise<void>; close: () => void }> {
285
+ const callbacks = {
286
+ onTranscript: (text: string, isFinal: boolean) => {
287
+ if (isFinal) {
288
+ this.captureTranscripts.push(text);
289
+ this.captureInterimText = '';
290
+ } else {
291
+ this.captureInterimText = text;
292
+ }
293
+ // Update state for status polling
294
+ this.setState({
295
+ captureStatus: { capturing: true, interimText: this.captureInterimText },
296
+ });
297
+ },
298
+ onError: (_error: string) => {},
299
+ onClose: () => {},
300
+ onReady: (_conn: any) => {},
301
+ };
302
+
303
+ switch (provider) {
304
+ case 'anthropic': {
305
+ const { connectVoiceStream, isVoiceStreamAvailable } = await import(
306
+ '../services/voiceStreamSTT.js'
307
+ );
308
+ if (!isVoiceStreamAvailable()) {
309
+ throw new Error('Anthropic Voice Stream not available');
310
+ }
311
+ return await connectVoiceStream(callbacks, { language, keyterms: ['code', 'versperclaw'] });
312
+ }
313
+
314
+ case 'local': {
315
+ const { connectLocalWhisperStream, preloadWhisperModel } = await import(
316
+ '../services/voice/whisperSTT.js'
317
+ );
318
+ await preloadWhisperModel({ language });
319
+ return await connectLocalWhisperStream(callbacks, { language });
320
+ }
321
+
322
+ case 'doubao': {
323
+ const { connectDoubaoStream } = await import('../services/doubaoSTT.js');
324
+ return await connectDoubaoStream(callbacks, { language: language || 'zh' });
325
+ }
326
+
327
+ default:
328
+ throw new Error(`Unknown STT provider: ${provider}`);
329
+ }
330
+ }
331
+
332
+ // ── Private: Audio capture (cpal wrapper) ────────────────────────────
333
+
334
+ private async loadAudioCapture(): Promise<AudioCaptureProvider> {
335
+ if (this.audioCapture) return this.audioCapture;
336
+
337
+ // Try native cpal module first
338
+ try {
339
+ const mod = await import('audio-capture-napi').catch(() => null);
340
+ if (mod && typeof mod.startNativeRecording === 'function') {
341
+ this.audioCapture = {
342
+ startRecording: async (onData, onEnd) => {
343
+ try {
344
+ return mod.startNativeRecording(
345
+ (data: Buffer) => onData(data),
346
+ () => onEnd(),
347
+ ) as boolean;
348
+ } catch {
349
+ return false;
350
+ }
351
+ },
352
+ stopRecording: async () => {
353
+ if (mod.isNativeRecordingActive()) {
354
+ mod.stopNativeRecording();
355
+ }
356
+ },
357
+ isRecording: () => mod.isNativeRecordingActive() as boolean,
358
+ };
359
+ return this.audioCapture;
360
+ }
361
+ } catch {
362
+ // cpal unavailable, fall through
363
+ }
364
+
365
+ // Fallback: spawn arecord/parecord as subprocess
366
+ const { spawn } = await import('node:child_process');
367
+ let captureProc: import('node:child_process').ChildProcess | null = null;
368
+
369
+ this.audioCapture = {
370
+ startRecording: async (onData, _onEnd) => {
371
+ for (const tool of ['parecord', 'arecord']) {
372
+ try {
373
+ const args = tool === 'parecord'
374
+ ? ['--raw', '--rate=16000', '--format=s16le', '--channels=1', '--latency-msec=20']
375
+ : ['-r', '16000', '-f', 'S16_LE', '-c', '1', '-t', 'raw', '-q', '-'];
376
+ const proc = spawn(tool, args, { stdio: ['pipe', 'pipe', 'pipe'] });
377
+ if (proc.pid !== undefined) {
378
+ captureProc = proc;
379
+ proc.stdout?.on('data', onData);
380
+ proc.on('exit', () => { captureProc = null; });
381
+ return true;
382
+ }
383
+ } catch {
384
+ continue;
385
+ }
386
+ }
387
+ return false;
388
+ },
389
+ stopRecording: async () => {
390
+ if (captureProc) {
391
+ captureProc.kill('SIGTERM');
392
+ setTimeout(() => {
393
+ try { captureProc?.kill('SIGKILL'); } catch {}
394
+ }, 2000);
395
+ captureProc = null;
396
+ }
397
+ },
398
+ isRecording: () => captureProc !== null,
399
+ };
400
+
401
+ return this.audioCapture;
402
+ }
403
+
404
+ // ── Private: TTS generation ──────────────────────────────────────────
405
+
406
+ private async generateTts(text: string): Promise<string | undefined> {
407
+ const prefs = getPrefs();
408
+ if (!prefs.ttsEnabled) return undefined;
409
+
410
+ const cleanText = stripForTts(text);
411
+ if (!cleanText) return undefined;
412
+
413
+ let result: { success: boolean; audioPath?: string; error?: string };
414
+ if (prefs.provider === 'qwen' && prefs.qwenKey) {
415
+ result = await qwenTts({
416
+ text: cleanText,
417
+ apiKey: prefs.qwenKey,
418
+ voice: prefs.voice,
419
+ model: prefs.qwenModel,
420
+ language: prefs.language,
421
+ });
422
+ } else {
423
+ result = await edgeTts({ text: cleanText, voice: prefs.voice });
424
+ }
425
+
426
+ if (result.success && result.audioPath) {
427
+ return registerAudioFile(result.audioPath);
428
+ }
429
+
430
+ return undefined;
431
+ }
432
+
433
+ // ── Private: state management ────────────────────────────────────────
434
+
435
+ private setState(next: Partial<FriendServiceState>): void {
436
+ this.state = { ...this.state, ...next };
437
+ for (const listener of this.listeners) listener();
438
+ }
439
+ }
440
+
441
+ // Singleton
442
+ export const friendService = new FriendService();
src/friend/chat-service.ts DELETED
@@ -1,360 +0,0 @@
1
- /**
2
- * FriendChatService — LLM conversation dispatch for Friend VRM desktop pet.
3
- *
4
- * Manages a dedicated VersperClaw CLI subprocess session (via ConversationService),
5
- * captures streaming SDK output, and broadcasts text/TTS through SSE to the
6
- * VRM frontend using the StreamingTtsTracker pattern.
7
- */
8
- import path from 'node:path';
9
- import { mkdirSync } from 'node:fs';
10
- import { conversationService } from '../server/services/conversationService.js';
11
- import { broadcastToVrm, type VrmBroadcastPayload } from './sse.js';
12
- import { edgeTts, qwenTts, registerAudioFile } from './tts.js';
13
- import { stripForTts, splitSentences } from './text-utils.js';
14
- import { getPrefs } from './prefs.js';
15
-
16
- const SESSION_ID = 'friend';
17
-
18
- // ── StreamingTtsTracker ──────────────────────────────────────────────────────
19
- //
20
- // Adapted from friend/src/channel.ts (OpenClaw version). Receives accumulated
21
- // text, detects sentence boundaries, generates TTS for completed sentences, and
22
- // broadcasts via the VRM SSE pipeline in the correct order.
23
-
24
- class StreamingTtsTracker {
25
- private sentencesSent = 0;
26
- private audioDispatched = 0;
27
- private accumulatedText = '';
28
- private resolveFirstSent!: () => void;
29
- private firstSentPromise = new Promise<void>((r) => { this.resolveFirstSent = r; });
30
- private finalized = false;
31
-
32
- private static readonly FIRST_TTS_TIMEOUT_MS = 5000;
33
- private static readonly SENTENCE_END_RE = /[。!?;!?;~]$/;
34
-
35
- constructor(
36
- private onSendFirstTts: (text: string, audioUrl: string | undefined) => void,
37
- private onAppendSentence: (text: string, audioUrl: string | undefined, index: number) => void,
38
- private onReplyDone: () => void,
39
- ) {}
40
-
41
- /** Feed accumulated streaming text. May be called many times as text arrives. */
42
- processPartial(partialText: string): void {
43
- if (this.finalized) return;
44
- this.accumulatedText = partialText;
45
- if (this.audioDispatched > 0) return;
46
-
47
- const sentences = splitSentences(partialText);
48
- if (sentences.length === 0) return;
49
-
50
- // Consider the first N-1 sentences as complete if the last sentence is
51
- // still incomplete (no sentence-ending punctuation).
52
- const lastComplete =
53
- sentences.length > 1
54
- ? sentences.length - 1
55
- : StreamingTtsTracker.SENTENCE_END_RE.test(sentences[0])
56
- ? 1
57
- : 0;
58
-
59
- if (lastComplete === 0) return;
60
-
61
- const first = sentences[0];
62
- const cleaned = stripForTts(first);
63
- if (!cleaned) return;
64
-
65
- this.audioDispatched++;
66
- this.dispatchFirstTts(cleaned);
67
- }
68
-
69
- /** Signal that the full text is available (streaming ended). */
70
- processFinal(): void {
71
- if (this.finalized) return;
72
- this.finalized = true;
73
-
74
- const sentences = splitSentences(this.accumulatedText);
75
- const newSentences = sentences.slice(this.sentencesSent);
76
- this.sentencesSent = sentences.length;
77
-
78
- const ttsSentences = newSentences.filter((s) => stripForTts(s).length > 0);
79
-
80
- // Edge case: no sentences were dispatched during streaming
81
- if (this.audioDispatched === 0) {
82
- if (ttsSentences.length === 0) {
83
- // No TTS-worthy content — still show the raw text
84
- this.onSendFirstTts(this.accumulatedText || '', undefined);
85
- this.resolveFirstSent();
86
- this.firstSentPromise.then(() => this.onReplyDone());
87
- return;
88
- }
89
- this.audioDispatched++;
90
- this.dispatchFirstTts(ttsSentences[0]);
91
- ttsSentences.shift();
92
- }
93
-
94
- if (ttsSentences.length === 0) {
95
- this.firstSentPromise.then(() => this.onReplyDone());
96
- return;
97
- }
98
-
99
- // Generate TTS for all remaining sentences concurrently,
100
- // but deliver them sequentially (ordered via promise chain).
101
- const ttsPromises = ttsSentences.map((s) => this.generateTtsUrl(s));
102
- let chain = this.firstSentPromise;
103
- for (let i = 0; i < ttsSentences.length; i++) {
104
- const sentence = ttsSentences[i];
105
- const idx = this.sentencesSent - ttsSentences.length + i;
106
- const p = ttsPromises[i];
107
- chain = chain.then(() => p).then((audioUrl) => {
108
- this.onAppendSentence(sentence, audioUrl, idx);
109
- });
110
- }
111
- chain.then(() => this.onReplyDone());
112
- }
113
-
114
- private async dispatchFirstTts(sentence: string): Promise<void> {
115
- const ttsPromise = this.generateTtsUrl(sentence);
116
- const audioUrl = await Promise.race([
117
- ttsPromise,
118
- new Promise<undefined>((r) =>
119
- setTimeout(r, StreamingTtsTracker.FIRST_TTS_TIMEOUT_MS),
120
- ),
121
- ]);
122
- this.onSendFirstTts(sentence, audioUrl);
123
- this.resolveFirstSent();
124
- }
125
-
126
- private async generateTtsUrl(text: string): Promise<string | undefined> {
127
- try {
128
- const prefs = getPrefs();
129
- if (!prefs.ttsEnabled) return undefined;
130
-
131
- const cleanText = stripForTts(text);
132
- if (!cleanText) return undefined;
133
-
134
- let result: { success: boolean; audioPath?: string; error?: string };
135
- if (prefs.provider === 'qwen' && prefs.qwenKey) {
136
- result = await qwenTts({
137
- text: cleanText,
138
- apiKey: prefs.qwenKey,
139
- voice: prefs.voice,
140
- model: prefs.qwenModel,
141
- language: prefs.language,
142
- });
143
- } else {
144
- result = await edgeTts({ text: cleanText, voice: prefs.voice });
145
- }
146
-
147
- if (result.success && result.audioPath) {
148
- return registerAudioFile(result.audioPath);
149
- }
150
- return undefined;
151
- } catch {
152
- return undefined;
153
- }
154
- }
155
- }
156
-
157
- // ── FriendChatService ────────────────────────────────────────────────────────
158
-
159
- export class FriendChatService {
160
- private sessionStarted = false;
161
- private outputRegistered = false;
162
-
163
- /** Accumulated text for the current conversational turn. */
164
- private accumulatedText = '';
165
- /** Non-null between sendMessage() and the conclusion of that turn. */
166
- private currentTracker: StreamingTtsTracker | null = null;
167
- /** True while the current user message is being processed by the CLI. */
168
- private turnActive = false;
169
- /** True if at least one text_delta was received this turn. */
170
- private hasTextContent = false;
171
-
172
- private replyDoneTimer: ReturnType<typeof setTimeout> | null = null;
173
- private readonly REPLY_DONE_DELAY_MS = 1500;
174
-
175
- // ── Lifecycle ────────────────────────────────────────────────────────────
176
-
177
- /**
178
- * Ensure a ConversationService session exists for Friend.
179
- * Safe to call multiple times — only starts once.
180
- */
181
- async ensureSession(serverHost: string, serverPort: number): Promise<void> {
182
- if (this.sessionStarted) return;
183
-
184
- const homeDir = process.env.HOME || process.env.USERPROFILE || '';
185
- const workDir = path.join(homeDir, '.config', 'VersperClaw', 'friend');
186
- mkdirSync(workDir, { recursive: true });
187
-
188
- const sdkUrl =
189
- `ws://${serverHost}:${serverPort}/sdk/${SESSION_ID}` +
190
- `?token=${crypto.randomUUID()}`;
191
-
192
- if (!conversationService.hasSession(SESSION_ID)) {
193
- try {
194
- await conversationService.startSession(SESSION_ID, workDir, sdkUrl);
195
- } catch (err) {
196
- console.warn(`[FriendChat] session start failed: ${err}`);
197
- throw err;
198
- }
199
- }
200
-
201
- this.sessionStarted = true;
202
- this.registerOutputCallback();
203
- }
204
-
205
- /** Tear down the Friend session. */
206
- stop(): void {
207
- if (this.replyDoneTimer) clearTimeout(this.replyDoneTimer);
208
- conversationService.stopSession(SESSION_ID);
209
- this.sessionStarted = false;
210
- this.turnActive = false;
211
- }
212
-
213
- // ── Send ─────────────────────────────────────────────────────────────────
214
-
215
- /**
216
- * Send a chat message through the CLI subprocess. Returns immediately;
217
- * the response is streamed asynchronously via SSE.
218
- */
219
- sendMessage(text: string): void {
220
- // Reset per-turn state
221
- this.accumulatedText = '';
222
- this.hasTextContent = false;
223
- this.turnActive = true;
224
-
225
- // Show thinking emotion immediately
226
- broadcastToVrm({ emotion: 'think', emotionIntensity: 0.7 });
227
-
228
- // Create a fresh tracker for this turn
229
- this.currentTracker = new StreamingTtsTracker(
230
- (t, audioUrl) => {
231
- const payload: VrmBroadcastPayload = { text: t, sendFirstTts: true };
232
- if (audioUrl) {
233
- payload.audioUrl = audioUrl;
234
- payload.audioIndex = 0;
235
- }
236
- broadcastToVrm(payload);
237
- },
238
- (t, audioUrl, index) => {
239
- const payload: VrmBroadcastPayload = { text: t, appendText: true, audioIndex: index };
240
- if (audioUrl) payload.audioUrl = audioUrl;
241
- broadcastToVrm(payload);
242
- },
243
- () => {
244
- broadcastToVrm({ replyDone: true });
245
- this.turnActive = false;
246
- },
247
- );
248
-
249
- const sent = conversationService.sendMessage(SESSION_ID, text);
250
- if (!sent) {
251
- console.warn('[FriendChat] CLI session not running, cannot send message');
252
- broadcastToVrm({ text: 'The companion is not available right now.' });
253
- broadcastToVrm({ replyDone: true });
254
- this.turnActive = false;
255
- }
256
- }
257
-
258
- /** Send a /new command to clear conversation context. */
259
- clearContext(): void {
260
- this.accumulatedText = '';
261
- this.hasTextContent = false;
262
- conversationService.sendMessage(SESSION_ID, '/new');
263
- }
264
-
265
- /** Send a memo message (user note appended without LLM reply). */
266
- sendMemo(text: string): void {
267
- // Append as a user message without expecting a reply.
268
- // Use the conversation message format.
269
- conversationService.sendMessage(SESSION_ID, text);
270
- }
271
-
272
- // ── SDK output handling ──────────────────────────────────────────���───────
273
-
274
- private registerOutputCallback(): void {
275
- if (this.outputRegistered) return;
276
- this.outputRegistered = true;
277
-
278
- // Clear any stale callbacks first — prevents accumulation on restart.
279
- conversationService.clearOutputCallbacks(SESSION_ID);
280
- conversationService.onOutput(SESSION_ID, (msg: any) => {
281
- this.handleSdkMessage(msg);
282
- });
283
- }
284
-
285
- private handleSdkMessage(msg: any): void {
286
- // ── Auto-grant all tool permissions for Friend ──────────────────────
287
- if (msg?.type === 'control_request' && msg.request?.subtype === 'can_use_tool') {
288
- conversationService.respondToPermission(msg.request_id, true, 'always');
289
- return;
290
- }
291
-
292
- // ── Streaming text ──────────────────────────────────────────────────
293
- if (msg.type === 'stream_event') {
294
- const event = msg.event;
295
- if (!event) return;
296
-
297
- // Accumulate text deltas and feed to the tracker
298
- if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta' && event.delta.text) {
299
- this.accumulatedText += event.delta.text;
300
- this.hasTextContent = true;
301
- this.currentTracker?.processPartial(this.accumulatedText);
302
- this.debounceReplyDone();
303
- return;
304
- }
305
-
306
- // An assistant message section ended — if we have accumulated text,
307
- // signal the tracker to process remaining sentences.
308
- if (event.type === 'message_stop' && this.hasTextContent) {
309
- this.currentTracker?.processFinal();
310
- return;
311
- }
312
-
313
- return;
314
- }
315
-
316
- // ── Complete assistant message (fallback when no streaming) ─────────
317
- if (msg.type === 'assistant' && msg.message?.content && !this.hasTextContent) {
318
- for (const block of msg.message.content) {
319
- if (block.type === 'text' && block.text) {
320
- this.accumulatedText = block.text;
321
- this.hasTextContent = true;
322
- this.currentTracker?.processFinal();
323
- break;
324
- }
325
- }
326
- return;
327
- }
328
-
329
- // ── result / error handling ─────────────────────────────────────────
330
- if (msg.type === 'result' && msg.is_error) {
331
- console.warn(`[FriendChat] SDK error: ${msg.result ?? 'unknown error'}`);
332
- if (this.turnActive) {
333
- broadcastToVrm({
334
- text: `Error: ${msg.result ?? 'Something went wrong'}`,
335
- appendText: true,
336
- audioIndex: 99,
337
- });
338
- broadcastToVrm({ replyDone: true });
339
- this.turnActive = false;
340
- }
341
- }
342
- }
343
-
344
- /**
345
- * Debounce `replyDone` broadcast. Each new text delta resets the timer.
346
- * This prevents premature turn completion when the CLI is generating
347
- * tool-related follow-up text.
348
- */
349
- private debounceReplyDone(): void {
350
- if (this.replyDoneTimer) clearTimeout(this.replyDoneTimer);
351
- this.replyDoneTimer = setTimeout(() => {
352
- if (this.currentTracker && this.hasTextContent) {
353
- this.currentTracker.processFinal();
354
- }
355
- }, this.REPLY_DONE_DELAY_MS);
356
- }
357
- }
358
-
359
- // Singleton
360
- export const friendChatService = new FriendChatService();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/friend/prefs.ts CHANGED
@@ -22,6 +22,10 @@ export interface FriendPrefs {
22
  language?: 'zh' | 'en';
23
  currentDance?: string;
24
  hideMood?: boolean;
 
 
 
 
25
  }
26
 
27
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
 
22
  language?: 'zh' | 'en';
23
  currentDance?: string;
24
  hideMood?: boolean;
25
+ /** STT provider: browser | anthropic | local | doubao */
26
+ sttProvider?: 'browser' | 'anthropic' | 'local' | 'doubao';
27
+ /** STT language override (e.g. 'en', 'zh', 'ja') */
28
+ sttLanguage?: string;
29
  }
30
 
31
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
src/friend/server.ts ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Friend HTTP server — lightweight Bun.serve for VRM frontend.
3
+ *
4
+ * Runs in the main CLI process alongside the Ink TUI.
5
+ * Serves the friend frontend static files, API routes, and SSE.
6
+ *
7
+ * Architecture:
8
+ * - No WebSocket (frontend uses SSE for server→client, HTTP for client→server)
9
+ * - No separate CLI SDK session (uses FriendService → enqueue() → main CLI)
10
+ * - No arecord/parecord subprocess (uses cpal in-process)
11
+ */
12
+
13
+ import { createSseResponse } from './sse.js';
14
+ import { handleFriendStaticRequest } from '../server/staticFriend.js';
15
+ import { handleFriendApi } from '../server/api/friend.js';
16
+
17
+ let server: ReturnType<typeof Bun.serve> | null = null;
18
+ let serverPort = 3456;
19
+
20
+ export function getServerPort(): number {
21
+ return serverPort;
22
+ }
23
+
24
+ /**
25
+ * Try to kill any existing process listening on the given port.
26
+ * Returns true if the port became free.
27
+ */
28
+ function freePort(port: number): boolean {
29
+ try {
30
+ // Find PID on the port
31
+ const ss = Bun.spawnSync(['ss', '-tlnp', 'sport', `= :${port}`]);
32
+ const out = ss.stdout.toString();
33
+ const pidMatch = out.match(/pid=(\d+)/);
34
+ if (!pidMatch) return true; // port already free
35
+
36
+ const pid = parseInt(pidMatch[1]!, 10);
37
+ if (pid === process.pid) return true; // we own it
38
+
39
+ // Only kill bun/VersperClaw processes — don't touch unknown services
40
+ const proc = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'comm=']);
41
+ const comm = proc.stdout.toString().trim();
42
+ if (!comm) return false; // process doesn't exist
43
+ const baseName = comm.split('/').pop() || comm;
44
+ if (baseName !== 'bun' && baseName !== 'VersperClaw' && !baseName.startsWith('claude-') && !baseName.includes('node')) {
45
+ console.warn(`[FriendServer] Port ${port} is occupied by non-VersperClaw process: ${comm}`);
46
+ return false;
47
+ }
48
+
49
+ // Send SIGTERM politely
50
+ process.kill(pid, 'SIGTERM');
51
+ // Wait up to 3s for it to die
52
+ for (let i = 0; i < 30; i++) {
53
+ Bun.sleepSync(100);
54
+ try { process.kill(pid, 0); } catch { return true; } // dead
55
+ }
56
+ // Force kill
57
+ try { process.kill(pid, 'SIGKILL'); } catch { /* */ }
58
+ Bun.sleepSync(200);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Start the friend HTTP server in-process.
67
+ * Safe to call multiple times — no-op if already running.
68
+ * If the port is already in use, attempts to free it first.
69
+ */
70
+ export function startFriendServer(port = 3456, host = '127.0.0.1'): ReturnType<typeof Bun.serve> {
71
+ if (server) return server;
72
+
73
+ serverPort = port;
74
+
75
+ // If port is in use, try to free it
76
+ const check = Bun.spawnSync(['ss', '-tlnp', 'sport', `= :${port}`]);
77
+ if (check.stdout.toString().includes('LISTEN')) {
78
+ console.log(`[FriendServer] Port ${port} is in use, attempting to free it...`);
79
+ if (!freePort(port)) {
80
+ console.warn(`[FriendServer] Could not free port ${port}. Please stop the existing server manually.`);
81
+ throw new Error(`Port ${port} is already in use`);
82
+ }
83
+ console.log(`[FriendServer] Port ${port} freed successfully.`);
84
+ }
85
+
86
+ server = Bun.serve<undefined>({
87
+ port,
88
+ hostname: host,
89
+ async fetch(req) {
90
+ const url = new URL(req.url);
91
+
92
+ // CORS preflight
93
+ if (req.method === 'OPTIONS') {
94
+ return new Response(null, {
95
+ status: 204,
96
+ headers: {
97
+ 'Access-Control-Allow-Origin': '*',
98
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
99
+ 'Access-Control-Allow-Headers': 'Content-Type',
100
+ },
101
+ });
102
+ }
103
+
104
+ // SSE events (GET /plugins/friend/events)
105
+ if (url.pathname === '/plugins/friend/events' && req.method === 'GET') {
106
+ return createSseResponse();
107
+ }
108
+
109
+ // Friend API routes (/plugins/friend/*)
110
+ if (url.pathname.startsWith('/plugins/friend/')) {
111
+ return handleFriendApi(req, url);
112
+ }
113
+
114
+ // WebSocket upgrade — not needed for friend (uses SSE + HTTP)
115
+ // but handle gracefully
116
+ if (req.headers.get('upgrade')?.toLowerCase() === 'websocket') {
117
+ return new Response('WebSocket not supported via friend server', { status: 426 });
118
+ }
119
+
120
+ // Static files (/friend/*)
121
+ if (url.pathname.startsWith('/friend/') || url.pathname === '/friend') {
122
+ const staticResponse = await handleFriendStaticRequest(req, url);
123
+ if (staticResponse) return staticResponse;
124
+ }
125
+
126
+ return new Response('Not Found', { status: 404 });
127
+ },
128
+ error(err) {
129
+ console.error('[FriendServer] Error:', err);
130
+ return new Response('Internal Server Error', { status: 500 });
131
+ },
132
+ });
133
+
134
+ console.log(`[FriendServer] Listening on http://${host}:${port}`);
135
+ return server;
136
+ }
137
+
138
+ /**
139
+ * Stop the friend HTTP server.
140
+ */
141
+ export function stopFriendServer(): void {
142
+ if (server) {
143
+ try {
144
+ server.stop();
145
+ server = null;
146
+ console.log('[FriendServer] Stopped');
147
+ } catch (err) {
148
+ console.error('[FriendServer] Error stopping:', err);
149
+ }
150
+ }
151
+ }
src/friend/stt-service.ts ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * STT file transcription for Friend.
3
+ *
4
+ * Provides file-based transcription using /voice STT providers
5
+ * (Anthropic Voice Stream, Local Whisper, Doubao).
6
+ *
7
+ * Streaming/in-process capture is handled by FriendService.ts
8
+ * (which uses cpal for native in-process audio capture).
9
+ */
10
+
11
+ import type { FriendPrefs } from './prefs.js';
12
+ import {
13
+ connectVoiceStream,
14
+ isVoiceStreamAvailable,
15
+ } from '../services/voiceStreamSTT.js';
16
+ import {
17
+ connectLocalWhisperStream,
18
+ preloadWhisperModel,
19
+ } from '../services/voice/whisperSTT.js';
20
+
21
+ // ── Types ──────────────────────────────────────────────────────────────
22
+
23
+ type SttProvider = 'browser' | 'anthropic' | 'local' | 'doubao';
24
+
25
+ // ── STT connection factory ─────────────────────────────────────────────
26
+
27
+ async function startSttConnection(
28
+ provider: SttProvider,
29
+ language: string | undefined,
30
+ callbacks: {
31
+ onTranscript(text: string, isFinal: boolean): void;
32
+ onError(error: string, opts?: { fatal?: boolean }): void;
33
+ onClose(): void;
34
+ onReady(conn: any): void;
35
+ },
36
+ ) {
37
+ switch (provider) {
38
+ case 'anthropic': {
39
+ if (!isVoiceStreamAvailable()) {
40
+ callbacks.onError('Anthropic Voice Stream not available (not logged in?)');
41
+ return null;
42
+ }
43
+ const conn = await connectVoiceStream(callbacks, {
44
+ language: language || 'en',
45
+ keyterms: ['code', 'versperclaw'],
46
+ });
47
+ return conn;
48
+ }
49
+
50
+ case 'local': {
51
+ await preloadWhisperModel({ language: language || 'en' });
52
+ const conn = await connectLocalWhisperStream(callbacks, {
53
+ language: language || 'en',
54
+ });
55
+ return conn;
56
+ }
57
+
58
+ case 'doubao': {
59
+ try {
60
+ const { connectDoubaoStream } = await import(
61
+ '../services/doubaoSTT.js'
62
+ );
63
+ const conn = await connectDoubaoStream(callbacks, {
64
+ language: language || 'zh',
65
+ });
66
+ return conn;
67
+ } catch (err: any) {
68
+ callbacks.onError(`Doubao STT import failed: ${err?.message}`);
69
+ return null;
70
+ }
71
+ }
72
+
73
+ default:
74
+ callbacks.onError(`Unknown STT provider: ${provider}`);
75
+ return null;
76
+ }
77
+ }
78
+
79
+ // ── File-based transcription (REST) ────────────────────────────────────
80
+
81
+ export async function transcribeAudioFile(
82
+ wavBuffer: Buffer,
83
+ prefs: FriendPrefs,
84
+ ): Promise<{ text: string }> {
85
+ const provider = (prefs.sttProvider || 'browser') as SttProvider;
86
+ const language = prefs.sttLanguage;
87
+
88
+ switch (provider) {
89
+ case 'local': {
90
+ const { mkdtempSync, writeFileSync, unlinkSync, rmdirSync } = await import(
91
+ 'node:fs'
92
+ );
93
+ const { tmpdir } = await import('node:os');
94
+ const { join } = await import('node:path');
95
+ const tmpDir = mkdtempSync(join(tmpdir(), 'friend-stt-'));
96
+ const wavPath = join(tmpDir, 'input.wav');
97
+ writeFileSync(wavPath, wavBuffer);
98
+
99
+ try {
100
+ await preloadWhisperModel({ language: language || 'en' });
101
+ const { connectLocalWhisperStream } = await import(
102
+ '../services/voice/whisperSTT.js'
103
+ );
104
+ const result = await new Promise<string>((resolve, reject) => {
105
+ const chunks: Buffer[] = [];
106
+ connectLocalWhisperStream(
107
+ {
108
+ onTranscript(text, _isFinal) {
109
+ chunks.push(Buffer.from(text, 'utf8'));
110
+ },
111
+ onError(error) {
112
+ reject(new Error(error));
113
+ },
114
+ onClose() {
115
+ resolve(Buffer.concat(chunks).toString('utf8'));
116
+ },
117
+ onReady(conn) {
118
+ conn.send(wavBuffer);
119
+ conn.finalize();
120
+ },
121
+ },
122
+ { language: language || 'en' },
123
+ );
124
+ });
125
+ return { text: result };
126
+ } finally {
127
+ try { unlinkSync(wavPath) } catch {}
128
+ try { rmdirSync(tmpDir) } catch {}
129
+ }
130
+ }
131
+
132
+ case 'doubao': {
133
+ const { connectDoubaoStream } = await import(
134
+ '../services/doubaoSTT.js'
135
+ );
136
+ const chunks: string[] = [];
137
+ const conn = await connectDoubaoStream(
138
+ {
139
+ onTranscript(text: string, _isFinal: boolean) {
140
+ chunks.push(text);
141
+ },
142
+ onError(_error: string) {},
143
+ onClose() {},
144
+ onReady(c: any) {
145
+ c.send(wavBuffer);
146
+ c.finalize();
147
+ },
148
+ },
149
+ { language: language || 'zh' },
150
+ );
151
+ if (!conn) throw new Error('Doubao STT unavailable');
152
+ await new Promise((r) => setTimeout(r, 1000));
153
+ return { text: chunks.join('') };
154
+ }
155
+
156
+ case 'anthropic': {
157
+ // Fall back to local Whisper for file transcription
158
+ return transcribeAudioFile(wavBuffer, { ...prefs, sttProvider: 'local' });
159
+ }
160
+
161
+ default:
162
+ throw new Error(`Unsupported STT provider for file transcription: ${provider}`);
163
+ }
164
+ }
src/friend/tauri-launcher.ts CHANGED
@@ -2,85 +2,17 @@
2
  * Tauri desktop app process management for Friend (VersperClaw native).
3
  *
4
  * Launches the VRM desktop pet as a native Tauri window.
5
- * Also manages a background server on port 3456 that serves the frontend.
 
6
  */
7
- import { spawn, execSync } from 'node:child_process';
8
  import path from 'node:path';
9
  import { existsSync } from 'node:fs';
10
 
11
  let tauriProcess: ReturnType<typeof spawn> | null = null;
12
- let serverProcess: ReturnType<typeof spawn> | null = null;
13
-
14
- const SERVER_PORT = 3456
15
- const FRIEND_URL = `http://127.0.0.1:${SERVER_PORT}/friend/`
16
-
17
- /** Check if the friend server is already listening. */
18
- function isServerRunning(): boolean {
19
- try {
20
- const result = execSync(
21
- `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${SERVER_PORT}/friend/ 2>/dev/null || true`,
22
- { timeout: 3000, encoding: 'utf-8' },
23
- )
24
- return result.trim() === '200'
25
- } catch {
26
- return false
27
- }
28
- }
29
-
30
- /** Wait until the server responds, up to `timeoutMs`. */
31
- function waitForServer(timeoutMs = 10_000): Promise<boolean> {
32
- const start = Date.now()
33
- return new Promise((resolve) => {
34
- const check = () => {
35
- if (isServerRunning()) return resolve(true)
36
- if (Date.now() - start > timeoutMs) return resolve(false)
37
- setTimeout(check, 500)
38
- }
39
- check()
40
- })
41
- }
42
-
43
- /** Start the backend server if not already running. */
44
- async function ensureServer(log: { info: (msg: string) => void; warn: (msg: string) => void }) {
45
- if (isServerRunning()) {
46
- log.info('[Friend] Server already running')
47
- return true
48
- }
49
-
50
- const cwd = process.cwd()
51
- const serverEntry = path.join(cwd, 'src', 'server', 'index.ts')
52
- if (!existsSync(serverEntry)) {
53
- log.warn(`[Friend] Server entry not found: ${serverEntry}`)
54
- return false
55
- }
56
-
57
- log.info('[Friend] Starting background server...')
58
- serverProcess = spawn('bun', ['run', serverEntry, `--port=${SERVER_PORT}`], {
59
- cwd,
60
- stdio: 'pipe',
61
- detached: true,
62
- })
63
-
64
- serverProcess.stderr?.on('data', (data: Buffer) => {
65
- for (const line of data.toString().split('\n').filter(Boolean)) {
66
- log.info(`[Friend:server] ${line}`)
67
- }
68
- })
69
-
70
- const ok = await waitForServer()
71
- if (!ok) log.warn('[Friend] Server did not become ready in time')
72
- return ok
73
- }
74
 
75
  export async function launchTauri(log: { info: (msg: string) => void; warn: (msg: string) => void }) {
76
- // 1. Ensure the server is running (Tauri loads from HTTP)
77
- const serverOk = await ensureServer(log)
78
- if (!serverOk) {
79
- log.warn('[Friend] Cannot start — server failed to start')
80
- return
81
- }
82
-
83
- // 2. Find the Tauri binary
84
  const cwd = process.cwd()
85
  const releaseBinary = path.join(cwd, 'src', 'components', 'friend', 'frontend', 'src-tauri', 'target', 'release', 'versperclaw-friend')
86
  const debugBinary = path.join(cwd, 'src', 'components', 'friend', 'frontend', 'src-tauri', 'target', 'debug', 'versperclaw-friend')
@@ -95,7 +27,6 @@ export async function launchTauri(log: { info: (msg: string) => void; warn: (msg
95
  return
96
  }
97
 
98
- // 3. Launch the Tauri desktop window (it connects to FRIEND_URL via lib.rs)
99
  log.info(`[Friend] Starting desktop window from ${binary}`)
100
 
101
  tauriProcess = spawn(binary, [], {
@@ -137,12 +68,6 @@ export function stopTauri(log: { info: (msg: string) => void }) {
137
  try { if (!proc.killed) proc.kill('SIGKILL') } catch { /* ignore */ }
138
  }, 3000)
139
  }
140
- if (serverProcess) {
141
- log.info('[Friend] Stopping background server...')
142
- const proc = serverProcess
143
- serverProcess = null
144
- proc.kill('SIGTERM')
145
- }
146
  }
147
 
148
  export function getTauriProcess(): ReturnType<typeof spawn> | null {
 
2
  * Tauri desktop app process management for Friend (VersperClaw native).
3
  *
4
  * Launches the VRM desktop pet as a native Tauri window.
5
+ * The HTTP server runs in-process via friend/server.ts —
6
+ * no separate server subprocess needed.
7
  */
8
+ import { spawn } from 'node:child_process';
9
  import path from 'node:path';
10
  import { existsSync } from 'node:fs';
11
 
12
  let tauriProcess: ReturnType<typeof spawn> | null = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  export async function launchTauri(log: { info: (msg: string) => void; warn: (msg: string) => void }) {
15
+ // Find the Tauri binary
 
 
 
 
 
 
 
16
  const cwd = process.cwd()
17
  const releaseBinary = path.join(cwd, 'src', 'components', 'friend', 'frontend', 'src-tauri', 'target', 'release', 'versperclaw-friend')
18
  const debugBinary = path.join(cwd, 'src', 'components', 'friend', 'frontend', 'src-tauri', 'target', 'debug', 'versperclaw-friend')
 
27
  return
28
  }
29
 
 
30
  log.info(`[Friend] Starting desktop window from ${binary}`)
31
 
32
  tauriProcess = spawn(binary, [], {
 
68
  try { if (!proc.killed) proc.kill('SIGKILL') } catch { /* ignore */ }
69
  }, 3000)
70
  }
 
 
 
 
 
 
71
  }
72
 
73
  export function getTauriProcess(): ReturnType<typeof spawn> | null {
src/hooks/useFriendBridge.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * useFriendBridge — Friend VRM inbound/outbound bridge hook.
3
+ *
4
+ * Mirror of useFeishuBridge:
5
+ * - Tracks AI response turns for Friend-originated messages
6
+ * - Broadcasts generated responses to the VRM display via SSE
7
+ *
8
+ * The frontend receives responses as SSE events from broadcastToVrm().
9
+ * No chatId needed — SSE is a broadcast channel to all connected
10
+ * Tauri display clients.
11
+ */
12
+
13
+ import { useEffect, useRef } from 'react'
14
+ import { friendService } from '../friend/FriendService.js'
15
+ import { getContentText } from '../utils/messages.js'
16
+ import type { Message } from '../types/message.js'
17
+
18
+ type Props = {
19
+ messages: Message[]
20
+ isLoading: boolean
21
+ }
22
+
23
+ const FRIEND_CHANNEL_SERVER = 'friend'
24
+
25
+ type ActiveFriendTurn = {
26
+ responseParts: string[]
27
+ }
28
+
29
+ export function useFriendBridge({ messages, isLoading }: Props): void {
30
+ const pendingInboundRef = useRef<number>(0) // counter, no chatId needed
31
+ const activeTurnRef = useRef<ActiveFriendTurn | null>(null)
32
+ const lastProcessedMessageCountRef = useRef(messages.length)
33
+ const previousLoadingRef = useRef(isLoading)
34
+
35
+ // Subscribe to inbound events (increments counter for turn tracking)
36
+ useEffect(() => {
37
+ return friendService.subscribeToInbound(event => {
38
+ pendingInboundRef.current++
39
+ })
40
+ }, [])
41
+
42
+ // Process new messages — detect Friend-originated user messages and collect
43
+ // assistant responses
44
+ useEffect(() => {
45
+ const newMessages = messages.slice(lastProcessedMessageCountRef.current)
46
+
47
+ for (const message of newMessages) {
48
+ if (
49
+ message.type === 'user' &&
50
+ typeof message.origin === 'object' &&
51
+ message.origin !== null &&
52
+ (message.origin as Record<string, unknown>).kind === 'channel' &&
53
+ (message.origin as Record<string, unknown>).server === FRIEND_CHANNEL_SERVER
54
+ ) {
55
+ // Consume one pending inbound
56
+ if (pendingInboundRef.current > 0) {
57
+ pendingInboundRef.current--
58
+ activeTurnRef.current = {
59
+ responseParts: [],
60
+ }
61
+ }
62
+ continue
63
+ }
64
+
65
+ if (message.type === 'assistant' && activeTurnRef.current) {
66
+ const text = getContentText(message.message.content)
67
+ if (text) {
68
+ activeTurnRef.current.responseParts.push(text)
69
+ }
70
+ continue
71
+ }
72
+
73
+ if (
74
+ message.type === 'system' &&
75
+ message.subtype === 'local_command' &&
76
+ activeTurnRef.current
77
+ ) {
78
+ const text = message.content
79
+ if (text) {
80
+ activeTurnRef.current.responseParts.push(text)
81
+ }
82
+ }
83
+ }
84
+
85
+ lastProcessedMessageCountRef.current = messages.length
86
+ }, [messages])
87
+
88
+ // When loading completes, broadcast accumulated response via SSE
89
+ useEffect(() => {
90
+ const wasLoading = previousLoadingRef.current
91
+ previousLoadingRef.current = isLoading
92
+
93
+ if (!wasLoading || isLoading || !activeTurnRef.current) return
94
+
95
+ const completedTurn = activeTurnRef.current
96
+ activeTurnRef.current = null
97
+
98
+ void (async () => {
99
+ try {
100
+ const reply =
101
+ completedTurn.responseParts.join('\n\n').trim()
102
+
103
+ if (reply) {
104
+ await friendService.broadcastResponse(reply)
105
+ }
106
+ } catch (error) {
107
+ console.warn(
108
+ '[friend] failed to broadcast reply:',
109
+ error instanceof Error ? error.message : String(error),
110
+ )
111
+ }
112
+ })()
113
+ }, [isLoading])
114
+ }
src/screens/REPL.tsx CHANGED
@@ -113,6 +113,7 @@ import {
113
  import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js'
114
  import { useLogMessages } from '../hooks/useLogMessages.js'
115
  import { useFeishuBridge } from '../hooks/useFeishuBridge.js'
 
116
  import { useReplBridge } from '../hooks/useReplBridge.js'
117
  import {
118
  type Command,
@@ -5403,6 +5404,7 @@ export function REPL({
5403
  useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt })
5404
 
5405
  useFeishuBridge({ messages, isLoading })
 
5406
 
5407
  // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
5408
  if (feature('AGENT_TRIGGERS')) {
 
113
  import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js'
114
  import { useLogMessages } from '../hooks/useLogMessages.js'
115
  import { useFeishuBridge } from '../hooks/useFeishuBridge.js'
116
+ import { useFriendBridge } from '../hooks/useFriendBridge.js'
117
  import { useReplBridge } from '../hooks/useReplBridge.js'
118
  import {
119
  type Command,
 
5404
  useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt })
5405
 
5406
  useFeishuBridge({ messages, isLoading })
5407
+ useFriendBridge({ messages, isLoading })
5408
 
5409
  // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
5410
  if (feature('AGENT_TRIGGERS')) {
src/server/api/friend.ts CHANGED
@@ -13,7 +13,8 @@ import {
13
  import { getPrefs, setPrefs, updatePrefs, type FriendPrefs } from '../../friend/prefs.js';
14
  import { edgeTts, qwenTts, registerAudioFile, getAudioFile } from '../../friend/tts.js';
15
  import { stripForTts } from '../../friend/text-utils.js';
16
- import { friendChatService } from '../../friend/chat-service.js';
 
17
 
18
  const GATEWAY_URL = `http://127.0.0.1:3456`;
19
 
@@ -132,24 +133,46 @@ export async function handleFriendApi(req: Request, url: URL): Promise<Response>
132
  const message = body?.message;
133
  if (!message) return jsonResponse({ error: 'message required' }, 400);
134
 
135
- // Ensure the Friend CLI session is running (lazy start on first chat).
136
- // The response comes back asynchronously via SSE.
137
- (async () => {
138
- try {
139
- await friendChatService.ensureSession(serverHost, serverPort);
140
- friendChatService.sendMessage(message);
141
- } catch (err) {
142
- console.error('[Friend] chat dispatch error:', err);
143
- broadcastToVrm({
144
- text: `Connection error: ${err instanceof Error ? err.message : String(err)}`,
145
- });
146
- broadcastToVrm({ replyDone: true });
147
- }
148
- })();
149
 
150
  return jsonResponse({ ok: true });
151
  }
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  // ── Touch endpoint (POST /plugins/friend/touch) ──
154
  if (pathname === '/plugins/friend/touch' && method === 'POST') {
155
  const body = await readJsonBody(req) as any;
@@ -190,6 +213,31 @@ export async function handleFriendApi(req: Request, url: URL): Promise<Response>
190
  return new Response(null, { status: 405 });
191
  }
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  // ── TTS Preview (POST /plugins/friend/preview) ──
194
  if (pathname === '/plugins/friend/preview' && method === 'POST') {
195
  const body = await readJsonBody(req) as any;
@@ -239,6 +287,8 @@ export async function handleFriendApi(req: Request, url: URL): Promise<Response>
239
  currentDance: prefs.currentDance,
240
  language: prefs.language,
241
  hideMood: prefs.hideMood,
 
 
242
  });
243
  }
244
 
@@ -257,6 +307,8 @@ export async function handleFriendApi(req: Request, url: URL): Promise<Response>
257
  if (body.currentDance !== undefined) patch.currentDance = body.currentDance;
258
  if (body.language !== undefined) patch.language = body.language;
259
  if (body.hideMood !== undefined) patch.hideMood = body.hideMood;
 
 
260
  setPrefs(updatePrefs(patch));
261
  return jsonResponse({ ok: true });
262
  }
@@ -380,7 +432,7 @@ export async function handleFriendApi(req: Request, url: URL): Promise<Response>
380
  // ── Clear context (POST /plugins/friend/context/clear) ──
381
  if (pathname === '/plugins/friend/context/clear' && method === 'POST') {
382
  broadcastToVrm({ clearText: true });
383
- friendChatService.clearContext();
384
  return jsonResponse({ ok: true });
385
  }
386
 
 
13
  import { getPrefs, setPrefs, updatePrefs, type FriendPrefs } from '../../friend/prefs.js';
14
  import { edgeTts, qwenTts, registerAudioFile, getAudioFile } from '../../friend/tts.js';
15
  import { stripForTts } from '../../friend/text-utils.js';
16
+ import { friendService } from '../../friend/FriendService.js';
17
+ import { transcribeAudioFile } from '../../friend/stt-service.js';
18
 
19
  const GATEWAY_URL = `http://127.0.0.1:3456`;
20
 
 
133
  const message = body?.message;
134
  if (!message) return jsonResponse({ error: 'message required' }, 400);
135
 
136
+ // Enqueue the message into the main CLI conversation via FriendService.
137
+ // The AI response is tracked by useFriendBridge in the REPL and
138
+ // broadcast back to the VRM display via SSE automatically.
139
+ try {
140
+ await friendService.start();
141
+ friendService.sendText(message);
142
+ } catch (err) {
143
+ console.error('[Friend] chat dispatch error:', err);
144
+ broadcastToVrm({
145
+ text: `Connection error: ${err instanceof Error ? err.message : String(err)}`,
146
+ });
147
+ broadcastToVrm({ replyDone: true });
148
+ }
 
149
 
150
  return jsonResponse({ ok: true });
151
  }
152
 
153
+ // ── Voice capture (POST /plugins/friend/voice/start, /stop, /status) ──
154
+ if (pathname === '/plugins/friend/voice/start' && method === 'POST') {
155
+ try {
156
+ await friendService.startVoiceCapture();
157
+ return jsonResponse({ ok: true });
158
+ } catch (err) {
159
+ return jsonResponse({ error: String(err) }, 500);
160
+ }
161
+ }
162
+
163
+ if (pathname === '/plugins/friend/voice/stop' && method === 'POST') {
164
+ try {
165
+ const text = await friendService.stopVoiceCapture();
166
+ return jsonResponse({ ok: true, text });
167
+ } catch (err) {
168
+ return jsonResponse({ error: String(err) }, 500);
169
+ }
170
+ }
171
+
172
+ if (pathname === '/plugins/friend/voice/status' && method === 'POST') {
173
+ return jsonResponse(friendService.getCaptureStatus());
174
+ }
175
+
176
  // ── Touch endpoint (POST /plugins/friend/touch) ──
177
  if (pathname === '/plugins/friend/touch' && method === 'POST') {
178
  const body = await readJsonBody(req) as any;
 
213
  return new Response(null, { status: 405 });
214
  }
215
 
216
+ // ── STT config (GET /plugins/friend/stt/config) ──
217
+ if (pathname === '/plugins/friend/stt/config' && method === 'GET') {
218
+ const prefs = getPrefs();
219
+ return jsonResponse({
220
+ sttProvider: prefs.sttProvider || 'browser',
221
+ sttLanguage: prefs.sttLanguage || 'zh',
222
+ });
223
+ }
224
+
225
+ // ── STT file transcription (POST /plugins/friend/stt/file) ──
226
+ if (pathname === '/plugins/friend/stt/file' && method === 'POST') {
227
+ try {
228
+ const formData = await req.formData();
229
+ const audioFile = formData.get('audio') as File | null;
230
+ if (!audioFile) return jsonResponse({ error: 'audio file required' }, 400);
231
+
232
+ const buffer = Buffer.from(await audioFile.arrayBuffer());
233
+ const prefs = getPrefs();
234
+ const result = await transcribeAudioFile(buffer, prefs);
235
+ return jsonResponse(result);
236
+ } catch (err) {
237
+ return jsonResponse({ error: String(err) }, 500);
238
+ }
239
+ }
240
+
241
  // ── TTS Preview (POST /plugins/friend/preview) ──
242
  if (pathname === '/plugins/friend/preview' && method === 'POST') {
243
  const body = await readJsonBody(req) as any;
 
287
  currentDance: prefs.currentDance,
288
  language: prefs.language,
289
  hideMood: prefs.hideMood,
290
+ sttProvider: prefs.sttProvider || 'browser',
291
+ sttLanguage: prefs.sttLanguage || 'zh',
292
  });
293
  }
294
 
 
307
  if (body.currentDance !== undefined) patch.currentDance = body.currentDance;
308
  if (body.language !== undefined) patch.language = body.language;
309
  if (body.hideMood !== undefined) patch.hideMood = body.hideMood;
310
+ if (body.sttProvider !== undefined) patch.sttProvider = body.sttProvider;
311
+ if (body.sttLanguage !== undefined) patch.sttLanguage = body.sttLanguage;
312
  setPrefs(updatePrefs(patch));
313
  return jsonResponse({ ok: true });
314
  }
 
432
  // ── Clear context (POST /plugins/friend/context/clear) ──
433
  if (pathname === '/plugins/friend/context/clear' && method === 'POST') {
434
  broadcastToVrm({ clearText: true });
435
+ friendService.sendText('/new');
436
  return jsonResponse({ ok: true });
437
  }
438
 
src/server/index.ts CHANGED
@@ -18,7 +18,6 @@ import { handleHahaOpenAIOAuthCallback } from './api/haha-openai-oauth.js'
18
  import { handleFriendApi, setFriendServerInfo } from './api/friend.js'
19
  import { handleFriendStaticRequest } from './staticFriend.js'
20
  import { getPrefs } from '../friend/prefs.js'
21
- import { launchTauri, stopTauri } from '../friend/tauri-launcher.js'
22
  import { fileURLToPath } from 'node:url'
23
  import path from 'node:path'
24
  import { OPENAI_CODEX_REDIRECT_PATH } from '../services/openaiAuth/client.js'
@@ -428,19 +427,12 @@ export function startServer(port = PORT, host = HOST) {
428
 
429
  console.log(`[Server] Claude Code API server running at http://${host}:${port}`)
430
 
431
- // Register Friend server info so the chat service can construct SDK URLs
432
  setFriendServerInfo(host, port)
433
 
434
- // ── Friend: launch Tauri desktop window when enabled ──
435
- if (getPrefs().enabled) {
436
- const friendUrl = `http://${localConnectHost}:${port}/friend/`
437
- console.log(`[Friend] VRM frontend available at ${friendUrl}`)
438
-
439
- launchTauri({
440
- info: (msg: string) => console.log(`[Friend] ${msg}`),
441
- warn: (msg: string) => console.warn(`[Friend] ${msg}`),
442
- })
443
- }
444
 
445
  return server
446
  }
@@ -458,29 +450,20 @@ function cleanupAllSessions() {
458
  }
459
  }
460
 
461
- function cleanupFriend() {
462
- stopTauri({
463
- info: (msg: string) => console.log(`[Friend] ${msg}`),
464
- })
465
- }
466
-
467
  process.on('SIGTERM', () => {
468
  console.log('[Server] Received SIGTERM')
469
  cleanupAllSessions()
470
- cleanupFriend()
471
  process.exit(0)
472
  })
473
 
474
  process.on('SIGINT', () => {
475
  console.log('[Server] Received SIGINT')
476
  cleanupAllSessions()
477
- cleanupFriend()
478
  process.exit(0)
479
  })
480
 
481
  process.on('exit', () => {
482
  cleanupAllSessions()
483
- cleanupFriend()
484
  })
485
 
486
  // Direct execution
 
18
  import { handleFriendApi, setFriendServerInfo } from './api/friend.js'
19
  import { handleFriendStaticRequest } from './staticFriend.js'
20
  import { getPrefs } from '../friend/prefs.js'
 
21
  import { fileURLToPath } from 'node:url'
22
  import path from 'node:path'
23
  import { OPENAI_CODEX_REDIRECT_PATH } from '../services/openaiAuth/client.js'
 
427
 
428
  console.log(`[Server] Claude Code API server running at http://${host}:${port}`)
429
 
430
+ // Register Friend server info so API routes can construct URLs
431
  setFriendServerInfo(host, port)
432
 
433
+ // Note: Friend Tauri window lifecycle is managed by the friend module
434
+ // (FriendService + friend/server.ts), not by the desktop server.
435
+ // Use `/friend start` in the CLI to launch the window.
 
 
 
 
 
 
 
436
 
437
  return server
438
  }
 
450
  }
451
  }
452
 
 
 
 
 
 
 
453
  process.on('SIGTERM', () => {
454
  console.log('[Server] Received SIGTERM')
455
  cleanupAllSessions()
 
456
  process.exit(0)
457
  })
458
 
459
  process.on('SIGINT', () => {
460
  console.log('[Server] Received SIGINT')
461
  cleanupAllSessions()
 
462
  process.exit(0)
463
  })
464
 
465
  process.on('exit', () => {
466
  cleanupAllSessions()
 
467
  })
468
 
469
  // Direct execution
src/server/staticFriend.ts CHANGED
@@ -69,17 +69,25 @@ export async function handleFriendStaticRequest(req: Request, url: URL): Promise
69
  }
70
 
71
  async function resolveFriendDistDir(): Promise<string | null> {
 
72
  const _srcDir = path.dirname(fileURLToPath(import.meta.url))
73
- // src/server/ -> ../../src/components/friend/frontend/dist
74
- const candidate = path.resolve(_srcDir, '..', '..', 'src', 'components', 'friend', 'frontend', 'dist')
75
  try {
76
- const stat = await fs.stat(path.join(candidate, 'index.html'))
77
- if (stat.isFile()) {
78
- return candidate
79
- }
80
  } catch {
81
  // Not found
82
  }
 
 
 
 
 
 
 
 
 
 
83
  return null
84
  }
85
 
 
69
  }
70
 
71
  async function resolveFriendDistDir(): Promise<string | null> {
72
+ // Method 1: relative to source file (works in dev mode / server subprocess)
73
  const _srcDir = path.dirname(fileURLToPath(import.meta.url))
74
+ const candidate1 = path.resolve(_srcDir, '..', '..', 'src', 'components', 'friend', 'frontend', 'dist')
 
75
  try {
76
+ const stat = await fs.stat(path.join(candidate1, 'index.html'))
77
+ if (stat.isFile()) return candidate1
 
 
78
  } catch {
79
  // Not found
80
  }
81
+
82
+ // Method 2: relative to cwd (works in compiled binary where import.meta.url is virtual)
83
+ const candidate2 = path.resolve(process.cwd(), 'src', 'components', 'friend', 'frontend', 'dist')
84
+ try {
85
+ const stat = await fs.stat(path.join(candidate2, 'index.html'))
86
+ if (stat.isFile()) return candidate2
87
+ } catch {
88
+ // Not found
89
+ }
90
+
91
  return null
92
  }
93
 
src/server/ws/handler.ts CHANGED
@@ -29,7 +29,6 @@ import {
29
  LOCAL_COMMAND_STDOUT_TAG,
30
  } from '../../constants/xml.js'
31
  import { shouldCreateWorktreeForSessionLaunch } from '../services/repositoryLaunchService.js'
32
-
33
  const settingsService = new SettingsService()
34
  const providerService = new ProviderService()
35
 
 
29
  LOCAL_COMMAND_STDOUT_TAG,
30
  } from '../../constants/xml.js'
31
  import { shouldCreateWorktreeForSessionLaunch } from '../services/repositoryLaunchService.js'
 
32
  const settingsService = new SettingsService()
33
  const providerService = new ProviderService()
34
 
src/utils/desktopBundledCli.ts CHANGED
@@ -24,9 +24,23 @@ export function resolveBundledCliPathFromExecPath(
24
  return fs.existsSync(bundledCliPath) ? bundledCliPath : null
25
  }
26
 
 
 
 
 
 
 
27
  return null
28
  }
29
 
 
 
 
 
 
 
 
 
30
  export function resolveClaudeCliLauncher(options?: {
31
  cliPath?: string | null
32
  execPath?: string
 
24
  return fs.existsSync(bundledCliPath) ? bundledCliPath : null
25
  }
26
 
27
+ // If execPath points to an existing regular file, it IS the CLI binary
28
+ // (handles standalone compiled binaries like VersperClaw, cli, etc.)
29
+ if (existsAndIsFile(execPath)) {
30
+ return execPath
31
+ }
32
+
33
  return null
34
  }
35
 
36
+ function existsAndIsFile(p: string): boolean {
37
+ try {
38
+ return fs.statSync(p).isFile()
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
  export function resolveClaudeCliLauncher(options?: {
45
  cliPath?: string | null
46
  execPath?: string