diff --git a/frontend/web/src/api/client.ts b/frontend/web/src/api/client.ts index 44acf2f..a110f5b 100644 --- a/frontend/web/src/api/client.ts +++ b/frontend/web/src/api/client.ts @@ -62,6 +62,22 @@ export const api = { `/messages/search?q=${encodeURIComponent(q)}`), }, + voice: { + // tts returns the synthesized audio as a Blob (audio/mpeg). + tts: async (text: string): Promise => { + const res = await fetch(`${BASE}/voice/tts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ text }), + }) + if (!res.ok) throw new Error(`tts failed: ${res.status}`) + return res.blob() + }, + // stt sends base64-encoded audio and returns the recognized text. + stt: (audioBase64: string) => + request<{ text: string }>('/voice/stt', { method: 'POST', body: JSON.stringify({ audio_data: audioBase64 }) }), + }, + admin: { dashboard: () => request<{ dashboard: Record }>('/admin/dashboard'), }, diff --git a/frontend/web/src/components/chat/ChatInput.tsx b/frontend/web/src/components/chat/ChatInput.tsx index 8871d4e..c02e664 100644 --- a/frontend/web/src/components/chat/ChatInput.tsx +++ b/frontend/web/src/components/chat/ChatInput.tsx @@ -1,3 +1,6 @@ +import { useRef, useState } from 'react' +import { api } from '../../api/client' + interface ChatInputProps { value: string onChange: (v: string) => void @@ -6,6 +9,9 @@ interface ChatInputProps { } export default function ChatInput({ value, onChange, onSend, disabled }: ChatInputProps) { + const [recording, setRecording] = useState(false) + const recorderRef = useRef(null) + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() @@ -13,6 +19,36 @@ export default function ChatInput({ value, onChange, onSend, disabled }: ChatInp } } + const toggleRecord = async () => { + if (recording) { + recorderRef.current?.stop() + return + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const recorder = new MediaRecorder(stream) + const chunks: Blob[] = [] + recorder.ondataavailable = (e) => chunks.push(e.data) + recorder.onstop = async () => { + stream.getTracks().forEach((t) => t.stop()) + setRecording(false) + const blob = new Blob(chunks, { type: recorder.mimeType }) + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader() + reader.onloadend = () => resolve(reader.result as string) + reader.readAsDataURL(blob) + }) + try { + const { text } = await api.voice.stt(dataUrl.split(',')[1] || '') + if (text.trim()) onChange(value ? `${value} ${text}` : text) + } catch { /* STT unavailable */ } + } + recorderRef.current = recorder + recorder.start() + setRecording(true) + } catch { /* mic permission denied */ } + } + return (
onChange(e.target.value)} onKeyDown={handleKeyDown} disabled={disabled} - placeholder="输入消息..." + placeholder={recording ? '正在录音,点击 ⏹ 结束…' : '输入消息...'} className="flex-1 bg-transparent px-3 py-1.5 text-sm text-[var(--mx-text)] placeholder:text-[var(--mx-text-dim)] outline-none disabled:opacity-40" /> + + ) +} + function MessageBubble({ message }: { message: Message }) { const isUser = message.role === 'user' const isAction = message.role === 'action' @@ -57,7 +93,10 @@ function MessageBubble({ message }: { message: Message }) { ) : ( - message.content + <> + {message.content} + {!isUser && message.role === 'assistant' && } + )}