feat(config-builder): scaffold Vite Lit schema spike

This commit is contained in:
Sebastian
2026-02-09 20:37:50 -05:00
parent 60d92ca561
commit cf3f8a6c85
12 changed files with 365 additions and 1 deletions

View File

@@ -10,6 +10,27 @@ Use the same front-end stack as the existing OpenClaw web UI (`ui/`):
- Lit
- Plain CSS (no Next.js/Tailwind)
## Planning
## Current status
Phase 0 spike is in place:
- app boots with Vite + Lit
- `OpenClawSchema.toJSONSchema()` runs in browser bundle
- `buildConfigSchema()` UI hints load in browser bundle
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,32 @@
import { buildConfigSchema } from "@openclaw/config/schema.ts";
import { OpenClawSchema } from "@openclaw/config/zod-schema.ts";
type JsonSchemaRoot = {
properties?: Record<string, unknown>;
};
export type SchemaSpikeSummary = {
schemaRootProperties: number;
schemaTopSections: string[];
uiHintCount: number;
generatedAt: string;
version: string;
};
export function runSchemaSpike(): SchemaSpikeSummary {
const schema = OpenClawSchema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
}) as JsonSchemaRoot;
const topLevelProps = Object.keys(schema.properties ?? {});
const configSchema = buildConfigSchema();
return {
schemaRootProperties: topLevelProps.length,
schemaTopSections: topLevelProps.slice(0, 10),
uiHintCount: Object.keys(configSchema.uiHints).length,
generatedAt: configSchema.generatedAt,
version: configSchema.version,
};
}

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";

View File

@@ -0,0 +1,21 @@
:root {
color-scheme: dark;
font-family: Inter, "Segoe UI", Roboto, sans-serif;
background: #09090b;
color: #fafafa;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background: #09090b;
}
body {
min-height: 100vh;
}

View File

@@ -0,0 +1,164 @@
import { LitElement, css, html } from "lit";
import { runSchemaSpike, type SchemaSpikeSummary } from "../lib/schema-spike.ts";
type AppState =
| { status: "loading" }
| { status: "ready"; summary: SchemaSpikeSummary }
| { status: "error"; message: string };
class ConfigBuilderApp extends LitElement {
static override styles = css`
:host {
display: block;
min-height: 100vh;
background: #09090b;
color: #fafafa;
}
.shell {
max-width: 900px;
margin: 0 auto;
padding: 40px 20px;
}
.title {
font-size: 1.4rem;
font-weight: 700;
margin: 0;
}
.subtitle {
margin-top: 10px;
color: #a1a1aa;
font-size: 0.95rem;
}
.card {
margin-top: 20px;
border: 1px solid #27272a;
border-radius: 12px;
background: #18181b;
padding: 18px;
}
.row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #27272a;
font-size: 0.92rem;
}
.row:last-child {
border-bottom: none;
}
.label {
color: #a1a1aa;
}
.value {
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
text-align: right;
color: #fafafa;
}
.ok {
color: #4ade80;
}
.error {
color: #f87171;
white-space: pre-wrap;
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
font-size: 0.85rem;
line-height: 1.4;
}
.hint {
margin-top: 16px;
color: #71717a;
font-size: 0.85rem;
}
`;
private state: AppState = { status: "loading" };
override connectedCallback(): void {
super.connectedCallback();
this.bootstrap();
}
private bootstrap(): void {
try {
const summary = runSchemaSpike();
this.state = { status: "ready", summary };
} catch (error) {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
this.state = { status: "error", message };
}
this.requestUpdate();
}
override render() {
return html`<main class="shell">
<h1 class="title">OpenClaw Config Builder</h1>
<div class="subtitle">Phase 0 spike: schema imports + browser runtime checks.</div>
${this.renderBody()}
<div class="hint">
Next: replace this spike page with Explorer read-only group rendering.
</div>
</main>`;
}
private renderBody() {
if (this.state.status === "loading") {
return html`<section class="card">Loading schema spike…</section>`;
}
if (this.state.status === "error") {
return html`<section class="card">
<div class="row">
<span class="label">Schema import status</span>
<span class="value error">failed</span>
</div>
<pre class="error">${this.state.message}</pre>
</section>`;
}
const { summary } = this.state;
return html`<section class="card">
<div class="row">
<span class="label">Schema import status</span>
<span class="value ok">ok</span>
</div>
<div class="row">
<span class="label">Top-level schema sections</span>
<span class="value">${summary.schemaRootProperties}</span>
</div>
<div class="row">
<span class="label">UI hint entries</span>
<span class="value">${summary.uiHintCount}</span>
</div>
<div class="row">
<span class="label">Schema version</span>
<span class="value">${summary.version}</span>
</div>
<div class="row">
<span class="label">Generated at</span>
<span class="value">${summary.generatedAt}</span>
</div>
<div class="row">
<span class="label">Sample sections</span>
<span class="value">${summary.schemaTopSections.join(", ") || "(none)"}</span>
</div>
</section>`;
}
}
customElements.define("config-builder-app", ConfigBuilderApp);

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,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,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});