@@ -661,6 +661,111 @@ func (s *Server) firstGeminiAPIKey() string {
661661 return ""
662662}
663663
664+ // --- Settings page ---
665+
666+ // settingSpec describes one user-editable scalar for the Settings tab.
667+ // Stays in the handlers file so adding a new setting is a one-liner.
668+ type settingSpec struct {
669+ Key string // kv_settings key (e.g. llm.SettingKeyClassifierTimeout)
670+ Label string
671+ Description string
672+ Default string // shown in UI placeholder + as "default: X" hint
673+ InputType string // HTML <input type>; "number" for ints, "text" otherwise
674+ Value string // current DB value, empty if unset
675+ }
676+
677+ // settingSpecs returns the list of scalars exposed in the Settings tab, in
678+ // display order. Extend this slice to surface more config keys.
679+ func (s * Server ) settingSpecs () []settingSpec {
680+ defs := []settingSpec {
681+ {
682+ Key : llm .SettingKeyClassifierTimeout ,
683+ Label : "Classifier timeout (seconds)" ,
684+ Description : "Max time to wait for the classifier model per request before defaulting to level 2." ,
685+ Default : fmt .Sprintf ("%d" , s .cfgRef .Routing .ClassifierTimeout ),
686+ InputType : "number" ,
687+ },
688+ {
689+ Key : llm .SettingKeyToolFilterTopK ,
690+ Label : "Tool filter top-K" ,
691+ Description : "Number of most-relevant MCP tools to include per request (0 = disabled). Applies on next restart." ,
692+ Default : fmt .Sprintf ("%d" , s .cfgRef .ToolFilter .TopK ),
693+ InputType : "number" ,
694+ },
695+ }
696+ if s .settings != nil {
697+ ctx , cancel := context .WithTimeout (context .Background (), 2 * time .Second )
698+ defer cancel ()
699+ for i := range defs {
700+ if v , ok , _ := s .settings .GetSetting (ctx , defs [i ].Key ); ok {
701+ defs [i ].Value = v
702+ }
703+ }
704+ }
705+ return defs
706+ }
707+
708+ func (s * Server ) handleSettings (w http.ResponseWriter , r * http.Request ) {
709+ data := map [string ]any {
710+ "ActiveTab" : "settings" ,
711+ "Settings" : s .settingSpecs (),
712+ }
713+ w .Header ().Set ("Content-Type" , "text/html; charset=utf-8" )
714+ if err := render (w , viewSettings , data ); err != nil {
715+ s .logger .Error ("render settings" , "err" , err )
716+ http .Error (w , "render error" , http .StatusInternalServerError )
717+ }
718+ }
719+
720+ // handleSettingSet: POST /settings/{key}/set with body value=...
721+ // Validates the key against the allowlist derived from settingSpecs so an
722+ // attacker can't smuggle arbitrary kv_settings keys through this endpoint.
723+ func (s * Server ) handleSettingSet (w http.ResponseWriter , r * http.Request ) {
724+ if r .Method != http .MethodPost {
725+ http .Error (w , "method not allowed" , http .StatusMethodNotAllowed )
726+ return
727+ }
728+ key := strings .TrimSuffix (strings .TrimPrefix (r .URL .Path , "/settings/" ), "/set" )
729+ allowed := false
730+ for _ , sp := range s .settingSpecs () {
731+ if sp .Key == key {
732+ allowed = true
733+ break
734+ }
735+ }
736+ if ! allowed {
737+ http .Error (w , "unknown setting" , http .StatusBadRequest )
738+ return
739+ }
740+ if err := r .ParseForm (); err != nil {
741+ http .Error (w , "parse form" , http .StatusBadRequest )
742+ return
743+ }
744+ value := strings .TrimSpace (r .FormValue ("value" ))
745+ if s .settings == nil {
746+ http .Error (w , "settings store not available" , http .StatusServiceUnavailable )
747+ return
748+ }
749+ ctx , cancel := context .WithTimeout (r .Context (), 5 * time .Second )
750+ defer cancel ()
751+ if value == "" {
752+ // Empty string = revert to config default. Delete the DB key so the
753+ // GetIntSetting fallback kicks in.
754+ if err := s .settings .PutSetting (ctx , key , "" ); err != nil {
755+ s .logger .Warn ("setting delete failed" , "key" , key , "err" , err )
756+ http .Error (w , "save failed" , http .StatusInternalServerError )
757+ return
758+ }
759+ } else if err := s .settings .PutSetting (ctx , key , value ); err != nil {
760+ s .logger .Warn ("setting save failed" , "key" , key , "err" , err )
761+ http .Error (w , "save failed" , http .StatusInternalServerError )
762+ return
763+ }
764+ s .logger .Info ("setting updated" , "key" , key , "value" , value )
765+ w .Header ().Set ("Content-Type" , "text/html; charset=utf-8" )
766+ w .Write ([]byte (`<span class="save-status ok">Saved</span>` )) //nolint:errcheck
767+ }
768+
664769// handlePrompts: GET /prompts — full page with current prompt values from DB.
665770func (s * Server ) handlePrompts (w http.ResponseWriter , r * http.Request ) {
666771 type promptsData struct {
0 commit comments