diff --git a/pkg/routing/route.go b/pkg/routing/route.go index 9eb060c53..f57edec08 100644 --- a/pkg/routing/route.go +++ b/pkg/routing/route.go @@ -44,8 +44,8 @@ func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute { accountID := NormalizeAccountID(input.AccountID) peer := input.Peer - dmScope := DMScope(r.cfg.Session.DMScope) - if dmScope == "" { + dmScope, ok := NormalizeDMScope(r.cfg.Session.DMScope) + if !ok { dmScope = DMScopeMain } identityLinks := r.cfg.Session.IdentityLinks diff --git a/pkg/routing/session_key.go b/pkg/routing/session_key.go index eab592bec..cd96129fa 100644 --- a/pkg/routing/session_key.go +++ b/pkg/routing/session_key.go @@ -15,6 +15,22 @@ const ( DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer" ) +// NormalizeDMScope converts known legacy aliases to their canonical backend values. +func NormalizeDMScope(scope string) (DMScope, bool) { + switch strings.ToLower(strings.TrimSpace(scope)) { + case string(DMScopeMain), "global": + return DMScopeMain, true + case string(DMScopePerPeer): + return DMScopePerPeer, true + case string(DMScopePerChannelPeer), "per-channel": + return DMScopePerChannelPeer, true + case string(DMScopePerAccountChannelPeer): + return DMScopePerAccountChannelPeer, true + default: + return "", false + } +} + // RoutePeer represents a chat peer with kind and ID. type RoutePeer struct { Kind string // "direct", "group", "channel" @@ -56,8 +72,8 @@ func BuildAgentPeerSessionKey(params SessionKeyParams) string { } if peerKind == "direct" { - dmScope := params.DMScope - if dmScope == "" { + dmScope, ok := NormalizeDMScope(string(params.DMScope)) + if !ok { dmScope = DMScopeMain } peerID := strings.TrimSpace(peer.ID) diff --git a/pkg/routing/session_key_test.go b/pkg/routing/session_key_test.go index ad7a1ca02..892dd6d67 100644 --- a/pkg/routing/session_key_test.go +++ b/pkg/routing/session_key_test.go @@ -18,6 +18,32 @@ func TestBuildAgentMainSessionKey_Normalizes(t *testing.T) { } } +func TestNormalizeDMScope(t *testing.T) { + tests := []struct { + name string + input string + want DMScope + ok bool + }{ + {name: "main", input: "main", want: DMScopeMain, ok: true}, + {name: "global alias", input: "global", want: DMScopeMain, ok: true}, + {name: "per peer", input: "per-peer", want: DMScopePerPeer, ok: true}, + {name: "per channel peer", input: "per-channel-peer", want: DMScopePerChannelPeer, ok: true}, + {name: "per channel alias", input: "per-channel", want: DMScopePerChannelPeer, ok: true}, + {name: "per account channel peer", input: "per-account-channel-peer", want: DMScopePerAccountChannelPeer, ok: true}, + {name: "unknown", input: "shared", want: "", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := NormalizeDMScope(tt.input) + if got != tt.want || ok != tt.ok { + t.Fatalf("NormalizeDMScope(%q) = (%q, %v), want (%q, %v)", tt.input, got, ok, tt.want, tt.ok) + } + }) + } +} + func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) { got := BuildAgentPeerSessionKey(SessionKeyParams{ AgentID: "main", @@ -31,6 +57,19 @@ func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) { } } +func TestBuildAgentPeerSessionKey_DMScopeGlobalAlias(t *testing.T) { + got := BuildAgentPeerSessionKey(SessionKeyParams{ + AgentID: "main", + Channel: "telegram", + Peer: &RoutePeer{Kind: "direct", ID: "user123"}, + DMScope: DMScope("global"), + }) + want := "agent:main:main" + if got != want { + t.Errorf("DMScope(global) = %q, want %q", got, want) + } +} + func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) { got := BuildAgentPeerSessionKey(SessionKeyParams{ AgentID: "main", @@ -57,6 +96,19 @@ func TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) { } } +func TestBuildAgentPeerSessionKey_DMScopePerChannelAlias(t *testing.T) { + got := BuildAgentPeerSessionKey(SessionKeyParams{ + AgentID: "main", + Channel: "telegram", + Peer: &RoutePeer{Kind: "direct", ID: "user123"}, + DMScope: DMScope("per-channel"), + }) + want := "agent:main:telegram:direct:user123" + if got != want { + t.Errorf("DMScope(per-channel) = %q, want %q", got, want) + } +} + func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) { got := BuildAgentPeerSessionKey(SessionKeyParams{ AgentID: "main", diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 618b8438d..e8e3f6693 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -10,6 +10,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/routing" ) // registerConfigRoutes binds configuration management endpoints to the ServeMux. @@ -250,6 +251,21 @@ func (h *Handler) handleTestCommandPatterns(w http.ResponseWriter, r *http.Reque func validateConfig(cfg *config.Config) []string { var errs []string + if rawScope := strings.TrimSpace(cfg.Session.DMScope); rawScope != "" { + normalizedScope, ok := routing.NormalizeDMScope(rawScope) + if !ok { + errs = append( + errs, + fmt.Sprintf( + "session.dm_scope %q is invalid; supported values: main, per-peer, per-channel-peer, per-account-channel-peer", + cfg.Session.DMScope, + ), + ) + } else { + cfg.Session.DMScope = string(normalizedScope) + } + } + // Validate model_list entries if err := cfg.ValidateModelList(); err != nil { errs = append(errs, err.Error()) diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 36acd95b0..6f7dc0b63 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -143,6 +143,45 @@ func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *tes } } +func TestValidateConfig_NormalizesLegacyDMScopeAliases(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "global", input: "global", want: "main"}, + {name: "per-channel", input: "per-channel", want: "per-channel-peer"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Session.DMScope = tt.input + + errs := validateConfig(cfg) + if len(errs) != 0 { + t.Fatalf("validateConfig() errors = %v, want none", errs) + } + if cfg.Session.DMScope != tt.want { + t.Fatalf("Session.DMScope = %q, want %q", cfg.Session.DMScope, tt.want) + } + }) + } +} + +func TestValidateConfig_RejectsUnknownDMScope(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Session.DMScope = "shared" + + errs := validateConfig(cfg) + if len(errs) == 0 { + t.Fatal("validateConfig() errors = nil, want dm_scope validation error") + } + if !bytes.Contains([]byte(errs[0]), []byte("session.dm_scope")) { + t.Fatalf("validateConfig() first error = %q, want dm_scope validation", errs[0]) + } +} + // setupPicoEnabledEnv creates a test environment with Pico channel enabled and // its token stored only in .security.yml (not in the JSON payload). func setupPicoEnabledEnv(t *testing.T) (string, func()) { diff --git a/web/frontend/src/components/config/form-model.ts b/web/frontend/src/components/config/form-model.ts index 10c5c71bb..94b6cfeef 100644 --- a/web/frontend/src/components/config/form-model.ts +++ b/web/frontend/src/components/config/form-model.ts @@ -40,11 +40,12 @@ export const DM_SCOPE_OPTIONS = [ descDefault: "Separate context for each user in each channel.", }, { - value: "per-channel", - labelKey: "pages.config.session_scope_per_channel", - labelDefault: "Per Channel", - descKey: "pages.config.session_scope_per_channel_desc", - descDefault: "One shared context per channel.", + value: "per-account-channel-peer", + labelKey: "pages.config.session_scope_per_account_channel_peer", + labelDefault: "Per Account + Channel + Peer", + descKey: "pages.config.session_scope_per_account_channel_peer_desc", + descDefault: + "Separate context for each user on each channel account instance.", }, { value: "per-peer", @@ -54,7 +55,7 @@ export const DM_SCOPE_OPTIONS = [ descDefault: "One context per user across channels.", }, { - value: "global", + value: "main", labelKey: "pages.config.session_scope_global", labelDefault: "Global", descKey: "pages.config.session_scope_global_desc", @@ -62,6 +63,11 @@ export const DM_SCOPE_OPTIONS = [ }, ] as const +const DM_SCOPE_ALIASES: Record = { + global: "main", + "per-channel": "per-channel-peer", +} + export const EMPTY_FORM: CoreConfigForm = { workspace: "", restrictToWorkspace: true, @@ -104,6 +110,19 @@ function asString(value: unknown): string { return typeof value === "string" ? value : "" } +function normalizeDmScope(value: unknown): string { + const raw = asString(value).trim() + if (!raw) { + return EMPTY_FORM.dmScope + } + + const normalized = DM_SCOPE_ALIASES[raw] ?? raw + + return DM_SCOPE_OPTIONS.some((scope) => scope.value === normalized) + ? normalized + : raw +} + function asBool(value: unknown): boolean { return value === true } @@ -195,7 +214,7 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { defaults.summarize_token_percent, EMPTY_FORM.summarizeTokenPercent, ), - dmScope: asString(session.dm_scope) || EMPTY_FORM.dmScope, + dmScope: normalizeDmScope(session.dm_scope), heartbeatEnabled: heartbeat.enabled === undefined ? EMPTY_FORM.heartbeatEnabled diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index d15cde693..28439cafb 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -460,6 +460,8 @@ "session_scope_hint": "How chat context is isolated across peers/channels.", "session_scope_per_channel_peer": "Per Channel + Peer", "session_scope_per_channel_peer_desc": "Separate context for each user in each channel.", + "session_scope_per_account_channel_peer": "Per Account + Channel + Peer", + "session_scope_per_account_channel_peer_desc": "Separate context for each user on each channel account instance.", "session_scope_per_channel": "Per Channel", "session_scope_per_channel_desc": "One shared context per channel.", "session_scope_per_peer": "Per Peer", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 7eb14e983..c2f4d1679 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -460,6 +460,8 @@ "session_scope_hint": "定义不同用户/频道之间如何隔离会话上下文。", "session_scope_per_channel_peer": "按频道+用户隔离", "session_scope_per_channel_peer_desc": "同一频道内不同用户使用独立上下文。", + "session_scope_per_account_channel_peer": "按账号+频道+用户隔离", + "session_scope_per_account_channel_peer_desc": "不同频道账号实例中的不同用户使用独立上下文。", "session_scope_per_channel": "按频道隔离", "session_scope_per_channel_desc": "同一频道内共享一个上下文。", "session_scope_per_peer": "按用户隔离",