chore: omni script
Browse files- desktop/scripts/start-omni.sh +55 -0
- desktop/sidecars/omni-adapter.ts +496 -0
- desktop/src/hooks/useMiniCPMoBackend.ts +604 -0
- desktop/src/pages/Companion.tsx +1356 -210
- desktop/src/stores/companionStore.ts +144 -1
- desktop/src/types/companion.ts +82 -0
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
const
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
const
|
| 33 |
-
const
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
const
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
)
|
| 49 |
-
|
| 50 |
-
companionAudio.flush()
|
| 51 |
-
}, [companionAudio])
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
// Enumerate cameras when camera is enabled
|
| 68 |
useEffect(() => {
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
}
|
| 77 |
-
}, [
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
// Periodic webcam frame capture (only send when connected)
|
| 97 |
useEffect(() => {
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
ws.sendFrame(frame)
|
| 103 |
-
}
|
| 104 |
-
}, 1000)
|
| 105 |
-
} else {
|
| 106 |
-
if (frameIntervalRef.current) {
|
| 107 |
-
clearInterval(frameIntervalRef.current)
|
| 108 |
-
frameIntervalRef.current = null
|
| 109 |
-
}
|
| 110 |
}
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
}
|
| 116 |
}
|
| 117 |
-
|
|
|
|
| 118 |
|
| 119 |
-
//
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
} else {
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
|
|
|
| 133 |
}
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
}
|
| 140 |
-
}, [
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
},
|
| 158 |
-
[ws],
|
| 159 |
-
)
|
| 160 |
|
| 161 |
-
|
| 162 |
-
companionAudio.resume()
|
| 163 |
-
}, [companionAudio])
|
| 164 |
|
| 165 |
-
const
|
| 166 |
-
|
| 167 |
-
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
}
|
| 178 |
-
|
|
|
|
| 179 |
|
| 180 |
-
const
|
| 181 |
-
const
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
}, [cameraFacingMode, setCameraFacingMode, cameraDevices])
|
| 185 |
|
| 186 |
-
|
| 187 |
-
if (
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
}
|
| 192 |
-
}, [screenShare, setScreenShareDialogOpen])
|
| 193 |
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
return (
|
| 199 |
-
<div className="relative h-full w-full overflow-hidden bg-
|
| 200 |
-
{/*
|
| 201 |
-
<
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
/>
|
| 208 |
|
| 209 |
-
{/* Top
|
| 210 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
-
{/* Error
|
| 219 |
{error && (
|
| 220 |
-
<div className="
|
| 221 |
{error}
|
| 222 |
</div>
|
| 223 |
)}
|
| 224 |
|
| 225 |
-
{/*
|
| 226 |
-
{
|
| 227 |
-
<div className="
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
</div>
|
| 230 |
)}
|
| 231 |
|
| 232 |
-
{/*
|
| 233 |
-
<
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|