feat: add group field to SchemaField for grouped display in Android UI
Propagate enclosing struct label tags as a group field on leaf SchemaFields so the Android Detail screen can render group headers with dividers (e.g. "WhatsApp", "Discord" under channels). Suppress redundant single-group headers (e.g. agents/Defaults). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
598971e282
commit
76d669efb5
6 changed files with 95 additions and 5 deletions
|
|
@ -31,6 +31,7 @@ data class SchemaSection(val key: String, val label: String, val fields: List<Sc
|
||||||
data class SchemaField(
|
data class SchemaField(
|
||||||
val key: String,
|
val key: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
|
val group: String = "",
|
||||||
val type: String,
|
val type: String,
|
||||||
val secret: Boolean = false,
|
val secret: Boolean = false,
|
||||||
val default: JsonElement = Json.parseToJsonElement("null"),
|
val default: JsonElement = Json.parseToJsonElement("null"),
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
|
@ -22,6 +24,7 @@ import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -150,7 +153,21 @@ fun ConfigSectionDetailScreen(
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
|
var lastGroup: String? = null
|
||||||
detail.fields.forEach { field ->
|
detail.fields.forEach { field ->
|
||||||
|
if (field.group != lastGroup) {
|
||||||
|
lastGroup = field.group
|
||||||
|
if (field.group.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
HorizontalDivider(color = GlassBorder)
|
||||||
|
Text(
|
||||||
|
text = field.group,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = NeonCyan,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
ConfigField(
|
ConfigField(
|
||||||
field = field,
|
field = field,
|
||||||
onValueChanged = { viewModel.onFieldValueChanged(field.key, it) },
|
onValueChanged = { viewModel.onFieldValueChanged(field.key, it) },
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ data class SectionSummary(val key: String, val label: String, val fieldCount: In
|
||||||
data class FieldState(
|
data class FieldState(
|
||||||
val key: String,
|
val key: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
|
val group: String = "",
|
||||||
val type: String,
|
val type: String,
|
||||||
val secret: Boolean,
|
val secret: Boolean,
|
||||||
val value: String,
|
val value: String,
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ class ConfigViewModel(private val apiClient: ConfigApiClient) : ViewModel() {
|
||||||
FieldState(
|
FieldState(
|
||||||
key = field.key,
|
key = field.key,
|
||||||
label = field.label,
|
label = field.label,
|
||||||
|
group = field.group,
|
||||||
type = field.type,
|
type = field.type,
|
||||||
secret = field.secret,
|
secret = field.secret,
|
||||||
value = display,
|
value = display,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
type SchemaField struct {
|
type SchemaField struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
Group string `json:"group,omitempty"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Secret bool `json:"secret"`
|
Secret bool `json:"secret"`
|
||||||
Default interface{} `json:"default"`
|
Default interface{} `json:"default"`
|
||||||
|
|
@ -70,7 +71,21 @@ func BuildSchema(defaultCfg *config.Config) SchemaResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldVal := cfgVal.Field(i)
|
fieldVal := cfgVal.Field(i)
|
||||||
section.Fields = buildFields(field.Type, fieldVal, "")
|
section.Fields = buildFields(field.Type, fieldVal, "", "")
|
||||||
|
|
||||||
|
// If every field shares the same single group, the header is redundant — clear it.
|
||||||
|
groups := map[string]bool{}
|
||||||
|
for _, f := range section.Fields {
|
||||||
|
if f.Group != "" {
|
||||||
|
groups[f.Group] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(groups) <= 1 {
|
||||||
|
for j := range section.Fields {
|
||||||
|
section.Fields[j].Group = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sections = append(sections, section)
|
sections = append(sections, section)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,8 +93,9 @@ func BuildSchema(defaultCfg *config.Config) SchemaResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildFields recursively collects fields from a struct type, flattening nested structs
|
// buildFields recursively collects fields from a struct type, flattening nested structs
|
||||||
// with dot-separated key prefixes.
|
// with dot-separated key prefixes. The group parameter propagates the label of the
|
||||||
func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField {
|
// enclosing struct so that leaf fields can be grouped under a header in the UI.
|
||||||
|
func buildFields(t reflect.Type, v reflect.Value, prefix string, group string) []SchemaField {
|
||||||
var fields []SchemaField
|
var fields []SchemaField
|
||||||
|
|
||||||
if t.Kind() == reflect.Ptr {
|
if t.Kind() == reflect.Ptr {
|
||||||
|
|
@ -122,8 +138,13 @@ func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField {
|
||||||
|
|
||||||
schemaType := goTypeToSchema(ft)
|
schemaType := goTypeToSchema(ft)
|
||||||
if schemaType == "object" {
|
if schemaType == "object" {
|
||||||
// Nested struct: recurse and flatten
|
// Nested struct: recurse and flatten.
|
||||||
fields = append(fields, buildFields(ft, fieldVal, fullKey)...)
|
// Use the nested struct's label tag as group; fall back to current group.
|
||||||
|
childGroup := labelTag(sf)
|
||||||
|
if childGroup == "" {
|
||||||
|
childGroup = group
|
||||||
|
}
|
||||||
|
fields = append(fields, buildFields(ft, fieldVal, fullKey, childGroup)...)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,6 +161,7 @@ func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField {
|
||||||
fields = append(fields, SchemaField{
|
fields = append(fields, SchemaField{
|
||||||
Key: fullKey,
|
Key: fullKey,
|
||||||
Label: labelTag(sf),
|
Label: labelTag(sf),
|
||||||
|
Group: group,
|
||||||
Type: schemaType,
|
Type: schemaType,
|
||||||
Secret: secretKeys[jk],
|
Secret: secretKeys[jk],
|
||||||
Default: defVal,
|
Default: defVal,
|
||||||
|
|
|
||||||
|
|
@ -2435,3 +2435,51 @@ func lastDot(s string) int {
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildSchema_FieldGroups(t *testing.T) {
|
||||||
|
schema := BuildSchema(config.DefaultConfig())
|
||||||
|
|
||||||
|
// Build a map from "section.fieldKey" → group
|
||||||
|
fieldGroup := map[string]string{}
|
||||||
|
for _, sec := range schema.Sections {
|
||||||
|
for _, f := range sec.Fields {
|
||||||
|
fieldGroup[sec.Key+"."+f.Key] = f.Group
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
fullKey string
|
||||||
|
wantGroup string
|
||||||
|
}{
|
||||||
|
// channels: each sub-struct label becomes group
|
||||||
|
{"channels.whatsapp.enabled", "WhatsApp"},
|
||||||
|
{"channels.discord.token", "Discord"},
|
||||||
|
{"channels.line.channel_secret", "LINE"},
|
||||||
|
{"channels.telegram.token", "Telegram"},
|
||||||
|
{"channels.slack.bot_token", "Slack"},
|
||||||
|
{"channels.websocket.enabled", "WebSocket"},
|
||||||
|
// tools: deeper nesting uses the innermost struct label
|
||||||
|
{"tools.web.brave.api_key", "Brave Search"},
|
||||||
|
{"tools.web.brave.enabled", "Brave Search"},
|
||||||
|
{"tools.web.duckduckgo.enabled", "DuckDuckGo"},
|
||||||
|
{"tools.exec.enabled", "Shell Exec"},
|
||||||
|
{"tools.android.enabled", "Android"},
|
||||||
|
{"tools.memory.enabled", "Memory"},
|
||||||
|
// llm: flat fields have no group
|
||||||
|
{"llm.model", ""},
|
||||||
|
{"llm.api_key", ""},
|
||||||
|
// agents: single intermediate struct — group suppressed
|
||||||
|
{"agents.defaults.max_tokens", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, ok := fieldGroup[tt.fullKey]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("field %q not found in schema", tt.fullKey)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != tt.wantGroup {
|
||||||
|
t.Errorf("field %q group = %q, want %q", tt.fullKey, got, tt.wantGroup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue