Skip to content

Commit 3435c90

Browse files
committed
fix(modeladmin): show and persist map fields like entity_actions in the editor
Two coupled fixes so detector entity_actions (and other map fields: roles, engine_args) can be viewed and edited in the model config UI: - Editor: flattenConfig stopped recursing past registered map leaves, so a populated entity_actions block now renders with its rows instead of being flattened into invisible dotted scalar paths. The load effect is split into a fetch and a derive step so a late metadata arrival re-flattens without a second fetch, and useConfigMetadata returns stable empty slices. - PatchConfig: replace the mergo deep-merge (which unions maps and never deletes keys) with a metadata-driven patchMerge that overwrites map-typed leaves wholesale. Removing an entity action in the editor and saving now drops it from the YAML; emptying the map removes the field entirely. Assisted-by: claude-code:claude-opus-4-8 [Claude Code]
1 parent 0acd7f8 commit 3435c90

5 files changed

Lines changed: 201 additions & 23 deletions

File tree

core/http/react-ui/e2e/model-config.spec.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const MOCK_METADATA = {
1414
{ 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 },
1515
{ path: 'pii_detection.builtins', yaml_key: 'builtins', go_type: '[]string', ui_type: '[]string', section: 'general', label: 'Built-in Secret Patterns', description: 'Built-in credential patterns', component: 'pii-builtins-select', options: [{ value: 'anthropic_api_key', label: 'anthropic_api_key — Anthropic API key' }, { value: 'github_token', label: 'github_token — GitHub token' }], order: 213 },
1616
{ path: 'pii_detection.patterns', yaml_key: 'patterns', go_type: '[]config.PIIPattern', ui_type: 'object', section: 'general', label: 'Custom Secret Patterns', description: 'Operator-defined restricted-regex patterns', component: 'pii-pattern-list', order: 214 },
17+
{ path: 'pii_detection.entity_actions', yaml_key: 'entity_actions', go_type: 'map[string]string', ui_type: 'map', section: 'general', label: 'Detector Entity Actions', description: 'Per-entity-group action policy', component: 'entity-action-list', order: 212 },
1718
],
1819
}
1920

@@ -287,4 +288,45 @@ test.describe('Model Editor - Interactive Tab', () => {
287288
await expect(page.locator('input[placeholder^="match,"]')).toBeVisible()
288289
})
289290

291+
// Regression: a map-typed field (entity_actions) present in the loaded YAML
292+
// must render WITH its values. flattenConfig used to recurse into the map,
293+
// scattering it across pii_detection.entity_actions.<GROUP> paths that match
294+
// no registered field, so the editor showed neither the field nor the
295+
// per-entity policy (e.g. SSN -> block) the operator had configured.
296+
test('entity_actions map field present in YAML renders with its values', async ({ page }) => {
297+
// Override the edit endpoint for this test: YAML that carries a populated
298+
// entity_actions map alongside a scalar sibling (default_action).
299+
await page.route('**/api/models/edit/ner-model', (route) => {
300+
route.fulfill({
301+
contentType: 'application/json',
302+
body: JSON.stringify({
303+
name: 'ner-model',
304+
config: [
305+
'name: ner-model',
306+
'backend: llama-cpp',
307+
'pii_detection:',
308+
' default_action: mask',
309+
' entity_actions:',
310+
' SSN: block',
311+
' EMAIL: mask',
312+
'',
313+
].join('\n'),
314+
}),
315+
})
316+
})
317+
318+
await page.goto('/app/model-editor/ner-model')
319+
320+
// The entity-action-list editor is rendered (field label visible)…
321+
await expect(page.getByText('Detector Entity Actions').first()).toBeVisible()
322+
// …and bound to the existing map: one row per configured group, in order.
323+
const groupInputs = page.locator('input[aria-label="Entity group"]')
324+
await expect(groupInputs).toHaveCount(2)
325+
await expect(groupInputs.nth(0)).toHaveValue('SSN')
326+
await expect(groupInputs.nth(1)).toHaveValue('EMAIL')
327+
// The action select shows the bound action label (block), proving the map
328+
// values bound, not just an empty editor.
329+
await expect(page.getByText(/block /i).first()).toBeVisible()
330+
})
331+
290332
})

core/http/react-ui/src/hooks/useConfigMetadata.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { useState, useEffect } from 'react'
22
import { modelsApi } from '../utils/api'
33

