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 +16 -0
- src/components/friend/frontend/App.tsx +5 -1
- src/components/friend/frontend/components/ChatInput.tsx +66 -210
- src/components/friend/frontend/components/SettingsPanel.tsx +33 -0
- src/components/friend/frontend/hooks/useServerStt.ts +119 -0
- src/friend/FriendService.ts +442 -0
- src/friend/chat-service.ts +0 -360
- src/friend/prefs.ts +4 -0
- src/friend/server.ts +151 -0
- src/friend/stt-service.ts +164 -0
- src/friend/tauri-launcher.ts +4 -79
- src/hooks/useFriendBridge.ts +114 -0
- src/screens/REPL.tsx +2 -0
- src/server/api/friend.ts +68 -16
- src/server/index.ts +4 -21
- src/server/staticFriend.ts +14 -6
- src/server/ws/handler.ts +0 -1
- src/utils/desktopBundledCli.ts +14 -0
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 |
-
|
| 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 |
-
//
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 77 |
setRecording(false)
|
| 78 |
-
|
| 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 = /[。!?\.\!\?]$/
|
| 102 |
-
const SOFT_PUNCT = /[,、;,;::]$/
|
| 103 |
-
const SOFT_PUNCT_MIN_LEN = 10
|
| 104 |
-
const MAX_UNSENT_LEN = 30
|
| 105 |
-
const SILENCE_SEND_MS = 1200
|
| 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
|
| 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) ||
|
| 170 |
-
(SOFT_PUNCT.test(unsent) && unsent.length >= SOFT_PUNCT_MIN_LEN) ||
|
| 171 |
-
unsent.length >= MAX_UNSENT_LEN
|
| 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:
|
| 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 |
-
//
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 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 |
-
|
| 314 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
| 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.
|
| 394 |
-
|
| 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:
|
| 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 || (
|
| 465 |
-
|
| 466 |
</span>
|
| 467 |
</div>
|
| 468 |
-
{/* 右侧:麦克风指示 + 挂断 */}
|
| 469 |
<span
|
| 470 |
style={{
|
| 471 |
...inlineBtnRight,
|
| 472 |
right: 44,
|
| 473 |
-
color:
|
| 474 |
-
animation:
|
| 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 |
-
*
|
|
|
|
| 6 |
*/
|
| 7 |
-
import { spawn
|
| 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 |
-
//
|
| 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 {
|
|
|
|
| 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 |
-
//
|
| 136 |
-
// The response
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 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 |
-
|
| 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
|
| 432 |
setFriendServerInfo(host, port)
|
| 433 |
|
| 434 |
-
//
|
| 435 |
-
|
| 436 |
-
|
| 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 |
-
|
| 74 |
-
const candidate = path.resolve(_srcDir, '..', '..', 'src', 'components', 'friend', 'frontend', 'dist')
|
| 75 |
try {
|
| 76 |
-
const stat = await fs.stat(path.join(
|
| 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
|