diff --git a/README.md b/README.md
index c21c4545e53b..0ef44d2877a5 100644
--- a/README.md
+++ b/README.md
@@ -196,6 +196,7 @@ See the full [Backend & Model Compatibility Table](https://localai.io/model-comp
- [Build from source](https://localai.io/basics/build/)
- [Kubernetes installation](https://localai.io/basics/getting_started/#run-localai-in-kubernetes)
- [Integrations & community projects](https://localai.io/docs/integrations/)
+- [Installation video walkthrough](https://www.youtube.com/watch?v=cMVNnlqwfw4)
- [Media & blog posts](https://localai.io/basics/news/#media-blogs-social)
- [Examples](https://github.com/mudler/LocalAI-examples)
diff --git a/core/config/meta/constants.go b/core/config/meta/constants.go
index 24e24015fb49..b0633c22dfa6 100644
--- a/core/config/meta/constants.go
+++ b/core/config/meta/constants.go
@@ -49,6 +49,22 @@ var DiffusersPipelineOptions = []FieldOption{
{Value: "StableVideoDiffusionPipeline", Label: "StableVideoDiffusionPipeline"},
}
+var UsecaseOptions = []FieldOption{
+ {Value: "chat", Label: "Chat"},
+ {Value: "completion", Label: "Completion"},
+ {Value: "edit", Label: "Edit"},
+ {Value: "embeddings", Label: "Embeddings"},
+ {Value: "rerank", Label: "Rerank"},
+ {Value: "image", Label: "Image"},
+ {Value: "transcript", Label: "Transcript"},
+ {Value: "tts", Label: "TTS"},
+ {Value: "sound_generation", Label: "Sound Generation"},
+ {Value: "tokenize", Label: "Tokenize"},
+ {Value: "vad", Label: "VAD"},
+ {Value: "video", Label: "Video"},
+ {Value: "detection", Label: "Detection"},
+}
+
var DiffusersSchedulerOptions = []FieldOption{
{Value: "ddim", Label: "DDIM"},
{Value: "ddpm", Label: "DDPM"},
diff --git a/core/config/meta/registry.go b/core/config/meta/registry.go
index bebba468dc2d..99f9e0298fd6 100644
--- a/core/config/meta/registry.go
+++ b/core/config/meta/registry.go
@@ -47,8 +47,9 @@ func DefaultRegistry() map[string]FieldMetaOverride {
"known_usecases": {
Section: "general",
Label: "Known Use Cases",
- Description: "Capabilities this model supports (e.g. FLAG_CHAT, FLAG_COMPLETION)",
+ Description: "Capabilities this model supports",
Component: "string-list",
+ Options: UsecaseOptions,
Order: 6,
},
@@ -287,6 +288,15 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 72,
},
+ // --- TTS ---
+ "tts.voice": {
+ Section: "tts",
+ Label: "Voice",
+ Description: "Default voice for TTS output",
+ Component: "input",
+ Order: 90,
+ },
+
// --- Diffusers ---
"diffusers.pipeline_type": {
Section: "diffusers",
diff --git a/core/http/endpoints/localai/config_meta.go b/core/http/endpoints/localai/config_meta.go
index 22d055b999a8..0e22d534cea6 100644
--- a/core/http/endpoints/localai/config_meta.go
+++ b/core/http/endpoints/localai/config_meta.go
@@ -180,27 +180,39 @@ func PatchConfigEndpoint(cl *config.ModelConfigLoader, _ *model.ModelLoader, app
return c.JSON(http.StatusBadRequest, map[string]any{"error": "invalid JSON: " + err.Error()})
}
- existingJSON, err := json.Marshal(modelConfig)
+ // Read the raw YAML from disk rather than serializing the in-memory config.
+ // The in-memory config has SetDefaults() applied, which would persist
+ // runtime-only defaults (top_p, temperature, mirostat, etc.) to the file.
+ configPath := modelConfig.GetModelConfigFile()
+ if err := utils.VerifyPath(configPath, appConfig.SystemState.Model.ModelsPath); err != nil {
+ return c.JSON(http.StatusForbidden, map[string]any{"error": "config path not trusted: " + err.Error()})
+ }
+
+ diskYAML, err := os.ReadFile(configPath)
if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to marshal existing config"})
+ return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to read config file: " + err.Error()})
}
var existingMap map[string]any
- if err := json.Unmarshal(existingJSON, &existingMap); err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to parse existing config"})
+ if err := yaml.Unmarshal(diskYAML, &existingMap); err != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to parse existing config: " + err.Error()})
+ }
+ if existingMap == nil {
+ existingMap = map[string]any{}
}
if err := mergo.Merge(&existingMap, patchMap, mergo.WithOverride); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to merge configs: " + err.Error()})
}
- mergedJSON, err := json.Marshal(existingMap)
+ // Marshal once and reuse for both validation and writing
+ yamlData, err := yaml.Marshal(existingMap)
if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to marshal merged config"})
+ return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to marshal YAML"})
}
var updatedConfig config.ModelConfig
- if err := json.Unmarshal(mergedJSON, &updatedConfig); err != nil {
+ if err := yaml.Unmarshal(yamlData, &updatedConfig); err != nil {
return c.JSON(http.StatusBadRequest, map[string]any{"error": "merged config is invalid: " + err.Error()})
}
@@ -212,16 +224,6 @@ func PatchConfigEndpoint(cl *config.ModelConfigLoader, _ *model.ModelLoader, app
return c.JSON(http.StatusBadRequest, map[string]any{"error": errMsg})
}
- configPath := modelConfig.GetModelConfigFile()
- if err := utils.VerifyPath(configPath, appConfig.SystemState.Model.ModelsPath); err != nil {
- return c.JSON(http.StatusForbidden, map[string]any{"error": "config path not trusted: " + err.Error()})
- }
-
- yamlData, err := yaml.Marshal(updatedConfig)
- if err != nil {
- return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to marshal YAML"})
- }
-
if err := os.WriteFile(configPath, yamlData, 0644); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]any{"error": "failed to write config file"})
}
diff --git a/core/http/endpoints/localai/config_meta_test.go b/core/http/endpoints/localai/config_meta_test.go
index f68e67f7a580..f56c14b00a1e 100644
--- a/core/http/endpoints/localai/config_meta_test.go
+++ b/core/http/endpoints/localai/config_meta_test.go
@@ -239,5 +239,54 @@ backend: llama-cpp
Expect(err).NotTo(HaveOccurred())
Expect(string(data)).To(ContainSubstring("vllm"))
})
+
+ It("should not persist runtime defaults (SetDefaults values) to disk", func() {
+ // Create a minimal pipeline config - no sampling params
+ seedConfig := `name: gpt-realtime
+pipeline:
+ vad: silero-vad
+ transcription: whisper-base
+ llm: llama3
+ tts: piper
+`
+ configPath := filepath.Join(tempDir, "gpt-realtime.yaml")
+ Expect(os.WriteFile(configPath, []byte(seedConfig), 0644)).To(Succeed())
+ Expect(configLoader.LoadModelConfigsFromPath(tempDir)).To(Succeed())
+
+ // PATCH with a small change to the pipeline
+ body := bytes.NewBufferString(`{"pipeline": {"tts": "vibevoice"}}`)
+ req := httptest.NewRequest(http.MethodPatch, "/api/models/config-json/gpt-realtime", body)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+
+ // Read the file from disk and verify no spurious defaults leaked
+ data, err := os.ReadFile(configPath)
+ Expect(err).NotTo(HaveOccurred())
+ fileContent := string(data)
+
+ // The patched value should be present
+ Expect(fileContent).To(ContainSubstring("vibevoice"))
+
+ // Runtime-only defaults from SetDefaults() should NOT be in the file
+ Expect(fileContent).NotTo(ContainSubstring("top_p"))
+ Expect(fileContent).NotTo(ContainSubstring("top_k"))
+ Expect(fileContent).NotTo(ContainSubstring("temperature"))
+ Expect(fileContent).NotTo(ContainSubstring("mirostat"))
+ Expect(fileContent).NotTo(ContainSubstring("mmap"))
+ Expect(fileContent).NotTo(ContainSubstring("mmlock"))
+ Expect(fileContent).NotTo(ContainSubstring("threads"))
+ Expect(fileContent).NotTo(ContainSubstring("low_vram"))
+ Expect(fileContent).NotTo(ContainSubstring("embeddings"))
+ Expect(fileContent).NotTo(ContainSubstring("f16"))
+
+ // Original fields should still be present
+ Expect(fileContent).To(ContainSubstring("gpt-realtime"))
+ Expect(fileContent).To(ContainSubstring("silero-vad"))
+ Expect(fileContent).To(ContainSubstring("whisper-base"))
+ Expect(fileContent).To(ContainSubstring("llama3"))
+ })
})
})
diff --git a/core/http/endpoints/localai/import_model.go b/core/http/endpoints/localai/import_model.go
index a1931bae9117..9a3d61728735 100644
--- a/core/http/endpoints/localai/import_model.go
+++ b/core/http/endpoints/localai/import_model.go
@@ -119,48 +119,20 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
return c.JSON(http.StatusBadRequest, response)
}
- // Check content type to determine how to parse
+ // Detect format once and reuse for both typed and map parsing
contentType := c.Request().Header.Get("Content-Type")
- var modelConfig config.ModelConfig
+ trimmed := strings.TrimSpace(string(body))
+ isJSON := strings.Contains(contentType, "application/json") ||
+ (!strings.Contains(contentType, "yaml") && len(trimmed) > 0 && trimmed[0] == '{')
- if strings.Contains(contentType, "application/json") {
- // Parse JSON
+ var modelConfig config.ModelConfig
+ if isJSON {
if err := json.Unmarshal(body, &modelConfig); err != nil {
- response := ModelResponse{
- Success: false,
- Error: "Failed to parse JSON: " + err.Error(),
- }
- return c.JSON(http.StatusBadRequest, response)
- }
- } else if strings.Contains(contentType, "application/x-yaml") || strings.Contains(contentType, "text/yaml") {
- // Parse YAML
- if err := yaml.Unmarshal(body, &modelConfig); err != nil {
- response := ModelResponse{
- Success: false,
- Error: "Failed to parse YAML: " + err.Error(),
- }
- return c.JSON(http.StatusBadRequest, response)
+ return c.JSON(http.StatusBadRequest, ModelResponse{Success: false, Error: "Failed to parse JSON: " + err.Error()})
}
} else {
- // Try to auto-detect format
- if len(body) > 0 && strings.TrimSpace(string(body))[0] == '{' {
- // Looks like JSON
- if err := json.Unmarshal(body, &modelConfig); err != nil {
- response := ModelResponse{
- Success: false,
- Error: "Failed to parse JSON: " + err.Error(),
- }
- return c.JSON(http.StatusBadRequest, response)
- }
- } else {
- // Assume YAML
- if err := yaml.Unmarshal(body, &modelConfig); err != nil {
- response := ModelResponse{
- Success: false,
- Error: "Failed to parse YAML: " + err.Error(),
- }
- return c.JSON(http.StatusBadRequest, response)
- }
+ if err := yaml.Unmarshal(body, &modelConfig); err != nil {
+ return c.JSON(http.StatusBadRequest, ModelResponse{Success: false, Error: "Failed to parse YAML: " + err.Error()})
}
}
@@ -173,10 +145,9 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
return c.JSON(http.StatusBadRequest, response)
}
- // Set defaults
- modelConfig.SetDefaults(appConfig.ToConfigLoaderOptions()...)
-
- // Validate the configuration
+ // Validate without calling SetDefaults() — runtime defaults should not
+ // be persisted to disk. SetDefaults() is called when loading configs
+ // for inference via LoadModelConfigsFromPath().
if valid, _ := modelConfig.Validate(); !valid {
response := ModelResponse{
Success: false,
@@ -195,8 +166,21 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
return c.JSON(http.StatusBadRequest, response)
}
- // Marshal to YAML for storage
- yamlData, err := yaml.Marshal(&modelConfig)
+ // Write only the user-provided fields to disk by parsing the original
+ // body into a map (not the typed struct, which includes Go zero values).
+ var bodyMap map[string]any
+ if isJSON {
+ _ = json.Unmarshal(body, &bodyMap)
+ } else {
+ _ = yaml.Unmarshal(body, &bodyMap)
+ }
+
+ var yamlData []byte
+ if bodyMap != nil {
+ yamlData, err = yaml.Marshal(bodyMap)
+ } else {
+ yamlData, err = yaml.Marshal(&modelConfig)
+ }
if err != nil {
response := ModelResponse{
Success: false,
diff --git a/core/http/react-ui/bun.lock b/core/http/react-ui/bun.lock
index 3c866c9332fc..db2b01e5b930 100644
--- a/core/http/react-ui/bun.lock
+++ b/core/http/react-ui/bun.lock
@@ -5,7 +5,16 @@
"": {
"name": "localai-react-ui",
"dependencies": {
+ "@codemirror/autocomplete": "^6.18.6",
+ "@codemirror/commands": "^6.8.1",
+ "@codemirror/lang-yaml": "^6.1.2",
+ "@codemirror/language": "^6.11.0",
+ "@codemirror/lint": "^6.8.5",
+ "@codemirror/search": "^6.5.10",
+ "@codemirror/state": "^6.5.2",
+ "@codemirror/view": "^6.36.8",
"@fortawesome/fontawesome-free": "^6.7.2",
+ "@lezer/highlight": "^1.2.1",
"@modelcontextprotocol/ext-apps": "^1.2.2",
"@modelcontextprotocol/sdk": "^1.25.1",
"dompurify": "^3.2.5",
@@ -14,6 +23,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.1",
+ "yaml": "^2.8.3",
},
"devDependencies": {
"@eslint/js": "^9.27.0",
@@ -66,6 +76,22 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
+ "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="],
+
+ "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
+
+ "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="],
+
+ "@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
+
+ "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="],
+
+ "@codemirror/search": ["@codemirror/search@6.6.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw=="],
+
+ "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
+
+ "@codemirror/view": ["@codemirror/view@6.40.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg=="],
+
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@@ -158,6 +184,16 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="],
+
+ "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
+
+ "@lezer/lr": ["@lezer/lr@1.4.8", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA=="],
+
+ "@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="],
+
+ "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
+
"@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.2.2", "", { "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-qMnhIKb8tyPesl+kZU76Xz9Bi9putCO+LcgvBJ00fDdIniiLZsnQbAeTKoq+sTiYH1rba2Fvj8NPAFxij+gyxw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
@@ -286,6 +322,8 @@
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
+ "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
+
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -572,6 +610,8 @@
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
+ "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
+
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@@ -592,6 +632,8 @@
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
+ "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
+
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
@@ -600,6 +642,8 @@
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
+
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
diff --git a/core/http/react-ui/e2e/model-config.spec.js b/core/http/react-ui/e2e/model-config.spec.js
new file mode 100644
index 000000000000..fe9557c739ee
--- /dev/null
+++ b/core/http/react-ui/e2e/model-config.spec.js
@@ -0,0 +1,191 @@
+import { test, expect } from '@playwright/test'
+
+const MOCK_METADATA = {
+ sections: [
+ { id: 'general', label: 'General', icon: 'settings', order: 0 },
+ { id: 'parameters', label: 'Parameters', icon: 'sliders', order: 20 },
+ ],
+ fields: [
+ { path: 'name', yaml_key: 'name', go_type: 'string', ui_type: 'string', section: 'general', label: 'Model Name', description: 'Unique identifier for this model', component: 'input', order: 0 },
+ { path: 'backend', yaml_key: 'backend', go_type: 'string', ui_type: 'string', section: 'general', label: 'Backend', description: 'Inference backend to use', component: 'select', autocomplete_provider: 'backends', order: 10 },
+ { path: 'context_size', yaml_key: 'context_size', go_type: '*int', ui_type: 'int', section: 'general', label: 'Context Size', description: 'Maximum context window in tokens', component: 'number', vram_impact: true, order: 20 },
+ { path: 'cuda', yaml_key: 'cuda', go_type: 'bool', ui_type: 'bool', section: 'general', label: 'CUDA', description: 'Enable CUDA GPU acceleration', component: 'toggle', order: 30 },
+ { path: 'parameters.temperature', yaml_key: 'temperature', go_type: '*float64', ui_type: 'float', section: 'parameters', label: 'Temperature', description: 'Sampling temperature', component: 'slider', min: 0, max: 2, step: 0.1, order: 0 },
+ { path: 'parameters.top_p', yaml_key: 'top_p', go_type: '*float64', ui_type: 'float', section: 'parameters', label: 'Top P', description: 'Nucleus sampling threshold', component: 'slider', min: 0, max: 1, step: 0.05, order: 10 },
+ ],
+}
+
+// Mock raw YAML (what the edit endpoint returns) — only fields actually in the file
+const MOCK_YAML = `name: mock-model
+backend: mock-backend
+parameters:
+ model: mock-model.bin
+`
+
+const MOCK_AUTOCOMPLETE_BACKENDS = { values: ['mock-backend', 'llama-cpp', 'vllm'] }
+
+test.describe('Model Editor - Interactive Tab', () => {
+ test.beforeEach(async ({ page }) => {
+ // Mock config metadata
+ await page.route('**/api/models/config-metadata*', (route) => {
+ route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify(MOCK_METADATA),
+ })
+ })
+
+ // Mock raw YAML edit endpoint (GET for loading, POST for saving)
+ await page.route('**/api/models/edit/mock-model', (route) => {
+ if (route.request().method() === 'POST') {
+ route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'Configuration file saved' }),
+ })
+ } else {
+ route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({ config: MOCK_YAML, name: 'mock-model' }),
+ })
+ }
+ })
+
+ // Mock PATCH config-json for interactive save
+ await page.route('**/api/models/config-json/mock-model', (route) => {
+ if (route.request().method() === 'PATCH') {
+ route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({ success: true, message: "Model 'mock-model' updated successfully" }),
+ })
+ } else {
+ route.fulfill({ contentType: 'application/json', body: '{}' })
+ }
+ })
+
+ // Mock autocomplete for backends
+ await page.route('**/api/models/config-metadata/autocomplete/backends', (route) => {
+ route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify(MOCK_AUTOCOMPLETE_BACKENDS),
+ })
+ })
+
+ await page.goto('/app/model-editor/mock-model')
+ // Wait for the page to load
+ await expect(page.locator('h1', { hasText: 'Model Editor' })).toBeVisible({ timeout: 10_000 })
+ })
+
+ test('page loads and shows model name in header', async ({ page }) => {
+ await expect(page.locator('text=mock-model')).toBeVisible()
+ await expect(page.locator('h1', { hasText: 'Model Editor' })).toBeVisible()
+ })
+
+ test('interactive tab is active by default', async ({ page }) => {
+ // The field browser should be visible (interactive tab content)
+ await expect(page.locator('input[placeholder="Search fields to add..."]')).toBeVisible()
+ })
+
+ test('existing config fields from YAML are populated', async ({ page }) => {
+ // The mock YAML has name and backend — they should be active fields
+ await expect(page.locator('text=Model Name')).toBeVisible()
+ await expect(page.locator('span', { hasText: /^Backend$/ }).first()).toBeVisible()
+ })
+
+ test('section sidebar shows sections with active fields', async ({ page }) => {
+ const sidebar = page.locator('nav')
+ await expect(sidebar.locator('text=General')).toBeVisible()
+ })
+
+ test('typing in field browser shows matching fields', async ({ page }) => {
+ const searchInput = page.locator('input[placeholder="Search fields to add..."]')
+ await searchInput.fill('Temperature')
+ await expect(page.locator('text=Temperature').first()).toBeVisible()
+ })
+
+ test('clicking a field result adds it to the config', async ({ page }) => {
+ const searchInput = page.locator('input[placeholder="Search fields to add..."]')
+ await searchInput.fill('Temperature')
+ const dropdown = searchInput.locator('..').locator('..')
+ await dropdown.locator('div', { hasText: 'Temperature' }).first().click()
+ await expect(page.locator('h3', { hasText: 'Parameters' })).toBeVisible()
+ })
+
+ test('toggle field renders a toggle switch', async ({ page }) => {
+ const searchInput = page.locator('input[placeholder="Search fields to add..."]')
+ await searchInput.fill('CUDA')
+ const dropdown = searchInput.locator('..').locator('..')
+ await dropdown.locator('div', { hasText: 'CUDA' }).first().click()
+ await expect(page.locator('text=CUDA').first()).toBeVisible()
+ const cudaSection = page.locator('div', { has: page.locator('span', { hasText: /^CUDA$/ }) }).first()
+ await expect(cudaSection.locator('input[type="checkbox"]')).toHaveCount(1)
+ })
+
+ test('number field renders a numeric input', async ({ page }) => {
+ const searchInput = page.locator('input[placeholder="Search fields to add..."]')
+ await searchInput.fill('Context Size')
+ const dropdown = searchInput.locator('..').locator('..')
+ await dropdown.locator('div', { hasText: 'Context Size' }).first().click()
+ await expect(page.locator('input[type="number"]')).toBeVisible()
+ })
+
+ test('changing a field value enables the Save button', async ({ page }) => {
+ const searchInput = page.locator('input[placeholder="Search fields to add..."]')
+ await searchInput.fill('Context Size')
+ const dropdown = searchInput.locator('..').locator('..')
+ await dropdown.locator('div', { hasText: 'Context Size' }).first().click()
+ const numberInput = page.locator('input[type="number"]')
+ await numberInput.fill('4096')
+ await expect(page.locator('button', { hasText: 'Save Changes' })).toBeVisible()
+ })
+
+ test('removing a field with X button removes it from the form', async ({ page }) => {
+ const searchInput = page.locator('input[placeholder="Search fields to add..."]')
+ await searchInput.fill('Temperature')
+ const dropdown = searchInput.locator('..').locator('..')
+ await dropdown.locator('div', { hasText: 'Temperature' }).first().click()
+ const paramsHeader = page.locator('h3', { hasText: 'Parameters' })
+ await expect(paramsHeader).toBeVisible()
+ const paramsSection = paramsHeader.locator('..')
+ await paramsSection.locator('button[title="Remove field"]').first().click()
+ await expect(paramsHeader).not.toBeVisible()
+ })
+
+ test('save sends PATCH and shows success toast', async ({ page }) => {
+ const searchInput = page.locator('input[placeholder="Search fields to add..."]')
+ await searchInput.fill('Context Size')
+ const dropdown = searchInput.locator('..').locator('..')
+ await dropdown.locator('div', { hasText: 'Context Size' }).first().click()
+ const numberInput = page.locator('input[type="number"]')
+ await numberInput.fill('8192')
+ await page.locator('button', { hasText: 'Save Changes' }).click()
+ await expect(page.locator('text=Configuration saved')).toBeVisible({ timeout: 5_000 })
+ })
+
+ test('added field is no longer shown in field browser results', async ({ page }) => {
+ const searchInput = page.locator('input[placeholder="Search fields to add..."]')
+ await searchInput.fill('Temperature')
+ const dropdown = searchInput.locator('..').locator('..')
+ await dropdown.locator('div', { hasText: 'Temperature' }).first().click()
+ await searchInput.fill('Temperature')
+ await page.waitForTimeout(200)
+ const results = dropdown.locator('div[style*="cursor: pointer"]', { hasText: 'Temperature' })
+ await expect(results).toHaveCount(0)
+ })
+
+ test('switching to YAML tab shows code editor', async ({ page }) => {
+ await page.locator('button', { hasText: 'YAML' }).click()
+ // The CodeMirror editor should be visible
+ await expect(page.locator('.cm-editor').first()).toBeVisible()
+ // The field browser should NOT be visible
+ await expect(page.locator('input[placeholder="Search fields to add..."]')).not.toBeVisible()
+ })
+
+ test('switching back to Interactive tab restores fields', async ({ page }) => {
+ // Go to YAML tab
+ await page.locator('button', { hasText: 'YAML' }).click()
+ await expect(page.locator('input[placeholder="Search fields to add..."]')).not.toBeVisible()
+ // Go back to Interactive tab
+ await page.locator('button', { hasText: 'Interactive' }).click()
+ await expect(page.locator('input[placeholder="Search fields to add..."]')).toBeVisible()
+ await expect(page.locator('text=Model Name')).toBeVisible()
+ })
+})
diff --git a/core/http/react-ui/package.json b/core/http/react-ui/package.json
index 5d3e6658393c..2e3368973579 100644
--- a/core/http/react-ui/package.json
+++ b/core/http/react-ui/package.json
@@ -20,7 +20,17 @@
"dompurify": "^3.2.5",
"@fortawesome/fontawesome-free": "^6.7.2",
"@modelcontextprotocol/sdk": "^1.25.1",
- "@modelcontextprotocol/ext-apps": "^1.2.2"
+ "@modelcontextprotocol/ext-apps": "^1.2.2",
+ "yaml": "^2.8.3",
+ "@codemirror/autocomplete": "^6.18.6",
+ "@codemirror/commands": "^6.8.1",
+ "@codemirror/lang-yaml": "^6.1.2",
+ "@codemirror/language": "^6.11.0",
+ "@codemirror/lint": "^6.8.5",
+ "@codemirror/search": "^6.5.10",
+ "@codemirror/state": "^6.5.2",
+ "@codemirror/view": "^6.36.8",
+ "@lezer/highlight": "^1.2.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.5.2",
diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css
index 7e741d73a3dc..67703d2d250e 100644
--- a/core/http/react-ui/src/App.css
+++ b/core/http/react-ui/src/App.css
@@ -904,39 +904,16 @@
box-shadow: 0 0 0 2px var(--color-primary-light);
}
-/* Code editor (syntax-highlighted textarea overlay) */
-.code-editor-highlight .hljs {
- background: transparent;
- padding: 0;
+/* CodeMirror editor wrapper */
+.code-editor-cm .cm-editor {
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
}
-.code-editor-wrapper textarea:focus {
+.code-editor-cm .cm-editor.cm-focused {
border-color: var(--color-border-strong);
+ outline: none;
}
-/* highlight.js YAML syntax colours – dark theme */
-[data-theme="dark"] .hljs-attr { color: #7dd3fc; }
-[data-theme="dark"] .hljs-string { color: #6ee7b7; }
-[data-theme="dark"] .hljs-number { color: #fcd34d; }
-[data-theme="dark"] .hljs-literal { color: #f9a8d4; }
-[data-theme="dark"] .hljs-keyword { color: #c4b5fd; }
-[data-theme="dark"] .hljs-comment { color: #64748b; font-style: italic; }
-[data-theme="dark"] .hljs-meta { color: #94a3b8; }
-[data-theme="dark"] .hljs-bullet { color: #38bdf8; }
-[data-theme="dark"] .hljs-section { color: #a78bfa; font-weight: 600; }
-[data-theme="dark"] .hljs-type { color: #f472b6; }
-
-/* highlight.js YAML syntax colours – light theme */
-[data-theme="light"] .hljs-attr { color: #0369a1; }
-[data-theme="light"] .hljs-string { color: #15803d; }
-[data-theme="light"] .hljs-number { color: #b45309; }
-[data-theme="light"] .hljs-literal { color: #be185d; }
-[data-theme="light"] .hljs-keyword { color: #7c3aed; }
-[data-theme="light"] .hljs-comment { color: #94a3b8; font-style: italic; }
-[data-theme="light"] .hljs-meta { color: #64748b; }
-[data-theme="light"] .hljs-bullet { color: #0284c7; }
-[data-theme="light"] .hljs-section { color: #6d28d9; font-weight: 600; }
-[data-theme="light"] .hljs-type { color: #db2777; }
-
/* Form groups */
.form-group {
margin-bottom: var(--spacing-md);
diff --git a/core/http/react-ui/src/components/AutocompleteInput.jsx b/core/http/react-ui/src/components/AutocompleteInput.jsx
new file mode 100644
index 000000000000..3eb1af8939d7
--- /dev/null
+++ b/core/http/react-ui/src/components/AutocompleteInput.jsx
@@ -0,0 +1,138 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { useAutocomplete } from '../hooks/useAutocomplete'
+
+export default function AutocompleteInput({ value, onChange, provider, placeholder = 'Type or select...', style }) {
+ const { values, loading } = useAutocomplete(provider)
+ const [query, setQuery] = useState('')
+ const [open, setOpen] = useState(false)
+ const [focusIndex, setFocusIndex] = useState(-1)
+ const wrapperRef = useRef(null)
+ const listRef = useRef(null)
+
+ useEffect(() => {
+ setQuery(value || '')
+ }, [value])
+
+ useEffect(() => {
+ const handler = (e) => {
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target)) setOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [])
+
+ const filtered = values.filter(v =>
+ v.toLowerCase().includes(query.toLowerCase())
+ )
+
+ const enterTargetIndex = focusIndex >= 0 ? focusIndex
+ : filtered.length > 0 ? 0
+ : -1
+
+ const commit = useCallback((val) => {
+ setQuery(val)
+ onChange(val)
+ setOpen(false)
+ setFocusIndex(-1)
+ }, [onChange])
+
+ const handleKeyDown = (e) => {
+ if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
+ setOpen(true)
+ return
+ }
+ if (!open && e.key === 'Enter') {
+ e.preventDefault()
+ commit(query)
+ return
+ }
+ if (!open) return
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ setFocusIndex(i => Math.min(i + 1, filtered.length - 1))
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ setFocusIndex(i => Math.max(i - 1, 0))
+ } else if (e.key === 'Enter') {
+ e.preventDefault()
+ if (enterTargetIndex >= 0) {
+ commit(filtered[enterTargetIndex])
+ } else {
+ commit(query)
+ }
+ } else if (e.key === 'Escape') {
+ setOpen(false)
+ setFocusIndex(-1)
+ }
+ }
+
+ useEffect(() => {
+ if (focusIndex >= 0 && listRef.current) {
+ const item = listRef.current.children[focusIndex]
+ if (item) item.scrollIntoView({ block: 'nearest' })
+ }
+ }, [focusIndex])
+
+ return (
+
+
{
+ setQuery(e.target.value)
+ setOpen(true)
+ setFocusIndex(-1)
+ onChange(e.target.value)
+ }}
+ onFocus={() => setOpen(true)}
+ onKeyDown={handleKeyDown}
+ placeholder={loading ? 'Loading...' : placeholder}
+ style={{ width: '100%', fontSize: '0.8125rem' }}
+ />
+ {open && !loading && filtered.length > 0 && (
+
+ {filtered.map((v, i) => {
+ const isEnterTarget = i === enterTargetIndex
+ return (
+
setFocusIndex(i)}
+ onMouseDown={(e) => {
+ e.preventDefault()
+ commit(v)
+ }}
+ >
+ {v}
+ {isEnterTarget && (
+ ↵
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/CodeEditor.jsx b/core/http/react-ui/src/components/CodeEditor.jsx
index 877fdd428bce..ba0244f5bba7 100644
--- a/core/http/react-ui/src/components/CodeEditor.jsx
+++ b/core/http/react-ui/src/components/CodeEditor.jsx
@@ -1,111 +1,99 @@
-import { useRef, useEffect, useCallback } from 'react'
-import hljs from 'highlight.js/lib/core'
-import yaml from 'highlight.js/lib/languages/yaml'
+import { useRef, useMemo } from 'react'
+import { keymap, lineNumbers, highlightActiveLineGutter, highlightActiveLine, drawSelection } from '@codemirror/view'
+import { EditorView } from '@codemirror/view'
+import { EditorState } from '@codemirror/state'
+import { yaml } from '@codemirror/lang-yaml'
+import { autocompletion } from '@codemirror/autocomplete'
+import { linter, lintGutter } from '@codemirror/lint'
+import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
+import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
+import { indentOnInput, indentUnit, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language'
+import YAML from 'yaml'
+import { useCodeMirror } from '../hooks/useCodeMirror'
+import { useTheme } from '../contexts/ThemeContext'
+import { getThemeExtension } from '../utils/cmTheme'
+import { createYamlCompletionSource } from '../utils/cmYamlComplete'
-hljs.registerLanguage('yaml', yaml)
+function yamlIssueToDiagnostic(issue, cmDoc, severity) {
+ const len = cmDoc.length
+ if (issue.linePos && issue.linePos[0]) {
+ const startLine = Math.min(issue.linePos[0].line, cmDoc.lines)
+ const from = cmDoc.line(startLine).from + issue.linePos[0].col - 1
+ let to = from + 1
+ if (issue.linePos[1]) {
+ const endLine = Math.min(issue.linePos[1].line, cmDoc.lines)
+ to = cmDoc.line(endLine).from + issue.linePos[1].col - 1
+ }
+ return { from: Math.min(from, len), to: Math.min(Math.max(to, from + 1), len), severity, message: issue.message.split('\n')[0] }
+ }
+ return { from: 0, to: Math.min(1, len), severity, message: issue.message.split('\n')[0] }
+}
-export default function CodeEditor({ value, onChange, disabled, minHeight = '500px' }) {
- const codeRef = useRef(null)
- const textareaRef = useRef(null)
- const preRef = useRef(null)
+const yamlLinter = linter(view => {
+ const text = view.state.doc.toString()
+ if (!text.trim()) return []
+ const parsed = YAML.parseDocument(text, { strict: true, prettyErrors: true })
+ const diagnostics = []
+ for (const err of parsed.errors) {
+ diagnostics.push(yamlIssueToDiagnostic(err, view.state.doc, 'error'))
+ }
+ for (const warn of parsed.warnings) {
+ diagnostics.push(yamlIssueToDiagnostic(warn, view.state.doc, 'warning'))
+ }
+ return diagnostics
+})
- const highlight = useCallback(() => {
- if (!codeRef.current) return
- const result = hljs.highlight(value + '\n', { language: 'yaml', ignoreIllegals: true })
- codeRef.current.innerHTML = result.value
- }, [value])
+export default function CodeEditor({ value, onChange, disabled, minHeight = '500px', fields }) {
+ const containerRef = useRef(null)
+ const { theme } = useTheme()
- useEffect(() => {
- highlight()
- }, [highlight])
+ // Static extensions — only recreate when fields change
+ const extensions = useMemo(() => {
+ const exts = [
+ yaml(),
+ lineNumbers(),
+ highlightActiveLineGutter(),
+ highlightActiveLine(),
+ drawSelection(),
+ foldGutter(),
+ indentOnInput(),
+ bracketMatching(),
+ highlightSelectionMatches(),
+ yamlLinter,
+ lintGutter(),
+ history(),
+ indentUnit.of(' '),
+ EditorState.tabSize.of(2),
+ keymap.of([
+ indentWithTab,
+ ...defaultKeymap,
+ ...historyKeymap,
+ ...searchKeymap,
+ ...foldKeymap,
+ ]),
+ EditorView.theme({
+ '&': { minHeight },
+ '.cm-scroller': { overflow: 'auto' },
+ }),
+ ]
- const handleScroll = () => {
- if (preRef.current && textareaRef.current) {
- preRef.current.scrollTop = textareaRef.current.scrollTop
- preRef.current.scrollLeft = textareaRef.current.scrollLeft
+ if (fields && fields.length > 0) {
+ exts.push(autocompletion({
+ override: [createYamlCompletionSource(fields)],
+ activateOnTyping: true,
+ }))
}
- }
- const handleKeyDown = (e) => {
- if (e.key === 'Tab') {
- e.preventDefault()
- const ta = e.target
- const start = ta.selectionStart
- const end = ta.selectionEnd
- const newValue = value.substring(0, start) + ' ' + value.substring(end)
- onChange(newValue)
- requestAnimationFrame(() => {
- ta.selectionStart = ta.selectionEnd = start + 2
- })
- }
- }
+ return exts
+ }, [minHeight, fields])
+
+ // Dynamic extensions — reconfigured via Compartments (preserves undo/cursor/scroll)
+ const dynamicExtensions = useMemo(() => ({
+ theme: getThemeExtension(theme),
+ readOnly: [EditorState.readOnly.of(!!disabled), EditorView.editable.of(!disabled)],
+ }), [theme, disabled])
+
+ useCodeMirror({ containerRef, value, onChange, extensions, dynamicExtensions })
- return (
-
- )
+ return
}
diff --git a/core/http/react-ui/src/components/ConfigFieldRenderer.jsx b/core/http/react-ui/src/components/ConfigFieldRenderer.jsx
new file mode 100644
index 000000000000..29bb3b190fa8
--- /dev/null
+++ b/core/http/react-ui/src/components/ConfigFieldRenderer.jsx
@@ -0,0 +1,373 @@
+import { useState } from 'react'
+import SettingRow from './SettingRow'
+import Toggle from './Toggle'
+import SearchableSelect from './SearchableSelect'
+import SearchableModelSelect from './SearchableModelSelect'
+import AutocompleteInput from './AutocompleteInput'
+import CodeEditor from './CodeEditor'
+
+// Map autocomplete provider to SearchableModelSelect capability
+const PROVIDER_TO_CAPABILITY = {
+ 'models:chat': 'FLAG_CHAT',
+ 'models:tts': 'FLAG_TTS',
+ 'models:transcript': 'FLAG_TRANSCRIPT',
+ 'models:vad': 'FLAG_VAD',
+}
+
+function coerceValue(raw, uiType) {
+ if (raw === '' || raw === null || raw === undefined) return raw
+ if (uiType === 'int') return parseInt(raw, 10) || 0
+ if (uiType === 'float') return parseFloat(raw) || 0
+ return raw
+}
+
+function StringListEditor({ value, onChange, options }) {
+ const items = Array.isArray(value) ? value : []
+
+ const update = (index, val) => {
+ const next = [...items]
+ next[index] = val
+ onChange(next)
+ }
+ const add = () => onChange([...items, ''])
+ const remove = (index) => onChange(items.filter((_, i) => i !== index))
+
+ // When options are available, filter out already-selected values
+ const availableOptions = options
+ ? options.filter(o => !items.includes(o.value))
+ : null
+
+ return (
+
+ {items.map((item, i) => (
+
+ {options ? (
+ update(i, val)}
+ options={[
+ // Include the current value so it shows as selected
+ ...(item ? [options.find(o => o.value === item) || { value: item, label: item }] : []),
+ ...availableOptions,
+ ]}
+ placeholder="Select..."
+ style={{ flex: 1 }}
+ />
+ ) : (
+ update(i, e.target.value)}
+ style={{ flex: 1, fontSize: '0.8125rem' }} />
+ )}
+
+
+ ))}
+ {(!options || availableOptions.length > 0) && (
+
+ )}
+
+ )
+}
+
+function MapEditor({ value, onChange }) {
+ const entries = value && typeof value === 'object' && !Array.isArray(value)
+ ? Object.entries(value) : []
+
+ const update = (index, key, val) => {
+ const next = [...entries]
+ next[index] = [key, val]
+ onChange(Object.fromEntries(next))
+ }
+ const add = () => onChange({ ...value, '': '' })
+ const remove = (index) => {
+ const next = entries.filter((_, i) => i !== index)
+ onChange(Object.fromEntries(next))
+ }
+
+ return (
+
+ )
+}
+
+function JsonEditor({ value, onChange }) {
+ const [text, setText] = useState(() =>
+ typeof value === 'string' ? value : JSON.stringify(value, null, 2) || ''
+ )
+ const [parseError, setParseError] = useState(null)
+
+ const handleChange = (val) => {
+ setText(val)
+ try {
+ const parsed = JSON.parse(val)
+ setParseError(null)
+ onChange(parsed)
+ } catch {
+ setParseError('Invalid JSON')
+ }
+ }
+
+ return (
+
+ )
+}
+
+function FieldLabel({ field }) {
+ return (
+
+ {field.label}
+ {field.vram_impact && (
+
+ VRAM
+
+ )}
+ {field.advanced && (
+
+ Advanced
+
+ )}
+
+ )
+}
+
+export default function ConfigFieldRenderer({ field, value, onChange, onRemove, annotation }) {
+ const handleChange = (raw) => {
+ onChange(coerceValue(raw, field.ui_type))
+ }
+
+ const removeBtn = (
+
+ )
+
+ const description = (
+
+ {field.description || field.path}
+ {removeBtn}
+
+ )
+
+ const component = field.component
+
+ // Toggle
+ if (component === 'toggle') {
+ return (
+ } description={description}>
+
+
+ )
+ }
+
+ // Model-select
+ if (component === 'model-select') {
+ const cap = PROVIDER_TO_CAPABILITY[field.autocomplete_provider] || undefined
+ return (
+ } description={description}>
+
+
+ )
+ }
+
+ // Select with autocomplete provider (dynamic)
+ if ((component === 'select' || component === 'input') && field.autocomplete_provider) {
+ return (
+ } description={description}>
+
+
+ )
+ }
+
+ // Select with static options
+ if (component === 'select' && field.options?.length > 0) {
+ return (
+ } description={description}>
+ ({ value: o.value, label: o.label }))}
+ placeholder={field.placeholder || 'Select...'}
+ style={{ width: 220 }}
+ />
+
+ )
+ }
+
+ // Slider
+ if (component === 'slider') {
+ const min = field.min ?? 0
+ const max = field.max ?? 1
+ const step = field.step ?? 0.1
+ return (
+ } description={description}>
+
+ handleChange(parseFloat(e.target.value))}
+ style={{ width: 120 }}
+ />
+
+ {value ?? min}
+
+
+
+ )
+ }
+
+ // Number
+ if (component === 'number') {
+ return (
+ } description={description}>
+ <>
+ handleChange(e.target.value)}
+ min={field.min} max={field.max} step={field.step}
+ placeholder={field.placeholder}
+ style={{ width: 120, fontSize: '0.8125rem' }}
+ />
+ {annotation}
+ >
+
+ )
+ }
+
+ // Textarea
+ if (component === 'textarea') {
+ return (
+
+ )
+ }
+
+ // Code editor
+ if (component === 'code-editor') {
+ return (
+
+ )
+ }
+
+ // String list
+ if (component === 'string-list') {
+ return (
+
+
+
0 ? field.options : null} />
+
+ )
+ }
+
+ // JSON editor
+ if (component === 'json-editor') {
+ return (
+
+ )
+ }
+
+ // Map editor
+ if (component === 'map-editor') {
+ return (
+
+ )
+ }
+
+ // Default: text input
+ return (
+ } description={description}>
+ handleChange(e.target.value)}
+ placeholder={field.placeholder}
+ style={{ width: 220, fontSize: '0.8125rem' }}
+ />
+
+ )
+}
diff --git a/core/http/react-ui/src/components/FieldBrowser.jsx b/core/http/react-ui/src/components/FieldBrowser.jsx
new file mode 100644
index 000000000000..882482f54ebe
--- /dev/null
+++ b/core/http/react-ui/src/components/FieldBrowser.jsx
@@ -0,0 +1,172 @@
+import { useState, useEffect, useRef, useMemo } from 'react'
+
+export default function FieldBrowser({ fields, activeFieldPaths, onAddField }) {
+ const [query, setQuery] = useState('')
+ const [open, setOpen] = useState(false)
+ const [focusIndex, setFocusIndex] = useState(-1)
+ const wrapperRef = useRef(null)
+ const listRef = useRef(null)
+
+ useEffect(() => {
+ const handler = (e) => {
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target)) setOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [])
+
+ const available = useMemo(() =>
+ fields.filter(f => !activeFieldPaths.has(f.path)),
+ [fields, activeFieldPaths]
+ )
+
+ const filtered = useMemo(() => {
+ if (!query) return available.slice(0, 30)
+ const q = query.toLowerCase()
+ return available.filter(f =>
+ f.label.toLowerCase().includes(q) ||
+ f.path.toLowerCase().includes(q) ||
+ (f.description || '').toLowerCase().includes(q) ||
+ f.section.toLowerCase().includes(q)
+ ).slice(0, 30)
+ }, [available, query])
+
+ const enterTargetIndex = focusIndex >= 0 ? focusIndex
+ : filtered.length > 0 ? 0
+ : -1
+
+ const handleSelect = (field) => {
+ onAddField(field)
+ setQuery('')
+ setOpen(false)
+ setFocusIndex(-1)
+ }
+
+ const handleKeyDown = (e) => {
+ if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
+ setOpen(true)
+ return
+ }
+ if (!open) return
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ setFocusIndex(i => Math.min(i + 1, filtered.length - 1))
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ setFocusIndex(i => Math.max(i - 1, 0))
+ } else if (e.key === 'Enter') {
+ e.preventDefault()
+ if (enterTargetIndex >= 0) {
+ handleSelect(filtered[enterTargetIndex])
+ }
+ } else if (e.key === 'Escape') {
+ setOpen(false)
+ setFocusIndex(-1)
+ }
+ }
+
+ useEffect(() => {
+ if (focusIndex >= 0 && listRef.current) {
+ const item = listRef.current.children[focusIndex]
+ if (item) item.scrollIntoView({ block: 'nearest' })
+ }
+ }, [focusIndex])
+
+ const sectionColors = {
+ general: 'var(--color-primary)',
+ llm: 'var(--color-accent)',
+ parameters: 'var(--color-success)',
+ templates: 'var(--color-warning)',
+ functions: 'var(--color-info, var(--color-primary))',
+ reasoning: 'var(--color-accent)',
+ diffusers: 'var(--color-warning)',
+ tts: 'var(--color-success)',
+ pipeline: 'var(--color-accent)',
+ grpc: 'var(--color-text-muted)',
+ agent: 'var(--color-primary)',
+ mcp: 'var(--color-accent)',
+ other: 'var(--color-text-muted)',
+ }
+
+ return (
+
+
+
+ { setQuery(e.target.value); setOpen(true); setFocusIndex(-1) }}
+ onFocus={() => setOpen(true)}
+ onKeyDown={handleKeyDown}
+ placeholder="Search fields to add..."
+ style={{ width: '100%', paddingLeft: 32, fontSize: '0.8125rem' }}
+ />
+
+ {open && (
+
+ {filtered.length === 0 ? (
+
+ {query ? 'No matching fields' : 'All fields are already configured'}
+
+ ) : (
+ filtered.map((field, i) => {
+ const isEnterTarget = i === enterTargetIndex
+ const isFocused = i === focusIndex || isEnterTarget
+ return (
+
setFocusIndex(i)}
+ onMouseDown={(e) => {
+ e.preventDefault()
+ handleSelect(field)
+ }}
+ >
+
+
+ {field.section}
+
+ {field.label}
+ {isEnterTarget && (
+ ↵
+ )}
+
+ {field.description && (
+
+ {field.description}
+
+ )}
+
+ {field.path}
+
+
+ )
+ })
+ )}
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/TemplateSelector.jsx b/core/http/react-ui/src/components/TemplateSelector.jsx
new file mode 100644
index 000000000000..d0ae043b0a40
--- /dev/null
+++ b/core/http/react-ui/src/components/TemplateSelector.jsx
@@ -0,0 +1,61 @@
+import MODEL_TEMPLATES from '../utils/modelTemplates'
+
+export default function TemplateSelector({ onSelect }) {
+ return (
+
+
+ Choose a template to get started. You can add or remove fields in the next step.
+
+
+ {MODEL_TEMPLATES.map(t => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/hooks/useAgentChat.js b/core/http/react-ui/src/hooks/useAgentChat.js
index 30ff5be258f9..27511b2777a0 100644
--- a/core/http/react-ui/src/hooks/useAgentChat.js
+++ b/core/http/react-ui/src/hooks/useAgentChat.js
@@ -1,8 +1,8 @@
-import { useState, useCallback, useRef, useEffect } from 'react'
+import { useState, useCallback, useEffect } from 'react'
import { generateId } from '../utils/format'
+import { useDebouncedEffect } from './useDebounce'
const STORAGE_KEY_PREFIX = 'localai_agent_chats_'
-const SAVE_DEBOUNCE_MS = 500
function storageKey(agentName) {
return STORAGE_KEY_PREFIX + agentName
@@ -67,24 +67,9 @@ export function useAgentChat(agentName) {
return conversations[0]?.id
})
- const saveTimerRef = useRef(null)
-
const activeConversation = conversations.find(c => c.id === activeId) || conversations[0]
- // Debounced save
- const debouncedSave = useCallback(() => {
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
- saveTimerRef.current = setTimeout(() => {
- saveConversations(agentName, conversations, activeId)
- }, SAVE_DEBOUNCE_MS)
- }, [agentName, conversations, activeId])
-
- useEffect(() => {
- debouncedSave()
- return () => {
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
- }
- }, [conversations, activeId, debouncedSave])
+ useDebouncedEffect(() => saveConversations(agentName, conversations, activeId), [agentName, conversations, activeId])
// Save immediately on unmount
useEffect(() => {
diff --git a/core/http/react-ui/src/hooks/useAutocomplete.js b/core/http/react-ui/src/hooks/useAutocomplete.js
new file mode 100644
index 000000000000..6934a9272fe7
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useAutocomplete.js
@@ -0,0 +1,47 @@
+import { useState, useEffect } from 'react'
+import { modelsApi } from '../utils/api'
+
+// Module-level cache so each provider is fetched once per page load
+const cache = {}
+
+// Shared fetch-with-cache for use outside React hooks (e.g. CodeMirror completions)
+export async function fetchCachedAutocomplete(provider) {
+ if (cache[provider]) return cache[provider].values
+ try {
+ const data = await modelsApi.getAutocomplete(provider)
+ const vals = data?.values || []
+ cache[provider] = { values: vals }
+ return vals
+ } catch {
+ return []
+ }
+}
+
+export function useAutocomplete(provider) {
+ const [values, setValues] = useState(cache[provider]?.values || [])
+ const [loading, setLoading] = useState(!cache[provider])
+
+ useEffect(() => {
+ if (!provider) {
+ setValues([])
+ setLoading(false)
+ return
+ }
+ if (cache[provider]) {
+ setValues(cache[provider].values)
+ setLoading(false)
+ return
+ }
+ setLoading(true)
+ modelsApi.getAutocomplete(provider)
+ .then(data => {
+ const vals = data?.values || []
+ cache[provider] = { values: vals }
+ setValues(vals)
+ })
+ .catch(() => setValues([]))
+ .finally(() => setLoading(false))
+ }, [provider])
+
+ return { values, loading }
+}
diff --git a/core/http/react-ui/src/hooks/useChat.js b/core/http/react-ui/src/hooks/useChat.js
index fe4c0ac7d180..3c82c12b0ca2 100644
--- a/core/http/react-ui/src/hooks/useChat.js
+++ b/core/http/react-ui/src/hooks/useChat.js
@@ -1,6 +1,7 @@
-import { useState, useCallback, useRef, useEffect } from 'react'
+import { useState, useCallback, useRef } from 'react'
import { API_CONFIG } from '../utils/config'
import { apiUrl } from '../utils/basePath'
+import { useDebouncedEffect } from './useDebounce'
const thinkingTagRegex = /([\s\S]*?)<\/thinking>|([\s\S]*?)<\/think>|<\|channel>thought([\s\S]*?)/g
const openThinkTagRegex = /||<\|channel>thought/
@@ -33,7 +34,6 @@ function extractThinking(text) {
import { generateId } from '../utils/format'
const CHATS_STORAGE_KEY = 'localai_chats_data'
-const SAVE_DEBOUNCE_MS = 500
function loadChats() {
try {
@@ -123,24 +123,13 @@ export function useChat(initialModel = '') {
const [tokensPerSecond, setTokensPerSecond] = useState(null)
const [maxTokensPerSecond, setMaxTokensPerSecond] = useState(null)
const abortControllerRef = useRef(null)
- const saveTimerRef = useRef(null)
const startTimeRef = useRef(null)
const tokenCountRef = useRef(0)
const maxTpsRef = useRef(0)
const activeChat = chats.find(c => c.id === activeChatId) || chats[0]
- // Debounced save
- const debouncedSave = useCallback(() => {
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
- saveTimerRef.current = setTimeout(() => {
- saveChats(chats, activeChatId)
- }, SAVE_DEBOUNCE_MS)
- }, [chats, activeChatId])
-
- useEffect(() => {
- debouncedSave()
- }, [chats, activeChatId, debouncedSave])
+ useDebouncedEffect(() => saveChats(chats, activeChatId), [chats, activeChatId])
const addChat = useCallback((model = '', systemPrompt = '', mcpMode = false) => {
const chat = createNewChat(model, systemPrompt, mcpMode)
diff --git a/core/http/react-ui/src/hooks/useCodeMirror.js b/core/http/react-ui/src/hooks/useCodeMirror.js
new file mode 100644
index 000000000000..84d601c82cc2
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useCodeMirror.js
@@ -0,0 +1,79 @@
+import { useRef, useEffect } from 'react'
+import { EditorView } from '@codemirror/view'
+import { EditorState, Compartment } from '@codemirror/state'
+
+export function useCodeMirror({ containerRef, value, onChange, extensions = [], dynamicExtensions = {} }) {
+ const viewRef = useRef(null)
+ const onChangeRef = useRef(onChange)
+ const isExternalUpdate = useRef(false)
+ const compartmentsRef = useRef({})
+
+ onChangeRef.current = onChange
+
+ // Create editor on mount (only depends on container and static extensions)
+ useEffect(() => {
+ if (!containerRef.current) return
+
+ const listener = EditorView.updateListener.of(update => {
+ if (update.docChanged && !isExternalUpdate.current) {
+ onChangeRef.current(update.state.doc.toString())
+ }
+ })
+
+ // Create compartments for each dynamic extension key
+ const compartments = {}
+ const compartmentExts = []
+ for (const [key, ext] of Object.entries(dynamicExtensions)) {
+ compartments[key] = new Compartment()
+ compartmentExts.push(compartments[key].of(ext))
+ }
+ compartmentsRef.current = compartments
+
+ const state = EditorState.create({
+ doc: value,
+ extensions: [...extensions, ...compartmentExts, listener],
+ })
+
+ const view = new EditorView({ state, parent: containerRef.current })
+ viewRef.current = view
+
+ return () => {
+ view.destroy()
+ viewRef.current = null
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [containerRef, extensions])
+
+ // Reconfigure dynamic extensions without recreating the editor
+ useEffect(() => {
+ const view = viewRef.current
+ if (!view) return
+ const effects = []
+ for (const [key, ext] of Object.entries(dynamicExtensions)) {
+ const compartment = compartmentsRef.current[key]
+ if (compartment) {
+ effects.push(compartment.reconfigure(ext))
+ }
+ }
+ if (effects.length > 0) {
+ view.dispatch({ effects })
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dynamicExtensions])
+
+ // Sync external value changes into CM6
+ useEffect(() => {
+ const view = viewRef.current
+ if (!view) return
+ const current = view.state.doc.toString()
+ if (value !== current) {
+ isExternalUpdate.current = true
+ view.dispatch({
+ changes: { from: 0, to: current.length, insert: value },
+ })
+ isExternalUpdate.current = false
+ }
+ }, [value])
+
+ return { view: viewRef }
+}
diff --git a/core/http/react-ui/src/hooks/useConfigMetadata.js b/core/http/react-ui/src/hooks/useConfigMetadata.js
new file mode 100644
index 000000000000..0a309949ebd8
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useConfigMetadata.js
@@ -0,0 +1,22 @@
+import { useState, useEffect } from 'react'
+import { modelsApi } from '../utils/api'
+
+export function useConfigMetadata() {
+ const [metadata, setMetadata] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ modelsApi.getConfigMetadata('all')
+ .then(data => setMetadata(data))
+ .catch(err => setError(err.message))
+ .finally(() => setLoading(false))
+ }, [])
+
+ return {
+ sections: metadata?.sections || [],
+ fields: metadata?.fields || [],
+ loading,
+ error,
+ }
+}
diff --git a/core/http/react-ui/src/hooks/useDebounce.js b/core/http/react-ui/src/hooks/useDebounce.js
new file mode 100644
index 000000000000..066beff1e191
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useDebounce.js
@@ -0,0 +1,40 @@
+import { useRef, useEffect, useCallback } from 'react'
+
+/**
+ * Returns a debounced version of the callback. Always calls the latest
+ * version of fn (via ref), so callers don't need to memoize it.
+ * Timer is cleaned up on unmount.
+ */
+export function useDebouncedCallback(fn, delay = 500) {
+ const timerRef = useRef(null)
+ const fnRef = useRef(fn)
+ fnRef.current = fn
+
+ useEffect(() => () => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }, [])
+
+ return useCallback((...args) => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ timerRef.current = setTimeout(() => fnRef.current(...args), delay)
+ }, [delay])
+}
+
+/**
+ * Runs a debounced effect: when deps change, waits `delay` ms before
+ * calling fn. Resets the timer on each deps change. Cleans up on unmount.
+ */
+export function useDebouncedEffect(fn, deps, delay = 500) {
+ const timerRef = useRef(null)
+ const fnRef = useRef(fn)
+ fnRef.current = fn
+
+ useEffect(() => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ timerRef.current = setTimeout(() => fnRef.current(), delay)
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps)
+}
diff --git a/core/http/react-ui/src/hooks/useVramEstimate.js b/core/http/react-ui/src/hooks/useVramEstimate.js
new file mode 100644
index 000000000000..bf1f25e6a382
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useVramEstimate.js
@@ -0,0 +1,53 @@
+import { useState, useEffect, useRef, useMemo } from 'react'
+import { modelsApi } from '../utils/api'
+
+const DEBOUNCE_MS = 500
+
+export function useVramEstimate({ model, contextSize, gpuLayers }) {
+ const [vramDisplay, setVramDisplay] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const debounceRef = useRef(null)
+ const abortRef = useRef(null)
+
+ useEffect(() => {
+ if (!model || contextSize === undefined) {
+ setVramDisplay(null)
+ setLoading(false)
+ return
+ }
+
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ if (abortRef.current) abortRef.current.abort()
+
+ debounceRef.current = setTimeout(async () => {
+ const controller = new AbortController()
+ abortRef.current = controller
+ setLoading(true)
+
+ try {
+ const body = { model }
+ if (contextSize != null && contextSize !== '') body.context_size = Number(contextSize)
+ if (gpuLayers != null && gpuLayers !== '') body.gpu_layers = Number(gpuLayers)
+
+ const data = await modelsApi.estimateVram(body, { signal: controller.signal })
+
+ if (!controller.signal.aborted) {
+ setVramDisplay(data?.vramDisplay || null)
+ setLoading(false)
+ }
+ } catch {
+ if (!controller.signal.aborted) {
+ setVramDisplay(null)
+ setLoading(false)
+ }
+ }
+ }, DEBOUNCE_MS)
+
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ if (abortRef.current) abortRef.current.abort()
+ }
+ }, [model, contextSize, gpuLayers])
+
+ return useMemo(() => ({ vramDisplay, loading }), [vramDisplay, loading])
+}
diff --git a/core/http/react-ui/src/pages/Backends.jsx b/core/http/react-ui/src/pages/Backends.jsx
index 61e6468b9b69..4e73a9669c01 100644
--- a/core/http/react-ui/src/pages/Backends.jsx
+++ b/core/http/react-ui/src/pages/Backends.jsx
@@ -1,6 +1,7 @@
-import { useState, useEffect, useCallback, useRef } from 'react'
+import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { backendsApi } from '../utils/api'
+import { useDebouncedCallback } from '../hooks/useDebounce'
import React from 'react'
import { useOperations } from '../hooks/useOperations'
import LoadingSpinner from '../components/LoadingSpinner'
@@ -24,8 +25,6 @@ export default function Backends() {
const [manualAlias, setManualAlias] = useState('')
const [expandedRow, setExpandedRow] = useState(null)
const [confirmDialog, setConfirmDialog] = useState(null)
- const debounceRef = useRef(null)
-
const [allBackends, setAllBackends] = useState([])
const fetchBackends = useCallback(async () => {
@@ -70,11 +69,12 @@ export default function Backends() {
const totalPages = Math.max(1, Math.ceil(filteredBackends.length / ITEMS_PER_PAGE))
const backends = filteredBackends.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE)
+ const debouncedFetch = useDebouncedCallback(() => fetchBackends())
+
const handleSearch = (value) => {
setSearch(value)
setPage(1)
- if (debounceRef.current) clearTimeout(debounceRef.current)
- debounceRef.current = setTimeout(() => fetchBackends(), 500)
+ debouncedFetch()
}
const handleSort = (col) => {
diff --git a/core/http/react-ui/src/pages/ImportModel.jsx b/core/http/react-ui/src/pages/ImportModel.jsx
index ed128cde054f..1838f3e94cd3 100644
--- a/core/http/react-ui/src/pages/ImportModel.jsx
+++ b/core/http/react-ui/src/pages/ImportModel.jsx
@@ -204,9 +204,6 @@ export default function ImportModel() {
-