diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3be820f --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# CamTalk 环境变量模板 +# 复制为 .env 并填入实际值:cp .env.example .env +# .env 已在 .gitignore 中,不会提交到版本控制 + +# ---- AI 服务 API Key ---- +CAMTALK_AI_LLM_API_KEY=sk-xxx +CAMTALK_AI_STT_API_KEY= +CAMTALK_AI_TTS_API_KEY= + +# ---- 可选覆盖(默认值见 config.yaml)---- +# 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_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/cmd/server/main.go b/backend/cmd/server/main.go index 9b508f2..134b791 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -46,12 +46,12 @@ func main() { defer sessionMgr.(*session.MemoryManager).Stop() // 初始化 AI 服务 - sttService := stt.NewDeepgramService(cfg.AI.STT.APIKey, cfg.AI.STT.Endpoint, logger.Log) + 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.Voice, cfg.AI.TTS.Endpoint, cfg.AI.TTS.Speed, cfg.AI.TTS.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) // 初始化 Orchestrator - orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr) + orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr, cfg.AI.LLM.Model) // Gin 模式 if cfg.App.Env == "prod" { diff --git a/backend/config.yaml b/backend/config.yaml index ab4607f..988f336 100644 --- a/backend/config.yaml +++ b/backend/config.yaml @@ -15,18 +15,20 @@ redis: ai: stt: - provider: deepgram - endpoint: "wss://api.deepgram.com/v1/listen" + provider: Xiaomi MiMo + model: mimo-v2.5 + endpoint: "https://token-plan-cn.xiaomimimo.com/v1" llm: - provider: openai - model: gpt-4o - endpoint: "https://api.openai.com/v1" - timeout: 10 + provider: dashscope + model: qwen3-vl-plus + endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1" + timeout: 30 tts: - provider: openai + provider: Xiaomi MiMo + model: mimo-v2.5 voice: alloy speed: 1.0 - endpoint: "https://api.openai.com/v1" + endpoint: "https://token-plan-cn.xiaomimimo.com/v1" timeout: 5 storage: diff --git a/backend/go.mod b/backend/go.mod index 8e60b15..31577ab 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -27,6 +27,7 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 01eeeb8..17b84e6 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -44,6 +44,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= diff --git a/backend/internal/ai/stt/deepgram.go b/backend/internal/ai/stt/deepgram.go index 529a2e7..54da674 100644 --- a/backend/internal/ai/stt/deepgram.go +++ b/backend/internal/ai/stt/deepgram.go @@ -16,17 +16,22 @@ import ( // DeepgramService 基于 Deepgram WebSocket API 的语音识别实现。 type DeepgramService struct { apiKey string + model string endpoint string logger *zap.SugaredLogger } // NewDeepgramService 创建 Deepgram STT 服务。 -func NewDeepgramService(apiKey, endpoint string, logger *zap.SugaredLogger) *DeepgramService { +func NewDeepgramService(apiKey, model, endpoint string, logger *zap.SugaredLogger) *DeepgramService { + if model == "" { + model = "nova-2" + } if endpoint == "" { endpoint = "wss://api.deepgram.com/v1/listen" } return &DeepgramService{ apiKey: apiKey, + model: model, endpoint: endpoint, logger: logger, } @@ -128,7 +133,7 @@ func (d *DeepgramService) buildURL(opts Options) string { q.Set("encoding", encoding) q.Set("sample_rate", fmt.Sprintf("%d", sampleRate)) q.Set("language", language) - q.Set("model", "nova-2") + q.Set("model", d.model) q.Set("punctuate", "true") u.RawQuery = q.Encode() diff --git a/backend/internal/ai/stt/deepgram_test.go b/backend/internal/ai/stt/deepgram_test.go index 7964db1..2d00e53 100644 --- a/backend/internal/ai/stt/deepgram_test.go +++ b/backend/internal/ai/stt/deepgram_test.go @@ -74,7 +74,7 @@ func TestDeepgramService_Recognize_Success(t *testing.T) { }) defer srv.Close() - svc := NewDeepgramService("test-key", wsToWss(srv.URL)+"/v1/listen", zap.NewNop().Sugar()) + svc := NewDeepgramService("test-key", "", wsToWss(srv.URL)+"/v1/listen", zap.NewNop().Sugar()) text, err := svc.Recognize(context.Background(), []byte("fake-pcm-audio"), Options{ Encoding: "pcm_s16le", @@ -90,7 +90,7 @@ func TestDeepgramService_Recognize_Success(t *testing.T) { } func TestDeepgramService_Recognize_EmptyAudio(t *testing.T) { - svc := NewDeepgramService("test-key", "ws://localhost", zap.NewNop().Sugar()) + svc := NewDeepgramService("test-key", "", "ws://localhost", zap.NewNop().Sugar()) _, err := svc.Recognize(context.Background(), nil, Options{}) if err == nil { t.Fatal("Recognize() with empty audio should return error") @@ -98,7 +98,7 @@ func TestDeepgramService_Recognize_EmptyAudio(t *testing.T) { } func TestDeepgramService_Recognize_ConnectError(t *testing.T) { - svc := NewDeepgramService("test-key", "ws://localhost:1", zap.NewNop().Sugar()) + svc := NewDeepgramService("test-key", "", "ws://localhost:1", zap.NewNop().Sugar()) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -117,7 +117,7 @@ func TestDeepgramService_Recognize_Timeout(t *testing.T) { }) defer srv.Close() - svc := NewDeepgramService("test-key", wsToWss(srv.URL)+"/v1/listen", zap.NewNop().Sugar()) + svc := NewDeepgramService("test-key", "", wsToWss(srv.URL)+"/v1/listen", zap.NewNop().Sugar()) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() @@ -147,7 +147,7 @@ func TestDeepgramService_Recognize_MultipleFinals(t *testing.T) { }) defer srv.Close() - svc := NewDeepgramService("test-key", wsToWss(srv.URL)+"/v1/listen", zap.NewNop().Sugar()) + svc := NewDeepgramService("test-key", "", wsToWss(srv.URL)+"/v1/listen", zap.NewNop().Sugar()) text, err := svc.Recognize(context.Background(), []byte("audio"), Options{}) if err != nil { @@ -159,7 +159,7 @@ func TestDeepgramService_Recognize_MultipleFinals(t *testing.T) { } func TestDeepgramService_buildURL(t *testing.T) { - svc := NewDeepgramService("key", "wss://api.deepgram.com/v1/listen", zap.NewNop().Sugar()) + svc := NewDeepgramService("key", "", "wss://api.deepgram.com/v1/listen", zap.NewNop().Sugar()) tests := []struct { name string diff --git a/backend/internal/ai/tts/openai.go b/backend/internal/ai/tts/openai.go index 24e350f..6e0123e 100644 --- a/backend/internal/ai/tts/openai.go +++ b/backend/internal/ai/tts/openai.go @@ -15,6 +15,7 @@ import ( // OpenAIService 基于 OpenAI TTS API 的语音合成实现。 type OpenAIService struct { apiKey string + model string voice string speed float64 endpoint string @@ -24,7 +25,10 @@ type OpenAIService struct { } // NewOpenAIService 创建 OpenAI TTS 服务。 -func NewOpenAIService(apiKey, voice, endpoint string, speed float64, timeoutSec int, logger *zap.SugaredLogger) *OpenAIService { +func NewOpenAIService(apiKey, model, voice, endpoint string, speed float64, timeoutSec int, logger *zap.SugaredLogger) *OpenAIService { + if model == "" { + model = "tts-1" + } if voice == "" { voice = "alloy" } @@ -40,6 +44,7 @@ func NewOpenAIService(apiKey, voice, endpoint string, speed float64, timeoutSec } return &OpenAIService{ apiKey: apiKey, + model: model, voice: voice, speed: speed, endpoint: endpoint, @@ -109,7 +114,7 @@ func (o *OpenAIService) synthesize(ctx context.Context, text, voice string, spee defer cancel() body := ttsRequest{ - Model: "tts-1", + Model: o.model, Input: text, Voice: voice, ResponseFormat: "mp3", diff --git a/backend/internal/ai/tts/openai_test.go b/backend/internal/ai/tts/openai_test.go index 3f5e609..36afd31 100644 --- a/backend/internal/ai/tts/openai_test.go +++ b/backend/internal/ai/tts/openai_test.go @@ -58,7 +58,7 @@ func TestOpenAIService_SynthesizeStream_Success(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) textStream := sendSentences("你好", "世界", "!") @@ -110,7 +110,7 @@ func TestOpenAIService_SynthesizeStream_APIError(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) textStream := sendSentences("你好") @@ -142,7 +142,7 @@ func TestOpenAIService_SynthesizeStream_Timeout(t *testing.T) { defer srv.Close() // 1 秒超时 - svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 1, zap.NewNop().Sugar()) + svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 1, zap.NewNop().Sugar()) textStream := sendSentences("很长的句子") @@ -177,7 +177,7 @@ func TestOpenAIService_SynthesizeStream_EmptyText(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) // 空句子应该被跳过 textStream := sendSentences("", "你好", "") @@ -210,7 +210,7 @@ func TestOpenAIService_SynthesizeStream_ContextCancelled(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) // 发送多个句子,但在第一个后取消 textStream := make(chan string, 3) @@ -252,7 +252,7 @@ func TestOpenAIService_SynthesizeStream_PartialFailure(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) textStream := sendSentences("第一句", "第二句", "第三句") @@ -286,7 +286,7 @@ func TestOpenAIService_SynthesizeStream_CustomVoice(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("test-key", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) + svc := NewOpenAIService("test-key", "", "alloy", srv.URL, 1.0, 5, zap.NewNop().Sugar()) textStream := sendSentences("你好") diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d8c252c..1a5cac8 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "strings" + "github.com/joho/godotenv" "github.com/spf13/viper" ) @@ -50,6 +51,7 @@ type AIConfig struct { type STTConfig struct { Provider string `mapstructure:"provider"` APIKey string `mapstructure:"api_key"` + Model string `mapstructure:"model"` Endpoint string `mapstructure:"endpoint"` } @@ -64,6 +66,7 @@ type LLMConfig struct { type TTSConfig struct { Provider string `mapstructure:"provider"` APIKey string `mapstructure:"api_key"` + Model string `mapstructure:"model"` Voice string `mapstructure:"voice"` Speed float64 `mapstructure:"speed"` Endpoint string `mapstructure:"endpoint"` @@ -98,12 +101,14 @@ func Load() (*Config, error) { v.SetDefault("redis.addr", "localhost:6379") v.SetDefault("redis.db", 0) v.SetDefault("ai.stt.provider", "deepgram") + v.SetDefault("ai.stt.model", "nova-2") v.SetDefault("ai.stt.endpoint", "wss://api.deepgram.com/v1/listen") v.SetDefault("ai.llm.provider", "openai") v.SetDefault("ai.llm.model", "gpt-4o") v.SetDefault("ai.llm.endpoint", "https://api.openai.com/v1") v.SetDefault("ai.llm.timeout", 10) v.SetDefault("ai.tts.provider", "openai") + v.SetDefault("ai.tts.model", "tts-1") v.SetDefault("ai.tts.voice", "alloy") v.SetDefault("ai.tts.speed", 1.0) v.SetDefault("ai.tts.endpoint", "https://api.openai.com/v1") @@ -125,6 +130,11 @@ func Load() (*Config, error) { _ = v.MergeInConfig() } + // 加载 .env 文件(不覆盖已有环境变量) + // 按优先级尝试:当前目录、上级目录(兼容从 backend/ 或项目根目录启动) + _ = godotenv.Load() + _ = godotenv.Load("../.env") + // 环境变量覆盖 v.SetEnvPrefix("CAMTALK") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) diff --git a/backend/internal/orchestrator/pipeline.go b/backend/internal/orchestrator/pipeline.go index 9285e51..0186108 100644 --- a/backend/internal/orchestrator/pipeline.go +++ b/backend/internal/orchestrator/pipeline.go @@ -22,6 +22,7 @@ type Pipeline struct { llmService llm.Service ttsService tts.Service sessionMgr session.Manager + model string // LLM 模型名,用于 llm_done 上报 } // New 创建 Pipeline 实例。 @@ -30,12 +31,14 @@ func New( llmService llm.Service, ttsService tts.Service, sessionMgr session.Manager, + model string, ) *Pipeline { return &Pipeline{ sttService: sttService, llmService: llmService, ttsService: ttsService, sessionMgr: sessionMgr, + model: model, } } @@ -206,7 +209,7 @@ func (p *Pipeline) ProcessQuery( Type: "llm_done", RequestID: req.RequestID, FullText: fullText, - Model: "gpt-4o", + Model: p.model, LatencyMs: latency, }); err != nil { log.Errorw("发送 llm_done 失败", "error", err) diff --git a/backend/internal/orchestrator/pipeline_test.go b/backend/internal/orchestrator/pipeline_test.go index 4e493d8..4a3f2a1 100644 --- a/backend/internal/orchestrator/pipeline_test.go +++ b/backend/internal/orchestrator/pipeline_test.go @@ -254,7 +254,7 @@ func TestProcessQuery_Success(t *testing.T) { mockSender.On("SendTTSAudio", mock.Anything).Return(nil) // 创建 Pipeline - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o") // 执行 ctx := context.Background() @@ -305,7 +305,7 @@ func TestProcessQuery_STTError(t *testing.T) { mockSender.On("SendError", mock.Anything).Return(nil) - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o") ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender) @@ -357,7 +357,7 @@ func TestProcessQuery_LLMError(t *testing.T) { mockSender.On("SendError", mock.Anything).Return(nil) - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o") ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender) @@ -415,7 +415,7 @@ func TestProcessQuery_TTSError(t *testing.T) { mockTTS.On("SynthesizeStream", mock.Anything, mock.Anything, mock.Anything). Return(nil, errors.New("TTS service unavailable")) - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o") ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender) @@ -485,7 +485,7 @@ func TestProcessQuery_ContextCancelled(t *testing.T) { }() mockTTS.On("SynthesizeStream", mock.Anything, mock.Anything, mock.Anything).Return((<-chan tts.Chunk)(ttsCh), nil) - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o") // 创建可取消的上下文 ctx, cancel := context.WithCancel(context.Background()) @@ -545,7 +545,7 @@ func TestProcessQuery_DisabledTTS(t *testing.T) { mockSender.On("SendLLMChunk", mock.Anything).Return(nil) mockSender.On("SendLLMDone", mock.Anything).Return(nil) - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o") ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender) @@ -617,7 +617,7 @@ func TestProcessQuery_InvalidAudio(t *testing.T) { mockSender.On("SendError", mock.Anything).Return(nil) - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o") ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender) @@ -650,7 +650,7 @@ func TestProcessQuery_SessionNotFound(t *testing.T) { mockSender.On("SendError", mock.Anything).Return(nil) - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o") ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender)