mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 19:54:46 +00:00
fix(mac): speed up config settings
This commit is contained in:
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.
|
||||
- Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting.
|
||||
- Gateway/protocol: restore Gateway WS protocol v4 and keep `message.action` room-event metadata on the existing `inboundTurnKind` wire field while preserving internal inbound-event classification.
|
||||
- Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.
|
||||
- Codex: sanitize inline image payloads before Codex app-server and OpenAI Responses replay, and clear poisoned Codex thread bindings after invalid image errors. Fixes #82878.
|
||||
- Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.
|
||||
- Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)
|
||||
|
||||
@@ -37,6 +37,38 @@ extension ChannelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func loadConfigSchemaLookup(path: String, force: Bool = false) async -> ConfigSchemaLookupNode? {
|
||||
let sourceKey = self.currentConfigCacheSourceKey()
|
||||
self.resetConfigSchemaCacheIfSourceChanged(sourceKey)
|
||||
let normalizedPath = Self.normalizeConfigLookupPath(path)
|
||||
if !force, let cached = self.configLookupNode(path: normalizedPath) {
|
||||
return cached
|
||||
}
|
||||
if self.configLookupLoadingPaths.contains(normalizedPath) {
|
||||
return self.configLookupNode(path: normalizedPath)
|
||||
}
|
||||
|
||||
self.configLookupLoadingPaths.insert(normalizedPath)
|
||||
defer { self.configLookupLoadingPaths.remove(normalizedPath) }
|
||||
|
||||
do {
|
||||
let res: ConfigSchemaLookupResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configSchemaLookup,
|
||||
params: ["path": AnyCodable(normalizedPath)],
|
||||
timeoutMs: 5000)
|
||||
guard let node = self.makeConfigLookupNode(res) else {
|
||||
self.configStatus = "Config schema lookup returned an unsupported payload."
|
||||
return nil
|
||||
}
|
||||
self.applyConfigLookupNode(node, sourceKey: sourceKey)
|
||||
return node
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig(force: Bool = true) async {
|
||||
let sourceKey = self.currentConfigCacheSourceKey()
|
||||
self.resetConfigCacheIfSourceChanged(sourceKey)
|
||||
@@ -100,6 +132,44 @@ extension ChannelsStore {
|
||||
self.configSchemaSourceKey = sourceKey
|
||||
}
|
||||
|
||||
func configLookupNode(path: String) -> ConfigSchemaLookupNode? {
|
||||
let normalizedPath = Self.normalizeConfigLookupPath(path)
|
||||
if normalizedPath == "." {
|
||||
return self.configLookupRoot
|
||||
}
|
||||
return self.configLookupCache[normalizedPath]
|
||||
}
|
||||
|
||||
func makeConfigLookupNode(_ res: ConfigSchemaLookupResult) -> ConfigSchemaLookupNode? {
|
||||
let schemaValue = res.schema.foundationValue
|
||||
guard let schema = ConfigSchemaNode(raw: schemaValue) else { return nil }
|
||||
let hint = res.hint.map { ConfigUiHint(raw: $0.mapValues(\.foundationValue)) }
|
||||
let children = res.children.compactMap(ConfigSchemaLookupChild.init(raw:))
|
||||
return ConfigSchemaLookupNode(
|
||||
path: Self.normalizeConfigLookupPath(res.path),
|
||||
schema: schema,
|
||||
hint: hint,
|
||||
hintPath: res.hintpath,
|
||||
children: children)
|
||||
}
|
||||
|
||||
func applyConfigLookupNode(_ node: ConfigSchemaLookupNode, sourceKey: String) {
|
||||
guard self.configSchemaSourceKey == sourceKey else { return }
|
||||
if node.path == "." {
|
||||
self.configLookupRoot = node
|
||||
} else {
|
||||
self.configLookupCache[node.path] = node
|
||||
}
|
||||
if let hint = node.hint {
|
||||
self.configUiHints[node.path] = hint
|
||||
}
|
||||
for child in node.children {
|
||||
if let hint = child.hint {
|
||||
self.configUiHints[child.path] = hint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyUIConfig(_ snap: ConfigSnapshot) {
|
||||
let ui = snap.config?["ui"]?.dictionaryValue
|
||||
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -154,6 +224,9 @@ extension ChannelsStore {
|
||||
}
|
||||
guard cachedSourceKey != sourceKey else { return }
|
||||
self.configSchema = nil
|
||||
self.configLookupRoot = nil
|
||||
self.configLookupCache.removeAll(keepingCapacity: true)
|
||||
self.configLookupLoadingPaths.removeAll(keepingCapacity: true)
|
||||
self.configUiHints = [:]
|
||||
self.configSchemaSourceKey = sourceKey
|
||||
}
|
||||
@@ -204,6 +277,11 @@ extension ChannelsStore {
|
||||
].joined(separator: "|")
|
||||
}
|
||||
|
||||
static func normalizeConfigLookupPath(_ path: String) -> String {
|
||||
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "." : trimmed
|
||||
}
|
||||
|
||||
private static func configFingerprint(_ value: Any?) -> String {
|
||||
guard let value else { return "nil" }
|
||||
if JSONSerialization.isValidJSONObject(value),
|
||||
|
||||
@@ -219,6 +219,51 @@ struct ConfigSnapshot: Codable {
|
||||
let issues: [Issue]?
|
||||
}
|
||||
|
||||
struct ConfigSchemaLookupChild: Identifiable {
|
||||
let key: String
|
||||
let path: String
|
||||
let typeLabel: String?
|
||||
let required: Bool
|
||||
let hasChildren: Bool
|
||||
let hint: ConfigUiHint?
|
||||
let hintPath: String?
|
||||
|
||||
var id: String { self.path }
|
||||
|
||||
init?(raw: [String: AnyCodable]) {
|
||||
guard let key = raw["key"]?.stringValue,
|
||||
let path = raw["path"]?.stringValue
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
self.key = key
|
||||
self.path = path
|
||||
if let type = raw["type"]?.stringValue {
|
||||
self.typeLabel = type
|
||||
} else if let types = raw["type"]?.arrayValue {
|
||||
self.typeLabel = types.compactMap(\.stringValue).joined(separator: " / ")
|
||||
} else {
|
||||
self.typeLabel = nil
|
||||
}
|
||||
self.required = raw["required"]?.boolValue ?? false
|
||||
self.hasChildren = raw["hasChildren"]?.boolValue ?? false
|
||||
if let hint = raw["hint"]?.dictionaryValue {
|
||||
self.hint = ConfigUiHint(raw: hint.mapValues(\.foundationValue))
|
||||
} else {
|
||||
self.hint = nil
|
||||
}
|
||||
self.hintPath = raw["hintPath"]?.stringValue
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigSchemaLookupNode {
|
||||
let path: String
|
||||
let schema: ConfigSchemaNode
|
||||
let hint: ConfigUiHint?
|
||||
let hintPath: String?
|
||||
let children: [ConfigSchemaLookupChild]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ChannelsStore {
|
||||
@@ -243,6 +288,9 @@ final class ChannelsStore {
|
||||
var isSavingConfig = false
|
||||
var configSchemaLoading = false
|
||||
var configSchema: ConfigSchemaNode?
|
||||
var configLookupRoot: ConfigSchemaLookupNode?
|
||||
var configLookupCache: [String: ConfigSchemaLookupNode] = [:]
|
||||
var configLookupLoadingPaths: Set<String> = []
|
||||
var configUiHints: [String: ConfigUiHint] = [:]
|
||||
var configSchemaSourceKey: String?
|
||||
var configSchemaLoadingSourceKey: String?
|
||||
|
||||
@@ -6,8 +6,8 @@ struct ConfigSettings: View {
|
||||
private let isNixMode = ProcessInfo.processInfo.isNixMode
|
||||
@Bindable var store: ChannelsStore
|
||||
@State private var hasLoaded = false
|
||||
@State private var activeSectionKey: String?
|
||||
@State private var activeSubsection: SubsectionSelection?
|
||||
@State private var activePath: String?
|
||||
@State private var failedLookupPaths: Set<String> = []
|
||||
|
||||
init(store: ChannelsStore = .shared) {
|
||||
self.store = store
|
||||
@@ -23,30 +23,32 @@ struct ConfigSettings: View {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
self.hasLoaded = true
|
||||
await self.store.loadConfigSchema()
|
||||
await self.store.loadConfig(force: false)
|
||||
Task { await self.store.loadConfig(force: false) }
|
||||
_ = await self.store.loadConfigSchemaLookup(path: ".")
|
||||
self.ensureSelection()
|
||||
}
|
||||
.task(id: self.activePath) {
|
||||
guard let activePath = self.activePath else { return }
|
||||
await self.loadPath(activePath)
|
||||
}
|
||||
.onAppear { self.ensureSelection() }
|
||||
.onChange(of: self.store.configSchemaLoading) { _, loading in
|
||||
if !loading { self.ensureSelection() }
|
||||
.onChange(of: self.store.configLookupRoot?.path) { _, _ in
|
||||
self.failedLookupPaths.removeAll()
|
||||
self.ensureSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigSettings {
|
||||
private enum SubsectionSelection: Hashable {
|
||||
case all
|
||||
case key(String)
|
||||
}
|
||||
|
||||
private struct ConfigSection: Identifiable {
|
||||
let key: String
|
||||
let label: String
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
let path: String
|
||||
let hasChildren: Bool
|
||||
|
||||
var id: String {
|
||||
self.key
|
||||
self.path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,21 +56,22 @@ extension ConfigSettings {
|
||||
let key: String
|
||||
let label: String
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
let path: String
|
||||
let hasChildren: Bool
|
||||
|
||||
var id: String {
|
||||
self.key
|
||||
self.path
|
||||
}
|
||||
}
|
||||
|
||||
private var sections: [ConfigSection] {
|
||||
guard let schema = self.store.configSchema else { return [] }
|
||||
return self.resolveSections(schema)
|
||||
guard let root = self.store.configLookupRoot else { return [] }
|
||||
return self.resolveSections(root.children)
|
||||
}
|
||||
|
||||
private var activeSection: ConfigSection? {
|
||||
self.sections.first { $0.key == self.activeSectionKey }
|
||||
guard let activePath = self.activePath else { return nil }
|
||||
return self.sections.first { activePath == $0.path || activePath.hasPrefix("\($0.path).") }
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
@@ -91,16 +94,16 @@ extension ConfigSettings {
|
||||
|
||||
private var detail: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if self.store.configSchemaLoading {
|
||||
if self.store.configLookupRoot == nil,
|
||||
!self.hasLoaded || self.store.configLookupLoadingPaths.contains(".")
|
||||
{
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let section = self.activeSection {
|
||||
self.sectionDetail(section)
|
||||
} else if self.store.configSchema != nil {
|
||||
} else if self.store.configLookupRoot != nil {
|
||||
self.emptyDetail
|
||||
} else {
|
||||
Text("Schema unavailable.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
self.schemaUnavailableDetail
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
@@ -117,6 +120,18 @@ extension ConfigSettings {
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
private var schemaUnavailableDetail: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
self.header
|
||||
Text(self.store.configStatus ?? "Schema unavailable.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
self.actionRow
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
|
||||
private func sectionDetail(_ section: ConfigSection) -> some View {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
@@ -176,13 +191,13 @@ extension ConfigSettings {
|
||||
Button(self.store.isSavingConfig ? "Saving…" : "Save") {
|
||||
Task { await self.store.saveConfigDraft() }
|
||||
}
|
||||
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configDirty)
|
||||
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configLoaded || !self.store.configDirty)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
private func sidebarSection(_ section: ConfigSection) -> some View {
|
||||
let isExpanded = self.activeSectionKey == section.key
|
||||
let isExpanded = self.activePath == section.path || self.activePath?.hasPrefix("\(section.path).") == true
|
||||
let subsections = isExpanded ? self.resolveSubsections(for: section) : []
|
||||
|
||||
return VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -200,7 +215,7 @@ extension ConfigSettings {
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(isExpanded && subsections.isEmpty
|
||||
.background(self.activePath == section.path
|
||||
? Color.accentColor.opacity(0.18)
|
||||
: Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
@@ -211,9 +226,8 @@ extension ConfigSettings {
|
||||
|
||||
if isExpanded, !subsections.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
self.sidebarSubRow(title: "All", key: nil, sectionKey: section.key)
|
||||
ForEach(subsections) { sub in
|
||||
self.sidebarSubRow(title: sub.label, key: sub.key, sectionKey: section.key)
|
||||
self.sidebarSubRow(sub)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 20)
|
||||
@@ -223,21 +237,12 @@ extension ConfigSettings {
|
||||
.animation(.easeInOut(duration: 0.18), value: isExpanded)
|
||||
}
|
||||
|
||||
private func sidebarSubRow(title: String, key: String?, sectionKey: String) -> some View {
|
||||
let isSelected: Bool = {
|
||||
guard self.activeSectionKey == sectionKey else { return false }
|
||||
if let key { return self.activeSubsection == .key(key) }
|
||||
return self.activeSubsection == .all
|
||||
}()
|
||||
|
||||
private func sidebarSubRow(_ subsection: ConfigSubsection) -> some View {
|
||||
let isSelected = self.activePath == subsection.path
|
||||
return Button {
|
||||
if let key {
|
||||
self.activeSubsection = .key(key)
|
||||
} else {
|
||||
self.activeSubsection = .all
|
||||
}
|
||||
self.selectPath(subsection.path)
|
||||
} label: {
|
||||
Text(title)
|
||||
Text(subsection.label)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
.padding(.vertical, 4)
|
||||
@@ -252,123 +257,191 @@ extension ConfigSettings {
|
||||
}
|
||||
|
||||
private func sectionForm(_ section: ConfigSection) -> some View {
|
||||
let subsection = self.activeSubsection
|
||||
let defaultPath: ConfigPath = [.key(section.key)]
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
let resolved: (ConfigSchemaNode, ConfigPath) = {
|
||||
if case let .key(key) = subsection,
|
||||
let match = subsections.first(where: { $0.key == key })
|
||||
{
|
||||
return (match.node, match.path)
|
||||
let path = self.activePath ?? section.path
|
||||
if self.store.configLookupLoadingPaths.contains(path) {
|
||||
return AnyView(ProgressView().controlSize(.small))
|
||||
}
|
||||
guard let node = self.store.configLookupNode(path: path) else {
|
||||
if self.failedLookupPaths.contains(path) {
|
||||
return AnyView(self.lookupUnavailable(path: path))
|
||||
}
|
||||
return (self.resolvedSchemaNode(section.node), defaultPath)
|
||||
}()
|
||||
|
||||
return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1)
|
||||
.disabled(self.isNixMode)
|
||||
return AnyView(ProgressView().controlSize(.small))
|
||||
}
|
||||
if !node.children.isEmpty, !Self.shouldRenderFormEditor(for: node.schema) {
|
||||
return AnyView(self.lookupChildrenList(node))
|
||||
}
|
||||
guard self.store.configLoaded else {
|
||||
return AnyView(
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Loading current values…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
})
|
||||
}
|
||||
guard let configPath = Self.configPath(from: node.path) else {
|
||||
return AnyView(
|
||||
Text("Wildcard config entries are edited from their concrete key.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary))
|
||||
}
|
||||
return AnyView(
|
||||
ConfigSchemaForm(store: self.store, schema: node.schema, path: configPath)
|
||||
.disabled(self.isNixMode))
|
||||
}
|
||||
|
||||
private func ensureSelection() {
|
||||
guard let schema = self.store.configSchema else { return }
|
||||
let sections = self.resolveSections(schema)
|
||||
let sections = self.sections
|
||||
guard !sections.isEmpty else { return }
|
||||
|
||||
let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0]
|
||||
if self.activeSectionKey != active.key {
|
||||
self.activeSectionKey = active.key
|
||||
}
|
||||
self.ensureSubsection(for: active)
|
||||
}
|
||||
|
||||
private func ensureSubsection(for section: ConfigSection) {
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
guard !subsections.isEmpty else {
|
||||
self.activeSubsection = nil
|
||||
if let activePath = self.activePath,
|
||||
sections.contains(where: { activePath == $0.path || activePath.hasPrefix("\($0.path).") })
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
switch self.activeSubsection {
|
||||
case .all:
|
||||
return
|
||||
case let .key(key):
|
||||
if subsections.contains(where: { $0.key == key }) { return }
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
if let first = subsections.first {
|
||||
self.activeSubsection = .key(first.key)
|
||||
}
|
||||
self.selectSection(sections[0])
|
||||
}
|
||||
|
||||
private func selectSection(_ section: ConfigSection) {
|
||||
guard self.activeSectionKey != section.key else { return }
|
||||
self.activeSectionKey = section.key
|
||||
let subsections = self.resolveSubsections(for: section)
|
||||
if let first = subsections.first {
|
||||
self.activeSubsection = .key(first.key)
|
||||
} else {
|
||||
self.activeSubsection = nil
|
||||
self.activePath = section.path
|
||||
}
|
||||
|
||||
private func selectPath(_ path: String) {
|
||||
self.activePath = path
|
||||
}
|
||||
|
||||
private func lookupUnavailable(path: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(self.store.configStatus ?? "Schema unavailable.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry") {
|
||||
self.failedLookupPaths.remove(path)
|
||||
Task { await self.loadPath(path) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] {
|
||||
let node = self.resolvedSchemaNode(root)
|
||||
let hints = self.store.configUiHints
|
||||
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0
|
||||
let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
@ViewBuilder
|
||||
private func lookupChildrenList(_ node: ConfigSchemaLookupNode) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(node.children) { child in
|
||||
Button {
|
||||
self.selectPath(child.path)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.label(for: child))
|
||||
.font(.callout.weight(.semibold))
|
||||
if let help = child.hint?.help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
} else if let type = child.typeLabel {
|
||||
Text(type)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if child.required {
|
||||
Text("Required")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Image(systemName: child.hasChildren ? "chevron.right" : "slider.horizontal.3")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.primary.opacity(0.04))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys.compactMap { key in
|
||||
guard let child = node.properties[key] else { return nil }
|
||||
let path: ConfigPath = [.key(key)]
|
||||
let hint = hintForPath(path, hints: hints)
|
||||
let label = hint?.label
|
||||
?? child.title
|
||||
?? self.humanize(key)
|
||||
let help = hint?.help ?? child.description
|
||||
return ConfigSection(key: key, label: label, help: help, node: child)
|
||||
}
|
||||
private func resolveSections(_ children: [ConfigSchemaLookupChild]) -> [ConfigSection] {
|
||||
children
|
||||
.sorted(by: self.sortLookupChildren)
|
||||
.map { child in
|
||||
ConfigSection(
|
||||
key: child.key,
|
||||
label: self.label(for: child),
|
||||
help: child.hint?.help,
|
||||
path: child.path,
|
||||
hasChildren: child.hasChildren)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
|
||||
let node = self.resolvedSchemaNode(section.node)
|
||||
guard node.schemaType == "object" else { return [] }
|
||||
let hints = self.store.configUiHints
|
||||
let keys = node.properties.keys.sorted { lhs, rhs in
|
||||
let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0
|
||||
let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs < rhs
|
||||
guard let node = self.store.configLookupNode(path: section.path) else {
|
||||
return []
|
||||
}
|
||||
return node.children
|
||||
.sorted(by: self.sortLookupChildren)
|
||||
.map { child in
|
||||
ConfigSubsection(
|
||||
key: child.key,
|
||||
label: self.label(for: child),
|
||||
help: child.hint?.help,
|
||||
path: child.path,
|
||||
hasChildren: child.hasChildren)
|
||||
}
|
||||
}
|
||||
|
||||
return keys.compactMap { key in
|
||||
guard let child = node.properties[key] else { return nil }
|
||||
let path: ConfigPath = [.key(section.key), .key(key)]
|
||||
let hint = hintForPath(path, hints: hints)
|
||||
let label = hint?.label
|
||||
?? child.title
|
||||
?? self.humanize(key)
|
||||
let help = hint?.help ?? child.description
|
||||
return ConfigSubsection(
|
||||
key: key,
|
||||
label: label,
|
||||
help: help,
|
||||
node: child,
|
||||
path: path)
|
||||
private func loadPath(_ path: String) async {
|
||||
guard self.store.configLookupNode(path: path) == nil else {
|
||||
self.failedLookupPaths.remove(path)
|
||||
return
|
||||
}
|
||||
guard !self.store.configLookupLoadingPaths.contains(path) else { return }
|
||||
if await self.store.loadConfigSchemaLookup(path: path) == nil {
|
||||
self.failedLookupPaths.insert(path)
|
||||
} else {
|
||||
self.failedLookupPaths.remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode {
|
||||
let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf
|
||||
if !variants.isEmpty {
|
||||
let nonNull = variants.filter { !$0.isNullSchema }
|
||||
if nonNull.count == 1, let only = nonNull.first { return only }
|
||||
private func label(for child: ConfigSchemaLookupChild) -> String {
|
||||
child.hint?.label
|
||||
?? self.humanize(child.key)
|
||||
}
|
||||
|
||||
private func sortLookupChildren(_ lhs: ConfigSchemaLookupChild, _ rhs: ConfigSchemaLookupChild) -> Bool {
|
||||
let orderA = lhs.hint?.order ?? 0
|
||||
let orderB = rhs.hint?.order ?? 0
|
||||
if orderA != orderB { return orderA < orderB }
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
|
||||
private static func configPath(from lookupPath: String) -> ConfigPath? {
|
||||
guard lookupPath != "." else { return [] }
|
||||
let normalized = lookupPath
|
||||
.replacingOccurrences(of: "[", with: ".")
|
||||
.replacingOccurrences(of: "]", with: "")
|
||||
let parts = normalized
|
||||
.split(separator: ".")
|
||||
.map(String.init)
|
||||
.filter { !$0.isEmpty }
|
||||
guard !parts.contains("*") else { return nil }
|
||||
return parts.map { part in
|
||||
if let index = Int(part) {
|
||||
return .index(index)
|
||||
}
|
||||
return .key(part)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
private static func shouldRenderFormEditor(for schema: ConfigSchemaNode) -> Bool {
|
||||
if schema.schemaType == "array" { return true }
|
||||
return schema.additionalProperties != nil
|
||||
}
|
||||
|
||||
private func humanize(_ key: String) -> String {
|
||||
|
||||
@@ -64,6 +64,7 @@ actor GatewayConnection {
|
||||
case configSet = "config.set"
|
||||
case configPatch = "config.patch"
|
||||
case configSchema = "config.schema"
|
||||
case configSchemaLookup = "config.schema.lookup"
|
||||
case wizardStart = "wizard.start"
|
||||
case wizardNext = "wizard.next"
|
||||
case wizardCancel = "wizard.cancel"
|
||||
|
||||
@@ -646,19 +646,54 @@ describe("config schema", () => {
|
||||
expect(schema?.properties).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a shallow lookup schema without nested composition keywords", () => {
|
||||
it("looks up root config schema children without returning the full schema tree", () => {
|
||||
const lookup = lookupConfigSchema(baseSchema, ".");
|
||||
expect(lookup?.path).toBe(".");
|
||||
expect(lookup?.children.map((child) => child.key)).toContain("gateway");
|
||||
expect(lookup?.children.find((child) => child.key === "gateway")?.path).toBe("gateway");
|
||||
const schema = lookup?.schema as { properties?: unknown } | undefined;
|
||||
expect(schema?.properties).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a shallow lookup schema with top-level composition for editing", () => {
|
||||
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.runtime");
|
||||
expect(lookup?.path).toBe("agents.list.0.runtime");
|
||||
expect(lookup?.hintPath).toBe("agents.list[].runtime");
|
||||
// The shallow lookup schema carries field docs, but should not expose
|
||||
// nested composition keywords (allOf, oneOf, etc.).
|
||||
expect(lookup?.schema).not.toHaveProperty("allOf");
|
||||
expect(lookup?.schema).not.toHaveProperty("oneOf");
|
||||
expect(lookup?.schema).not.toHaveProperty("anyOf");
|
||||
const schema = lookup?.schema as { anyOf?: Array<{ properties?: Record<string, unknown> }> };
|
||||
expect(schema.anyOf?.some((variant) => variant.properties?.type)).toBe(true);
|
||||
expect(lookup?.schema).toHaveProperty("title", "Agent Runtime");
|
||||
expect(lookup?.schema).toHaveProperty("description");
|
||||
});
|
||||
|
||||
it("keeps scoped collection item schemas for form editing", () => {
|
||||
const lookup = lookupConfigSchema(baseSchema, "agents.list");
|
||||
expect(lookup?.schema).toHaveProperty("items");
|
||||
const schema = lookup?.schema as
|
||||
| {
|
||||
items?: {
|
||||
properties?: Record<
|
||||
string,
|
||||
{ anyOf?: Array<{ properties?: Record<string, unknown> }> }
|
||||
>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(schema?.items?.properties).toHaveProperty("runtime");
|
||||
const runtimeVariants = schema?.items?.properties?.runtime?.anyOf ?? [];
|
||||
expect(runtimeVariants.length).toBeGreaterThan(0);
|
||||
expect(runtimeVariants.some((variant) => variant.properties?.type)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps scoped map properties for form editing", () => {
|
||||
const lookup = lookupConfigSchema(baseSchema, "env");
|
||||
expect(lookup?.children.map((child) => child.key)).toEqual(["shellEnv", "vars", "*"]);
|
||||
const schema = lookup?.schema as { properties?: Record<string, unknown> } | undefined;
|
||||
expect(schema?.properties).toHaveProperty("shellEnv");
|
||||
expect(schema?.properties).toHaveProperty("vars");
|
||||
});
|
||||
|
||||
it("matches wildcard ui hints for concrete lookup paths", () => {
|
||||
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.identity.avatar");
|
||||
expect(lookup?.path).toBe("agents.list.0.identity.avatar");
|
||||
|
||||
@@ -25,6 +25,9 @@ type JsonSchemaObject = JsonSchemaNode & {
|
||||
required?: string[];
|
||||
additionalProperties?: JsonSchemaObject | boolean;
|
||||
items?: JsonSchemaObject | JsonSchemaObject[];
|
||||
anyOf?: JsonSchemaObject[];
|
||||
oneOf?: JsonSchemaObject[];
|
||||
allOf?: JsonSchemaObject[];
|
||||
};
|
||||
|
||||
const asJsonSchemaObject = (value: unknown): JsonSchemaObject | null =>
|
||||
@@ -62,6 +65,8 @@ const LOOKUP_SCHEMA_BOOLEAN_KEYS = new Set([
|
||||
"writeOnly",
|
||||
]);
|
||||
const MAX_LOOKUP_PATH_SEGMENTS = 32;
|
||||
const LOOKUP_SCHEMA_COMPOSITION_KEYS = ["anyOf", "oneOf", "allOf"] as const;
|
||||
const LOOKUP_SCHEMA_NESTED_FORM_DEPTH = 4;
|
||||
|
||||
function isObjectSchema(schema: JsonSchemaObject): boolean {
|
||||
const type = schema.type;
|
||||
@@ -655,7 +660,7 @@ function resolveLookupChildSchema(
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripSchemaForLookup(schema: JsonSchemaObject): JsonSchemaNode {
|
||||
function stripSchemaForLookup(schema: JsonSchemaObject, nestedFormDepth = 0): JsonSchemaNode {
|
||||
const next: JsonSchemaNode = {};
|
||||
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
@@ -703,6 +708,41 @@ function stripSchemaForLookup(schema: JsonSchemaObject): JsonSchemaNode {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
schema.properties &&
|
||||
((nestedFormDepth > 0 && nestedFormDepth <= LOOKUP_SCHEMA_NESTED_FORM_DEPTH) ||
|
||||
(schema.additionalProperties && typeof schema.additionalProperties === "object"))
|
||||
) {
|
||||
next.properties = Object.fromEntries(
|
||||
Object.entries(schema.properties).map(([key, child]) => [
|
||||
key,
|
||||
stripSchemaForLookup(child, nestedFormDepth + 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
next.additionalProperties = stripSchemaForLookup(
|
||||
schema.additionalProperties,
|
||||
nestedFormDepth + 1,
|
||||
);
|
||||
}
|
||||
if (Array.isArray(schema.items)) {
|
||||
next.items = schema.items.map((item) => stripSchemaForLookup(item, nestedFormDepth + 1));
|
||||
} else if (schema.items && typeof schema.items === "object") {
|
||||
next.items = stripSchemaForLookup(schema.items, nestedFormDepth + 1);
|
||||
}
|
||||
if (nestedFormDepth <= LOOKUP_SCHEMA_NESTED_FORM_DEPTH) {
|
||||
for (const key of LOOKUP_SCHEMA_COMPOSITION_KEYS) {
|
||||
const variants = schema[key];
|
||||
if (!Array.isArray(variants)) {
|
||||
continue;
|
||||
}
|
||||
next[key] = variants
|
||||
.filter((variant) => variant && typeof variant === "object")
|
||||
.map((variant) => stripSchemaForLookup(variant, nestedFormDepth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -749,12 +789,13 @@ export function lookupConfigSchema(
|
||||
response: ConfigSchemaResponse,
|
||||
path: string,
|
||||
): ConfigSchemaLookupResult | null {
|
||||
const wantsRoot = path.trim() === ".";
|
||||
const normalizedPath = normalizeLookupPath(path);
|
||||
if (!normalizedPath) {
|
||||
if (!normalizedPath && !wantsRoot) {
|
||||
return null;
|
||||
}
|
||||
const parts = splitLookupPath(normalizedPath);
|
||||
if (parts.length === 0 || parts.length > MAX_LOOKUP_PATH_SEGMENTS) {
|
||||
if ((!wantsRoot && parts.length === 0) || parts.length > MAX_LOOKUP_PATH_SEGMENTS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -772,10 +813,10 @@ export function lookupConfigSchema(
|
||||
|
||||
const resolvedHint = resolveUiHintMatch(response.uiHints, normalizedPath);
|
||||
return {
|
||||
path: normalizedPath,
|
||||
path: wantsRoot ? "." : normalizedPath,
|
||||
schema: stripSchemaForLookup(current),
|
||||
hint: resolvedHint?.hint,
|
||||
hintPath: resolvedHint?.path,
|
||||
children: buildLookupChildren(current, normalizedPath, response.uiHints),
|
||||
children: buildLookupChildren(current, wantsRoot ? "" : normalizedPath, response.uiHints),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user