| import type { |
| MiniCPMoMessage, |
| MiniCPMoServiceStatus, |
| MiniCPMoPresetMode, |
| MiniCPMoPreset, |
| MiniCPMoBackendContentItem, |
| } from '../types/companion' |
|
|
| |
|
|
| function createId(prefix: string): string { |
| return `${prefix}-${Math.random().toString(36).slice(2, 10)}` |
| } |
|
|
| function float32ToBase64(samples: Float32Array): string { |
| const bytes = new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength) |
| const chunkSize = 0x8000 |
| let binary = '' |
| for (let offset = 0; offset < bytes.length; offset += chunkSize) { |
| const slice = bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length)) |
| binary += String.fromCharCode.apply(null, Array.from(slice) as number[]) |
| } |
| return btoa(binary) |
| } |
|
|
| export function float32ToWavBlobUrl(float32: Float32Array, sampleRate: number): string { |
| const buffer = new ArrayBuffer(44 + float32.length * 2) |
| const view = new DataView(buffer) |
| function writeString(offset: number, value: string) { |
| for (let i = 0; i < value.length; i++) view.setUint8(offset + i, value.charCodeAt(i)) |
| } |
| writeString(0, 'RIFF') |
| view.setUint32(4, 36 + float32.length * 2, true) |
| writeString(8, 'WAVE') |
| writeString(12, 'fmt ') |
| view.setUint32(16, 16, true) |
| view.setUint16(20, 1, true) |
| view.setUint16(22, 1, true) |
| view.setUint32(24, sampleRate, true) |
| view.setUint32(28, sampleRate * 2, true) |
| view.setUint16(32, 2, true) |
| view.setUint16(34, 16, true) |
| writeString(36, 'data') |
| view.setUint32(40, float32.length * 2, true) |
| let offset = 44 |
| for (let i = 0; i < float32.length; i++) { |
| const sample = Math.max(-1, Math.min(1, float32[i] ?? 0)) |
| view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true) |
| offset += 2 |
| } |
| return URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' })) |
| } |
|
|
| function audioBase64ToBlobUrl(base64Data: string, sampleRate = 24000): string { |
| const binary = atob(base64Data) |
| const raw = new Uint8Array(binary.length) |
| for (let i = 0; i < binary.length; i++) raw[i] = binary.charCodeAt(i) |
|
|
| |
| if (raw.length >= 44 && raw[0] === 0x52 && raw[1] === 0x49 && raw[2] === 0x46 && raw[3] === 0x46) { |
| return URL.createObjectURL(new Blob([raw], { type: 'audio/wav' })) |
| } |
|
|
| |
| const float32 = new Float32Array(raw.length / 4) |
| for (let i = 0; i < float32.length; i++) { |
| float32[i] = new Float32Array(raw.slice(i * 4, (i + 1) * 4))[0] ?? 0 |
| } |
| return float32ToWavBlobUrl(float32, sampleRate) |
| } |
|
|
| function concatFloat32(chunks: Float32Array[]): Float32Array { |
| let total = 0 |
| for (const chunk of chunks) total += chunk.length |
| const out = new Float32Array(total) |
| let offset = 0 |
| for (const chunk of chunks) { |
| out.set(chunk, offset) |
| offset += chunk.length |
| } |
| return out |
| } |
|
|
| function resampleLinear(input: Float32Array, fromRate: number, toRate: number): Float32Array { |
| if (fromRate === toRate || input.length === 0) return input |
| const ratio = fromRate / toRate |
| const outLength = Math.max(1, Math.floor(input.length / ratio)) |
| const out = new Float32Array(outLength) |
| for (let i = 0; i < outLength; i++) { |
| const srcPos = i * ratio |
| const idx = Math.floor(srcPos) |
| const frac = srcPos - idx |
| const a = input[idx] ?? 0 |
| const b = input[idx + 1] ?? a |
| out[i] = a + (b - a) * frac |
| } |
| return out |
| } |
|
|
| export async function fileToBase64Stripped(file: Blob): Promise<string> { |
| return new Promise((resolve, reject) => { |
| const reader = new FileReader() |
| reader.onload = () => { |
| const result = String(reader.result ?? '') |
| const i = result.indexOf(',') |
| resolve(i >= 0 ? result.slice(i + 1) : result) |
| } |
| reader.onerror = () => reject(reader.error) |
| reader.readAsDataURL(file) |
| }) |
| } |
|
|
| export async function readFileAsDataUrl(file: Blob): Promise<string> { |
| return new Promise((resolve, reject) => { |
| const reader = new FileReader() |
| reader.onload = () => resolve(String(reader.result ?? '')) |
| reader.onerror = () => reject(reader.error) |
| reader.readAsDataURL(file) |
| }) |
| } |
|
|
| export async function downscaleImageToAttachment( |
| file: File, |
| maxEdge = 1280, |
| quality = 0.85, |
| ): Promise<{ id: string; kind: 'image'; previewUrl: string; base64: string; name: string }> { |
| const dataUrl = await readFileAsDataUrl(file) |
| const img: HTMLImageElement = await new Promise((resolve, reject) => { |
| const i = new Image() |
| i.onload = () => resolve(i) |
| i.onerror = () => reject(new Error('image load failed')) |
| i.src = dataUrl |
| }) |
| let w = img.naturalWidth |
| let h = img.naturalHeight |
| const longEdge = Math.max(w, h) |
| if (longEdge > maxEdge) { |
| const scale = maxEdge / longEdge |
| w = Math.round(w * scale) |
| h = Math.round(h * scale) |
| } |
| const canvas = document.createElement('canvas') |
| canvas.width = w |
| canvas.height = h |
| const ctx = canvas.getContext('2d') |
| if (!ctx) throw new Error('canvas 2d unavailable') |
| ctx.drawImage(img, 0, 0, w, h) |
| const outDataUrl = canvas.toDataURL('image/jpeg', quality) |
| const base64 = outDataUrl.slice(outDataUrl.indexOf(',') + 1) |
| return { id: createId('att'), kind: 'image', previewUrl: outDataUrl, base64, name: file.name || 'photo.jpg' } |
| } |
|
|
| export async function mediaFileToAttachment( |
| file: File, |
| kind: 'audio' | 'video', |
| ): Promise<{ id: string; kind: 'audio' | 'video'; previewUrl: string; base64: string; name: string; duration?: number }> { |
| const base64 = await fileToBase64Stripped(file) |
| const previewUrl = URL.createObjectURL(file) |
| let duration: number | undefined |
| try { |
| duration = await new Promise<number>((resolve) => { |
| const el = document.createElement(kind === 'audio' ? 'audio' : 'video') |
| el.preload = 'metadata' |
| const onLoaded = () => { |
| const d = Number.isFinite(el.duration) ? el.duration : 0 |
| resolve(d) |
| } |
| el.addEventListener('loadedmetadata', onLoaded, { once: true }) |
| el.addEventListener('error', () => resolve(0), { once: true }) |
| el.src = previewUrl |
| }) |
| } catch { |
| duration = undefined |
| } |
| return { id: createId('att'), kind, previewUrl, base64, name: file.name || kind, duration } |
| } |
|
|
| |
|
|
| type ServiceStatusResponse = { |
| gateway_healthy: boolean |
| total_workers: number |
| idle_workers: number |
| busy_workers: number |
| queue_length: number |
| offline_workers: number |
| } |
|
|
| export async function fetchServiceStatus(host: string): Promise<MiniCPMoServiceStatus> { |
| try { |
| const res = await fetch(`${host}/status`, { signal: AbortSignal.timeout(5000) }) |
| const data = (await res.json()) as ServiceStatusResponse |
| return { |
| phase: data.gateway_healthy ? 'ready' : 'error', |
| summary: data.gateway_healthy ? '后端就绪' : '网关异常', |
| detail: `${data.idle_workers}/${data.total_workers} workers, 队列 ${data.queue_length}, 离线 ${data.offline_workers}`, |
| } |
| } catch { |
| return { phase: 'error', summary: '后端不可达', detail: '请确保 MiniCPM-o 服务已启动' } |
| } |
| } |
|
|
| export async function fetchPresets(host: string): Promise<Record<MiniCPMoPresetMode, MiniCPMoPreset[]>> { |
| try { |
| const res = await fetch(`${host}/api/presets`, { signal: AbortSignal.timeout(5000) }) |
| if (!res.ok) return { turnbased: [], audio_duplex: [], omni: [] } |
| const data = (await res.json()) as Partial<Record<MiniCPMoPresetMode, MiniCPMoPreset[]>> |
| return { |
| turnbased: data.turnbased ?? [], |
| audio_duplex: data.audio_duplex ?? [], |
| omni: data.omni ?? [], |
| } |
| } catch { |
| return { turnbased: [], audio_duplex: [], omni: [] } |
| } |
| } |
|
|
| |
|
|
| function buildRequestMessages( |
| entries: MiniCPMoMessage[], |
| systemMessage?: string | MiniCPMoBackendContentItem[] | null, |
| ): Array<{ role: string; content: string | MiniCPMoBackendContentItem[] }> { |
| const messages: Array<{ role: string; content: string | MiniCPMoBackendContentItem[] }> = [] |
|
|
| if (typeof systemMessage === 'string' && systemMessage.trim()) { |
| messages.push({ role: 'system', content: systemMessage.trim() }) |
| } else if (Array.isArray(systemMessage) && systemMessage.length) { |
| messages.push({ role: 'system', content: systemMessage }) |
| } |
|
|
| for (const entry of entries) { |
| if (entry.role === 'assistant') { |
| messages.push({ role: 'assistant', content: entry.text }) |
| } else if (entry.kind === 'text') { |
| const atts = entry.attachments ?? [] |
| if (atts.length === 0) { |
| messages.push({ role: 'user', content: entry.text }) |
| } else { |
| const items: MiniCPMoBackendContentItem[] = [] |
| for (const a of atts) { |
| if (a.kind === 'image') items.push({ type: 'image', data: a.base64 }) |
| else if (a.kind === 'audio') items.push({ type: 'audio', data: a.base64, name: a.name, duration: a.duration }) |
| else items.push({ type: 'video', data: a.base64, duration: a.duration }) |
| } |
| if (entry.text) items.push({ type: 'text', text: entry.text }) |
| messages.push({ role: 'user', content: items }) |
| } |
| } else if (entry.kind === 'voice') { |
| const voiceAtts = entry.attachments ?? [] |
| if (voiceAtts.length === 0) { |
| messages.push({ role: 'user', content: [{ type: 'audio', data: entry.audioBase64 }] }) |
| } else { |
| const items: MiniCPMoBackendContentItem[] = [] |
| for (const a of voiceAtts) { |
| if (a.kind === 'image') items.push({ type: 'image', data: a.base64 }) |
| else if (a.kind === 'audio') items.push({ type: 'audio', data: a.base64, name: a.name, duration: a.duration }) |
| else items.push({ type: 'video', data: a.base64, duration: a.duration }) |
| } |
| items.push({ type: 'audio', data: entry.audioBase64 }) |
| messages.push({ role: 'user', content: items }) |
| } |
| } |
| } |
|
|
| return messages |
| } |
|
|
| |
|
|
| export class StreamingPcmPlayer { |
| private readonly audioCtx: AudioContext |
| private readonly sampleRate: number |
| private readonly chunks: Float32Array[] = [] |
| private nextStartTime = 0 |
| private finished = false |
| private disposed = false |
|
|
| constructor(sampleRate = 24000) { |
| this.sampleRate = sampleRate |
| const ctor = window.AudioContext ?? (window as any).webkitAudioContext |
| if (!ctor) throw new Error('AudioContext not supported') |
| this.audioCtx = new ctor({ sampleRate }) |
| } |
|
|
| pushBase64(base64Data: string): void { |
| if (this.disposed) return |
| const binary = atob(base64Data) |
| const bytes = new Uint8Array(binary.length) |
| for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) |
| if (bytes.byteLength === 0) return |
| const float32 = new Float32Array(bytes.buffer.slice(0)) |
| if (float32.length === 0) return |
| this.chunks.push(float32) |
| this.scheduleChunk(float32) |
| } |
|
|
| private scheduleChunk(float32: Float32Array): void { |
| if (this.audioCtx.state === 'suspended') void this.audioCtx.resume().catch(() => {}) |
| const buffer = this.audioCtx.createBuffer(1, float32.length, this.sampleRate) |
| buffer.getChannelData(0).set(float32) |
| const source = this.audioCtx.createBufferSource() |
| source.buffer = buffer |
| source.connect(this.audioCtx.destination) |
| const now = this.audioCtx.currentTime |
| const when = Math.max(now + 0.02, this.nextStartTime) |
| source.start(when) |
| this.nextStartTime = when + buffer.duration |
| } |
|
|
| markFinished(): void { this.finished = true } |
| isFinished(): boolean { return this.finished } |
|
|
| getMergedFloat32(): Float32Array | null { |
| if (this.chunks.length === 0) return null |
| const total = this.chunks.reduce((sum, c) => sum + c.length, 0) |
| const merged = new Float32Array(total) |
| let offset = 0 |
| for (const chunk of this.chunks) { merged.set(chunk, offset); offset += chunk.length } |
| return merged |
| } |
|
|
| getSampleRate(): number { return this.sampleRate } |
|
|
| async dispose(): Promise<void> { |
| this.disposed = true |
| try { await this.audioCtx.close() } catch { } |
| } |
|
|
| disposeAfterDrain(onDrained?: () => void): void { |
| if (this.disposed) { onDrained?.(); return } |
| const now = this.audioCtx.currentTime |
| const drainSeconds = Math.max(0, this.nextStartTime - now) |
| setTimeout(() => { void this.dispose(); onDrained?.() }, Math.ceil(drainSeconds * 1000) + 500) |
| } |
| } |
|
|
| |
|
|
| type ChatPayload = { |
| text?: string |
| error?: string |
| success?: boolean |
| audio_data?: string | null |
| audio_sample_rate?: number |
| recording_session_id?: string | null |
| } |
|
|
| export async function submitChatNonStreaming( |
| host: string, |
| messages: MiniCPMoMessage[], |
| systemMessage: string | null, |
| maxNewTokens: number, |
| lengthPenalty: number, |
| ttsEnabled: boolean, |
| signal?: AbortSignal, |
| ): Promise<{ entry: MiniCPMoMessage; sessionId: string | null }> { |
| const requestBody = JSON.stringify({ |
| messages: buildRequestMessages(messages, systemMessage), |
| streaming: false, |
| generation: { max_new_tokens: maxNewTokens, length_penalty: lengthPenalty }, |
| ...(ttsEnabled ? { use_tts_template: true } : {}), |
| tts: { enabled: ttsEnabled, mode: 'audio_assistant' }, |
| }) |
|
|
| const res = await fetch(`${host}/api/chat`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: requestBody, |
| signal, |
| }) |
|
|
| const rawText = await res.text() |
| let payload: ChatPayload |
| try { payload = JSON.parse(rawText) as ChatPayload } catch { throw new Error(rawText || `HTTP ${res.status}`) } |
| if (!res.ok || payload.success === false) throw new Error(payload.error || `HTTP ${res.status}`) |
|
|
| let audioUrl: string | null = null |
| const audioSampleRate = payload.audio_sample_rate ?? 24000 |
| if (payload.audio_data) { |
| try { audioUrl = audioBase64ToBlobUrl(payload.audio_data, audioSampleRate) } catch { audioUrl = null } |
| } |
|
|
| return { |
| entry: { |
| id: createId('assistant'), |
| role: 'assistant', |
| kind: 'assistant', |
| text: payload.text?.trim() || '(empty reply)', |
| audioPreviewUrl: audioUrl, |
| audioBase64: payload.audio_data ?? null, |
| audioSampleRate: payload.audio_data ? audioSampleRate : null, |
| recordingSessionId: payload.recording_session_id ?? null, |
| }, |
| sessionId: payload.recording_session_id ?? null, |
| } |
| } |
|
|
| export type StreamCallbacks = { |
| onChunk: (text: string) => void |
| onAudioBase64: (data: string, sampleRate: number) => void |
| onDone: (fullText: string, recordingSessionId: string | null) => void |
| onError: (error: string) => void |
| } |
|
|
| export function submitChatStreaming( |
| host: string, |
| messages: MiniCPMoMessage[], |
| systemMessage: string | null, |
| maxNewTokens: number, |
| lengthPenalty: number, |
| ttsEnabled: boolean, |
| callbacks: StreamCallbacks, |
| player?: StreamingPcmPlayer, |
| ): { abort: () => void } { |
| const wsProto = host.startsWith('https') ? 'wss:' : 'ws:' |
| const wsHost = host.replace(/^https?:\/\//, '') |
| const wsUrl = `${wsProto}//${wsHost}/ws/chat` |
| let finished = false |
| let ws: WebSocket | null = null |
| let fullText = '' |
|
|
| try { ws = new WebSocket(wsUrl) } catch (e) { callbacks.onError('WebSocket creation failed'); return { abort: () => {} } } |
|
|
| ws.onopen = () => { |
| ws!.send(JSON.stringify({ |
| messages: buildRequestMessages(messages, systemMessage), |
| streaming: true, |
| generation: { max_new_tokens: maxNewTokens, length_penalty: lengthPenalty }, |
| ...(ttsEnabled ? { use_tts_template: true } : {}), |
| tts: { enabled: ttsEnabled, mode: 'audio_assistant' }, |
| })) |
| } |
|
|
| ws.onmessage = (event) => { |
| if (finished) return |
| let msg: any |
| try { msg = JSON.parse(event.data) } catch { return } |
|
|
| if (msg.type === 'prefill_done') return |
| if (msg.type === 'chunk') { |
| if (typeof msg.text_delta === 'string' && msg.text_delta) { |
| fullText += msg.text_delta |
| callbacks.onChunk(fullText) |
| } |
| if (msg.audio_data && player) { |
| try { player.pushBase64(msg.audio_data) } catch { } |
| } |
| return |
| } |
| if (msg.type === 'done') { |
| finished = true |
| const finalText = (fullText || msg.text || '').trim() || '(empty reply)' |
| callbacks.onDone(finalText, msg.recording_session_id ?? null) |
| try { ws?.close() } catch { } |
| return |
| } |
| if (msg.type === 'error') { |
| finished = true |
| callbacks.onError(msg.error || 'unknown error') |
| try { ws?.close() } catch { } |
| } |
| } |
|
|
| ws.onerror = () => { if (!finished) { finished = true; callbacks.onError('WebSocket connection error') } } |
| ws.onclose = () => { if (!finished) { finished = true; callbacks.onError('WebSocket closed unexpectedly') } } |
|
|
| return { |
| abort: () => { |
| finished = true |
| try { ws?.close() } catch { } |
| }, |
| } |
| } |
|
|
| |
|
|
| function getPcmWorkletUrl(backendHost: string): string { |
| const host = backendHost.replace(/\/+$/, '') |
| return `${host}/static/duplex/lib/pcm-capture-turnbased.js` |
| } |
|
|
| export function getAudioContextCtor(): typeof AudioContext | null { |
| return window.AudioContext ?? (window as any).webkitAudioContext ?? null |
| } |
|
|
| type MicCaptureState = { |
| stream: MediaStream | null |
| ctx: AudioContext | null |
| source: MediaStreamAudioSourceNode | null |
| worklet: AudioWorkletNode | null |
| processor: ScriptProcessorNode | null |
| muteGain: GainNode | null |
| chunks: Float32Array[] |
| sampleRate: number |
| capturing: boolean |
| } |
|
|
| export function createMicCapture(): MicCaptureState { |
| return { |
| stream: null, |
| ctx: null, |
| source: null, |
| worklet: null, |
| processor: null, |
| muteGain: null, |
| chunks: [], |
| sampleRate: 16000, |
| capturing: false, |
| } |
| } |
|
|
| export async function prewarmMic(state: MicCaptureState, backendHost: string): Promise<boolean> { |
| if (state.ctx && state.stream) return true |
| const AudioContextCtor = getAudioContextCtor() |
| if (!AudioContextCtor || !navigator.mediaDevices?.getUserMedia) return false |
|
|
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) |
| const ctx = new AudioContextCtor() |
| if (ctx.state === 'suspended') await ctx.resume().catch(() => {}) |
| const source = ctx.createMediaStreamSource(stream) |
| state.stream = stream |
| state.ctx = ctx |
| state.source = source |
| state.chunks = [] |
| state.sampleRate = ctx.sampleRate |
|
|
| if (typeof AudioWorkletNode !== 'undefined' && typeof ctx.audioWorklet?.addModule === 'function') { |
| try { |
| const workletUrl = getPcmWorkletUrl(backendHost) |
| await ctx.audioWorklet.addModule(workletUrl) |
| const node = new AudioWorkletNode(ctx, 'pcm-capture-turnbased') |
| node.port.onmessage = (event: MessageEvent) => { |
| const data = event.data as { type: string; samples: Float32Array } | undefined |
| if (data?.type === 'pcm' && state.capturing) state.chunks.push(data.samples) |
| } |
| source.connect(node) |
| state.worklet = node |
| return true |
| } catch { |
| |
| } |
| } |
|
|
| |
| const processor = ctx.createScriptProcessor(4096, 1, 1) |
| const muteGain = ctx.createGain() |
| muteGain.gain.value = 0 |
| processor.onaudioprocess = (event) => { |
| if (!state.capturing) return |
| const input = event.inputBuffer.getChannelData(0) |
| const copy = new Float32Array(input.length) |
| copy.set(input) |
| state.chunks.push(copy) |
| } |
| source.connect(processor) |
| processor.connect(muteGain) |
| muteGain.connect(ctx.destination) |
| state.processor = processor |
| state.muteGain = muteGain |
| return true |
| } catch { |
| return false |
| } |
| } |
|
|
| export function setCapturing(state: MicCaptureState, value: boolean) { |
| state.capturing = value |
| if (state.worklet) { |
| try { state.worklet.port.postMessage({ type: 'capture', value }) } catch { } |
| } |
| } |
|
|
| export function coldDownMic(state: MicCaptureState) { |
| state.capturing = false |
| try { state.worklet?.port.postMessage({ type: 'capture', value: false }) } catch { } |
| try { state.worklet?.disconnect() } catch { } |
| try { state.processor?.disconnect() } catch { } |
| try { state.source?.disconnect() } catch { } |
| try { state.muteGain?.disconnect() } catch { } |
| state.worklet = null |
| state.processor = null |
| state.source = null |
| state.muteGain = null |
| if (state.ctx && state.ctx.state !== 'closed') void state.ctx.close().catch(() => {}) |
| state.ctx = null |
| state.stream?.getTracks().forEach((t) => t.stop()) |
| state.stream = null |
| state.chunks = [] |
| } |
|
|
| export function finalizeRecordingChunks( |
| state: MicCaptureState, |
| ): { audioBase64: string; previewUrl: string; durationMs: number } | null { |
| const chunks = state.chunks |
| if (chunks.length === 0) return null |
| const merged = concatFloat32(chunks) |
| if (merged.length === 0) return null |
| const resampled = resampleLinear(merged, state.sampleRate, 16000) |
| const audioBase64 = float32ToBase64(resampled) |
| const previewUrl = float32ToWavBlobUrl(resampled, 16000) |
| return { audioBase64, previewUrl, durationMs: 0 } |
| } |
|
|