Files
Moltbot/src/config/schema.ts
Tak Hoffman f8171ffcdc Config UI: tag filters and complete schema help/labels coverage (#23796)
* Config UI: add tag filters and complete schema help/labels

* Config UI: finalize tags/help polish and unblock test suite

* Protocol: regenerate Swift gateway models
2026-02-22 15:17:07 -06:00

371 lines
11 KiB
TypeScript

import { CHANNEL_IDS } from "../channels/registry.js";
import { VERSION } from "../version.js";
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
import { applySensitiveHints, buildBaseHints, mapSensitivePaths } from "./schema.hints.js";
import { applyDerivedTags } from "./schema.tags.js";
import { OpenClawSchema } from "./zod-schema.js";
export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
export type ConfigSchema = ReturnType<typeof OpenClawSchema.toJSONSchema>;
type JsonSchemaNode = Record<string, unknown>;
type JsonSchemaObject = JsonSchemaNode & {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
required?: string[];
additionalProperties?: JsonSchemaObject | boolean;
};
function cloneSchema<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
function asSchemaObject(value: unknown): JsonSchemaObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as JsonSchemaObject;
}
function isObjectSchema(schema: JsonSchemaObject): boolean {
const type = schema.type;
if (type === "object") {
return true;
}
if (Array.isArray(type) && type.includes("object")) {
return true;
}
return Boolean(schema.properties || schema.additionalProperties);
}
function mergeObjectSchema(base: JsonSchemaObject, extension: JsonSchemaObject): JsonSchemaObject {
const mergedRequired = new Set<string>([...(base.required ?? []), ...(extension.required ?? [])]);
const merged: JsonSchemaObject = {
...base,
...extension,
properties: {
...base.properties,
...extension.properties,
},
};
if (mergedRequired.size > 0) {
merged.required = Array.from(mergedRequired);
}
const additional = extension.additionalProperties ?? base.additionalProperties;
if (additional !== undefined) {
merged.additionalProperties = additional;
}
return merged;
}
export type ConfigSchemaResponse = {
schema: ConfigSchema;
uiHints: ConfigUiHints;
version: string;
generatedAt: string;
};
export type PluginUiMetadata = {
id: string;
name?: string;
description?: string;
configUiHints?: Record<
string,
Pick<ConfigUiHint, "label" | "help" | "tags" | "advanced" | "sensitive" | "placeholder">
>;
configSchema?: JsonSchemaNode;
};
export type ChannelUiMetadata = {
id: string;
label?: string;
description?: string;
configSchema?: JsonSchemaNode;
configUiHints?: Record<string, ConfigUiHint>;
};
function collectExtensionHintKeys(
hints: ConfigUiHints,
plugins: PluginUiMetadata[],
channels: ChannelUiMetadata[],
): Set<string> {
const pluginPrefixes = plugins
.map((plugin) => plugin.id.trim())
.filter(Boolean)
.map((id) => `plugins.entries.${id}`);
const channelPrefixes = channels
.map((channel) => channel.id.trim())
.filter(Boolean)
.map((id) => `channels.${id}`);
const prefixes = [...pluginPrefixes, ...channelPrefixes];
return new Set(
Object.keys(hints).filter((key) =>
prefixes.some((prefix) => key === prefix || key.startsWith(`${prefix}.`)),
),
);
}
function applyPluginHints(hints: ConfigUiHints, plugins: PluginUiMetadata[]): ConfigUiHints {
const next: ConfigUiHints = { ...hints };
for (const plugin of plugins) {
const id = plugin.id.trim();
if (!id) {
continue;
}
const name = (plugin.name ?? id).trim() || id;
const basePath = `plugins.entries.${id}`;
next[basePath] = {
...next[basePath],
label: name,
help: plugin.description
? `${plugin.description} (plugin: ${id})`
: `Plugin entry for ${id}.`,
};
next[`${basePath}.enabled`] = {
...next[`${basePath}.enabled`],
label: `Enable ${name}`,
};
next[`${basePath}.config`] = {
...next[`${basePath}.config`],
label: `${name} Config`,
help: `Plugin-defined config payload for ${id}.`,
};
const uiHints = plugin.configUiHints ?? {};
for (const [relPathRaw, hint] of Object.entries(uiHints)) {
const relPath = relPathRaw.trim().replace(/^\./, "");
if (!relPath) {
continue;
}
const key = `${basePath}.config.${relPath}`;
next[key] = {
...next[key],
...hint,
};
}
}
return next;
}
function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]): ConfigUiHints {
const next: ConfigUiHints = { ...hints };
for (const channel of channels) {
const id = channel.id.trim();
if (!id) {
continue;
}
const basePath = `channels.${id}`;
const current = next[basePath] ?? {};
const label = channel.label?.trim();
const help = channel.description?.trim();
next[basePath] = {
...current,
...(label ? { label } : {}),
...(help ? { help } : {}),
};
const uiHints = channel.configUiHints ?? {};
for (const [relPathRaw, hint] of Object.entries(uiHints)) {
const relPath = relPathRaw.trim().replace(/^\./, "");
if (!relPath) {
continue;
}
const key = `${basePath}.${relPath}`;
next[key] = {
...next[key],
...hint,
};
}
}
return next;
}
function listHeartbeatTargetChannels(channels: ChannelUiMetadata[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
for (const id of CHANNEL_IDS) {
const normalized = id.trim().toLowerCase();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
ordered.push(normalized);
}
for (const channel of channels) {
const normalized = channel.id.trim().toLowerCase();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
ordered.push(normalized);
}
return ordered;
}
function applyHeartbeatTargetHints(
hints: ConfigUiHints,
channels: ChannelUiMetadata[],
): ConfigUiHints {
const next: ConfigUiHints = { ...hints };
const channelList = listHeartbeatTargetChannels(channels);
const channelHelp = channelList.length ? ` Known channels: ${channelList.join(", ")}.` : "";
const help = `Delivery target ("last", "none", or a channel id).${channelHelp}`;
const paths = ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"];
for (const path of paths) {
const current = next[path] ?? {};
next[path] = {
...current,
help: current.help ?? help,
placeholder: current.placeholder ?? "last",
};
}
return next;
}
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
const pluginsNode = asSchemaObject(root?.properties?.plugins);
const entriesNode = asSchemaObject(pluginsNode?.properties?.entries);
if (!entriesNode) {
return next;
}
const entryBase = asSchemaObject(entriesNode.additionalProperties);
const entryProperties = entriesNode.properties ?? {};
entriesNode.properties = entryProperties;
for (const plugin of plugins) {
if (!plugin.configSchema) {
continue;
}
const entrySchema = entryBase
? cloneSchema(entryBase)
: ({ type: "object" } as JsonSchemaObject);
const entryObject = asSchemaObject(entrySchema) ?? ({ type: "object" } as JsonSchemaObject);
const baseConfigSchema = asSchemaObject(entryObject.properties?.config);
const pluginSchema = asSchemaObject(plugin.configSchema);
const nextConfigSchema =
baseConfigSchema &&
pluginSchema &&
isObjectSchema(baseConfigSchema) &&
isObjectSchema(pluginSchema)
? mergeObjectSchema(baseConfigSchema, pluginSchema)
: cloneSchema(plugin.configSchema);
entryObject.properties = {
...entryObject.properties,
config: nextConfigSchema,
};
entryProperties[plugin.id] = entryObject;
}
return next;
}
function applyChannelSchemas(schema: ConfigSchema, channels: ChannelUiMetadata[]): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
const channelsNode = asSchemaObject(root?.properties?.channels);
if (!channelsNode) {
return next;
}
const channelProps = channelsNode.properties ?? {};
channelsNode.properties = channelProps;
for (const channel of channels) {
if (!channel.configSchema) {
continue;
}
const existing = asSchemaObject(channelProps[channel.id]);
const incoming = asSchemaObject(channel.configSchema);
if (existing && incoming && isObjectSchema(existing) && isObjectSchema(incoming)) {
channelProps[channel.id] = mergeObjectSchema(existing, incoming);
} else {
channelProps[channel.id] = cloneSchema(channel.configSchema);
}
}
return next;
}
let cachedBase: ConfigSchemaResponse | null = null;
function stripChannelSchema(schema: ConfigSchema): ConfigSchema {
const next = cloneSchema(schema);
const root = asSchemaObject(next);
if (!root || !root.properties) {
return next;
}
// Allow `$schema` in config files for editor tooling, but hide it from the
// Control UI form schema so it does not show up as a configurable section.
delete root.properties.$schema;
if (Array.isArray(root.required)) {
root.required = root.required.filter((key) => key !== "$schema");
}
const channelsNode = asSchemaObject(root.properties.channels);
if (channelsNode) {
channelsNode.properties = {};
channelsNode.required = [];
channelsNode.additionalProperties = true;
}
return next;
}
function buildBaseConfigSchema(): ConfigSchemaResponse {
if (cachedBase) {
return cachedBase;
}
const schema = OpenClawSchema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
});
schema.title = "OpenClawConfig";
const hints = applyDerivedTags(mapSensitivePaths(OpenClawSchema, "", buildBaseHints()));
const next = {
schema: stripChannelSchema(schema),
uiHints: hints,
version: VERSION,
generatedAt: new Date().toISOString(),
};
cachedBase = next;
return next;
}
export function buildConfigSchema(params?: {
plugins?: PluginUiMetadata[];
channels?: ChannelUiMetadata[];
}): ConfigSchemaResponse {
const base = buildBaseConfigSchema();
const plugins = params?.plugins ?? [];
const channels = params?.channels ?? [];
if (plugins.length === 0 && channels.length === 0) {
return base;
}
const mergedWithoutSensitiveHints = applyHeartbeatTargetHints(
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
channels,
);
const extensionHintKeys = collectExtensionHintKeys(
mergedWithoutSensitiveHints,
plugins,
channels,
);
const mergedHints = applyDerivedTags(
applySensitiveHints(mergedWithoutSensitiveHints, extensionHintKeys),
);
const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels);
return {
...base,
schema: mergedSchema,
uiHints: mergedHints,
};
}