mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-19 22:40:35 -04:00
feat(ui): Interactive model config editor with autocomplete (#9149)
* feat(ui): Add dynamic model editor with autocomplete Signed-off-by: Richard Palethorpe <io@richiejp.com> * chore(docs): Add link to longformat installation video Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
committed by
GitHub
parent
fdc9f7bf35
commit
9ac1bdc587
@@ -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)
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
191
core/http/react-ui/e2e/model-config.spec.js
Normal file
191
core/http/react-ui/e2e/model-config.spec.js
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
138
core/http/react-ui/src/components/AutocompleteInput.jsx
Normal file
138
core/http/react-ui/src/components/AutocompleteInput.jsx
Normal file
@@ -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 (
|
||||
<div ref={wrapperRef} style={{ position: 'relative', ...style }}>
|
||||
<input
|
||||
className="input"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50,
|
||||
maxHeight: 220, overflowY: 'auto', marginTop: 2,
|
||||
background: 'var(--color-bg-primary)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-md)',
|
||||
animation: 'dropdownIn 120ms ease-out',
|
||||
}}
|
||||
>
|
||||
{filtered.map((v, i) => {
|
||||
const isEnterTarget = i === enterTargetIndex
|
||||
return (
|
||||
<div
|
||||
key={v}
|
||||
role="option"
|
||||
aria-selected={v === value}
|
||||
style={{
|
||||
padding: '6px 10px', fontSize: '0.8125rem', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
color: v === value ? 'var(--color-primary)' : 'var(--color-text-primary)',
|
||||
fontWeight: v === value ? 600 : 400,
|
||||
background: (i === focusIndex || isEnterTarget) ? 'var(--color-bg-tertiary)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={() => setFocusIndex(i)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
commit(v)
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{v}</span>
|
||||
{isEnterTarget && (
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', flexShrink: 0 }}>↵</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
export default function CodeEditor({ value, onChange, disabled, minHeight = '500px' }) {
|
||||
const codeRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const preRef = useRef(null)
|
||||
|
||||
const highlight = useCallback(() => {
|
||||
if (!codeRef.current) return
|
||||
const result = hljs.highlight(value + '\n', { language: 'yaml', ignoreIllegals: true })
|
||||
codeRef.current.innerHTML = result.value
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
highlight()
|
||||
}, [highlight])
|
||||
|
||||
const handleScroll = () => {
|
||||
if (preRef.current && textareaRef.current) {
|
||||
preRef.current.scrollTop = textareaRef.current.scrollTop
|
||||
preRef.current.scrollLeft = textareaRef.current.scrollLeft
|
||||
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] }
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="code-editor-wrapper" style={{ position: 'relative', minHeight, fontSize: '0.8125rem' }}>
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="code-editor-highlight"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
margin: 0,
|
||||
padding: 'var(--spacing-sm)',
|
||||
overflow: 'auto',
|
||||
pointerEvents: 'none',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 1.5,
|
||||
tabSize: 2,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border-default)',
|
||||
}}
|
||||
>
|
||||
<code
|
||||
ref={codeRef}
|
||||
className="language-yaml"
|
||||
style={{
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
padding: 0,
|
||||
background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onScroll={handleScroll}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight,
|
||||
margin: 0,
|
||||
padding: 'var(--spacing-sm)',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 1.5,
|
||||
tabSize: 2,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
color: 'transparent',
|
||||
caretColor: 'var(--color-text-primary)',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--color-border-default)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
outline: 'none',
|
||||
resize: 'vertical',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return { from: 0, to: Math.min(1, len), severity, message: issue.message.split('\n')[0] }
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
export default function CodeEditor({ value, onChange, disabled, minHeight = '500px', fields }) {
|
||||
const containerRef = useRef(null)
|
||||
const { theme } = useTheme()
|
||||
|
||||
// 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' },
|
||||
}),
|
||||
]
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
exts.push(autocompletion({
|
||||
override: [createYamlCompletionSource(fields)],
|
||||
activateOnTyping: true,
|
||||
}))
|
||||
}
|
||||
|
||||
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 <div ref={containerRef} className="code-editor-cm" />
|
||||
}
|
||||
|
||||
373
core/http/react-ui/src/components/ConfigFieldRenderer.jsx
Normal file
373
core/http/react-ui/src/components/ConfigFieldRenderer.jsx
Normal file
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, width: '100%' }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
{options ? (
|
||||
<SearchableSelect
|
||||
value={item}
|
||||
onChange={val => 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 }}
|
||||
/>
|
||||
) : (
|
||||
<input className="input" value={item} onChange={e => update(i, e.target.value)}
|
||||
style={{ flex: 1, fontSize: '0.8125rem' }} />
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => remove(i)}
|
||||
style={{ padding: '2px 6px', fontSize: '0.75rem' }}>
|
||||
<i className="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{(!options || availableOptions.length > 0) && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={add}
|
||||
style={{ alignSelf: 'flex-start', fontSize: '0.75rem' }}>
|
||||
<i className="fas fa-plus" /> Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, width: '100%' }}>
|
||||
{entries.map(([k, v], i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input className="input" value={k} placeholder="key"
|
||||
onChange={e => update(i, e.target.value, v)}
|
||||
style={{ flex: 1, fontSize: '0.8125rem' }} />
|
||||
<input className="input" value={typeof v === 'string' ? v : JSON.stringify(v)} placeholder="value"
|
||||
onChange={e => update(i, k, e.target.value)}
|
||||
style={{ flex: 1, fontSize: '0.8125rem' }} />
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => remove(i)}
|
||||
style={{ padding: '2px 6px', fontSize: '0.75rem' }}>
|
||||
<i className="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={add}
|
||||
style={{ alignSelf: 'flex-start', fontSize: '0.75rem' }}>
|
||||
<i className="fas fa-plus" /> Add
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ width: '100%' }}>
|
||||
<textarea
|
||||
className="input"
|
||||
value={text}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
style={{ width: '100%', minHeight: 80, fontFamily: 'monospace', fontSize: '0.8125rem', resize: 'vertical' }}
|
||||
/>
|
||||
{parseError && <div style={{ color: 'var(--color-error)', fontSize: '0.75rem', marginTop: 2 }}>{parseError}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({ field }) {
|
||||
return (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{field.label}
|
||||
{field.vram_impact && (
|
||||
<span style={{ fontSize: '0.625rem', padding: '1px 4px', borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-warning-light, rgba(245,158,11,0.15))', color: 'var(--color-warning)' }}>
|
||||
VRAM
|
||||
</span>
|
||||
)}
|
||||
{field.advanced && (
|
||||
<span style={{ fontSize: '0.625rem', padding: '1px 4px', borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
|
||||
Advanced
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ConfigFieldRenderer({ field, value, onChange, onRemove, annotation }) {
|
||||
const handleChange = (raw) => {
|
||||
onChange(coerceValue(raw, field.ui_type))
|
||||
}
|
||||
|
||||
const removeBtn = (
|
||||
<button type="button" onClick={() => onRemove(field.path)}
|
||||
title="Remove field"
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px',
|
||||
color: 'var(--color-text-muted)', fontSize: '0.75rem',
|
||||
}}>
|
||||
<i className="fas fa-times" />
|
||||
</button>
|
||||
)
|
||||
|
||||
const description = (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{field.description || field.path}
|
||||
{removeBtn}
|
||||
</span>
|
||||
)
|
||||
|
||||
const component = field.component
|
||||
|
||||
// Toggle
|
||||
if (component === 'toggle') {
|
||||
return (
|
||||
<SettingRow label={<FieldLabel field={field} />} description={description}>
|
||||
<Toggle checked={!!value} onChange={handleChange} />
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
|
||||
// Model-select
|
||||
if (component === 'model-select') {
|
||||
const cap = PROVIDER_TO_CAPABILITY[field.autocomplete_provider] || undefined
|
||||
return (
|
||||
<SettingRow label={<FieldLabel field={field} />} description={description}>
|
||||
<SearchableModelSelect
|
||||
value={value || ''}
|
||||
onChange={handleChange}
|
||||
capability={cap}
|
||||
placeholder={field.placeholder || 'Select model...'}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
|
||||
// Select with autocomplete provider (dynamic)
|
||||
if ((component === 'select' || component === 'input') && field.autocomplete_provider) {
|
||||
return (
|
||||
<SettingRow label={<FieldLabel field={field} />} description={description}>
|
||||
<AutocompleteInput
|
||||
value={value || ''}
|
||||
onChange={handleChange}
|
||||
provider={field.autocomplete_provider}
|
||||
placeholder={field.placeholder || 'Type or select...'}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
|
||||
// Select with static options
|
||||
if (component === 'select' && field.options?.length > 0) {
|
||||
return (
|
||||
<SettingRow label={<FieldLabel field={field} />} description={description}>
|
||||
<SearchableSelect
|
||||
value={value || ''}
|
||||
onChange={handleChange}
|
||||
options={field.options.map(o => ({ value: o.value, label: o.label }))}
|
||||
placeholder={field.placeholder || 'Select...'}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
|
||||
// Slider
|
||||
if (component === 'slider') {
|
||||
const min = field.min ?? 0
|
||||
const max = field.max ?? 1
|
||||
const step = field.step ?? 0.1
|
||||
return (
|
||||
<SettingRow label={<FieldLabel field={field} />} description={description}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input type="range" min={min} max={max} step={step}
|
||||
value={value ?? min}
|
||||
onChange={e => handleChange(parseFloat(e.target.value))}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<span style={{ fontSize: '0.8125rem', minWidth: 40, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{value ?? min}
|
||||
</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
|
||||
// Number
|
||||
if (component === 'number') {
|
||||
return (
|
||||
<SettingRow label={<FieldLabel field={field} />} description={description}>
|
||||
<>
|
||||
<input className="input" type="number"
|
||||
value={value ?? ''}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
min={field.min} max={field.max} step={field.step}
|
||||
placeholder={field.placeholder}
|
||||
style={{ width: 120, fontSize: '0.8125rem' }}
|
||||
/>
|
||||
{annotation}
|
||||
</>
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
|
||||
// Textarea
|
||||
if (component === 'textarea') {
|
||||
return (
|
||||
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}><FieldLabel field={field} /></div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea className="input" value={value || ''}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
style={{ width: '100%', minHeight: 80, fontSize: '0.8125rem', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Code editor
|
||||
if (component === 'code-editor') {
|
||||
return (
|
||||
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}><FieldLabel field={field} /></div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<CodeEditor value={value || ''} onChange={handleChange} minHeight="80px" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// String list
|
||||
if (component === 'string-list') {
|
||||
return (
|
||||
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}><FieldLabel field={field} /></div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<StringListEditor value={value} onChange={handleChange} options={field.options?.length > 0 ? field.options : null} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// JSON editor
|
||||
if (component === 'json-editor') {
|
||||
return (
|
||||
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}><FieldLabel field={field} /></div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<JsonEditor value={value} onChange={handleChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Map editor
|
||||
if (component === 'map-editor') {
|
||||
return (
|
||||
<div style={{ padding: 'var(--spacing-sm) 0', borderBottom: '1px solid var(--color-border-subtle)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}><FieldLabel field={field} /></div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2 }}>{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MapEditor value={value} onChange={handleChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default: text input
|
||||
return (
|
||||
<SettingRow label={<FieldLabel field={field} />} description={description}>
|
||||
<input className="input" value={value ?? ''}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
style={{ width: 220, fontSize: '0.8125rem' }}
|
||||
/>
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
172
core/http/react-ui/src/components/FieldBrowser.jsx
Normal file
172
core/http/react-ui/src/components/FieldBrowser.jsx
Normal file
@@ -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 (
|
||||
<div ref={wrapperRef} style={{ position: 'relative', marginBottom: 'var(--spacing-md)' }}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<i className="fas fa-search" style={{
|
||||
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
|
||||
color: 'var(--color-text-muted)', fontSize: '0.75rem', pointerEvents: 'none',
|
||||
}} />
|
||||
<input
|
||||
className="input"
|
||||
value={query}
|
||||
onChange={e => { 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' }}
|
||||
/>
|
||||
</div>
|
||||
{open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
style={{
|
||||
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 100, marginTop: 4,
|
||||
maxHeight: 320, overflowY: 'auto',
|
||||
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-md)',
|
||||
animation: 'dropdownIn 120ms ease-out',
|
||||
}}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '12px 16px', fontSize: '0.8125rem', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||||
{query ? 'No matching fields' : 'All fields are already configured'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((field, i) => {
|
||||
const isEnterTarget = i === enterTargetIndex
|
||||
const isFocused = i === focusIndex || isEnterTarget
|
||||
return (
|
||||
<div
|
||||
key={field.path}
|
||||
style={{
|
||||
padding: '8px 12px', cursor: 'pointer',
|
||||
background: isFocused ? 'var(--color-bg-tertiary)' : 'transparent',
|
||||
borderBottom: '1px solid var(--color-border-subtle)',
|
||||
}}
|
||||
onMouseEnter={() => setFocusIndex(i)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
handleSelect(field)
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
fontSize: '0.625rem', padding: '1px 6px', borderRadius: 'var(--radius-sm)',
|
||||
background: `color-mix(in srgb, ${sectionColors[field.section] || 'var(--color-text-muted)'} 15%, transparent)`,
|
||||
color: sectionColors[field.section] || 'var(--color-text-muted)',
|
||||
fontWeight: 600, whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{field.section}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.8125rem', fontWeight: 500 }}>{field.label}</span>
|
||||
{isEnterTarget && (
|
||||
<span style={{ marginLeft: 'auto', color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>↵</span>
|
||||
)}
|
||||
</div>
|
||||
{field.description && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 2, marginLeft: 0 }}>
|
||||
{field.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)', marginTop: 1, fontFamily: 'monospace' }}>
|
||||
{field.path}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
core/http/react-ui/src/components/TemplateSelector.jsx
Normal file
61
core/http/react-ui/src/components/TemplateSelector.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import MODEL_TEMPLATES from '../utils/modelTemplates'
|
||||
|
||||
export default function TemplateSelector({ onSelect }) {
|
||||
return (
|
||||
<div style={{ padding: '0 var(--spacing-lg) var(--spacing-lg)' }}>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-lg)' }}>
|
||||
Choose a template to get started. You can add or remove fields in the next step.
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 'var(--spacing-md)',
|
||||
}}>
|
||||
{MODEL_TEMPLATES.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className="template-card"
|
||||
onClick={() => onSelect(t)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', width: '100%' }}>
|
||||
<i className={`fas ${t.icon}`} style={{ fontSize: '1.25rem', color: 'var(--color-primary)', width: 28, textAlign: 'center' }} />
|
||||
<span style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--color-text-primary)' }}>{t.label}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', lineHeight: 1.5, margin: 0 }}>
|
||||
{t.description}
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginTop: 'var(--spacing-xs)' }}>
|
||||
{Object.keys(t.fields).filter(k => k !== 'name').map(k => (
|
||||
<span key={k} className="badge" style={{
|
||||
fontSize: '0.6875rem', background: 'var(--color-bg-tertiary)',
|
||||
color: 'var(--color-text-muted)', padding: '2px 6px',
|
||||
}}>
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<style>{`
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 150ms;
|
||||
}
|
||||
.template-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
core/http/react-ui/src/hooks/useAgentChat.js
vendored
21
core/http/react-ui/src/hooks/useAgentChat.js
vendored
@@ -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(() => {
|
||||
|
||||
47
core/http/react-ui/src/hooks/useAutocomplete.js
vendored
Normal file
47
core/http/react-ui/src/hooks/useAutocomplete.js
vendored
Normal file
@@ -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 }
|
||||
}
|
||||
17
core/http/react-ui/src/hooks/useChat.js
vendored
17
core/http/react-ui/src/hooks/useChat.js
vendored
@@ -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 = /<thinking>([\s\S]*?)<\/thinking>|<think>([\s\S]*?)<\/think>|<\|channel>thought([\s\S]*?)<channel\|>/g
|
||||
const openThinkTagRegex = /<thinking>|<think>|<\|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)
|
||||
|
||||
79
core/http/react-ui/src/hooks/useCodeMirror.js
vendored
Normal file
79
core/http/react-ui/src/hooks/useCodeMirror.js
vendored
Normal file
@@ -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 }
|
||||
}
|
||||
22
core/http/react-ui/src/hooks/useConfigMetadata.js
vendored
Normal file
22
core/http/react-ui/src/hooks/useConfigMetadata.js
vendored
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
40
core/http/react-ui/src/hooks/useDebounce.js
vendored
Normal file
40
core/http/react-ui/src/hooks/useDebounce.js
vendored
Normal file
@@ -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)
|
||||
}
|
||||
53
core/http/react-ui/src/hooks/useVramEstimate.js
vendored
Normal file
53
core/http/react-ui/src/hooks/useVramEstimate.js
vendored
Normal file
@@ -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])
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -204,9 +204,6 @@ export default function ImportModel() {
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/app/pipeline-editor')}>
|
||||
<i className="fas fa-diagram-project" /> Create Pipeline Model
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setIsAdvancedMode(!isAdvancedMode)}>
|
||||
<i className={`fas ${isAdvancedMode ? 'fa-magic' : 'fa-code'}`} />
|
||||
{isAdvancedMode ? ' Simple Mode' : ' Advanced Mode'}
|
||||
|
||||
@@ -1,43 +1,280 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
|
||||
import YAML from 'yaml'
|
||||
import { modelsApi } from '../utils/api'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
import { useConfigMetadata } from '../hooks/useConfigMetadata'
|
||||
import { useVramEstimate } from '../hooks/useVramEstimate'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import CodeEditor from '../components/CodeEditor'
|
||||
import FieldBrowser from '../components/FieldBrowser'
|
||||
import ConfigFieldRenderer from '../components/ConfigFieldRenderer'
|
||||
import TemplateSelector from '../components/TemplateSelector'
|
||||
import MODEL_TEMPLATES from '../utils/modelTemplates'
|
||||
|
||||
const SECTION_ICONS = {
|
||||
general: 'fa-cog', llm: 'fa-microchip', parameters: 'fa-sliders',
|
||||
templates: 'fa-file-code', functions: 'fa-wrench', reasoning: 'fa-brain',
|
||||
diffusers: 'fa-image', tts: 'fa-volume-up', pipeline: 'fa-code-branch',
|
||||
grpc: 'fa-server', agent: 'fa-robot', mcp: 'fa-plug', other: 'fa-ellipsis-h',
|
||||
}
|
||||
|
||||
const SECTION_COLORS = {
|
||||
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)',
|
||||
}
|
||||
|
||||
function flattenConfig(obj, prefix = '') {
|
||||
const result = {}
|
||||
if (!obj || typeof obj !== 'object') return result
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
|
||||
Object.assign(result, flattenConfig(val, path))
|
||||
} else {
|
||||
result[path] = val
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function unflattenConfig(flat) {
|
||||
const result = Object.create(null)
|
||||
for (const [path, val] of Object.entries(flat)) {
|
||||
const keys = path.split('.')
|
||||
let obj = result
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!obj[keys[i]]) obj[keys[i]] = Object.create(null)
|
||||
obj = obj[keys[i]]
|
||||
}
|
||||
obj[keys[keys.length - 1]] = val
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function defaultForType(uiType) {
|
||||
switch (uiType) {
|
||||
case 'bool': return false
|
||||
case 'int': case 'float': return 0
|
||||
case '[]string': return []
|
||||
case 'map': return {}
|
||||
case 'object': return {}
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
export default function ModelEditor() {
|
||||
const { name } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { addToast } = useOutletContext()
|
||||
const [config, setConfig] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { sections, fields, loading: metaLoading, error: metaError } = useConfigMetadata()
|
||||
|
||||
const isCreateMode = !name
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null)
|
||||
|
||||
const [tab, setTab] = useState('interactive') // 'interactive' | 'yaml'
|
||||
const [yamlText, setYamlText] = useState('')
|
||||
const [savedYamlText, setSavedYamlText] = useState('')
|
||||
const [values, setValues] = useState({})
|
||||
const [initialValues, setInitialValues] = useState({})
|
||||
const [activeFieldPaths, setActiveFieldPaths] = useState(new Set())
|
||||
const [collapsedSections, setCollapsedSections] = useState(new Set())
|
||||
const [configLoading, setConfigLoading] = useState(!isCreateMode)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeSection, setActiveSection] = useState(null)
|
||||
const [tabSwitchWarning, setTabSwitchWarning] = useState(false)
|
||||
|
||||
const contentRef = useRef(null)
|
||||
const sectionRefs = useRef({})
|
||||
|
||||
const vramEstimate = useVramEstimate({
|
||||
model: name,
|
||||
contextSize: values['context_size'],
|
||||
gpuLayers: values['gpu_layers'],
|
||||
})
|
||||
|
||||
const handleSelectTemplate = useCallback((template) => {
|
||||
setSelectedTemplate(template)
|
||||
const flat = { ...template.fields }
|
||||
setValues(flat)
|
||||
setInitialValues({})
|
||||
setActiveFieldPaths(new Set(Object.keys(flat)))
|
||||
}, [])
|
||||
|
||||
// Auto-select template from URL query param (e.g. ?template=pipeline)
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) return
|
||||
const templateId = searchParams.get('template')
|
||||
if (templateId) {
|
||||
const t = MODEL_TEMPLATES.find(t => t.id === templateId)
|
||||
if (t) handleSelectTemplate(t)
|
||||
}
|
||||
}, [isCreateMode, searchParams, handleSelectTemplate])
|
||||
|
||||
// Load raw YAML config (edit mode only)
|
||||
useEffect(() => {
|
||||
if (!name) return
|
||||
modelsApi.getEditConfig(name).then(data => {
|
||||
setConfig(data?.config || '')
|
||||
setLoading(false)
|
||||
}).catch(err => {
|
||||
addToast(`Failed to load config: ${err.message}`, 'error')
|
||||
setLoading(false)
|
||||
})
|
||||
modelsApi.getEditConfig(name)
|
||||
.then(data => {
|
||||
const raw = data?.config || ''
|
||||
setYamlText(raw)
|
||||
setSavedYamlText(raw)
|
||||
|
||||
// Parse YAML to get only the fields actually present in the file
|
||||
try {
|
||||
const parsed = YAML.parse(raw)
|
||||
const flat = flattenConfig(parsed || {})
|
||||
const active = new Set(Object.keys(flat))
|
||||
setValues(flat)
|
||||
setInitialValues(structuredClone(flat))
|
||||
setActiveFieldPaths(active)
|
||||
} catch {
|
||||
// If YAML parsing fails, start with empty state
|
||||
setValues({})
|
||||
setInitialValues({})
|
||||
setActiveFieldPaths(new Set())
|
||||
}
|
||||
})
|
||||
.catch(err => addToast(`Failed to load config: ${err.message}`, 'error'))
|
||||
.finally(() => setConfigLoading(false))
|
||||
}, [name, addToast])
|
||||
|
||||
const handleSave = async () => {
|
||||
// Build field lookup
|
||||
const fieldsByPath = useMemo(() => {
|
||||
const map = {}
|
||||
for (const f of fields) map[f.path] = f
|
||||
return map
|
||||
}, [fields])
|
||||
|
||||
// Sections with active fields
|
||||
const activeSections = useMemo(() => {
|
||||
const sectionSet = new Set()
|
||||
for (const path of activeFieldPaths) {
|
||||
if (isCreateMode && path === 'name') continue
|
||||
const field = fieldsByPath[path]
|
||||
if (field) sectionSet.add(field.section)
|
||||
}
|
||||
return sections
|
||||
.filter(s => sectionSet.has(s.id))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}, [sections, activeFieldPaths, fieldsByPath, isCreateMode])
|
||||
|
||||
// Fields per section (skip 'name' in create mode — it has a dedicated input)
|
||||
const fieldsBySection = useMemo(() => {
|
||||
const result = {}
|
||||
for (const path of activeFieldPaths) {
|
||||
if (isCreateMode && path === 'name') continue
|
||||
const field = fieldsByPath[path]
|
||||
if (!field) continue
|
||||
if (!result[field.section]) result[field.section] = []
|
||||
result[field.section].push(field)
|
||||
}
|
||||
for (const arr of Object.values(result)) {
|
||||
arr.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return result
|
||||
}, [activeFieldPaths, fieldsByPath, isCreateMode])
|
||||
|
||||
// Default to first active section
|
||||
useEffect(() => {
|
||||
if (!activeSection && activeSections.length > 0) {
|
||||
setActiveSection(activeSections[0].id)
|
||||
}
|
||||
}, [activeSection, activeSections])
|
||||
|
||||
// Scroll tracking
|
||||
useEffect(() => {
|
||||
const container = contentRef.current
|
||||
if (!container || tab !== 'interactive') return
|
||||
const onScroll = () => {
|
||||
const containerTop = container.getBoundingClientRect().top
|
||||
let closest = activeSections[0]?.id
|
||||
let closestDist = Infinity
|
||||
for (const s of activeSections) {
|
||||
const el = sectionRefs.current[s.id]
|
||||
if (el) {
|
||||
const dist = Math.abs(el.getBoundingClientRect().top - containerTop - 8)
|
||||
if (dist < closestDist) { closestDist = dist; closest = s.id }
|
||||
}
|
||||
}
|
||||
if (closest) setActiveSection(closest)
|
||||
}
|
||||
container.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => container.removeEventListener('scroll', onScroll)
|
||||
}, [activeSections, configLoading, metaLoading, tab])
|
||||
|
||||
const scrollTo = (id) => {
|
||||
setActiveSection(id)
|
||||
sectionRefs.current[id]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
const interactiveDirty = useMemo(() => {
|
||||
if (isCreateMode) return activeFieldPaths.size > 0
|
||||
return JSON.stringify(values) !== JSON.stringify(initialValues) ||
|
||||
[...activeFieldPaths].some(p => !(p in initialValues))
|
||||
}, [isCreateMode, values, initialValues, activeFieldPaths])
|
||||
|
||||
const yamlDirty = useMemo(() => {
|
||||
if (isCreateMode) return yamlText.trim().length > 0
|
||||
return yamlText !== savedYamlText
|
||||
}, [isCreateMode, yamlText, savedYamlText])
|
||||
|
||||
const isDirty = tab === 'interactive' ? interactiveDirty : yamlDirty
|
||||
|
||||
const vramAnnotation = useMemo(() => {
|
||||
if (isCreateMode) return null
|
||||
if (vramEstimate.loading) {
|
||||
return (
|
||||
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
<i className="fas fa-spinner fa-spin" style={{ marginRight: 4 }} />
|
||||
Estimating VRAM...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (vramEstimate.vramDisplay) {
|
||||
return (
|
||||
<div style={{ fontSize: '0.6875rem', color: 'var(--color-warning)', marginTop: 4, fontWeight: 500 }}>
|
||||
<i className="fas fa-memory" style={{ marginRight: 4 }} />
|
||||
~{vramEstimate.vramDisplay} VRAM
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}, [isCreateMode, vramEstimate.loading, vramEstimate.vramDisplay])
|
||||
|
||||
// Interactive save — uses PATCH (edit mode) or importConfig (create mode)
|
||||
const handleInteractiveSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
// Send raw YAML/text to the edit endpoint (not JSON-encoded)
|
||||
const response = await fetch(apiUrl(`/models/edit/${encodeURIComponent(name)}`), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-yaml' },
|
||||
body: config,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`)
|
||||
const patchFlat = {}
|
||||
for (const path of activeFieldPaths) {
|
||||
if (path in values) patchFlat[path] = values[path]
|
||||
}
|
||||
const config = unflattenConfig(patchFlat)
|
||||
|
||||
if (isCreateMode) {
|
||||
const modelName = values['name']
|
||||
if (!modelName?.trim()) { addToast('Model name is required', 'error'); setSaving(false); return }
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(modelName.trim())) { addToast('Invalid model name — use only letters, numbers, hyphens, underscores, and dots', 'error'); setSaving(false); return }
|
||||
await modelsApi.importConfig(JSON.stringify(config), 'application/json')
|
||||
addToast('Model created successfully', 'success')
|
||||
navigate(`/app/model-editor/${encodeURIComponent(modelName.trim())}`)
|
||||
} else {
|
||||
await modelsApi.patchConfig(name, config)
|
||||
setInitialValues(structuredClone(values))
|
||||
try {
|
||||
const data = await modelsApi.getEditConfig(name)
|
||||
const refreshedYaml = data?.config || ''
|
||||
setYamlText(refreshedYaml)
|
||||
setSavedYamlText(refreshedYaml)
|
||||
} catch { /* ignore refresh failure */ }
|
||||
setTabSwitchWarning(false)
|
||||
addToast('Configuration saved', 'success')
|
||||
}
|
||||
addToast('Config saved', 'success')
|
||||
} catch (err) {
|
||||
addToast(`Save failed: ${err.message}`, 'error')
|
||||
} finally {
|
||||
@@ -45,27 +282,357 @@ export default function ModelEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="page"><LoadingSpinner size="lg" /></div>
|
||||
// YAML save — sends raw text
|
||||
const handleYamlSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (isCreateMode) {
|
||||
// In create mode, import the YAML as a new config
|
||||
await modelsApi.importConfig(yamlText, 'application/x-yaml')
|
||||
addToast('Model created successfully', 'success')
|
||||
try {
|
||||
const parsed = YAML.parse(yamlText)
|
||||
if (parsed?.name) navigate(`/app/model-editor/${encodeURIComponent(parsed.name)}`)
|
||||
else navigate('/app/manage')
|
||||
} catch { navigate('/app/manage') }
|
||||
} else {
|
||||
const response = await fetch(apiUrl(`/models/edit/${encodeURIComponent(name)}`), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-yaml' },
|
||||
body: yamlText,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`)
|
||||
}
|
||||
// Refresh interactive state from saved YAML
|
||||
setSavedYamlText(yamlText)
|
||||
try {
|
||||
const parsed = YAML.parse(yamlText)
|
||||
const flat = flattenConfig(parsed || {})
|
||||
setValues(flat)
|
||||
setInitialValues(structuredClone(flat))
|
||||
setActiveFieldPaths(new Set(Object.keys(flat)))
|
||||
} catch { /* ignore parse failure */ }
|
||||
setTabSwitchWarning(false)
|
||||
addToast('Config saved', 'success')
|
||||
}
|
||||
} catch (err) {
|
||||
addToast(`Save failed: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createYamlPreview = useMemo(() => {
|
||||
if (!isCreateMode || tab !== 'yaml') return ''
|
||||
const patchFlat = {}
|
||||
for (const path of activeFieldPaths) {
|
||||
if (path in values && values[path] !== '' && values[path] !== null) patchFlat[path] = values[path]
|
||||
}
|
||||
try {
|
||||
return YAML.stringify(unflattenConfig(patchFlat))
|
||||
} catch { return '' }
|
||||
}, [isCreateMode, tab, values, activeFieldPaths])
|
||||
|
||||
const handleAddField = (field) => {
|
||||
setActiveFieldPaths(prev => new Set(prev).add(field.path))
|
||||
if (!(field.path in values)) {
|
||||
setValues(prev => ({ ...prev, [field.path]: field.default ?? defaultForType(field.ui_type) }))
|
||||
}
|
||||
setCollapsedSections(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(field.section)
|
||||
return next
|
||||
})
|
||||
setTimeout(() => {
|
||||
sectionRefs.current[field.section]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const handleRemoveField = (path) => {
|
||||
setActiveFieldPaths(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(path)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleFieldChange = (path, val) => {
|
||||
setValues(prev => ({ ...prev, [path]: val }))
|
||||
}
|
||||
|
||||
const toggleSection = (id) => {
|
||||
setCollapsedSections(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const loading = metaLoading || configLoading
|
||||
const showTemplateSelector = isCreateMode && !selectedTemplate
|
||||
|
||||
if (loading) return <div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
|
||||
if (metaError) return <div className="page"><div className="empty-state"><p className="empty-state-text">Failed to load config metadata: {metaError}</p></div></div>
|
||||
|
||||
return (
|
||||
<div className="page" style={{ maxWidth: '900px' }}>
|
||||
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="page" style={{ maxWidth: 1000, padding: 0 }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: 'var(--spacing-lg) var(--spacing-lg) var(--spacing-md)',
|
||||
}}>
|
||||
<div>
|
||||
<h1 className="page-title">Model Editor</h1>
|
||||
<p className="page-subtitle">{decodeURIComponent(name)}</p>
|
||||
<h1 className="page-title">{isCreateMode ? 'Add Model' : 'Model Editor'}</h1>
|
||||
<p className="page-subtitle">
|
||||
{isCreateMode
|
||||
? (showTemplateSelector ? 'Choose a model type to get started' : `New model${selectedTemplate ? ` — ${selectedTemplate.label}` : ''}`)
|
||||
: decodeURIComponent(name)}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
|
||||
<button className="btn btn-secondary" onClick={() => {
|
||||
if (isCreateMode && selectedTemplate) { setSelectedTemplate(null); setValues({}); setActiveFieldPaths(new Set()) }
|
||||
else navigate(isCreateMode ? '/app/models' : '/app/manage')
|
||||
}}>
|
||||
<i className="fas fa-arrow-left" /> Back
|
||||
</button>
|
||||
{!showTemplateSelector && tab === 'interactive' && (
|
||||
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleInteractiveSave} disabled={saving || !isDirty}>
|
||||
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isCreateMode ? 'Create Model' : (isDirty ? 'Save Changes' : 'Saved')}</>}
|
||||
</button>
|
||||
)}
|
||||
{!showTemplateSelector && tab === 'yaml' && (
|
||||
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleYamlSave} disabled={saving || !isDirty}>
|
||||
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isCreateMode ? 'Create Model' : (isDirty ? 'Save Changes' : 'Saved')}</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/app/manage')}>
|
||||
<i className="fas fa-arrow-left" /> Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CodeEditor value={config} onChange={setConfig} minHeight="500px" />
|
||||
{/* Template selector (create mode, step 1) */}
|
||||
{showTemplateSelector && <TemplateSelector onSelect={handleSelectTemplate} />}
|
||||
|
||||
<div style={{ marginTop: 'var(--spacing-md)', display: 'flex', gap: 'var(--spacing-sm)' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
|
||||
</button>
|
||||
</div>
|
||||
{/* Tabs (hidden during template selection) */}
|
||||
{!showTemplateSelector && (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex', gap: 0, padding: '0 var(--spacing-lg)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
{['interactive', 'yaml'].map(t => {
|
||||
const active = tab === t
|
||||
const blocked = !active && isDirty
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
if (active) return
|
||||
if (blocked) { setTabSwitchWarning(true); return }
|
||||
setTabSwitchWarning(false)
|
||||
setTab(t)
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px', border: 'none',
|
||||
cursor: blocked ? 'not-allowed' : 'pointer',
|
||||
background: 'transparent', fontSize: '0.875rem',
|
||||
fontWeight: active ? 600 : 400,
|
||||
opacity: blocked ? 0.5 : 1,
|
||||
color: active ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||
borderBottom: active ? '2px solid var(--color-primary)' : '2px solid transparent',
|
||||
transition: 'all 150ms',
|
||||
}}
|
||||
>
|
||||
<i className={`fas ${t === 'interactive' ? 'fa-sliders' : 'fa-code'}`} style={{ marginRight: 6 }} />
|
||||
{t === 'interactive' ? 'Interactive' : 'YAML'}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{tabSwitchWarning && isDirty && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
|
||||
padding: 'var(--spacing-sm) var(--spacing-lg)',
|
||||
fontSize: '0.8125rem', color: 'var(--color-warning, #f59e0b)',
|
||||
background: 'var(--color-warning-light, rgba(245, 158, 11, 0.08))',
|
||||
}}>
|
||||
<i className="fas fa-exclamation-triangle" />
|
||||
<span>Save or discard changes before switching tabs.</span>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ marginLeft: 'auto', padding: '2px 10px', fontSize: '0.75rem' }}
|
||||
onClick={() => {
|
||||
if (tab === 'yaml') {
|
||||
setYamlText(savedYamlText)
|
||||
} else {
|
||||
setValues(structuredClone(initialValues))
|
||||
setActiveFieldPaths(new Set(Object.keys(initialValues)))
|
||||
}
|
||||
setTabSwitchWarning(false)
|
||||
setTab(tab === 'yaml' ? 'interactive' : 'yaml')
|
||||
}}
|
||||
>
|
||||
Discard & Switch
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YAML Tab */}
|
||||
{!showTemplateSelector && tab === 'yaml' && (
|
||||
<div style={{ padding: '0 var(--spacing-lg) var(--spacing-lg)' }}>
|
||||
{isCreateMode && (
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-sm)' }}>
|
||||
Edit the YAML directly. The model name must be set in the YAML for create to work.
|
||||
</p>
|
||||
)}
|
||||
<CodeEditor
|
||||
value={isCreateMode ? (yamlText || createYamlPreview) : yamlText}
|
||||
onChange={setYamlText}
|
||||
minHeight="calc(100vh - 260px)"
|
||||
fields={fields}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interactive Tab */}
|
||||
{!showTemplateSelector && tab === 'interactive' && (
|
||||
<>
|
||||
{/* Model name input (create mode) */}
|
||||
{isCreateMode && (
|
||||
<div style={{ padding: '0 var(--spacing-lg)', marginBottom: 'var(--spacing-md)' }}>
|
||||
<div className="card" style={{ padding: 'var(--spacing-md)' }}>
|
||||
<label className="form-label" style={{ fontWeight: 600 }}>
|
||||
<i className="fas fa-tag" style={{ marginRight: '6px', color: 'var(--color-primary)' }} />
|
||||
Model Name
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={values['name'] || ''}
|
||||
onChange={e => handleFieldChange('name', e.target.value)}
|
||||
placeholder="my-model-name"
|
||||
style={{ maxWidth: 400 }}
|
||||
/>
|
||||
<p style={{ marginTop: '4px', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
|
||||
Use letters, numbers, hyphens, underscores, and dots only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Field browser */}
|
||||
<div style={{ padding: '0 var(--spacing-lg)' }}>
|
||||
<FieldBrowser
|
||||
fields={fields}
|
||||
activeFieldPaths={activeFieldPaths}
|
||||
onAddField={handleAddField}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div style={{ display: 'flex', gap: 0, minHeight: 'calc(100vh - 340px)' }}>
|
||||
{/* Sidebar */}
|
||||
<nav style={{
|
||||
width: 180, flexShrink: 0, padding: '0 var(--spacing-sm)',
|
||||
position: 'sticky', top: 0, alignSelf: 'flex-start',
|
||||
}}>
|
||||
{activeSections.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => scrollTo(s.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
|
||||
width: '100%', padding: '8px 12px',
|
||||
background: activeSection === s.id ? 'var(--color-primary-light)' : 'transparent',
|
||||
border: 'none', borderRadius: 'var(--radius-md)', cursor: 'pointer',
|
||||
color: activeSection === s.id ? 'var(--color-primary)' : 'var(--color-text-secondary)',
|
||||
fontSize: '0.8125rem', fontWeight: activeSection === s.id ? 600 : 400,
|
||||
textAlign: 'left', transition: 'all 150ms', marginBottom: 2,
|
||||
borderLeft: activeSection === s.id ? '2px solid var(--color-primary)' : '2px solid transparent',
|
||||
}}
|
||||
>
|
||||
<i className={`fas ${SECTION_ICONS[s.id] || 'fa-cog'}`} style={{
|
||||
width: 16, textAlign: 'center', fontSize: '0.75rem',
|
||||
color: activeSection === s.id ? (SECTION_COLORS[s.id] || 'var(--color-primary)') : 'var(--color-text-muted)',
|
||||
}} />
|
||||
{s.label}
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
|
||||
{fieldsBySection[s.id]?.length || 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{activeSections.length === 0 && (
|
||||
<div style={{ padding: '12px', fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
|
||||
Use the search bar above to add fields
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={{
|
||||
flex: 1, overflow: 'auto', padding: '0 var(--spacing-lg) var(--spacing-xl) var(--spacing-md)',
|
||||
maxHeight: 'calc(100vh - 340px)',
|
||||
}}
|
||||
>
|
||||
{activeSections.length === 0 && (
|
||||
<div className="card" style={{ padding: 'var(--spacing-xl)', textAlign: 'center' }}>
|
||||
<i className="fas fa-sliders" style={{ fontSize: '2rem', color: 'var(--color-text-muted)', marginBottom: 'var(--spacing-md)' }} />
|
||||
<h3 style={{ marginBottom: 'var(--spacing-sm)' }}>No fields configured</h3>
|
||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Use the search bar above to find and add configuration fields.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSections.map(s => {
|
||||
const sectionFields = fieldsBySection[s.id] || []
|
||||
const isCollapsed = collapsedSections.has(s.id)
|
||||
return (
|
||||
<div key={s.id} ref={el => sectionRefs.current[s.id] = el} style={{ marginBottom: 'var(--spacing-xl)' }}>
|
||||
<h3
|
||||
onClick={() => toggleSection(s.id)}
|
||||
style={{
|
||||
fontSize: '1rem', fontWeight: 700, cursor: 'pointer', userSelect: 'none',
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)',
|
||||
marginBottom: isCollapsed ? 0 : 'var(--spacing-md)',
|
||||
}}
|
||||
>
|
||||
<i className={`fas ${isCollapsed ? 'fa-chevron-right' : 'fa-chevron-down'}`}
|
||||
style={{ fontSize: '0.625rem', width: 12, color: 'var(--color-text-muted)' }} />
|
||||
<i className={`fas ${SECTION_ICONS[s.id] || 'fa-cog'}`}
|
||||
style={{ color: SECTION_COLORS[s.id] || 'var(--color-primary)' }} />
|
||||
{s.label}
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 400, color: 'var(--color-text-muted)' }}>
|
||||
({sectionFields.length})
|
||||
</span>
|
||||
</h3>
|
||||
{!isCollapsed && (
|
||||
<div className="card">
|
||||
{sectionFields.map(field => (
|
||||
<ConfigFieldRenderer
|
||||
key={field.path}
|
||||
field={field}
|
||||
value={values[field.path]}
|
||||
onChange={val => handleFieldChange(field.path, val)}
|
||||
onRemove={handleRemoveField}
|
||||
annotation={field.path === 'context_size' ? vramAnnotation : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { modelsApi } from '../utils/api'
|
||||
import { useDebouncedCallback } from '../hooks/useDebounce'
|
||||
import { useOperations } from '../hooks/useOperations'
|
||||
import { useResources } from '../hooks/useResources'
|
||||
import SearchableSelect from '../components/SearchableSelect'
|
||||
@@ -117,7 +118,6 @@ export default function Models() {
|
||||
const [stats, setStats] = useState({ total: 0, installed: 0, repositories: 0 })
|
||||
const [backendFilter, setBackendFilter] = useState('')
|
||||
const [allBackends, setAllBackends] = useState([])
|
||||
const debounceRef = useRef(null)
|
||||
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||
|
||||
// Total GPU memory for "fits" check
|
||||
@@ -165,13 +165,14 @@ export default function Models() {
|
||||
if (!loading) fetchModels()
|
||||
}, [operations.length])
|
||||
|
||||
const debouncedFetch = useDebouncedCallback((value) => {
|
||||
setPage(1)
|
||||
fetchModels({ search: value, page: 1 })
|
||||
})
|
||||
|
||||
const handleSearch = (value) => {
|
||||
setSearch(value)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setPage(1)
|
||||
fetchModels({ search: value, page: 1 })
|
||||
}, 500)
|
||||
debouncedFetch(value)
|
||||
}
|
||||
|
||||
const handleSort = (col) => {
|
||||
@@ -272,6 +273,9 @@ export default function Models() {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/model-editor')}>
|
||||
<i className="fas fa-plus" /> Add Model
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>
|
||||
<i className="fas fa-upload" /> Import Model
|
||||
</button>
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
|
||||
import { modelsApi } from '../utils/api'
|
||||
import SearchableModelSelect from '../components/SearchableModelSelect'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import { CAP_VAD, CAP_TRANSCRIPT, CAP_CHAT, CAP_TTS } from '../utils/capabilities'
|
||||
|
||||
const PIPELINE_FIELDS = [
|
||||
{ key: 'vad', label: 'VAD Model', capability: CAP_VAD, icon: 'fas fa-microphone-lines', hint: 'Voice Activity Detection model' },
|
||||
{ key: 'transcription', label: 'Transcription Model', capability: CAP_TRANSCRIPT, icon: 'fas fa-closed-captioning', hint: 'Speech-to-text model' },
|
||||
{ key: 'llm', label: 'LLM Model', capability: CAP_CHAT, icon: 'fas fa-brain', hint: 'Language model for generating responses' },
|
||||
{ key: 'tts', label: 'TTS Model', capability: CAP_TTS, icon: 'fas fa-volume-high', hint: 'Text-to-speech model' },
|
||||
]
|
||||
|
||||
function buildPayload(formData) {
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
pipeline: {
|
||||
vad: formData.vad.trim(),
|
||||
transcription: formData.transcription.trim(),
|
||||
llm: formData.llm.trim(),
|
||||
tts: formData.tts.trim(),
|
||||
},
|
||||
}
|
||||
if (formData.voice.trim()) {
|
||||
payload.tts = { voice: formData.voice.trim() }
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
export default function PipelineEditor() {
|
||||
const { name } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { addToast } = useOutletContext()
|
||||
const isEditMode = !!name
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '', vad: '', transcription: '', llm: '', tts: '', voice: '',
|
||||
})
|
||||
const [errors, setErrors] = useState({})
|
||||
const [loading, setLoading] = useState(isEditMode)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode) return
|
||||
modelsApi.getConfigJson(name)
|
||||
.then(cfg => {
|
||||
setFormData({
|
||||
name: cfg.name || name,
|
||||
vad: cfg.pipeline?.vad || '',
|
||||
transcription: cfg.pipeline?.transcription || '',
|
||||
llm: cfg.pipeline?.llm || '',
|
||||
tts: cfg.pipeline?.tts || '',
|
||||
voice: cfg.tts?.voice || '',
|
||||
})
|
||||
})
|
||||
.catch(err => addToast(`Failed to load config: ${err.message}`, 'error'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [name, isEditMode, addToast])
|
||||
|
||||
const updateField = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
setErrors(prev => ({ ...prev, [field]: '' }))
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const errs = {}
|
||||
if (!isEditMode) {
|
||||
if (!formData.name.trim()) errs.name = 'Model name is required'
|
||||
else if (!/^[a-zA-Z0-9_.-]+$/.test(formData.name.trim())) errs.name = 'Only letters, numbers, hyphens, underscores, and dots'
|
||||
}
|
||||
for (const f of PIPELINE_FIELDS) {
|
||||
if (!formData[f.key].trim()) errs[f.key] = `${f.label} is required`
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const errs = validate()
|
||||
if (Object.keys(errs).length > 0) {
|
||||
setErrors(errs)
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = buildPayload(formData)
|
||||
if (isEditMode) {
|
||||
await modelsApi.editConfig(name, payload)
|
||||
addToast('Pipeline model updated', 'success')
|
||||
} else {
|
||||
await modelsApi.importConfig(JSON.stringify(payload), 'application/json')
|
||||
addToast('Pipeline model created', 'success')
|
||||
}
|
||||
navigate('/app/talk')
|
||||
} catch (err) {
|
||||
addToast(`Save failed: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="page"><LoadingSpinner size="lg" /></div>
|
||||
|
||||
return (
|
||||
<div className="page" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div style={{ width: '100%', maxWidth: '48rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 'var(--spacing-lg)' }}>
|
||||
<div>
|
||||
<h1 className="page-title">{isEditMode ? 'Edit Pipeline Model' : 'Create Pipeline Model'}</h1>
|
||||
<p className="page-subtitle">
|
||||
{isEditMode
|
||||
? decodeURIComponent(name)
|
||||
: 'Configure a real-time voice pipeline (VAD + Transcription + LLM + TTS)'}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/app/talk')}>
|
||||
<i className="fas fa-arrow-left" style={{ marginRight: 'var(--spacing-xs)' }} /> Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 'var(--spacing-lg)' }}>
|
||||
{/* Model Name */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
<i className="fas fa-tag" style={{ color: 'var(--color-primary)', marginRight: 4 }} />
|
||||
Model Name {!isEditMode && <span style={{ color: 'var(--color-error)' }}>*</span>}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={formData.name}
|
||||
onChange={e => updateField('name', e.target.value)}
|
||||
disabled={isEditMode}
|
||||
placeholder="my-pipeline-model"
|
||||
style={{ fontSize: '0.8125rem' }}
|
||||
/>
|
||||
{errors.name && <p style={{ color: 'var(--color-error)', fontSize: '0.75rem', margin: '4px 0 0' }}>{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Model Type */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
<i className="fas fa-layer-group" style={{ color: 'var(--color-primary)', marginRight: 4 }} />
|
||||
Model Type
|
||||
</label>
|
||||
<div>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 10px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-primary-light)',
|
||||
color: 'var(--color-primary)',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.8125rem',
|
||||
}}>
|
||||
pipeline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline model selectors */}
|
||||
{PIPELINE_FIELDS.map(field => (
|
||||
<div className="form-group" key={field.key}>
|
||||
<label className="form-label">
|
||||
<i className={field.icon} style={{ color: 'var(--color-primary)', marginRight: 4 }} />
|
||||
{field.label} <span style={{ color: 'var(--color-error)' }}>*</span>
|
||||
</label>
|
||||
<SearchableModelSelect
|
||||
value={formData[field.key]}
|
||||
onChange={v => updateField(field.key, v)}
|
||||
capability={field.capability}
|
||||
placeholder={`Select or type ${field.label.toLowerCase()}...`}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{errors[field.key] && <p style={{ color: 'var(--color-error)', fontSize: '0.75rem', margin: '4px 0 0' }}>{errors[field.key]}</p>}
|
||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.75rem', margin: '4px 0 0' }}>{field.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Voice (optional) */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">
|
||||
<i className="fas fa-comment-dots" style={{ color: 'var(--color-primary)', marginRight: 4 }} />
|
||||
Voice <span style={{ color: 'var(--color-text-secondary)', fontWeight: 400 }}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={formData.voice}
|
||||
onChange={e => updateField('voice', e.target.value)}
|
||||
placeholder="Voice name (e.g., en-us-1)"
|
||||
style={{ fontSize: '0.8125rem' }}
|
||||
/>
|
||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.75rem', margin: '4px 0 0' }}>
|
||||
Default voice for TTS output. Leave empty for model default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', marginTop: 'var(--spacing-lg)' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving
|
||||
? <><LoadingSpinner size="sm" /> Saving...</>
|
||||
: <><i className="fas fa-save" style={{ marginRight: 'var(--spacing-xs)' }} /> {isEditMode ? 'Save Changes' : 'Create Pipeline Model'}</>}
|
||||
</button>
|
||||
{isEditMode && (
|
||||
<button className="btn btn-secondary" onClick={() => navigate(`/app/model-editor/${encodeURIComponent(name)}`)}>
|
||||
<i className="fas fa-code" style={{ marginRight: 'var(--spacing-xs)' }} /> Edit Raw YAML
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -502,7 +502,7 @@ export default function Talk() {
|
||||
disabled={isConnected}
|
||||
searchPlaceholder="Search pipeline models..."
|
||||
/>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/pipeline-editor')}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/model-editor?template=pipeline')}
|
||||
style={{ marginTop: 'var(--spacing-xs)' }}>
|
||||
<i className="fas fa-plus" style={{ marginRight: 'var(--spacing-xs)' }} /> Create Pipeline Model
|
||||
</button>
|
||||
@@ -532,7 +532,7 @@ export default function Talk() {
|
||||
)}
|
||||
{selectedModelInfo && !isConnected && (
|
||||
<div style={{ marginBottom: 'var(--spacing-md)' }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/pipeline-editor/${encodeURIComponent(selectedModel)}`)}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/model-editor/${encodeURIComponent(selectedModel)}`)}>
|
||||
<i className="fas fa-pen-to-square" style={{ marginRight: 'var(--spacing-xs)' }} /> Edit Pipeline
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ import AgentJobs from './pages/AgentJobs'
|
||||
import AgentTaskDetails from './pages/AgentTaskDetails'
|
||||
import AgentJobDetails from './pages/AgentJobDetails'
|
||||
import ModelEditor from './pages/ModelEditor'
|
||||
import PipelineEditor from './pages/PipelineEditor'
|
||||
// PipelineEditor removed — the Model Editor with templates handles all model types
|
||||
import ImportModel from './pages/ImportModel'
|
||||
import BackendLogs from './pages/BackendLogs'
|
||||
import Explorer from './pages/Explorer'
|
||||
@@ -101,9 +101,8 @@ const appChildren = [
|
||||
{ path: 'agent-jobs/jobs/:id', element: <Feature feature="mcp_jobs"><AgentJobDetails /></Feature> },
|
||||
{ path: 'fine-tune', element: <Feature feature="fine_tuning"><FineTune /></Feature> },
|
||||
{ path: 'quantize', element: <Feature feature="quantization"><Quantize /></Feature> },
|
||||
{ path: 'model-editor', element: <Admin><ModelEditor /></Admin> },
|
||||
{ path: 'model-editor/:name', element: <Admin><ModelEditor /></Admin> },
|
||||
{ path: 'pipeline-editor', element: <Admin><PipelineEditor /></Admin> },
|
||||
{ path: 'pipeline-editor/:name', element: <Admin><PipelineEditor /></Admin> },
|
||||
{ path: 'import-model', element: <Admin><ImportModel /></Admin> },
|
||||
{ path: '*', element: <NotFound /> },
|
||||
]
|
||||
|
||||
10
core/http/react-ui/src/utils/api.js
vendored
10
core/http/react-ui/src/utils/api.js
vendored
@@ -97,6 +97,16 @@ export const modelsApi = {
|
||||
getJobStatus: (uid) => fetchJSON(API_CONFIG.endpoints.modelsJobStatus(uid)),
|
||||
getEditConfig: (name) => fetchJSON(API_CONFIG.endpoints.modelEditGet(name)),
|
||||
editConfig: (name, body) => postJSON(API_CONFIG.endpoints.modelEdit(name), body),
|
||||
getConfigMetadata: (section) => fetchJSON(
|
||||
section ? `${API_CONFIG.endpoints.configMetadata}?section=${section}`
|
||||
: API_CONFIG.endpoints.configMetadata
|
||||
),
|
||||
getAutocomplete: (provider) => fetchJSON(API_CONFIG.endpoints.configAutocomplete(provider)),
|
||||
estimateVram: (body, options) => postJSON(API_CONFIG.endpoints.vramEstimate, body, options),
|
||||
patchConfig: (name, patch) => fetchJSON(API_CONFIG.endpoints.configPatch(name), {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
}
|
||||
|
||||
// Backends API
|
||||
|
||||
132
core/http/react-ui/src/utils/cmTheme.js
vendored
Normal file
132
core/http/react-ui/src/utils/cmTheme.js
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
|
||||
// Dark theme — vibrant palette on deep indigo background
|
||||
const darkEditorTheme = EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: '#1a1a2e',
|
||||
color: '#e2e8f0',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: '#a78bfa',
|
||||
padding: '0',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#a78bfa', borderLeftWidth: '2px' },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.3)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: '#16162a',
|
||||
color: '#4c5772',
|
||||
borderRight: '1px solid #2d2b55',
|
||||
},
|
||||
'.cm-activeLineGutter': { backgroundColor: 'rgba(139, 92, 246, 0.12)', color: '#8b8db5' },
|
||||
'.cm-activeLine': { backgroundColor: 'rgba(139, 92, 246, 0.06)' },
|
||||
'.cm-foldPlaceholder': { backgroundColor: '#2d2b55', border: 'none', color: '#8b8db5' },
|
||||
'.cm-matchingBracket': { backgroundColor: 'rgba(139, 92, 246, 0.25)', outline: '1px solid rgba(139, 92, 246, 0.5)' },
|
||||
'.cm-tooltip': {
|
||||
backgroundColor: '#1e1e3a',
|
||||
border: '1px solid #2d2b55',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
},
|
||||
'.cm-tooltip-autocomplete': {
|
||||
'& > ul': { fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: '0.8125rem' },
|
||||
'& > ul > li': { padding: '4px 8px' },
|
||||
'& > ul > li[aria-selected]': { backgroundColor: 'rgba(139, 92, 246, 0.3)', color: '#f1f5f9' },
|
||||
},
|
||||
'.cm-tooltip.cm-completionInfo': { padding: '8px 10px', maxWidth: '300px' },
|
||||
'.cm-completionDetail': { color: '#8b8db5', fontStyle: 'italic', marginLeft: '0.5em' },
|
||||
'.cm-panels': { backgroundColor: '#16162a', color: '#e2e8f0' },
|
||||
'.cm-panels.cm-panels-top': { borderBottom: '1px solid #2d2b55' },
|
||||
'.cm-panels.cm-panels-bottom': { borderTop: '1px solid #2d2b55' },
|
||||
'.cm-searchMatch': { backgroundColor: 'rgba(250, 204, 21, 0.2)', outline: '1px solid rgba(250, 204, 21, 0.4)' },
|
||||
'.cm-searchMatch.cm-searchMatch-selected': { backgroundColor: 'rgba(250, 204, 21, 0.4)' },
|
||||
'.cm-selectionMatch': { backgroundColor: 'rgba(139, 92, 246, 0.15)' },
|
||||
}, { dark: true })
|
||||
|
||||
const darkHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.propertyName, color: '#79c0ff', fontWeight: '500' }, // YAML keys — bright blue
|
||||
{ tag: tags.string, color: '#7ee787' }, // strings — vivid green
|
||||
{ tag: tags.number, color: '#ffa657' }, // numbers — warm orange
|
||||
{ tag: tags.bool, color: '#ff7eb6' }, // booleans — hot pink
|
||||
{ tag: tags.null, color: '#ff7eb6' }, // null — hot pink
|
||||
{ tag: tags.keyword, color: '#d2a8ff' }, // keywords — bright purple
|
||||
{ tag: tags.comment, color: '#5c6a82', fontStyle: 'italic' }, // comments — subtle
|
||||
{ tag: tags.meta, color: '#a5b4cf' }, // directives
|
||||
{ tag: tags.punctuation, color: '#8b949e' }, // colons, dashes
|
||||
{ tag: tags.atom, color: '#ff7eb6' }, // special values
|
||||
{ tag: tags.labelName, color: '#79c0ff', fontWeight: '500' }, // anchors/aliases
|
||||
])
|
||||
|
||||
// Light theme — rich saturated colors on warm paper background
|
||||
const lightEditorTheme = EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: '#fafaf9',
|
||||
color: '#1c1917',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: '#7c3aed',
|
||||
padding: '0',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#7c3aed', borderLeftWidth: '2px' },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'rgba(124, 58, 237, 0.15)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: '#f5f5f4',
|
||||
color: '#a8a29e',
|
||||
borderRight: '1px solid #e7e5e4',
|
||||
},
|
||||
'.cm-activeLineGutter': { backgroundColor: 'rgba(124, 58, 237, 0.06)', color: '#78716c' },
|
||||
'.cm-activeLine': { backgroundColor: 'rgba(124, 58, 237, 0.03)' },
|
||||
'.cm-foldPlaceholder': { backgroundColor: '#e7e5e4', border: 'none', color: '#78716c' },
|
||||
'.cm-matchingBracket': { backgroundColor: 'rgba(124, 58, 237, 0.15)', outline: '1px solid rgba(124, 58, 237, 0.3)' },
|
||||
'.cm-tooltip': {
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e7e5e4',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.08)',
|
||||
},
|
||||
'.cm-tooltip-autocomplete': {
|
||||
'& > ul': { fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: '0.8125rem' },
|
||||
'& > ul > li': { padding: '4px 8px' },
|
||||
'& > ul > li[aria-selected]': { backgroundColor: 'rgba(124, 58, 237, 0.1)', color: '#1c1917' },
|
||||
},
|
||||
'.cm-tooltip.cm-completionInfo': { padding: '8px 10px', maxWidth: '300px' },
|
||||
'.cm-completionDetail': { color: '#78716c', fontStyle: 'italic', marginLeft: '0.5em' },
|
||||
'.cm-panels': { backgroundColor: '#f5f5f4', color: '#1c1917' },
|
||||
'.cm-panels.cm-panels-top': { borderBottom: '1px solid #e7e5e4' },
|
||||
'.cm-panels.cm-panels-bottom': { borderTop: '1px solid #e7e5e4' },
|
||||
'.cm-searchMatch': { backgroundColor: 'rgba(234, 179, 8, 0.25)', outline: '1px solid rgba(234, 179, 8, 0.5)' },
|
||||
'.cm-searchMatch.cm-searchMatch-selected': { backgroundColor: 'rgba(234, 179, 8, 0.45)' },
|
||||
'.cm-selectionMatch': { backgroundColor: 'rgba(124, 58, 237, 0.08)' },
|
||||
})
|
||||
|
||||
const lightHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.propertyName, color: '#0550ae', fontWeight: '500' }, // YAML keys — deep blue
|
||||
{ tag: tags.string, color: '#116329' }, // strings — forest green
|
||||
{ tag: tags.number, color: '#cf5500' }, // numbers — burnt orange
|
||||
{ tag: tags.bool, color: '#cf222e' }, // booleans — crimson
|
||||
{ tag: tags.null, color: '#cf222e' }, // null — crimson
|
||||
{ tag: tags.keyword, color: '#8250df' }, // keywords — vivid purple
|
||||
{ tag: tags.comment, color: '#a3a3a3', fontStyle: 'italic' }, // comments — soft gray
|
||||
{ tag: tags.meta, color: '#57606a' }, // directives
|
||||
{ tag: tags.punctuation, color: '#6e7781' }, // colons, dashes
|
||||
{ tag: tags.atom, color: '#cf222e' }, // special values
|
||||
{ tag: tags.labelName, color: '#0550ae', fontWeight: '500' }, // anchors/aliases
|
||||
])
|
||||
|
||||
export const darkTheme = [darkEditorTheme, syntaxHighlighting(darkHighlightStyle)]
|
||||
export const lightTheme = [lightEditorTheme, syntaxHighlighting(lightHighlightStyle)]
|
||||
|
||||
export function getThemeExtension(theme) {
|
||||
return theme === 'light' ? lightTheme : darkTheme
|
||||
}
|
||||
201
core/http/react-ui/src/utils/cmYamlComplete.js
vendored
Normal file
201
core/http/react-ui/src/utils/cmYamlComplete.js
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
import { fetchCachedAutocomplete } from '../hooks/useAutocomplete'
|
||||
|
||||
const NO_COMPLETION = Object.freeze({ position: 'none', path: [], currentWord: '', keyName: '' })
|
||||
|
||||
function analyzeYamlContext(state, pos) {
|
||||
const doc = state.doc
|
||||
const line = doc.lineAt(pos)
|
||||
const lineText = line.text
|
||||
const cursorCol = pos - line.from
|
||||
|
||||
// Check if we're inside a block scalar (| or >)
|
||||
for (let ln = line.number - 1; ln >= 1; ln--) {
|
||||
const prevLine = doc.line(ln)
|
||||
const prevText = prevLine.text
|
||||
const trimmed = prevText.trimEnd()
|
||||
if (/:\s*[|>][+-]?\s*$/.test(trimmed)) {
|
||||
const scalarIndent = prevText.length - prevText.trimStart().length + 2
|
||||
const currentIndent = lineText.length - lineText.trimStart().length
|
||||
if (currentIndent >= scalarIndent) return NO_COMPLETION
|
||||
break
|
||||
}
|
||||
if (prevText.trim() !== '' && !prevText.trim().startsWith('#')) break
|
||||
}
|
||||
|
||||
// Check if line is a comment
|
||||
if (lineText.trimStart().startsWith('#')) return NO_COMPLETION
|
||||
|
||||
const currentIndent = lineText.length - lineText.trimStart().length
|
||||
const colonIdx = lineText.indexOf(':')
|
||||
|
||||
// Build parent path by scanning backward for less-indented keys
|
||||
const path = []
|
||||
let targetIndent = currentIndent
|
||||
for (let ln = line.number - 1; ln >= 1 && targetIndent > 0; ln--) {
|
||||
const prevText = doc.line(ln).text
|
||||
if (prevText.trim() === '' || prevText.trim().startsWith('#')) continue
|
||||
const prevIndent = prevText.length - prevText.trimStart().length
|
||||
if (prevIndent < targetIndent) {
|
||||
const match = prevText.match(/^(\s*)([a-zA-Z_][\w.-]*):\s*/)
|
||||
if (match && match[1].length === prevIndent) {
|
||||
path.unshift(match[2])
|
||||
targetIndent = prevIndent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List items (e.g. " - value") need value completions from their parent key
|
||||
const listItemMatch = lineText.match(/^(\s*)-\s*(.*)$/)
|
||||
if (listItemMatch) {
|
||||
const listIndent = listItemMatch[1].length
|
||||
let parentKey = ''
|
||||
for (let ln = line.number - 1; ln >= 1; ln--) {
|
||||
const prevText = doc.line(ln).text
|
||||
if (prevText.trim() === '' || prevText.trim().startsWith('#')) continue
|
||||
const prevIndent = prevText.length - prevText.trimStart().length
|
||||
if (prevIndent < listIndent) {
|
||||
const km = prevText.match(/^(\s*)([a-zA-Z_][\w.-]*):\s*$/)
|
||||
if (km && km[1].length === prevIndent) parentKey = km[2]
|
||||
break
|
||||
}
|
||||
// Another list item at same indent — keep scanning for the key
|
||||
if (prevText.match(/^\s*-\s/)) continue
|
||||
break
|
||||
}
|
||||
if (parentKey) {
|
||||
const dashPos = lineText.indexOf('- ')
|
||||
const valueStart = dashPos + 2
|
||||
const currentWord = cursorCol > valueStart ? lineText.substring(valueStart, cursorCol) : ''
|
||||
return { position: 'list-item', path, currentWord, keyName: parentKey }
|
||||
}
|
||||
}
|
||||
|
||||
if (colonIdx === -1 || cursorCol <= colonIdx) {
|
||||
const textBeforeCursor = lineText.substring(0, cursorCol)
|
||||
const currentWord = textBeforeCursor.trimStart()
|
||||
return { position: 'key', path, currentWord, keyName: '' }
|
||||
}
|
||||
|
||||
const keyMatch = lineText.match(/^\s*([a-zA-Z_][\w.-]*):\s*/)
|
||||
const keyName = keyMatch ? keyMatch[1] : ''
|
||||
const valueStart = colonIdx + 1
|
||||
const textAfterColon = lineText.substring(valueStart, cursorCol).trimStart()
|
||||
return { position: 'value', path, currentWord: textAfterColon, keyName }
|
||||
}
|
||||
|
||||
// Build lookup structures from field metadata
|
||||
function buildFieldIndex(fields) {
|
||||
// Map from dot-path to field metadata
|
||||
const byPath = new Map()
|
||||
// Map from prefix to child key names (for key completions at each nesting level)
|
||||
// e.g., '' -> ['name', 'backend', 'parameters', 'pipeline', ...]
|
||||
// 'parameters' -> ['temperature', 'top_p', 'top_k', ...]
|
||||
const childKeys = new Map()
|
||||
|
||||
for (const field of fields) {
|
||||
byPath.set(field.path, field)
|
||||
|
||||
const parts = field.path.split('.')
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const prefix = parts.slice(0, i).join('.')
|
||||
const key = parts[i]
|
||||
if (!childKeys.has(prefix)) childKeys.set(prefix, new Map())
|
||||
const siblings = childKeys.get(prefix)
|
||||
if (!siblings.has(key)) {
|
||||
// For intermediate keys (not the leaf), store a synthetic entry
|
||||
// For the leaf key, store the actual field
|
||||
const isLeaf = i === parts.length - 1
|
||||
siblings.set(key, isLeaf ? field : { path: parts.slice(0, i + 1).join('.'), label: key, section: field.section })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { byPath, childKeys }
|
||||
}
|
||||
|
||||
export function createYamlCompletionSource(fields) {
|
||||
if (!fields || fields.length === 0) return () => null
|
||||
|
||||
const { byPath, childKeys } = buildFieldIndex(fields)
|
||||
|
||||
return async (context) => {
|
||||
const { state, pos } = context
|
||||
const ctx = analyzeYamlContext(state, pos)
|
||||
|
||||
if (ctx.position === 'none') return null
|
||||
|
||||
if (ctx.position === 'key') {
|
||||
const prefix = ctx.path.join('.')
|
||||
const siblings = childKeys.get(prefix)
|
||||
if (!siblings) return null
|
||||
|
||||
const word = context.matchBefore(/[\w.-]*/)
|
||||
if (!word && !context.explicit) return null
|
||||
|
||||
const options = []
|
||||
for (const [key, info] of siblings) {
|
||||
const field = byPath.get(info.path) || info
|
||||
options.push({
|
||||
label: key,
|
||||
detail: field.ui_type || (byPath.has(info.path) ? '' : 'section'),
|
||||
info: field.description || '',
|
||||
type: byPath.has(info.path) ? 'property' : 'namespace',
|
||||
apply: byPath.has(info.path) ? key + ': ' : key + ':\n' + ' '.repeat((ctx.path.length + 1) * 2),
|
||||
boost: field.order != null ? -field.order : 0,
|
||||
})
|
||||
}
|
||||
|
||||
return { from: word ? word.from : pos, options, validFor: /^[\w.-]*$/ }
|
||||
}
|
||||
|
||||
if ((ctx.position === 'value' || ctx.position === 'list-item') && ctx.keyName) {
|
||||
// For list items, path already includes the parent key; for values, append keyName
|
||||
const fullPath = ctx.position === 'list-item'
|
||||
? ctx.path.join('.')
|
||||
: (ctx.path.length > 0 ? ctx.path.join('.') + '.' + ctx.keyName : ctx.keyName)
|
||||
const field = byPath.get(fullPath)
|
||||
if (!field) return null
|
||||
|
||||
const word = context.matchBefore(/\S*/)
|
||||
const from = word ? word.from : pos
|
||||
|
||||
// Static options from field metadata
|
||||
if (field.options && field.options.length > 0) {
|
||||
return {
|
||||
from,
|
||||
options: field.options.map(opt => ({
|
||||
label: opt.value,
|
||||
detail: opt.label !== opt.value ? opt.label : '',
|
||||
type: 'enum',
|
||||
})),
|
||||
validFor: /^\S*$/,
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic autocomplete from provider
|
||||
if (field.autocomplete_provider) {
|
||||
const values = await fetchCachedAutocomplete(field.autocomplete_provider)
|
||||
if (values.length === 0) return null
|
||||
return {
|
||||
from,
|
||||
options: values.map(v => ({ label: v, type: 'value' })),
|
||||
validFor: /^\S*$/,
|
||||
}
|
||||
}
|
||||
|
||||
// Boolean fields
|
||||
if (field.ui_type === 'bool') {
|
||||
return {
|
||||
from,
|
||||
options: [
|
||||
{ label: 'true', type: 'enum' },
|
||||
{ label: 'false', type: 'enum' },
|
||||
],
|
||||
validFor: /^\S*$/,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
4
core/http/react-ui/src/utils/config.js
vendored
4
core/http/react-ui/src/utils/config.js
vendored
@@ -11,6 +11,9 @@ export const API_CONFIG = {
|
||||
deleteModel: (id) => `/api/models/delete/${id}`,
|
||||
modelConfig: (id) => `/api/models/config/${id}`,
|
||||
modelConfigJson: (name) => `/api/models/config-json/${name}`,
|
||||
configMetadata: '/api/models/config-metadata',
|
||||
configAutocomplete: (provider) => `/api/models/config-metadata/autocomplete/${encodeURIComponent(provider)}`,
|
||||
configPatch: (name) => `/api/models/config-json/${encodeURIComponent(name)}`,
|
||||
modelJob: (uid) => `/api/models/job/${uid}`,
|
||||
|
||||
// Backends gallery
|
||||
@@ -86,6 +89,7 @@ export const API_CONFIG = {
|
||||
modelsReload: '/models/reload',
|
||||
modelsImportUri: '/models/import-uri',
|
||||
modelsImport: '/models/import',
|
||||
vramEstimate: '/api/models/vram-estimate',
|
||||
modelsJobStatus: (uid) => `/models/jobs/${uid}`,
|
||||
modelEditGet: (name) => `/api/models/edit/${name}`,
|
||||
modelEdit: (name) => `/models/edit/${name}`,
|
||||
|
||||
79
core/http/react-ui/src/utils/modelTemplates.js
vendored
Normal file
79
core/http/react-ui/src/utils/modelTemplates.js
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
// Model templates for the "Add Model" create flow.
|
||||
// Each template pre-populates the Model Editor with relevant fields.
|
||||
|
||||
const MODEL_TEMPLATES = [
|
||||
{
|
||||
id: 'other',
|
||||
label: 'Other',
|
||||
icon: 'fa-file-alt',
|
||||
description: 'Blank configuration — add any fields you need',
|
||||
fields: {
|
||||
'name': '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pipeline',
|
||||
label: 'Voice Pipeline',
|
||||
icon: 'fa-diagram-project',
|
||||
description: 'Real-time voice pipeline combining VAD, transcription, LLM, and TTS models',
|
||||
fields: {
|
||||
'name': '',
|
||||
'pipeline.vad': '',
|
||||
'pipeline.transcription': '',
|
||||
'pipeline.llm': '',
|
||||
'pipeline.tts': '',
|
||||
'tts.voice': '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'llm',
|
||||
label: 'LLM',
|
||||
icon: 'fa-brain',
|
||||
description: 'Language model for chat and text completion',
|
||||
fields: {
|
||||
'name': '',
|
||||
'backend': '',
|
||||
'parameters.model': '',
|
||||
'context_size': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tts',
|
||||
label: 'TTS',
|
||||
icon: 'fa-volume-up',
|
||||
description: 'Text-to-speech model for voice synthesis',
|
||||
fields: {
|
||||
'name': '',
|
||||
'backend': '',
|
||||
'parameters.model': '',
|
||||
'tts.voice': '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
label: 'Image Generation',
|
||||
icon: 'fa-image',
|
||||
description: 'Image generation model using diffusers or other backends',
|
||||
fields: {
|
||||
'name': '',
|
||||
'backend': 'diffusers',
|
||||
'parameters.model': '',
|
||||
'diffusers.pipeline_type': '',
|
||||
'diffusers.cuda': false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'embedding',
|
||||
label: 'Embedding',
|
||||
icon: 'fa-vector-square',
|
||||
description: 'Embedding model for text vectorization',
|
||||
fields: {
|
||||
'name': '',
|
||||
'backend': '',
|
||||
'parameters.model': '',
|
||||
'embeddings': true,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default MODEL_TEMPLATES
|
||||
@@ -8,6 +8,10 @@ icon: download
|
||||
|
||||
LocalAI can be installed in multiple ways depending on your platform and preferences.
|
||||
|
||||
## Video Walkthrough
|
||||
|
||||
[](https://www.youtube.com/watch?v=cMVNnlqwfw4)
|
||||
|
||||
## Installation Methods
|
||||
|
||||
Choose the installation method that best suits your needs:
|
||||
|
||||
Reference in New Issue
Block a user