From b0a7ce885e3659b1d75ce378dd4a08d77e21103a Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sat, 13 Jun 2026 21:09:56 +0800 Subject: [PATCH 01/40] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E7=BB=99=E5=89=8D=E7=AB=AF=E7=9A=84=20totalTokens=20?= =?UTF-8?q?=E4=B8=BA0=E7=9A=84=E9=97=AE=E9=A2=98=E5=B9=B6=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E7=A4=BA=E4=BE=8B=20.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - backend/.env | 17 +++++++++++ backend/.gitignore | 1 - backend/internal/orchestrator/pipeline.go | 36 ++++++++++++++++------- 4 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 backend/.env diff --git a/.gitignore b/.gitignore index 4c77b4c..d7a0342 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ backend/bin/ backend/server # ---- 环境变量 ---- -.env .env.local .env.*.local diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..48b24a0 --- /dev/null +++ b/backend/.env @@ -0,0 +1,17 @@ +# ---- AI 服务 API Key ---- +CAMTALK_AI_LLM_API_KEY=sk-ws-H.REHELLY.C4s3.MEUCIQCRee37XWEKp2szaxVLFDtR1rxNNsf372zMvCR0Xl6UvQIgZgvhRTvaa1FmhbCQJgaHu4Jny29AQkn01-3hX9CWBOg +CAMTALK_AI_STT_API_KEY=tp-c9e7scwfx94qvqyhpnahnw8uaiya01za2qzvg4xe24rp3xiv +CAMTALK_AI_TTS_API_KEY=tp-c9e7scwfx94qvqyhpnahnw8uaiya01za2qzvg4xe24rp3xiv + +# ---- 可选覆盖(默认值见 config.yaml)---- +# CAMTALK_AI_LLM_MODEL=qwen3-vl-plus +# CAMTALK_AI_LLM_ENDPOINT=https://api.openai.com/v1 +# CAMTALK_AI_LLM_TIMEOUT=10 +# CAMTALK_AI_STT_ENDPOINT=wss://api.deepgram.com/v1/listen +# CAMTALK_AI_TTS_ENDPOINT=https://api.openai.com/v1 +# CAMTALK_AI_TTS_VOICE=alloy +# CAMTALK_AI_TTS_SPEED=1.0 +# CAMTALK_AI_TTS_TIMEOUT=5 + +# ---- 应用 ---- +# APP_ENV=dev diff --git a/backend/.gitignore b/backend/.gitignore index 8304260..ac13b7b 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -3,7 +3,6 @@ bin/ # 环境配置 -.env config.dev.yaml config.prod.yaml diff --git a/backend/internal/orchestrator/pipeline.go b/backend/internal/orchestrator/pipeline.go index 0186108..d8d2823 100644 --- a/backend/internal/orchestrator/pipeline.go +++ b/backend/internal/orchestrator/pipeline.go @@ -166,11 +166,12 @@ func (p *Pipeline) ProcessQuery( var ttsErr error // goroutine 1: 消费 LLM token + 句子切分 + var tokenUsage *llm.TokenUsage wg.Add(1) go func() { defer wg.Done() defer close(sentenceCh) - fullText = p.consumeLLMStream(ctx, llmStream, req.RequestID, sender, splitter) + fullText, tokenUsage = p.consumeLLMStream(ctx, llmStream, req.RequestID, sender, splitter) }() // goroutine 2: TTS 合成(如果启用) @@ -205,13 +206,25 @@ func (p *Pipeline) ProcessQuery( // 发送 llm_done latency := time.Since(startTime).Milliseconds() - if err := sender.SendLLMDone(models.WsLLMDone{ + done := models.WsLLMDone{ Type: "llm_done", RequestID: req.RequestID, FullText: fullText, Model: p.model, LatencyMs: latency, - }); err != nil { + } + if tokenUsage != nil { + done.TokensUsed = struct { + Prompt int `json:"prompt"` + Completion int `json:"completion"` + Total int `json:"total"` + }{ + Prompt: tokenUsage.Prompt, + Completion: tokenUsage.Completion, + Total: tokenUsage.Total, + } + } + if err := sender.SendLLMDone(done); err != nil { log.Errorw("发送 llm_done 失败", "error", err) } @@ -225,33 +238,36 @@ func (p *Pipeline) ProcessQuery( } // consumeLLMStream 消费 LLM 流式输出,发送 llm_chunk 并进行句子切分。 +// 返回完整文本和 token 用量。 func (p *Pipeline) consumeLLMStream( ctx context.Context, stream <-chan llm.Chunk, requestID string, sender Sender, splitter *Splitter, -) string { +) (string, *llm.TokenUsage) { log := logger.Log var fullText strings.Builder + var tokenUsage *llm.TokenUsage for chunk := range stream { // 检查上下文是否已取消 select { case <-ctx.Done(): log.Infow("LLM 流被中断", "request_id", requestID) - return fullText.String() + return fullText.String(), tokenUsage default: } if chunk.Done { - // 流结束 + // 流结束,记录 token 用量 if chunk.TokensUsed != nil { + tokenUsage = chunk.TokensUsed log.Infow("LLM 用量统计", "request_id", requestID, - "prompt_tokens", chunk.TokensUsed.Prompt, - "completion_tokens", chunk.TokensUsed.Completion, - "total_tokens", chunk.TokensUsed.Total, + "prompt_tokens", tokenUsage.Prompt, + "completion_tokens", tokenUsage.Completion, + "total_tokens", tokenUsage.Total, ) } break @@ -277,7 +293,7 @@ func (p *Pipeline) consumeLLMStream( // 刷新切分器中的剩余文本 splitter.Flush() - return fullText.String() + return fullText.String(), tokenUsage } // synthesizeTTS 从句子 channel 读取文本,进行 TTS 合成并发送音频。 -- 2.49.1 From 3d3de828fc3584cfd57138245f049912b872b073 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sat, 13 Jun 2026 21:31:58 +0800 Subject: [PATCH 02/40] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Xiaomi=20MiM?= =?UTF-8?q?o=20ASR=20=E8=AF=AD=E9=9F=B3=E8=AF=86=E5=88=AB=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E8=80=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MiMoService 实现 stt.Service 接口,通过 HTTP POST 调用 OpenAI 兼容的 /chat/completions 接口 - 自动将原始 PCM 数据封装为 WAV 格式(MiMo 仅支持 mp3/wav) - 语言代码映射:zh-CN→zh、en-US→en、其他→auto - main.go 添加 provider 选择逻辑(mimo/xiaomi → MiMo,其他 → Deepgram) - 更新 config.yaml 使用正确的 model 名称 mimo-v2.5-asr - 添加完整单元测试 --- backend/cmd/server/main.go | 9 +- backend/config.yaml | 4 +- backend/internal/ai/stt/mimo.go | 232 ++++++++++++++++++++++++ backend/internal/ai/stt/mimo_test.go | 255 +++++++++++++++++++++++++++ 4 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 backend/internal/ai/stt/mimo.go create mode 100644 backend/internal/ai/stt/mimo_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 134b791..5840f87 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "os/signal" + "strings" "syscall" "time" @@ -46,7 +47,13 @@ func main() { defer sessionMgr.(*session.MemoryManager).Stop() // 初始化 AI 服务 - sttService := stt.NewDeepgramService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, logger.Log) + var sttService stt.Service + switch strings.ToLower(cfg.AI.STT.Provider) { + case "mimo", "xiaomi": + sttService = stt.NewMiMoService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, logger.Log) + default: + sttService = stt.NewDeepgramService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, logger.Log) + } llmService := llm.NewOpenAIService(cfg.AI.LLM.APIKey, cfg.AI.LLM.Model, cfg.AI.LLM.Endpoint, cfg.AI.LLM.Timeout, logger.Log) ttsService := tts.NewOpenAIService(cfg.AI.TTS.APIKey, cfg.AI.TTS.Model, cfg.AI.TTS.Voice, cfg.AI.TTS.Endpoint, cfg.AI.TTS.Speed, cfg.AI.TTS.Timeout, logger.Log) diff --git a/backend/config.yaml b/backend/config.yaml index 988f336..2e9ae04 100644 --- a/backend/config.yaml +++ b/backend/config.yaml @@ -15,8 +15,8 @@ redis: ai: stt: - provider: Xiaomi MiMo - model: mimo-v2.5 + provider: mimo + model: mimo-v2.5-asr endpoint: "https://token-plan-cn.xiaomimimo.com/v1" llm: provider: dashscope diff --git a/backend/internal/ai/stt/mimo.go b/backend/internal/ai/stt/mimo.go new file mode 100644 index 0000000..49d96fd --- /dev/null +++ b/backend/internal/ai/stt/mimo.go @@ -0,0 +1,232 @@ +package stt + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "go.uber.org/zap" +) + +// MiMoService 基于 Xiaomi MiMo ASR HTTP API 的语音识别实现。 +// 接口兼容 OpenAI chat/completions 格式,音频仅支持 mp3/wav。 +type MiMoService struct { + apiKey string + model string + endpoint string + logger *zap.SugaredLogger +} + +// NewMiMoService 创建 MiMo STT 服务。 +func NewMiMoService(apiKey, model, endpoint string, logger *zap.SugaredLogger) *MiMoService { + if model == "" { + model = "mimo-v2.5-asr" + } + if endpoint == "" { + endpoint = "https://api.xiaomimimo.com/v1" + } + return &MiMoService{ + apiKey: apiKey, + model: model, + endpoint: endpoint, + logger: logger, + } +} + +// mimoRequest MiMo ASR 请求体。 +type mimoRequest struct { + Model string `json:"model"` + Messages []mimoMessage `json:"messages"` + ASROptions *mimoASROptions `json:"asr_options,omitempty"` +} + +type mimoMessage struct { + Role string `json:"role"` + Content []mimoContent `json:"content"` +} + +type mimoContent struct { + Type string `json:"type"` + InputAudio *mimoAudioIn `json:"input_audio,omitempty"` +} + +type mimoAudioIn struct { + Data string `json:"data"` // data URL: data:{mime};base64,{data} +} + +type mimoASROptions struct { + Language string `json:"language"` +} + +// mimoResponse MiMo ASR 非流式响应。 +type mimoResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +// Recognize 实现 stt.Service。将音频发送到 MiMo ASR API,返回识别文本。 +func (m *MiMoService) Recognize(ctx context.Context, audio []byte, opts Options) (string, error) { + if len(audio) == 0 { + return "", fmt.Errorf("stt: empty audio") + } + + // MiMo 仅支持 mp3/wav,若输入为原始 PCM 则封装为 WAV + audioData := audio + mimeType := "audio/wav" + if !isWAV(audio) && !isMP3(audio) { + wav, err := pcmToWAV(audio, opts.SampleRate, 1) + if err != nil { + return "", fmt.Errorf("stt: pcm to wav: %w", err) + } + audioData = wav + } else if isMP3(audio) { + mimeType = "audio/mpeg" + } + + b64 := base64.StdEncoding.EncodeToString(audioData) + dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64) + + // 映射语言代码 + language := mapLanguage(opts.Language) + + reqBody := mimoRequest{ + Model: m.model, + Messages: []mimoMessage{ + { + Role: "user", + Content: []mimoContent{ + { + Type: "input_audio", + InputAudio: &mimoAudioIn{ + Data: dataURL, + }, + }, + }, + }, + }, + } + if language != "" { + reqBody.ASROptions = &mimoASROptions{Language: language} + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("stt: marshal request: %w", err) + } + + url := strings.TrimRight(m.endpoint, "/") + "/chat/completions" + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("stt: create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("api-key", m.apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("stt: request mimo: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("stt: read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("stt: mimo returned %d: %s", resp.StatusCode, string(respBody)) + } + + var mResp mimoResponse + if err := json.Unmarshal(respBody, &mResp); err != nil { + return "", fmt.Errorf("stt: unmarshal response: %w", err) + } + + if len(mResp.Choices) == 0 { + return "", fmt.Errorf("stt: mimo returned empty choices") + } + + text := strings.TrimSpace(mResp.Choices[0].Message.Content) + return text, nil +} + +// mapLanguage 将标准语言代码映射为 MiMo 支持的值(auto/zh/en)。 +func mapLanguage(lang string) string { + switch { + case lang == "": + return "auto" + case strings.HasPrefix(lang, "zh"): + return "zh" + case strings.HasPrefix(lang, "en"): + return "en" + default: + return "auto" + } +} + +// isWAV 检查数据是否为 WAV 格式(RIFF 头)。 +func isWAV(data []byte) bool { + return len(data) > 4 && string(data[:4]) == "RIFF" +} + +// isMP3 检查数据是否为 MP3 格式(ID3 标签或帧同步字)。 +func isMP3(data []byte) bool { + if len(data) > 3 && string(data[:3]) == "ID3" { + return true + } + // 帧同步字:0xFF 0xFB/0xF3/0xF2 + return len(data) > 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0 +} + +// pcmToWAV 将原始 PCM 数据封装为 WAV 文件。 +func pcmToWAV(pcm []byte, sampleRate, channels int) ([]byte, error) { + if sampleRate == 0 { + sampleRate = 16000 + } + if channels == 0 { + channels = 1 + } + + bitsPerSample := 16 + byteRate := sampleRate * channels * bitsPerSample / 8 + blockAlign := channels * bitsPerSample / 8 + dataSize := len(pcm) + + var buf bytes.Buffer + + // RIFF header + buf.WriteString("RIFF") + binary.Write(&buf, binary.LittleEndian, uint32(36+dataSize)) + buf.WriteString("WAVE") + + // fmt 子块 + buf.WriteString("fmt ") + binary.Write(&buf, binary.LittleEndian, uint32(16)) // 子块大小 + binary.Write(&buf, binary.LittleEndian, uint16(1)) // PCM 格式 + binary.Write(&buf, binary.LittleEndian, uint16(channels)) // 通道数 + binary.Write(&buf, binary.LittleEndian, uint32(sampleRate)) // 采样率 + binary.Write(&buf, binary.LittleEndian, uint32(byteRate)) // 字节率 + binary.Write(&buf, binary.LittleEndian, uint16(blockAlign)) // 块对齐 + binary.Write(&buf, binary.LittleEndian, uint16(bitsPerSample)) // 每样本位数 + + // data 子块 + buf.WriteString("data") + binary.Write(&buf, binary.LittleEndian, uint32(dataSize)) + buf.Write(pcm) + + return buf.Bytes(), nil +} diff --git a/backend/internal/ai/stt/mimo_test.go b/backend/internal/ai/stt/mimo_test.go new file mode 100644 index 0000000..80c4cf1 --- /dev/null +++ b/backend/internal/ai/stt/mimo_test.go @@ -0,0 +1,255 @@ +package stt + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" +) + +func newTestMiMoService(handler http.HandlerFunc) (*MiMoService, *httptest.Server) { + srv := httptest.NewServer(handler) + s := NewMiMoService("test-key", "mimo-v2.5-asr", srv.URL, zap.NewNop().Sugar()) + return s, srv +} + +func TestMiMoService_Recognize_Success(t *testing.T) { + s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) { + // 验证请求 + if r.Header.Get("api-key") != "test-key" { + t.Errorf("expected api-key test-key, got %s", r.Header.Get("api-key")) + } + if r.URL.Path != "/chat/completions" { + t.Errorf("expected path /chat/completions, got %s", r.URL.Path) + } + + var req mimoRequest + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if req.Model != "mimo-v2.5-asr" { + t.Errorf("expected model mimo-v2.5-asr, got %s", req.Model) + } + if len(req.Messages) == 0 || req.Messages[0].Role != "user" { + t.Error("expected user message") + } + if req.ASROptions == nil || req.ASROptions.Language != "zh" { + t.Errorf("expected language zh, got %v", req.ASROptions) + } + + resp := mimoResponse{ + Choices: []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + }{ + {Message: struct { + Content string `json:"content"` + }{Content: "你好世界"}}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + + // 发送一个简单的有效 WAV(44 字节头 + 少量 PCM) + wav := makeValidWAV([]byte{0x00, 0x00, 0x00, 0x00}) + text, err := s.Recognize(context.Background(), wav, Options{Language: "zh-CN"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if text != "你好世界" { + t.Errorf("expected '你好世界', got '%s'", text) + } +} + +func TestMiMoService_Recognize_EmptyAudio(t *testing.T) { + s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) {}) + defer srv.Close() + + _, err := s.Recognize(context.Background(), nil, Options{}) + if err == nil { + t.Fatal("expected error for empty audio") + } +} + +func TestMiMoService_Recognize_ServerError(t *testing.T) { + s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + }) + defer srv.Close() + + wav := makeValidWAV([]byte{0x00, 0x00}) + _, err := s.Recognize(context.Background(), wav, Options{}) + if err == nil { + t.Fatal("expected error for 500 response") + } +} + +func TestMiMoService_Recognize_EmptyChoices(t *testing.T) { + s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) { + resp := mimoResponse{Choices: nil} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + + wav := makeValidWAV([]byte{0x00, 0x00}) + _, err := s.Recognize(context.Background(), wav, Options{}) + if err == nil { + t.Fatal("expected error for empty choices") + } +} + +func TestMiMoService_Recognize_PCMAutoWrap(t *testing.T) { + // 测试原始 PCM 数据自动封装为 WAV + s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) { + var req mimoRequest + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + + // 验证 data URL 格式 + if len(req.Messages) == 0 || len(req.Messages[0].Content) == 0 { + t.Fatal("empty message content") + } + dataURL := req.Messages[0].Content[0].InputAudio.Data + if len(dataURL) < 22 || dataURL[:14] != "data:audio/wav" { + t.Errorf("expected wav data URL, got prefix: %s", dataURL[:min(len(dataURL), 30)]) + } + + // 验证 base64 可解码 + b64Part := dataURL[22:] // skip "data:audio/wav;base64," + decoded, err := base64.StdEncoding.DecodeString(b64Part) + if err != nil { + t.Fatalf("base64 decode failed: %v", err) + } + // 应该是有效 WAV(RIFF 头) + if len(decoded) < 44 || string(decoded[:4]) != "RIFF" { + t.Error("decoded data is not a valid WAV") + } + + resp := mimoResponse{ + Choices: []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + }{ + {Message: struct { + Content string `json:"content"` + }{Content: "test"}}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + }) + defer srv.Close() + + // 发送原始 PCM(非 WAV/MP3) + pcm := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07} + text, err := s.Recognize(context.Background(), pcm, Options{SampleRate: 16000}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if text != "test" { + t.Errorf("expected 'test', got '%s'", text) + } +} + +func TestMapLanguage(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", "auto"}, + {"zh-CN", "zh"}, + {"zh", "zh"}, + {"en-US", "en"}, + {"en", "en"}, + {"ja", "auto"}, + } + for _, tt := range tests { + got := mapLanguage(tt.input) + if got != tt.want { + t.Errorf("mapLanguage(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestIsWAV(t *testing.T) { + if !isWAV([]byte("RIFF....")) { + t.Error("expected true for RIFF header") + } + if isWAV([]byte("ID3...")) { + t.Error("expected false for ID3 header") + } + if isWAV([]byte{0x00}) { + t.Error("expected false for short data") + } +} + +func TestIsMP3(t *testing.T) { + if !isMP3([]byte("ID3\x03")) { + t.Error("expected true for ID3 header") + } + if !isMP3([]byte{0xFF, 0xFB, 0x00}) { + t.Error("expected true for MP3 sync word") + } + if isMP3([]byte("RIFF")) { + t.Error("expected false for RIFF header") + } +} + +func makeValidWAV(pcm []byte) []byte { + // 构造一个最小有效 WAV + wav := make([]byte, 44+len(pcm)) + copy(wav[:4], "RIFF") + // little-endian size = 36 + len(pcm) + size := uint32(36 + len(pcm)) + wav[4] = byte(size) + wav[5] = byte(size >> 8) + wav[6] = byte(size >> 16) + wav[7] = byte(size >> 24) + copy(wav[8:12], "WAVE") + copy(wav[12:16], "fmt ") + // fmt chunk size = 16 + wav[16] = 16 + // PCM format = 1 + wav[20] = 1 + // channels = 1 + wav[22] = 1 + // sample rate = 16000 + wav[24] = 0x80 + wav[25] = 0x3E + // byte rate = 32000 + wav[28] = 0x00 + wav[29] = 0x7D + // block align = 2 + wav[32] = 2 + // bits per sample = 16 + wav[34] = 16 + copy(wav[36:40], "data") + dSize := uint32(len(pcm)) + wav[40] = byte(dSize) + wav[41] = byte(dSize >> 8) + wav[42] = byte(dSize >> 16) + wav[43] = byte(dSize >> 24) + copy(wav[44:], pcm) + return wav +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} -- 2.49.1 From e3eb4f264b7dcb976096ac1e6488238cf2bfafce Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sat, 13 Jun 2026 21:43:43 +0800 Subject: [PATCH 03/40] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20STT=20API?= =?UTF-8?q?=20Key=20=E5=92=8C=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env | 2 +- backend/config.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/.env b/backend/.env index 48b24a0..d3705dc 100644 --- a/backend/.env +++ b/backend/.env @@ -1,6 +1,6 @@ # ---- AI 服务 API Key ---- CAMTALK_AI_LLM_API_KEY=sk-ws-H.REHELLY.C4s3.MEUCIQCRee37XWEKp2szaxVLFDtR1rxNNsf372zMvCR0Xl6UvQIgZgvhRTvaa1FmhbCQJgaHu4Jny29AQkn01-3hX9CWBOg -CAMTALK_AI_STT_API_KEY=tp-c9e7scwfx94qvqyhpnahnw8uaiya01za2qzvg4xe24rp3xiv +CAMTALK_AI_STT_API_KEY=sk-c3jhv58rr5djhxw398w2rrij5tfpnpdgxqq1bojagshzviah CAMTALK_AI_TTS_API_KEY=tp-c9e7scwfx94qvqyhpnahnw8uaiya01za2qzvg4xe24rp3xiv # ---- 可选覆盖(默认值见 config.yaml)---- diff --git a/backend/config.yaml b/backend/config.yaml index 2e9ae04..f6040cf 100644 --- a/backend/config.yaml +++ b/backend/config.yaml @@ -17,7 +17,7 @@ ai: stt: provider: mimo model: mimo-v2.5-asr - endpoint: "https://token-plan-cn.xiaomimimo.com/v1" + endpoint: "https://api.xiaomimimo.com/v1/chat/completions" llm: provider: dashscope model: qwen3-vl-plus -- 2.49.1 From 420871cd7e18272b05b0f311530b5e1e80ac0e0e Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sat, 13 Jun 2026 21:51:26 +0800 Subject: [PATCH 04/40] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20MiMo=20ASR=20?= =?UTF-8?q?endpoint=20=E8=B7=AF=E5=BE=84=E9=87=8D=E5=A4=8D=E6=8B=BC?= =?UTF-8?q?=E6=8E=A5=E5=AF=BC=E8=87=B4=20404=20=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config.yaml 中 endpoint 已包含 /chat/completions,而 mimo.go 会自动拼接该路径, 导致实际请求地址变为 .../chat/completions/chat/completions。 - config.yaml: endpoint 改为 base URL https://api.xiaomimimo.com/v1 - .env.example: STT endpoint 示例更新为 MiMo 地址 --- .env.example | 2 +- backend/config.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 3be820f..21ade5f 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ CAMTALK_AI_TTS_API_KEY= # CAMTALK_AI_LLM_MODEL=gpt-4o # CAMTALK_AI_LLM_ENDPOINT=https://api.openai.com/v1 # CAMTALK_AI_LLM_TIMEOUT=10 -# CAMTALK_AI_STT_ENDPOINT=wss://api.deepgram.com/v1/listen +# CAMTALK_AI_STT_ENDPOINT=https://api.xiaomimimo.com/v1 # CAMTALK_AI_TTS_ENDPOINT=https://api.openai.com/v1 # CAMTALK_AI_TTS_VOICE=alloy # CAMTALK_AI_TTS_SPEED=1.0 diff --git a/backend/config.yaml b/backend/config.yaml index f6040cf..cd6dc66 100644 --- a/backend/config.yaml +++ b/backend/config.yaml @@ -17,7 +17,7 @@ ai: stt: provider: mimo model: mimo-v2.5-asr - endpoint: "https://api.xiaomimimo.com/v1/chat/completions" + endpoint: "https://api.xiaomimimo.com/v1" llm: provider: dashscope model: qwen3-vl-plus -- 2.49.1 From 322061c387cbb0b3281af5255e9e1cce8a32a565 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sat, 13 Jun 2026 21:57:56 +0800 Subject: [PATCH 05/40] =?UTF-8?q?fix:=20MiMo=20ASR=20=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E5=A4=B4=E6=94=B9=E4=B8=BA=20Authorization:=20Bearer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MiMo API 使用标准 Bearer Token 认证,而非自定义 api-key 头。 --- backend/internal/ai/stt/mimo.go | 2 +- backend/internal/ai/stt/mimo_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/ai/stt/mimo.go b/backend/internal/ai/stt/mimo.go index 49d96fd..887b285 100644 --- a/backend/internal/ai/stt/mimo.go +++ b/backend/internal/ai/stt/mimo.go @@ -134,7 +134,7 @@ func (m *MiMoService) Recognize(ctx context.Context, audio []byte, opts Options) return "", fmt.Errorf("stt: create request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("api-key", m.apiKey) + req.Header.Set("Authorization", "Bearer "+m.apiKey) resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/backend/internal/ai/stt/mimo_test.go b/backend/internal/ai/stt/mimo_test.go index 80c4cf1..435a926 100644 --- a/backend/internal/ai/stt/mimo_test.go +++ b/backend/internal/ai/stt/mimo_test.go @@ -21,8 +21,8 @@ func newTestMiMoService(handler http.HandlerFunc) (*MiMoService, *httptest.Serve func TestMiMoService_Recognize_Success(t *testing.T) { s, srv := newTestMiMoService(func(w http.ResponseWriter, r *http.Request) { // 验证请求 - if r.Header.Get("api-key") != "test-key" { - t.Errorf("expected api-key test-key, got %s", r.Header.Get("api-key")) + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Errorf("expected Authorization Bearer test-key, got %s", r.Header.Get("Authorization")) } if r.URL.Path != "/chat/completions" { t.Errorf("expected path /chat/completions, got %s", r.URL.Path) -- 2.49.1 From a7d679d04fc3f9cde35220152190db1427c52551 Mon Sep 17 00:00:00 2001 From: cfy666 <3087823110@qq.com> Date: Sat, 13 Jun 2026 22:20:31 +0800 Subject: [PATCH 06/40] =?UTF-8?q?feat:=20=E5=8A=9F=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config.yaml | 3 ++ frontend/.gitignore | 4 ++ .../src/components/EdgeProcessor/index.tsx | 2 + frontend/vite.config.ts | 51 +++++++++++++++++-- 功能创意.md | 5 ++ 5 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 功能创意.md diff --git a/backend/config.yaml b/backend/config.yaml index cd6dc66..272e25b 100644 --- a/backend/config.yaml +++ b/backend/config.yaml @@ -18,10 +18,12 @@ ai: provider: mimo model: mimo-v2.5-asr endpoint: "https://api.xiaomimimo.com/v1" + api_key: "sk-c3jhv58rr5djhxw398w2rrij5tfpnpdgxqq1bojagshzviah" llm: provider: dashscope model: qwen3-vl-plus endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1" + api_key: "sk-ws-H.REHELLY.C4s3.MEUCIQCRee37XWEKp2szaxVLFDtR1rxNNsf372zMvCR0Xl6UvQIgZgvhRTvaa1FmhbCQJgaHu4Jny29AQkn01-3hX9CWBOg" timeout: 30 tts: provider: Xiaomi MiMo @@ -29,6 +31,7 @@ ai: voice: alloy speed: 1.0 endpoint: "https://token-plan-cn.xiaomimimo.com/v1" + api_key: "tp-c9e7scwfx94qvqyhpnahnw8uaiya01za2qzvg4xe24rp3xiv" timeout: 5 storage: diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..f3c1586 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,10 @@ dist dist-ssr *.local +# VAD 静态资源(从 node_modules 自动复制) +public/silero_vad_*.onnx +public/vad.worklet.bundle.min.js + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/src/components/EdgeProcessor/index.tsx b/frontend/src/components/EdgeProcessor/index.tsx index a7c83a6..a95abc2 100644 --- a/frontend/src/components/EdgeProcessor/index.tsx +++ b/frontend/src/components/EdgeProcessor/index.tsx @@ -48,6 +48,8 @@ export function useVAD(options?: VADOptions) { getStream: () => Promise.resolve(stream), startOnLoad: true, model: "legacy", + // 指向 node_modules 中的 WASM 文件(由 Vite 中间件提供) + onnxWASMBasePath: "/node_modules/onnxruntime-web/dist/", onSpeechStart: () => { setIsSpeaking(true); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8b0f57b..d3deedc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,52 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import fs from 'fs' +import path from 'path' + +const ortDistDir = path.resolve(__dirname, 'node_modules/onnxruntime-web/dist') +const vadDistDir = path.resolve(__dirname, 'node_modules/@ricky0123/vad-web/dist') +const publicDir = path.resolve(__dirname, 'public') + +// 需要复制到 public 的静态资源 +const staticFiles = [ + { src: vadDistDir, name: 'silero_vad_legacy.onnx' }, + { src: vadDistDir, name: 'silero_vad_v5.onnx' }, + { src: vadDistDir, name: 'vad.worklet.bundle.min.js' }, +] -// https://vite.dev/config/ export default defineConfig({ - plugins: [react()], -}) + plugins: [ + react(), + { + name: 'serve-vad-assets', + // 1. 启动时将 VAD 模型/工作线程复制到 public + configResolved() { + for (const { src, name } of staticFiles) { + const dest = path.join(publicDir, name) + if (!fs.existsSync(dest)) { + fs.copyFileSync(path.join(src, name), dest) + } + } + }, + // 2. 中间件拦截所有 WASM 相关请求,从原始路径提供文件 + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = req.url?.split('?')[0] ?? '' + // 匹配任意路径下的 ort-wasm 文件(包括 .vite/deps/ 和根路径) + const wasmMatch = url.match(/\/(ort-wasm-simd-threaded\.(mjs|wasm))$/) + if (wasmMatch) { + const fileName = wasmMatch[1] + const filePath = path.join(ortDistDir, fileName) + if (fs.existsSync(filePath)) { + res.setHeader('Content-Type', fileName.endsWith('.wasm') ? 'application/wasm' : 'application/javascript') + res.setHeader('Cache-Control', 'no-cache') + res.end(fs.readFileSync(filePath)) + return + } + } + next() + }) + }, + }, + ], +}) \ No newline at end of file diff --git a/功能创意.md b/功能创意.md new file mode 100644 index 0000000..5cf4036 --- /dev/null +++ b/功能创意.md @@ -0,0 +1,5 @@ +1.视频录制 +2.对话翻译 +3.对话总结 +4.手动对话功能 +5.视频框大小可调整,可最小化然后拖动 \ No newline at end of file -- 2.49.1 From 6e0c67e1cbaf43ec5a5d58fdec287ae50508e18c Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 08:52:36 +0800 Subject: [PATCH 07/40] =?UTF-8?q?docs:=20=E6=96=87=E6=A1=A3=E4=B8=8E?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=B8=80=E8=87=B4=E6=80=A7=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E4=B8=8E=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复心跳 Bug:应用层 ping 不更新 lastPong,60 秒后连接会被错误断开 - 03-接口文档:audio/mpeg→audio/mp3、STTConfig/TTSConfig 补充 Model 字段、 APP_ENV 环境变量名修正、配置搜索路径补充、.env 加载说明、Vite proxy 说明修正 - 02-系统架构:补充 ConfigPanel/Toast 组件、Model Router/Rate Limiter 标注规划中、 补充 Gin 框架、MVP 存储改为 Memory、AI 服务 provider 更新、Orchestrator 伪代码对齐 - 04-技术选型:新增 AI 服务栈选型章节(STT/LLM/TTS)、PostgreSQL 标注规划中 - 06-语音交互:VAD 参数名修正、STT 改为一次性识别描述、音频编码格式补充 - 07-视觉理解:关键帧检测代码改为 TypeScript、分辨率修正、阈值逻辑统一 - 08-成本控制:变量名修正、未实现功能标注规划中、对话历史裁剪策略补充 - CLAUDE.md:同步更新技术栈、模块结构、存储策略描述 --- CLAUDE.md | 36 ++++++----- backend/internal/ws/handler.go | 1 + docs/02-系统架构.md | 111 ++++++++++++--------------------- docs/03-接口文档.md | 43 ++++++------- docs/04-技术选型.md | 46 ++++++++++++-- docs/06-语音交互.md | 37 +++++------ docs/07-视觉理解.md | 28 ++++++--- docs/08-成本控制.md | 39 ++++-------- 8 files changed, 171 insertions(+), 170 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f66681a..8a8a26a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,24 +12,23 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头 三层系统: -1. **浏览器客户端**(React 18 + TypeScript, Vite)—— 媒体采集、边缘预处理(VAD 通过 `@ricky0123/vad-web`、关键帧检测通过 ONNX Runtime Web)、UI 渲染。核心 Hook:`useVisionSession()` -2. **Go 网关**(gorilla/websocket, Redis, Viper, Zap)—— WebSocket 服务器、会话管理、模型路由、AI 编排、速率限制。每个 WebSocket 连接一个 goroutine。 -3. **云端 AI 服务** —— GPT-4o(LLM)、Deepgram(STT)、OpenAI TTS。仅通过 Go 网关访问,浏览器不直连。 +1. **浏览器客户端**(React 18 + TypeScript, Vite)—— 媒体采集、边缘预处理(VAD 通过 `@ricky0123/vad-web`、关键帧检测通过 Canvas 像素比较)、UI 渲染。核心 Hook:`useVisionSession()` +2. **Go 网关**(Gin, gorilla/websocket, Viper, Zap)—— WebSocket 服务器、会话管理、AI 编排。每个 WebSocket 连接一个 goroutine。 +3. **云端 AI 服务** —— 通过 OpenAI 兼容接口可灵活切换。默认:GPT-4o(LLM)、Deepgram(STT)、OpenAI TTS。仅通过 Go 网关访问,浏览器不直连。 **关键模式**:LLM 文本流和 TTS 音频流并行推送给客户端,以最小化感知延迟。 -**存储**:冷热分离 —— Redis 存实时会话状态,PostgreSQL 存对话历史和用量统计(MVP 后引入)。Repository 接口模式(`HistoryRepository`、`UsageRepository`),MVP 用内存实现。 +**存储**:MVP 阶段使用进程内存(`MemoryManager`),Redis 实现已就绪可通过配置切换,PostgreSQL 为规划中。Repository 接口模式(`HistoryRepository`、`UsageRepository`),MVP 用内存实现。 ## 技术栈 | 层级 | 技术 | |------|------| -| 前端 | React 18, TypeScript, Vite, ONNX Runtime Web, @ricky0123/vad-web | -| 后端 | Go, gorilla/websocket, Redis, Viper, Zap | -| LLM | GPT-4o(主), Claude Sonnet(备) | -| STT | Deepgram(主), FunASR(自部署备选) | -| TTS | OpenAI TTS(主), Edge TTS(免费替代) | -| 模型路由 | GPT-4o-mini 用于轻量分类 | +| 前端 | React 18, TypeScript, Vite, @ricky0123/vad-web | +| 后端 | Go, Gin, gorilla/websocket, Viper, Zap | +| LLM | GPT-4o(默认,通过 OpenAI 兼容接口可切换) | +| STT | Deepgram(默认) / MiMo ASR | +| TTS | OpenAI TTS(默认) / MiMo TTS | ## 构建与运行命令 @@ -50,7 +49,7 @@ go test -run TestName ./path # 运行单个测试 go vet ./... # 静态分析 ``` -基础设施:Redis 为会话状态必需。PostgreSQL 为 MVP 可选(内存回退)。 +基础设施:MVP 使用进程内存管理会话状态。Redis 已实现可通过配置切换,PostgreSQL 为规划中。 ## WebSocket 协议 @@ -80,7 +79,7 @@ go vet ./... # 静态分析 |------|------| | `CameraManager` | 摄像头流采集 | | `MicManager` | 麦克风音频采集 | -| `EdgeProcessor` | VAD + 关键帧检测(ONNX Runtime) | +| `EdgeProcessor` | VAD + 关键帧检测(Canvas 像素比较) | | `WebSocketManager` | WebSocket 连接生命周期管理 | | `ChatPanel` | 消息展示 | | `VideoPreview` | 摄像头画面预览 | @@ -89,11 +88,14 @@ go vet ./... # 静态分析 | 模块 | 职责 | |------|------| -| WebSocket Hub | 连接管理、广播/定向推送 | -| Session Manager | 会话状态、对话历史(Redis + TTL) | -| Model Router | 按请求选择 AI 模型(规则引擎 + 成本阈值) | -| AI Orchestrator | 并行/串行 AI 调用编排,context 超时控制 | -| Rate Limiter | 按用户的令牌桶速率限制 | +| WebSocket Handler | 连接管理、单播消息推送 | +| Session Manager | 会话状态、对话历史(Memory/Redis,30 分钟 TTL) | +| AI Orchestrator | STT→LLM→TTS 流式并行管道编排 | +| AI Service Layer | AI 服务抽象层(STT/LLM/TTS 多 provider) | +| REST API | 健康检查、会话管理(Gin 路由) | +| Models | 数据模型定义 | +| Model Router | 按请求选择 AI 模型(规划中) | +| Rate Limiter | 按用户的令牌桶速率限制(规划中) | ## 编码规范 diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go index eb84af9..4e17bc8 100644 --- a/backend/internal/ws/handler.go +++ b/backend/internal/ws/handler.go @@ -159,6 +159,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche switch envelope.Type { case "ping": + lastPong = time.Now() // 刷新心跳计时器 _ = client.SendJSON(models.WsPong{Type: "pong"}) case "query": diff --git a/docs/02-系统架构.md b/docs/02-系统架构.md index 41f2a33..e4de9d1 100644 --- a/docs/02-系统架构.md +++ b/docs/02-系统架构.md @@ -9,7 +9,7 @@ | 层级 | 职责 | 关键约束 | |------|------|---------| | **客户端(浏览器)** | 媒体采集、边缘预处理、UI 渲染 | 浏览器资源有限,模型需轻量 | -| **Go 网关** | 会话管理、模型路由、AI 服务编排 | 高并发、低延迟、状态管理 | +| **Go 网关** | 会话管理、AI 服务编排、流式管道 | 高并发、低延迟、状态管理 | | **AI 服务** | LLM 推理、语音识别、语音合成 | 按量计费,需控制调用频率 | > 为什么要单独加一层 Go 网关,而不是让前端直连 AI API?1)API Key 安全性;2)统一的速率限制和成本管控;3)多模型路由逻辑集中在一处便于维护。 @@ -22,7 +22,7 @@ |------|------|---------| | 框架 | React 18 + TypeScript | 组件化开发,类型安全,生态成熟 | | 构建 | Vite | 开发热更新快,构建产物小 | -| 实时通信 | WebSocket(原生 API) | 浏览器原生支持,无需额外依赖 | +| 实时通信 | WebSocket(原生 API) + 自封装连接管理 | 浏览器原生支持,封装心跳/重连/消息分发 | | 边缘推理 | ONNX Runtime Web | 浏览器端跑轻量模型(VAD、关键帧检测) | | 语音检测 | @ricky0123/vad-web | 基于 WebRTC VAD,纯前端零延迟 | | 媒体采集 | MediaDevices API | 浏览器原生摄像头/麦克风访问 | @@ -32,22 +32,22 @@ | 技术 | 选型 | 选择理由 | |------|------|---------| | 语言 | Go | 高并发 goroutine 模型,适合长连接管理 | +| HTTP 框架 | Gin | 高性能 HTTP 路由,中间件生态成熟 | | WebSocket | gorilla/websocket | Go 生态最成熟的 WebSocket 库 | -| 会话存储 | Redis | 高速 KV 存储,适合会话状态和上下文缓存 | -| 持久化存储 | PostgreSQL | 对话历史、用量统计、用户偏好(MVP 阶段可选) | -| 配置管理 | Viper | 支持 YAML + 环境变量覆盖,详见 `03-接口文档.md` 第六章 | +| 会话存储 | Redis(规划中) / Memory(MVP 默认) | 高速 KV 存储,MVP 阶段使用进程内存,可通过配置切换到 Redis | +| 持久化存储 | PostgreSQL(规划中) | 对话历史、用量统计、用户偏好(MVP 阶段未实现) | +| 配置管理 | Viper + godotenv | 支持 YAML + .env + 环境变量覆盖,详见 `03-接口文档.md` 第六章 | | 日志 | Zap | 高性能结构化日志 | ### AI 服务 | 能力 | 主选方案 | 备选方案 | 选型考量 | |------|---------|---------|---------| -| 多模态 LLM | GPT-4o | Claude Sonnet | 视觉理解能力强,API 成熟 | -| 语音识别 STT | Deepgram | FunASR 自部署 | 流式识别延迟低(<500ms) | -| 语音合成 TTS | OpenAI TTS | Edge TTS(免费) | 音质自然,支持流式 | -| 轻量分类 | GPT-4o-mini | Haiku | 模型路由时的复杂度判断 | +| 多模态 LLM | GPT-4o(默认) | 通义千问等 OpenAI 兼容模型 | 通过 OpenAI 兼容接口,可灵活切换 | +| 语音识别 STT | Deepgram(默认) | MiMo ASR(小米) | 支持多 provider 切换 | +| 语音合成 TTS | OpenAI TTS(默认) | MiMo TTS(小米) | 支持多 provider 切换 | -> 不必绑定单一厂商。Go 网关的模型路由层统一封装不同 AI 服务的调用接口,按场景动态切换。 +> 不必绑定单一厂商。Go 网关的 AI 服务层统一封装不同服务商的调用接口,通过配置切换 provider。 ## 核心交互流程 @@ -78,52 +78,35 @@ Browser Go Gateway STT LLM TTS | 模块 | 职责 | 关键实现 | |------|------|---------| -| WebSocket Hub | 管理所有客户端连接,广播/定向推送 | goroutine per connection | -| Session Manager | 维护用户会话状态、对话历史 | Redis Hash + List,30 分钟 TTL(详见 `03-接口文档.md` 第五章) | -| Model Router | 根据请求类型选择 AI 模型 | 规则引擎 + 成本阈值 | -| AI Orchestrator | 编排多路 AI 调用(并行/串行) | context 取消 + 超时控制 | -| Rate Limiter | 防止单用户过度消耗 API 额度 | 令牌桶算法 | +| WebSocket Handler | 管理客户端连接生命周期,单播消息推送 | goroutine per connection | +| Session Manager | 维护用户会话状态、对话历史 | Memory(MVP 默认)/ Redis(可切换),30 分钟 TTL(详见 `03-接口文档.md` 第五章) | +| AI Orchestrator | 编排 STT→LLM→TTS 流式并行管道 | context 取消 + 超时控制 + 句子切分 | +| AI Service Layer | AI 服务抽象层(STT/LLM/TTS) | 多 provider 支持(Deepgram/MiMo/OpenAI 等) | +| REST API | 健康检查、会话管理端点 | Gin 路由 | +| Error Handler | 统一错误码定义与发送 | 错误码枚举 | +| Logger | 日志初始化封装 | Zap 结构化日志 | +| Models | 数据模型定义 | WebSocket 消息、会话、配置等 | +| Model Router | 根据请求类型选择 AI 模型(规划中) | 规则引擎 + 成本阈值 | +| Rate Limiter | 防止单用户过度消耗 API 额度(规划中) | 令牌桶算法 | -AI Orchestrator 核心代码(句子级流式并行): +AI Orchestrator 核心接口(`internal/orchestrator/orchestrator.go`): ```go -func (o *Orchestrator) ProcessQuery(ctx context.Context, client MessageSender, req *QueryRequest) { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - // Step 1: STT — 识别用户语音(串行) - text, err := o.stt.Recognize(ctx, req.Audio, STTOptions{...}) - if err != nil { - client.SendError(req.RequestID, "STT_ERROR", err.Error()) - return - } - client.SendSTTResult(req.RequestID, text, true) - - // Step 2: LLM 流式输出 + 句子切分(并行) - llmStream, _ := o.llm.ChatStream(ctx, LLMRequest{Image: req.Image, Text: text, ...}) - sentenceCh := make(chan string, 4) - go func() { - defer close(sentenceCh) - var buf strings.Builder - for chunk := range llmStream { - client.SendLLMChunk(req.RequestID, chunk.Delta) // 逐 token 推送文字 - buf.WriteString(chunk.Delta) - if isSentenceEnd(chunk.Delta) { // 按 。!?\n 切分 - sentenceCh <- buf.String() - buf.Reset() - } - } - if buf.Len() > 0 { sentenceCh <- buf.String() } - }() - - // Step 3: TTS 并行消费句子流 - ttsStream, _ := o.tts.SynthesizeStream(ctx, sentenceCh, TTSOptions{...}) - for chunk := range ttsStream { - client.SendTTSAudio(req.RequestID, chunk.Audio, chunk.IsLast) - } +// Orchestrator AI 编排器接口。 +type Orchestrator interface { + ProcessQuery(ctx context.Context, sessionID string, req models.WsQuery, + history []models.Message, sender Sender) error } ``` +Pipeline 实现(`internal/orchestrator/pipeline.go`)流程: +1. Base64 解码音频/图片 +2. 调用 `stt.Recognize()` → 发送 `stt_result` +3. 调用 `llm.ChatStream()` 获取流式输出,goroutine 消费 token → 发送 `llm_chunk` + 句子切分 +4. 另一 goroutine 从句子 channel 读取 → 调用 `tts.SynthesizeStream()` → 发送 `tts_audio` +5. 流结束 → 发送 `llm_done` +6. TTS 失败静默跳过,STT/LLM 失败发送对应 error 消息 + > **关键优化**:LLM 文本流和 TTS 音频流**并行推送**——客户端先逐 token 展示文字,同时 TTS 逐句子合成并推送音频,用户感知延迟大幅降低。详细的 AI 服务层接口和编排策略见 `03-接口文档.md` 第三、四章。 ## 前端组件 @@ -132,10 +115,12 @@ func (o *Orchestrator) ProcessQuery(ctx context.Context, client MessageSender, r |------|------| | CameraManager | 摄像头流采集 | | MicManager | 麦克风音频采集 | -| EdgeProcessor | VAD + 关键帧检测(ONNX Runtime) | +| EdgeProcessor | VAD + 关键帧检测(Canvas 像素比较) | | WebSocketManager | WS 连接生命周期管理 | | ChatPanel | 消息展示 | | VideoPreview | 摄像头画面预览 | +| ConfigPanel | 右侧抽屉式配置面板(主题、TTS 开关、detail level、语言) | +| Toast | 轻量通知提示(3 秒自动消失) | 核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态)。 @@ -173,7 +158,7 @@ function useVisionSession() { | 阶段 | 存储方案 | 持久化内容 | 理由 | |------|---------|-----------|------| -| MVP | Redis only | 无 | 快速验证核心功能,重启丢数据可接受 | +| MVP | Memory(进程内) | 无 | 快速验证核心功能,重启丢数据可接受。Redis 实现已就绪,可通过 `storage.driver` 配置切换 | | 上线 | Redis + PostgreSQL | 对话历史、用户偏好、用量统计 | 用户需要查看历史,运营需要成本数据 | | 规模化 | Redis + PG + 对象存储 | 图像帧、音频片段归档 | 大文件不适合存关系库 | @@ -262,24 +247,8 @@ server { > WebSocket 是长连接,Nginx 必须配置 `Upgrade` 和 `Connection` 头。`proxy_read_timeout` 需要覆盖心跳间隔(客户端 30s ping),否则 Nginx 会主动断开空闲连接。 -### 开发环境(Vite proxy) +### 开发环境 -开发时前端(Vite :5173)和后端(Gin :8080)不同端口,用 Vite 内置代理解决跨域: +开发时前端(Vite :5173)和后端(Gin :8080)不同端口。当前实现中前端 WebSocket 地址硬编码为 `ws://localhost:8080/ws`,直连后端,不经过 Vite 代理。 -```typescript -// frontend/vite.config.ts -export default defineConfig({ - plugins: [react()], - server: { - proxy: { - "/api": "http://localhost:8080", - "/ws": { - target: "ws://localhost:8080", - ws: true, - }, - }, - }, -}); -``` - -前端代码中 WebSocket 地址改为相对路径 `ws://localhost:5173/ws`,Vite 自动代理到后端。部署时 Nginx 同理,前端无需区分开发/生产地址。 +> 如需使用 Vite 代理解决跨域,可在 `vite.config.ts` 中添加 `server.proxy` 配置,并将前端 WebSocket 地址改为相对路径。 diff --git a/docs/03-接口文档.md b/docs/03-接口文档.md index f1b7e1c..152ac89 100644 --- a/docs/03-接口文档.md +++ b/docs/03-接口文档.md @@ -73,7 +73,7 @@ interface ConfigMessage { ```typescript interface InterruptMessage { type: "interrupt"; - request_id?: string; // 可选,指定打断哪次请求 + request_id?: string; // 可选,当前实现不使用此字段,服务端始终取消当前活跃请求 } ``` @@ -143,7 +143,7 @@ interface TTSAudioMessage { type: "tts_audio"; request_id: string; audio: string; // Base64 编码的音频片段 - mime_type: string; // "audio/mpeg" + mime_type: string; // "audio/mp3" is_last: boolean; // 是否为最后一片 } ``` @@ -152,7 +152,7 @@ interface TTSAudioMessage { | 属性 | 值 | 说明 | |------|------|------| -| 编码 | `audio/mpeg`(MP3) | 浏览器 `