feat(config-builder): add wizard and mode navigation models

This commit is contained in:
Sebastian
2026-02-09 21:23:01 -05:00
parent 451439e4ff
commit 7546dfaa88
6 changed files with 251 additions and 30 deletions

View File

@@ -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();
});
});

View File

@@ -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<string, ExplorerSection>();
@@ -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,

View File

@@ -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");
});
});

View File

@@ -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 "#/";
}

View File

@@ -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);
}
});
});

View File

@@ -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: [],
};
}