4+
// Stable empty references so consumers that memoize on `sections`/`fields`
5+
// (e.g. ModelEditor's leafPaths) don't see a new array every render while
6+
// the metadata request is still in flight — which would thrash their effects.
7+
const EMPTY = []
8+
49
export function useConfigMetadata() {
510
const [metadata, setMetadata] = useState(null)
611
const [loading, setLoading] = useState(true)
@@ -14,8 +19,8 @@ export function useConfigMetadata() {
1419
}, [])
1520

1621
return {
17-
sections: metadata?.sections || [],
18-
fields: metadata?.fields || [],
22+
sections: metadata?.sections || EMPTY,
23+
fields: metadata?.fields || EMPTY,
1924
loading,
2025
error,
2126
}

core/http/react-ui/src/pages/ModelEditor.jsx

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,23 @@ const SECTION_COLORS = {
3030
mitm: 'var(--color-warning)', pii: 'var(--color-error)', other: 'var(--color-text-muted)',
3131
}
3232

33-
function flattenConfig(obj, prefix = '') {
33+
// flattenConfig turns a parsed YAML config into a flat { 'a.b.c': value }
34+
// map keyed by the same dotted paths the field registry uses. leafPaths is
35+
// the set of registered schema leaf paths: recursion STOPS at any of them so
36+
// a map-typed field (e.g. pii_detection.entity_actions, a {GROUP: action}
37+
// object) is stored whole at its own path. Without this guard a map's value
38+
// was scattered into `pii_detection.entity_actions.SSN` etc. — paths that
39+
// match no registered field — so the editor rendered neither the field nor
40+
// its values, hiding per-entity policy like SSN→block from the operator.
41+
function flattenConfig(obj, leafPaths, prefix = '') {
3442
const result = {}
3543
if (!obj || typeof obj !== 'object') return result
3644
for (const [key, val] of Object.entries(obj)) {
3745
const path = prefix ? `${prefix}.${key}` : key
38-
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
39-
Object.assign(result, flattenConfig(val, path))
46+
if (leafPaths && leafPaths.has(path)) {
47+
result[path] = val
48+
} else if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
49+
Object.assign(result, flattenConfig(val, leafPaths, path))
4050
} else {
4151
result[path] = val
4252
}
@@ -80,6 +90,16 @@ export default function ModelEditor() {
8090
const { addToast } = useOutletContext()
8191
const { sections, fields, loading: metaLoading, error: metaError } = useConfigMetadata()
8292

93+
// Registered schema leaf paths. flattenConfig stops recursing at these so
94+
// map-typed fields (e.g. pii_detection.entity_actions) bind as a whole
95+
// object to their registered editor instead of vanishing into sub-paths.
96+
const leafPaths = useMemo(() => new Set(fields.map(f => f.path)), [fields])
97+
98+
// The parsed (not-yet-flattened) config loaded from the server. Flattening
99+
// is deferred to a separate effect keyed on leafPaths so the schema metadata
100+
// can arrive after the config without a fetch race re-clobbering values.
101+
const [loadedConfig, setLoadedConfig] = useState(null)
102+
83103
const isCreateMode = !name
84104
const [selectedTemplate, setSelectedTemplate] = useState(null)
85105

@@ -121,34 +141,39 @@ export default function ModelEditor() {
121141
}
122142
}, [isCreateMode, searchParams, handleSelectTemplate])
123143

124-
// Load raw YAML config (edit mode only)
144+
// Load raw YAML config (edit mode only). This only fetches + parses; the
145+
// flatten-into-form-values step is the separate effect below so it can
146+
// re-run when the schema metadata (leafPaths) resolves without re-fetching.
125147
useEffect(() => {
126148
if (!name) return
127149
modelsApi.getEditConfig(name)
128150
.then(data => {
129151
const raw = data?.config || ''
130152
setYamlText(raw)
131153
setSavedYamlText(raw)
132-
133-
// Parse YAML to get only the fields actually present in the file
134154
try {
135-
const parsed = YAML.parse(raw)
136-
const flat = flattenConfig(parsed || {})
137-
const active = new Set(Object.keys(flat))
138-
setValues(flat)
139-
setInitialValues(structuredClone(flat))
140-
setActiveFieldPaths(active)
155+
setLoadedConfig(YAML.parse(raw) || {})
141156
} catch {
142-
// If YAML parsing fails, start with empty state
143-
setValues({})
144-
setInitialValues({})
145-
setActiveFieldPaths(new Set())
157+
setLoadedConfig({})
146158
}
147159
})
148160
.catch(err => addToast(`Failed to load config: ${err.message}`, 'error'))
149161
.finally(() => setConfigLoading(false))
150162
}, [name, addToast])
151163

164+
// Flatten the loaded config into form values. Keyed on leafPaths so a late
165+
// schema-metadata resolution re-flattens (keeping map fields whole) WITHOUT
166+
// re-fetching — avoiding a two-fetch race that could clobber values. Only
167+
// fires on (re)load: loadedConfig changes per model, leafPaths is stable
168+
// once metadata is in, so this never stomps in-progress edits.
169+
useEffect(() => {
170+
if (loadedConfig === null) return
171+
const flat = flattenConfig(loadedConfig, leafPaths)
172+
setValues(flat)
173+
setInitialValues(structuredClone(flat))
174+
setActiveFieldPaths(new Set(Object.keys(flat)))
175+
}, [loadedConfig, leafPaths])
176+
152177
// Build field lookup
153178
const fieldsByPath = useMemo(() => {
154179
const map = {}
@@ -323,7 +348,7 @@ export default function ModelEditor() {
323348
try {
324349
const parsed = YAML.parse(yamlText)
325350
parsedName = parsed?.name ?? null
326-
const flat = flattenConfig(parsed || {})
351+
const flat = flattenConfig(parsed || {}, leafPaths)
327352
setValues(flat)
328353
setInitialValues(structuredClone(flat))
329354
setActiveFieldPaths(new Set(Object.keys(flat)))

core/services/modeladmin/config.go

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import (
66
"fmt"
77
"os"
88
"path/filepath"
9+
"reflect"
910
"strings"
1011

11-
"dario.cat/mergo"
1212
"gopkg.in/yaml.v3"
1313

1414
"github.com/mudler/LocalAI/core/config"
15+
"github.com/mudler/LocalAI/core/config/meta"
1516
"github.com/mudler/LocalAI/core/gallery"
1617
"github.com/mudler/LocalAI/pkg/model"
1718
"github.com/mudler/LocalAI/pkg/utils"
@@ -114,9 +115,7 @@ func (s *ConfigService) PatchConfig(_ context.Context, name string, patch map[st
114115
if existingMap == nil {
115116
existingMap = map[string]any{}
116117
}
117-
if err := mergo.Merge(&existingMap, patch, mergo.WithOverride); err != nil {
118-
return nil, fmt.Errorf("merge configs: %w", err)
119-
}
118+
patchMerge(existingMap, patch, mapLeafFieldPaths(), "")
120119
yamlData, err := yaml.Marshal(existingMap)
121120
if err != nil {
122121
return nil, fmt.Errorf("marshal merged YAML: %w", err)
@@ -142,6 +141,55 @@ func (s *ConfigService) PatchConfig(_ context.Context, name string, patch map[st
142141
return &updated, nil
143142
}
144143

144+
// mapLeafFieldPaths returns the set of dotted config paths whose schema type is
145+
// a map that the editor edits as one complete value (e.g.
146+
// pii_detection.entity_actions, roles, engine_args). A PATCH must REPLACE these
147+
// wholesale rather than union them: the deep-merge only adds and overrides
148+
// keys, so a map entry the admin deleted in the editor would otherwise silently
149+
// survive. Derived from the config schema so it stays correct as map fields are
150+
// added. (UIType comes from reflection, independent of any registry override.)
151+
func mapLeafFieldPaths() map[string]struct{} {
152+
md := meta.BuildConfigMetadata(reflect.TypeFor[config.ModelConfig]())
153+
out := make(map[string]struct{})
154+
for _, f := range md.Fields {
155+
if f.UIType == "map" {
156+
out[f.Path] = struct{}{}
157+
}
158+
}
159+
return out
160+
}
161+
162+
// patchMerge deep-merges src into dst with the same shape as the previous
163+
// mergo.WithOverride behaviour — scalars and slices replace; nested
164+
// struct-maps (e.g. pii_detection, parameters) recurse so unknown sibling keys
165+
// the editor doesn't model survive — EXCEPT that any path in mapLeaves is
166+
// replaced wholesale, and removed when the patch sets it empty, so deletions
167+
// inside a map field persist to disk.
168+
func patchMerge(dst, src map[string]any, mapLeaves map[string]struct{}, prefix string) {
169+
for k, sv := range src {
170+
path := k
171+
if prefix != "" {
172+
path = prefix + "." + k
173+
}
174+
if _, isLeaf := mapLeaves[path]; isLeaf {
175+
if m, ok := sv.(map[string]any); ok && len(m) == 0 {
176+
delete(dst, k) // emptied map field -> drop it from the YAML
177+
} else {
178+
dst[k] = sv
179+
}
180+
continue
181+
}
182+
// Recurse into struct-like nesting so dst-only sibling keys survive.
183+
if sm, ok := sv.(map[string]any); ok {
184+
if dm, ok2 := dst[k].(map[string]any); ok2 {
185+
patchMerge(dm, sm, mapLeaves, path)
186+
continue
187+
}
188+
}
189+
dst[k] = sv
190+
}
191+
}
192+
145193
// EditYAML replaces the YAML for an installed model, with optional rename
146194
// support. ml may be nil; when set, EditYAML calls ml.ShutdownModel(oldName)
147195
// after a successful write so the next inference picks up the new config.

core/services/modeladmin/config_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,64 @@ var _ = Describe("ConfigService", func() {
107107
_, err := svc.PatchConfig(ctx, "qwen", map[string]any{})
108108
Expect(err).To(MatchError(ErrEmptyBody))
109109
})
110+
111+
It("replaces a map field wholesale so deleted entries do not survive", func() {
112+
// A detector model with a populated entity_actions map. The editor
113+
// removes SSN and re-sends the remaining map; a naive deep-merge
114+
// would re-add SSN (it only adds/overrides keys, never deletes).
115+
writeModelYAML(svc, dir, "ner", map[string]any{
116+
"backend": "llama-cpp",
117+
"known_usecases": []any{"token_classify"},
118+
"pii_detection": map[string]any{
119+
"default_action": "mask",
120+
"entity_actions": map[string]any{"SSN": "block", "EMAIL": "mask"},
121+
},
122+
})
123+
124+
_, err := svc.PatchConfig(ctx, "ner", map[string]any{
125+
"pii_detection": map[string]any{
126+
"default_action": "mask",
127+
"entity_actions": map[string]any{"EMAIL": "mask"},
128+
},
129+
})
130+
Expect(err).ToNot(HaveOccurred())
131+
132+
raw, err := os.ReadFile(filepath.Join(dir, "ner.yaml"))
133+
Expect(err).ToNot(HaveOccurred())
134+
var got map[string]any
135+
Expect(yaml.Unmarshal(raw, &got)).To(Succeed())
136+
pii := got["pii_detection"].(map[string]any)
137+
ea := pii["entity_actions"].(map[string]any)
138+
Expect(ea).To(HaveKeyWithValue("EMAIL", "mask"))
139+
Expect(ea).NotTo(HaveKey("SSN"), "deleted map entry must not survive the patch")
140+
// The scalar sibling in the same nested block is still preserved.
141+
Expect(pii).To(HaveKeyWithValue("default_action", "mask"))
142+
})
143+
144+
It("drops a map field entirely when the patch empties it", func() {
145+
writeModelYAML(svc, dir, "ner", map[string]any{
146+
"backend": "llama-cpp",
147+
"known_usecases": []any{"token_classify"},
148+
"pii_detection": map[string]any{
149+
"default_action": "mask",
150+
"entity_actions": map[string]any{"SSN": "block"},
151+
},
152+
})
153+
154+
_, err := svc.PatchConfig(ctx, "ner", map[string]any{
155+
"pii_detection": map[string]any{
156+
"entity_actions": map[string]any{},
157+
},
158+
})
159+
Expect(err).ToNot(HaveOccurred())
160+
161+
raw, err := os.ReadFile(filepath.Join(dir, "ner.yaml"))
162+
Expect(err).ToNot(HaveOccurred())
163+
var got map[string]any
164+
Expect(yaml.Unmarshal(raw, &got)).To(Succeed())
165+
pii := got["pii_detection"].(map[string]any)
166+
Expect(pii).NotTo(HaveKey("entity_actions"))
167+
})
110168
})
111169

112170
Describe("EditYAML", func() {

0 commit comments

Comments
 (0)