|
|
|
|
Quelle config.ts
Sprache: JAVA
|
|
Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import { html, nothing, type TemplateResult } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
import { BORDER_RADIUS_STOPS, type BorderRadiusStop } from "../storage.ts";
import type { ThemeTransitionContext } from "../theme-transition.ts";
import type { ThemeMode, ThemeName } from "../theme.ts";
import type { ConfigUiHints } from "../types.ts";
import {
countSensitiveConfigValues,
humanize,
isSensitiveConfigPath,
pathKey,
REDACTED_PLACEHOLDER,
schemaType,
type JsonSchema,
} from "./config-form.shared.ts";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
const BORDER_RADIUS_LABELS: Record<BorderRadiusStop, string> = {
0: "None",
25: "Slight",
50: "Default",
75: "Round",
100: "Full",
};
export type ConfigProps = {
raw: string;
originalRaw: string;
valid: boolean | null;
issues: unknown[];
loading: boolean;
saving: boolean;
applying: boolean;
updating: boolean;
connected: boolean;
schema: unknown;
schemaLoading: boolean;
uiHints: ConfigUiHints;
formMode: "form" | "raw";
rawAvailable?: boolean;
showModeToggle?: boolean;
formValue: Record<string, unknown> | null;
originalValue: Record<string, unknown> | null;
searchQuery: string;
activeSection: string | null;
activeSubsection: string | null;
onRawChange: (next: string) => void;
onFormModeChange: (mode: "form" | "raw") => void;
onFormPatch: (path: Array<string | number>, value: unknown) => void;
onSearchChange: (query: string) => void;
onSectionChange: (section: string | null) => void;
onSubsectionChange: (section: string | null) => void;
onReload: () => void;
onReset: () => void;
onSave: () => void;
onApply: () => void;
onUpdate: () => void;
onOpenFile?: () => void;
version: string;
theme: ThemeName;
themeMode: ThemeMode;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
hasCustomTheme: boolean;
customThemeLabel: string | null;
customThemeSourceUrl: string | null;
customThemeImportUrl: string;
customThemeImportBusy: boolean;
customThemeImportMessage: { kind: "success" | "error"; text: string } | null;
customThemeImportExpanded?: boolean;
customThemeImportFocusToken?: number;
onCustomThemeImportUrlChange: (next: string) => void;
onImportCustomTheme: () => void;
onClearCustomTheme: () => void;
onOpenCustomThemeImport?: () => void;
borderRadius: number;
setBorderRadius: (value: number) => void;
gatewayUrl: string;
assistantName: string;
configPath?: string | null;
navRootLabel?: string;
includeSections?: string[];
excludeSections?: string[];
includeVirtualSections?: boolean;
/** Layout mode: "tabs" (default flat scroll) or "accordion" (grouped collapsible). */
settingsLayout?: "tabs" | "accordion";
/** Callback to navigate back to Quick Settings. Shown in accordion mode. */
onBackToQuick?: () => void;
onRequestUpdate?: () => void;
};
// SVG Icons for sidebar (Lucide-style)
const sidebarIcons = {
all: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
env: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
update: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
agents: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
auth: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
channels: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
messages: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
commands: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
`,
hooks: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
skills: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
tools: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
gateway: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
wizard: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
// Additional sections
meta: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
`,
logging: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
`,
browser: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="4"></circle>
<line x1="21.17" y1="8" x2="12" y2="8"></line>
<line x1="3.95" y1="6.06" x2="8.54" y2="14"></line>
<line x1="10.88" y1="21.94" x2="15.46" y2="14"></line>
</svg>
`,
ui: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
models: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
bindings: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
`,
broadcast: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
audio: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
`,
session: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
cron: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
`,
web: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
discovery: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
`,
canvasHost: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
`,
talk: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
`,
plugins: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
diagnostics: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>
`,
cli: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
`,
secrets: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"
></path>
</svg>
`,
acp: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
mcp: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
`,
__appearance__: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
default: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
`,
};
// Categorised section definitions
type SectionCategory = {
id: string;
label: string;
sections: Array<{ key: string; label: string }>;
};
const SECTION_CATEGORIES: SectionCategory[] = [
{
id: "core",
label: "Core",
sections: [
{ key: "env", label: "Environment" },
{ key: "auth", label: "Authentication" },
{ key: "update", label: "Updates" },
{ key: "meta", label: "Meta" },
{ key: "logging", label: "Logging" },
{ key: "diagnostics", label: "Diagnostics" },
{ key: "cli", label: "Cli" },
{ key: "secrets", label: "Secrets" },
],
},
{
id: "ai",
label: "AI & Agents",
sections: [
{ key: "agents", label: "Agents" },
{ key: "models", label: "Models" },
{ key: "skills", label: "Skills" },
{ key: "tools", label: "Tools" },
{ key: "memory", label: "Memory" },
{ key: "session", label: "Session" },
],
},
{
id: "communication",
label: "Communication",
sections: [
{ key: "channels", label: "Channels" },
{ key: "messages", label: "Messages" },
{ key: "broadcast", label: "Broadcast" },
{ key: "talk", label: "Talk" },
{ key: "audio", label: "Audio" },
],
},
{
id: "automation",
label: "Automation",
sections: [
{ key: "commands", label: "Commands" },
{ key: "hooks", label: "Hooks" },
{ key: "bindings", label: "Bindings" },
{ key: "cron", label: "Cron" },
{ key: "approvals", label: "Approvals" },
{ key: "plugins", label: "Plugins" },
],
},
{
id: "infrastructure",
label: "Infrastructure",
sections: [
{ key: "gateway", label: "Gateway" },
{ key: "web", label: "Web" },
{ key: "browser", label: "Browser" },
{ key: "nodeHost", label: "NodeHost" },
{ key: "canvasHost", label: "CanvasHost" },
{ key: "discovery", label: "Discovery" },
{ key: "media", label: "Media" },
{ key: "acp", label: "Acp" },
{ key: "mcp", label: "Mcp" },
],
},
{
id: "appearance",
label: t("tabs.appearance"),
sections: [
{ key: "__appearance__", label: "Theme" },
{ key: "ui", label: "UI" },
{ key: "wizard", label: "Setup Wizard" },
],
},
];
// Flat lookup: all categorised keys
const CATEGORISED_KEYS = new Set(SECTION_CATEGORIES.flatMap((c) => c.sections.map((s) => s.key)));
function getSectionIcon(key: string) {
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
}
function scopeSchemaSections(
schema: JsonSchema | null,
params: { include?: ReadonlySet<string> | null; exclude?: ReadonlySet<string> | null },
): JsonSchema | null {
if (!schema || schemaType(schema) !== "object" || !schema.properties) {
return schema;
}
const include = params.include;
const exclude = params.exclude;
const nextProps: Record<string, JsonSchema> = {};
for (const [key, value] of Object.entries(schema.properties)) {
if (include && include.size > 0 && !include.has(key)) {
continue;
}
if (exclude && exclude.size > 0 && exclude.has(key)) {
continue;
}
nextProps[key] = value;
}
return { ...schema, properties: nextProps };
}
function scopeUnsupportedPaths(
unsupportedPaths: string[],
params: { include?: ReadonlySet<string> | null; exclude?: ReadonlySet<string> | null },
): string[] {
const include = params.include;
const exclude = params.exclude;
if ((!include || include.size === 0) && (!exclude || exclude.size === 0)) {
return unsupportedPaths;
}
return unsupportedPaths.filter((entry) => {
if (entry === "<root>") {
return true;
}
const [top] = entry.split(".");
if (include && include.size > 0) {
return include.has(top);
}
if (exclude && exclude.size > 0) {
return !exclude.has(top);
}
return true;
});
}
function resolveSectionMeta(
key: string,
schema?: JsonSchema,
): {
label: string;
description?: string;
} {
const meta = SECTION_META[key];
if (meta) {
return meta;
}
return {
label: schema?.title ?? humanize(key),
description: schema?.description ?? "",
};
}
function computeDiff(
original: Record<string, unknown> | null,
current: Record<string, unknown> | null,
): Array<{ path: string; from: unknown; to: unknown }> {
if (!original || !current) {
return [];
}
const changes: Array<{ path: string; from: unknown; to: unknown }> = [];
function compare(orig: unknown, curr: unknown, path: string) {
if (orig === curr) {
return;
}
if (typeof orig !== typeof curr) {
changes.push({ path, from: orig, to: curr });
return;
}
if (typeof orig !== "object" || orig === null || curr === null) {
if (orig !== curr) {
changes.push({ path, from: orig, to: curr });
}
return;
}
if (Array.isArray(orig) && Array.isArray(curr)) {
if (JSON.stringify(orig) !== JSON.stringify(curr)) {
changes.push({ path, from: orig, to: curr });
}
return;
}
const origObj = orig as Record<string, unknown>;
const currObj = curr as Record<string, unknown>;
const allKeys = new Set([...Object.keys(origObj), ...Object.keys(currObj)]);
for (const key of allKeys) {
compare(origObj[key], currObj[key], path ? `${path}.${key}` : key);
}
}
compare(original, current, "");
return changes;
}
function truncateValue(value: unknown, maxLen = 40): string {
let str: string;
try {
const json = JSON.stringify(value);
str = json ?? String(value);
} catch {
str = String(value);
}
if (str.length <= maxLen) {
return str;
}
return str.slice(0, maxLen - 3) + "...";
}
function renderDiffValue(path: string, value: unknown, _uiHints: ConfigUiHints): string {
if (isSensitiveConfigPath(path) && value != null && truncateValue(value).trim() !== "") {
return REDACTED_PLACEHOLDER;
}
return truncateValue(value);
}
type ThemeOption = {
id: ThemeName;
label: string;
description: string;
icon: TemplateResult;
};
const BUILTIN_THEME_OPTIONS: ThemeOption[] = [
{ id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap },
{ id: "knot", label: "Knot", description: "Black & red", icon: icons.link },
{ id: "dash", label: "Dash", description: "Chocolate blueprint", icon: icons.barChart },
];
function focusCustomThemeImportInput() {
const schedule =
typeof requestAnimationFrame === "function"
? requestAnimationFrame
: (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0);
schedule(() => {
const input = globalThis.document?.querySelector<HTMLInputElement>(
"[data-custom-theme-import-input]",
);
if (!input) {
return;
}
if (typeof input.scrollIntoView === "function") {
input.scrollIntoView({ block: "center", behavior: "smooth" });
}
input.focus();
input.select();
});
}
function renderAppearanceSection(props: ConfigProps) {
const showCustomThemeImport = props.hasCustomTheme || props.customThemeImportExpanded === true;
if (
showCustomThemeImport &&
props.customThemeImportFocusToken != null &&
props.customThemeImportFocusToken !== cvs.lastCustomThemeImportFocusToken
) {
cvs.lastCustomThemeImportFocusToken = props.customThemeImportFocusToken;
focusCustomThemeImportInput();
}
const themeOptions: ThemeOption[] = [
...BUILTIN_THEME_OPTIONS,
{
id: "custom",
label: "Custom",
description: props.hasCustomTheme
? `Imported from tweakcn${props.customThemeLabel ? `: ${props.customThemeLabel}` : ""}`
: "Open the tweakcn importer for this browser-local slot",
icon: icons.spark,
},
];
return html`
<div class="settings-appearance">
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Theme</h3>
<p class="settings-appearance__hint">Choose a theme family.</p>
<div class="settings-theme-grid">
${themeOptions.map(
(opt) => html`
<button
class="settings-theme-card ${opt.id === props.theme
? "settings-theme-card--active"
: ""}"
title=${opt.description}
@click=${(e: Event) => {
if (opt.id === "custom" && !props.hasCustomTheme) {
props.onOpenCustomThemeImport?.();
return;
}
if (opt.id !== props.theme) {
const context: ThemeTransitionContext = {
element: (e.currentTarget as HTMLElement) ?? undefined,
};
props.setTheme(opt.id, context);
}
}}
>
<span class="settings-theme-card__icon" aria-hidden="true">${opt.icon}</span>
<span class="settings-theme-card__label">${opt.label}</span>
${opt.id === props.theme
? html`<span class="settings-theme-card__check" aria-hidden="true"
>${icons.check}</span
>`
: nothing}
</button>
`,
)}
</div>
${showCustomThemeImport
? html`
<div class="settings-theme-import">
<div class="settings-theme-import__copy">
<div class="settings-theme-import__title">Import from tweakcn</div>
<p class="settings-theme-import__hint">
Paste a tweakcn share link. The import stays in this browser only and replaces
the current custom slot.
</p>
</div>
<label class="settings-theme-import__field">
<span class="settings-theme-import__label">tweakcn link</span>
<input
class="settings-theme-import__input"
data-custom-theme-import-input
type="url"
placeholder="https://tweakcn.com/themes/..."
.value=${props.customThemeImportUrl}
@input=${(e: Event) =>
props.onCustomThemeImportUrlChange(
(e.currentTarget as HTMLInputElement).value,
)}
/>
</label>
<div class="settings-theme-import__actions">
<button
class="btn btn--sm primary"
?disabled=${props.customThemeImportBusy ||
props.customThemeImportUrl.trim().length === 0}
@click=${props.onImportCustomTheme}
>
${props.customThemeImportBusy
? "Importing…"
: props.hasCustomTheme
? "Replace custom theme"
: "Import custom theme"}
</button>
${props.hasCustomTheme
? html`
<button class="btn btn--sm danger" @click=${props.onClearCustomTheme}>
Clear custom theme
</button>
`
: nothing}
</div>
${props.hasCustomTheme
? html`
<div class="settings-theme-import__meta">
<span class="settings-theme-import__meta-label">Loaded</span>
<span class="settings-theme-import__meta-value"
>${props.customThemeLabel ?? "Custom"} ·
${props.customThemeSourceUrl ?? "tweakcn"}</span
>
</div>
`
: nothing}
${props.customThemeImportMessage
? html`
<div
class="settings-theme-import__message settings-theme-import__message--${props
.customThemeImportMessage.kind}"
>
${props.customThemeImportMessage.text}
</div>
`
: nothing}
</div>
`
: html`
<p class="settings-theme-import__inline-hint">
Click <strong>Custom</strong> to import a tweakcn theme into this browser-local
slot.
</p>
`}
</div>
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Roundness</h3>
<p class="settings-appearance__hint">Adjust corner radius across the UI.</p>
<div class="settings-roundness">
<div class="settings-roundness__options">
${BORDER_RADIUS_STOPS.map(
(stop) => html`
<button
type="button"
class="settings-roundness__btn ${stop === props.borderRadius ? "active" : ""}"
@click=${() => props.setBorderRadius(stop)}
>
<span
class="settings-roundness__swatch"
style="border-radius: ${Math.round(10 * (stop / 50))}px"
></span>
<span class="settings-roundness__label">${BORDER_RADIUS_LABELS[stop]}</span>
</button>
`,
)}
</div>
</div>
</div>
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Connection</h3>
<div class="settings-info-grid">
<div class="settings-info-row">
<span class="settings-info-row__label">Gateway</span>
<span class="settings-info-row__value mono">${props.gatewayUrl || "-"}</span>
</div>
<div class="settings-info-row">
<span class="settings-info-row__label">Status</span>
<span class="settings-info-row__value">
<span
class="settings-status-dot ${props.connected ? "settings-status-dot--ok" : ""}"
></span>
${props.connected ? t("common.connected") : t("common.offline")}
</span>
</div>
${props.assistantName
? html`
<div class="settings-info-row">
<span class="settings-info-row__label">Assistant</span>
<span class="settings-info-row__value">${props.assistantName}</span>
</div>
`
: nothing}
</div>
</div>
</div>
`;
}
interface ConfigEphemeralState {
rawRevealed: boolean;
envRevealed: boolean;
validityDismissed: boolean;
revealedSensitivePaths: Set<string>;
lastCustomThemeImportFocusToken: number | null;
}
function createConfigEphemeralState(): ConfigEphemeralState {
return {
rawRevealed: false,
envRevealed: false,
validityDismissed: false,
revealedSensitivePaths: new Set(),
lastCustomThemeImportFocusToken: null,
};
}
const cvs = createConfigEphemeralState();
function isSensitivePathRevealed(path: Array<string | number>): boolean {
const key = pathKey(path);
return key ? cvs.revealedSensitivePaths.has(key) : false;
}
function toggleSensitivePathReveal(path: Array<string | number>) {
const key = pathKey(path);
if (!key) {
return;
}
if (cvs.revealedSensitivePaths.has(key)) {
cvs.revealedSensitivePaths.delete(key);
} else {
cvs.revealedSensitivePaths.add(key);
}
}
export function resetConfigViewStateForTests() {
Object.assign(cvs, createConfigEphemeralState());
}
export function renderConfig(props: ConfigProps) {
const showModeToggle = props.showModeToggle ?? false;
const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const includeVirtualSections = props.includeVirtualSections ?? true;
const include = props.includeSections?.length ? new Set(props.includeSections) : null;
const exclude = props.excludeSections?.length ? new Set(props.excludeSections) : null;
const rawAnalysis = analyzeConfigSchema(props.schema);
const analysis = {
schema: scopeSchemaSections(rawAnalysis.schema, { include, exclude }),
unsupportedPaths: scopeUnsupportedPaths(rawAnalysis.unsupportedPaths, { include, exclude }),
};
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
const rawAvailable = props.rawAvailable ?? true;
const formMode = showModeToggle && rawAvailable ? props.formMode : "form";
const envSensitiveVisible = cvs.envRevealed;
const requestUpdate = props.onRequestUpdate ?? (() => props.onRawChange(props.raw));
// Build categorised nav from schema - only include sections that exist in the schema
const schemaProps = analysis.schema?.properties ?? {};
const VIRTUAL_SECTIONS = new Set(["__appearance__"]);
const visibleCategories = SECTION_CATEGORIES.map((cat) =>
Object.assign({}, cat, {
sections: cat.sections.filter(
(s) => (includeVirtualSections && VIRTUAL_SECTIONS.has(s.key)) || s.key in schemaProps,
),
}),
).filter((cat) => cat.sections.length > 0);
// Catch any schema keys not in our categories
const extraSections = Object.keys(schemaProps)
.filter((k) => !CATEGORISED_KEYS.has(k))
.map((k) => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
const otherCategory: SectionCategory | null =
extraSections.length > 0 ? { id: "other", label: "Other", sections: extraSections } : null;
const isVirtualSection =
includeVirtualSections &&
props.activeSection != null &&
VIRTUAL_SECTIONS.has(props.activeSection);
const activeSectionSchema =
props.activeSection &&
!isVirtualSection &&
analysis.schema &&
schemaType(analysis.schema) === "object"
? analysis.schema.properties?.[props.activeSection]
: undefined;
const activeSectionMeta =
props.activeSection && !isVirtualSection
? resolveSectionMeta(props.activeSection, activeSectionSchema)
: null;
// Config subsections are always rendered as a single page per section.
const effectiveSubsection = null;
const topTabs = [
{ key: null as string | null, label: props.navRootLabel ?? "Settings" },
...[...visibleCategories, ...(otherCategory ? [otherCategory] : [])].flatMap((cat) =>
cat.sections.map((s) => ({ key: s.key, label: s.label })),
),
];
const settingsLayout = props.settingsLayout ?? "tabs";
const allCategories = [...visibleCategories, ...(otherCategory ? [otherCategory] : [])];
const resetContentScroll = (target: EventTarget | null) => {
queueMicrotask(() => {
const origin = target instanceof Element ? target : null;
const content = origin
?.closest(".config-main")
?.querySelector<HTMLElement>(".config-content");
if (!content) {
return;
}
if (typeof content.scrollTo === "function") {
content.scrollTo({ top: 0, left: 0, behavior: "auto" });
return;
}
content.scrollTop = 0;
content.scrollLeft = 0;
});
};
function renderAccordionNav() {
return html`
<div class="config-accordion-nav">
${props.onBackToQuick
? html`
<button class="config-accordion-nav__back" @click=${props.onBackToQuick}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
Quick Settings
</button>
`
: nothing}
${allCategories.map(
(cat) => html`
<div class="config-accordion-group">
<button
class="config-accordion-group__header ${props.activeSection != null &&
cat.sections.some((s) => s.key === props.activeSection)
? "config-accordion-group__header--active"
: ""}"
@click=${(e: Event) => {
const firstKey = cat.sections[0]?.key ?? null;
const isCurrentlyInGroup = cat.sections.some(
(s) => s.key === props.activeSection,
);
props.onSectionChange(isCurrentlyInGroup ? null : firstKey);
resetContentScroll(e.currentTarget);
}}
>
<span class="config-accordion-group__icon">
${getSectionIcon(cat.sections[0]?.key ?? "default")}
</span>
<span>${cat.label}</span>
<svg
class="config-accordion-group__chevron ${cat.sections.some(
(s) => s.key === props.activeSection,
)
? "config-accordion-group__chevron--open"
: ""}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
${cat.sections.some((s) => s.key === props.activeSection)
? html`
<div class="config-accordion-group__items">
${cat.sections.map(
(s) => html`
<button
class="config-accordion-group__item ${props.activeSection === s.key
? "config-accordion-group__item--active"
: ""}"
@click=${(e: Event) => {
props.onSectionChange(s.key);
resetContentScroll(e.currentTarget);
}}
>
<span class="config-accordion-group__item-icon">
${getSectionIcon(s.key)}
</span>
${s.label}
</button>
`,
)}
</div>
`
: nothing}
</div>
`,
)}
</div>
`;
}
// Compute diff for showing changes (works for both form and raw modes)
const diff = formMode === "form" ? computeDiff(props.originalValue, props.formValue) : [];
const hasRawChanges = formMode === "raw" && props.raw !== props.originalRaw;
const hasChanges = formMode === "form" ? diff.length > 0 : hasRawChanges;
// Save/apply buttons require actual changes to be enabled.
// Note: formUnsafe warns about unsupported schema paths but shouldn't block saving.
const canSaveForm = Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
const canSave =
props.connected && !props.saving && hasChanges && (formMode === "raw" ? true : canSaveForm);
const canApply =
props.connected &&
!props.applying &&
!props.updating &&
hasChanges &&
(formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
const showAppearanceOnRoot =
includeVirtualSections &&
formMode === "form" &&
props.activeSection === null &&
Boolean(include?.has("__appearance__"));
return html`
<div class="config-layout">
<main class="config-main">
<div class="config-actions">
<div class="config-actions__left">
${showModeToggle
? html`
<div class="config-mode-toggle">
<button
class="config-mode-toggle__btn ${formMode === "form" ? "active" : ""}"
?disabled=${props.schemaLoading || !props.schema}
title=${formUnsafe ? "Form view can't safely edit some fields" : ""}
@click=${() => props.onFormModeChange("form")}
>
Form
</button>
<button
class="config-mode-toggle__btn ${formMode === "raw" ? "active" : ""}"
?disabled=${!rawAvailable}
title=${rawAvailable
? "Edit raw JSON/JSON5 config"
: "Raw mode unavailable for this snapshot"}
@click=${() => props.onFormModeChange("raw")}
>
Raw
</button>
</div>
`
: nothing}
${hasChanges
? html`
<span class="config-changes-badge"
>${formMode === "raw"
? "Unsaved changes"
: `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span
>
`
: html` <span class="config-status muted">No changes</span> `}
</div>
<div class="config-actions__right">
${!rawAvailable
? html`
<span class="config-status muted config-actions__notice"
>Raw mode disabled (snapshot cannot safely round-trip raw text).</span
>
`
: nothing}
<div class="config-actions__buttons">
${props.onOpenFile
? html`
<button
class="btn btn--sm"
title=${props.configPath ? `Open ${props.configPath}` : "Open config file"}
@click=${props.onOpenFile}
>
${icons.fileText} Open
</button>
`
: nothing}
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? t("common.loading") : t("common.reload")}
</button>
<button class="btn btn--sm" ?disabled=${!hasChanges} @click=${props.onReset}>
Clear
</button>
<button class="btn btn--sm primary" ?disabled=${!canSave} @click=${props.onSave}>
${props.saving ? "Saving…" : "Save"}
</button>
<button class="btn btn--sm" ?disabled=${!canApply} @click=${props.onApply}>
${props.applying ? "Applying…" : "Apply"}
</button>
<button class="btn btn--sm" ?disabled=${!canUpdate} @click=${props.onUpdate}>
${props.updating ? "Updating…" : "Update"}
</button>
</div>
</div>
</div>
${settingsLayout === "accordion"
? renderAccordionNav()
: html`
<div class="config-top-tabs">
${formMode === "form"
? html`
<div class="config-search config-search--top">
<div class="config-search__input-row">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
aria-label="Search settings"
.value=${props.searchQuery}
@input=${(e: Event) =>
props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${props.searchQuery
? html`
<button
class="config-search__clear"
aria-label="Clear search"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing}
</div>
</div>
`
: nothing}
<div
class="config-top-tabs__scroller"
role="tablist"
aria-label="${t("common.settingsSections")}"
>
${topTabs.map(
(tab) => html`
<button
class="config-top-tabs__tab ${props.activeSection === tab.key
? "active"
: ""}"
role="tab"
aria-selected=${props.activeSection === tab.key}
@click=${(e: Event) => {
props.onSectionChange(tab.key);
resetContentScroll(e.currentTarget);
}}
title=${tab.label}
>
${tab.label}
</button>
`,
)}
</div>
</div>
`}
${validity === "invalid" && !cvs.validityDismissed
? html`
<div class="config-validity-warning">
<svg
class="config-validity-warning__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="16"
height="16"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span class="config-validity-warning__text"
>Your configuration is invalid. Some settings may not work as expected.</span
>
<button
class="btn btn--sm"
@click=${() => {
cvs.validityDismissed = true;
requestUpdate();
}}
>
Don't remind again
</button>
</div>
`
: nothing}
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
${hasChanges && formMode === "form"
? html`
<details class="config-diff">
<summary class="config-diff__summary">
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span>
<svg
class="config-diff__chevron"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</summary>
<div class="config-diff__content">
${diff.map(
(change) => html`
<div class="config-diff__item">
<div class="config-diff__path">${change.path}</div>
<div class="config-diff__values">
<span class="config-diff__from"
>${renderDiffValue(change.path, change.from, props.uiHints)}</span
>
<span class="config-diff__arrow">→</span>
<span class="config-diff__to"
>${renderDiffValue(change.path, change.to, props.uiHints)}</span
>
</div>
</div>
`,
)}
</div>
</details>
`
: nothing}
${activeSectionMeta && formMode === "form"
? html`
<div class="config-section-hero">
<div class="config-section-hero__icon">
${getSectionIcon(props.activeSection ?? "")}
</div>
<div class="config-section-hero__text">
<div class="config-section-hero__title">${activeSectionMeta.label}</div>
${activeSectionMeta.description
? html`<div class="config-section-hero__desc">
${activeSectionMeta.description}
</div>`
: nothing}
</div>
${props.activeSection === "env"
? html`
<button
class="config-env-peek-btn ${envSensitiveVisible
? "config-env-peek-btn--active"
: ""}"
title=${envSensitiveVisible ? "Hide env values" : "Reveal env values"}
@click=${() => {
cvs.envRevealed = !cvs.envRevealed;
requestUpdate();
}}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="16"
height="16"
>
<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>
</svg>
Peek
</button>
`
: nothing}
</div>
`
: nothing}
<!-- Form content -->
<div class="config-content">
${props.activeSection === "__appearance__"
? includeVirtualSections
? renderAppearanceSection(props)
: nothing
: formMode === "form"
? html`
${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing}
${props.schemaLoading
? html`
<div class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema…</span>
</div>
`
: renderConfigForm({
schema: analysis.schema,
uiHints: props.uiHints,
value: props.formValue,
rawAvailable,
disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths,
onPatch: props.onFormPatch,
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
revealSensitive:
props.activeSection === "env" ? envSensitiveVisible : false,
isSensitivePathRevealed,
onToggleSensitivePath: (path) => {
toggleSensitivePathReveal(path);
requestUpdate();
},
})}
`
: (() => {
const sensitiveCount = countSensitiveConfigValues(
props.formValue,
[],
props.uiHints,
);
const blurred = sensitiveCount > 0 && !cvs.rawRevealed;
return html`
${formUnsafe
? html`
<div class="callout info" style="margin-bottom: 12px">
Your config contains fields the form editor can't safely represent. Use
Raw mode to edit those entries.
</div>
`
: nothing}
<div class="field config-raw-field">
<span style="display:flex;align-items:center;gap:8px;">
Raw config (JSON/JSON5)
${sensitiveCount > 0
? html`
<span class="pill pill--sm"
>${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"}
${blurred ? "redacted" : "visible"}</span
>
<button
class="btn btn--icon config-raw-toggle ${blurred ? "" : "active"}"
title=${blurred
? "Reveal sensitive values"
: "Hide sensitive values"}
aria-label="Toggle raw config redaction"
aria-pressed=${!blurred}
@click=${() => {
cvs.rawRevealed = !cvs.rawRevealed;
requestUpdate();
}}
>
${blurred ? icons.eyeOff : icons.eye}
</button>
`
: nothing}
</span>
${blurred
? html`
<div class="callout info" style="margin-top: 12px">
${sensitiveCount} sensitive value${sensitiveCount === 1 ? "" : "s"}
hidden. Use the reveal button above to edit the raw config.
</div>
`
: html`
<textarea
placeholder="Raw config (JSON/JSON5)"
.value=${props.raw}
@input=${(e: Event) => {
props.onRawChange((e.target as HTMLTextAreaElement).value);
}}
></textarea>
`}
</div>
`;
})()}
</div>
${props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;">
<pre class="code-block">${JSON.stringify(props.issues, null, 2)}</pre>
</div>`
: nothing}
</main>
</div>
`;
}
¤ Dauer der Verarbeitung: 0.28 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|
2026-05-26
|
|
|
|
|