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 ( -
- -