mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat(config-builder): add wizard and mode navigation models
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
apps/config-builder/src/ui/navigation.test.ts
Normal file
20
apps/config-builder/src/ui/navigation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
29
apps/config-builder/src/ui/navigation.ts
Normal file
29
apps/config-builder/src/ui/navigation.ts
Normal 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 "#/";
|
||||
}
|
||||
15
apps/config-builder/src/ui/wizard.test.ts
Normal file
15
apps/config-builder/src/ui/wizard.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
105
apps/config-builder/src/ui/wizard.ts
Normal file
105
apps/config-builder/src/ui/wizard.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user