From 7546dfaa88dafac88807fb791d8e6555de2d549f Mon Sep 17 00:00:00 2001 From: Sebastian <19554889+sebslight@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:23:01 -0500 Subject: [PATCH] feat(config-builder): add wizard and mode navigation models --- .../src/lib/schema-spike.test.ts | 15 ++- apps/config-builder/src/lib/schema-spike.ts | 97 +++++++++++----- apps/config-builder/src/ui/navigation.test.ts | 20 ++++ apps/config-builder/src/ui/navigation.ts | 29 +++++ apps/config-builder/src/ui/wizard.test.ts | 15 +++ apps/config-builder/src/ui/wizard.ts | 105 ++++++++++++++++++ 6 files changed, 251 insertions(+), 30 deletions(-) create mode 100644 apps/config-builder/src/ui/navigation.test.ts create mode 100644 apps/config-builder/src/ui/navigation.ts create mode 100644 apps/config-builder/src/ui/wizard.test.ts create mode 100644 apps/config-builder/src/ui/wizard.ts diff --git a/apps/config-builder/src/lib/schema-spike.test.ts b/apps/config-builder/src/lib/schema-spike.test.ts index a12cc329b3..7af055c717 100644 --- a/apps/config-builder/src/lib/schema-spike.test.ts +++ b/apps/config-builder/src/lib/schema-spike.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildExplorerSnapshot } from "./schema-spike.ts"; +import { buildExplorerSnapshot, resolveExplorerField } from "./schema-spike.ts"; describe("buildExplorerSnapshot", () => { it("builds ordered sections and field metadata", () => { @@ -36,3 +36,16 @@ describe("buildExplorerSnapshot", () => { expect(recordField?.recordValueKind).toBe("string"); }); }); + +describe("resolveExplorerField", () => { + it("resolves metadata for paths that do not have explicit UI hints", () => { + const port = resolveExplorerField("gateway.port"); + expect(port).toBeTruthy(); + expect(port?.kind).toBe("integer"); + expect(port?.editable).toBe(true); + }); + + it("returns null for unknown paths", () => { + expect(resolveExplorerField("this.path.does.not.exist")).toBeNull(); + }); +}); diff --git a/apps/config-builder/src/lib/schema-spike.ts b/apps/config-builder/src/lib/schema-spike.ts index bd09500f79..ae717fb52d 100644 --- a/apps/config-builder/src/lib/schema-spike.ts +++ b/apps/config-builder/src/lib/schema-spike.ts @@ -1,4 +1,4 @@ -import { buildConfigSchema, type ConfigUiHint } from "@openclaw/config/schema.ts"; +import { buildConfigSchema, type ConfigUiHint, type ConfigUiHints } from "@openclaw/config/schema.ts"; type JsonSchemaNode = { description?: string; @@ -13,6 +13,13 @@ type JsonSchemaNode = { allOf?: JsonSchemaNode[]; }; +type SchemaContext = { + schemaRoot: JsonSchemaNode; + uiHints: ConfigUiHints; + version: string; + generatedAt: string; +}; + export type FieldKind = | "string" | "number" @@ -57,6 +64,24 @@ export type ExplorerSnapshot = { const SECTION_FALLBACK_ORDER = 500; +let cachedContext: SchemaContext | null = null; + +function getSchemaContext(): SchemaContext { + if (cachedContext) { + return cachedContext; + } + + const configSchema = buildConfigSchema(); + const schemaRoot = asObjectNode(configSchema.schema) ?? {}; + cachedContext = { + schemaRoot, + uiHints: configSchema.uiHints, + version: configSchema.version, + generatedAt: configSchema.generatedAt, + }; + return cachedContext; +} + function humanizeKey(value: string): string { if (!value.trim()) { return value; @@ -241,11 +266,46 @@ function toEnumValues(values: unknown[] | undefined): string[] { return values.map((value) => String(value)); } +function buildExplorerField(path: string, hint: ConfigUiHint | undefined, root: JsonSchemaNode): ExplorerField { + const schemaNode = resolveSchemaNode(root, path); + const kind = resolveType(schemaNode); + const arrayItemNode = kind === "array" ? firstArrayItemNode(schemaNode) : null; + const itemKind = arrayItemNode ? resolveType(arrayItemNode) : null; + const recordNode = kind === "object" ? recordValueNode(schemaNode) : null; + const recordValueKind = recordNode ? resolveType(recordNode) : null; + + return { + path, + label: hint?.label?.trim() || humanizeKey(lastPathSegment(path)), + help: hint?.help?.trim() ?? schemaNode?.description?.trim() ?? "", + sensitive: Boolean(hint?.sensitive), + advanced: Boolean(hint?.advanced), + kind, + enumValues: toEnumValues(schemaNode?.enum), + itemKind, + itemEnumValues: toEnumValues(arrayItemNode?.enum), + recordValueKind, + recordEnumValues: toEnumValues(recordNode?.enum), + hasDefault: schemaNode?.default !== undefined, + editable: isEditable(path, kind), + }; +} + +export function resolveExplorerField(path: string): ExplorerField | null { + const context = getSchemaContext(); + const hint = context.uiHints[path]; + const schemaNode = resolveSchemaNode(context.schemaRoot, path); + if (!schemaNode && !hint) { + return null; + } + return buildExplorerField(path, hint, context.schemaRoot); +} + export function buildExplorerSnapshot(): ExplorerSnapshot { - const configSchema = buildConfigSchema(); - const uiHints = configSchema.uiHints; - const schemaRoot = asObjectNode(configSchema.schema); - const schemaProperties = schemaRoot?.properties ?? {}; + const context = getSchemaContext(); + const uiHints = context.uiHints; + const schemaRoot = context.schemaRoot; + const schemaProperties = schemaRoot.properties ?? {}; const sections = new Map(); @@ -305,28 +365,7 @@ export function buildExplorerSnapshot(): ExplorerSnapshot { continue; } - const schemaNode = resolveSchemaNode(schemaRoot ?? {}, path); - const kind = resolveType(schemaNode); - const arrayItemNode = kind === "array" ? firstArrayItemNode(schemaNode) : null; - const itemKind = arrayItemNode ? resolveType(arrayItemNode) : null; - const recordNode = kind === "object" ? recordValueNode(schemaNode) : null; - const recordValueKind = recordNode ? resolveType(recordNode) : null; - - target.fields.push({ - path, - label: hint.label?.trim() || humanizeKey(lastPathSegment(path)), - help: hint.help?.trim() ?? schemaNode?.description?.trim() ?? "", - sensitive: Boolean(hint.sensitive), - advanced: Boolean(hint.advanced), - kind, - enumValues: toEnumValues(schemaNode?.enum), - itemKind, - itemEnumValues: toEnumValues(arrayItemNode?.enum), - recordValueKind, - recordEnumValues: toEnumValues(recordNode?.enum), - hasDefault: schemaNode?.default !== undefined, - editable: isEditable(path, kind), - }); + target.fields.push(buildExplorerField(path, hint, schemaRoot)); } const orderedSections = Array.from(sections.values()) @@ -336,8 +375,8 @@ export function buildExplorerSnapshot(): ExplorerSnapshot { const fieldCount = orderedSections.reduce((sum, section) => sum + section.fields.length, 0); return { - version: configSchema.version, - generatedAt: configSchema.generatedAt, + version: context.version, + generatedAt: context.generatedAt, sectionCount: orderedSections.length, fieldCount, sections: orderedSections, diff --git a/apps/config-builder/src/ui/navigation.test.ts b/apps/config-builder/src/ui/navigation.test.ts new file mode 100644 index 0000000000..805da688ce --- /dev/null +++ b/apps/config-builder/src/ui/navigation.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { modeToHash, parseModeFromHash } from "./navigation.ts"; + +describe("navigation mode hash", () => { + it("parses known hashes", () => { + expect(parseModeFromHash("#/wizard")).toBe("wizard"); + expect(parseModeFromHash("#/explorer")).toBe("explorer"); + expect(parseModeFromHash("#/")).toBe("landing"); + }); + + it("falls back to landing for unknown hash", () => { + expect(parseModeFromHash("#/unknown")).toBe("landing"); + }); + + it("builds hashes for modes", () => { + expect(modeToHash("landing")).toBe("#/"); + expect(modeToHash("explorer")).toBe("#/explorer"); + expect(modeToHash("wizard")).toBe("#/wizard"); + }); +}); diff --git a/apps/config-builder/src/ui/navigation.ts b/apps/config-builder/src/ui/navigation.ts new file mode 100644 index 0000000000..8b747885d1 --- /dev/null +++ b/apps/config-builder/src/ui/navigation.ts @@ -0,0 +1,29 @@ +export type ConfigBuilderMode = "landing" | "explorer" | "wizard"; + +export function parseModeFromHash(hash: string): ConfigBuilderMode { + const normalized = hash.trim().toLowerCase(); + if (!normalized) { + return "landing"; + } + + if (normalized === "#/wizard" || normalized === "#wizard") { + return "wizard"; + } + if (normalized === "#/explorer" || normalized === "#explorer") { + return "explorer"; + } + if (normalized === "#/" || normalized === "#") { + return "landing"; + } + return "landing"; +} + +export function modeToHash(mode: ConfigBuilderMode): string { + if (mode === "wizard") { + return "#/wizard"; + } + if (mode === "explorer") { + return "#/explorer"; + } + return "#/"; +} diff --git a/apps/config-builder/src/ui/wizard.test.ts b/apps/config-builder/src/ui/wizard.test.ts new file mode 100644 index 0000000000..0e1293536b --- /dev/null +++ b/apps/config-builder/src/ui/wizard.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { WIZARD_STEPS, wizardStepFields } from "./wizard.ts"; + +describe("wizard step definitions", () => { + it("defines the expected number of curated steps", () => { + expect(WIZARD_STEPS).toHaveLength(7); + }); + + it("resolves all configured fields to schema metadata", () => { + for (const step of WIZARD_STEPS) { + const fields = wizardStepFields(step); + expect(fields).toHaveLength(step.fields.length); + } + }); +}); diff --git a/apps/config-builder/src/ui/wizard.ts b/apps/config-builder/src/ui/wizard.ts new file mode 100644 index 0000000000..e9a1eeeb2a --- /dev/null +++ b/apps/config-builder/src/ui/wizard.ts @@ -0,0 +1,105 @@ +import type { ExplorerField } from "../lib/schema-spike.ts"; +import { resolveExplorerField } from "../lib/schema-spike.ts"; + +export type WizardStep = { + id: string; + label: string; + description: string; + fields: string[]; +}; + +export const WIZARD_STEPS: WizardStep[] = [ + { + id: "gateway", + label: "Gateway", + description: "Core gateway networking and auth settings.", + fields: [ + "gateway.port", + "gateway.mode", + "gateway.bind", + "gateway.auth.mode", + "gateway.auth.token", + "gateway.auth.password", + ], + }, + { + id: "channels", + label: "Channels", + description: "Common channel credentials and DM policies.", + fields: [ + "channels.whatsapp.dmPolicy", + "channels.telegram.botToken", + "channels.telegram.dmPolicy", + "channels.discord.token", + "channels.discord.dm.policy", + "channels.slack.botToken", + "channels.slack.dm.policy", + "channels.signal.account", + "channels.signal.dmPolicy", + ], + }, + { + id: "agents", + label: "Agents", + description: "Default model + workspace behavior.", + fields: [ + "agents.defaults.model.primary", + "agents.defaults.model.fallbacks", + "agents.defaults.workspace", + "agents.defaults.repoRoot", + "agents.defaults.humanDelay.mode", + ], + }, + { + id: "models", + label: "Models", + description: "Auth and model catalog data.", + fields: ["agents.defaults.models", "auth.profiles", "auth.order"], + }, + { + id: "messages", + label: "Messages", + description: "Reply behavior and acknowledgment defaults.", + fields: [ + "messages.ackReaction", + "messages.ackReactionScope", + "messages.inbound.debounceMs", + "channels.telegram.streamMode", + ], + }, + { + id: "session", + label: "Session", + description: "DM scoping and agent-to-agent behavior.", + fields: ["session.dmScope", "session.identityLinks", "session.agentToAgent.maxPingPongTurns"], + }, + { + id: "tools", + label: "Tools", + description: "Web and execution tool defaults.", + fields: [ + "tools.profile", + "tools.web.search.enabled", + "tools.web.search.provider", + "tools.web.search.apiKey", + "tools.web.fetch.enabled", + "tools.exec.applyPatch.enabled", + ], + }, +]; + +export function wizardStepFields(step: WizardStep): ExplorerField[] { + return step.fields + .map((path) => resolveExplorerField(path)) + .filter((field): field is ExplorerField => field !== null); +} + +export function wizardStepByIndex(index: number): WizardStep { + const clamped = Math.max(0, Math.min(index, WIZARD_STEPS.length - 1)); + return WIZARD_STEPS[clamped] ?? WIZARD_STEPS[0] ?? { + id: "empty", + label: "Empty", + description: "No wizard steps configured.", + fields: [], + }; +}