diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 01a9b8e..829c415 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -22,6 +22,10 @@ import ( "github.com/hhs/camtalk/internal/ws" ) +// Version 通过构建时 -ldflags 注入,如: +// go build -ldflags "-X main.Version=v1.0.0" ./cmd/server +var Version string + var startTime = time.Now() func main() { @@ -43,7 +47,10 @@ func main() { // 初始化 Session Manager(MVP 默认内存实现) var sessionMgr session.Manager // TODO: 当 Redis 配置非空时切换为 RedisManager - sessionMgr = session.NewMemoryManager(30*time.Minute, 20) + sessionMgr = session.NewMemoryManager( + time.Duration(cfg.Session.TTL)*time.Minute, + cfg.Session.MaxHistory, + ) defer sessionMgr.(*session.MemoryManager).Stop() // 初始化 AI 服务 @@ -60,27 +67,27 @@ func main() { 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) + sttService = stt.NewMiMoService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, cfg.AI.STT.Timeout, logger.Log) logger.Log.Infow("STT service initialized", "provider", "mimo", "model", cfg.AI.STT.Model, "endpoint", cfg.AI.STT.Endpoint) default: - sttService = stt.NewDeepgramService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, logger.Log) + sttService = stt.NewDeepgramService(cfg.AI.STT.APIKey, cfg.AI.STT.Model, cfg.AI.STT.Endpoint, cfg.AI.STT.Timeout, logger.Log) logger.Log.Infow("STT service initialized", "provider", "deepgram", "model", cfg.AI.STT.Model) } - llmService := llm.NewOpenAIService(cfg.AI.LLM.APIKey, cfg.AI.LLM.Model, cfg.AI.LLM.Endpoint, cfg.AI.LLM.Timeout, logger.Log) + llmService := llm.NewOpenAIService(cfg.AI.LLM.APIKey, cfg.AI.LLM.Model, cfg.AI.LLM.Endpoint, cfg.AI.LLM.Timeout, cfg.AI.LLM.HTTPClientTimeout, logger.Log) logger.Log.Infow("LLM service initialized", "provider", cfg.AI.LLM.Provider, "model", cfg.AI.LLM.Model, "endpoint", cfg.AI.LLM.Endpoint, "timeout", cfg.AI.LLM.Timeout) var ttsService tts.Service switch strings.ToLower(cfg.AI.TTS.Provider) { case "mimo", "xiaomi": - ttsService = tts.NewMiMoService(cfg.AI.TTS.APIKey, cfg.AI.TTS.Model, cfg.AI.TTS.Voice, cfg.AI.TTS.Endpoint, cfg.AI.TTS.Timeout, logger.Log) + ttsService = tts.NewMiMoService(cfg.AI.TTS.APIKey, cfg.AI.TTS.Model, cfg.AI.TTS.Voice, cfg.AI.TTS.Endpoint, cfg.AI.TTS.Timeout, cfg.AI.TTS.HTTPClientTimeout, logger.Log) logger.Log.Infow("TTS service initialized", "provider", "mimo", "model", cfg.AI.TTS.Model, "voice", cfg.AI.TTS.Voice, "endpoint", cfg.AI.TTS.Endpoint) default: - 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) + 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, cfg.AI.TTS.HTTPClientTimeout, logger.Log) logger.Log.Infow("TTS service initialized", "provider", "openai", "model", cfg.AI.TTS.Model, "voice", cfg.AI.TTS.Voice, "speed", cfg.AI.TTS.Speed) } // 初始化 Orchestrator - orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr, cfg.AI.LLM.Model, cfg.AI.TTS.Voice, cfg.AI.TTS.Speed) + orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr, cfg) // Gin 模式 if cfg.App.Env == "prod" { @@ -93,7 +100,7 @@ func main() { // REST API apiGroup := r.Group("/api") { - apiGroup.GET("/health", healthHandler(sessionMgr)) + apiGroup.GET("/health", healthHandler(sessionMgr, cfg)) } // Session REST 端点 @@ -101,7 +108,7 @@ func main() { sessionHandler.RegisterRoutes(apiGroup) // WebSocket - r.GET("/ws", ws.ServeWS(sessionMgr, orch)) + r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg)) // HTTP Server srv := &http.Server{ @@ -125,7 +132,7 @@ func main() { <-ctx.Done() logger.Log.Info("shutting down...") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { @@ -135,11 +142,15 @@ func main() { } // healthHandler 健康检查。 -func healthHandler(sessionMgr session.Manager) gin.HandlerFunc { +func healthHandler(sessionMgr session.Manager, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { + version := Version + if version == "" { + version = cfg.App.Version + } c.JSON(200, gin.H{ "status": "ok", - "version": "0.1.0", + "version": version, "uptime_seconds": int(time.Since(startTime).Seconds()), "active_sessions": sessionMgr.ActiveCount(), }) diff --git a/backend/internal/ai/llm/openai.go b/backend/internal/ai/llm/openai.go index bbc9444..754176c 100644 --- a/backend/internal/ai/llm/openai.go +++ b/backend/internal/ai/llm/openai.go @@ -26,24 +26,23 @@ type OpenAIService struct { } // NewOpenAIService 创建 OpenAI LLM 服务。 -func NewOpenAIService(apiKey, model, endpoint string, timeoutSec int, logger *zap.SugaredLogger) *OpenAIService { - if model == "" { - model = "gpt-4o" - } - if endpoint == "" { - endpoint = "https://api.openai.com/v1" - } +// model、endpoint 由 config 层保证非空。 +func NewOpenAIService(apiKey, model, endpoint string, timeoutSec, httpClientTimeoutSec int, logger *zap.SugaredLogger) *OpenAIService { timeout := time.Duration(timeoutSec) * time.Second if timeout <= 0 { timeout = 10 * time.Second } + httpClientTimeout := time.Duration(httpClientTimeoutSec) * time.Second + if httpClientTimeout <= 0 { + httpClientTimeout = 60 * time.Second + } return &OpenAIService{ apiKey: apiKey, model: model, endpoint: endpoint, timeout: timeout, logger: logger, - client: &http.Client{Timeout: 60 * time.Second}, // HTTP client timeout > LLM timeout + client: &http.Client{Timeout: httpClientTimeout}, } } diff --git a/backend/internal/ai/llm/openai_test.go b/backend/internal/ai/llm/openai_test.go index 9f2eee2..240e523 100644 --- a/backend/internal/ai/llm/openai_test.go +++ b/backend/internal/ai/llm/openai_test.go @@ -53,7 +53,7 @@ func TestOpenAIService_ChatStream_Success(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar()) + svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar()) ch, err := svc.ChatStream(context.Background(), Request{ Text: "这是什么?", @@ -99,7 +99,7 @@ func TestOpenAIService_ChatStream_WithImage(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar()) + svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar()) ch, err := svc.ChatStream(context.Background(), Request{ Image: []byte("fake-jpeg-data"), @@ -123,7 +123,7 @@ func TestOpenAIService_ChatStream_WithHistory(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar()) + svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar()) ch, err := svc.ChatStream(context.Background(), Request{ Text: "继续", @@ -149,7 +149,7 @@ func TestOpenAIService_ChatStream_APIError(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("bad-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar()) + svc := NewOpenAIService("bad-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar()) _, err := svc.ChatStream(context.Background(), Request{ Text: "test", @@ -172,7 +172,7 @@ func TestOpenAIService_ChatStream_Timeout(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 1, zap.NewNop().Sugar()) // 1s timeout + svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 1, 60, zap.NewNop().Sugar()) // 1s timeout ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -204,7 +204,7 @@ func TestOpenAIService_ChatStream_UsageInResponse(t *testing.T) { }) defer srv.Close() - svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, zap.NewNop().Sugar()) + svc := NewOpenAIService("test-key", "gpt-4o", srv.URL, 10, 60, zap.NewNop().Sugar()) ch, err := svc.ChatStream(context.Background(), Request{Text: "test"}) if err != nil { diff --git a/backend/internal/ai/stt/deepgram.go b/backend/internal/ai/stt/deepgram.go index 54da674..8d19246 100644 --- a/backend/internal/ai/stt/deepgram.go +++ b/backend/internal/ai/stt/deepgram.go @@ -18,21 +18,22 @@ type DeepgramService struct { apiKey string model string endpoint string + timeout time.Duration logger *zap.SugaredLogger } // NewDeepgramService 创建 Deepgram STT 服务。 -func NewDeepgramService(apiKey, model, endpoint string, logger *zap.SugaredLogger) *DeepgramService { - if model == "" { - model = "nova-2" - } - if endpoint == "" { - endpoint = "wss://api.deepgram.com/v1/listen" +// model、endpoint 由 config 层保证非空,timeoutSec 为 0 时默认 5 秒。 +func NewDeepgramService(apiKey, model, endpoint string, timeoutSec int, logger *zap.SugaredLogger) *DeepgramService { + timeout := time.Duration(timeoutSec) * time.Second + if timeout <= 0 { + timeout = 5 * time.Second } return &DeepgramService{ apiKey: apiKey, model: model, endpoint: endpoint, + timeout: timeout, logger: logger, } } @@ -57,8 +58,8 @@ func (d *DeepgramService) Recognize(ctx context.Context, audio []byte, opts Opti // 构建 WebSocket URL,附带查询参数 wsURL := d.buildURL(opts) - // 5 秒总超时 - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + // 总超时 + ctx, cancel := context.WithTimeout(ctx, d.timeout) defer cancel() // 建立 WebSocket 连接 diff --git a/backend/internal/ai/stt/deepgram_test.go b/backend/internal/ai/stt/deepgram_test.go index 2d00e53..70cc52e 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", 0, 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", 0, 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", 0, 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", 0, 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", 0, 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", 0, zap.NewNop().Sugar()) tests := []struct { name string diff --git a/backend/internal/ai/stt/mimo.go b/backend/internal/ai/stt/mimo.go index 887b285..68e0c0d 100644 --- a/backend/internal/ai/stt/mimo.go +++ b/backend/internal/ai/stt/mimo.go @@ -21,21 +21,22 @@ type MiMoService struct { apiKey string model string endpoint string + timeout time.Duration 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" +// model、endpoint 由 config 层保证非空,timeoutSec 为 0 时默认 10 秒。 +func NewMiMoService(apiKey, model, endpoint string, timeoutSec int, logger *zap.SugaredLogger) *MiMoService { + timeout := time.Duration(timeoutSec) * time.Second + if timeout <= 0 { + timeout = 10 * time.Second } return &MiMoService{ apiKey: apiKey, model: model, endpoint: endpoint, + timeout: timeout, logger: logger, } } @@ -126,7 +127,7 @@ func (m *MiMoService) Recognize(ctx context.Context, audio []byte, opts Options) url := strings.TrimRight(m.endpoint, "/") + "/chat/completions" - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + ctx, cancel := context.WithTimeout(ctx, m.timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) diff --git a/backend/internal/ai/stt/mimo_test.go b/backend/internal/ai/stt/mimo_test.go index 435a926..78ab038 100644 --- a/backend/internal/ai/stt/mimo_test.go +++ b/backend/internal/ai/stt/mimo_test.go @@ -14,7 +14,7 @@ import ( 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()) + s := NewMiMoService("test-key", "mimo-v2.5-asr", srv.URL, 0, zap.NewNop().Sugar()) return s, srv } diff --git a/backend/internal/ai/tts/mimo.go b/backend/internal/ai/tts/mimo.go index 208d301..5e80cbb 100644 --- a/backend/internal/ai/tts/mimo.go +++ b/backend/internal/ai/tts/mimo.go @@ -27,20 +27,16 @@ type MiMoService struct { } // NewMiMoService 创建 MiMo TTS 服务。 -func NewMiMoService(apiKey, model, voice, endpoint string, timeoutSec int, logger *zap.SugaredLogger) *MiMoService { - if model == "" { - model = "mimo-v2.5-tts" - } - if voice == "" { - voice = "冰糖" - } - if endpoint == "" { - endpoint = "https://api.xiaomimimo.com/v1" - } +// model、voice、endpoint 由 config 层保证非空。 +func NewMiMoService(apiKey, model, voice, endpoint string, timeoutSec, httpClientTimeoutSec int, logger *zap.SugaredLogger) *MiMoService { timeout := time.Duration(timeoutSec) * time.Second if timeout <= 0 { timeout = 5 * time.Second } + httpClientTimeout := time.Duration(httpClientTimeoutSec) * time.Second + if httpClientTimeout <= 0 { + httpClientTimeout = 30 * time.Second + } return &MiMoService{ apiKey: apiKey, model: model, @@ -48,7 +44,7 @@ func NewMiMoService(apiKey, model, voice, endpoint string, timeoutSec int, logge endpoint: endpoint, timeout: timeout, logger: logger, - client: &http.Client{Timeout: 30 * time.Second}, + client: &http.Client{Timeout: httpClientTimeout}, } } diff --git a/backend/internal/ai/tts/mimo_test.go b/backend/internal/ai/tts/mimo_test.go index 0e419bf..0d6b1de 100644 --- a/backend/internal/ai/tts/mimo_test.go +++ b/backend/internal/ai/tts/mimo_test.go @@ -93,7 +93,7 @@ func TestMiMoService_SynthesizeStream_Success(t *testing.T) { }) defer srv.Close() - svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) + svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar()) textStream := sendSentences("你好", "世界", "!") @@ -145,7 +145,7 @@ func TestMiMoService_SynthesizeStream_APIError(t *testing.T) { }) defer srv.Close() - svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) + svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar()) textStream := sendSentences("你好") @@ -178,7 +178,7 @@ func TestMiMoService_SynthesizeStream_Timeout(t *testing.T) { defer srv.Close() // 1 秒超时 - svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 1, zap.NewNop().Sugar()) + svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 1, 30, zap.NewNop().Sugar()) textStream := sendSentences("很长的句子") @@ -214,7 +214,7 @@ func TestMiMoService_SynthesizeStream_EmptyText(t *testing.T) { }) defer srv.Close() - svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) + svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar()) // 空句子应该被跳过 textStream := sendSentences("", "你好", "") @@ -248,7 +248,7 @@ func TestMiMoService_SynthesizeStream_ContextCancelled(t *testing.T) { }) defer srv.Close() - svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) + svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar()) textStream := make(chan string, 3) textStream <- "第一句" @@ -290,7 +290,7 @@ func TestMiMoService_SynthesizeStream_PartialFailure(t *testing.T) { }) defer srv.Close() - svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) + svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar()) textStream := sendSentences("第一句", "第二句", "第三句") @@ -329,7 +329,7 @@ func TestMiMoService_SynthesizeStream_CustomVoice(t *testing.T) { }) defer srv.Close() - svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) + svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar()) textStream := sendSentences("你好") @@ -360,7 +360,7 @@ func TestMiMoService_SynthesizeStream_DefaultVoice(t *testing.T) { defer srv.Close() // 不指定 voice - svc := NewMiMoService("test-key", "", "", srv.URL, 5, zap.NewNop().Sugar()) + svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar()) textStream := sendSentences("你好") @@ -381,7 +381,7 @@ func TestMiMoService_SynthesizeStream_EmptyAudioData(t *testing.T) { }) defer srv.Close() - svc := NewMiMoService("test-key", "", "冰糖", srv.URL, 5, zap.NewNop().Sugar()) + svc := NewMiMoService("test-key", "mimo-v2.5-tts", "冰糖", srv.URL, 5, 30, zap.NewNop().Sugar()) textStream := sendSentences("你好") diff --git a/backend/internal/ai/tts/openai.go b/backend/internal/ai/tts/openai.go index 6e0123e..8741ce6 100644 --- a/backend/internal/ai/tts/openai.go +++ b/backend/internal/ai/tts/openai.go @@ -25,23 +25,19 @@ type OpenAIService struct { } // NewOpenAIService 创建 OpenAI TTS 服务。 -func NewOpenAIService(apiKey, model, voice, endpoint string, speed float64, timeoutSec int, logger *zap.SugaredLogger) *OpenAIService { - if model == "" { - model = "tts-1" - } - if voice == "" { - voice = "alloy" - } +// model、voice、endpoint 由 config 层保证非空。 +func NewOpenAIService(apiKey, model, voice, endpoint string, speed float64, timeoutSec, httpClientTimeoutSec int, logger *zap.SugaredLogger) *OpenAIService { if speed <= 0 { speed = 1.0 } - if endpoint == "" { - endpoint = "https://api.openai.com/v1" - } timeout := time.Duration(timeoutSec) * time.Second if timeout <= 0 { timeout = 5 * time.Second } + httpClientTimeout := time.Duration(httpClientTimeoutSec) * time.Second + if httpClientTimeout <= 0 { + httpClientTimeout = 30 * time.Second + } return &OpenAIService{ apiKey: apiKey, model: model, @@ -50,7 +46,7 @@ func NewOpenAIService(apiKey, model, voice, endpoint string, speed float64, time endpoint: endpoint, timeout: timeout, logger: logger, - client: &http.Client{Timeout: 30 * time.Second}, + client: &http.Client{Timeout: httpClientTimeout}, } } diff --git a/backend/internal/ai/tts/openai_test.go b/backend/internal/ai/tts/openai_test.go index 36afd31..7a0d7f5 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", "tts-1", "alloy", srv.URL, 1.0, 5, 30, 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", "tts-1", "alloy", srv.URL, 1.0, 5, 30, 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", "tts-1", "alloy", srv.URL, 1.0, 1, 30, 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", "tts-1", "alloy", srv.URL, 1.0, 5, 30, 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", "tts-1", "alloy", srv.URL, 1.0, 5, 30, 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", "tts-1", "alloy", srv.URL, 1.0, 5, 30, 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", "tts-1", "alloy", srv.URL, 1.0, 5, 30, zap.NewNop().Sugar()) textStream := sendSentences("你好") diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 04f581a..aff3a8e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -11,12 +11,19 @@ import ( // Config 应用配置。 type Config struct { - App AppConfig `mapstructure:"app"` - Server ServerConfig `mapstructure:"server"` - Redis RedisConfig `mapstructure:"redis"` - AI AIConfig `mapstructure:"ai"` - Storage StorageConfig `mapstructure:"storage"` - Log LogConfig `mapstructure:"log"` + App AppConfig `mapstructure:"app"` + Server ServerConfig `mapstructure:"server"` + Session SessionConfig `mapstructure:"session"` + Redis RedisConfig `mapstructure:"redis"` + AI AIConfig `mapstructure:"ai"` + Storage StorageConfig `mapstructure:"storage"` + Log LogConfig `mapstructure:"log"` +} + +// SessionConfig 会话管理配置。 +type SessionConfig struct { + TTL int `mapstructure:"ttl"` // 会话过期时间(分钟) + MaxHistory int `mapstructure:"max_history"` // 对话历史上限(条) } type AppConfig struct { @@ -25,10 +32,14 @@ type AppConfig struct { } type ServerConfig struct { - Host string `mapstructure:"host"` - Port int `mapstructure:"port"` - ReadTimeout int `mapstructure:"read_timeout"` - WriteTimeout int `mapstructure:"write_timeout"` + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + ReadTimeout int `mapstructure:"read_timeout"` + WriteTimeout int `mapstructure:"write_timeout"` + HeartbeatInterval int `mapstructure:"heartbeat_interval"` // 心跳检查间隔(秒) + HeartbeatTimeout int `mapstructure:"heartbeat_timeout"` // 心跳超时(秒) + ShutdownTimeout int `mapstructure:"shutdown_timeout"` // 优雅关闭超时(秒) + AllowedOrigins []string `mapstructure:"allowed_origins"` // CORS 允许的来源,空表示允许所有 } // Addr 返回 host:port 地址。 @@ -49,28 +60,34 @@ type AIConfig struct { } type STTConfig struct { - Provider string `mapstructure:"provider"` - APIKey string `mapstructure:"api_key"` - Model string `mapstructure:"model"` - Endpoint string `mapstructure:"endpoint"` + Provider string `mapstructure:"provider"` + APIKey string `mapstructure:"api_key"` + Model string `mapstructure:"model"` + Endpoint string `mapstructure:"endpoint"` + Timeout int `mapstructure:"timeout"` // STT 超时(秒) + HTTPClientTimeout int `mapstructure:"http_client_timeout"` // HTTP 客户端超时(秒) } type LLMConfig struct { - Provider string `mapstructure:"provider"` - APIKey string `mapstructure:"api_key"` - Model string `mapstructure:"model"` - Endpoint string `mapstructure:"endpoint"` - Timeout int `mapstructure:"timeout"` + Provider string `mapstructure:"provider"` + APIKey string `mapstructure:"api_key"` + Model string `mapstructure:"model"` + Endpoint string `mapstructure:"endpoint"` + Timeout int `mapstructure:"timeout"` + HTTPClientTimeout int `mapstructure:"http_client_timeout"` // HTTP 客户端超时(秒) } 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"` - Timeout int `mapstructure:"timeout"` + 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"` + Timeout int `mapstructure:"timeout"` + HTTPClientTimeout int `mapstructure:"http_client_timeout"` // HTTP 客户端超时(秒) + OutputFormat string `mapstructure:"output_format"` // 输出格式:mp3/wav + SampleRate int `mapstructure:"sample_rate"` // 输出采样率 } type StorageConfig struct { @@ -91,28 +108,42 @@ func Load() (*Config, error) { v.AddConfigPath(".") v.AddConfigPath("./config") v.AddConfigPath("./backend") + v.AddConfigPath("..") // 兼容从 backend/cmd/ 启动 + v.AddConfigPath("../..") // 兼容从 backend/cmd/server/ 启动 // 默认值 v.SetDefault("app.env", "dev") + v.SetDefault("app.version", "dev") v.SetDefault("server.host", "0.0.0.0") v.SetDefault("server.port", 8080) v.SetDefault("server.read_timeout", 30) v.SetDefault("server.write_timeout", 30) + v.SetDefault("server.heartbeat_interval", 30) + v.SetDefault("server.heartbeat_timeout", 60) + v.SetDefault("server.shutdown_timeout", 10) + v.SetDefault("session.ttl", 30) + v.SetDefault("session.max_history", 20) 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.stt.timeout", 5) + v.SetDefault("ai.stt.http_client_timeout", 30) 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.llm.http_client_timeout", 60) v.SetDefault("ai.tts.provider", "openai") v.SetDefault("ai.tts.model", "tts-1") v.SetDefault("ai.tts.voice", "mimo_default") v.SetDefault("ai.tts.speed", 1.0) v.SetDefault("ai.tts.endpoint", "https://api.openai.com/v1") v.SetDefault("ai.tts.timeout", 5) + v.SetDefault("ai.tts.http_client_timeout", 30) + v.SetDefault("ai.tts.output_format", "mp3") + v.SetDefault("ai.tts.sample_rate", 24000) v.SetDefault("storage.driver", "memory") v.SetDefault("log.level", "info") v.SetDefault("log.format", "console") @@ -134,6 +165,7 @@ func Load() (*Config, error) { // 按优先级尝试:当前目录、上级目录(兼容从 backend/ 或项目根目录启动) _ = godotenv.Load() _ = godotenv.Load("../.env") + _ = godotenv.Load("../../.env") // 兼容从 backend/cmd/server/ 启动 // 环境变量覆盖 v.SetEnvPrefix("CAMTALK") diff --git a/backend/internal/orchestrator/pipeline.go b/backend/internal/orchestrator/pipeline.go index fd84c68..048055c 100644 --- a/backend/internal/orchestrator/pipeline.go +++ b/backend/internal/orchestrator/pipeline.go @@ -11,6 +11,7 @@ import ( "github.com/hhs/camtalk/internal/ai/llm" "github.com/hhs/camtalk/internal/ai/stt" "github.com/hhs/camtalk/internal/ai/tts" + "github.com/hhs/camtalk/internal/config" "github.com/hhs/camtalk/internal/logger" "github.com/hhs/camtalk/internal/models" "github.com/hhs/camtalk/internal/session" @@ -18,13 +19,15 @@ import ( // Pipeline 实现 Orchestrator 接口,管理 STT → LLM → TTS 流式管道。 type Pipeline struct { - sttService stt.Service - llmService llm.Service - ttsService tts.Service - sessionMgr session.Manager - model string // LLM 模型名,用于 llm_done 上报 - ttsVoice string // TTS 音色 - ttsSpeed float64 // TTS 语速 + sttService stt.Service + llmService llm.Service + ttsService tts.Service + sessionMgr session.Manager + model string // LLM 模型名,用于 llm_done 上报 + ttsVoice string // TTS 音色 + ttsSpeed float64 // TTS 语速 + ttsOutputFmt string // TTS 输出格式 + ttsSampleRate int // TTS 输出采样率 } // New 创建 Pipeline 实例。 @@ -33,18 +36,18 @@ func New( llmService llm.Service, ttsService tts.Service, sessionMgr session.Manager, - model string, - ttsVoice string, - ttsSpeed float64, + cfg *config.Config, ) *Pipeline { return &Pipeline{ - sttService: sttService, - llmService: llmService, - ttsService: ttsService, - sessionMgr: sessionMgr, - model: model, - ttsVoice: ttsVoice, - ttsSpeed: ttsSpeed, + sttService: sttService, + llmService: llmService, + ttsService: ttsService, + sessionMgr: sessionMgr, + model: cfg.AI.LLM.Model, + ttsVoice: cfg.AI.TTS.Voice, + ttsSpeed: cfg.AI.TTS.Speed, + ttsOutputFmt: cfg.AI.TTS.OutputFormat, + ttsSampleRate: cfg.AI.TTS.SampleRate, } } @@ -314,8 +317,8 @@ func (p *Pipeline) synthesizeTTS( ttsStream, err := p.ttsService.SynthesizeStream(ctx, sentenceCh, tts.Options{ Voice: p.ttsVoice, Speed: p.ttsSpeed, - OutputFmt: "mp3", - SampleRate: 24000, + OutputFmt: p.ttsOutputFmt, + SampleRate: p.ttsSampleRate, }) if err != nil { log.Errorw("TTS 合成启动失败", "error", err) diff --git a/backend/internal/orchestrator/pipeline_test.go b/backend/internal/orchestrator/pipeline_test.go index 2a36a29..ad9b847 100644 --- a/backend/internal/orchestrator/pipeline_test.go +++ b/backend/internal/orchestrator/pipeline_test.go @@ -13,6 +13,7 @@ import ( "github.com/hhs/camtalk/internal/ai/llm" "github.com/hhs/camtalk/internal/ai/stt" "github.com/hhs/camtalk/internal/ai/tts" + "github.com/hhs/camtalk/internal/config" "github.com/hhs/camtalk/internal/logger" "github.com/hhs/camtalk/internal/models" ) @@ -254,7 +255,12 @@ func TestProcessQuery_Success(t *testing.T) { mockSender.On("SendTTSAudio", mock.Anything).Return(nil) // 创建 Pipeline - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o", "alloy", 1.0) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{ + AI: config.AIConfig{ + LLM: config.LLMConfig{Model: "gpt-4o"}, + TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000}, + }, + }) // 执行 ctx := context.Background() @@ -305,7 +311,12 @@ func TestProcessQuery_STTError(t *testing.T) { mockSender.On("SendError", mock.Anything).Return(nil) - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o", "alloy", 1.0) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{ + AI: config.AIConfig{ + LLM: config.LLMConfig{Model: "gpt-4o"}, + TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000}, + }, + }) ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender) @@ -357,7 +368,12 @@ func TestProcessQuery_LLMError(t *testing.T) { mockSender.On("SendError", mock.Anything).Return(nil) - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o", "alloy", 1.0) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{ + AI: config.AIConfig{ + LLM: config.LLMConfig{Model: "gpt-4o"}, + TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000}, + }, + }) ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender) @@ -415,7 +431,12 @@ 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, "gpt-4o", "alloy", 1.0) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{ + AI: config.AIConfig{ + LLM: config.LLMConfig{Model: "gpt-4o"}, + TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000}, + }, + }) ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender) @@ -485,7 +506,12 @@ 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, "gpt-4o", "alloy", 1.0) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{ + AI: config.AIConfig{ + LLM: config.LLMConfig{Model: "gpt-4o"}, + TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000}, + }, + }) // 创建可取消的上下文 ctx, cancel := context.WithCancel(context.Background()) @@ -545,7 +571,12 @@ 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, "gpt-4o", "alloy", 1.0) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{ + AI: config.AIConfig{ + LLM: config.LLMConfig{Model: "gpt-4o"}, + TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000}, + }, + }) ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender) @@ -617,7 +648,12 @@ func TestProcessQuery_InvalidAudio(t *testing.T) { mockSender.On("SendError", mock.Anything).Return(nil) - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o", "alloy", 1.0) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{ + AI: config.AIConfig{ + LLM: config.LLMConfig{Model: "gpt-4o"}, + TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000}, + }, + }) ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender) @@ -650,7 +686,12 @@ func TestProcessQuery_SessionNotFound(t *testing.T) { mockSender.On("SendError", mock.Anything).Return(nil) - pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, "gpt-4o", "alloy", 1.0) + pipeline := New(mockSTT, mockLLM, mockTTS, mockSession, &config.Config{ + AI: config.AIConfig{ + LLM: config.LLMConfig{Model: "gpt-4o"}, + TTS: config.TTSConfig{Voice: "alloy", Speed: 1.0, OutputFormat: "mp3", SampleRate: 24000}, + }, + }) ctx := context.Background() err := pipeline.ProcessQuery(ctx, "session-123", req, nil, mockSender) diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go index 4e17bc8..5aa2acd 100644 --- a/backend/internal/ws/handler.go +++ b/backend/internal/ws/handler.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "github.com/hhs/camtalk/internal/config" "github.com/hhs/camtalk/internal/errors" "github.com/hhs/camtalk/internal/logger" "github.com/hhs/camtalk/internal/models" @@ -17,8 +18,23 @@ import ( "github.com/hhs/camtalk/internal/session" ) -var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, // 开发阶段允许所有来源 +// newUpgrader 根据配置创建 WebSocket upgrader。 +func newUpgrader(cfg *config.Config) websocket.Upgrader { + allowedOrigins := cfg.Server.AllowedOrigins + return websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + if len(allowedOrigins) == 0 { + return true // 未配置则允许所有来源(开发模式) + } + origin := r.Header.Get("Origin") + for _, o := range allowedOrigins { + if o == origin || o == "*" { + return true + } + } + return false + }, + } } // Client 代表一个 WebSocket 客户端连接。 @@ -75,13 +91,21 @@ func (w *WSClient) SendError(err models.WsError) error { } // ServeWS 处理 WebSocket 升级请求。 -func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator) gin.HandlerFunc { +func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config) gin.HandlerFunc { + upgrader := newUpgrader(cfg) + heartbeatInterval := time.Duration(cfg.Server.HeartbeatInterval) * time.Second + heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second + version := cfg.App.Version + + maxHistory := cfg.Session.MaxHistory + return func(c *gin.Context) { - serveWS(c, sessionMgr, orch) + serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, maxHistory) } } -func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator) { +func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator, + upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int) { conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { logger.Log.Errorw("websocket upgrade failed", "error", err) @@ -108,7 +132,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche _ = client.SendJSON(models.WsConnected{ Type: "connected", SessionID: sessionID, - ServerVersion: "0.1.0", + ServerVersion: version, }) logger.Log.Infow("client connected", "session", sessionID) @@ -122,12 +146,12 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche // 启动心跳检查 goroutine done := make(chan struct{}) go func() { - ticker := time.NewTicker(30 * time.Second) + ticker := time.NewTicker(heartbeatInterval) defer ticker.Stop() for { select { case <-ticker.C: - if time.Since(lastPong) > 60*time.Second { + if time.Since(lastPong) > heartbeatTimeout { logger.Log.Warnw("heartbeat timeout", "session", sessionID) conn.Close() return @@ -181,7 +205,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche } // 获取对话历史 - history, _ := client.sessionMgr.GetHistory(context.Background(), sessionID, 20) + history, _ := client.sessionMgr.GetHistory(context.Background(), sessionID, maxHistory) // 创建可取消的 context ctx, cancel := context.WithCancel(context.Background()) diff --git a/backend/internal/ws/handler_test.go b/backend/internal/ws/handler_test.go index 97cf697..2872d5c 100644 --- a/backend/internal/ws/handler_test.go +++ b/backend/internal/ws/handler_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "context" + "github.com/hhs/camtalk/internal/config" "github.com/hhs/camtalk/internal/logger" "github.com/hhs/camtalk/internal/models" "github.com/hhs/camtalk/internal/orchestrator" @@ -138,7 +139,12 @@ func setupTestServer(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Se t.Cleanup(func() { sessionMgr.Stop() }) r := gin.New() - r.GET("/ws", ServeWS(sessionMgr, orch)) + cfg := &config.Config{ + App: config.AppConfig{Version: "test"}, + Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60}, + Session: config.SessionConfig{MaxHistory: 20}, + } + r.GET("/ws", ServeWS(sessionMgr, orch, cfg)) srv := httptest.NewServer(r) @@ -181,7 +187,7 @@ func TestWS_Connected(t *testing.T) { msg := readJSON(t, conn) assert.Equal(t, "connected", msg["type"]) assert.NotEmpty(t, msg["session_id"]) - assert.Equal(t, "0.1.0", msg["server_version"]) + assert.Equal(t, "test", msg["server_version"]) } // TestWS_PingPong 验证 ping/pong 心跳。 diff --git a/frontend/src/lib/websocket.ts b/frontend/src/lib/websocket.ts index 2fd7a9e..7d8c947 100644 --- a/frontend/src/lib/websocket.ts +++ b/frontend/src/lib/websocket.ts @@ -6,7 +6,10 @@ import type { ClientMessage, ServerMessage, WsMessage } from "../types"; -const WS_URL = "ws://localhost:8080/ws"; +// WebSocket 地址:优先使用环境变量,否则基于当前页面地址自动推导 +const WS_URL = + import.meta.env.VITE_WS_URL || + `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`; const PING_INTERVAL = 30_000; // 30 秒心跳 const MAX_RECONNECT_DELAY = 30_000; // 最大重连延迟 30 秒 diff --git a/功能创意.md b/功能创意.md index db2e0ba..0234104 100644 --- a/功能创意.md +++ b/功能创意.md @@ -4,3 +4,8 @@ 4.手动对话功能 5.视频框大小可调整,可最小化然后拖动 6.增加对话情景选择功能,参考豆包(面试官,英语老师,辩论赛,同声翻译等) + + +修复bug: +对话语音重复 +麦克风开关