mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-25 03:04:29 -04:00
feat(config-builder): add phase 2 draft state and JSON5 preview
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
18
apps/config-builder/src/lib/config-store.test.ts
Normal file
18
apps/config-builder/src/lib/config-store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
160
apps/config-builder/src/lib/config-store.ts
Normal file
160
apps/config-builder/src/lib/config-store.ts
Normal 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
|
||||
}
|
||||
}
|
||||
12
apps/config-builder/src/lib/json5-format.test.ts
Normal file
12
apps/config-builder/src/lib/json5-format.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
34
apps/config-builder/src/lib/json5-format.ts
Normal file
34
apps/config-builder/src/lib/json5-format.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user