Compare commits
16 Commits
secrets/pr
...
feat/confi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
178259e680 | ||
|
|
dbe8b8dc4a | ||
|
|
21d075f0c9 | ||
|
|
e40b125bb8 | ||
|
|
60554db11e | ||
|
|
671ac4d924 | ||
|
|
7546dfaa88 | ||
|
|
451439e4ff | ||
|
|
dea5ad9c6a | ||
|
|
82871daf36 | ||
|
|
d22b4c3769 | ||
|
|
04c741b3ec | ||
|
|
58b54b24f0 | ||
|
|
d3bac0b26d | ||
|
|
cf3f8a6c85 | ||
|
|
60d92ca561 |
58
apps/config-builder/README.md
Normal file
58
apps/config-builder/README.md
Normal 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.
|
||||
12
apps/config-builder/index.html
Normal file
12
apps/config-builder/index.html
Normal 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>
|
||||
20
apps/config-builder/package.json
Normal file
20
apps/config-builder/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
170
apps/config-builder/src/lib/config-store.ts
Normal file
170
apps/config-builder/src/lib/config-store.ts
Normal 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
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
72
apps/config-builder/src/lib/schema-spike.test.ts
Normal file
72
apps/config-builder/src/lib/schema-spike.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
517
apps/config-builder/src/lib/schema-spike.ts
Normal file
517
apps/config-builder/src/lib/schema-spike.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
34
apps/config-builder/src/lib/validation.test.ts
Normal file
34
apps/config-builder/src/lib/validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
73
apps/config-builder/src/lib/validation.ts
Normal file
73
apps/config-builder/src/lib/validation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2
apps/config-builder/src/main.ts
Normal file
2
apps/config-builder/src/main.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "./styles.css";
|
||||
import "./ui/app.ts";
|
||||
10
apps/config-builder/src/shims/channel-registry.ts
Normal file
10
apps/config-builder/src/shims/channel-registry.ts
Normal 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;
|
||||
3
apps/config-builder/src/shims/version.ts
Normal file
3
apps/config-builder/src/shims/version.ts
Normal 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";
|
||||
1832
apps/config-builder/src/styles.css
Normal file
1832
apps/config-builder/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
1398
apps/config-builder/src/ui/app.ts
Normal file
1398
apps/config-builder/src/ui/app.ts
Normal file
File diff suppressed because it is too large
Load Diff
729
apps/config-builder/src/ui/components/field-renderer.ts
Normal file
729
apps/config-builder/src/ui/components/field-renderer.ts
Normal 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>`;
|
||||
}
|
||||
329
apps/config-builder/src/ui/components/icons.ts
Normal file
329
apps/config-builder/src/ui/components/icons.ts
Normal 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;
|
||||
}
|
||||
247
apps/config-builder/src/ui/components/import-dialog.ts
Normal file
247
apps/config-builder/src/ui/components/import-dialog.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
20
apps/config-builder/src/ui/navigation.test.ts
Normal file
20
apps/config-builder/src/ui/navigation.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { modeToHash, parseModeFromHash } from "./navigation.ts";
|
||||
|
||||
describe("navigation mode hash", () => {
|
||||
it("parses known hashes", () => {
|
||||
expect(parseModeFromHash("#/wizard")).toBe("wizard");
|
||||
expect(parseModeFromHash("#/explorer")).toBe("explorer");
|
||||
expect(parseModeFromHash("#/")).toBe("landing");
|
||||
});
|
||||
|
||||
it("falls back to landing for unknown hash", () => {
|
||||
expect(parseModeFromHash("#/unknown")).toBe("landing");
|
||||
});
|
||||
|
||||
it("builds hashes for modes", () => {
|
||||
expect(modeToHash("landing")).toBe("#/");
|
||||
expect(modeToHash("explorer")).toBe("#/explorer");
|
||||
expect(modeToHash("wizard")).toBe("#/wizard");
|
||||
});
|
||||
});
|
||||
29
apps/config-builder/src/ui/navigation.ts
Normal file
29
apps/config-builder/src/ui/navigation.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type ConfigBuilderMode = "landing" | "explorer" | "wizard";
|
||||
|
||||
export function parseModeFromHash(hash: string): ConfigBuilderMode {
|
||||
const normalized = hash.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "landing";
|
||||
}
|
||||
|
||||
if (normalized === "#/wizard" || normalized === "#wizard") {
|
||||
return "wizard";
|
||||
}
|
||||
if (normalized === "#/explorer" || normalized === "#explorer") {
|
||||
return "explorer";
|
||||
}
|
||||
if (normalized === "#/" || normalized === "#") {
|
||||
return "landing";
|
||||
}
|
||||
return "landing";
|
||||
}
|
||||
|
||||
export function modeToHash(mode: ConfigBuilderMode): string {
|
||||
if (mode === "wizard") {
|
||||
return "#/wizard";
|
||||
}
|
||||
if (mode === "explorer") {
|
||||
return "#/explorer";
|
||||
}
|
||||
return "#/";
|
||||
}
|
||||
15
apps/config-builder/src/ui/wizard.test.ts
Normal file
15
apps/config-builder/src/ui/wizard.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WIZARD_STEPS, wizardStepFields } from "./wizard.ts";
|
||||
|
||||
describe("wizard step definitions", () => {
|
||||
it("defines the expected number of curated steps", () => {
|
||||
expect(WIZARD_STEPS).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("resolves all configured fields to schema metadata", () => {
|
||||
for (const step of WIZARD_STEPS) {
|
||||
const fields = wizardStepFields(step);
|
||||
expect(fields).toHaveLength(step.fields.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
105
apps/config-builder/src/ui/wizard.ts
Normal file
105
apps/config-builder/src/ui/wizard.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ExplorerField } from "../lib/schema-spike.ts";
|
||||
import { resolveExplorerField } from "../lib/schema-spike.ts";
|
||||
|
||||
export type WizardStep = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
fields: string[];
|
||||
};
|
||||
|
||||
export const WIZARD_STEPS: WizardStep[] = [
|
||||
{
|
||||
id: "gateway",
|
||||
label: "Gateway",
|
||||
description: "Core gateway networking and auth settings.",
|
||||
fields: [
|
||||
"gateway.port",
|
||||
"gateway.mode",
|
||||
"gateway.bind",
|
||||
"gateway.auth.mode",
|
||||
"gateway.auth.token",
|
||||
"gateway.auth.password",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "channels",
|
||||
label: "Channels",
|
||||
description: "Common channel credentials and DM policies.",
|
||||
fields: [
|
||||
"channels.whatsapp.dmPolicy",
|
||||
"channels.telegram.botToken",
|
||||
"channels.telegram.dmPolicy",
|
||||
"channels.discord.token",
|
||||
"channels.discord.dm.policy",
|
||||
"channels.slack.botToken",
|
||||
"channels.slack.dm.policy",
|
||||
"channels.signal.account",
|
||||
"channels.signal.dmPolicy",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
label: "Agents",
|
||||
description: "Default model + workspace behavior.",
|
||||
fields: [
|
||||
"agents.defaults.model.primary",
|
||||
"agents.defaults.model.fallbacks",
|
||||
"agents.defaults.workspace",
|
||||
"agents.defaults.repoRoot",
|
||||
"agents.defaults.humanDelay.mode",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
label: "Models",
|
||||
description: "Auth and model catalog data.",
|
||||
fields: ["agents.defaults.models", "auth.profiles", "auth.order"],
|
||||
},
|
||||
{
|
||||
id: "messages",
|
||||
label: "Messages",
|
||||
description: "Reply behavior and acknowledgment defaults.",
|
||||
fields: [
|
||||
"messages.ackReaction",
|
||||
"messages.ackReactionScope",
|
||||
"messages.inbound.debounceMs",
|
||||
"channels.telegram.streamMode",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
description: "DM scoping and agent-to-agent behavior.",
|
||||
fields: ["session.dmScope", "session.identityLinks", "session.agentToAgent.maxPingPongTurns"],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tools",
|
||||
description: "Web and execution tool defaults.",
|
||||
fields: [
|
||||
"tools.profile",
|
||||
"tools.web.search.enabled",
|
||||
"tools.web.search.provider",
|
||||
"tools.web.search.apiKey",
|
||||
"tools.web.fetch.enabled",
|
||||
"tools.exec.applyPatch.enabled",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function wizardStepFields(step: WizardStep): ExplorerField[] {
|
||||
return step.fields
|
||||
.map((path) => resolveExplorerField(path))
|
||||
.filter((field): field is ExplorerField => field !== null);
|
||||
}
|
||||
|
||||
export function wizardStepByIndex(index: number): WizardStep {
|
||||
const clamped = Math.max(0, Math.min(index, WIZARD_STEPS.length - 1));
|
||||
return WIZARD_STEPS[clamped] ?? WIZARD_STEPS[0] ?? {
|
||||
id: "empty",
|
||||
label: "Empty",
|
||||
description: "No wizard steps configured.",
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
11
apps/config-builder/tsconfig.json
Normal file
11
apps/config-builder/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@openclaw/config/*": ["../../src/config/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"]
|
||||
}
|
||||
5
apps/config-builder/vercel.json
Normal file
5
apps/config-builder/vercel.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"installCommand": "pnpm install",
|
||||
"buildCommand": "vite build --outDir dist",
|
||||
"outputDirectory": "dist"
|
||||
}
|
||||
60
apps/config-builder/vite.config.ts
Normal file
60
apps/config-builder/vite.config.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
18
apps/config-builder/vitest.config.ts
Normal file
18
apps/config-builder/vitest.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
@@ -3,6 +3,7 @@ packages:
|
||||
- ui
|
||||
- packages/*
|
||||
- extensions/*
|
||||
- apps/*
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- "@lydell/node-pty"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user