Compare commits

...

16 Commits

Author SHA1 Message Date
Sebastian
178259e680 chore(config-builder): drop unused icon import 2026-02-10 08:41:16 -05:00
Sebastian
dbe8b8dc4a fix(config-builder): derive tool suggestions without config schema edits 2026-02-10 08:40:50 -05:00
Sebastian
21d075f0c9 Revert "feat(config): surface dynamic tool policy suggestions in schema"
This reverts commit 60554db11e.
2026-02-10 08:38:58 -05:00
Sebastian
e40b125bb8 feat(config-builder): ship interactive editor overhaul UI 2026-02-10 08:34:56 -05:00
Sebastian
60554db11e feat(config): surface dynamic tool policy suggestions in schema 2026-02-10 08:34:50 -05:00
Sebastian
671ac4d924 feat(config-builder): ship integrated wizard and explorer UI 2026-02-09 21:23:11 -05:00
Sebastian
7546dfaa88 feat(config-builder): add wizard and mode navigation models 2026-02-09 21:23:01 -05:00
Sebastian
451439e4ff feat(config-builder): add draft validation utilities 2026-02-09 21:22:57 -05:00
Sebastian
dea5ad9c6a fix(agents): match billing upgrade error prefix 2026-02-09 21:05:54 -05:00
Sebastian
82871daf36 fix(config-builder): resolve lint assertions in field state 2026-02-09 21:05:48 -05:00
Sebastian
d22b4c3769 feat(config-builder): add typed field renderer for phase 3 2026-02-09 20:57:30 -05:00
Sebastian
04c741b3ec feat(config-builder): add phase 2 draft state and JSON5 preview 2026-02-09 20:52:27 -05:00
Sebastian
58b54b24f0 fix(config-builder): align explorer visuals with web UI 2026-02-09 20:46:58 -05:00
Sebastian
d3bac0b26d feat(config-builder): add explorer read-only scaffold 2026-02-09 20:43:37 -05:00
Sebastian
cf3f8a6c85 feat(config-builder): scaffold Vite Lit schema spike 2026-02-09 20:37:50 -05:00
Sebastian
60d92ca561 Config builder: bootstrap workspace package 2026-02-09 19:58:50 -05:00
29 changed files with 5836 additions and 1 deletions

View File

@@ -0,0 +1,58 @@
# Config Builder (WIP)
This workspace package will host the standalone OpenClaw config builder app.
## Stack
Use the same front-end stack as the existing OpenClaw web UI (`ui/`):
- Vite
- Lit
- Plain CSS (no Next.js/Tailwind)
## Current status
Phase 0 through Phase 6 are implemented:
- app boots with Vite + Lit
- `OpenClawSchema.toJSONSchema()` runs in browser bundle
- `buildConfigSchema()` UI hints load in browser bundle
- Explorer mode supports grouped schema editing + search/filter
- Typed field renderer covers:
- strings, numbers, integers, booleans, enums
- primitive arrays with add/remove
- record-like objects (key/value editor)
- JSON fallback editor for complex array/object shapes
- Validation + error UX:
- real-time `OpenClawSchema` validation
- inline field-level errors
- section-level error counts + global summary
- Wizard mode:
- 7 curated steps with progress indicators
- back/continue flow with shared renderer/state
- JSON5 preview panel:
- sparse output
- copy/download/reset
- sensitive-value warning banner
- Routing + polish:
- landing page + mode routing via hash (`#/`, `#/explorer`, `#/wizard`)
- responsive layout including mobile preview drawer behavior
- docs link in topbar
- Vercel static config (`apps/config-builder/vercel.json`)
To run locally:
```bash
pnpm --filter @openclaw/config-builder dev
```
## Notes
Implementation details are tracked in `.local/config-builder-spec.md`.
For the spike, Vite aliases lightweight browser shims for:
- `src/version.ts`
- `src/channels/registry.ts`
This keeps schema imports browser-safe while preserving the existing Node runtime modules.

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenClaw Config Builder</title>
</head>
<body>
<config-builder-app></config-builder-app>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
{
"name": "@openclaw/config-builder",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"preview": "vite preview",
"test": "vitest run --config vitest.config.ts"
},
"dependencies": {
"json5": "^2.2.3",
"lit": "^3.3.2",
"zod": "^4.3.6"
},
"devDependencies": {
"vite": "7.3.1",
"vitest": "4.0.18"
}
}

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,170 @@
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];
if (!segment) {
continue;
}
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;
}
const segment = segments[index];
if (!segment) {
return next;
}
parents.push(cursor);
cursor = cursor[segment];
}
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];
if (!parent || !key) {
continue;
}
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

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { buildExplorerSnapshot, resolveExplorerField } from "./schema-spike.ts";
describe("buildExplorerSnapshot", () => {
it("builds ordered sections and field metadata", () => {
const snapshot = buildExplorerSnapshot();
expect(snapshot.sectionCount).toBeGreaterThan(0);
expect(snapshot.fieldCount).toBeGreaterThan(0);
expect(snapshot.sections[0]?.order).toBeLessThanOrEqual(snapshot.sections.at(-1)?.order ?? 0);
const gatewaySection = snapshot.sections.find((section) => section.id === "gateway");
expect(gatewaySection).toBeTruthy();
expect(gatewaySection?.fields.some((field) => field.path === "gateway.auth.token")).toBe(true);
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);
const telegramToken = snapshot.sections
.flatMap((section) => section.fields)
.find((field) => field.path === "channels.telegram.botToken");
expect(telegramToken?.kind).toBe("string");
const updateChannel = snapshot.sections
.flatMap((section) => section.fields)
.find((field) => field.path === "update.channel");
expect(updateChannel?.kind).toBe("enum");
expect(updateChannel?.enumValues).toContain("stable");
const arrayField = snapshot.sections
.flatMap((section) => section.fields)
.find((field) => field.path === "tools.alsoAllow");
expect(arrayField?.kind).toBe("array");
expect(arrayField?.itemKind).toBe("string");
const recordField = snapshot.sections
.flatMap((section) => section.fields)
.find((field) => field.path === "diagnostics.otel.headers");
expect(recordField?.kind).toBe("object");
expect(recordField?.recordValueKind).toBe("string");
const browserFields = snapshot.sections
.find((section) => section.id === "browser")
?.fields.map((field) => field.path) ?? [];
expect(browserFields.includes("browser.snapshotDefaults.mode")).toBe(true);
expect(browserFields.includes("browser.snapshotDefaults")).toBe(false);
});
});
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();
});
it("drops hint-only paths that are not in the schema", () => {
expect(resolveExplorerField("tools.web.fetch.firecrawl.enabled")).toBeNull();
});
});

