chenbhao commited on
Commit
23780d5
·
1 Parent(s): 1ef6a2f

chore: omni script

Browse files
desktop/scripts/start-omni.sh ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # start-omni.sh — Start llama-server + omni-adapter for Companion video call
3
+ # Usage: bash scripts/start-omni.sh
4
+ set -e
5
+
6
+ LLAMA_DIR="$HOME/Code/Llm/llama.cpp-omni"
7
+ MODEL_DIR="$HOME/Code/Llm/MiniCPM-o-4_5-gguf"
8
+ LLAMA_PORT=8025
9
+ ADAPTER_PORT=9301
10
+
11
+ echo "=== Starting llama-server (MiniCPM-o-4_5) ==="
12
+ "$LLAMA_DIR/build/bin/llama-server" \
13
+ --host 0.0.0.0 --port "$LLAMA_PORT" \
14
+ -m "$MODEL_DIR/MiniCPM-o-4_5-Q4_K_M.gguf" \
15
+ --mmproj "$MODEL_DIR/vision/MiniCPM-o-4_5-vision-F16.gguf" \
16
+ --model-vocoder "$MODEL_DIR/tts/MiniCPM-o-4_5-tts-F16.gguf" \
17
+ -ngl 99 \
18
+ --temp 0.7 \
19
+ --repeat-penalty 1.15 \
20
+ --ctx-size 2048 \
21
+ --no-kv-offload \
22
+ --no-mmproj-offload &
23
+ LLAMA_PID=$!
24
+ echo "llama-server PID: $LLAMA_PID"
25
+
26
+ # Wait for server to be ready
27
+ echo "Waiting for llama-server..."
28
+ for i in $(seq 1 30); do
29
+ if curl -s "http://localhost:$LLAMA_PORT/health" > /dev/null 2>&1; then
30
+ echo "llama-server ready!"
31
+ break
32
+ fi
33
+ sleep 2
34
+ done
35
+
36
+ echo ""
37
+ echo "=== Starting omni-adapter ==="
38
+ OMNI_PORT="$ADAPTER_PORT" LLAMA_SERVER="http://localhost:$LLAMA_PORT" \
39
+ OMNI_MODEL_DIR="$MODEL_DIR" \
40
+ OMNI_TMP="/tmp/omni-adapter" \
41
+ bun run "$(dirname "$0")/../sidecars/omni-adapter.ts" &
42
+ ADAPTER_PID=$!
43
+ echo "omni-adapter PID: $ADAPTER_PID"
44
+
45
+ echo ""
46
+ echo "=== Ready ==="
47
+ echo "llama-server: http://localhost:$LLAMA_PORT"
48
+ echo "omni-adapter: http://localhost:$ADAPTER_PORT"
49
+ echo ""
50
+ echo "Then start the desktop app and set backendHost to http://localhost:$ADAPTER_PORT"
51
+ echo ""
52
+ echo "Press Ctrl+C to stop all services"
53
+
54
+ trap "kill $LLAMA_PID $ADAPTER_PID 2>/dev/null; echo 'stopped'" EXIT
55
+ wait
desktop/sidecars/omni-adapter.ts ADDED
@@ -0,0 +1,496 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * omni-adapter.ts — Bridge between VersperClaw Companion frontend and
3
+ * llama.cpp-omni's llama-server.
4
+ *
5
+ * Uses Omni Streaming API (/v1/stream/*) following the exact protocol
6
+ * from MiniCPM-o-Demo:
7
+ * omni_init → update_session_config → prefill(cnt) → decode(round)
8
+ *
9
+ * TTS output: polls round_N/tts_wav/wav_N.wav, sends as Float32 PCM base64.
10
+ *
11
+ * Usage:
12
+ * OMNI_PORT=9301 LLAMA_SERVER=http://localhost:8025 bun run sidecars/omni-adapter.ts
13
+ *
14
+ * Env:
15
+ * OMNI_PORT=9301 LLAMA_SERVER=http://localhost:8025 OMNI_MODEL_DIR=...
16
+ * OMNI_TMP=/tmp/omni-adapter
17
+ */
18
+
19
+ import { serve } from 'bun'
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'node:fs'
21
+ import { randomBytes } from 'crypto'
22
+
23
+ // ─── Config ──────────────────────────────────────────────────
24
+
25
+ const PORT = parseInt(process.env.OMNI_PORT || '9301')
26
+ const LLAMA = (process.env.LLAMA_SERVER || 'http://localhost:8025').replace(/\/+$/, '')
27
+ const MODEL_DIR = (process.env.OMNI_MODEL_DIR || '/home/yuki/Code/Llm/MiniCPM-o-4_5-gguf').replace(/\/+$/, '')
28
+ const TMP = process.env.OMNI_TMP || '/tmp/omni-adapter'
29
+ const TTS_OUT = TMP + '/tts-output'
30
+
31
+ // ─── Helpers ─────────────────────────────────────────────────
32
+
33
+ function json(data: unknown, status = 200) {
34
+ return new Response(JSON.stringify(data), {
35
+ status,
36
+ headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
37
+ })
38
+ }
39
+
40
+ function b32() { return randomBytes(4).readUInt32BE(0).toString(36) }
41
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
42
+
43
+ // ─── llama-server client ─────────────────────────────────────
44
+
45
+ async function llamaPost(path: string, body: unknown): Promise<Response> {
46
+ return fetch(`${LLAMA}${path}`, {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json' },
49
+ body: JSON.stringify(body),
50
+ })
51
+ }
52
+
53
+ async function llamaGet(path: string): Promise<Response> {
54
+ return fetch(`${LLAMA}${path}`)
55
+ }
56
+
57
+ // ─── Audio utils ─────────────────────────────────────────────
58
+
59
+ /** Write Float32 PCM as 16-bit mono WAV and return file path. */
60
+ function writePcmToWav(f32: Float32Array, sampleRate: number): string {
61
+ const dataSize = f32.length * 2
62
+ const buf = Buffer.alloc(44 + dataSize)
63
+ let o = 0
64
+ const w = (s: string) => { for (let i = 0; i < s.length; i++) buf[o++] = s.charCodeAt(i) }
65
+ const u16 = (v: number) => { buf[o++] = v & 0xff; buf[o++] = (v >> 8) & 0xff }
66
+ const u32 = (v: number) => { buf[o++] = v & 0xff; buf[o++] = (v >> 8) & 0xff; buf[o++] = (v >> 16) & 0xff; buf[o++] = (v >> 24) & 0xff }
67
+ w('RIFF'); u32(36 + dataSize); w('WAVE'); w('fmt '); u32(16)
68
+ u16(1); u16(1); u32(sampleRate); u32(sampleRate * 2); u16(2); u16(16)
69
+ w('data'); u32(dataSize)
70
+ for (let i = 0; i < f32.length; i++) {
71
+ const s = Math.max(-1, Math.min(1, f32[i]))
72
+ buf.writeInt16LE(s < 0 ? s * 0x8000 : s * 0x7fff, o); o += 2
73
+ }
74
+ const fp = `${TMP}/aud_${b32()}.wav`
75
+ writeFileSync(fp, buf)
76
+ return fp
77
+ }
78
+
79
+ /** Read WAV file and return Float32 PCM data (raw samples). */
80
+ function readWavToF32(fp: string): Float32Array | null {
81
+ try {
82
+ const buf = readFileSync(fp)
83
+ if (buf.length < 44) return null
84
+ const nSamples = Math.floor((buf.length - 44) / 2)
85
+ if (nSamples <= 0) return null
86
+ const f32 = new Float32Array(nSamples)
87
+ for (let i = 0; i < nSamples; i++) {
88
+ const s16 = buf.readInt16LE(44 + i * 2)
89
+ f32[i] = s16 < 0 ? s16 / 0x8000 : s16 / 0x7fff
90
+ }
91
+ return f32
92
+ } catch { return null }
93
+ }
94
+
95
+ function float32ToBase64(f32: Float32Array): string {
96
+ return Buffer.from(new Uint8Array(f32.buffer)).toString('base64')
97
+ }
98
+
99
+ /** Convert browser base64 Float32 PCM to WAV file path. */
100
+ function pcmB64ToWav(b64: string, sr = 16000): string {
101
+ const raw = Buffer.from(b64, 'base64')
102
+ const len = Math.floor(raw.length / 4) * 4
103
+ const f32 = new Float32Array(len / 4)
104
+ for (let i = 0; i < f32.length; i++) f32[i] = raw.readFloatLE(i * 4)
105
+ return writePcmToWav(f32, sr)
106
+ }
107
+
108
+ /** Convert base64-encoded JPEG/PNG image to temp file path. */
109
+ function saveImageB64(b64: string): string {
110
+ const fp = `${TMP}/img_${b32()}.jpg`
111
+ writeFileSync(fp, Buffer.from(b64, 'base64'))
112
+ return fp
113
+ }
114
+
115
+ // ─── Omni session state ──────────────────────────────────────
116
+
117
+ let initialized = false
118
+ let roundIdx = 0
119
+
120
+ async function ensureInit(): Promise<boolean> {
121
+ if (initialized) return true
122
+ console.log('[omni] init...')
123
+ const res = await llamaPost('/v1/stream/omni_init', {
124
+ media_type: 2,
125
+ use_tts: true,
126
+ duplex_mode: false,
127
+ model_dir: MODEL_DIR + '/',
128
+ output_dir: TTS_OUT,
129
+ })
130
+ if (!res.ok) { console.error('[omni] init fail:', await res.text()); return false }
131
+ initialized = true
132
+ console.log('[omni] init ok')
133
+ return true
134
+ }
135
+
136
+ /**
137
+ * Call update_session_config to set system prompt and assistant template.
138
+ * This is the CRITICAL step we were missing — it sets up the model's
139
+ * prompt structure including <|audio_start|>/<|audio_end|> markers
140
+ * and the user/assistant turn template.
141
+ */
142
+ async function updateSessionConfig(systemPrompt: string): Promise<boolean> {
143
+ const voice_clone_prompt = `<|im_start|>system\n${systemPrompt || 'You are a helpful assistant.'}\n<|audio_start|>`
144
+ // assistant_prompt includes behavior instructions between <|audio_end|> and <|im_end|>,
145
+ // matching MiniCPM-o-Demo's non-duplex format.
146
+ const assistant_prompt = `<|audio_end|>请认真、高质量地回复用户的问题。请用高自然度的方式和用户聊天。<|im_end|>\n<|im_start|>user\n`
147
+ const res = await llamaPost('/v1/stream/update_session_config', {
148
+ media_type: 2,
149
+ duplex_mode: false,
150
+ voice_clone_prompt,
151
+ assistant_prompt,
152
+ lang: 'zh',
153
+ reset_context: true,
154
+ })
155
+ if (!res.ok) { console.error('[omni] update_session_config fail:', await res.text()); return false }
156
+ roundIdx = 0
157
+ return true
158
+ }
159
+
160
+ /**
161
+ * Prefill user text/audio/image input.
162
+ * cnt starts from 0 and increments per call (following demo protocol).
163
+ */
164
+ async function prefill(cnt: number, text: string, audioPath?: string, imagePath?: string): Promise<boolean> {
165
+ const body: Record<string, unknown> = {
166
+ audio_path_prefix: audioPath || '',
167
+ img_path_prefix: imagePath || '',
168
+ cnt,
169
+ text: text || '',
170
+ }
171
+ const res = await llamaPost('/v1/stream/prefill', body)
172
+ if (!res.ok) { console.error('[omni] prefill fail:', await res.text()) }
173
+ return res.ok
174
+ }
175
+
176
+ /**
177
+ * Decode (generate) with SSE streaming.
178
+ * Calls onText(content, stop) for each SSE event.
179
+ * Returns the path to the merged TTS WAV file (or null if none).
180
+ */
181
+ async function decodeStream(
182
+ onText: (content: string, stop: boolean) => void,
183
+ ): Promise<string | null> {
184
+ const res = await llamaPost('/v1/stream/decode', {
185
+ stream: true,
186
+ length_penalty: 1.1,
187
+ round_idx: roundIdx,
188
+ })
189
+ if (!res.ok) throw new Error(`decode status ${res.status}`)
190
+
191
+ const reader = res.body!.getReader()
192
+ const dec = new TextDecoder()
193
+ let buf = ''
194
+
195
+ while (true) {
196
+ const { done, value } = await reader.read()
197
+ if (done) break
198
+ buf += dec.decode(value, { stream: true })
199
+ const lines = buf.split('\n')
200
+ buf = lines.pop() || ''
201
+ for (const line of lines) {
202
+ if (!line.startsWith('data: ')) continue
203
+ const p = line.slice(6).trim()
204
+ if (p === '[DONE]') continue
205
+ try {
206
+ const ev = JSON.parse(p)
207
+ if (ev.content !== undefined) onText(ev.content || '', ev.stop || false)
208
+ } catch { /* skip */ }
209
+ }
210
+ }
211
+
212
+ return pollTtsChunks()
213
+ }
214
+
215
+ /** Poll for TTS WAV files (incremental, following demo pattern). */
216
+ async function pollTtsChunks(): Promise<string | null> {
217
+ const rounds = readdirSync(TTS_OUT).filter((e) => e.startsWith('round_')).sort()
218
+ if (rounds.length === 0) return null
219
+ const wavDir = `${TTS_OUT}/${rounds[rounds.length - 1]}/tts_wav`
220
+ if (!existsSync(wavDir)) return null
221
+
222
+ // Wait for generation_done.flag (up to ~30 s)
223
+ const flagPath = `${wavDir}/generation_done.flag`
224
+ for (let i = 0; i < 150; i++) {
225
+ if (existsSync(flagPath)) break
226
+ await sleep(200)
227
+ }
228
+ if (!existsSync(flagPath)) return null
229
+
230
+ // Read all wav_N.wav files
231
+ const files = readdirSync(wavDir)
232
+ .filter((f) => /^wav_\d+\.wav$/.test(f))
233
+ .sort()
234
+ if (files.length === 0) return null
235
+
236
+ // Merge into single Float32Array (original sample rate from WAV header)
237
+ let sampleRate = 24000
238
+ const chunks: Float32Array[] = []
239
+ for (const f of files) {
240
+ const buf = readFileSync(`${wavDir}/${f}`)
241
+ if (buf.length < 44) continue
242
+ const hdrSr = buf.readUInt32LE(24)
243
+ if (hdrSr > 0) sampleRate = hdrSr
244
+ const nSamples = Math.floor((buf.length - 44) / 2)
245
+ if (nSamples <= 0) continue
246
+ const f32 = new Float32Array(nSamples)
247
+ for (let i = 0; i < nSamples; i++) {
248
+ const s16 = buf.readInt16LE(44 + i * 2)
249
+ f32[i] = s16 < 0 ? s16 / 0x8000 : s16 / 0x7fff
250
+ }
251
+ chunks.push(f32)
252
+ }
253
+ if (chunks.length === 0) return null
254
+
255
+ const total = chunks.reduce((s, c) => s + c.length, 0)
256
+ const merged = new Float32Array(total)
257
+ let offset = 0
258
+ for (const c of chunks) { merged.set(c, offset); offset += c.length }
259
+
260
+ // Write merged WAV
261
+ const outPath = writePcmToWav(merged, sampleRate)
262
+ return outPath
263
+ }
264
+
265
+ // ─── Parse frontend messages ─────────────────────────────────
266
+
267
+ interface ParsedMsgs {
268
+ systemPrompt: string
269
+ userText: string
270
+ audioB64: string | null
271
+ imageB64: string | null
272
+ }
273
+
274
+ /** Extract system prompt + last user message content from frontend messages array. */
275
+ function parseMessages(messages: any[]): ParsedMsgs {
276
+ let systemPrompt = ''
277
+ let lastUser: any = null
278
+
279
+ for (const msg of messages) {
280
+ if (msg?.role === 'system' && typeof msg.content === 'string') {
281
+ systemPrompt = msg.content
282
+ }
283
+ if (msg?.role === 'user') lastUser = msg
284
+ }
285
+
286
+ if (!lastUser) return { systemPrompt, userText: '', audioB64: null, imageB64: null }
287
+
288
+ const content = lastUser.content
289
+ if (!content) return { systemPrompt, userText: '', audioB64: null, imageB64: null }
290
+
291
+ if (typeof content === 'string') {
292
+ return { systemPrompt, userText: content, audioB64: null, imageB64: null }
293
+ }
294
+
295
+ if (Array.isArray(content)) {
296
+ let text = '', audioB64: string | null = null, imageB64: string | null = null
297
+ for (const item of content) {
298
+ if (item.type === 'text') text = item.text || ''
299
+ else if (item.type === 'audio') audioB64 = item.data || null
300
+ else if (item.type === 'image') imageB64 = item.data || null
301
+ }
302
+ return { systemPrompt, userText: text, audioB64, imageB64 }
303
+ }
304
+
305
+ return { systemPrompt, userText: String(content), audioB64: null, imageB64: null }
306
+ }
307
+
308
+ // ─── Chat handler (WS streaming) ─────────────────────────────
309
+
310
+ async function handleChatMessage(ws: any, msg: any) {
311
+ const { systemPrompt, userText, audioB64, imageB64 } = parseMessages(msg.messages)
312
+
313
+ // Signal received
314
+ ws.send(JSON.stringify({ type: 'prefill_done' }))
315
+
316
+ try {
317
+ // 1. Init if needed
318
+ if (!await ensureInit()) {
319
+ ws.send(JSON.stringify({ type: 'error', error: 'omni_init failed' }))
320
+ return
321
+ }
322
+
323
+ // 2. Update session config with system prompt (resets context)
324
+ await updateSessionConfig(systemPrompt || msg.system_prompt || '')
325
+
326
+ // 3. Prefill user input (text + audio + image)
327
+ let audioPath: string | null = null
328
+ let imagePath: string | null = null
329
+
330
+ if (audioB64) audioPath = pcmB64ToWav(audioB64)
331
+ if (imageB64) imagePath = saveImageB64(imageB64)
332
+
333
+ // Always prefill text content (even empty) with cnt=0
334
+ const ok = await prefill(0, userText, audioPath || undefined, imagePath || undefined)
335
+ if (audioPath) { try { unlinkSync(audioPath) } catch {} }
336
+ if (imagePath) { try { unlinkSync(imagePath) } catch {} }
337
+ if (!ok) {
338
+ ws.send(JSON.stringify({ type: 'error', error: 'prefill failed' }))
339
+ return
340
+ }
341
+
342
+ // 4. Decode (streaming SSE)
343
+ let accumulated = ''
344
+
345
+ const ttsWav = await decodeStream((content, _stop) => {
346
+ // Content is incremental text segments; send directly
347
+ if (content) {
348
+ accumulated += content
349
+ ws.send(JSON.stringify({ type: 'chunk', text_delta: content }))
350
+ }
351
+ })
352
+
353
+ // 5. Send TTS audio if available
354
+ if (ttsWav) {
355
+ const f32 = readWavToF32(ttsWav)
356
+ if (f32) {
357
+ const audioB64 = float32ToBase64(f32)
358
+ ws.send(JSON.stringify({ type: 'chunk', text_delta: '', audio_data: audioB64 }))
359
+ }
360
+ try { unlinkSync(ttsWav) } catch {}
361
+ }
362
+
363
+ // 6. Done
364
+ ws.send(JSON.stringify({
365
+ type: 'done',
366
+ text: accumulated || '(empty reply)',
367
+ recording_session_id: null,
368
+ }))
369
+ } catch (err) {
370
+ console.error('[omni] error:', err)
371
+ ws.send(JSON.stringify({ type: 'error', error: String(err) }))
372
+ }
373
+ }
374
+
375
+ // ─── HTTP Server ─────────────────────────────────────────────
376
+
377
+ mkdirSync(TMP, { recursive: true })
378
+ mkdirSync(TTS_OUT, { recursive: true })
379
+
380
+ console.log(`[omni] adapter on :${PORT} → llama-server ${LLAMA}`)
381
+ console.log(`[omni] model: ${MODEL_DIR}`)
382
+ console.log(`[omni] tmp: ${TMP}, tts-out: ${TTS_OUT}`)
383
+
384
+ const app = serve({
385
+ port: PORT,
386
+ async fetch(req) {
387
+ const url = new URL(req.url)
388
+ const p = url.pathname
389
+
390
+ if (req.method === 'OPTIONS') {
391
+ return new Response(null, {
392
+ headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', 'Access-Control-Allow-Headers': '*' },
393
+ })
394
+ }
395
+
396
+ // GET /status
397
+ if (p === '/status' && req.method === 'GET') {
398
+ try {
399
+ const h = await llamaGet('/health')
400
+ return json({ gateway_healthy: h.ok, total_workers: 1, idle_workers: h.ok ? 1 : 0, busy_workers: 0, queue_length: 0, offline_workers: h.ok ? 0 : 1 })
401
+ } catch { return json({ gateway_healthy: false, total_workers: 0, idle_workers: 0, busy_workers: 0, queue_length: 0, offline_workers: 1 }) }
402
+ }
403
+
404
+ // GET /api/presets
405
+ if (p === '/api/presets' && req.method === 'GET') {
406
+ return json({ turnbased: [], audio_duplex: [], omni: [] })
407
+ }
408
+
409
+ // POST /api/chat (non-streaming)
410
+ if (p === '/api/chat' && req.method === 'POST') {
411
+ try {
412
+ const body = await req.json()
413
+ const { systemPrompt, userText, audioB64, imageB64 } = parseMessages(body.messages)
414
+
415
+ if (!await ensureInit()) return json({ error: 'init failed', success: false }, 500)
416
+ await updateSessionConfig(systemPrompt || body.system_prompt || '')
417
+
418
+ let audioPath: string | null = null
419
+ let imagePath: string | null = null
420
+ if (audioB64) audioPath = pcmB64ToWav(audioB64)
421
+ if (imageB64) imagePath = saveImageB64(imageB64)
422
+
423
+ await prefill(0, userText, audioPath || undefined, imagePath || undefined)
424
+ if (audioPath) { try { unlinkSync(audioPath) } catch {} }
425
+ if (imagePath) { try { unlinkSync(imagePath) } catch {} }
426
+
427
+ let fullText = ''
428
+ const ttsWav = await decodeStream((content) => { if (content) fullText += content })
429
+ let audioData: string | null = null
430
+ if (ttsWav) {
431
+ const f32 = readWavToF32(ttsWav)
432
+ if (f32) audioData = float32ToBase64(f32)
433
+ try { unlinkSync(ttsWav) } catch {}
434
+ }
435
+
436
+ return json({ text: fullText.trim() || '(empty reply)', audio_data: audioData, audio_sample_rate: audioData ? 24000 : null, recording_session_id: null, success: true })
437
+ } catch (err) {
438
+ console.error('[omni] /api/chat error:', err)
439
+ return json({ error: String(err), success: false }, 500)
440
+ }
441
+ }
442
+
443
+ // WebSocket /ws/chat (streaming)
444
+ if (p === '/ws/chat') {
445
+ if (app.upgrade(req)) return
446
+ return new Response('WS upgrade failed', { status: 400 })
447
+ }
448
+
449
+ // POST /omni/reset
450
+ if (p === '/omni/reset' && req.method === 'POST') {
451
+ await llamaPost('/v1/stream/reset', {}).catch(() => {})
452
+ initialized = false
453
+ return json({ success: true })
454
+ }
455
+
456
+ return new Response('Not found', { status: 404 })
457
+ },
458
+
459
+ websocket: {
460
+ async message(ws, raw) {
461
+ try {
462
+ const msg = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw as BufferSource))
463
+
464
+ if (msg.messages) { await handleChatMessage(ws, msg); return }
465
+ if (msg.type === 'chat') { await handleChatMessage(ws, msg); return }
466
+
467
+ if (msg.type === 'init') {
468
+ const ok = await ensureInit()
469
+ ws.send(JSON.stringify({ type: 'prefill_done', ok }))
470
+ return
471
+ }
472
+
473
+ if (msg.type === 'reset') {
474
+ await llamaPost('/v1/stream/reset', {}).catch(() => {})
475
+ initialized = false
476
+ ws.send(JSON.stringify({ type: 'reset', ok: true }))
477
+ return
478
+ }
479
+
480
+ if (msg.type === 'break') {
481
+ await llamaPost('/v1/stream/break', {}).catch(() => {})
482
+ ws.send(JSON.stringify({ type: 'break', ok: true }))
483
+ return
484
+ }
485
+ } catch (err) {
486
+ console.error('[omni] ws error:', err)
487
+ ws.send(JSON.stringify({ type: 'error', error: String(err) }))
488
+ }
489
+ },
490
+ open() { console.log('[omni] ws connected') },
491
+ close() { console.log('[omni] ws disconnected') },
492
+ },
493
+ })
494
+
495
+ process.on('SIGINT', () => { console.log('[omni] shutting down'); process.exit(0) })
496
+ process.on('SIGTERM', () => { console.log('[omni] shutting down'); process.exit(0) })
desktop/src/hooks/useMiniCPMoBackend.ts ADDED
@@ -0,0 +1,604 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ MiniCPMoMessage,
3
+ MiniCPMoServiceStatus,
4
+ MiniCPMoPresetMode,
5
+ MiniCPMoPreset,
6
+ MiniCPMoBackendContentItem,
7
+ } from '../types/companion'
8
+
9
+ // ─── Utility functions ported from MiniCPM-o mobile ──────
10
+
11
+ function createId(prefix: string): string {
12
+ return `${prefix}-${Math.random().toString(36).slice(2, 10)}`
13
+ }
14
+
15
+ function float32ToBase64(samples: Float32Array): string {
16
+ const bytes = new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength)
17
+ const chunkSize = 0x8000
18
+ let binary = ''
19
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
20
+ const slice = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length))
21
+ binary += String.fromCharCode.apply(null, Array.from(slice) as number[])
22
+ }
23
+ return btoa(binary)
24
+ }
25
+
26
+ export function float32ToWavBlobUrl(float32: Float32Array, sampleRate: number): string {
27
+ const buffer = new ArrayBuffer(44 + float32.length * 2)
28
+ const view = new DataView(buffer)
29
+ function writeString(offset: number, value: string) {
30
+ for (let i = 0; i < value.length; i++) view.setUint8(offset + i, value.charCodeAt(i))
31
+ }
32
+ writeString(0, 'RIFF')
33
+ view.setUint32(4, 36 + float32.length * 2, true)
34
+ writeString(8, 'WAVE')
35
+ writeString(12, 'fmt ')
36
+ view.setUint32(16, 16, true)
37
+ view.setUint16(20, 1, true)
38
+ view.setUint16(22, 1, true)
39
+ view.setUint32(24, sampleRate, true)
40
+ view.setUint32(28, sampleRate * 2, true)
41
+ view.setUint16(32, 2, true)
42
+ view.setUint16(34, 16, true)
43
+ writeString(36, 'data')
44
+ view.setUint32(40, float32.length * 2, true)
45
+ let offset = 44
46
+ for (let i = 0; i < float32.length; i++) {
47
+ const sample = Math.max(-1, Math.min(1, float32[i] ?? 0))
48
+ view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true)
49
+ offset += 2
50
+ }
51
+ return URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' }))
52
+ }
53
+
54
+ function audioBase64ToBlobUrl(base64Data: string, sampleRate = 24000): string {
55
+ const binary = atob(base64Data)
56
+ const raw = new Uint8Array(binary.length)
57
+ for (let i = 0; i < binary.length; i++) raw[i] = binary.charCodeAt(i)
58
+
59
+ // Check if it's already WAV
60
+ if (raw.length >= 44 && raw[0] === 0x52 && raw[1] === 0x49 && raw[2] === 0x46 && raw[3] === 0x46) {
61
+ return URL.createObjectURL(new Blob([raw], { type: 'audio/wav' }))
62
+ }
63
+
64
+ // Treat as float32 PCM bytes
65
+ const float32 = new Float32Array(raw.length / 4)
66
+ for (let i = 0; i < float32.length; i++) {
67
+ float32[i] = new Float32Array(raw.slice(i * 4, (i + 1) * 4))[0] ?? 0
68
+ }
69
+ return float32ToWavBlobUrl(float32, sampleRate)
70
+ }
71
+
72
+ function concatFloat32(chunks: Float32Array[]): Float32Array {
73
+ let total = 0
74
+ for (const chunk of chunks) total += chunk.length
75
+ const out = new Float32Array(total)
76
+ let offset = 0
77
+ for (const chunk of chunks) {
78
+ out.set(chunk, offset)
79
+ offset += chunk.length
80
+ }
81
+ return out
82
+ }
83
+
84
+ function resampleLinear(input: Float32Array, fromRate: number, toRate: number): Float32Array {
85
+ if (fromRate === toRate || input.length === 0) return input
86
+ const ratio = fromRate / toRate
87
+ const outLength = Math.max(1, Math.floor(input.length / ratio))
88
+ const out = new Float32Array(outLength)
89
+ for (let i = 0; i < outLength; i++) {
90
+ const srcPos = i * ratio
91
+ const idx = Math.floor(srcPos)
92
+ const frac = srcPos - idx
93
+ const a = input[idx] ?? 0
94
+ const b = input[idx + 1] ?? a
95
+ out[i] = a + (b - a) * frac
96
+ }
97
+ return out
98
+ }
99
+
100
+ export async function fileToBase64Stripped(file: Blob): Promise<string> {
101
+ return new Promise((resolve, reject) => {
102
+ const reader = new FileReader()
103
+ reader.onload = () => {
104
+ const result = String(reader.result ?? '')
105
+ const i = result.indexOf(',')
106
+ resolve(i >= 0 ? result.slice(i + 1) : result)
107
+ }
108
+ reader.onerror = () => reject(reader.error)
109
+ reader.readAsDataURL(file)
110
+ })
111
+ }
112
+
113
+ export async function readFileAsDataUrl(file: Blob): Promise<string> {
114
+ return new Promise((resolve, reject) => {
115
+ const reader = new FileReader()
116
+ reader.onload = () => resolve(String(reader.result ?? ''))
117
+ reader.onerror = () => reject(reader.error)
118
+ reader.readAsDataURL(file)
119
+ })
120
+ }
121
+
122
+ export async function downscaleImageToAttachment(
123
+ file: File,
124
+ maxEdge = 1280,
125
+ quality = 0.85,
126
+ ): Promise<{ id: string; kind: 'image'; previewUrl: string; base64: string; name: string }> {
127
+ const dataUrl = await readFileAsDataUrl(file)
128
+ const img: HTMLImageElement = await new Promise((resolve, reject) => {
129
+ const i = new Image()
130
+ i.onload = () => resolve(i)
131
+ i.onerror = () => reject(new Error('image load failed'))
132
+ i.src = dataUrl
133
+ })
134
+ let w = img.naturalWidth
135
+ let h = img.naturalHeight
136
+ const longEdge = Math.max(w, h)
137
+ if (longEdge > maxEdge) {
138
+ const scale = maxEdge / longEdge
139
+ w = Math.round(w * scale)
140
+ h = Math.round(h * scale)
141
+ }
142
+ const canvas = document.createElement('canvas')
143
+ canvas.width = w
144
+ canvas.height = h
145
+ const ctx = canvas.getContext('2d')
146
+ if (!ctx) throw new Error('canvas 2d unavailable')
147
+ ctx.drawImage(img, 0, 0, w, h)
148
+ const outDataUrl = canvas.toDataURL('image/jpeg', quality)
149
+ const base64 = outDataUrl.slice(outDataUrl.indexOf(',') + 1)
150
+ return { id: createId('att'), kind: 'image', previewUrl: outDataUrl, base64, name: file.name || 'photo.jpg' }
151
+ }
152
+
153
+ export async function mediaFileToAttachment(
154
+ file: File,
155
+ kind: 'audio' | 'video',
156
+ ): Promise<{ id: string; kind: 'audio' | 'video'; previewUrl: string; base64: string; name: string; duration?: number }> {
157
+ const base64 = await fileToBase64Stripped(file)
158
+ const previewUrl = URL.createObjectURL(file)
159
+ let duration: number | undefined
160
+ try {
161
+ duration = await new Promise<number>((resolve) => {
162
+ const el = document.createElement(kind === 'audio' ? 'audio' : 'video')
163
+ el.preload = 'metadata'
164
+ const onLoaded = () => {
165
+ const d = Number.isFinite(el.duration) ? el.duration : 0
166
+ resolve(d)
167
+ }
168
+ el.addEventListener('loadedmetadata', onLoaded, { once: true })
169
+ el.addEventListener('error', () => resolve(0), { once: true })
170
+ el.src = previewUrl
171
+ })
172
+ } catch {
173
+ duration = undefined
174
+ }
175
+ return { id: createId('att'), kind, previewUrl, base64, name: file.name || kind, duration }
176
+ }
177
+
178
+ // ─── API Hooks ──────────────────────────────────────────
179
+
180
+ type ServiceStatusResponse = {
181
+ gateway_healthy: boolean
182
+ total_workers: number
183
+ idle_workers: number
184
+ busy_workers: number
185
+ queue_length: number
186
+ offline_workers: number
187
+ }
188
+
189
+ export async function fetchServiceStatus(host: string): Promise<MiniCPMoServiceStatus> {
190
+ try {
191
+ const res = await fetch(`${host}/status`, { signal: AbortSignal.timeout(5000) })
192
+ const data = (await res.json()) as ServiceStatusResponse
193
+ return {
194
+ phase: data.gateway_healthy ? 'ready' : 'error',
195
+ summary: data.gateway_healthy ? '后端就绪' : '网关异常',
196
+ detail: `${data.idle_workers}/${data.total_workers} workers, 队列 ${data.queue_length}, 离线 ${data.offline_workers}`,
197
+ }
198
+ } catch {
199
+ return { phase: 'error', summary: '后端不可达', detail: '请确保 MiniCPM-o 服务已启动' }
200
+ }
201
+ }
202
+
203
+ export async function fetchPresets(host: string): Promise<Record<MiniCPMoPresetMode, MiniCPMoPreset[]>> {
204
+ try {
205
+ const res = await fetch(`${host}/api/presets`, { signal: AbortSignal.timeout(5000) })
206
+ if (!res.ok) return { turnbased: [], audio_duplex: [], omni: [] }
207
+ const data = (await res.json()) as Partial<Record<MiniCPMoPresetMode, MiniCPMoPreset[]>>
208
+ return {
209
+ turnbased: data.turnbased ?? [],
210
+ audio_duplex: data.audio_duplex ?? [],
211
+ omni: data.omni ?? [],
212
+ }
213
+ } catch {
214
+ return { turnbased: [], audio_duplex: [], omni: [] }
215
+ }
216
+ }
217
+
218
+ // ─── Chat message builder ───────────────────────────────
219
+
220
+ function buildRequestMessages(
221
+ entries: MiniCPMoMessage[],
222
+ systemMessage?: string | MiniCPMoBackendContentItem[] | null,
223
+ ): Array<{ role: string; content: string | MiniCPMoBackendContentItem[] }> {
224
+ const messages: Array<{ role: string; content: string | MiniCPMoBackendContentItem[] }> = []
225
+
226
+ if (typeof systemMessage === 'string' && systemMessage.trim()) {
227
+ messages.push({ role: 'system', content: systemMessage.trim() })
228
+ } else if (Array.isArray(systemMessage) && systemMessage.length) {
229
+ messages.push({ role: 'system', content: systemMessage })
230
+ }
231
+
232
+ for (const entry of entries) {
233
+ if (entry.role === 'assistant') {
234
+ messages.push({ role: 'assistant', content: entry.text })
235
+ } else if (entry.kind === 'text') {
236
+ const atts = entry.attachments ?? []
237
+ if (atts.length === 0) {
238
+ messages.push({ role: 'user', content: entry.text })
239
+ } else {
240
+ const items: MiniCPMoBackendContentItem[] = []
241
+ for (const a of atts) {
242
+ if (a.kind === 'image') items.push({ type: 'image', data: a.base64 })
243
+ else if (a.kind === 'audio') items.push({ type: 'audio', data: a.base64, name: a.name, duration: a.duration })
244
+ else items.push({ type: 'video', data: a.base64, duration: a.duration })
245
+ }
246
+ if (entry.text) items.push({ type: 'text', text: entry.text })
247
+ messages.push({ role: 'user', content: items })
248
+ }
249
+ } else if (entry.kind === 'voice') {
250
+ const voiceAtts = entry.attachments ?? []
251
+ if (voiceAtts.length === 0) {
252
+ messages.push({ role: 'user', content: [{ type: 'audio', data: entry.audioBase64 }] })
253
+ } else {
254
+ const items: MiniCPMoBackendContentItem[] = []
255
+ for (const a of voiceAtts) {
256
+ if (a.kind === 'image') items.push({ type: 'image', data: a.base64 })
257
+ else if (a.kind === 'audio') items.push({ type: 'audio', data: a.base64, name: a.name, duration: a.duration })
258
+ else items.push({ type: 'video', data: a.base64, duration: a.duration })
259
+ }
260
+ items.push({ type: 'audio', data: entry.audioBase64 })
261
+ messages.push({ role: 'user', content: items })
262
+ }
263
+ }
264
+ }
265
+
266
+ return messages
267
+ }
268
+
269
+ // ─── Streaming PCM Player ───────────────────────────────
270
+
271
+ export class StreamingPcmPlayer {
272
+ private readonly audioCtx: AudioContext
273
+ private readonly sampleRate: number
274
+ private readonly chunks: Float32Array[] = []
275
+ private nextStartTime = 0
276
+ private finished = false
277
+ private disposed = false
278
+
279
+ constructor(sampleRate = 24000) {
280
+ this.sampleRate = sampleRate
281
+ const ctor = window.AudioContext ?? (window as any).webkitAudioContext
282
+ if (!ctor) throw new Error('AudioContext not supported')
283
+ this.audioCtx = new ctor({ sampleRate })
284
+ }
285
+
286
+ pushBase64(base64Data: string): void {
287
+ if (this.disposed) return
288
+ const binary = atob(base64Data)
289
+ const bytes = new Uint8Array(binary.length)
290
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
291
+ if (bytes.byteLength === 0) return
292
+ const float32 = new Float32Array(bytes.buffer.slice(0))
293
+ if (float32.length === 0) return
294
+ this.chunks.push(float32)
295
+ this.scheduleChunk(float32)
296
+ }
297
+
298
+ private scheduleChunk(float32: Float32Array): void {
299
+ if (this.audioCtx.state === 'suspended') void this.audioCtx.resume().catch(() => {})
300
+ const buffer = this.audioCtx.createBuffer(1, float32.length, this.sampleRate)
301
+ buffer.getChannelData(0).set(float32)
302
+ const source = this.audioCtx.createBufferSource()
303
+ source.buffer = buffer
304
+ source.connect(this.audioCtx.destination)
305
+ const now = this.audioCtx.currentTime
306
+ const when = Math.max(now + 0.02, this.nextStartTime)
307
+ source.start(when)
308
+ this.nextStartTime = when + buffer.duration
309
+ }
310
+
311
+ markFinished(): void { this.finished = true }
312
+ isFinished(): boolean { return this.finished }
313
+
314
+ getMergedFloat32(): Float32Array | null {
315
+ if (this.chunks.length === 0) return null
316
+ const total = this.chunks.reduce((sum, c) => sum + c.length, 0)
317
+ const merged = new Float32Array(total)
318
+ let offset = 0
319
+ for (const chunk of this.chunks) { merged.set(chunk, offset); offset += chunk.length }
320
+ return merged
321
+ }
322
+
323
+ getSampleRate(): number { return this.sampleRate }
324
+
325
+ async dispose(): Promise<void> {
326
+ this.disposed = true
327
+ try { await this.audioCtx.close() } catch { /* ignore */ }
328
+ }
329
+
330
+ disposeAfterDrain(onDrained?: () => void): void {
331
+ if (this.disposed) { onDrained?.(); return }
332
+ const now = this.audioCtx.currentTime
333
+ const drainSeconds = Math.max(0, this.nextStartTime - now)
334
+ setTimeout(() => { void this.dispose(); onDrained?.() }, Math.ceil(drainSeconds * 1000) + 500)
335
+ }
336
+ }
337
+
338
+ // ─── Chat submission ────────────────────────────────────
339
+
340
+ type ChatPayload = {
341
+ text?: string
342
+ error?: string
343
+ success?: boolean
344
+ audio_data?: string | null
345
+ audio_sample_rate?: number
346
+ recording_session_id?: string | null
347
+ }
348
+
349
+ export async function submitChatNonStreaming(
350
+ host: string,
351
+ messages: MiniCPMoMessage[],
352
+ systemMessage: string | null,
353
+ maxNewTokens: number,
354
+ lengthPenalty: number,
355
+ ttsEnabled: boolean,
356
+ signal?: AbortSignal,
357
+ ): Promise<{ entry: MiniCPMoMessage; sessionId: string | null }> {
358
+ const requestBody = JSON.stringify({
359
+ messages: buildRequestMessages(messages, systemMessage),
360
+ streaming: false,
361
+ generation: { max_new_tokens: maxNewTokens, length_penalty: lengthPenalty },
362
+ ...(ttsEnabled ? { use_tts_template: true } : {}),
363
+ tts: { enabled: ttsEnabled, mode: 'audio_assistant' },
364
+ })
365
+
366
+ const res = await fetch(`${host}/api/chat`, {
367
+ method: 'POST',
368
+ headers: { 'Content-Type': 'application/json' },
369
+ body: requestBody,
370
+ signal,
371
+ })
372
+
373
+ const rawText = await res.text()
374
+ let payload: ChatPayload
375
+ try { payload = JSON.parse(rawText) as ChatPayload } catch { throw new Error(rawText || `HTTP ${res.status}`) }
376
+ if (!res.ok || payload.success === false) throw new Error(payload.error || `HTTP ${res.status}`)
377
+
378
+ let audioUrl: string | null = null
379
+ const audioSampleRate = payload.audio_sample_rate ?? 24000
380
+ if (payload.audio_data) {
381
+ try { audioUrl = audioBase64ToBlobUrl(payload.audio_data, audioSampleRate) } catch { audioUrl = null }
382
+ }
383
+
384
+ return {
385
+ entry: {
386
+ id: createId('assistant'),
387
+ role: 'assistant',
388
+ kind: 'assistant',
389
+ text: payload.text?.trim() || '(empty reply)',
390
+ audioPreviewUrl: audioUrl,
391
+ audioBase64: payload.audio_data ?? null,
392
+ audioSampleRate: payload.audio_data ? audioSampleRate : null,
393
+ recordingSessionId: payload.recording_session_id ?? null,
394
+ },
395
+ sessionId: payload.recording_session_id ?? null,
396
+ }
397
+ }
398
+
399
+ export type StreamCallbacks = {
400
+ onChunk: (text: string) => void
401
+ onAudioBase64: (data: string, sampleRate: number) => void
402
+ onDone: (fullText: string, recordingSessionId: string | null) => void
403
+ onError: (error: string) => void
404
+ }
405
+
406
+ export function submitChatStreaming(
407
+ host: string,
408
+ messages: MiniCPMoMessage[],
409
+ systemMessage: string | null,
410
+ maxNewTokens: number,
411
+ lengthPenalty: number,
412
+ ttsEnabled: boolean,
413
+ callbacks: StreamCallbacks,
414
+ player?: StreamingPcmPlayer,
415
+ ): { abort: () => void } {
416
+ const wsProto = host.startsWith('https') ? 'wss:' : 'ws:'
417
+ const wsHost = host.replace(/^https?:\/\//, '')
418
+ const wsUrl = `${wsProto}//${wsHost}/ws/chat`
419
+ let finished = false
420
+ let ws: WebSocket | null = null
421
+ let fullText = ''
422
+
423
+ try { ws = new WebSocket(wsUrl) } catch (e) { callbacks.onError('WebSocket creation failed'); return { abort: () => {} } }
424
+
425
+ ws.onopen = () => {
426
+ ws!.send(JSON.stringify({
427
+ messages: buildRequestMessages(messages, systemMessage),
428
+ streaming: true,
429
+ generation: { max_new_tokens: maxNewTokens, length_penalty: lengthPenalty },
430
+ ...(ttsEnabled ? { use_tts_template: true } : {}),
431
+ tts: { enabled: ttsEnabled, mode: 'audio_assistant' },
432
+ }))
433
+ }
434
+
435
+ ws.onmessage = (event) => {
436
+ if (finished) return
437
+ let msg: any
438
+ try { msg = JSON.parse(event.data) } catch { return }
439
+
440
+ if (msg.type === 'prefill_done') return
441
+ if (msg.type === 'chunk') {
442
+ if (typeof msg.text_delta === 'string' && msg.text_delta) {
443
+ fullText += msg.text_delta
444
+ callbacks.onChunk(fullText)
445
+ }
446
+ if (msg.audio_data && player) {
447
+ try { player.pushBase64(msg.audio_data) } catch { /* ignore */ }
448
+ }
449
+ return
450
+ }
451
+ if (msg.type === 'done') {
452
+ finished = true
453
+ const finalText = (fullText || msg.text || '').trim() || '(empty reply)'
454
+ callbacks.onDone(finalText, msg.recording_session_id ?? null)
455
+ try { ws?.close() } catch { /* ignore */ }
456
+ return
457
+ }
458
+ if (msg.type === 'error') {
459
+ finished = true
460
+ callbacks.onError(msg.error || 'unknown error')
461
+ try { ws?.close() } catch { /* ignore */ }
462
+ }
463
+ }
464
+
465
+ ws.onerror = () => { if (!finished) { finished = true; callbacks.onError('WebSocket connection error') } }
466
+ ws.onclose = () => { if (!finished) { finished = true; callbacks.onError('WebSocket closed unexpectedly') } }
467
+
468
+ return {
469
+ abort: () => {
470
+ finished = true
471
+ try { ws?.close() } catch { /* ignore */ }
472
+ },
473
+ }
474
+ }
475
+
476
+ // ─── Microphone capture (ported from MiniCPM-o mobile) ──
477
+
478
+ function getPcmWorkletUrl(backendHost: string): string {
479
+ const host = backendHost.replace(/\/+$/, '')
480
+ return `${host}/static/duplex/lib/pcm-capture-turnbased.js`
481
+ }
482
+
483
+ export function getAudioContextCtor(): typeof AudioContext | null {
484
+ return window.AudioContext ?? (window as any).webkitAudioContext ?? null
485
+ }
486
+
487
+ type MicCaptureState = {
488
+ stream: MediaStream | null
489
+ ctx: AudioContext | null
490
+ source: MediaStreamAudioSourceNode | null
491
+ worklet: AudioWorkletNode | null
492
+ processor: ScriptProcessorNode | null
493
+ muteGain: GainNode | null
494
+ chunks: Float32Array[]
495
+ sampleRate: number
496
+ capturing: boolean
497
+ }
498
+
499
+ export function createMicCapture(): MicCaptureState {
500
+ return {
501
+ stream: null,
502
+ ctx: null,
503
+ source: null,
504
+ worklet: null,
505
+ processor: null,
506
+ muteGain: null,
507
+ chunks: [],
508
+ sampleRate: 16000,
509
+ capturing: false,
510
+ }
511
+ }
512
+
513
+ export async function prewarmMic(state: MicCaptureState, backendHost: string): Promise<boolean> {
514
+ if (state.ctx && state.stream) return true
515
+ const AudioContextCtor = getAudioContextCtor()
516
+ if (!AudioContextCtor || !navigator.mediaDevices?.getUserMedia) return false
517
+
518
+ try {
519
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
520
+ const ctx = new AudioContextCtor()
521
+ if (ctx.state === 'suspended') await ctx.resume().catch(() => {})
522
+ const source = ctx.createMediaStreamSource(stream)
523
+ state.stream = stream
524
+ state.ctx = ctx
525
+ state.source = source
526
+ state.chunks = []
527
+ state.sampleRate = ctx.sampleRate
528
+
529
+ if (typeof AudioWorkletNode !== 'undefined' && typeof ctx.audioWorklet?.addModule === 'function') {
530
+ try {
531
+ const workletUrl = getPcmWorkletUrl(backendHost)
532
+ await ctx.audioWorklet.addModule(workletUrl)
533
+ const node = new AudioWorkletNode(ctx, 'pcm-capture-turnbased')
534
+ node.port.onmessage = (event: MessageEvent) => {
535
+ const data = event.data as { type: string; samples: Float32Array } | undefined
536
+ if (data?.type === 'pcm' && state.capturing) state.chunks.push(data.samples)
537
+ }
538
+ source.connect(node)
539
+ state.worklet = node
540
+ return true
541
+ } catch {
542
+ // fall through to ScriptProcessor
543
+ }
544
+ }
545
+
546
+ // ScriptProcessor fallback
547
+ const processor = ctx.createScriptProcessor(4096, 1, 1)
548
+ const muteGain = ctx.createGain()
549
+ muteGain.gain.value = 0
550
+ processor.onaudioprocess = (event) => {
551
+ if (!state.capturing) return
552
+ const input = event.inputBuffer.getChannelData(0)
553
+ const copy = new Float32Array(input.length)
554
+ copy.set(input)
555
+ state.chunks.push(copy)
556
+ }
557
+ source.connect(processor)
558
+ processor.connect(muteGain)
559
+ muteGain.connect(ctx.destination)
560
+ state.processor = processor
561
+ state.muteGain = muteGain
562
+ return true
563
+ } catch {
564
+ return false
565
+ }
566
+ }
567
+
568
+ export function setCapturing(state: MicCaptureState, value: boolean) {
569
+ state.capturing = value
570
+ if (state.worklet) {
571
+ try { state.worklet.port.postMessage({ type: 'capture', value }) } catch { /* ignore */ }
572
+ }
573
+ }
574
+
575
+ export function coldDownMic(state: MicCaptureState) {
576
+ state.capturing = false
577
+ try { state.worklet?.port.postMessage({ type: 'capture', value: false }) } catch { /* ignore */ }
578
+ try { state.worklet?.disconnect() } catch { /* ignore */ }
579
+ try { state.processor?.disconnect() } catch { /* ignore */ }
580
+ try { state.source?.disconnect() } catch { /* ignore */ }
581
+ try { state.muteGain?.disconnect() } catch { /* ignore */ }
582
+ state.worklet = null
583
+ state.processor = null
584
+ state.source = null
585
+ state.muteGain = null
586
+ if (state.ctx && state.ctx.state !== 'closed') void state.ctx.close().catch(() => {})
587
+ state.ctx = null
588
+ state.stream?.getTracks().forEach((t) => t.stop())
589
+ state.stream = null
590
+ state.chunks = []
591
+ }
592
+
593
+ export function finalizeRecordingChunks(
594
+ state: MicCaptureState,
595
+ ): { audioBase64: string; previewUrl: string; durationMs: number } | null {
596
+ const chunks = state.chunks
597
+ if (chunks.length === 0) return null
598
+ const merged = concatFloat32(chunks)
599
+ if (merged.length === 0) return null
600
+ const resampled = resampleLinear(merged, state.sampleRate, 16000)
601
+ const audioBase64 = float32ToBase64(resampled)
602
+ const previewUrl = float32ToWavBlobUrl(resampled, 16000)
603
+ return { audioBase64, previewUrl, durationMs: 0 } // caller should compute actual duration
604
+ }
desktop/src/pages/Companion.tsx CHANGED
@@ -1,263 +1,1409 @@
1
- import { useEffect, useRef, useCallback } from 'react'
2
- import { useCompanionStore } from '../stores/companionStore'
3
- import { useCompanionWebSocket } from '../hooks/useCompanionWebSocket'
4
- import { useWebcam } from '../hooks/useWebcam'
5
- import { useMicrophone } from '../hooks/useMicrophone'
6
- import { useCompanionAudio } from '../hooks/useCompanionAudio'
7
- import { useScreenShare } from '../hooks/useScreenShare'
8
- import { useCameraDevices } from '../hooks/useCameraDevices'
9
- import { CompanionVideoPanel } from '../components/companion/CompanionVideoPanel'
10
- import { CompanionTranscript } from '../components/companion/CompanionTranscript'
11
- import { CompanionControls } from '../components/companion/CompanionControls'
12
- import { CompanionTopBar } from '../components/companion/CompanionTopBar'
13
  import { ScenarioSelector } from '../components/companion/ScenarioSelector'
14
- import { ScreenShareDialog } from '../components/companion/ScreenShareDialog'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- export function Companion() {
17
- const status = useCompanionStore((s) => s.status)
18
- const speaking = useCompanionStore((s) => s.speaking)
19
- const generating = useCompanionStore((s) => s.generating)
20
- const transcript = useCompanionStore((s) => s.transcript)
21
- const fullTranscript = useCompanionStore((s) => s.fullTranscript)
22
- const micEnabled = useCompanionStore((s) => s.micEnabled)
23
- const cameraEnabled = useCompanionStore((s) => s.cameraEnabled)
24
- const cameraFacingMode = useCompanionStore((s) => s.cameraFacingMode)
25
- const error = useCompanionStore((s) => s.error)
26
- const scenario = useCompanionStore((s) => s.scenario)
27
- const statusText = useCompanionStore((s) => s.statusText)
28
- const setMicEnabled = useCompanionStore((s) => s.setMicEnabled)
29
- const setCameraEnabled = useCompanionStore((s) => s.setCameraEnabled)
30
- const setCameraFacingMode = useCompanionStore((s) => s.setCameraFacingMode)
31
- const setCameraDevices = useCompanionStore((s) => s.setCameraDevices)
32
- const setActiveCameraLabel = useCompanionStore((s) => s.setActiveCameraLabel)
33
- const setCameraFullscreen = useCompanionStore((s) => s.setCameraFullscreen)
34
- const setScreenShareStream = useCompanionStore((s) => s.setScreenShareStream)
35
- const setScreenShareDialogOpen = useCompanionStore((s) => s.setScreenShareDialogOpen)
36
- const setStatusText = useCompanionStore((s) => s.setStatusText)
37
- const reset = useCompanionStore((s) => s.reset)
38
- const frameIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
39
- const screenShareIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
40
-
41
- // Audio playback
42
- const companionAudio = useCompanionAudio()
43
- const onAudioReceived = useCallback(
44
- (data: ArrayBuffer) => {
45
- companionAudio.enqueueAudio(data)
46
- },
47
- [companionAudio],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  )
49
- const onDone = useCallback(() => {
50
- companionAudio.flush()
51
- }, [companionAudio])
52
 
53
- // WebSocket
54
- const ws = useCompanionWebSocket({ onAudioReceived, onDone })
 
 
 
 
 
55
 
56
- // Camera devices
57
- const cameraDevices = useCameraDevices()
 
 
 
 
 
 
58
 
59
- // Screen share
60
- const screenShare = useScreenShare()
 
 
 
 
 
61
 
62
- // Webcam runs independently (toggle on/off without needing connection)
63
- const webcam = useWebcam(cameraEnabled)
64
- // Mic only runs when connected (sends audio over WS)
65
- useMicrophone(ws.connected && micEnabled, ws.sendAudio)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
- // Enumerate cameras when camera is enabled
68
  useEffect(() => {
69
- if (cameraEnabled) {
70
- cameraDevices.enumerateCameras().then((devices) => {
71
- setCameraDevices(devices)
72
- if (devices.length > 0) {
73
- setActiveCameraLabel(devices[0]?.label ?? null)
74
- }
75
- })
 
 
 
 
 
 
 
76
  }
77
- }, [cameraEnabled, cameraDevices, setCameraDevices, setActiveCameraLabel])
78
 
79
- // Update status text based on state
80
- useEffect(() => {
81
- if (status === 'connected') {
82
- if (generating) {
83
- setStatusText('AI 说话中...')
84
- } else if (speaking) {
85
- setStatusText('请说话')
86
- } else {
87
- setStatusText('你可以开始说话')
 
 
 
 
 
 
 
 
88
  }
89
- } else if (status === 'connecting') {
90
- setStatusText('连接中...')
91
- } else {
92
- setStatusText('')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  }
94
- }, [status, generating, speaking, setStatusText])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
- // Periodic webcam frame capture (only send when connected)
97
  useEffect(() => {
98
- if (ws.connected && cameraEnabled) {
99
- frameIntervalRef.current = setInterval(async () => {
100
- const frame = await webcam.captureFrame()
101
- if (frame) {
102
- ws.sendFrame(frame)
103
- }
104
- }, 1000)
105
- } else {
106
- if (frameIntervalRef.current) {
107
- clearInterval(frameIntervalRef.current)
108
- frameIntervalRef.current = null
109
- }
110
  }
111
- return () => {
112
- if (frameIntervalRef.current) {
113
- clearInterval(frameIntervalRef.current)
114
- frameIntervalRef.current = null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  }
116
  }
117
- }, [ws.connected, cameraEnabled, webcam, ws])
 
118
 
119
- // Periodic screen share frame capture
120
- useEffect(() => {
121
- if (ws.connected && screenShare.isSharing) {
122
- screenShareIntervalRef.current = setInterval(async () => {
123
- const frame = await screenShare.captureFrame()
124
- if (frame) {
125
- ws.sendFrame(frame)
126
- }
127
- }, 1000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  } else {
129
- if (screenShareIntervalRef.current) {
130
- clearInterval(screenShareIntervalRef.current)
131
- screenShareIntervalRef.current = null
 
 
 
 
 
132
  }
 
133
  }
134
- return () => {
135
- if (screenShareIntervalRef.current) {
136
- clearInterval(screenShareIntervalRef.current)
137
- screenShareIntervalRef.current = null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  }
139
  }
140
- }, [ws.connected, screenShare.isSharing, screenShare, ws])
141
 
142
- // Send scenario context when connecting
143
- useEffect(() => {
144
- if (ws.connected && scenario) {
145
- ws.sendText(`[Scenario: ${scenario}]`)
 
 
 
 
 
 
 
 
 
 
 
146
  }
147
- }, [ws.connected]) // only send on connect
 
 
 
 
148
 
149
- // Sync screen share stream to store
150
- useEffect(() => {
151
- setScreenShareStream(screenShare.stream)
152
- }, [screenShare.stream, setScreenShareStream])
153
-
154
- const handleSendText = useCallback(
155
- (text: string) => {
156
- ws.sendText(text)
157
- },
158
- [ws],
159
- )
160
 
161
- const handleResumeAudio = useCallback(() => {
162
- companionAudio.resume()
163
- }, [companionAudio])
164
 
165
- const handleToggleMic = useCallback(() => {
166
- setMicEnabled(!micEnabled)
167
- }, [micEnabled, setMicEnabled])
168
 
169
- const handleToggleCamera = useCallback(() => {
170
- const next = !cameraEnabled
171
- setCameraEnabled(next)
172
- // When camera turns on, show as fullscreen background
173
- if (next) {
174
- setCameraFullscreen(true)
175
- } else {
176
- setCameraFullscreen(false)
 
 
 
 
 
 
177
  }
178
- }, [cameraEnabled, setCameraEnabled, setCameraFullscreen])
 
179
 
180
- const handleFlipCamera = useCallback(async () => {
181
- const nextMode = cameraFacingMode === 'user' ? 'environment' : 'user'
182
- setCameraFacingMode(nextMode)
183
- await cameraDevices.getStreamForFacingMode(nextMode)
184
- }, [cameraFacingMode, setCameraFacingMode, cameraDevices])
185
 
186
- const handleScreenShareClick = useCallback(() => {
187
- if (screenShare.isSharing) {
188
- screenShare.stopScreenShare()
189
- } else {
190
- setScreenShareDialogOpen(true)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  }
192
- }, [screenShare, setScreenShareDialogOpen])
193
 
194
- const handleStartScreenShare = useCallback(async () => {
195
- await screenShare.startScreenShare()
196
- }, [screenShare])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
  return (
199
- <div className="relative h-full w-full overflow-hidden bg-black">
200
- {/* Video background + content */}
201
- <CompanionVideoPanel
202
- webcamStream={webcam.stream}
203
- speaking={speaking}
204
- generating={generating}
205
- status={status}
206
- onFlipCamera={handleFlipCamera}
207
- />
208
 
209
- {/* Top bar with scenario selector + subtitle toggle */}
210
- <CompanionTopBar />
 
 
 
 
 
 
 
211
 
212
- {/* Scenario selection panel overlay */}
213
- <ScenarioSelector />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
- {/* Screen share dialog overlay */}
216
- <ScreenShareDialog onStartScreenShare={handleStartScreenShare} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
- {/* Error overlay */}
219
  {error && (
220
- <div className="absolute top-14 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl bg-red-500/20 border border-red-500/30 text-red-300 text-sm z-20">
221
  {error}
222
  </div>
223
  )}
224
 
225
- {/* Status text bar */}
226
- {statusText && (
227
- <div className="absolute bottom-[88px] left-1/2 -translate-x-1/2 z-10">
228
- <span className="text-white/30 text-xs tracking-widest">{statusText}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  </div>
230
  )}
231
 
232
- {/* Transcript + text input */}
233
- <CompanionTranscript
234
- transcript={transcript}
235
- fullTranscript={fullTranscript}
236
- generating={generating || speaking}
237
- onSendText={handleSendText}
238
- disabled={status !== 'connected'}
239
- />
 
 
 
 
 
 
240
 
241
- {/* Bottom controls */}
242
- <CompanionControls
243
- status={status}
244
- micEnabled={micEnabled}
245
- cameraEnabled={cameraEnabled}
246
- generating={generating}
247
- isSharing={screenShare.isSharing}
248
- onToggleMic={handleToggleMic}
249
- onToggleCamera={handleToggleCamera}
250
- onScreenShareClick={handleScreenShareClick}
251
- onConnect={ws.connect}
252
- onDisconnect={() => {
253
- ws.disconnect()
254
- if (screenShare.isSharing) {
255
- screenShare.stopScreenShare()
256
- }
257
- reset()
258
- }}
259
- onStop={ws.sendStop}
260
- onResumeAudio={handleResumeAudio}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  />
262
  </div>
263
  )
 
1
+ import { useEffect, useRef, useState, useCallback } from 'react'
2
+ import { useCompanionStore, SCENARIOS } from '../stores/companionStore'
 
 
 
 
 
 
 
 
 
 
3
  import { ScenarioSelector } from '../components/companion/ScenarioSelector'
4
+ import {
5
+ fetchServiceStatus,
6
+ fetchPresets,
7
+ submitChatNonStreaming,
8
+ submitChatStreaming,
9
+ StreamingPcmPlayer,
10
+ createMicCapture,
11
+ prewarmMic,
12
+ setCapturing,
13
+ coldDownMic,
14
+ finalizeRecordingChunks,
15
+ downscaleImageToAttachment,
16
+ mediaFileToAttachment,
17
+ float32ToWavBlobUrl,
18
+ } from '../hooks/useMiniCPMoBackend'
19
+ import type { MiniCPMoMessage, MiniCPMoAttachment } from '../types/companion'
20
+ import type { MiniCPMoSession } from '../stores/companionStore'
21
 
22
+ // ─── Helpers ─────────────────────────────────────────────
23
+
24
+ function createId(prefix: string): string {
25
+ return `${prefix}-${Math.random().toString(36).slice(2, 10)}`
26
+ }
27
+
28
+ const CANCEL_DRAG_PX = 80
29
+
30
+ function formatDurationMs(durationMs: number): string {
31
+ return `${(durationMs / 1000).toFixed(1)}s`
32
+ }
33
+
34
+ function formatRelativeTime(ts: number): string {
35
+ const diff = Date.now() - ts
36
+ if (diff < 60_000) return '刚刚'
37
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分钟前`
38
+ const d = new Date(ts)
39
+ const today = new Date()
40
+ if (today.getFullYear() === d.getFullYear() && today.getMonth() === d.getMonth() && today.getDate() === d.getDate()) {
41
+ return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
42
+ }
43
+ const yesterday = new Date(Date.now() - 86_400_000)
44
+ if (yesterday.getFullYear() === d.getFullYear() && yesterday.getMonth() === d.getMonth() && yesterday.getDate() === d.getDate()) {
45
+ return '昨天'
46
+ }
47
+ return `${d.getMonth() + 1}/${d.getDate()}`
48
+ }
49
+
50
+ function deriveSessionTitle(messages: MiniCPMoMessage[]): string {
51
+ for (const m of messages) {
52
+ if (m.role !== 'user') continue
53
+ if (m.kind === 'text' && m.text.trim()) {
54
+ const txt = m.text.trim().replace(/\s+/g, ' ')
55
+ return txt.length > 28 ? `${txt.slice(0, 28)}…` : txt
56
+ }
57
+ if (m.kind === 'voice') return '语音消息'
58
+ if (m.kind === 'text' && m.attachments?.length) {
59
+ const a = m.attachments[0]
60
+ if (a && a.kind === 'image') return '图片消息'
61
+ if (a && a.kind === 'audio') return '音频消息'
62
+ if (a && a.kind === 'video') return '视频消息'
63
+ }
64
+ }
65
+ return '新的对话'
66
+ }
67
+
68
+ // ─── SVG Icon Components ─────────────────────────────────
69
+
70
+ function IconHamburger({ className }: { className?: string }) {
71
+ return (
72
+ <svg className={className} fill="none" viewBox="0 0 24 24">
73
+ <path d="M4.5 7h15M4.5 12h15M4.5 17h15" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
74
+ </svg>
75
+ )
76
+ }
77
+
78
+ function IconSettings({ className }: { className?: string }) {
79
+ return (
80
+ <svg className={className} fill="none" viewBox="0 0 24 24">
81
+ <path d="M10.3 4.8h3.4l.5 2.1a5.8 5.8 0 0 1 1.5.9l2-.7 1.7 2.9-1.5 1.4a6 6 0 0 1 0 1.7l1.5 1.4-1.7 2.9-2-.7a5.8 5.8 0 0 1-1.5.9l-.5 2.1h-3.4l-.5-2.1a5.8 5.8 0 0 1-1.5-.9l-2 .7-1.7-2.9 1.5-1.4a6 6 0 0 1 0-1.7L4.6 10l1.7-2.9 2 .7a5.8 5.8 0 0 1 1.5-.9Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.6" />
82
+ <circle cx="12" cy="12" r="2.5" stroke="currentColor" strokeWidth="1.6" />
83
+ </svg>
84
+ )
85
+ }
86
+
87
+ function IconSend({ className }: { className?: string }) {
88
+ return (
89
+ <svg className={className} fill="none" viewBox="0 0 24 24">
90
+ <path d="M4.5 11.5 19 5l-4.5 14-2.6-5-7.4-2.5Z" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
91
+ <path d="M19 5 11.8 14" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
92
+ </svg>
93
+ )
94
+ }
95
+
96
+ function IconStop({ className }: { className?: string }) {
97
+ return (
98
+ <svg className={className} fill="none" viewBox="0 0 24 24">
99
+ <rect x="7" y="7" width="10" height="10" rx="2.4" fill="currentColor" />
100
+ </svg>
101
+ )
102
+ }
103
+
104
+ function IconKeyboard({ className }: { className?: string }) {
105
+ return (
106
+ <svg className={className} fill="none" viewBox="0 0 24 24">
107
+ <rect x="3.5" y="6" width="17" height="12" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
108
+ <path d="M7.5 10h9M7.5 13h4.5M14 13h2.5M7.5 16h7" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
109
+ </svg>
110
  )
111
+ }
 
 
112
 
113
+ function IconWave({ className }: { className?: string }) {
114
+ return (
115
+ <svg className={className} fill="none" viewBox="0 0 24 24">
116
+ <path d="M4 13h2l1.4-4 2.4 9 2.4-12 2.1 7H20" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
117
+ </svg>
118
+ )
119
+ }
120
 
121
+ function IconPlus({ className }: { className?: string }) {
122
+ return (
123
+ <svg className={className} fill="none" viewBox="0 0 24 24">
124
+ <circle cx="12" cy="12" r="9.2" stroke="currentColor" strokeWidth="1.6" />
125
+ <path d="M12 8v8M8 12h8" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
126
+ </svg>
127
+ )
128
+ }
129
 
130
+ function IconClose({ className }: { className?: string }) {
131
+ return (
132
+ <svg className={className} fill="none" viewBox="0 0 24 24">
133
+ <path d="m8 8 8 8M16 8l-8 8" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
134
+ </svg>
135
+ )
136
+ }
137
 
138
+ function IconPlay({ className }: { className?: string }) {
139
+ return (
140
+ <svg className={className} fill="none" viewBox="0 0 24 24">
141
+ <path d="M9 7.5v9l7-4.5-7-4.5Z" fill="currentColor" />
142
+ </svg>
143
+ )
144
+ }
145
+
146
+ function IconPause({ className }: { className?: string }) {
147
+ return (
148
+ <svg className={className} fill="none" viewBox="0 0 24 24">
149
+ <rect x="7" y="6" width="3.2" height="12" rx="1.2" fill="currentColor" />
150
+ <rect x="13.8" y="6" width="3.2" height="12" rx="1.2" fill="currentColor" />
151
+ </svg>
152
+ )
153
+ }
154
+
155
+ function IconSpeaker({ className }: { className?: string }) {
156
+ return (
157
+ <svg className={className} fill="none" viewBox="0 0 24 24">
158
+ <path d="M11 5 6.5 9H3.5C2.67 9 2 9.67 2 10.5v3c0 .83.67 1.5 1.5 1.5h3L11 19V5Z" fill="currentColor" />
159
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
160
+ <path d="M18.36 5.64a9 9 0 0 1 0 12.73" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
161
+ </svg>
162
+ )
163
+ }
164
+
165
+ function IconCamera({ className }: { className?: string }) {
166
+ return (
167
+ <svg className={className} fill="none" viewBox="0 0 24 24">
168
+ <path d="M5 9.5A2.5 2.5 0 0 1 7.5 7h1.6l1.4-2h3l1.4 2h1.6A2.5 2.5 0 0 1 19 9.5v7A2.5 2.5 0 0 1 16.5 19h-9A2.5 2.5 0 0 1 5 16.5Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.6" />
169
+ <circle cx="12" cy="13" r="3.2" stroke="currentColor" strokeWidth="1.6" />
170
+ </svg>
171
+ )
172
+ }
173
+
174
+ function IconPhoto({ className }: { className?: string }) {
175
+ return (
176
+ <svg className={className} fill="none" viewBox="0 0 24 24">
177
+ <rect x="3.5" y="5.5" width="17" height="13" rx="2.2" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.6" />
178
+ <circle cx="9" cy="10.5" r="1.6" stroke="currentColor" strokeWidth="1.5" />
179
+ <path d="M3.7 16.5 9 12l4 3.5 3-2.5 4.3 4" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6" />
180
+ </svg>
181
+ )
182
+ }
183
+
184
+ function IconFile({ className }: { className?: string }) {
185
+ return (
186
+ <svg className={className} fill="none" viewBox="0 0 24 24">
187
+ <path d="M14 3.5H7.5A2 2 0 0 0 5.5 5.5v13a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V8.2L14 3.5Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.6" />
188
+ <path d="M13.5 3.5v4.7h5" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.6" />
189
+ </svg>
190
+ )
191
+ }
192
+
193
+ function IconCopy({ className }: { className?: string }) {
194
+ return (
195
+ <svg className={className} fill="none" viewBox="0 0 24 24">
196
+ <rect x="8.5" y="8.5" width="10" height="11" rx="2.2" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.6" />
197
+ <path d="M15.5 6h-7A2 2 0 0 0 6.5 8v9" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6" />
198
+ </svg>
199
+ )
200
+ }
201
+
202
+ function IconRefresh({ className }: { className?: string }) {
203
+ return (
204
+ <svg className={className} fill="none" viewBox="0 0 24 24">
205
+ <path d="M5.5 12a6.5 6.5 0 0 1 11.2-4.5L19 10" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
206
+ <path d="M19 5v5h-5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
207
+ <path d="M18.5 12a6.5 6.5 0 0 1-11.2 4.5L5 14" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
208
+ <path d="M5 19v-5h5" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
209
+ </svg>
210
+ )
211
+ }
212
+
213
+ function IconTrash({ className }: { className?: string }) {
214
+ return (
215
+ <svg className={className} fill="none" viewBox="0 0 24 24">
216
+ <path d="M5 7h14M9.5 7V5.5a1.5 1.5 0 0 1 1.5-1.5h2a1.5 1.5 0 0 1 1.5 1.5V7m-7.5 0 .8 11.2a1.8 1.8 0 0 0 1.8 1.6h6.8a1.8 1.8 0 0 0 1.8-1.6L17.5 7" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6" />
217
+ <path d="M10.5 11v5M13.5 11v5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.6" />
218
+ </svg>
219
+ )
220
+ }
221
+
222
+ function IconEdit({ className }: { className?: string }) {
223
+ return (
224
+ <svg className={className} fill="none" viewBox="0 0 24 24">
225
+ <path d="M5 6.8A2.2 2.2 0 0 1 7.2 4.6h5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.6" />
226
+ <path d="M19.4 11v5.8a2.2 2.2 0 0 1-2.2 2.2H7.2A2.2 2.2 0 0 1 5 16.8V11" stroke="currentColor" strokeLinecap="round" strokeWidth="1.6" />
227
+ <path d="m13.6 11.5 6-6a1.6 1.6 0 0 1 2.3 2.3l-6 6-2.7.4.4-2.7Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.6" />
228
+ </svg>
229
+ )
230
+ }
231
+
232
+ // ─── Audio Play Pill ────────────────────────────────────
233
+
234
+ function AudioPlayPill({ url, className = '' }: { url: string; className?: string }) {
235
+ const [playing, setPlaying] = useState(false)
236
+ const audioRef = useRef<HTMLAudioElement | null>(null)
237
 
 
238
  useEffect(() => {
239
+ const audio = new Audio(url)
240
+ audioRef.current = audio
241
+ const onPlay = () => setPlaying(true)
242
+ const onPause = () => setPlaying(false)
243
+ const onEnded = () => setPlaying(false)
244
+ audio.addEventListener('play', onPlay)
245
+ audio.addEventListener('pause', onPause)
246
+ audio.addEventListener('ended', onEnded)
247
+ return () => {
248
+ audio.removeEventListener('play', onPlay)
249
+ audio.removeEventListener('pause', onPause)
250
+ audio.removeEventListener('ended', onEnded)
251
+ try { audio.pause() } catch { /* ignore */ }
252
+ audioRef.current = null
253
  }
254
+ }, [url])
255
 
256
+ return (
257
+ <button
258
+ type="button"
259
+ onClick={() => {
260
+ const a = audioRef.current
261
+ if (!a) return
262
+ if (playing) { a.pause(); return }
263
+ try { a.currentTime = 0 } catch { /* ignore */ }
264
+ void a.play().catch(() => setPlaying(false))
265
+ }}
266
+ className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors ${
267
+ playing ? 'bg-orange-500/30 text-orange-200' : 'bg-white/10 text-white/70 hover:bg-white/20'
268
+ } ${className}`}
269
+ >
270
+ {playing
271
+ ? <IconPause className="w-3.5 h-3.5" />
272
+ : <IconPlay className="w-3.5 h-3.5" />
273
  }
274
+ <span>{playing ? '暂停' : '播放'}</span>
275
+ </button>
276
+ )
277
+ }
278
+
279
+ // ─── Copy Button ────────────────────────────────────────
280
+
281
+ function CopyButton({ text }: { text: string }) {
282
+ const [copied, setCopied] = useState(false)
283
+ useEffect(() => {
284
+ if (!copied) return
285
+ const t = setTimeout(() => setCopied(false), 1200)
286
+ return () => clearTimeout(t)
287
+ }, [copied])
288
+
289
+ return (
290
+ <button
291
+ type="button"
292
+ onClick={async () => {
293
+ try {
294
+ if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text)
295
+ setCopied(true)
296
+ } catch { /* ignore */ }
297
+ }}
298
+ className="w-7 h-7 flex items-center justify-center rounded-lg bg-white/5 text-white/40 hover:bg-white/10 hover:text-white/70 transition-colors"
299
+ title={copied ? '已复制' : '复制'}
300
+ >
301
+ <IconCopy className="w-3.5 h-3.5" />
302
+ </button>
303
+ )
304
+ }
305
+
306
+ // ─── Message Attachment ─────────────────────────────────
307
+
308
+ function MessageAttachment({ attachment }: { attachment: MiniCPMoAttachment }) {
309
+ if (attachment.kind === 'image') {
310
+ return <img src={attachment.previewUrl} alt={attachment.name} className="max-w-full rounded-lg mb-1 max-h-60 object-cover" />
311
+ }
312
+ if (attachment.kind === 'audio') {
313
+ return <AudioPlayPill url={attachment.previewUrl} className="mb-1" />
314
+ }
315
+ return (
316
+ <video src={attachment.previewUrl} controls preload="metadata" playsInline className="max-w-full rounded-lg mb-1 max-h-60" />
317
+ )
318
+ }
319
+
320
+ // ─── Message Bubble ─────────────────────────────────────
321
+
322
+ function MessageBubble({
323
+ msg,
324
+ isLastAssistant,
325
+ isStreaming,
326
+ onRegenerate,
327
+ }: {
328
+ msg: MiniCPMoMessage
329
+ isLastAssistant: boolean
330
+ isStreaming: boolean
331
+ onRegenerate?: () => void
332
+ }) {
333
+ const [audioPlaying, setAudioPlaying] = useState(false)
334
+ const audioRef = useRef<HTMLAudioElement | null>(null)
335
+
336
+ const audioUrl = msg.role === 'assistant' && msg.audioPreviewUrl ? msg.audioPreviewUrl : null
337
+
338
+ useEffect(() => {
339
+ if (!audioUrl) return
340
+ const audio = new Audio(audioUrl)
341
+ audioRef.current = audio
342
+ const onPlay = () => setAudioPlaying(true)
343
+ const onPause = () => setAudioPlaying(false)
344
+ const onEnded = () => setAudioPlaying(false)
345
+ audio.addEventListener('play', onPlay)
346
+ audio.addEventListener('pause', onPause)
347
+ audio.addEventListener('ended', onEnded)
348
+ return () => {
349
+ audio.removeEventListener('play', onPlay)
350
+ audio.removeEventListener('pause', onPause)
351
+ audio.removeEventListener('ended', onEnded)
352
+ try { audio.pause() } catch { /* ignore */ }
353
+ audioRef.current = null
354
  }
355
+ }, [audioUrl])
356
+
357
+ if (msg.role === 'user' && msg.kind === 'voice') {
358
+ const voiceAtts = msg.attachments ?? []
359
+ return (
360
+ <div className="flex justify-end mb-3">
361
+ <div className="max-w-[80%]">
362
+ {voiceAtts.length > 0 && (
363
+ <div className="flex flex-wrap gap-1 mb-1 justify-end">
364
+ {voiceAtts.map((a) => <MessageAttachment key={a.id} attachment={a} />)}
365
+ </div>
366
+ )}
367
+ <div className="bg-orange-500/20 text-white rounded-2xl rounded-br-md px-3.5 py-2.5 inline-flex items-center gap-2">
368
+ <AudioPlayPill url={msg.previewUrl} />
369
+ <div className="text-white/50 text-xs">{formatDurationMs(msg.durationMs)}</div>
370
+ </div>
371
+ </div>
372
+ </div>
373
+ )
374
+ }
375
+
376
+ const isAssistant = msg.role === 'assistant'
377
+ const attachments = !isAssistant && msg.kind === 'text' ? msg.attachments ?? [] : []
378
+
379
+ return (
380
+ <div className={`flex mb-3 ${isAssistant ? 'justify-start' : 'justify-end'}`}>
381
+ <div className={`max-w-[80%] ${isAssistant ? '' : 'items-end'}`}>
382
+ {attachments.length > 0 && (
383
+ <div className="flex flex-wrap gap-1 mb-1 justify-end">
384
+ {attachments.map((a) => <MessageAttachment key={a.id} attachment={a} />)}
385
+ </div>
386
+ )}
387
+ <div
388
+ className={`rounded-2xl px-3.5 py-2.5 ${
389
+ isAssistant
390
+ ? msg.error
391
+ ? 'bg-red-500/10 text-red-300 rounded-bl-md'
392
+ : 'bg-white/8 text-white/90 rounded-bl-md'
393
+ : 'bg-orange-500/20 text-white rounded-br-md'
394
+ }`}
395
+ >
396
+ {msg.text && <div className="text-sm leading-relaxed whitespace-pre-wrap break-words">{msg.text}</div>}
397
+ {isAssistant && msg.interrupted && (
398
+ <div className="text-xs text-white/30 mt-1">[已中断]</div>
399
+ )}
400
+ </div>
401
+ {isAssistant && !msg.error && !isStreaming && (
402
+ <div className="flex items-center gap-1.5 mt-1.5 ml-1">
403
+ <CopyButton text={msg.text} />
404
+ {audioUrl && (
405
+ <button
406
+ type="button"
407
+ onClick={() => {
408
+ const a = audioRef.current
409
+ if (!a) return
410
+ if (audioPlaying) { a.pause(); return }
411
+ try { a.currentTime = 0 } catch { /* ignore */ }
412
+ void a.play().catch(() => setAudioPlaying(false))
413
+ }}
414
+ className={`w-7 h-7 flex items-center justify-center rounded-lg transition-colors ${
415
+ audioPlaying ? 'bg-orange-500/20 text-orange-300' : 'bg-white/5 text-white/40 hover:bg-white/10 hover:text-white/70'
416
+ }`}
417
+ title={audioPlaying ? '停止播放' : '朗读'}
418
+ >
419
+ {audioPlaying ? <IconPause className="w-3.5 h-3.5" /> : <IconSpeaker className="w-3.5 h-3.5" />}
420
+ </button>
421
+ )}
422
+ {isLastAssistant && onRegenerate && (
423
+ <button
424
+ type="button"
425
+ onClick={onRegenerate}
426
+ className="w-7 h-7 flex items-center justify-center rounded-lg bg-white/5 text-white/40 hover:bg-white/10 hover:text-white/70 transition-colors"
427
+ title="重新生成"
428
+ >
429
+ <IconRefresh className="w-3.5 h-3.5" />
430
+ </button>
431
+ )}
432
+ </div>
433
+ )}
434
+ </div>
435
+ </div>
436
+ )
437
+ }
438
+
439
+ // ─── Pending Reply ──────────────────────────────────────
440
+
441
+ function PendingReply({ text }: { text: string }) {
442
+ return (
443
+ <div className="flex justify-start mb-3">
444
+ <div className="max-w-[80%] bg-white/8 text-white/90 rounded-2xl rounded-bl-md px-3.5 py-2.5">
445
+ <span className="text-sm whitespace-pre-wrap break-words">{text}</span>
446
+ {!text && (
447
+ <span className="inline-flex gap-1">
448
+ <span className="w-1.5 h-1.5 rounded-full bg-white/30 animate-bounce" style={{ animationDelay: '0s' }} />
449
+ <span className="w-1.5 h-1.5 rounded-full bg-white/30 animate-bounce" style={{ animationDelay: '0.15s' }} />
450
+ <span className="w-1.5 h-1.5 rounded-full bg-white/30 animate-bounce" style={{ animationDelay: '0.3s' }} />
451
+ </span>
452
+ )}
453
+ </div>
454
+ </div>
455
+ )
456
+ }
457
+
458
+ // ─── Settings Sheet ─────────────────────────────────────
459
+
460
+ function SettingsSheet({
461
+ open,
462
+ onClose,
463
+ }: {
464
+ open: boolean
465
+ onClose: () => void
466
+ }) {
467
+ const backendHost = useCompanionStore((s) => s.backendHost)
468
+ const systemPrompt = useCompanionStore((s) => s.miniSystemPromptTurnbased)
469
+ const maxNewTokens = useCompanionStore((s) => s.miniMaxNewTokens)
470
+ const lengthPenalty = useCompanionStore((s) => s.miniLengthPenalty)
471
+ const ttsEnabled = useCompanionStore((s) => s.miniTtsEnabled)
472
+ const streamingEnabled = useCompanionStore((s) => s.miniStreamingEnabled)
473
+ const presets = useCompanionStore((s) => s.miniPresetsByMode)
474
+ const activePresetId = useCompanionStore((s) => s.miniSettingsSheetMode)
475
+
476
+ const setBackendHost = useCompanionStore((s) => s.setBackendHost)
477
+ const setMiniSystemPromptTurnbased = useCompanionStore((s) => s.setMiniSystemPromptTurnbased)
478
+ const setMiniMaxNewTokens = useCompanionStore((s) => s.setMiniMaxNewTokens)
479
+ const setMiniLengthPenalty = useCompanionStore((s) => s.setMiniLengthPenalty)
480
+ const setMiniTtsEnabled = useCompanionStore((s) => s.setMiniTtsEnabled)
481
+ const setMiniStreamingEnabled = useCompanionStore((s) => s.setMiniStreamingEnabled)
482
+ const setMiniSettingsSheetMode = useCompanionStore((s) => s.setMiniSettingsSheetMode)
483
+ const setMiniSystemPrompt = useCompanionStore((s) => s.setMiniSystemPromptTurnbased)
484
+
485
+ if (!open) return null
486
+
487
+ const currentPresets = presets.turnbased ?? []
488
+ const modes: Array<{ key: string; label: string }> = [
489
+ { key: 'turnbased', label: '文字对话' },
490
+ { key: 'audio_duplex', label: '语音通话' },
491
+ { key: 'omni', label: '视频通话' },
492
+ ]
493
+
494
+ return (
495
+ <div className="absolute inset-0 z-50 flex items-end justify-center" onClick={onClose}>
496
+ <div className="absolute inset-0 bg-black/50" />
497
+ <div
498
+ className="relative w-full max-w-lg max-h-[80vh] bg-[#1c1c1c] border border-white/10 rounded-t-2xl overflow-y-auto"
499
+ onClick={(e) => e.stopPropagation()}
500
+ >
501
+ <div className="sticky top-0 bg-[#1c1c1c] px-5 pt-4 pb-3 border-b border-white/10 flex items-center justify-between">
502
+ <h2 className="text-white/90 text-base font-semibold">设置</h2>
503
+ <button type="button" onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-white/10 text-white/50">
504
+ <IconClose className="w-5 h-5" />
505
+ </button>
506
+ </div>
507
+
508
+ <div className="p-5 space-y-5">
509
+ {/* Mode selector */}
510
+ <div className="flex gap-2">
511
+ {modes.map((m) => (
512
+ <button
513
+ key={m.key}
514
+ type="button"
515
+ onClick={() => setMiniSettingsSheetMode(m.key as any)}
516
+ className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
517
+ activePresetId === m.key ? 'bg-orange-500/20 text-orange-300 border border-orange-400/30' : 'bg-white/5 text-white/50 hover:bg-white/10'
518
+ }`}
519
+ >
520
+ {m.label}
521
+ </button>
522
+ ))}
523
+ </div>
524
+
525
+ {/* Backend host */}
526
+ <div>
527
+ <label className="text-white/50 text-xs block mb-1.5">后端地址</label>
528
+ <input
529
+ type="text"
530
+ value={backendHost}
531
+ onChange={(e) => setBackendHost(e.target.value)}
532
+ className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/80 outline-none focus:border-orange-400/40"
533
+ placeholder="http://localhost:8006"
534
+ />
535
+ </div>
536
+
537
+ {/* Presets */}
538
+ <div>
539
+ <label className="text-white/50 text-xs block mb-1.5">预设</label>
540
+ <div className="flex flex-wrap gap-1.5">
541
+ {currentPresets.map((p) => (
542
+ <button
543
+ key={p.id}
544
+ type="button"
545
+ className="px-2.5 py-1 rounded-lg text-xs bg-white/5 text-white/60 hover:bg-white/10 transition-colors"
546
+ onClick={() => {
547
+ if (p.system_prompt) setMiniSystemPrompt(p.system_prompt)
548
+ }}
549
+ >
550
+ {p.name}
551
+ </button>
552
+ ))}
553
+ {currentPresets.length === 0 && (
554
+ <span className="text-xs text-white/30">未获取到预设</span>
555
+ )}
556
+ </div>
557
+ </div>
558
+
559
+ {/* System prompt */}
560
+ <div>
561
+ <label className="text-white/50 text-xs block mb-1.5" htmlFor="sys-prompt">系统提示词</label>
562
+ <textarea
563
+ id="sys-prompt"
564
+ value={systemPrompt}
565
+ onChange={(e) => setMiniSystemPromptTurnbased(e.target.value)}
566
+ className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/80 outline-none focus:border-orange-400/40 resize-none"
567
+ rows={4}
568
+ placeholder="输入系统提示词..."
569
+ />
570
+ </div>
571
+
572
+ {/* Params */}
573
+ <div className="grid grid-cols-2 gap-3">
574
+ <div>
575
+ <label className="text-white/50 text-xs block mb-1">最大 Token</label>
576
+ <input
577
+ type="number"
578
+ value={maxNewTokens}
579
+ onChange={(e) => setMiniMaxNewTokens(Number(e.target.value))}
580
+ className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/80 outline-none focus:border-orange-400/40"
581
+ min={1}
582
+ max={2048}
583
+ />
584
+ </div>
585
+ <div>
586
+ <label className="text-white/50 text-xs block mb-1">长度惩罚</label>
587
+ <input
588
+ type="number"
589
+ value={lengthPenalty}
590
+ onChange={(e) => setMiniLengthPenalty(Number(e.target.value))}
591
+ className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white/80 outline-none focus:border-orange-400/40"
592
+ min={0.1}
593
+ max={5}
594
+ step={0.05}
595
+ />
596
+ </div>
597
+ </div>
598
+
599
+ {/* Toggles */}
600
+ <div className="space-y-3">
601
+ <label className="flex items-center gap-2.5 cursor-pointer">
602
+ <input type="checkbox" checked={ttsEnabled} onChange={(e) => setMiniTtsEnabled(e.target.checked)} className="accent-orange-500" />
603
+ <span className="text-sm text-white/70">语音回复 (TTS)</span>
604
+ </label>
605
+ <label className="flex items-center gap-2.5 cursor-pointer">
606
+ <input type="checkbox" checked={streamingEnabled} onChange={(e) => setMiniStreamingEnabled(e.target.checked)} className="accent-orange-500" />
607
+ <span className="text-sm text-white/70">流式输出</span>
608
+ </label>
609
+ </div>
610
+ </div>
611
+ </div>
612
+ </div>
613
+ )
614
+ }
615
+
616
+ // ─── History Drawer ─────────────────────────────────────
617
+
618
+ function HistoryDrawer({
619
+ open,
620
+ onClose,
621
+ onSwitch,
622
+ onDelete,
623
+ onNewSession,
624
+ onClearAll,
625
+ }: {
626
+ open: boolean
627
+ onClose: () => void
628
+ onSwitch: (id: string) => void
629
+ onDelete: (id: string) => void
630
+ onNewSession: () => void
631
+ onClearAll: () => void
632
+ }) {
633
+ const sessions = useCompanionStore((s) => s.miniSessions)
634
+ const activeId = useCompanionStore((s) => s.miniActiveSessionId)
635
+
636
+ if (!open) return null
637
+
638
+ const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt)
639
+
640
+ return (
641
+ <div className="absolute inset-0 z-50 flex" onClick={onClose}>
642
+ <div className="absolute inset-0 bg-black/50" />
643
+ <aside
644
+ className="relative w-72 max-w-[80vw] h-full bg-[#1c1c1c] border-r border-white/10 flex flex-col"
645
+ onClick={(e) => e.stopPropagation()}
646
+ >
647
+ <div className="p-3 border-b border-white/10">
648
+ <button
649
+ type="button"
650
+ onClick={onNewSession}
651
+ className="w-full flex items-center gap-2 px-3 py-2 rounded-lg bg-orange-500/15 text-orange-300 hover:bg-orange-500/25 transition-colors text-sm font-medium"
652
+ >
653
+ <IconEdit className="w-4 h-4" />
654
+ <span>新建对话</span>
655
+ </button>
656
+ </div>
657
+
658
+ <div className="flex-1 overflow-y-auto p-2 space-y-0.5">
659
+ {sorted.length === 0 ? (
660
+ <div className="text-center text-white/20 text-sm py-8">暂无历史记录</div>
661
+ ) : (
662
+ sorted.map((s) => (
663
+ <div
664
+ key={s.id}
665
+ className={`flex items-center rounded-lg transition-colors ${
666
+ s.id === activeId ? 'bg-white/8' : 'hover:bg-white/5'
667
+ }`}
668
+ >
669
+ <button
670
+ type="button"
671
+ onClick={() => onSwitch(s.id)}
672
+ className="flex-1 text-left px-3 py-2.5 min-w-0"
673
+ >
674
+ <div className="text-sm text-white/80 truncate">{s.title}</div>
675
+ <div className="text-xs text-white/30 mt-0.5">{formatRelativeTime(s.updatedAt)}</div>
676
+ </button>
677
+ <button
678
+ type="button"
679
+ onClick={(e) => { e.stopPropagation(); if (confirm(`删除「${s.title}」?`)) onDelete(s.id) }}
680
+ className="w-8 h-8 flex items-center justify-center text-white/20 hover:text-red-400 hover:bg-white/5 rounded-lg mr-1 transition-colors"
681
+ >
682
+ <IconTrash className="w-3.5 h-3.5" />
683
+ </button>
684
+ </div>
685
+ ))
686
+ )}
687
+ </div>
688
+
689
+ <div className="p-3 border-t border-white/10">
690
+ <button
691
+ type="button"
692
+ onClick={() => { if (confirm('确定清除所有对话?')) onClearAll() }}
693
+ className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-red-500/10 text-white/30 hover:text-red-300 transition-colors text-xs"
694
+ >
695
+ <IconTrash className="w-3.5 h-3.5" />
696
+ <span>清除所有数据</span>
697
+ </button>
698
+ </div>
699
+ </aside>
700
+ </div>
701
+ )
702
+ }
703
+
704
+ // ─── Recording Overlay ──────────────────────────────────
705
+
706
+ function RecordingOverlay({ willCancel }: { willCancel: boolean }) {
707
+ return (
708
+ <div className="absolute inset-0 z-40 pointer-events-none flex items-center justify-center">
709
+ <div className={`px-8 py-4 rounded-2xl backdrop-blur-xl border transition-colors ${
710
+ willCancel
711
+ ? 'bg-red-500/20 border-red-400/30 text-red-300'
712
+ : 'bg-orange-500/10 border-orange-400/20 text-orange-200'
713
+ }`}>
714
+ <div className="text-sm font-medium text-center">
715
+ {willCancel ? '松开关闭' : '上滑取消'}
716
+ </div>
717
+ <div className="flex items-center justify-center gap-1 mt-2">
718
+ {Array.from({ length: 28 }).map((_, i) => (
719
+ <span
720
+ key={i}
721
+ className="w-0.5 bg-current rounded-full animate-pulse"
722
+ style={{
723
+ height: `${6 + Math.sin(i * 0.5 + Date.now() * 0.003) * 8}px`,
724
+ animationDelay: `${(i % 14) * 60}ms`,
725
+ opacity: willCancel ? 0.4 : 0.6,
726
+ }}
727
+ />
728
+ ))}
729
+ </div>
730
+ </div>
731
+ </div>
732
+ )
733
+ }
734
+
735
+ // ─── Main Companion Page ────────────────────────────────
736
+
737
+ export function Companion() {
738
+ // ─── Store state ────────────────────────────────────────
739
+ const messages = useCompanionStore((s) => s.miniMessages)
740
+ const isGenerating = useCompanionStore((s) => s.miniIsGenerating)
741
+ const pendingText = useCompanionStore((s) => s.miniPendingText)
742
+ const serviceStatus = useCompanionStore((s) => s.miniServiceStatus)
743
+ const backendHost = useCompanionStore((s) => s.backendHost)
744
+ const scenario = useCompanionStore((s) => s.scenario)
745
+ const settingsOpen = useCompanionStore((s) => s.miniSettingsOpen)
746
+ const historyOpen = useCompanionStore((s) => s.miniHistoryOpen)
747
+ const composeMode = useCompanionStore((s) => s.miniComposeMode)
748
+ const draft = useCompanionStore((s) => s.miniDraft)
749
+ const pendingAttachments = useCompanionStore((s) => s.miniPendingAttachments)
750
+ const attachMenuOpen = useCompanionStore((s) => s.miniAttachMenuOpen)
751
+ const isRecording = useCompanionStore((s) => s.miniRecording)
752
+ const isPreparingRecording = useCompanionStore((s) => s.miniPreparingRecording)
753
+ const recordingWillCancel = useCompanionStore((s) => s.miniRecordingWillCancel)
754
+ const error = useCompanionStore((s) => s.miniError)
755
+
756
+ const setMiniMessages = useCompanionStore((s) => s.setMiniMessages)
757
+ const setMiniIsGenerating = useCompanionStore((s) => s.setMiniIsGenerating)
758
+ const setMiniPendingText = useCompanionStore((s) => s.setMiniPendingText)
759
+ const setMiniServiceStatus = useCompanionStore((s) => s.setMiniServiceStatus)
760
+ const setMiniPresetsByMode = useCompanionStore((s) => s.setMiniPresetsByMode)
761
+ const setMiniSettingsOpen = useCompanionStore((s) => s.setMiniSettingsOpen)
762
+ const setMiniDraft = useCompanionStore((s) => s.setMiniDraft)
763
+ const setMiniPendingAttachments = useCompanionStore((s) => s.setMiniPendingAttachments)
764
+ const setMiniAttachMenuOpen = useCompanionStore((s) => s.setMiniAttachMenuOpen)
765
+ const setMiniComposeMode = useCompanionStore((s) => s.setMiniComposeMode)
766
+ const setMiniRecording = useCompanionStore((s) => s.setMiniRecording)
767
+ const setMiniPreparingRecording = useCompanionStore((s) => s.setMiniPreparingRecording)
768
+ const setMiniRecordingWillCancel = useCompanionStore((s) => s.setMiniRecordingWillCancel)
769
+ const setMiniHistoryOpen = useCompanionStore((s) => s.setMiniHistoryOpen)
770
+ const setMiniError = useCompanionStore((s) => s.setMiniError)
771
+ const setMiniSessions = useCompanionStore((s) => s.setMiniSessions)
772
+ const setMiniActiveSessionId = useCompanionStore((s) => s.setMiniActiveSessionId)
773
+ const removeMiniPendingAttachment = useCompanionStore((s) => s.removeMiniPendingAttachment)
774
+ const addMiniPendingAttachments = useCompanionStore((s) => s.addMiniPendingAttachments)
775
+
776
+ const systemPrompt = useCompanionStore((s) => s.miniSystemPromptTurnbased)
777
+ const maxNewTokens = useCompanionStore((s) => s.miniMaxNewTokens)
778
+ const lengthPenalty = useCompanionStore((s) => s.miniLengthPenalty)
779
+ const ttsEnabled = useCompanionStore((s) => s.miniTtsEnabled)
780
+ const streamingEnabled = useCompanionStore((s) => s.miniStreamingEnabled)
781
+
782
+ // ─── Refs ───────────────────────────────────────────────
783
+ const threadWrapRef = useRef<HTMLDivElement>(null)
784
+ const threadEndRef = useRef<HTMLDivElement>(null)
785
+ const textInputRef = useRef<HTMLTextAreaElement>(null)
786
+ const cameraInputRef = useRef<HTMLInputElement>(null)
787
+ const albumInputRef = useRef<HTMLInputElement>(null)
788
+ const fileInputRef = useRef<HTMLInputElement>(null)
789
+ const captureStateRef = useRef(createMicCapture())
790
+ const recordingStartRef = useRef(0)
791
+ const recordingActionRef = useRef<'send' | 'cancel'>('send')
792
+ const recordingPointerIdRef = useRef<number | null>(null)
793
+ const recordingPointerStartYRef = useRef<number | null>(null)
794
+ const abortRef = useRef<(() => void) | null>(null)
795
+ const playerRef = useRef<StreamingPcmPlayer | null>(null)
796
+ const isStreamAudioPlayingRef = useRef(false)
797
+ const messagesRef = useRef(messages)
798
+ messagesRef.current = messages
799
+
800
+ // ─── Helpers ───────────────────────────────────────────
801
+
802
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
803
+ const wrap = threadWrapRef.current
804
+ if (wrap) wrap.scrollTo({ top: wrap.scrollHeight, behavior })
805
+ }, [])
806
+
807
+ const autoGrowTextarea = useCallback((el: HTMLTextAreaElement | null) => {
808
+ if (!el) return
809
+ el.style.height = 'auto'
810
+ el.style.height = `${Math.min(el.scrollHeight, 140)}px`
811
+ }, [])
812
+
813
+ // ─── Service status polling ──────────────────────────
814
 
 
815
  useEffect(() => {
816
+ let cancelled = false
817
+ const poll = async () => {
818
+ const status = await fetchServiceStatus(backendHost)
819
+ if (!cancelled) setMiniServiceStatus(status)
 
 
 
 
 
 
 
 
820
  }
821
+ void poll()
822
+ const interval = setInterval(poll, 15000)
823
+ return () => { cancelled = true; clearInterval(interval) }
824
+ }, [backendHost, setMiniServiceStatus])
825
+
826
+ // ─── Presets loading ─────────────────────────────────
827
+
828
+ useEffect(() => {
829
+ let cancelled = false
830
+ void (async () => {
831
+ const presets = await fetchPresets(backendHost)
832
+ if (!cancelled) setMiniPresetsByMode(presets)
833
+ })()
834
+ return () => { cancelled = true }
835
+ }, [backendHost, setMiniPresetsByMode])
836
+
837
+ // ─── Scroll to bottom on new messages ────────────────
838
+
839
+ useEffect(() => {
840
+ scrollToBottom(isGenerating ? 'smooth' : 'auto')
841
+ }, [messages.length, pendingText, scrollToBottom])
842
+
843
+ // ─── Update sessions when messages change ────────────
844
+
845
+ useEffect(() => {
846
+ const activeSessionId = useCompanionStore.getState().miniActiveSessionId
847
+ if (messages.length === 0) return
848
+ setMiniSessions((prev) => {
849
+ const idx = prev.findIndex((s) => s.id === activeSessionId)
850
+ const title = deriveSessionTitle(messages)
851
+ const now = Date.now()
852
+ if (idx === -1) {
853
+ const created: MiniCPMoSession = { id: activeSessionId, title, createdAt: now, updatedAt: now, messages }
854
+ return [...prev, created]
855
+ }
856
+ const next = [...prev]
857
+ next[idx] = { ...next[idx]!, title, messages, updatedAt: now }
858
+ return next
859
+ })
860
+ }, [messages])
861
+
862
+ // ─── Build system prompt with scenario ───────────────
863
+
864
+ const buildSystemMessage = useCallback((): string | null => {
865
+ let prompt = systemPrompt.trim()
866
+ if (scenario) {
867
+ const sc = SCENARIOS.find((s) => s.id === scenario)
868
+ if (sc) {
869
+ prompt = `[场景: ${sc.name}]\n${sc.description || ''}\n\n${prompt}`
870
  }
871
  }
872
+ return prompt || null
873
+ }, [systemPrompt, scenario])
874
 
875
+ // ─── Submit message ─────────────────────────────────
876
+
877
+ const submitMessage = useCallback(async (nextMessages: MiniCPMoMessage[]) => {
878
+ const systemMessage = buildSystemMessage()
879
+
880
+ setMiniMessages(nextMessages)
881
+ setMiniIsGenerating(true)
882
+
883
+ if (streamingEnabled) {
884
+ const player = ttsEnabled ? new StreamingPcmPlayer(24000) : undefined
885
+ playerRef.current = player ?? null
886
+
887
+ const { abort } = submitChatStreaming(backendHost, nextMessages, systemMessage, maxNewTokens, lengthPenalty, ttsEnabled, {
888
+ onChunk: (text) => { setMiniPendingText(text) },
889
+ onAudioBase64: (data) => { player?.pushBase64(data) },
890
+ onDone: (text, sessionId) => {
891
+ const entry: MiniCPMoMessage = {
892
+ id: createId('assistant'),
893
+ role: 'assistant',
894
+ kind: 'assistant',
895
+ text,
896
+ audioPreviewUrl: null,
897
+ recordingSessionId: sessionId,
898
+ }
899
+ // flush player audio
900
+ if (player) {
901
+ const merged = player.getMergedFloat32()
902
+ if (merged && merged.length > 0) {
903
+ entry.audioPreviewUrl = float32ToWavBlobUrl(merged, player.getSampleRate())
904
+ }
905
+ player.markFinished()
906
+ player.disposeAfterDrain(() => { isStreamAudioPlayingRef.current = false })
907
+ }
908
+ const current = useCompanionStore.getState().miniMessages
909
+ setMiniMessages([...current, entry])
910
+ setMiniIsGenerating(false)
911
+ setMiniPendingText('')
912
+ if (sessionId) useCompanionStore.getState().setMiniLastSessionId(sessionId)
913
+ },
914
+ onError: (err) => {
915
+ const current = useCompanionStore.getState().miniMessages
916
+ setMiniMessages([...current, { id: createId('assistant'), role: 'assistant', kind: 'assistant', text: `错误: ${err}`, error: true }])
917
+ setMiniIsGenerating(false)
918
+ setMiniPendingText('')
919
+ },
920
+ }, player)
921
+ abortRef.current = abort
922
  } else {
923
+ try {
924
+ const { entry, sessionId } = await submitChatNonStreaming(backendHost, nextMessages, systemMessage, maxNewTokens, lengthPenalty, ttsEnabled)
925
+ setMiniMessages([...useCompanionStore.getState().miniMessages, entry])
926
+ if (sessionId) useCompanionStore.getState().setMiniLastSessionId(sessionId)
927
+ } catch (err) {
928
+ setMiniMessages([...useCompanionStore.getState().miniMessages, {
929
+ id: createId('assistant'), role: 'assistant', kind: 'assistant', text: `请求失败: ${err instanceof Error ? err.message : '未知错误'}`, error: true,
930
+ }])
931
  }
932
+ setMiniIsGenerating(false)
933
  }
934
+ }, [backendHost, buildSystemMessage, maxNewTokens, lengthPenalty, ttsEnabled, streamingEnabled, setMiniMessages, setMiniIsGenerating, setMiniPendingText])
935
+
936
+ // ─── Send text message ──────────────────────────────
937
+
938
+ const sendTextMessage = useCallback(() => {
939
+ const text = draft.trim()
940
+ const atts = pendingAttachments
941
+ if ((!text && atts.length === 0) || isGenerating || isPreparingRecording) return
942
+
943
+ setMiniDraft('')
944
+ setMiniPendingAttachments([])
945
+ setMiniError(null)
946
+
947
+ const nextMessages: MiniCPMoMessage[] = [
948
+ ...messagesRef.current,
949
+ { id: createId('user'), role: 'user', kind: 'text', text, attachments: atts.length > 0 ? atts : undefined },
950
+ ]
951
+ void submitMessage(nextMessages)
952
+ }, [draft, pendingAttachments, isGenerating, isPreparingRecording, setMiniDraft, setMiniPendingAttachments, submitMessage])
953
+
954
+ // ─── Regenerate ─────────────────────────────────────
955
+
956
+ const regenerateLastReply = useCallback(() => {
957
+ if (isGenerating || isPreparingRecording) return
958
+ const current = messagesRef.current
959
+ let lastUserIndex = -1
960
+ for (let i = current.length - 1; i >= 0; i--) {
961
+ if (current[i]!.role === 'user') { lastUserIndex = i; break }
962
+ }
963
+ if (lastUserIndex < 0) return
964
+ const trimmed = current.slice(0, lastUserIndex + 1)
965
+ setMiniMessages(trimmed)
966
+ setMiniError(null)
967
+ void submitMessage(trimmed)
968
+ }, [isGenerating, isPreparingRecording, setMiniMessages, submitMessage])
969
+
970
+ // ─── Session management ─────────────────────────────
971
+
972
+ const startNewSession = useCallback(() => {
973
+ const newId = createId('session')
974
+ abortRef.current?.()
975
+ setMiniActiveSessionId(newId)
976
+ setMiniMessages([])
977
+ setMiniDraft('')
978
+ setMiniPendingAttachments([])
979
+ setMiniHistoryOpen(false)
980
+ setMiniError(null)
981
+ }, [setMiniActiveSessionId, setMiniMessages, setMiniDraft, setMiniPendingAttachments, setMiniHistoryOpen])
982
+
983
+ const switchToSession = useCallback((id: string) => {
984
+ if (id === useCompanionStore.getState().miniActiveSessionId) { setMiniHistoryOpen(false); return }
985
+ const sessions = useCompanionStore.getState().miniSessions
986
+ const target = sessions.find((s) => s.id === id)
987
+ if (!target) return
988
+ abortRef.current?.()
989
+ setMiniActiveSessionId(id)
990
+ setMiniMessages(target.messages)
991
+ setMiniDraft('')
992
+ setMiniPendingAttachments([])
993
+ setMiniHistoryOpen(false)
994
+ setMiniError(null)
995
+ }, [setMiniActiveSessionId, setMiniMessages, setMiniDraft, setMiniPendingAttachments, setMiniHistoryOpen])
996
+
997
+ const deleteSession = useCallback((id: string) => {
998
+ setMiniSessions((prev) => prev.filter((s) => s.id !== id))
999
+ if (id === useCompanionStore.getState().miniActiveSessionId) {
1000
+ const remaining = useCompanionStore.getState().miniSessions.filter((s) => s.id !== id)
1001
+ if (remaining.length > 0) {
1002
+ switchToSession(remaining[0]!.id)
1003
+ } else {
1004
+ startNewSession()
1005
  }
1006
  }
1007
+ }, [setMiniSessions])
1008
 
1009
+ const clearAllData = useCallback(() => {
1010
+ setMiniSessions([])
1011
+ startNewSession()
1012
+ }, [setMiniSessions, startNewSession])
1013
+
1014
+ // ─── Attachments ─────────────────────────────────────
1015
+
1016
+ const handleAttachFiles = useCallback(async (files: FileList | null, kind: 'image' | 'audio' | 'video') => {
1017
+ if (!files || files.length === 0) return
1018
+ const built: MiniCPMoAttachment[] = []
1019
+ for (const f of Array.from(files)) {
1020
+ try {
1021
+ const att = kind === 'image' ? await downscaleImageToAttachment(f) : await mediaFileToAttachment(f, kind)
1022
+ built.push(att as MiniCPMoAttachment)
1023
+ } catch { /* skip */ }
1024
  }
1025
+ if (built.length > 0) {
1026
+ addMiniPendingAttachments(built)
1027
+ setMiniAttachMenuOpen(false)
1028
+ }
1029
+ }, [addMiniPendingAttachments, setMiniAttachMenuOpen])
1030
 
1031
+ const handleCameraCapture = useCallback(async (files: FileList | null) => {
1032
+ if (!files || !files[0]) return
1033
+ try {
1034
+ const att = await downscaleImageToAttachment(files[0])
1035
+ addMiniPendingAttachments([att as MiniCPMoAttachment])
1036
+ setMiniAttachMenuOpen(false)
1037
+ } catch { /* ignore */ }
1038
+ }, [addMiniPendingAttachments, setMiniAttachMenuOpen])
 
 
 
1039
 
1040
+ // ─── Recording ──────────────────────────────────────
 
 
1041
 
1042
+ const beginRecordingCapture = useCallback(async (initiatingPointerId: number) => {
1043
+ const state = captureStateRef.current
1044
+ const stillHolding = () => recordingPointerIdRef.current === initiatingPointerId
1045
 
1046
+ let warm = state.ctx !== null && state.stream !== null
1047
+ if (!warm) warm = await prewarmMic(state, backendHost)
1048
+ if (!stillHolding()) return
1049
+ if (!warm || !state.ctx || !state.stream) {
1050
+ setMiniError('麦克风初始化失败')
1051
+ coldDownMic(state)
1052
+ return
1053
+ }
1054
+ if (state.ctx.state === 'suspended') await state.ctx.resume().catch(() => {})
1055
+ if (!stillHolding()) return
1056
+ if (state.ctx.state !== 'running') {
1057
+ setMiniError('音频通道不可用')
1058
+ coldDownMic(state)
1059
+ return
1060
  }
1061
+ setCapturing(state, true)
1062
+ }, [setMiniError])
1063
 
1064
+ const finalizeRecording = useCallback(async () => {
1065
+ const state = captureStateRef.current
1066
+ setCapturing(state, false)
1067
+ recordingPointerIdRef.current = null
 
1068
 
1069
+ const result = finalizeRecordingChunks(state)
1070
+ if (!result) return
1071
+
1072
+ const carriedAttachments = pendingAttachments
1073
+ if (carriedAttachments.length > 0) setMiniPendingAttachments([])
1074
+
1075
+ const nextMessages: MiniCPMoMessage[] = [
1076
+ ...messagesRef.current,
1077
+ {
1078
+ id: createId('voice'),
1079
+ role: 'user',
1080
+ kind: 'voice',
1081
+ audioBase64: result.audioBase64,
1082
+ durationMs: performance.now() - recordingStartRef.current,
1083
+ previewUrl: result.previewUrl,
1084
+ attachments: carriedAttachments.length > 0 ? carriedAttachments : undefined,
1085
+ },
1086
+ ]
1087
+ setMiniRecording(false)
1088
+ setMiniPreparingRecording(false)
1089
+ void submitMessage(nextMessages)
1090
+ }, [pendingAttachments, setMiniPendingAttachments, setMiniRecording, setMiniPreparingRecording, submitMessage])
1091
+
1092
+ const handleTalkPointerDown = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
1093
+ if (isRecording || isPreparingRecording) return
1094
+ recordingPointerStartYRef.current = event.clientY
1095
+ recordingPointerIdRef.current = event.pointerId
1096
+ recordingActionRef.current = 'send'
1097
+ captureStateRef.current.chunks = []
1098
+ recordingStartRef.current = performance.now()
1099
+ setMiniRecording(true)
1100
+ setMiniError(null)
1101
+ try { event.currentTarget.setPointerCapture(event.pointerId) } catch { /* ignore */ }
1102
+ void beginRecordingCapture(event.pointerId)
1103
+ }, [isRecording, isPreparingRecording, setMiniRecording, beginRecordingCapture])
1104
+
1105
+ const handleTalkPointerMove = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
1106
+ if (recordingPointerIdRef.current !== event.pointerId) return
1107
+ const startY = recordingPointerStartYRef.current
1108
+ if (startY === null) return
1109
+ setMiniRecordingWillCancel(startY - event.clientY > CANCEL_DRAG_PX)
1110
+ }, [setMiniRecordingWillCancel])
1111
+
1112
+ const handleTalkPointerUp = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
1113
+ if (recordingPointerIdRef.current !== event.pointerId && recordingPointerIdRef.current !== null) return
1114
+ try { event.currentTarget.releasePointerCapture(event.pointerId) } catch { /* ignore */ }
1115
+
1116
+ if (!isRecording) {
1117
+ recordingPointerIdRef.current = null
1118
+ return
1119
+ }
1120
+
1121
+ if (recordingWillCancel) {
1122
+ coldDownMic(captureStateRef.current)
1123
+ setMiniRecording(false)
1124
+ setMiniPreparingRecording(false)
1125
+ recordingPointerIdRef.current = null
1126
+ return
1127
  }
 
1128
 
1129
+ void finalizeRecording()
1130
+ }, [isRecording, recordingWillCancel, finalizeRecording, setMiniRecording, setMiniPreparingRecording])
1131
+
1132
+ const handleTalkPointerCancel = useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
1133
+ if (recordingPointerIdRef.current !== event.pointerId && recordingPointerIdRef.current !== null) return
1134
+ coldDownMic(captureStateRef.current)
1135
+ setMiniRecording(false)
1136
+ setMiniPreparingRecording(false)
1137
+ recordingPointerIdRef.current = null
1138
+ }, [setMiniRecording, setMiniPreparingRecording])
1139
+
1140
+ // ─── Attach menu drawing ───────────────────────────
1141
+
1142
+ const attachItems = [
1143
+ { icon: IconCamera, label: '拍照', onClick: () => cameraInputRef.current?.click() },
1144
+ { icon: IconPhoto, label: '相册', onClick: () => albumInputRef.current?.click() },
1145
+ { icon: IconFile, label: '文件', onClick: () => fileInputRef.current?.click() },
1146
+ ]
1147
+
1148
+ // ─── Render ──────────────────────────────────────────
1149
+
1150
+ const currentScenario = scenario ? SCENARIOS.find((s) => s.id === scenario) : null
1151
 
1152
  return (
1153
+ <div className="relative h-full w-full overflow-hidden bg-[#131313] flex flex-col">
1154
+ {/* Hidden file inputs */}
1155
+ <input ref={cameraInputRef} type="file" accept="image/*" capture="environment" hidden
1156
+ onChange={(e) => { void handleCameraCapture(e.target.files); e.target.value = '' }} />
1157
+ <input ref={albumInputRef} type="file" accept="image/*" multiple hidden
1158
+ onChange={(e) => { void handleAttachFiles(e.target.files, 'image'); e.target.value = '' }} />
1159
+ <input ref={fileInputRef} type="file" accept="image/*,audio/*,video/*" multiple hidden
1160
+ onChange={(e) => { void handleAttachFiles(e.target.files, 'image'); e.target.value = '' }} />
 
1161
 
1162
+ {/* ─── Top Bar ────────────────────────────────────── */}
1163
+ <header className="flex items-center gap-2 px-3 py-2.5 border-b border-white/5 shrink-0">
1164
+ <button
1165
+ type="button"
1166
+ onClick={() => setMiniHistoryOpen(true)}
1167
+ className="w-9 h-9 flex items-center justify-center rounded-xl hover:bg-white/5 text-white/50 hover:text-white/80 transition-colors"
1168
+ >
1169
+ <IconHamburger className="w-5 h-5" />
1170
+ </button>
1171
 
1172
+ <div className="flex-1 flex items-center justify-center gap-2">
1173
+ <button
1174
+ type="button"
1175
+ onClick={() => useCompanionStore.getState().setScenarioPanelOpen(true)}
1176
+ className="flex items-center gap-1.5 px-3 h-7 rounded-full bg-white/5 border border-white/8 hover:bg-white/10 transition-all text-white/60 hover:text-white/80"
1177
+ >
1178
+ {currentScenario ? (
1179
+ <>
1180
+ <span className="material-symbols-outlined text-sm">{currentScenario.icon}</span>
1181
+ <span className="text-xs font-medium">{currentScenario.name}</span>
1182
+ </>
1183
+ ) : (
1184
+ <span className="text-xs font-medium">选择场景</span>
1185
+ )}
1186
+ <span className="material-symbols-outlined text-xs text-white/20">expand_more</span>
1187
+ </button>
1188
+ </div>
1189
 
1190
+ <div className="flex items-center gap-1">
1191
+ {/* Status dot */}
1192
+ <span className={`w-1.5 h-1.5 rounded-full ${
1193
+ serviceStatus.phase === 'ready' ? 'bg-green-500' :
1194
+ serviceStatus.phase === 'error' ? 'bg-red-500' : 'bg-yellow-500 animate-pulse'
1195
+ }`} title={serviceStatus.detail} />
1196
+ <button
1197
+ type="button"
1198
+ onClick={() => setMiniSettingsOpen(true)}
1199
+ className="w-9 h-9 flex items-center justify-center rounded-xl hover:bg-white/5 text-white/50 hover:text-white/80 transition-colors"
1200
+ >
1201
+ <IconSettings className="w-5 h-5" />
1202
+ </button>
1203
+ </div>
1204
+ </header>
1205
+
1206
+ {/* ─── Messages Thread ───────────────────────────── */}
1207
+ <div ref={threadWrapRef} className="flex-1 overflow-y-auto px-4 py-3">
1208
+ <div ref={threadEndRef} />
1209
+ {messages.length === 0 && !isGenerating && (
1210
+ <div className="flex flex-col items-center justify-center h-full text-white/15">
1211
+ <span className="material-symbols-outlined text-5xl mb-3">chat</span>
1212
+ <p className="text-sm">开始一段新对话</p>
1213
+ <p className="text-xs mt-1">点击下方按钮发送消息或录音</p>
1214
+ </div>
1215
+ )}
1216
+ {messages.map((msg, idx) => (
1217
+ <MessageBubble
1218
+ key={msg.id}
1219
+ msg={msg}
1220
+ isLastAssistant={msg.role === 'assistant' && idx === messages.length - 1}
1221
+ isStreaming={isGenerating && idx === messages.length - 1}
1222
+ onRegenerate={regenerateLastReply}
1223
+ />
1224
+ ))}
1225
+ {isGenerating && <PendingReply text={pendingText} />}
1226
+ <div />
1227
+ </div>
1228
 
1229
+ {/* ─── Error ──────────────────────────────────────── */}
1230
  {error && (
1231
+ <div className="px-4 py-2 mx-3 mb-1 rounded-lg bg-red-500/10 border border-red-500/20 text-red-300 text-xs text-center">
1232
  {error}
1233
  </div>
1234
  )}
1235
 
1236
+ {/* ─── Pending Attachments Strip ─────────────────── */}
1237
+ {pendingAttachments.length > 0 && (
1238
+ <div className="flex gap-1.5 px-4 py-1.5 overflow-x-auto shrink-0">
1239
+ {pendingAttachments.map((a) => (
1240
+ <div key={a.id} className="relative shrink-0">
1241
+ {a.kind === 'image' ? (
1242
+ <img src={a.previewUrl} alt="" className="w-12 h-12 rounded-lg object-cover" />
1243
+ ) : (
1244
+ <div className="w-12 h-12 rounded-lg bg-white/8 flex items-center justify-center text-white/40">
1245
+ <span className="material-symbols-outlined text-lg">
1246
+ {a.kind === 'audio' ? 'music_note' : 'movie'}
1247
+ </span>
1248
+ </div>
1249
+ )}
1250
+ <button
1251
+ type="button"
1252
+ onClick={() => removeMiniPendingAttachment(a.id)}
1253
+ className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500/80 text-white flex items-center justify-center text-xs hover:bg-red-500"
1254
+ >
1255
+ ×
1256
+ </button>
1257
+ </div>
1258
+ ))}
1259
  </div>
1260
  )}
1261
 
1262
+ {/* ─── Composer ──────────────────────────────────── */}
1263
+ <div className="shrink-0 px-3 pb-3 pt-1">
1264
+ <div className={`flex items-center gap-1.5 bg-white/5 border border-white/8 rounded-2xl px-2 py-1.5 transition-all ${
1265
+ isGenerating ? 'border-orange-400/30' : ''
1266
+ }`}>
1267
+ {/* Camera button */}
1268
+ <button
1269
+ type="button"
1270
+ onClick={() => cameraInputRef.current?.click()}
1271
+ disabled={isGenerating || isPreparingRecording}
1272
+ className="w-9 h-9 flex items-center justify-center rounded-xl hover:bg-white/5 text-white/40 hover:text-white/70 disabled:opacity-30 transition-colors"
1273
+ >
1274
+ <IconCamera className="w-5 h-5" />
1275
+ </button>
1276
 
1277
+ {/* Main input area */}
1278
+ {composeMode === 'text' ? (
1279
+ <form
1280
+ onSubmit={(e) => { e.preventDefault(); sendTextMessage() }}
1281
+ className="flex-1 flex items-center"
1282
+ >
1283
+ <textarea
1284
+ ref={textInputRef}
1285
+ value={draft}
1286
+ onChange={(e) => { setMiniDraft(e.target.value); autoGrowTextarea(e.target) }}
1287
+ onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendTextMessage() } }}
1288
+ placeholder="输入消息..."
1289
+ rows={1}
1290
+ disabled={isGenerating || isPreparingRecording}
1291
+ className="flex-1 bg-transparent text-sm text-white/80 placeholder-white/20 outline-none resize-none max-h-[140px] px-1 py-1.5"
1292
+ />
1293
+ {draft.trim() || pendingAttachments.length > 0 ? (
1294
+ <button
1295
+ type="submit"
1296
+ disabled={isGenerating}
1297
+ className="w-9 h-9 flex items-center justify-center rounded-xl bg-orange-500/20 text-orange-300 hover:bg-orange-500/30 transition-colors disabled:opacity-30"
1298
+ >
1299
+ <IconSend className="w-4.5 h-4.5" />
1300
+ </button>
1301
+ ) : null}
1302
+ </form>
1303
+ ) : (
1304
+ <button
1305
+ type="button"
1306
+ onPointerDown={handleTalkPointerDown}
1307
+ onPointerMove={handleTalkPointerMove}
1308
+ onPointerUp={handleTalkPointerUp}
1309
+ onPointerCancel={handleTalkPointerCancel}
1310
+ disabled={isGenerating}
1311
+ className={`flex-1 h-9 flex items-center justify-center rounded-xl transition-all ${
1312
+ isRecording
1313
+ ? 'bg-orange-500/20 text-orange-300'
1314
+ : isGenerating
1315
+ ? 'bg-white/5 text-white/30'
1316
+ : 'bg-white/5 text-white/50 hover:bg-white/10 hover:text-white/70'
1317
+ } disabled:opacity-40`}
1318
+ >
1319
+ <span className="text-xs font-medium">
1320
+ {isRecording ? '录音中...' : isGenerating ? '等待回复...' : '按住说话'}
1321
+ </span>
1322
+ </button>
1323
+ )}
1324
+
1325
+ {/* Mode switch: keyboard <-> voice */}
1326
+ <button
1327
+ type="button"
1328
+ onClick={() => {
1329
+ setMiniComposeMode(composeMode === 'voice' ? 'text' : 'voice')
1330
+ if (composeMode === 'voice') {
1331
+ setTimeout(() => textInputRef.current?.focus(), 100)
1332
+ }
1333
+ }}
1334
+ disabled={isGenerating || isPreparingRecording}
1335
+ className="w-9 h-9 flex items-center justify-center rounded-xl hover:bg-white/5 text-white/40 hover:text-white/70 disabled:opacity-30 transition-colors"
1336
+ >
1337
+ {composeMode === 'voice' ? <IconKeyboard className="w-5 h-5" /> : <IconWave className="w-5 h-5" />}
1338
+ </button>
1339
+
1340
+ {/* Attach button */}
1341
+ <button
1342
+ type="button"
1343
+ onClick={() => setMiniAttachMenuOpen(!attachMenuOpen)}
1344
+ disabled={isGenerating || isPreparingRecording}
1345
+ className={`w-9 h-9 flex items-center justify-center rounded-xl transition-colors ${
1346
+ attachMenuOpen ? 'bg-orange-500/15 text-orange-300' : 'hover:bg-white/5 text-white/40 hover:text-white/70'
1347
+ } disabled:opacity-30`}
1348
+ >
1349
+ {attachMenuOpen ? <IconClose className="w-5 h-5" /> : <IconPlus className="w-5 h-5" />}
1350
+ </button>
1351
+
1352
+ {/* Send / Stop button */}
1353
+ {isGenerating || (composeMode === 'text' && draft.trim()) || pendingAttachments.length > 0 ? (
1354
+ composeMode === 'voice' && !isGenerating && !draft.trim() && pendingAttachments.length === 0 ? null : (
1355
+ <button
1356
+ type="button"
1357
+ onClick={() => {
1358
+ if (isGenerating) {
1359
+ abortRef.current?.()
1360
+ setMiniIsGenerating(false)
1361
+ setMiniPendingText('')
1362
+ return
1363
+ }
1364
+ sendTextMessage()
1365
+ }}
1366
+ className={`w-9 h-9 flex items-center justify-center rounded-xl transition-colors ${
1367
+ isGenerating
1368
+ ? 'bg-red-500/20 text-red-300 hover:bg-red-500/30'
1369
+ : 'bg-orange-500/20 text-orange-300 hover:bg-orange-500/30'
1370
+ }`}
1371
+ >
1372
+ {isGenerating ? <IconStop className="w-4.5 h-4.5" /> : <IconSend className="w-4.5 h-4.5" />}
1373
+ </button>
1374
+ )
1375
+ ) : null}
1376
+ </div>
1377
+
1378
+ {/* Attach menu drawer */}
1379
+ {attachMenuOpen && (
1380
+ <div className="flex items-center gap-2 mt-2 px-1">
1381
+ {attachItems.map((item) => (
1382
+ <button
1383
+ key={item.label}
1384
+ type="button"
1385
+ onClick={item.onClick}
1386
+ className="flex flex-col items-center gap-1 px-3 py-2 rounded-xl hover:bg-white/5 transition-colors"
1387
+ >
1388
+ <item.icon className="w-6 h-6 text-white/40" />
1389
+ <span className="text-[10px] text-white/30">{item.label}</span>
1390
+ </button>
1391
+ ))}
1392
+ </div>
1393
+ )}
1394
+ </div>
1395
+
1396
+ {/* ─── Overlays ───────────────────────────────────── */}
1397
+ {isRecording && <RecordingOverlay willCancel={recordingWillCancel} />}
1398
+ <ScenarioSelector />
1399
+ <SettingsSheet open={settingsOpen} onClose={() => setMiniSettingsOpen(false)} />
1400
+ <HistoryDrawer
1401
+ open={historyOpen}
1402
+ onClose={() => setMiniHistoryOpen(false)}
1403
+ onSwitch={switchToSession}
1404
+ onDelete={deleteSession}
1405
+ onNewSession={startNewSession}
1406
+ onClearAll={clearAllData}
1407
  />
1408
  </div>
1409
  )
desktop/src/stores/companionStore.ts CHANGED
@@ -1,5 +1,13 @@
1
  import { create } from 'zustand'
2
- import type { CompanionStatus, ScenarioOption } from '../types/companion'
 
 
 
 
 
 
 
 
3
 
4
  const AVATAR_KEY = 'companion-avatar-url'
5
  const BACKGROUND_KEY = 'companion-background-url'
@@ -139,10 +147,81 @@ interface CompanionStore {
139
  setScreenShareDialogOpen: (open: boolean) => void
140
  setStatusText: (text: string) => void
141
  setCameraFullscreen: (fullscreen: boolean) => void
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  }
143
 
144
  const DEFAULT_SERVER_URL = 'ws://127.0.0.1:8889/ws/companion'
145
 
 
 
 
 
 
 
 
146
  export const useCompanionStore = create<CompanionStore>((set) => ({
147
  status: 'disconnected',
148
  serverUrl: DEFAULT_SERVER_URL,
@@ -169,6 +248,33 @@ export const useCompanionStore = create<CompanionStore>((set) => ({
169
  statusText: '你可以开始说话',
170
  cameraFullscreen: false,
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  setServerUrl: (url) => set({ serverUrl: url }),
173
  setVoiceName: (name) => set({ voiceName: name }),
174
  setStatus: (status) => set({ status, error: status === 'error' ? undefined : null }),
@@ -216,4 +322,41 @@ export const useCompanionStore = create<CompanionStore>((set) => ({
216
  setScreenShareDialogOpen: (open) => set({ screenShareDialogOpen: open }),
217
  setStatusText: (text) => set({ statusText: text }),
218
  setCameraFullscreen: (fullscreen) => set({ cameraFullscreen: fullscreen }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  }))
 
1
  import { create } from 'zustand'
2
+ import type {
3
+ CompanionStatus,
4
+ ScenarioOption,
5
+ MiniCPMoMessage,
6
+ MiniCPMoServiceStatus,
7
+ MiniCPMoPresetMode,
8
+ MiniCPMoPreset,
9
+ MiniCPMoAttachment,
10
+ } from '../types/companion'
11
 
12
  const AVATAR_KEY = 'companion-avatar-url'
13
  const BACKGROUND_KEY = 'companion-background-url'
 
147
  setScreenShareDialogOpen: (open: boolean) => void
148
  setStatusText: (text: string) => void
149
  setCameraFullscreen: (fullscreen: boolean) => void
150
+
151
+ // ─── MiniCPM-o state ──────────────────────────────────
152
+ backendHost: string
153
+ miniMessages: MiniCPMoMessage[]
154
+ miniIsGenerating: boolean
155
+ miniPendingText: string
156
+ miniServiceStatus: MiniCPMoServiceStatus
157
+ miniPresetsByMode: Record<MiniCPMoPresetMode, MiniCPMoPreset[]>
158
+ miniSettingsOpen: boolean
159
+ miniSettingsSheetMode: MiniCPMoPresetMode
160
+ miniSystemPromptTurnbased: string
161
+ miniMaxNewTokens: number
162
+ miniLengthPenalty: number
163
+ miniTtsEnabled: boolean
164
+ miniStreamingEnabled: boolean
165
+ miniError: string | null
166
+ miniLastSessionId: string | null
167
+ miniSessions: MiniCPMoSession[]
168
+ miniActiveSessionId: string
169
+ miniComposeMode: 'voice' | 'text'
170
+ miniDraft: string
171
+ miniPendingAttachments: MiniCPMoAttachment[]
172
+ miniAttachMenuOpen: boolean
173
+ miniRecording: boolean
174
+ miniPreparingRecording: boolean
175
+ miniRecordingWillCancel: boolean
176
+ miniHistoryOpen: boolean
177
+
178
+ setBackendHost: (host: string) => void
179
+ setMiniMessages: (msgs: MiniCPMoMessage[]) => void
180
+ appendMiniMessage: (msg: MiniCPMoMessage) => void
181
+ setMiniIsGenerating: (v: boolean) => void
182
+ setMiniPendingText: (t: string) => void
183
+ setMiniServiceStatus: (s: MiniCPMoServiceStatus) => void
184
+ setMiniPresetsByMode: (p: Record<MiniCPMoPresetMode, MiniCPMoPreset[]>) => void
185
+ setMiniSettingsOpen: (o: boolean) => void
186
+ setMiniSettingsSheetMode: (m: MiniCPMoPresetMode) => void
187
+ setMiniSystemPromptTurnbased: (v: string) => void
188
+ setMiniMaxNewTokens: (v: number) => void
189
+ setMiniLengthPenalty: (v: number) => void
190
+ setMiniTtsEnabled: (v: boolean) => void
191
+ setMiniStreamingEnabled: (v: boolean) => void
192
+ setMiniError: (e: string | null) => void
193
+ setMiniLastSessionId: (id: string | null) => void
194
+ setMiniSessions: (s: MiniCPMoSession[] | ((prev: MiniCPMoSession[]) => MiniCPMoSession[])) => void
195
+ setMiniActiveSessionId: (id: string) => void
196
+ setMiniComposeMode: (m: 'voice' | 'text') => void
197
+ setMiniDraft: (d: string) => void
198
+ setMiniPendingAttachments: (a: MiniCPMoAttachment[]) => void
199
+ setMiniAttachMenuOpen: (o: boolean) => void
200
+ setMiniRecording: (v: boolean) => void
201
+ setMiniPreparingRecording: (v: boolean) => void
202
+ setMiniRecordingWillCancel: (v: boolean) => void
203
+ setMiniHistoryOpen: (o: boolean) => void
204
+ removeMiniPendingAttachment: (id: string) => void
205
+ addMiniPendingAttachments: (a: MiniCPMoAttachment[]) => void
206
+ }
207
+
208
+ export type MiniCPMoSession = {
209
+ id: string
210
+ title: string
211
+ createdAt: number
212
+ updatedAt: number
213
+ messages: MiniCPMoMessage[]
214
  }
215
 
216
  const DEFAULT_SERVER_URL = 'ws://127.0.0.1:8889/ws/companion'
217
 
218
+ // llama backend defaults — switch DEFAULT_BACKEND_HOST to use omni-adapter
219
+ const DEFAULT_BACKEND_HOST = 'http://localhost:9301'
220
+
221
+ function createId(prefix: string): string {
222
+ return `${prefix}-${Math.random().toString(36).slice(2, 10)}`
223
+ }
224
+
225
  export const useCompanionStore = create<CompanionStore>((set) => ({
226
  status: 'disconnected',
227
  serverUrl: DEFAULT_SERVER_URL,
 
248
  statusText: '你可以开始说话',
249
  cameraFullscreen: false,
250
 
251
+ // ─── MiniCPM-o defaults ────────────────────────────────
252
+ backendHost: DEFAULT_BACKEND_HOST,
253
+ miniMessages: [],
254
+ miniIsGenerating: false,
255
+ miniPendingText: '',
256
+ miniServiceStatus: { phase: 'loading', summary: '连接中...', detail: '正在连接后端服务' },
257
+ miniPresetsByMode: { turnbased: [], audio_duplex: [], omni: [] },
258
+ miniSettingsOpen: false,
259
+ miniSettingsSheetMode: 'turnbased',
260
+ miniSystemPromptTurnbased: '你的任务是作为一个助手认真、高质量地回复用户的问题。请用高自然度的方式和用户聊天。',
261
+ miniMaxNewTokens: 256,
262
+ miniLengthPenalty: 1.1,
263
+ miniTtsEnabled: true,
264
+ miniStreamingEnabled: true,
265
+ miniError: null,
266
+ miniLastSessionId: null,
267
+ miniSessions: [],
268
+ miniActiveSessionId: createId('session'),
269
+ miniComposeMode: 'voice',
270
+ miniDraft: '',
271
+ miniPendingAttachments: [],
272
+ miniAttachMenuOpen: false,
273
+ miniRecording: false,
274
+ miniPreparingRecording: false,
275
+ miniRecordingWillCancel: false,
276
+ miniHistoryOpen: false,
277
+
278
  setServerUrl: (url) => set({ serverUrl: url }),
279
  setVoiceName: (name) => set({ voiceName: name }),
280
  setStatus: (status) => set({ status, error: status === 'error' ? undefined : null }),
 
322
  setScreenShareDialogOpen: (open) => set({ screenShareDialogOpen: open }),
323
  setStatusText: (text) => set({ statusText: text }),
324
  setCameraFullscreen: (fullscreen) => set({ cameraFullscreen: fullscreen }),
325
+
326
+ // ─── MiniCPM-o actions ──────────────────────────────────
327
+ setBackendHost: (host) => set({ backendHost: host }),
328
+ setMiniMessages: (msgs) => set({ miniMessages: msgs }),
329
+ appendMiniMessage: (msg) =>
330
+ set((s) => ({ miniMessages: [...s.miniMessages, msg] })),
331
+ setMiniIsGenerating: (v) => set({ miniIsGenerating: v, miniPendingText: v ? '' : '' }),
332
+ setMiniPendingText: (t) => set({ miniPendingText: t }),
333
+ setMiniServiceStatus: (s) => set({ miniServiceStatus: s }),
334
+ setMiniPresetsByMode: (p) => set({ miniPresetsByMode: p }),
335
+ setMiniSettingsOpen: (o) => set({ miniSettingsOpen: o }),
336
+ setMiniSettingsSheetMode: (m) => set({ miniSettingsSheetMode: m }),
337
+ setMiniSystemPromptTurnbased: (v) => set({ miniSystemPromptTurnbased: v }),
338
+ setMiniMaxNewTokens: (v) => set({ miniMaxNewTokens: v }),
339
+ setMiniLengthPenalty: (v) => set({ miniLengthPenalty: v }),
340
+ setMiniTtsEnabled: (v) => set({ miniTtsEnabled: v }),
341
+ setMiniStreamingEnabled: (v) => set({ miniStreamingEnabled: v }),
342
+ setMiniError: (e) => set({ miniError: e }),
343
+ setMiniLastSessionId: (id) => set({ miniLastSessionId: id }),
344
+ setMiniSessions: (s) => set((state) => ({ miniSessions: typeof s === 'function' ? s(state.miniSessions) : s })),
345
+ setMiniActiveSessionId: (id) => set({ miniActiveSessionId: id }),
346
+ setMiniComposeMode: (m) => set({ miniComposeMode: m }),
347
+ setMiniDraft: (d) => set({ miniDraft: d }),
348
+ setMiniPendingAttachments: (a) => set({ miniPendingAttachments: a }),
349
+ setMiniAttachMenuOpen: (o) => set({ miniAttachMenuOpen: o }),
350
+ setMiniRecording: (v) => set({ miniRecording: v }),
351
+ setMiniPreparingRecording: (v) => set({ miniPreparingRecording: v }),
352
+ setMiniRecordingWillCancel: (v) => set({ miniRecordingWillCancel: v }),
353
+ setMiniHistoryOpen: (o) => set({ miniHistoryOpen: o }),
354
+ removeMiniPendingAttachment: (id) =>
355
+ set((s) => ({
356
+ miniPendingAttachments: s.miniPendingAttachments.filter((a) => a.id !== id),
357
+ })),
358
+ addMiniPendingAttachments: (a) =>
359
+ set((s) => ({
360
+ miniPendingAttachments: [...s.miniPendingAttachments, ...a],
361
+ })),
362
  }))
desktop/src/types/companion.ts CHANGED
@@ -27,3 +27,85 @@ export type CompanionClientMessage =
27
  | { type: 'stop' }
28
  | { type: 'end' }
29
  | { type: 'context'; voice?: string }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  | { type: 'stop' }
28
  | { type: 'end' }
29
  | { type: 'context'; voice?: string }
30
+
31
+ // ─── MiniCPM-o types ──────────────────────────────────────
32
+
33
+ export type MiniCPMoAttachment =
34
+ | {
35
+ id: string
36
+ kind: 'image'
37
+ previewUrl: string
38
+ base64: string
39
+ name: string
40
+ }
41
+ | {
42
+ id: string
43
+ kind: 'audio'
44
+ previewUrl: string
45
+ base64: string
46
+ name: string
47
+ duration?: number
48
+ }
49
+ | {
50
+ id: string
51
+ kind: 'video'
52
+ previewUrl: string
53
+ base64: string
54
+ name: string
55
+ duration?: number
56
+ }
57
+
58
+ export type MiniCPMoMessage =
59
+ | {
60
+ id: string
61
+ role: 'assistant'
62
+ kind: 'assistant'
63
+ text: string
64
+ error?: boolean
65
+ interrupted?: boolean
66
+ audioPreviewUrl?: string | null
67
+ audioBase64?: string | null
68
+ audioSampleRate?: number | null
69
+ recordingSessionId?: string | null
70
+ }
71
+ | {
72
+ id: string
73
+ role: 'user'
74
+ kind: 'text'
75
+ text: string
76
+ attachments?: MiniCPMoAttachment[]
77
+ }
78
+ | {
79
+ id: string
80
+ role: 'user'
81
+ kind: 'voice'
82
+ audioBase64: string
83
+ durationMs: number
84
+ previewUrl: string
85
+ attachments?: MiniCPMoAttachment[]
86
+ }
87
+
88
+ export type MiniCPMoServiceStatus = {
89
+ phase: 'loading' | 'ready' | 'error'
90
+ summary: string
91
+ detail: string
92
+ }
93
+
94
+ export type MiniCPMoPresetMode = 'turnbased' | 'audio_duplex' | 'omni'
95
+
96
+ export type MiniCPMoPreset = {
97
+ id: string
98
+ order?: number
99
+ name: string
100
+ description?: string
101
+ system_prompt?: string
102
+ }
103
+
104
+ export interface MiniCPMoBackendContentItem {
105
+ type: 'text' | 'audio' | 'image' | 'video'
106
+ text?: string
107
+ data?: string
108
+ path?: string
109
+ name?: string
110
+ duration?: number
111
+ }