mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 07:04:01 -06:00
183db47e97
* fix(ui): harden schema-backed settings controls Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com> * test(ui): match config secret label in Chromium * chore(ui): remove release-owned changelog entry * test(agents): mock incremental registry persistence --------- Co-authored-by: wangmiao0668000666 <wang.miao86@xydigit.com>
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
// Control UI helpers shared by config form node renderers.
|
|
import { html, nothing, type TemplateResult } from "lit";
|
|
import { ref } from "lit/directives/ref.js";
|
|
import type { ConfigUiHints } from "../api/types.ts";
|
|
import { icons } from "../components/icons.ts";
|
|
import "../components/tooltip.ts";
|
|
import { t } from "../i18n/index.ts";
|
|
import { formatUnknownText } from "../lib/format.ts";
|
|
import { isSupportedConfigValueValid } from "./config-form.constraints.ts";
|
|
import type { ConfigSearchCriteria } from "./config-form.search.ts";
|
|
import {
|
|
configFieldId,
|
|
hasSensitiveConfigData,
|
|
redactedPlaceholder,
|
|
type JsonSchema,
|
|
} from "./config-form.shared.ts";
|
|
import { renderSettingsSegmented } from "./settings-ui.ts";
|
|
|
|
const META_KEYS = new Set([
|
|
"title",
|
|
"description",
|
|
"default",
|
|
"nullable",
|
|
"enumIncludesNull",
|
|
"tags",
|
|
"x-tags",
|
|
]);
|
|
const jsonTextareaState = new WeakMap<
|
|
HTMLTextAreaElement,
|
|
{ sourceValue: unknown; rowIdentity: unknown; fallback: string; pathKey: string }
|
|
>();
|
|
|
|
export type ConfigNodeRenderParams = {
|
|
schema: JsonSchema;
|
|
value: unknown;
|
|
path: Array<string | number>;
|
|
hints: ConfigUiHints;
|
|
rawAvailable?: boolean;
|
|
unsupported: Set<string>;
|
|
disabled: boolean;
|
|
isRequired?: boolean;
|
|
sourceIdentity?: unknown;
|
|
controlIdentity?: unknown;
|
|
rowIdentity?: unknown;
|
|
structuredDraftOwner?: boolean;
|
|
showLabel?: boolean;
|
|
searchCriteria?: ConfigSearchCriteria;
|
|
revealSensitive?: boolean;
|
|
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
|
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
|
onPatch: (path: Array<string | number>, value: unknown) => boolean | void;
|
|
};
|
|
|
|
export type ConfigNodeRenderer = (
|
|
params: ConfigNodeRenderParams,
|
|
) => TemplateResult | typeof nothing;
|
|
|
|
type SensitiveRenderState = {
|
|
isSensitive: boolean;
|
|
isRedacted: boolean;
|
|
isRevealed: boolean;
|
|
canReveal: boolean;
|
|
};
|
|
|
|
export function isAnySchema(schema: JsonSchema): boolean {
|
|
const keys = Object.keys(schema ?? {}).filter((key) => !META_KEYS.has(key));
|
|
return keys.length === 0;
|
|
}
|
|
|
|
export function jsonValue(value: unknown): string {
|
|
if (value === undefined) {
|
|
return "";
|
|
}
|
|
try {
|
|
return JSON.stringify(value, null, 2) ?? "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function formatComparablePrimitive(value: unknown): string | null {
|
|
if (
|
|
typeof value === "string" ||
|
|
typeof value === "number" ||
|
|
typeof value === "boolean" ||
|
|
typeof value === "bigint"
|
|
) {
|
|
return String(value);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function matchesComparablePrimitiveValue(left: unknown, right: unknown): boolean {
|
|
if (Object.is(left, right)) {
|
|
return true;
|
|
}
|
|
const leftComparable = formatComparablePrimitive(left);
|
|
const rightComparable = formatComparablePrimitive(right);
|
|
return leftComparable !== null && leftComparable === rightComparable;
|
|
}
|
|
|
|
export function isSecretRefObject(value: unknown): value is {
|
|
source: string;
|
|
id: string;
|
|
provider?: string;
|
|
} {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return false;
|
|
}
|
|
const candidate = value as Record<string, unknown>;
|
|
if (typeof candidate.source !== "string" || typeof candidate.id !== "string") {
|
|
return false;
|
|
}
|
|
return candidate.provider === undefined || typeof candidate.provider === "string";
|
|
}
|
|
|
|
export function getSensitiveRenderState(params: {
|
|
path: Array<string | number>;
|
|
value: unknown;
|
|
hints: ConfigUiHints;
|
|
revealSensitive: boolean;
|
|
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
|
}): SensitiveRenderState {
|
|
const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints);
|
|
const isRevealed =
|
|
isSensitive &&
|
|
(params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false));
|
|
return {
|
|
isSensitive,
|
|
isRedacted: isSensitive && !isRevealed,
|
|
isRevealed,
|
|
canReveal: isSensitive,
|
|
};
|
|
}
|
|
|
|
export function renderSensitiveToggleButton(params: {
|
|
path: Array<string | number>;
|
|
state: SensitiveRenderState;
|
|
disabled: boolean;
|
|
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
|
}): TemplateResult | typeof nothing {
|
|
const { state } = params;
|
|
if (!state.isSensitive || !params.onToggleSensitivePath) {
|
|
return nothing;
|
|
}
|
|
const label = state.canReveal
|
|
? state.isRevealed
|
|
? t("configForm.hideValue")
|
|
: t("configForm.revealValue")
|
|
: t("configForm.disableStreamToReveal");
|
|
return html`
|
|
<openclaw-tooltip .content=${label}>
|
|
<button
|
|
type="button"
|
|
class="settings-secret__toggle"
|
|
aria-label=${label}
|
|
aria-pressed=${state.isRevealed}
|
|
?disabled=${params.disabled || !state.canReveal}
|
|
@click=${() => params.onToggleSensitivePath?.(params.path)}
|
|
>
|
|
${state.isRevealed ? icons.eye : icons.eyeOff}
|
|
</button>
|
|
</openclaw-tooltip>
|
|
`;
|
|
}
|
|
|
|
/* Sensitive fields inset the reveal eye inside the field (settings-secret
|
|
* pattern); non-sensitive fields render the bare control unchanged. */
|
|
export function wrapSensitiveControl(
|
|
control: TemplateResult,
|
|
toggle: TemplateResult | typeof nothing,
|
|
): TemplateResult {
|
|
if (toggle === nothing) {
|
|
return control;
|
|
}
|
|
return html`<span class="settings-secret">${control}${toggle}</span>`;
|
|
}
|
|
|
|
export function renderTags(tags: string[]): TemplateResult | typeof nothing {
|
|
const visibleTags = tags.filter((tag) => tag !== "advanced");
|
|
if (visibleTags.length === 0) {
|
|
return nothing;
|
|
}
|
|
return html`
|
|
<div class="cfg-tags">
|
|
${visibleTags.map((tag) => html`<span class="cfg-tag">${tag}</span>`)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
export function renderFieldRow(params: {
|
|
label: unknown;
|
|
help?: unknown;
|
|
helpId?: string;
|
|
tags: string[];
|
|
showLabel: boolean;
|
|
control: TemplateResult | typeof nothing;
|
|
stacked?: boolean;
|
|
error?: unknown;
|
|
}): TemplateResult {
|
|
// Array/map item rows resolve their meta from the parent path (numeric and
|
|
// wildcard segments collapse), so their help is the parent's. Showing it again
|
|
// per item is noise; a row with no label of its own gets no help of its own.
|
|
const help = params.showLabel ? params.help : undefined;
|
|
const hasText =
|
|
params.showLabel || Boolean(help) || params.tags.length > 0 || Boolean(params.error);
|
|
// Control-only rows (array/map item values) stack so the control gets full width.
|
|
const stacked = params.stacked || !hasText;
|
|
const className = stacked ? "settings-row settings-row--stacked" : "settings-row";
|
|
return html`
|
|
<div class=${className}>
|
|
${hasText
|
|
? html`
|
|
<div class="settings-row__text">
|
|
${params.showLabel
|
|
? html`<span class="settings-row__title">${params.label}</span>`
|
|
: nothing}
|
|
${help
|
|
? html`<span class="settings-row__desc" id=${params.helpId ?? nothing}
|
|
>${help}</span
|
|
>`
|
|
: nothing}
|
|
${renderTags(params.tags)}
|
|
${params.error
|
|
? html`<span class="cfg-field__error" role="alert">${params.error}</span>`
|
|
: nothing}
|
|
</div>
|
|
`
|
|
: nothing}
|
|
${params.control !== nothing
|
|
? html`<div class="settings-row__control">${params.control}</div>`
|
|
: nothing}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
export function renderSegmentedControl(params: {
|
|
options: unknown[];
|
|
resolvedValue: unknown;
|
|
disabled: boolean;
|
|
ariaLabel: string;
|
|
onSelect: (value: unknown) => void;
|
|
}): TemplateResult {
|
|
const selectedIndex = params.options.findIndex((option) =>
|
|
matchesComparablePrimitiveValue(option, params.resolvedValue),
|
|
);
|
|
return renderSettingsSegmented({
|
|
value: selectedIndex < 0 ? "" : String(selectedIndex),
|
|
options: params.options.map((option, index) => ({
|
|
value: String(index),
|
|
label: formatUnknownText(option),
|
|
})),
|
|
disabled: params.disabled,
|
|
ariaLabel: params.ariaLabel,
|
|
onChange: (index) => {
|
|
const option = params.options[Number(index)];
|
|
if (option !== undefined) {
|
|
params.onSelect(option);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export function renderJsonTextareaControl(params: {
|
|
schema: JsonSchema;
|
|
path: Array<string | number>;
|
|
ariaLabel: string;
|
|
descriptionId?: string;
|
|
sourceValue: unknown;
|
|
rowIdentity?: unknown;
|
|
fallback: string;
|
|
rows: number;
|
|
sensitiveState: SensitiveRenderState;
|
|
disabled: boolean;
|
|
isRequired?: boolean;
|
|
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
|
onPatch: (path: Array<string | number>, value: unknown) => boolean | void;
|
|
}): TemplateResult {
|
|
const { path, fallback, sensitiveState, disabled, onPatch } = params;
|
|
const errorId = configFieldId(path, "json-error");
|
|
const describedBy = [params.descriptionId, errorId].filter(Boolean).join(" ");
|
|
const setValidity = (target: HTMLTextAreaElement, message: string) => {
|
|
const error = target
|
|
.closest(".cfg-json-editor")
|
|
?.querySelector<HTMLElement>(".cfg-field__error");
|
|
target.setCustomValidity(message);
|
|
target.setAttribute("aria-invalid", String(Boolean(message)));
|
|
if (error) {
|
|
error.hidden = !message;
|
|
error.textContent = message;
|
|
}
|
|
};
|
|
const updateValidity = (target: HTMLTextAreaElement) => {
|
|
let message = "";
|
|
const raw = target.value.trim();
|
|
if (!raw && params.isRequired) {
|
|
message = t("configForm.invalidJson");
|
|
} else if (raw) {
|
|
try {
|
|
if (!isSupportedConfigValueValid(params.schema, JSON.parse(raw))) {
|
|
message = t("configForm.invalidJson");
|
|
}
|
|
} catch {
|
|
message = t("configForm.invalidJson");
|
|
}
|
|
}
|
|
setValidity(target, message);
|
|
return !message;
|
|
};
|
|
const renderedFallback = sensitiveState.isRedacted ? "" : fallback;
|
|
const pathKey = JSON.stringify(path);
|
|
const commitJsonValue = (target: HTMLTextAreaElement, candidate: unknown) => {
|
|
if (onPatch(path, candidate) !== false) {
|
|
return true;
|
|
}
|
|
target.value = renderedFallback;
|
|
updateValidity(target);
|
|
return false;
|
|
};
|
|
const textareaControl = html`
|
|
<textarea
|
|
${ref((element) => {
|
|
if (!(element instanceof HTMLTextAreaElement)) {
|
|
return;
|
|
}
|
|
const previous = jsonTextareaState.get(element);
|
|
if (
|
|
previous &&
|
|
(!Object.is(previous.sourceValue, params.sourceValue) ||
|
|
!Object.is(previous.rowIdentity, params.rowIdentity) ||
|
|
previous.fallback !== renderedFallback ||
|
|
previous.pathKey !== pathKey)
|
|
) {
|
|
element.value = renderedFallback;
|
|
setValidity(element, "");
|
|
}
|
|
jsonTextareaState.set(element, {
|
|
sourceValue: params.sourceValue,
|
|
rowIdentity: params.rowIdentity,
|
|
fallback: renderedFallback,
|
|
pathKey,
|
|
});
|
|
})}
|
|
class="settings-input${sensitiveState.isRedacted ? " cfg-redacted" : ""}"
|
|
aria-label=${params.ariaLabel}
|
|
aria-describedby=${describedBy || nothing}
|
|
aria-invalid="false"
|
|
placeholder=${sensitiveState.isRedacted ? redactedPlaceholder() : t("configForm.jsonValue")}
|
|
rows=${params.rows}
|
|
.value=${renderedFallback}
|
|
?disabled=${disabled}
|
|
?readonly=${sensitiveState.isRedacted}
|
|
@click=${() => {
|
|
if (sensitiveState.isRedacted && params.onToggleSensitivePath) {
|
|
params.onToggleSensitivePath(path);
|
|
}
|
|
}}
|
|
@input=${(event: Event) => {
|
|
if (!sensitiveState.isRedacted) {
|
|
updateValidity(event.target as HTMLTextAreaElement);
|
|
}
|
|
}}
|
|
@change=${(event: Event) => {
|
|
if (sensitiveState.isRedacted) {
|
|
return;
|
|
}
|
|
const target = event.target as HTMLTextAreaElement;
|
|
if (!updateValidity(target)) {
|
|
return;
|
|
}
|
|
const raw = target.value.trim();
|
|
if (!raw) {
|
|
commitJsonValue(target, undefined);
|
|
return;
|
|
}
|
|
try {
|
|
commitJsonValue(target, JSON.parse(raw));
|
|
} catch {
|
|
// Input validity is already surfaced inline; preserve the draft until it is valid JSON.
|
|
}
|
|
}}
|
|
></textarea>
|
|
`;
|
|
return html`
|
|
<span class="cfg-json-editor">
|
|
${wrapSensitiveControl(
|
|
textareaControl,
|
|
renderSensitiveToggleButton({
|
|
path,
|
|
state: sensitiveState,
|
|
disabled,
|
|
onToggleSensitivePath: params.onToggleSensitivePath,
|
|
}),
|
|
)}
|
|
<span id=${errorId} class="cfg-field__error" role="alert" hidden></span>
|
|
</span>
|
|
`;
|
|
}
|