View File

@@ -0,0 +1,517 @@
import { buildConfigSchema, type ConfigUiHint, type ConfigUiHints } from "@openclaw/config/schema.ts";
import { OpenClawSchema } from "@openclaw/config/zod-schema.ts";
type JsonSchemaNode = {
description?: string;
default?: unknown;
const?: unknown;
type?: string | string[];
enum?: unknown[];
properties?: Record<string, JsonSchemaNode>;
items?: JsonSchemaNode | JsonSchemaNode[];
additionalProperties?: JsonSchemaNode | boolean;
anyOf?: JsonSchemaNode[];
oneOf?: JsonSchemaNode[];
allOf?: JsonSchemaNode[];
};
type SchemaContext = {
schemaRoot: JsonSchemaNode;
uiHints: ConfigUiHints;
version: string;
generatedAt: string;
};
export type FieldKind =
| "string"
| "number"
| "integer"
| "boolean"
| "enum"
| "array"
| "object"
| "unknown";
export type ExplorerSchemaNode = {
kind: FieldKind;
enumValues: string[];
properties: Record<string, ExplorerSchemaNode>;
item: ExplorerSchemaNode | null;
additionalProperties: ExplorerSchemaNode | null;
allowsUnknownProperties: boolean;
};
export type ExplorerField = {
path: string;
label: string;
help: string;
sensitive: boolean;
advanced: boolean;
kind: FieldKind;
enumValues: string[];
itemKind: FieldKind | null;
itemEnumValues: string[];
recordValueKind: FieldKind | null;
recordEnumValues: string[];
hasDefault: boolean;
editable: boolean;
schemaNode: ExplorerSchemaNode | null;
};
export type ExplorerSection = {
id: string;
label: string;
order: number;
description: string;
fields: ExplorerField[];
};
export type ExplorerSnapshot = {
version: string;
generatedAt: string;
sectionCount: number;
fieldCount: number;
sections: ExplorerSection[];
};
const SECTION_FALLBACK_ORDER = 500;
let cachedContext: SchemaContext | null = null;
function getSchemaContext(): SchemaContext {
if (cachedContext) {
return cachedContext;
}
// buildConfigSchema() intentionally strips core channel schema from the base response.
// For the standalone builder we want the complete core schema for interactive controls,
// while still reusing uiHints/version metadata from buildConfigSchema().
const configSchema = buildConfigSchema();
const fullSchema = OpenClawSchema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
});
const schemaRoot = asObjectNode(fullSchema) ?? {};
cachedContext = {
schemaRoot,
uiHints: configSchema.uiHints,
version: configSchema.version,
generatedAt: configSchema.generatedAt,
};
return cachedContext;
}
function humanizeKey(value: string): string {
if (!value.trim()) {
return value;
}
return value
.replace(/[_-]+/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim()
.replace(/^./, (char) => char.toUpperCase());
}
function firstPathSegment(path: string): string {
const [segment] = path.split(".");
return segment?.trim() ?? "";
}
function lastPathSegment(path: string): string {
const segments = path.split(".");
return segments.at(-1) ?? path;
}
function isSectionHint(path: string, hint: ConfigUiHint): boolean {
return !path.includes(".") && typeof hint.order === "number" && typeof hint.group === "string";
}
function fieldSort(a: ExplorerField, b: ExplorerField): number {
return a.path.localeCompare(b.path);
}
function sectionSort(a: ExplorerSection, b: ExplorerSection): number {
if (a.order !== b.order) {
return a.order - b.order;
}
return a.label.localeCompare(b.label);
}
function pruneRedundantCompositeFields(fields: ExplorerField[]): ExplorerField[] {
return fields.filter((field) => {
if (field.kind !== "object" && field.kind !== "array") {
return true;
}
// If we already expose concrete descendants as first-class fields,
// do not also render the composite parent card (it duplicates controls).
const prefix = `${field.path}.`;
const hasDescendant = fields.some((candidate) =>
candidate.path !== field.path && candidate.path.startsWith(prefix)
);
return !hasDescendant;
});
}
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;
}
function enumValuesFromNode(node: JsonSchemaNode | null, depth = 0): string[] {
if (!node || depth > 5) {
return [];
}
const values = new Set<string>();
if (Array.isArray(node.enum)) {
for (const entry of node.enum) {
values.add(String(entry));
}
}
if (node.const !== undefined) {
values.add(String(node.const));
}
const unionPool = [...(node.anyOf ?? []), ...(node.oneOf ?? []), ...(node.allOf ?? [])];
for (const entry of unionPool) {
for (const option of enumValuesFromNode(entry, depth + 1)) {
values.add(option);
}
}
return Array.from(values);
}
function hasOpenScalarType(node: JsonSchemaNode | null, expected: "string" | "number" | "integer" | "boolean", depth = 0): boolean {
if (!node || depth > 8) {
return false;
}
const rawType = node.type;
const matchesType =
rawType === expected || (Array.isArray(rawType) && rawType.includes(expected));
if (matchesType && node.const === undefined && !Array.isArray(node.enum)) {
return true;
}
const unionPool = [...(node.anyOf ?? []), ...(node.oneOf ?? []), ...(node.allOf ?? [])];
return unionPool.some((entry) => hasOpenScalarType(entry, expected, depth + 1));
}
function resolveType(node: JsonSchemaNode | null): FieldKind {
if (!node) {
return "unknown";
}
const enumValues = enumValuesFromNode(node);
if (enumValues.length > 0) {
if (hasOpenScalarType(node, "string")) {
return "string";
}
if (hasOpenScalarType(node, "integer")) {
return "integer";
}
if (hasOpenScalarType(node, "number")) {
return "number";
}
if (hasOpenScalarType(node, "boolean")) {
return "boolean";
}
return "enum";
}
const resolved = resolveUnion(node);
const rawType = resolved.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 (resolved.properties) {
return "object";
}
if (resolved.items) {
return "array";
}
return "unknown";
}
}
function firstArrayItemNode(node: JsonSchemaNode | null): JsonSchemaNode | null {
if (!node) {
return null;
}
const resolved = resolveUnion(node);
if (Array.isArray(resolved.items)) {
return asObjectNode(resolved.items[0] ?? null);
}
return asObjectNode(resolved.items);
}
function recordValueNode(node: JsonSchemaNode | null): JsonSchemaNode | null {
if (!node) {
return null;
}
const resolved = resolveUnion(node);
const properties = resolved.properties ?? {};
if (Object.keys(properties).length > 0) {
return null;
}
return asObjectNode(resolved.additionalProperties);
}
function isEditable(path: string, kind: FieldKind): boolean {
if (path.includes("*") || path.includes("[]")) {
return false;
}
return kind !== "unknown";
}
function buildExplorerSchemaNode(node: JsonSchemaNode | null, depth = 0): ExplorerSchemaNode | null {
if (!node || depth > 8) {
return null;
}
const resolved = resolveUnion(node);
const kind = resolveType(resolved);
const properties: Record<string, ExplorerSchemaNode> = {};
for (const [key, child] of Object.entries(resolved.properties ?? {})) {
const childSchema = buildExplorerSchemaNode(asObjectNode(child), depth + 1);
if (childSchema) {
properties[key] = childSchema;
}
}
const item = buildExplorerSchemaNode(firstArrayItemNode(resolved), depth + 1);
const additionalRaw = resolved.additionalProperties;
const additionalProperties = buildExplorerSchemaNode(asObjectNode(additionalRaw), depth + 1);
const allowsUnknownProperties = additionalRaw === true;
return {
kind,
enumValues: enumValuesFromNode(node),
properties,
item,
additionalProperties,
allowsUnknownProperties,
};
}
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: enumValuesFromNode(schemaNode),
itemKind,
itemEnumValues: enumValuesFromNode(arrayItemNode),
recordValueKind,
recordEnumValues: enumValuesFromNode(recordNode),
hasDefault: schemaNode?.default !== undefined,
editable: isEditable(path, kind),
schemaNode: buildExplorerSchemaNode(schemaNode),
};
}
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;
}
const field = buildExplorerField(path, hint, context.schemaRoot);
return field.kind === "unknown" ? null : field;
}
export function buildExplorerSnapshot(): ExplorerSnapshot {
const context = getSchemaContext();
const uiHints = context.uiHints;
const schemaRoot = context.schemaRoot;
const schemaProperties = schemaRoot.properties ?? {};
const sections = new Map<string, ExplorerSection>();
for (const [path, hint] of Object.entries(uiHints)) {
if (!isSectionHint(path, hint)) {
continue;
}
sections.set(path, {
id: path,
label: hint.label?.trim() || hint.group?.trim() || humanizeKey(path),
order: hint.order,
description: "",
fields: [],
});
}
for (const [rootKey, node] of Object.entries(schemaProperties)) {
if (sections.has(rootKey)) {
const existing = sections.get(rootKey);
if (existing) {
existing.description = node.description?.trim() ?? existing.description;
}
continue;
}
const rootHint = uiHints[rootKey];
sections.set(rootKey, {
id: rootKey,
label: rootHint?.label?.trim() || humanizeKey(rootKey),
order: rootHint?.order ?? SECTION_FALLBACK_ORDER,
description: node.description?.trim() ?? rootHint?.help?.trim() ?? "",
fields: [],
});
}
for (const [path, hint] of Object.entries(uiHints)) {
const rootKey = firstPathSegment(path);
if (!rootKey) {
continue;
}
if (!sections.has(rootKey)) {
sections.set(rootKey, {
id: rootKey,
label: humanizeKey(rootKey),
order: SECTION_FALLBACK_ORDER,
description: "",
fields: [],
});
}
if (isSectionHint(path, hint)) {
continue;
}
const target = sections.get(rootKey);
if (!target) {
continue;
}
const field = buildExplorerField(path, hint, schemaRoot);
if (field.kind === "unknown") {
// Ignore hint-only fields that do not resolve against the current schema.
continue;
}
target.fields.push(field);
}
const orderedSections = Array.from(sections.values())
.map((section) => {
const sorted = section.fields.toSorted(fieldSort);
return { ...section, fields: pruneRedundantCompositeFields(sorted) };
})
.filter((section) => section.fields.length > 0)
.toSorted(sectionSort);
const fieldCount = orderedSections.reduce((sum, section) => sum + section.fields.length, 0);
return {
version: context.version,
generatedAt: context.generatedAt,
sectionCount: orderedSections.length,
fieldCount,
sections: orderedSections,
};
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { validateConfigDraft } from "./validation.ts";
describe("validateConfigDraft", () => {
it("accepts empty drafts", () => {
const result = validateConfigDraft({});
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("collects issues by path and section", () => {
const result = validateConfigDraft({
gateway: {
auth: {
token: 123,
},
},
});
expect(result.valid).toBe(false);
expect(result.issues.length).toBeGreaterThan(0);
expect(result.issuesByPath["gateway.auth.token"]?.length).toBeGreaterThan(0);
expect(result.sectionErrorCounts.gateway).toBeGreaterThan(0);
});
it("tracks root-level schema issues", () => {
const result = validateConfigDraft({
__unexpected__: true,
});
expect(result.valid).toBe(false);
expect(result.sectionErrorCounts.root).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,73 @@
import { OpenClawSchema } from "@openclaw/config/zod-schema.ts";
import type { ConfigDraft } from "./config-store.ts";
export type ValidationIssue = {
path: string;
section: string;
message: string;
};
export type ValidationResult = {
valid: boolean;
issues: ValidationIssue[];
issuesByPath: Record<string, string[]>;
sectionErrorCounts: Record<string, number>;
};
function issuePath(path: Array<string | number>): string {
if (path.length === 0) {
return "";
}
return path
.map((segment) => (typeof segment === "number" ? String(segment) : segment))
.join(".");
}
function issueSection(path: string): string {
if (!path) {
return "root";
}
const [section] = path.split(".");
return section?.trim() || "root";
}
export function validateConfigDraft(config: ConfigDraft): ValidationResult {
const parsed = OpenClawSchema.safeParse(config);
if (parsed.success) {
return {
valid: true,
issues: [],
issuesByPath: {},
sectionErrorCounts: {},
};
}
const issues: ValidationIssue[] = parsed.error.issues.map((issue) => {
const path = issuePath(issue.path);
return {
path,
section: issueSection(path),
message: issue.message,
};
});
const issuesByPath: Record<string, string[]> = {};
const sectionErrorCounts: Record<string, number> = {};
for (const issue of issues) {
const key = issue.path;
if (!issuesByPath[key]) {
issuesByPath[key] = [];
}
issuesByPath[key].push(issue.message);
sectionErrorCounts[issue.section] = (sectionErrorCounts[issue.section] ?? 0) + 1;
}
return {
valid: false,
issues,
issuesByPath,
sectionErrorCounts,
};
}

View File

@@ -0,0 +1,2 @@
import "./styles.css";
import "./ui/app.ts";

View File

@@ -0,0 +1,10 @@
// Browser-safe channel ID shim for config-builder schema imports.
export const CHANNEL_IDS = [
"telegram",
"whatsapp",
"discord",
"googlechat",
"slack",
"signal",
"imessage",
] as const;

View File

@@ -0,0 +1,3 @@
// Browser-safe version shim for config-builder schema imports.
// Real gateway/runtime paths can still use src/version.ts.
export const VERSION = "dev";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,729 @@
import { html, nothing, type TemplateResult } from "lit";
import type { ExplorerField, ExplorerSchemaNode, FieldKind } from "../../lib/schema-spike.ts";
type FieldRendererParams = {
field: ExplorerField;
value: unknown;
onSet: (value: unknown) => void;
onClear: () => void;
onValidationError?: (message: string) => void;
suggestions?: string[];
};
type ScalarKind = "string" | "number" | "integer" | "boolean" | "enum";
type ScalarControlParams = {
kind: ScalarKind;
enumValues: string[];
value: unknown;
sensitive?: boolean;
compact?: boolean;
onSet: (value: unknown) => void;
onClear?: () => void;
onValidationError?: (message: string) => void;
suggestions?: string[];
};
type NodeRendererParams = {
node: ExplorerSchemaNode | null;
value: unknown;
onSet: (value: unknown) => void;
onClear?: () => void;
onValidationError?: (message: string) => void;
depth?: number;
compact?: boolean;
suggestions?: string[];
};
const MAX_EDITOR_DEPTH = 6;
function humanizeKey(value: string): string {
if (!value.trim()) {
return value;
}
return value
.replace(/[_-]+/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim()
.replace(/^./, (char) => char.toUpperCase());
}
function defaultValueForKind(kind: FieldKind, enumValues: string[] = []): unknown {
if (kind === "boolean") {
return false;
}
if (kind === "number" || kind === "integer") {
return 0;
}
if (kind === "array") {
return [];
}
if (kind === "object") {
return {};
}
if (kind === "enum") {
return enumValues[0] ?? "";
}
return "";
}
function defaultValueForNode(node: ExplorerSchemaNode | null): unknown {
if (!node) {
return "";
}
return defaultValueForKind(node.kind, node.enumValues);
}
function parseScalar(kind: FieldKind, raw: string): unknown {
if (kind === "number") {
const parsed = Number(raw);
if (Number.isNaN(parsed)) {
throw new Error("Enter a valid number.");
}
return parsed;
}
if (kind === "integer") {
const parsed = Number(raw);
if (Number.isNaN(parsed) || !Number.isFinite(parsed)) {
throw new Error("Enter a valid integer.");
}
return Math.trunc(parsed);
}
if (kind === "boolean") {
if (raw === "true") {
return true;
}
if (raw === "false") {
return false;
}
throw new Error("Use true or false.");
}
return raw;
}
function asObject(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function jsonValue(value: unknown): string {
if (value === undefined) {
return "{}";
}
try {
return JSON.stringify(value, null, 2) ?? "{}";
} catch {
return "{}";
}
}
function scalarInputValue(value: unknown): string {
if (value == null) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
return "";
}
function normalizeSuggestion(value: string): string {
return value.trim().toLowerCase();
}
function dedupeSuggestions(values: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const entry of values) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const normalized = normalizeSuggestion(trimmed);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
out.push(trimmed);
}
return out;
}
function subsequenceScore(query: string, candidate: string): number {
let qi = 0;
let score = 0;
for (let i = 0; i < candidate.length && qi < query.length; i += 1) {
if (candidate[i] === query[qi]) {
score += i;
qi += 1;
}
}
if (qi !== query.length) {
return Number.POSITIVE_INFINITY;
}
return score;
}
function fuzzyFilterSuggestions(options: string[], query: string, limit = 8): string[] {
const unique = dedupeSuggestions(options);
const normalizedQuery = normalizeSuggestion(query);
if (!normalizedQuery) {
return unique.slice(0, limit);
}
const ranked = unique
.map((entry) => {
const normalized = normalizeSuggestion(entry);
if (normalized === normalizedQuery) {
return { entry, score: 0 };
}
if (normalized.startsWith(normalizedQuery)) {
return { entry, score: 1 + normalized.length / 1000 };
}
const includesAt = normalized.indexOf(normalizedQuery);
if (includesAt >= 0) {
return { entry, score: 2 + includesAt / 100 };
}
const subseq = subsequenceScore(normalizedQuery, normalized);
if (Number.isFinite(subseq)) {
return { entry, score: 3 + subseq / 1000 };
}
return null;
})
.filter((entry): entry is { entry: string; score: number } => entry !== null)
.toSorted((a, b) => {
if (a.score !== b.score) {
return a.score - b.score;
}
if (a.entry.length !== b.entry.length) {
return a.entry.length - b.entry.length;
}
return a.entry.localeCompare(b.entry);
});
return ranked.slice(0, limit).map((entry) => entry.entry);
}
function renderJsonControl(params: {
kind: FieldKind;
value: unknown;
onSet: (value: unknown) => void;
onValidationError?: (message: string) => void;
}): TemplateResult {
const { kind, value, onSet, onValidationError } = params;
const fallback = kind === "array" ? [] : {};
return html`
<label class="cfg-field">
<span class="cfg-field__help">Edit as JSON (${kind})</span>
<textarea
class="cfg-textarea"
rows="4"
.value=${jsonValue(value ?? fallback)}
@change=${(event: Event) => {
const target = event.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onSet(fallback);
return;
}
try {
onSet(JSON.parse(raw));
} catch {
onValidationError?.("Invalid JSON value.");
target.value = jsonValue(value ?? fallback);
}
}}
></textarea>
</label>
`;
}
function renderScalarControl(params: ScalarControlParams): TemplateResult {
const { kind, enumValues, value, sensitive, compact, onSet, onClear, onValidationError, suggestions } =
params;
if (kind === "boolean") {
return html`
<label class="cfg-toggle-row builder-toggle-row">
<span class="cfg-field__help">Toggle value</span>
<div class="cfg-toggle">
<input
type="checkbox"
.checked=${value === true}
@change=${(event: Event) => onSet((event.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</div>
</label>
`;
}
if (kind === "enum") {
const selected = typeof value === "string" ? value : "";
if (!compact && enumValues.length > 0 && enumValues.length <= 4) {
return html`
<div class="cfg-segmented">
${enumValues.map(
(entry) => html`
<button
type="button"
class="cfg-segmented__btn ${entry === selected ? "active" : ""}"
@click=${() => onSet(entry)}
>
${entry}
</button>
`,
)}
${onClear
? html`
<button
type="button"
class="cfg-segmented__btn ${selected ? "" : "active"}"
@click=${onClear}
>
unset
</button>
`
: nothing}
</div>
`;
}
return html`
<select
class="cfg-select ${compact ? "cfg-select--sm" : ""}"
.value=${selected}
@change=${(event: Event) => {
const next = (event.target as HTMLSelectElement).value;
if (!next) {
if (onClear) {
onClear();
} else if (enumValues[0]) {
onSet(enumValues[0]);
}
return;
}
onSet(next);
}}
>
${onClear ? html`<option value="">(unset)</option>` : nothing}
${enumValues.map((entry) => html`<option value=${entry}>${entry}</option>`) }
</select>
`;
}
const inputType = kind === "number" || kind === "integer" ? "number" : "text";
const inputValue = scalarInputValue(value);
const applyRawValue = (raw: string) => {
if (raw.trim() === "") {
if (onClear) {
onClear();
} else {
onSet(defaultValueForKind(kind));
}
return;
}
try {
onSet(parseScalar(kind, raw));
} catch (error) {
onValidationError?.(error instanceof Error ? error.message : String(error));
}
};
const input = html`
<input
class="cfg-input ${compact ? "cfg-input--sm" : ""}"
type=${sensitive ? "password" : inputType}
.value=${inputValue}
@input=${(event: Event) => {
applyRawValue((event.target as HTMLInputElement).value);
}}
/>
`;
if (kind !== "string") {
return input;
}
const filteredSuggestions = fuzzyFilterSuggestions(suggestions ?? [], inputValue);
if (filteredSuggestions.length === 0) {
return input;
}
return html`
<div class="cb-typeahead ${compact ? "cb-typeahead--compact" : ""}">
${input}
<div class="cb-typeahead__menu" role="listbox" aria-label="Suggestions">
${filteredSuggestions.map((entry) => html`
<button
type="button"
class="cb-typeahead__option"
@mousedown=${(event: Event) => event.preventDefault()}
@click=${() => onSet(entry)}
>
${entry}
</button>
`)}
</div>
</div>
`;
}
function renderArrayNodeControl(params: {
node: ExplorerSchemaNode;
value: unknown;
onSet: (value: unknown) => void;
onValidationError?: (message: string) => void;
depth: number;
suggestions?: string[];
}): TemplateResult {
const { node, value, onSet, onValidationError, depth, suggestions } = params;
const list = asArray(value);
const itemNode = node.item;
if (!itemNode || itemNode.kind === "unknown") {
return renderJsonControl({ kind: "array", value, onSet, onValidationError });
}
return html`
<div class="cfg-array">
<div class="cfg-array__header">
<span class="cfg-array__label">Items</span>
<span class="cfg-array__count">${list.length} item${list.length === 1 ? "" : "s"}</span>
<button
type="button"
class="cfg-array__add"
@click=${() => onSet([...list, defaultValueForNode(itemNode)])}
>
Add
</button>
</div>
${list.length === 0
? html`<div class="cfg-array__empty">No items yet.</div>`
: html`
<div class="cfg-array__items">
${list.map((item, index) =>
html`
<div class="cfg-array__item">
<div class="cfg-array__item-header">
<span class="cfg-array__item-index">#${index + 1}</span>
<button
type="button"
class="cfg-array__item-remove"
title="Remove item"
@click=${() => {
const next = [...list];
next.splice(index, 1);
onSet(next);
}}
>
×
</button>
</div>
<div class="cfg-array__item-content">
${renderNodeEditor({
node: itemNode,
value: item,
onSet: (nextValue) => {
const next = [...list];
next[index] = nextValue;
onSet(next);
},
onValidationError,
depth: depth + 1,
compact: true,
suggestions,
})}
</div>
</div>
`,
)}
</div>
`}
</div>
`;
}
function renderObjectNodeControl(params: {
node: ExplorerSchemaNode;
value: unknown;
onSet: (value: unknown) => void;
onValidationError?: (message: string) => void;
depth: number;
suggestions?: string[];
}): TemplateResult {
const { node, value, onSet, onValidationError, depth, suggestions } = params;
const record = asObject(value);
const fixedEntries = Object.entries(node.properties);
const fixedKeys = new Set(fixedEntries.map(([key]) => key));
const extraSchema = node.additionalProperties;
const extraEntries = Object.entries(record).filter(([key]) => !fixedKeys.has(key));
const hasFixed = fixedEntries.length > 0;
const canEditExtras = Boolean(extraSchema && extraSchema.kind !== "unknown");
if (!hasFixed && !canEditExtras && node.allowsUnknownProperties) {
return renderJsonControl({ kind: "object", value, onSet, onValidationError });
}
const setChildValue = (key: string, nextValue: unknown) => {
const next = { ...record };
next[key] = nextValue;
onSet(next);
};
const clearChildValue = (key: string) => {
const next = { ...record };
delete next[key];
onSet(next);
};
const addExtraEntry = () => {
if (!extraSchema) {
return;
}
const next = { ...record };
let index = 1;
let key = `key-${index}`;
while (key in next) {
index += 1;
key = `key-${index}`;
}
next[key] = defaultValueForNode(extraSchema);
onSet(next);
};
return html`
<div class="cfg-object-stack">
${hasFixed
? html`
<div class="cfg-map">
<div class="cfg-map__header">
<span class="cfg-map__label">Fields</span>
</div>
<div class="cfg-map__items">
${fixedEntries.map(([key, childNode]) => {
const childValue = record[key];
const hasValue = childValue !== undefined;
return html`
<div class="cfg-map__item">
<div class="cfg-map__item-key">
<span class="cfg-field__help">${humanizeKey(key)}</span>
</div>
<div class="cfg-map__item-value">
${renderNodeEditor({
node: childNode,
value: childValue,
onSet: (nextValue) => setChildValue(key, nextValue),
onClear: () => clearChildValue(key),
onValidationError,
depth: depth + 1,
compact: true,
suggestions,
})}
</div>
<button
type="button"
class="cfg-map__item-remove"
title="Clear field"
?disabled=${!hasValue}
@click=${() => clearChildValue(key)}
>
×
</button>
</div>
`;
})}
</div>
</div>
`
: nothing}
${canEditExtras && extraSchema
? html`
<div class="cfg-map">
<div class="cfg-map__header">
<span class="cfg-map__label">Entries</span>
<button type="button" class="cfg-map__add" @click=${addExtraEntry}>Add Entry</button>
</div>
${extraEntries.length === 0
? html`<div class="cfg-map__empty">No entries yet.</div>`
: html`
<div class="cfg-map__items">
${extraEntries.map(([key, entryValue]) =>
html`
<div class="cfg-map__item">
<div class="cfg-map__item-key">
<input
type="text"
class="cfg-input cfg-input--sm"
.value=${key}
@change=${(event: Event) => {
const nextKey = (event.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key || nextKey in record) {
return;
}
const next = { ...record };
next[nextKey] = next[key];
delete next[key];
onSet(next);
}}
/>
</div>
<div class="cfg-map__item-value">
${renderNodeEditor({
node: extraSchema,
value: entryValue,
onSet: (nextValue) => setChildValue(key, nextValue),
onValidationError,
depth: depth + 1,
compact: true,
suggestions,
})}
</div>
<button
type="button"
class="cfg-map__item-remove"
title="Remove entry"
@click=${() => clearChildValue(key)}
>
×
</button>
</div>
`,
)}
</div>
`}
</div>
`
: nothing}
${!hasFixed && !canEditExtras && !node.allowsUnknownProperties
? html`<div class="cfg-field__help">No editable keys in this object schema.</div>`
: nothing}
</div>
`;
}
function renderNodeEditor(params: NodeRendererParams): TemplateResult {
const { node, value, onSet, onClear, onValidationError, suggestions } = params;
const depth = params.depth ?? 0;
const compact = params.compact ?? false;
if (!node || depth > MAX_EDITOR_DEPTH) {
return renderJsonControl({ kind: "object", value, onSet, onValidationError });
}
if (
node.kind === "string" ||
node.kind === "number" ||
node.kind === "integer" ||
node.kind === "boolean" ||
node.kind === "enum"
) {
return renderScalarControl({
kind: node.kind,
enumValues: node.enumValues,
value,
onSet,
onClear,
onValidationError,
compact,
suggestions,
});
}
if (node.kind === "array") {
return renderArrayNodeControl({
node,
value,
onSet,
onValidationError,
depth,
suggestions,
});
}
if (node.kind === "object") {
return renderObjectNodeControl({
node,
value,
onSet,
onValidationError,
depth,
suggestions,
});
}
return renderJsonControl({ kind: node.kind, value, onSet, onValidationError });
}
export function renderFieldEditor(params: FieldRendererParams): TemplateResult | typeof nothing {
const { field, value, onSet, onClear, onValidationError, suggestions } = params;
if (!field.editable) {
return html`<div class="cfg-field__help">Read-only in this phase.</div>`;
}
if (
field.kind === "string" ||
field.kind === "number" ||
field.kind === "integer" ||
field.kind === "boolean" ||
field.kind === "enum"
) {
return renderScalarControl({
kind: field.kind,
enumValues: field.enumValues,
value,
sensitive: field.sensitive,
onSet,
onClear,
onValidationError,
suggestions,
});
}
if (field.kind === "array" || field.kind === "object") {
return renderNodeEditor({
node: field.schemaNode,
value,
onSet,
onValidationError,
depth: 0,
suggestions,
});
}
return html`<div class="cfg-field__help">Unsupported schema node.</div>`;
}

View File

@@ -0,0 +1,329 @@
import { svg, type TemplateResult } from "lit";
// Lucide-style SVG icons used throughout the config builder.
// Ported from the OpenClaw web UI icon set + additions.
function icon(content: TemplateResult): TemplateResult {
return svg`<svg
class="cb-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
${content}
</svg>`;
}
// --- Section icons (match web UI sidebar) ---
export const iconGateway = icon(svg`
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
`);
export const iconChannels = icon(svg`
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
`);
export const iconAgents = icon(svg`
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"></path>
<circle cx="8" cy="14" r="1"></circle>
<circle cx="16" cy="14" r="1"></circle>
`);
export const iconAuth = icon(svg`
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
`);
export const iconMessages = icon(svg`
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
`);
export const iconTools = icon(svg`
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
`);
export const iconSession = icon(svg`
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
`);
export const iconHooks = icon(svg`
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
`);
export const iconSkills = icon(svg`
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
`);
export const iconCommands = icon(svg`
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
`);
export const iconModels = icon(svg`
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
`);
export const iconEnv = icon(svg`
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
`);
export const iconUpdate = icon(svg`
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
`);
export const iconLogging = icon(svg`
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
`);
export const iconBroadcast = icon(svg`
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
<circle cx="12" cy="12" r="2"></circle>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path>
`);
export const iconPlugins = icon(svg`
<path d="M12 2v6"></path>
<path d="m4.93 10.93 4.24 4.24"></path>
<path d="M2 12h6"></path>
<path d="m4.93 13.07 4.24-4.24"></path>
<path d="M12 22v-6"></path>
<path d="m19.07 13.07-4.24-4.24"></path>
<path d="M22 12h-6"></path>
<path d="m19.07 10.93-4.24 4.24"></path>
`);
export const iconWeb = icon(svg`
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
`);
export const iconCron = icon(svg`
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
`);
export const iconAudio = icon(svg`
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
`);
export const iconUI = icon(svg`
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
`);
export const iconWizard = icon(svg`
<path d="M15 4V2"></path>
<path d="M15 16v-2"></path>
<path d="M8 9h2"></path>
<path d="M20 9h2"></path>
<path d="M17.8 11.8 19 13"></path>
<path d="M15 9h0"></path>
<path d="M17.8 6.2 19 5"></path>
<path d="m3 21 9-9"></path>
<path d="M12.2 6.2 11 5"></path>
`);
export const iconDefault = icon(svg`
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
`);
// --- UI action icons ---
export const iconSearch = icon(svg`
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
`);
export const iconChevronDown = icon(svg`
<polyline points="6 9 12 15 18 9"></polyline>
`);
export const iconChevronRight = icon(svg`
<polyline points="9 18 15 12 9 6"></polyline>
`);
export const iconChevronLeft = icon(svg`
<polyline points="15 18 9 12 15 6"></polyline>
`);
export const iconCopy = icon(svg`
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
`);
export const iconDownload = icon(svg`
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
`);
export const iconTrash = icon(svg`
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
`);
export const iconCheck = icon(svg`
<polyline points="20 6 9 17 4 12"></polyline>
`);
export const iconX = icon(svg`
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
`);
export const iconSparkles = icon(svg`
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"></path>
<path d="M5 3v4"></path>
<path d="M19 17v4"></path>
<path d="M3 5h4"></path>
<path d="M17 19h4"></path>
`);
export const iconImport = icon(svg`
<path d="M12 3v12"></path>
<path d="m8 11 4 4 4-4"></path>
<path d="M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4"></path>
`);
export const iconExternalLink = icon(svg`
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
`);
export const iconEye = icon(svg`
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
`);
export const iconEyeOff = icon(svg`
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"></path>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"></path>
`);
export const iconGrid = icon(svg`
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
`);
export const iconLock = icon(svg`
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
`);
export const iconShield = icon(svg`
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
`);
export const iconCode = icon(svg`
<polyline points="16 18 22 12 16 6"></polyline>
<polyline points="8 6 2 12 8 18"></polyline>
`);
export const iconSun = icon(svg`
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
`);
export const iconMoon = icon(svg`
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
`);
export const iconArrowRight = icon(svg`
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
`);
export const iconArrowLeft = icon(svg`
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
`);
export const iconMoreVertical = icon(svg`
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
`);
export const iconFile = icon(svg`
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
`);
export const iconPanelRight = icon(svg`
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="15" y1="3" x2="15" y2="21"></line>
`);
export const iconSidebar = icon(svg`
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
`);
// --- Section icon lookup ---
const SECTION_ICON_MAP: Record<string, TemplateResult> = {
gateway: iconGateway,
channels: iconChannels,
agents: iconAgents,
auth: iconAuth,
messages: iconMessages,
tools: iconTools,
session: iconSession,
hooks: iconHooks,
skills: iconSkills,
commands: iconCommands,
models: iconModels,
env: iconEnv,
update: iconUpdate,
logging: iconLogging,
broadcast: iconBroadcast,
plugins: iconPlugins,
web: iconWeb,
cron: iconCron,
audio: iconAudio,
ui: iconUI,
wizard: iconWizard,
};
export function sectionIcon(sectionId: string): TemplateResult {
return SECTION_ICON_MAP[sectionId] ?? iconDefault;
}

View File

@@ -0,0 +1,247 @@
import { html, nothing, type TemplateResult } from "lit";
import JSON5 from "json5";
import type { ConfigDraft } from "../../lib/config-store.ts";
import { iconImport, iconFile, iconX, iconCheck } from "./icons.ts";
export type ImportDialogState = {
open: boolean;
tab: "paste" | "upload";
pasteValue: string;
error: string | null;
dragOver: boolean;
};
export function createImportDialogState(): ImportDialogState {
return {
open: false,
tab: "paste",
pasteValue: "",
error: null,
dragOver: false,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function deepMerge(
base: Record<string, unknown>,
incoming: Record<string, unknown>,
): Record<string, unknown> {
const result = { ...base };
for (const [key, value] of Object.entries(incoming)) {
if (isRecord(value) && isRecord(result[key])) {
result[key] = deepMerge(result[key], value);
} else {
result[key] = value;
}
}
return result;
}
function parseInput(raw: string): { config: ConfigDraft; error: string | null } {
const trimmed = raw.trim();
if (!trimmed) {
return { config: {}, error: "Input is empty." };
}
try {
const parsed = JSON5.parse(trimmed) as unknown;
if (!isRecord(parsed)) {
return { config: {}, error: "Parsed value is not an object." };
}
return { config: parsed, error: null };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { config: {}, error: `Parse error: ${message}` };
}
}
export type ImportCallbacks = {
onReplace: (config: ConfigDraft) => void;
onMerge: (config: ConfigDraft) => void;
onClose: () => void;
onStateChange: (state: ImportDialogState) => void;
};
export function renderImportDialog(
state: ImportDialogState,
hasExistingDraft: boolean,
callbacks: ImportCallbacks,
): TemplateResult | typeof nothing {
if (!state.open) {return nothing;}
const handlePasteImport = (mode: "replace" | "merge") => {
const { config, error } = parseInput(state.pasteValue);
if (error) {
callbacks.onStateChange({ ...state, error });
return;
}
if (mode === "replace") {
callbacks.onReplace(config);
} else {
callbacks.onMerge(config);
}
callbacks.onClose();
};
const handleFileContent = (content: string, mode: "replace" | "merge") => {
const { config, error } = parseInput(content);
if (error) {
callbacks.onStateChange({ ...state, error });
return;
}
if (mode === "replace") {
callbacks.onReplace(config);
} else {
callbacks.onMerge(config);
}
callbacks.onClose();
};
const handleFileDrop = (e: DragEvent, mode: "replace" | "merge") => {
e.preventDefault();
callbacks.onStateChange({ ...state, dragOver: false });
const file = e.dataTransfer?.files?.[0];
if (!file) {return;}
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
handleFileContent(reader.result, mode);
}
};
reader.readAsText(file);
};
const handleFilePick = (e: Event, mode: "replace" | "merge") => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) {return;}
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
handleFileContent(reader.result, mode);
}
};
reader.readAsText(file);
};
return html`
<div class="cb-palette-overlay" @click=${(e: Event) => {
if (e.target === e.currentTarget) {callbacks.onClose();}
}}>
<div class="cb-import-dialog">
<div class="cb-import-dialog__header">
<div class="cb-import-dialog__title">
${iconImport} Import Config
</div>
<button class="cb-import-dialog__close" @click=${callbacks.onClose}>
${iconX}
</button>
</div>
<div class="cb-import-dialog__tabs">
<button
class="cb-import-dialog__tab ${state.tab === "paste" ? "active" : ""}"
@click=${() => callbacks.onStateChange({ ...state, tab: "paste", error: null })}
>
Paste JSON5
</button>
<button
class="cb-import-dialog__tab ${state.tab === "upload" ? "active" : ""}"
@click=${() => callbacks.onStateChange({ ...state, tab: "upload", error: null })}
>
Upload File
</button>
</div>
<div class="cb-import-dialog__body">
${state.tab === "paste"
? html`
<textarea
class="cb-import-dialog__textarea"
rows="10"
placeholder='Paste your openclaw.json or JSON5 content here…\n\n{\n gateway: { port: 18789 },\n agents: { ... }\n}'
.value=${state.pasteValue}
@input=${(e: Event) => {
callbacks.onStateChange({
...state,
pasteValue: (e.target as HTMLTextAreaElement).value,
error: null,
});
}}
></textarea>
`
: html`
<div
class="cb-import-dialog__drop-zone ${state.dragOver ? "cb-import-dialog__drop-zone--active" : ""}"
@dragover=${(e: DragEvent) => {
e.preventDefault();
if (!state.dragOver) {
callbacks.onStateChange({ ...state, dragOver: true });
}
}}
@dragleave=${() => {
callbacks.onStateChange({ ...state, dragOver: false });
}}
@drop=${(e: DragEvent) => handleFileDrop(e, hasExistingDraft ? "merge" : "replace")}
>
<div class="cb-import-dialog__drop-icon">${iconFile}</div>
<div class="cb-import-dialog__drop-text">
Drop your config file here
</div>
<div class="cb-import-dialog__drop-sub">
or
<label class="cb-import-dialog__file-label">
browse files
<input
type="file"
accept=".json,.json5,.jsonc"
style="display:none"
@change=${(e: Event) => handleFilePick(e, hasExistingDraft ? "merge" : "replace")}
/>
</label>
</div>
</div>
`}
${state.error
? html`<div class="cb-import-dialog__error">${state.error}</div>`
: nothing}
</div>
${state.tab === "paste"
? html`
<div class="cb-import-dialog__footer">
${hasExistingDraft
? html`
<button
class="btn btn--sm"
?disabled=${!state.pasteValue.trim()}
@click=${() => handlePasteImport("merge")}
>
Merge with draft
</button>
<button
class="btn btn--sm danger"
?disabled=${!state.pasteValue.trim()}
@click=${() => handlePasteImport("replace")}
>
Replace draft
</button>
`
: html`
<button
class="btn btn--sm primary"
?disabled=${!state.pasteValue.trim()}
@click=${() => handlePasteImport("replace")}
>
${iconCheck} Import
</button>
`}
</div>
`
: nothing}
</div>
</div>
`;
}

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

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["vite/client"],
"paths": {
"@openclaw/config/*": ["../../src/config/*"]
}
},
"include": ["src/**/*", "vite.config.ts"]
}

View File

@@ -0,0 +1,5 @@
{
"installCommand": "pnpm install",
"buildCommand": "vite build --outDir dist",
"outputDirectory": "dist"
}

View File

@@ -0,0 +1,60 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../..");
function normalizeBase(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
return "/";
}
if (trimmed === "./") {
return "./";
}
if (trimmed.endsWith("/")) {
return trimmed;
}
return `${trimmed}/`;
}
export default defineConfig(() => {
const envBase = process.env.OPENCLAW_CONFIG_BUILDER_BASE_PATH?.trim();
const base = envBase ? normalizeBase(envBase) : "./";
return {
base,
publicDir: path.resolve(here, "public"),
resolve: {
alias: [
{
find: "@openclaw/config",
replacement: path.resolve(repoRoot, "src/config"),
},
{
// src/config/schema.ts imports ../version.js; redirect to a browser-safe shim.
find: "../version.js",
replacement: path.resolve(here, "src/shims/version.ts"),
},
{
// src/config/schema.ts imports ../channels/registry.js; redirect to a light shim.
find: "../channels/registry.js",
replacement: path.resolve(here, "src/shims/channel-registry.ts"),
},
],
},
optimizeDeps: {
include: ["lit"],
},
build: {
outDir: path.resolve(here, "../../dist/config-builder"),
emptyOutDir: true,
sourcemap: true,
},
server: {
host: true,
port: 5174,
strictPort: true,
},
};
});

View File

@@ -0,0 +1,18 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "../..");
export default defineConfig({
resolve: {
alias: {
"@openclaw/config": path.resolve(repoRoot, "src/config"),
},
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});

View File

@@ -3,6 +3,7 @@ packages:
- ui
- packages/*
- extensions/*
- apps/*
onlyBuiltDependencies:
- "@lydell/node-pty"

View File

@@ -473,6 +473,7 @@ const ERROR_PATTERNS = {
"insufficient credits",
"credit balance",
"plans & billing",
"billing:",
"insufficient balance",
],
auth: [
@@ -534,7 +535,7 @@ export function isBillingErrorMessage(raw: string): boolean {
if (!value) {
return false;
}
return matchesErrorPatterns(value, ERROR_PATTERNS.billing);
}