From e88d0321f74a11800a82977dbc5d1bf2d626c6e1 Mon Sep 17 00:00:00 2001 From: Louis <8515500@gmail.com> Date: Wed, 13 May 2026 14:52:37 +0800 Subject: [PATCH] feat: add context settings controls to webui --- API.en.md | 1 + API.md | 1 + .../httpapi/admin/handler_settings_test.go | 72 +++++++++++++++++++ .../admin/settings/handler_settings_parse.go | 65 ++++++++++------- .../admin/settings/handler_settings_write.go | 12 +++- .../settings/CurrentInputFileSection.jsx | 37 ++++++++++ .../features/settings/FeatureFlagsSection.jsx | 67 ++++++++++++++++- .../features/settings/SettingsContainer.jsx | 2 +- .../src/features/settings/useSettingsForm.js | 27 +++++-- webui/src/locales/en.json | 32 +++++++-- webui/src/locales/zh.json | 32 +++++++-- 11 files changed, 305 insertions(+), 43 deletions(-) diff --git a/API.en.md b/API.en.md index 14b87de02..78802a7bf 100644 --- a/API.en.md +++ b/API.en.md @@ -789,6 +789,7 @@ Hot-updates runtime settings. Supported fields: - `auto_delete.mode` - `current_input_file.enabled` / `current_input_file.min_chars` / `current_input_file.inline_max_tokens` / `current_input_file.filename_policy` - `thinking_injection.enabled` / `thinking_injection.prompt` +- `context_engine.mode` / `context_engine.strategy` - `model_aliases` - `toolcall` policy is fixed and is no longer writable through settings diff --git a/API.md b/API.md index c8d1436d3..f05a01d5a 100644 --- a/API.md +++ b/API.md @@ -795,6 +795,7 @@ data: {"type":"message_stop"} - `auto_delete.mode` - `current_input_file.enabled` / `current_input_file.min_chars` / `current_input_file.inline_max_tokens` / `current_input_file.filename_policy` - `thinking_injection.enabled` / `thinking_injection.prompt` +- `context_engine.mode` / `context_engine.strategy` - `model_aliases` - `toolcall` 策略已固定,不再作为可写入字段 diff --git a/internal/httpapi/admin/handler_settings_test.go b/internal/httpapi/admin/handler_settings_test.go index 1e1471227..1e31c8684 100644 --- a/internal/httpapi/admin/handler_settings_test.go +++ b/internal/httpapi/admin/handler_settings_test.go @@ -430,6 +430,78 @@ func TestUpdateSettingsThinkingInjectionPartialEnabledPreservesPrompt(t *testing } } +func TestUpdateSettingsContextEngine(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"]}`) + payload := map[string]any{ + "context_engine": map[string]any{ + "mode": "shadow", + "strategy": "context_capsule", + }, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateSettings(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + snap := h.Store.Snapshot() + if snap.ContextEngine.Mode != "shadow" { + t.Fatalf("expected context_engine.mode=shadow, got %#v", snap.ContextEngine) + } + if snap.ContextEngine.Strategy != "context_capsule" { + t.Fatalf("expected context_engine.strategy=context_capsule, got %#v", snap.ContextEngine) + } + if got := h.Store.ContextEngineMode(); got != "shadow" { + t.Fatalf("ContextEngineMode()=%q want=shadow", got) + } + if got := h.Store.ContextEngineStrategy(); got != "context_capsule" { + t.Fatalf("ContextEngineStrategy()=%q want=context_capsule", got) + } +} + +func TestUpdateSettingsContextEnginePartialUpdatePreservesStrategy(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"],"context_engine":{"mode":"enforce","strategy":"natural_context"}}`) + payload := map[string]any{ + "context_engine": map[string]any{ + "mode": "off", + }, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateSettings(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + snap := h.Store.Snapshot() + if snap.ContextEngine.Mode != "off" { + t.Fatalf("expected context_engine.mode=off, got %#v", snap.ContextEngine) + } + if snap.ContextEngine.Strategy != "natural_context" { + t.Fatalf("expected context_engine.strategy to be preserved, got %#v", snap.ContextEngine) + } +} + +func TestUpdateSettingsContextEngineRejectsInvalidMode(t *testing.T) { + h := newAdminTestHandler(t, `{"keys":["k1"]}`) + payload := map[string]any{ + "context_engine": map[string]any{ + "mode": "maybe", + }, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPut, "/admin/settings", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateSettings(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("context_engine.mode")) { + t.Fatalf("expected context engine validation detail, got %s", rec.Body.String()) + } +} + func TestUpdateSettingsAutoDeleteMode(t *testing.T) { h := newAdminTestHandler(t, `{"keys":["k1"],"auto_delete":{"sessions":true}}`) diff --git a/internal/httpapi/admin/settings/handler_settings_parse.go b/internal/httpapi/admin/settings/handler_settings_parse.go index f3ab30cf5..63457be45 100644 --- a/internal/httpapi/admin/settings/handler_settings_parse.go +++ b/internal/httpapi/admin/settings/handler_settings_parse.go @@ -33,17 +33,18 @@ func stringFrom(v any) string { } } -func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.CurrentInputFileConfig, *config.ThinkingInjectionConfig, map[string]string, *config.LogConfig, error) { +func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.CurrentInputFileConfig, *config.ThinkingInjectionConfig, *config.ContextEngineConfig, map[string]string, *config.LogConfig, error) { var ( - adminCfg *config.AdminConfig - runtimeCfg *config.RuntimeConfig - respCfg *config.ResponsesConfig - embCfg *config.EmbeddingsConfig - autoDeleteCfg *config.AutoDeleteConfig - currentInputCfg *config.CurrentInputFileConfig - thinkingInjCfg *config.ThinkingInjectionConfig - aliasMap map[string]string - logCfg *config.LogConfig + adminCfg *config.AdminConfig + runtimeCfg *config.RuntimeConfig + respCfg *config.ResponsesConfig + embCfg *config.EmbeddingsConfig + autoDeleteCfg *config.AutoDeleteConfig + currentInputCfg *config.CurrentInputFileConfig + thinkingInjCfg *config.ThinkingInjectionConfig + contextEngineCfg *config.ContextEngineConfig + aliasMap map[string]string + logCfg *config.LogConfig ) if raw, ok := req["admin"].(map[string]any); ok { @@ -51,7 +52,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["jwt_expire_hours"]; exists { n := intFrom(v) if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.JWTExpireHours = n } @@ -63,33 +64,33 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["account_max_inflight"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxInflight = n } if v, exists := raw["account_max_queue"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.AccountMaxQueue = n } if v, exists := raw["global_max_inflight"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.GlobalMaxInflight = n } if v, exists := raw["token_refresh_interval_hours"]; exists { n := intFrom(v) if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.TokenRefreshIntervalHours = n } if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") } runtimeCfg = cfg } @@ -99,7 +100,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["store_ttl_seconds"]; exists { n := intFrom(v) if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.StoreTTLSeconds = n } @@ -111,7 +112,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["provider"]; exists { p := strings.TrimSpace(fmt.Sprintf("%v", v)) if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.Provider = p } @@ -137,7 +138,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["mode"]; exists { mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v))) if err := config.ValidateAutoDeleteMode(mode); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } if mode == "" { mode = "none" @@ -159,14 +160,14 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["min_chars"]; exists { n := intFrom(v) if err := config.ValidateIntRange("current_input_file.min_chars", n, 0, 100000000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.MinChars = n } if v, exists := raw["inline_max_tokens"]; exists { n := intFrom(v) if err := config.ValidateIntRange("current_input_file.inline_max_tokens", n, 0, 100000000, true); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } cfg.InlineMaxTokens = n } @@ -174,7 +175,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi cfg.FilenamePolicy = strings.TrimSpace(stringFrom(v)) } if err := config.ValidateCurrentInputFileConfig(*cfg); err != nil { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err } currentInputCfg = cfg } @@ -191,6 +192,20 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi thinkingInjCfg = cfg } + if raw, ok := req["context_engine"].(map[string]any); ok { + cfg := &config.ContextEngineConfig{} + if v, exists := raw["mode"]; exists { + cfg.Mode = strings.ToLower(strings.TrimSpace(stringFrom(v))) + } + if v, exists := raw["strategy"]; exists { + cfg.Strategy = config.NormalizeContextEngineStrategy(stringFrom(v)) + } + if err := config.ValidateContextEngineConfig(*cfg); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err + } + contextEngineCfg = cfg + } + if raw, ok := req["log"].(map[string]any); ok { cfg := &config.LogConfig{} if v, exists := raw["level"]; exists { @@ -201,7 +216,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi case "debug", "info", "warn", "error": cfg.Level = level default: - return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("log.level must be one of: debug, info, warn, error") + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("log.level must be one of: debug, info, warn, error") } } if v, exists := raw["file"]; exists { @@ -213,7 +228,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi if v, exists := raw["max_size_mb"]; exists { n := intFrom(v) if n > 1024 { - return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("log.max_size_mb must be <= 1024") + return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("log.max_size_mb must be <= 1024") } if n > 0 { cfg.MaxSizeMB = n @@ -228,5 +243,5 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi logCfg = cfg } - return adminCfg, runtimeCfg, respCfg, embCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, aliasMap, logCfg, nil + return adminCfg, runtimeCfg, respCfg, embCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, contextEngineCfg, aliasMap, logCfg, nil } diff --git a/internal/httpapi/admin/settings/handler_settings_write.go b/internal/httpapi/admin/settings/handler_settings_write.go index a88967e08..499d49a0c 100644 --- a/internal/httpapi/admin/settings/handler_settings_write.go +++ b/internal/httpapi/admin/settings/handler_settings_write.go @@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { return } - adminCfg, runtimeCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, aliasMap, logCfg, err := parseSettingsUpdateRequest(req) + adminCfg, runtimeCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, contextEngineCfg, aliasMap, logCfg, err := parseSettingsUpdateRequest(req) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) return @@ -34,6 +34,8 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { currentInputFilenamePolicySet := hasNestedSettingsKey(req, "current_input_file", "filename_policy") thinkingInjectionEnabledSet := hasNestedSettingsKey(req, "thinking_injection", "enabled") thinkingInjectionPromptSet := hasNestedSettingsKey(req, "thinking_injection", "prompt") + contextEngineModeSet := hasNestedSettingsKey(req, "context_engine", "mode") + contextEngineStrategySet := hasNestedSettingsKey(req, "context_engine", "strategy") logLevelSet := hasNestedSettingsKey(req, "log", "level") logFileSet := hasNestedSettingsKey(req, "log", "file") logFileEnabledSet := hasNestedSettingsKey(req, "log", "file_enabled") @@ -93,6 +95,14 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { c.ThinkingInjection.Prompt = thinkingInjCfg.Prompt } } + if contextEngineCfg != nil { + if contextEngineModeSet { + c.ContextEngine.Mode = contextEngineCfg.Mode + } + if contextEngineStrategySet { + c.ContextEngine.Strategy = contextEngineCfg.Strategy + } + } if aliasMap != nil { c.ModelAliases = aliasMap } diff --git a/webui/src/features/settings/CurrentInputFileSection.jsx b/webui/src/features/settings/CurrentInputFileSection.jsx index d4e84afba..9127bca5a 100644 --- a/webui/src/features/settings/CurrentInputFileSection.jsx +++ b/webui/src/features/settings/CurrentInputFileSection.jsx @@ -42,6 +42,43 @@ export default function CurrentInputFileSection({ t, form, setForm }) { />
{t('settings.currentInputFileHelp')}
+ + ) diff --git a/webui/src/features/settings/FeatureFlagsSection.jsx b/webui/src/features/settings/FeatureFlagsSection.jsx index c3025c2bc..d12453ab7 100644 --- a/webui/src/features/settings/FeatureFlagsSection.jsx +++ b/webui/src/features/settings/FeatureFlagsSection.jsx @@ -13,13 +13,78 @@ function ModeBadge({ mode }) { ) } -export default function FeatureFlagsSection({ t, form }) { +export default function FeatureFlagsSection({ t, form, setForm }) { + const contextEngine = form.context_engine || { mode: 'enforce', strategy: 'hybrid_recent', env_override: false } const parserV2 = form.parser_v2 || { mode: 'off', env_override: false } + const contextDisabled = Boolean(contextEngine.env_override) return ({t('settings.contextEngineEnvOverrideHelp')}
+ )} +