diff --git a/backend/internal/application/conversation/context_compaction_policy.go b/backend/internal/application/conversation/context_compaction_policy.go index 23b42dff..6e3b6005 100644 --- a/backend/internal/application/conversation/context_compaction_policy.go +++ b/backend/internal/application/conversation/context_compaction_policy.go @@ -20,7 +20,7 @@ func (s *Service) resolveContextCompactionPolicy(ctx context.Context, cfg config AdminEnabled: cfg.ContextCompactEnabled, UserEnabled: true, } - if val, valErr := s.getUserSettingCached(ctx, userID, "chat.context_compact_auto"); valErr == nil && val == "false" { + if val, valErr := s.repo.GetUserSettingValue(ctx, userID, "chat.context_compact_auto"); valErr == nil && val == "false" { policy.UserEnabled = false } return policy diff --git a/backend/internal/application/conversation/service.go b/backend/internal/application/conversation/service.go index a84bb62e..4f8cc28d 100644 --- a/backend/internal/application/conversation/service.go +++ b/backend/internal/application/conversation/service.go @@ -117,7 +117,6 @@ type Service struct { generationStreams *generationStreamRegistry snapshotCache sync.Map // conversationID (uint) → *cachedSnapshot userMemCache sync.Map // userID (uint) → *cachedUserMemories - userSettingCache sync.Map // "userID:key" (string) → *cachedUserSetting imageContextCache *preparedConversationImageCache } diff --git a/backend/internal/application/conversation/service_cache.go b/backend/internal/application/conversation/service_cache.go index 284fe4a5..60e40b3a 100644 --- a/backend/internal/application/conversation/service_cache.go +++ b/backend/internal/application/conversation/service_cache.go @@ -2,7 +2,6 @@ package conversation import ( "context" - "fmt" "time" model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" @@ -14,8 +13,6 @@ const ( snapshotCacheTTL = 2 * time.Minute // userMemCacheTTL:用户记忆在会话期间极少变化,缓存 3 分钟。 userMemCacheTTL = 3 * time.Minute - // userSettingCacheTTL:用户设置在会话期间几乎不变,缓存 10 分钟。 - userSettingCacheTTL = 10 * time.Minute // inMemoryCacheSweepInterval:主动清理过期内存缓存,避免冷 key 长期驻留。 inMemoryCacheSweepInterval = time.Minute ) @@ -30,12 +27,6 @@ type cachedUserMemories struct { expiresAt time.Time } -type cachedUserSetting struct { - value string - valid bool - expiresAt time.Time -} - // getCachedSnapshot 从内存缓存读取最新 Snapshot,未命中时回退到 DB 查询。 func (s *Service) getCachedSnapshot(ctx context.Context, conversationID uint) (*model.ContextSnapshot, error) { if v, ok := s.snapshotCache.Load(conversationID); ok { @@ -61,28 +52,6 @@ func (s *Service) invalidateSnapshotCache(conversationID uint) { s.snapshotCache.Delete(conversationID) } -// getUserSettingCached 从内存缓存读取用户设置,未命中时回退到 DB 查询。 -func (s *Service) getUserSettingCached(ctx context.Context, userID uint, key string) (string, error) { - cacheKey := fmt.Sprintf("%d:%s", userID, key) - if v, ok := s.userSettingCache.Load(cacheKey); ok { - entry := v.(*cachedUserSetting) - if time.Now().Before(entry.expiresAt) { - if !entry.valid { - return "", fmt.Errorf("not found") - } - return entry.value, nil - } - s.userSettingCache.Delete(cacheKey) - } - val, err := s.repo.GetUserSettingValue(ctx, userID, key) - if err != nil { - s.userSettingCache.Store(cacheKey, &cachedUserSetting{valid: false, expiresAt: time.Now().Add(userSettingCacheTTL)}) - return "", err - } - s.userSettingCache.Store(cacheKey, &cachedUserSetting{value: val, valid: true, expiresAt: time.Now().Add(userSettingCacheTTL)}) - return val, nil -} - // getCachedUserMemories 从内存缓存读取用户长期记忆,未命中时回退到 DB 查询。 func (s *Service) getCachedUserMemories(ctx context.Context, userID uint) ([]domainmemory.UserMemory, error) { if v, ok := s.userMemCache.Load(userID); ok { @@ -136,11 +105,4 @@ func (s *Service) cleanupExpiredInMemoryCaches(now time.Time) { } return true }) - s.userSettingCache.Range(func(key, value interface{}) bool { - entry, ok := value.(*cachedUserSetting) - if !ok || !now.Before(entry.expiresAt) { - s.userSettingCache.Delete(key) - } - return true - }) } diff --git a/backend/internal/application/conversation/service_cache_test.go b/backend/internal/application/conversation/service_cache_test.go index 34d318ea..9eacdf54 100644 --- a/backend/internal/application/conversation/service_cache_test.go +++ b/backend/internal/application/conversation/service_cache_test.go @@ -13,8 +13,6 @@ func TestCleanupExpiredInMemoryCaches(t *testing.T) { svc.snapshotCache.Store(uint(2), &cachedSnapshot{expiresAt: now.Add(time.Minute)}) svc.userMemCache.Store(uint(1), &cachedUserMemories{expiresAt: now.Add(-time.Second)}) svc.userMemCache.Store(uint(2), &cachedUserMemories{expiresAt: now.Add(time.Minute)}) - svc.userSettingCache.Store("1:stale", &cachedUserSetting{expiresAt: now.Add(-time.Second)}) - svc.userSettingCache.Store("1:fresh", &cachedUserSetting{expiresAt: now.Add(time.Minute)}) svc.cleanupExpiredInMemoryCaches(now) @@ -30,10 +28,4 @@ func TestCleanupExpiredInMemoryCaches(t *testing.T) { if _, ok := svc.userMemCache.Load(uint(2)); !ok { t.Fatal("expected fresh user memory cache entry to remain") } - if _, ok := svc.userSettingCache.Load("1:stale"); ok { - t.Fatal("expected expired user setting cache entry to be deleted") - } - if _, ok := svc.userSettingCache.Load("1:fresh"); !ok { - t.Fatal("expected fresh user setting cache entry to remain") - } } diff --git a/backend/internal/application/conversation/service_message_send.go b/backend/internal/application/conversation/service_message_send.go index 44863ec6..0e7ec239 100644 --- a/backend/internal/application/conversation/service_message_send.go +++ b/backend/internal/application/conversation/service_message_send.go @@ -48,10 +48,18 @@ func (s *Service) reasoningContentPassbackEnabled(ctx context.Context, userID ui if route == nil || !route.ReasoningContentPassback { return false } - value, err := s.getUserSettingCached(ctx, userID, reasoningContentPassbackSettingKey) + value, err := s.repo.GetUserSettingValue(ctx, userID, reasoningContentPassbackSettingKey) return err == nil && value != "false" } +func (s *Service) resolveMessageFileMode(ctx context.Context, userID uint) string { + fileMode := "auto" + if fm, fmErr := s.repo.GetUserSettingValue(ctx, userID, "chat.file_mode"); fmErr == nil && fm != "" { + fileMode = fm + } + return fileMode +} + func messageRouteConfig(route *channel.ResolvedRoute, attributionReferer string, attributionTitle string) llm.RouteConfig { return llm.RouteConfig{ Protocol: route.Protocol, @@ -398,11 +406,8 @@ func (s *Service) sendMessageInternal( }() // 读取用户的文件处理模式偏好(auto / full_context / rag)。 - fileMode := "auto" capability := s.resolveChatFileCapability(ctx) - if fm, fmErr := s.getUserSettingCached(ctx, input.UserID, "chat.file_mode"); fmErr == nil && fm != "" { - fileMode = fm - } + fileMode := s.resolveMessageFileMode(ctx, input.UserID) // 收集并行预取结果,再规划本轮可发送的 PromptScope。 prefetch := <-prefetchCh diff --git a/backend/internal/application/conversation/service_user_settings_test.go b/backend/internal/application/conversation/service_user_settings_test.go new file mode 100644 index 00000000..e19c4967 --- /dev/null +++ b/backend/internal/application/conversation/service_user_settings_test.go @@ -0,0 +1,90 @@ +package conversation + +import ( + "context" + "testing" + + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel" + appusersettings "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/usersettings" + domainusersettings "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/usersettings" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +type mutableUserSettingsRepository struct { + repository.ConversationRepository + values map[uint]map[string]string +} + +func (r *mutableUserSettingsRepository) GetUserSettingValue(_ context.Context, userID uint, key string) (string, error) { + return r.values[userID][key], nil +} + +func (r *mutableUserSettingsRepository) ListByUserID(_ context.Context, userID uint) ([]domainusersettings.UserSetting, error) { + values := r.values[userID] + items := make([]domainusersettings.UserSetting, 0, len(values)) + for key, value := range values { + items = append(items, domainusersettings.UserSetting{UserID: userID, Key: key, Value: value}) + } + return items, nil +} + +func (r *mutableUserSettingsRepository) Upsert(_ context.Context, items []domainusersettings.UserSetting) error { + for _, item := range items { + if r.values[item.UserID] == nil { + r.values[item.UserID] = make(map[string]string) + } + r.values[item.UserID][item.Key] = item.Value + } + return nil +} + +func TestConversationSettingsReadPatchedValuesImmediately(t *testing.T) { + const userID uint = 17 + ctx := context.Background() + runtimeCfg := config.NewRuntime(config.Config{ContextCompactEnabled: true}) + repo := &mutableUserSettingsRepository{ + values: map[uint]map[string]string{ + userID: { + "chat.reasoning_content_passback": "true", + "chat.context_compact_auto": "true", + "chat.file_mode": "auto", + }, + }, + } + conversationService := &Service{ + cfg: runtimeCfg, + repo: repo, + } + settingsService := appusersettings.NewService(repo) + + if !conversationService.reasoningContentPassbackEnabled(ctx, userID, &channel.ResolvedRoute{ReasoningContentPassback: true}) { + t.Fatal("expected initial reasoning passback to be enabled") + } + if !conversationService.resolveContextCompactionPolicy(ctx, runtimeCfg.Snapshot(), userID).EffectiveEnabled() { + t.Fatal("expected initial context compaction to be enabled") + } + initialFileMode := conversationService.resolveMessageFileMode(ctx, userID) + if initialFileMode != "auto" { + t.Fatalf("initial message file mode = %q, want auto", initialFileMode) + } + + if _, err := settingsService.PatchSettings(ctx, userID, map[string]string{ + "chat.reasoning_content_passback": "false", + "chat.context_compact_auto": "false", + "chat.file_mode": "rag", + }); err != nil { + t.Fatalf("patch settings: %v", err) + } + + if conversationService.reasoningContentPassbackEnabled(ctx, userID, &channel.ResolvedRoute{ReasoningContentPassback: true}) { + t.Fatal("expected patched reasoning passback to be disabled") + } + if conversationService.resolveContextCompactionPolicy(ctx, runtimeCfg.Snapshot(), userID).EffectiveEnabled() { + t.Fatal("expected patched context compaction to be disabled") + } + updatedFileMode := conversationService.resolveMessageFileMode(ctx, userID) + if updatedFileMode != "rag" { + t.Fatalf("updated message file mode = %q, want rag", updatedFileMode) + } +}