feat(config-builder): add phase 2 draft state and JSON5 preview

This commit is contained in:
Sebastian
2026-02-09 20:52:27 -05:00
parent 58b54b24f0
commit 04c741b3ec
9 changed files with 699 additions and 16 deletions

View File

@@ -12,12 +12,15 @@ Use the same front-end stack as the existing OpenClaw web UI (`ui/`):
## Current status
Phase 0 and Phase 1 are in place:
Phase 0, Phase 1, and Phase 2 are in place:
- app boots with Vite + Lit
- `OpenClawSchema.toJSONSchema()` runs in browser bundle
- `buildConfigSchema()` UI hints load in browser bundle
- Explorer read-only scaffold renders grouped sections + field metadata
- Explorer scaffold renders grouped sections + field metadata
- Primitive field editing writes sparse config state by dot-path
- Draft state persists to localStorage
- Live JSON5 preview supports copy/download/reset
To run locally:

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { clearFieldValue, getFieldValue, setFieldValue } from "./config-store.ts";
describe("config-store helpers", () => {
it("sets and reads nested fields", () => {
const next = setFieldValue({}, "gateway.auth.token", "abc123");
expect(getFieldValue(next, "gateway.auth.token")).toBe("abc123");
expect(next.gateway).toBeTruthy();
});
it("clears nested fields and prunes empty parents", () => {
const seeded = setFieldValue({}, "gateway.auth.token", "abc123");
const cleared = clearFieldValue(seeded, "gateway.auth.token");
expect(getFieldValue(cleared, "gateway.auth.token")).toBeUndefined();
expect(cleared.gateway).toBeUndefined();
});
});

View File

@@ -0,0 +1,160 @@
export type ConfigDraft = Record<string, unknown>;
const STORAGE_KEY = "openclaw.config-builder.v1";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cloneDraft(input: ConfigDraft): ConfigDraft {
if (typeof structuredClone === "function") {
return structuredClone(input);
}
return JSON.parse(JSON.stringify(input)) as ConfigDraft;
}
function normalizePath(path: string): string[] {
return path
.split(".")
.map((part) => part.trim())
.filter(Boolean);
}
function pruneEmptyObjects(value: unknown): unknown {
if (!isRecord(value)) {
return value;
}
const next: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value)) {
const cleaned = pruneEmptyObjects(nested);
if (cleaned === undefined) {
continue;
}
if (isRecord(cleaned) && Object.keys(cleaned).length === 0) {
continue;
}
next[key] = cleaned;
}
return Object.keys(next).length === 0 ? undefined : next;
}
export function getFieldValue(config: ConfigDraft, path: string): unknown {
const segments = normalizePath(path);
let current: unknown = config;
for (const segment of segments) {
if (!isRecord(current)) {
return undefined;
}
current = current[segment];
}
return current;
}
export function setFieldValue(config: ConfigDraft, path: string, value: unknown): ConfigDraft {
const segments = normalizePath(path);
if (segments.length === 0) {
return config;
}
const next = cloneDraft(config);
let cursor: Record<string, unknown> = next;
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index]!;
const existing = cursor[segment];
if (isRecord(existing)) {
cursor = existing;
continue;
}
const child: Record<string, unknown> = {};
cursor[segment] = child;
cursor = child;
}
const leaf = segments.at(-1);
if (!leaf) {
return next;
}
cursor[leaf] = value;
return next;
}
export function clearFieldValue(config: ConfigDraft, path: string): ConfigDraft {
const segments = normalizePath(path);
if (segments.length === 0) {
return config;
}
const next = cloneDraft(config);
const parents: Array<Record<string, unknown>> = [];
let cursor: unknown = next;
for (let index = 0; index < segments.length - 1; index += 1) {
if (!isRecord(cursor)) {
return next;
}
parents.push(cursor);
cursor = cursor[segments[index]!];
}
if (!isRecord(cursor)) {
return next;
}
const leaf = segments.at(-1);
if (!leaf) {
return next;
}
delete cursor[leaf];
for (let index = segments.length - 2; index >= 0; index -= 1) {
const parent = parents[index];
const key = segments[index]!;
const child = parent[key];
if (!isRecord(child)) {
continue;
}
if (Object.keys(child).length === 0) {
delete parent[key];
}
}
const cleaned = pruneEmptyObjects(next);
return isRecord(cleaned) ? cleaned : {};
}
export function resetDraft(): ConfigDraft {
return {};
}
export function loadPersistedDraft(storage: Storage | null = globalThis.localStorage ?? null): ConfigDraft {
if (!storage) {
return {};
}
try {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as unknown;
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
export function persistDraft(
config: ConfigDraft,
storage: Storage | null = globalThis.localStorage ?? null,
): void {
if (!storage) {
return;
}
try {
storage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch {
// best-effort persistence only
}
}

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { formatConfigJson5 } from "./json5-format.ts";
describe("formatConfigJson5", () => {
it("formats sparse config and computes size metadata", () => {
const preview = formatConfigJson5({ gateway: { port: 18789 } });
expect(preview.text).toContain("gateway");
expect(preview.text).toContain("18789");
expect(preview.lineCount).toBeGreaterThan(0);
expect(preview.byteCount).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,34 @@
import JSON5 from "json5";
import type { ConfigDraft } from "./config-store.ts";
export type Json5Preview = {
text: string;
lineCount: number;
byteCount: number;
};
export function formatConfigJson5(config: ConfigDraft): Json5Preview {
const text = `${JSON5.stringify(config, null, 2)}\n`;
const lineCount = text.split(/\r?\n/).length - 1;
const byteCount = new TextEncoder().encode(text).byteLength;
return {
text,
lineCount,
byteCount,
};
}
export function downloadJson5File(text: string, filename = "openclaw.json"): void {
if (typeof document === "undefined") {
return;
}
const blob = new Blob([text], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}

View File

@@ -15,5 +15,12 @@ describe("buildExplorerSnapshot", () => {
const tokenField = gatewaySection?.fields.find((field) => field.path === "gateway.auth.token");
expect(tokenField?.sensitive).toBe(true);
expect(tokenField?.kind).toBe("string");
expect(tokenField?.editable).toBe(true);
const wildcardField = snapshot.sections
.flatMap((section) => section.fields)
.find((field) => field.path.includes("*"));
expect(wildcardField?.editable).toBe(false);
});
});

View File

@@ -2,15 +2,37 @@ import { buildConfigSchema, type ConfigUiHint } from "@openclaw/config/schema.ts
type JsonSchemaNode = {
description?: string;
default?: unknown;
type?: string | string[];
enum?: unknown[];
properties?: Record<string, JsonSchemaNode>;
items?: JsonSchemaNode | JsonSchemaNode[];
additionalProperties?: JsonSchemaNode | boolean;
anyOf?: JsonSchemaNode[];
oneOf?: JsonSchemaNode[];
allOf?: JsonSchemaNode[];
};
export type FieldKind =
| "string"
| "number"
| "integer"
| "boolean"
| "enum"
| "array"
| "object"
| "unknown";
export type ExplorerField = {
path: string;
label: string;
help: string;
sensitive: boolean;
advanced: boolean;
kind: FieldKind;
enumValues: string[];
hasDefault: boolean;
editable: boolean;
};
export type ExplorerSection = {
@@ -68,10 +90,137 @@ function sectionSort(a: ExplorerSection, b: ExplorerSection): number {
return a.label.localeCompare(b.label);
}
function normalizeSchemaPath(path: string): string[] {
return path
.replace(/\[\]/g, ".*")
.split(".")
.map((segment) => segment.trim())
.filter(Boolean);
}
function asObjectNode(node: unknown): JsonSchemaNode | null {
if (!node || typeof node !== "object" || Array.isArray(node)) {
return null;
}
return node as JsonSchemaNode;
}
function resolveUnion(node: JsonSchemaNode): JsonSchemaNode {
const pool = [...(node.anyOf ?? []), ...(node.oneOf ?? []), ...(node.allOf ?? [])];
const preferred = pool.find((entry) => {
const type = entry.type;
if (typeof type === "string") {
return type !== "null";
}
if (Array.isArray(type)) {
return type.some((part) => part !== "null");
}
return true;
});
return preferred ?? node;
}
function resolveSchemaNode(root: JsonSchemaNode, path: string): JsonSchemaNode | null {
const segments = normalizeSchemaPath(path);
let current: JsonSchemaNode | null = root;
for (const segment of segments) {
if (!current) {
return null;
}
current = resolveUnion(current);
if (segment === "*") {
if (Array.isArray(current.items)) {
current = current.items[0] ?? null;
continue;
}
const itemNode = asObjectNode(current.items);
if (itemNode) {
current = itemNode;
continue;
}
const additionalNode = asObjectNode(current.additionalProperties);
if (additionalNode) {
current = additionalNode;
continue;
}
return null;
}
const properties = current.properties ?? {};
if (segment in properties) {
current = properties[segment] ?? null;
continue;
}
const additionalNode = asObjectNode(current.additionalProperties);
if (additionalNode) {
current = additionalNode;
continue;
}
return null;
}
return current ? resolveUnion(current) : null;
}
function resolveType(node: JsonSchemaNode | null): FieldKind {
if (!node) {
return "unknown";
}
if (Array.isArray(node.enum) && node.enum.length > 0) {
return "enum";
}
const rawType = node.type;
const type = Array.isArray(rawType) ? rawType.find((entry) => entry !== "null") : rawType;
switch (type) {
case "string":
return "string";
case "number":
return "number";
case "integer":
return "integer";
case "boolean":
return "boolean";
case "array":
return "array";
case "object":
return "object";
default:
if (node.properties) {
return "object";
}
if (node.items) {
return "array";
}
return "unknown";
}
}
function isEditable(path: string, kind: FieldKind): boolean {
if (path.includes("*") || path.includes("[]")) {
return false;
}
return kind === "string" || kind === "number" || kind === "integer" || kind === "boolean" || kind === "enum";
}
function toEnumValues(values: unknown[] | undefined): string[] {
if (!values || values.length === 0) {
return [];
}
return values.map((value) => String(value));
}
export function buildExplorerSnapshot(): ExplorerSnapshot {
const configSchema = buildConfigSchema();
const uiHints = configSchema.uiHints;
const schemaRoot = (configSchema.schema as JsonSchemaNode).properties ?? {};
const schemaRoot = asObjectNode(configSchema.schema);
const schemaProperties = schemaRoot?.properties ?? {};
const sections = new Map<string, ExplorerSection>();
@@ -88,7 +237,7 @@ export function buildExplorerSnapshot(): ExplorerSnapshot {
});
}
for (const [rootKey, node] of Object.entries(schemaRoot)) {
for (const [rootKey, node] of Object.entries(schemaProperties)) {
if (sections.has(rootKey)) {
const existing = sections.get(rootKey);
if (existing) {
@@ -112,8 +261,7 @@ export function buildExplorerSnapshot(): ExplorerSnapshot {
continue;
}
const section = sections.get(rootKey);
if (!section) {
if (!sections.has(rootKey)) {
sections.set(rootKey, {
id: rootKey,
label: humanizeKey(rootKey),
@@ -123,21 +271,28 @@ export function buildExplorerSnapshot(): ExplorerSnapshot {
});
}
if (isSectionHint(path, hint)) {
continue;
}
const target = sections.get(rootKey);
if (!target) {
continue;
}
if (isSectionHint(path, hint)) {
continue;
}
const schemaNode = resolveSchemaNode(schemaRoot ?? {}, path);
const kind = resolveType(schemaNode);
target.fields.push({
path,
label: hint.label?.trim() || humanizeKey(lastPathSegment(path)),
help: hint.help?.trim() ?? "",
help: hint.help?.trim() ?? schemaNode?.description?.trim() ?? "",
sensitive: Boolean(hint.sensitive),
advanced: Boolean(hint.advanced),
kind,
enumValues: toEnumValues(schemaNode?.enum),
hasDefault: schemaNode?.default !== undefined,
editable: isEditable(path, kind),
});
}

View File

@@ -110,3 +110,87 @@ config-builder-app {
min-height: 100vh;
}
}
/* Phase 2 layout: add dedicated JSON5 preview panel. */
.builder-layout.config-layout {
grid-template-columns: 260px minmax(0, 1fr) 400px;
}
.builder-preview {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-height: 0;
background: var(--bg-accent);
border-left: 1px solid var(--border);
}
.builder-preview__header,
.builder-preview__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border);
}
.builder-preview__footer {
border-top: 1px solid var(--border);
border-bottom: none;
justify-content: flex-start;
}
.builder-preview__title {
font-size: 13px;
color: var(--text);
}
.builder-preview__meta {
font-size: 11px;
color: var(--muted);
}
.builder-preview__code {
margin: 0;
border: 0;
border-radius: 0;
background: transparent;
font-size: 12px;
line-height: 1.55;
overflow: auto;
padding: 12px;
}
.builder-field--set {
border-color: var(--border-strong);
}
.builder-field__controls {
display: grid;
gap: 8px;
}
.builder-field__actions {
display: flex;
justify-content: flex-end;
margin-top: 2px;
}
.builder-toggle-row {
margin: 0;
}
@media (max-width: 1500px) {
.builder-layout.config-layout {
grid-template-columns: 260px minmax(0, 1fr);
height: auto;
min-height: 100vh;
}
.builder-preview {
grid-column: 1 / -1;
border-left: 0;
border-top: 1px solid var(--border);
min-height: 320px;
}
}

View File

@@ -1,4 +1,14 @@
import { LitElement, html, nothing } from "lit";
import {
clearFieldValue,
getFieldValue,
loadPersistedDraft,
persistDraft,
resetDraft,
setFieldValue,
type ConfigDraft,
} from "../lib/config-store.ts";
import { downloadJson5File, formatConfigJson5 } from "../lib/json5-format.ts";
import {
buildExplorerSnapshot,
type ExplorerField,
@@ -11,6 +21,8 @@ type AppState =
| { status: "ready"; snapshot: ExplorerSnapshot }
| { status: "error"; message: string };
type CopyState = "idle" | "copied" | "failed";
function includesQuery(value: string, query: string): boolean {
return value.toLowerCase().includes(query);
}
@@ -43,8 +55,11 @@ function sectionGlyph(label: string): string {
class ConfigBuilderApp extends LitElement {
private state: AppState = { status: "loading" };
private config: ConfigDraft = {};
private selectedSectionId: string | null = null;
private searchQuery = "";
private copyState: CopyState = "idle";
private copyResetTimer: number | null = null;
override createRenderRoot() {
// Match the existing OpenClaw web UI approach (global CSS classes/tokens).
@@ -56,8 +71,17 @@ class ConfigBuilderApp extends LitElement {
this.bootstrap();
}
override disconnectedCallback(): void {
if (this.copyResetTimer != null) {
window.clearTimeout(this.copyResetTimer);
this.copyResetTimer = null;
}
super.disconnectedCallback();
}
private bootstrap(): void {
try {
this.config = loadPersistedDraft();
const snapshot = buildExplorerSnapshot();
this.state = { status: "ready", snapshot };
} catch (error) {
@@ -77,6 +101,83 @@ class ConfigBuilderApp extends LitElement {
this.requestUpdate();
}
private saveConfig(next: ConfigDraft): void {
this.config = next;
persistDraft(next);
this.requestUpdate();
}
private clearField(path: string): void {
this.saveConfig(clearFieldValue(this.config, path));
}
private setField(path: string, value: unknown): void {
this.saveConfig(setFieldValue(this.config, path, value));
}
private resetAllFields(): void {
this.saveConfig(resetDraft());
}
private parseAndSetField(field: ExplorerField, raw: string): void {
const trimmed = raw.trim();
if (trimmed.length === 0) {
this.clearField(field.path);
return;
}
if (field.kind === "number" || field.kind === "integer") {
const parsed = Number(trimmed);
if (Number.isNaN(parsed)) {
return;
}
this.setField(field.path, field.kind === "integer" ? Math.trunc(parsed) : parsed);
return;
}
if (field.kind === "boolean") {
if (trimmed === "true") {
this.setField(field.path, true);
return;
}
if (trimmed === "false") {
this.setField(field.path, false);
}
return;
}
if (field.kind === "enum") {
if (field.enumValues.includes(trimmed)) {
this.setField(field.path, trimmed);
}
return;
}
this.setField(field.path, raw);
}
private async copyPreview(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
this.copyState = "copied";
} catch {
this.copyState = "failed";
}
if (this.copyResetTimer != null) {
window.clearTimeout(this.copyResetTimer);
}
this.copyResetTimer = window.setTimeout(() => {
this.copyState = "idle";
this.copyResetTimer = null;
this.requestUpdate();
}, 1500);
this.requestUpdate();
}
private getVisibleSections(snapshot: ExplorerSnapshot): ExplorerSection[] {
const bySection = this.selectedSectionId
? snapshot.sections.filter((section) => section.id === this.selectedSectionId)
@@ -144,9 +245,9 @@ class ConfigBuilderApp extends LitElement {
<div class="config-sidebar__header">
<div>
<div class="config-sidebar__title">Config Builder</div>
<div class="builder-subtitle">Explorer scaffold</div>
<div class="builder-subtitle">Explorer + JSON5 preview</div>
</div>
<span class="pill pill--sm pill--ok">ready</span>
<span class="pill pill--sm pill--ok">phase 2</span>
</div>
${this.renderSearch()}
@@ -179,25 +280,105 @@ class ConfigBuilderApp extends LitElement {
<div class="config-sidebar__footer">
<div class="builder-footer-note">
Read-only schema explorer using OpenClaw config hints.
Draft values persist in localStorage and render to JSON5 preview.
</div>
</div>
</aside>
`;
}
private renderField(field: ExplorerField) {
private renderFieldControl(field: ExplorerField, value: unknown) {
if (!field.editable) {
return html`<div class="cfg-field__help">Phase 2 edits only primitive concrete paths.</div>`;
}
if (field.kind === "boolean") {
return html`
<label class="cfg-toggle-row builder-toggle-row">
<span class="cfg-field__help">Toggle to set this boolean value.</span>
<div class="cfg-toggle">
<input
type="checkbox"
.checked=${value === true}
@change=${(event: Event) =>
this.setField(field.path, (event.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</div>
</label>
`;
}
if (field.kind === "enum") {
const selectValue = typeof value === "string" ? value : "";
return html`
<select
class="cfg-select"
.value=${selectValue}
@change=${(event: Event) => {
const next = (event.target as HTMLSelectElement).value;
if (!next) {
this.clearField(field.path);
return;
}
this.setField(field.path, next);
}}
>
<option value="">(unset)</option>
${field.enumValues.map((option) => html`<option value=${option}>${option}</option>`)}
</select>
`;
}
if (field.kind === "number" || field.kind === "integer") {
const textValue = typeof value === "number" ? String(value) : "";
return html`
<input
class="cfg-input"
type="number"
.value=${textValue}
@input=${(event: Event) =>
this.parseAndSetField(field, (event.target as HTMLInputElement).value)}
/>
`;
}
const textValue = typeof value === "string" ? value : "";
return html`
<div class="cfg-field builder-field">
<input
class="cfg-input"
type=${field.sensitive ? "password" : "text"}
.value=${textValue}
@input=${(event: Event) => this.parseAndSetField(field, (event.target as HTMLInputElement).value)}
/>
`;
}
private renderField(field: ExplorerField) {
const value = getFieldValue(this.config, field.path);
const hasValue = value !== undefined;
return html`
<div class="cfg-field builder-field ${hasValue ? "builder-field--set" : ""}">
<div class="builder-field__head">
<div class="cfg-field__label">${field.label}</div>
<div class="builder-field__badges">
${hasValue ? html`<span class="pill pill--sm">set</span>` : nothing}
${field.sensitive ? html`<span class="pill pill--sm pill--danger">sensitive</span>` : nothing}
${field.advanced ? html`<span class="pill pill--sm">advanced</span>` : nothing}
<span class="pill pill--sm mono">${field.kind}</span>
</div>
</div>
<div class="builder-field__path mono">${field.path}</div>
${field.help ? html`<div class="cfg-field__help">${field.help}</div>` : nothing}
<div class="builder-field__controls">${this.renderFieldControl(field, value)}</div>
<div class="builder-field__actions">
<button class="btn btn--sm" @click=${() => this.clearField(field.path)}>Clear</button>
</div>
</div>
`;
}
@@ -236,6 +417,33 @@ class ConfigBuilderApp extends LitElement {
`;
}
private renderPreview() {
const preview = formatConfigJson5(this.config);
return html`
<aside class="builder-preview">
<div class="builder-preview__header">
<div class="builder-preview__title mono">openclaw.json</div>
<div class="builder-preview__meta mono">${preview.lineCount} lines · ${preview.byteCount} B</div>
</div>
<pre class="builder-preview__code code-block">${preview.text}</pre>
<div class="builder-preview__footer">
<button class="btn btn--sm" @click=${() => this.copyPreview(preview.text)}>
${this.copyState === "copied"
? "Copied"
: this.copyState === "failed"
? "Copy failed"
: "Copy"}
</button>
<button class="btn btn--sm" @click=${() => downloadJson5File(preview.text)}>Download</button>
<button class="btn btn--sm danger" @click=${() => this.resetAllFields()}>Reset all</button>
</div>
</aside>
`;
}
override render() {
if (this.state.status === "loading") {
return html`<div class="builder-screen"><div class="card">Loading schema explorer…</div></div>`;
@@ -256,7 +464,7 @@ class ConfigBuilderApp extends LitElement {
<main class="config-main">
<div class="config-actions">
<div class="config-actions__left">
<span class="config-status">Schema explorer (read-only)</span>
<span class="config-status">Phase 2: sparse state + live JSON5 preview</span>
</div>
<div class="config-actions__right">
<span class="pill pill--sm">sections: ${snapshot.sectionCount}</span>
@@ -273,6 +481,8 @@ class ConfigBuilderApp extends LitElement {
${this.renderSections(visibleSections)}
</div>
</main>
${this.renderPreview()}
</div>
</div>
`;