feat: overhaul OpenClaw config panels with JSON5 round-trip write engine

- Add json-five crate for JSON5 serialization preserving comments and formatting
- Rewrite openclaw_config.rs with comment-preserving JSON5 read/write engine
- Add Tauri commands: get_openclaw_live_provider, write_openclaw_config_section
- Redesign EnvPanel as full JSON editor with structured error handling
- Add tools.profile selection (minimal/coding/messaging/full) to ToolsPanel
- Add legacy timeout migration support to AgentsDefaultsPanel
- Add OpenClawHealthBanner component for config validation warnings
- Add supporting hooks, mutations, utility functions, and unit tests
This commit is contained in:
Jason
2026-03-06 18:35:23 +08:00
parent b4fdd5fc0d
commit 7e6f803035
18 changed files with 1242 additions and 385 deletions
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Save, Plus, Trash2 } from "lucide-react";
import { Save, Plus, Trash2, TriangleAlert } from "lucide-react";
import { toast } from "sonner";
import {
useOpenClawAgentsDefaults,
@@ -10,6 +10,7 @@ import { extractErrorMessage } from "@/utils/errorUtils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Select,
SelectContent,
@@ -19,6 +20,7 @@ import {
} from "@/components/ui/select";
import type { OpenClawAgentsDefaults } from "@/types";
import { useOpenClawModelOptions } from "./hooks/useOpenClawModelOptions";
import { getOpenClawTimeoutInputValue } from "./utils";
const UNSET_SENTINEL = "__unset__";
@@ -50,9 +52,16 @@ const AgentsDefaultsPanel: React.FC = () => {
// Extract known extra fields
setWorkspace(String(agentsData.workspace ?? ""));
setTimeout_(String(agentsData.timeout ?? ""));
setTimeout_(getOpenClawTimeoutInputValue(agentsData));
setContextTokens(String(agentsData.contextTokens ?? ""));
setMaxConcurrent(String(agentsData.maxConcurrent ?? ""));
} else {
setPrimaryModel("");
setFallbacks([]);
setWorkspace("");
setTimeout_("");
setContextTokens("");
setMaxConcurrent("");
}
}, [agentsData]);
@@ -146,8 +155,9 @@ const AgentsDefaultsPanel: React.FC = () => {
};
const timeoutNum = timeout.trim() ? parseNum(timeout) : undefined;
if (timeoutNum !== undefined) updated.timeout = timeoutNum;
else delete updated.timeout;
if (timeoutNum !== undefined) updated.timeoutSeconds = timeoutNum;
else delete updated.timeoutSeconds;
delete updated.timeout;
const ctxNum = contextTokens.trim() ? parseNum(contextTokens) : undefined;
if (ctxNum !== undefined) updated.contextTokens = ctxNum;
@@ -159,8 +169,15 @@ const AgentsDefaultsPanel: React.FC = () => {
if (concNum !== undefined) updated.maxConcurrent = concNum;
else delete updated.maxConcurrent;
await saveAgentsMutation.mutateAsync(updated);
toast.success(t("openclaw.agents.saveSuccess"));
const outcome = await saveAgentsMutation.mutateAsync(updated);
toast.success(t("openclaw.agents.saveSuccess"), {
description: outcome.backupPath
? t("openclaw.backupCreated", {
path: outcome.backupPath,
defaultValue: "Backup created: {{path}}",
})
: undefined,
});
} catch (error) {
const detail = extractErrorMessage(error);
toast.error(t("openclaw.agents.saveFailed"), {
@@ -180,6 +197,11 @@ const AgentsDefaultsPanel: React.FC = () => {
}
const noModels = modelOptions.length === 0 && !modelsLoading;
const hasLegacyTimeout =
agentsData !== undefined &&
agentsData !== null &&
typeof agentsData.timeout === "number" &&
typeof agentsData.timeoutSeconds !== "number";
return (
<div className="px-6 pt-4 pb-8">
@@ -187,6 +209,23 @@ const AgentsDefaultsPanel: React.FC = () => {
{t("openclaw.agents.description")}
</p>
{hasLegacyTimeout && (
<Alert className="mb-4 border-amber-500/30 bg-amber-500/5">
<TriangleAlert className="h-4 w-4" />
<AlertTitle>
{t("openclaw.agents.legacyTimeoutTitle", {
defaultValue: "Legacy timeout detected",
})}
</AlertTitle>
<AlertDescription>
{t("openclaw.agents.legacyTimeoutDescription", {
defaultValue:
"This config still uses agents.defaults.timeout. Saving here will migrate it to timeoutSeconds.",
})}
</AlertDescription>
</Alert>
)}
{/* Model Configuration Card */}
<div className="rounded-xl border border-border bg-card p-5 mb-4">
<h3 className="text-sm font-medium mb-4">
+66 -128
View File
@@ -1,96 +1,77 @@
import React, { useState, useEffect } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, Save, Eye, EyeOff } from "lucide-react";
import { Save } from "lucide-react";
import { toast } from "sonner";
import { useOpenClawEnv, useSaveOpenClawEnv } from "@/hooks/useOpenClaw";
import { extractErrorMessage } from "@/utils/errorUtils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { OpenClawEnvConfig } from "@/types";
interface EnvEntry {
id: string;
key: string;
value: string;
isNew?: boolean;
}
import JsonEditor from "@/components/JsonEditor";
import { parseOpenClawEnvEditorValue } from "./utils";
const EnvPanel: React.FC = () => {
const { t } = useTranslation();
const { data: envData, isLoading } = useOpenClawEnv();
const saveEnvMutation = useSaveOpenClawEnv();
const [entries, setEntries] = useState<EnvEntry[]>([]);
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
const [editorValue, setEditorValue] = useState("{}");
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
if (envData) {
const items: EnvEntry[] = Object.entries(envData).map(([key, value]) => ({
id: crypto.randomUUID(),
key,
value: String(value ?? ""),
}));
setEntries(items.length > 0 ? items : []);
}
const nextValue =
envData && Object.keys(envData).length > 0
? JSON.stringify(envData, null, 2)
: "{}";
setEditorValue(nextValue);
}, [envData]);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const handleSave = async () => {
try {
const env: OpenClawEnvConfig = {};
const seen = new Set<string>();
for (const entry of entries) {
const trimmedKey = entry.key.trim();
if (trimmedKey) {
if (seen.has(trimmedKey)) {
toast.error(t("openclaw.env.duplicateKey", { key: trimmedKey }));
return;
}
seen.add(trimmedKey);
env[trimmedKey] = entry.value;
}
}
await saveEnvMutation.mutateAsync(env);
toast.success(t("openclaw.env.saveSuccess"));
const env = parseOpenClawEnvEditorValue(editorValue);
const outcome = await saveEnvMutation.mutateAsync(env);
toast.success(t("openclaw.env.saveSuccess"), {
description: outcome.backupPath
? t("openclaw.backupCreated", {
path: outcome.backupPath,
defaultValue: "Backup created: {{path}}",
})
: undefined,
});
} catch (error) {
const detail = extractErrorMessage(error);
let description = detail || undefined;
if (detail === "OPENCLAW_ENV_EMPTY") {
description = t("openclaw.env.empty", {
defaultValue: "OpenClaw env cannot be empty. Use {} for an empty object.",
});
} else if (detail === "OPENCLAW_ENV_INVALID_JSON") {
description = t("openclaw.env.invalidJson", {
defaultValue: "OpenClaw env must be valid JSON.",
});
} else if (detail === "OPENCLAW_ENV_OBJECT_REQUIRED") {
description = t("openclaw.env.objectRequired", {
defaultValue: "OpenClaw env must be a JSON object.",
});
}
toast.error(t("openclaw.env.saveFailed"), {
description: detail || undefined,
description,
});
}
};
const addEntry = () => {
setEntries((prev) => [
...prev,
{ id: crypto.randomUUID(), key: "", value: "", isNew: true },
]);
};
const removeEntry = (index: number) => {
setEntries((prev) => prev.filter((_, i) => i !== index));
};
const updateEntry = (index: number, field: "key" | "value", val: string) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, [field]: val } : entry,
),
);
};
const toggleVisibility = (key: string) => {
setVisibleKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const isApiKey = (key: string) => /key|token|secret|password/i.test(key);
if (isLoading) {
return (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
@@ -106,66 +87,23 @@ const EnvPanel: React.FC = () => {
<p className="text-sm text-muted-foreground mb-4">
{t("openclaw.env.description")}
</p>
<div className="space-y-3">
{entries.map((entry, index) => {
const sensitive = isApiKey(entry.key);
const visibilityId = entry.key || `__new_${index}`;
const visible = visibleKeys.has(visibilityId);
return (
<div key={entry.id} className="flex items-center gap-2">
<div className="w-[200px] flex-shrink-0">
<Input
value={entry.key}
onChange={(e) => updateEntry(index, "key", e.target.value)}
placeholder={t("openclaw.env.keyPlaceholder")}
className="font-mono text-xs"
autoFocus={entry.isNew}
/>
</div>
<div className="flex-1 flex items-center gap-1">
<Input
type={sensitive && !visible ? "password" : "text"}
value={entry.value}
onChange={(e) => updateEntry(index, "value", e.target.value)}
placeholder={t("openclaw.env.valuePlaceholder")}
className="font-mono text-xs"
/>
{sensitive && (
<Button
variant="ghost"
size="icon"
className="flex-shrink-0 h-9 w-9 text-muted-foreground"
onClick={() => toggleVisibility(visibilityId)}
>
{visible ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => removeEntry(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
);
<p className="text-xs text-muted-foreground mb-4">
{t("openclaw.env.editorHint", {
defaultValue:
"Edit the full env section as JSON. Nested objects such as env.vars and env.shellEnv are supported.",
})}
</div>
</p>
<div className="flex items-center gap-2 mt-4">
<Button variant="outline" size="sm" onClick={addEntry}>
<Plus className="w-4 h-4 mr-1" />
{t("openclaw.env.add")}
</Button>
<div className="flex-1" />
<JsonEditor
value={editorValue}
onChange={setEditorValue}
darkMode={isDarkMode}
rows={18}
showValidation={true}
language="json"
/>
<div className="flex justify-end mt-4">
<Button
size="sm"
onClick={handleSave}
@@ -0,0 +1,89 @@
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TriangleAlert } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { OpenClawHealthWarning } from "@/types";
interface OpenClawHealthBannerProps {
warnings: OpenClawHealthWarning[];
}
function getWarningText(
code: string,
fallback: string,
t: ReturnType<typeof useTranslation>["t"],
) {
switch (code) {
case "invalid_tools_profile":
return t("openclaw.health.invalidToolsProfile", {
defaultValue:
"tools.profile contains an unsupported value. OpenClaw currently expects minimal, coding, messaging, or full.",
});
case "legacy_agents_timeout":
return t("openclaw.health.legacyTimeout", {
defaultValue:
"agents.defaults.timeout is deprecated. Save the Agents panel to migrate it to timeoutSeconds.",
});
case "stringified_env_vars":
return t("openclaw.health.stringifiedEnvVars", {
defaultValue:
"env.vars should be an object, but the current value looks stringified or malformed.",
});
case "stringified_env_shell_env":
return t("openclaw.health.stringifiedShellEnv", {
defaultValue:
"env.shellEnv should be an object, but the current value looks stringified or malformed.",
});
case "config_parse_failed":
return t("openclaw.health.parseFailed", {
defaultValue:
"openclaw.json could not be parsed as valid JSON5. Fix the file before editing it here.",
});
default:
return fallback;
}
}
const OpenClawHealthBanner: React.FC<OpenClawHealthBannerProps> = ({
warnings,
}) => {
const { t } = useTranslation();
const items = useMemo(
() =>
warnings.map((warning) => ({
...warning,
text: getWarningText(warning.code, warning.message, t),
})),
[t, warnings],
);
if (warnings.length === 0) {
return null;
}
return (
<div className="px-6 pt-4">
<Alert className="border-amber-500/30 bg-amber-500/5">
<TriangleAlert className="h-4 w-4" />
<AlertTitle>
{t("openclaw.health.title", {
defaultValue: "OpenClaw config warnings detected",
})}
</AlertTitle>
<AlertDescription>
<ul className="list-disc space-y-1 pl-5">
{items.map((warning) => (
<li key={`${warning.code}:${warning.path ?? warning.message}`}>
{warning.text}
{warning.path ? ` (${warning.path})` : ""}
</li>
))}
</ul>
</AlertDescription>
</Alert>
</div>
);
};
export default OpenClawHealthBanner;
+90 -24
View File
@@ -1,12 +1,13 @@
import React, { useState, useEffect } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, Save } from "lucide-react";
import { Plus, Trash2, Save, TriangleAlert } from "lucide-react";
import { toast } from "sonner";
import { useOpenClawTools, useSaveOpenClawTools } from "@/hooks/useOpenClaw";
import { extractErrorMessage } from "@/utils/errorUtils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Select,
SelectContent,
@@ -14,15 +15,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { OpenClawToolsConfig } from "@/types";
import type { OpenClawToolsConfig, OpenClawToolsProfile } from "@/types";
import {
getOpenClawToolsProfileSelectValue,
getOpenClawUnsupportedProfile,
OPENCLAW_TOOL_PROFILES,
OPENCLAW_UNSET_PROFILE,
OPENCLAW_UNSUPPORTED_PROFILE,
} from "./utils";
interface ListItem {
id: string;
value: string;
}
const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"];
const ToolsPanel: React.FC = () => {
const { t } = useTranslation();
const { data: toolsData, isLoading } = useOpenClawTools();
@@ -35,31 +41,59 @@ const ToolsPanel: React.FC = () => {
if (toolsData) {
setConfig(toolsData);
setAllowList(
(toolsData.allow ?? []).map((v) => ({
(toolsData.allow ?? []).map((value) => ({
id: crypto.randomUUID(),
value: v,
value,
})),
);
setDenyList(
(toolsData.deny ?? []).map((v) => ({
(toolsData.deny ?? []).map((value) => ({
id: crypto.randomUUID(),
value: v,
value,
})),
);
}
}, [toolsData]);
const unsupportedProfile = getOpenClawUnsupportedProfile(config.profile);
const profileLabels = useMemo<Record<OpenClawToolsProfile, string>>(
() => ({
minimal: t("openclaw.tools.profileMinimal", {
defaultValue: "Minimal",
}),
coding: t("openclaw.tools.profileCoding", {
defaultValue: "Coding",
}),
messaging: t("openclaw.tools.profileMessaging", {
defaultValue: "Messaging",
}),
full: t("openclaw.tools.profileFull", {
defaultValue: "Full",
}),
}),
[t],
);
const handleSave = async () => {
try {
const { profile, allow, deny, ...other } = config;
const { allow, deny, ...other } = config;
const newConfig: OpenClawToolsConfig = {
...other,
profile: config.profile,
allow: allowList.map((item) => item.value).filter((s) => s.trim()),
deny: denyList.map((item) => item.value).filter((s) => s.trim()),
};
await saveToolsMutation.mutateAsync(newConfig);
toast.success(t("openclaw.tools.saveSuccess"));
const outcome = await saveToolsMutation.mutateAsync(newConfig);
toast.success(t("openclaw.tools.saveSuccess"), {
description: outcome.backupPath
? t("openclaw.backupCreated", {
path: outcome.backupPath,
defaultValue: "Backup created: {{path}}",
})
: undefined,
});
} catch (error) {
const detail = extractErrorMessage(error);
toast.error(t("openclaw.tools.saveFailed"), {
@@ -101,29 +135,63 @@ const ToolsPanel: React.FC = () => {
{t("openclaw.tools.description")}
</p>
{/* Profile selector */}
{unsupportedProfile && (
<Alert className="mb-6 border-amber-500/30 bg-amber-500/5">
<TriangleAlert className="h-4 w-4" />
<AlertTitle>
{t("openclaw.tools.unsupportedProfileTitle", {
defaultValue: "Unsupported tools profile",
})}
</AlertTitle>
<AlertDescription>
{t("openclaw.tools.unsupportedProfileDescription", {
value: unsupportedProfile,
defaultValue:
"The current tools.profile value '{{value}}' is not in the supported OpenClaw list. It will be preserved until you choose a new value.",
})}
</AlertDescription>
</Alert>
)}
<div className="mb-6">
<Label className="mb-2 block">{t("openclaw.tools.profile")}</Label>
<Select
value={config.profile ?? "default"}
onValueChange={(val) =>
setConfig((prev) => ({ ...prev, profile: val }))
}
value={getOpenClawToolsProfileSelectValue(config.profile)}
onValueChange={(value) => {
if (value === OPENCLAW_UNSUPPORTED_PROFILE) return;
if (value === OPENCLAW_UNSET_PROFILE) {
setConfig((prev) => ({ ...prev, profile: undefined }));
return;
}
setConfig((prev) => ({ ...prev, profile: value }));
}}
>
<SelectTrigger className="w-[200px]">
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROFILE_OPTIONS.map((opt) => (
<SelectItem key={opt} value={opt}>
{t(`openclaw.tools.profiles.${opt}`)}
<SelectItem value={OPENCLAW_UNSET_PROFILE}>
{t("openclaw.tools.profileUnset", {
defaultValue: "Not set",
})}
</SelectItem>
{unsupportedProfile && (
<SelectItem
value={OPENCLAW_UNSUPPORTED_PROFILE}
disabled={true}
>{`${unsupportedProfile} (${t("openclaw.tools.unsupportedProfileLabel", {
defaultValue: "unsupported",
})})`}</SelectItem>
)}
{OPENCLAW_TOOL_PROFILES.map((profile) => (
<SelectItem key={profile} value={profile}>
{profileLabels[profile]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Allow list */}
<div className="mb-6">
<Label className="mb-2 block">{t("openclaw.tools.allowList")}</Label>
<div className="space-y-2">
@@ -163,7 +231,6 @@ const ToolsPanel: React.FC = () => {
</div>
</div>
{/* Deny list */}
<div className="mb-6">
<Label className="mb-2 block">{t("openclaw.tools.denyList")}</Label>
<div className="space-y-2">
@@ -203,7 +270,6 @@ const ToolsPanel: React.FC = () => {
</div>
</div>
{/* Save button */}
<div className="flex justify-end">
<Button
size="sm"
+71
View File
@@ -0,0 +1,71 @@
import type {
OpenClawAgentsDefaults,
OpenClawEnvConfig,
OpenClawToolsProfile,
} from "@/types";
export const OPENCLAW_TOOL_PROFILES: OpenClawToolsProfile[] = [
"minimal",
"coding",
"messaging",
"full",
];
export const OPENCLAW_UNSUPPORTED_PROFILE = "__unsupported_profile__";
export const OPENCLAW_UNSET_PROFILE = "__unset_profile__";
export function parseOpenClawEnvEditorValue(raw: string): OpenClawEnvConfig {
if (!raw.trim()) {
throw new Error("OPENCLAW_ENV_EMPTY");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("OPENCLAW_ENV_INVALID_JSON");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("OPENCLAW_ENV_OBJECT_REQUIRED");
}
return parsed as OpenClawEnvConfig;
}
export function isOpenClawToolsProfile(
profile?: string,
): profile is OpenClawToolsProfile {
return (
typeof profile === "string" &&
OPENCLAW_TOOL_PROFILES.includes(profile as OpenClawToolsProfile)
);
}
export function getOpenClawToolsProfileSelectValue(profile?: string): string {
if (!profile) {
return OPENCLAW_UNSET_PROFILE;
}
return isOpenClawToolsProfile(profile)
? profile
: OPENCLAW_UNSUPPORTED_PROFILE;
}
export function getOpenClawUnsupportedProfile(profile?: string): string | null {
if (!profile || isOpenClawToolsProfile(profile)) {
return null;
}
return profile;
}
export function getOpenClawTimeoutInputValue(
defaults?: OpenClawAgentsDefaults | null,
): string {
const timeoutSeconds =
typeof defaults?.timeoutSeconds === "number"
? defaults.timeoutSeconds
: undefined;
const legacyTimeout =
typeof defaults?.timeout === "number" ? defaults.timeout : undefined;
const value = timeoutSeconds ?? legacyTimeout;
return value === undefined ? "" : String(value);
}
@@ -8,7 +8,7 @@ import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { providersApi, vscodeApi, type AppId } from "@/lib/api";
import { openclawApi, providersApi, vscodeApi, type AppId } from "@/lib/api";
interface EditProviderDialogProps {
open: boolean;
@@ -73,6 +73,26 @@ export function EditProviderDialog({
return;
}
if (appId === "openclaw") {
try {
const live = await openclawApi.getLiveProvider(provider.id);
if (!cancelled && live && typeof live === "object") {
setLiveSettings(live);
} else if (!cancelled) {
setLiveSettings(null);
}
} catch {
if (!cancelled) {
setLiveSettings(null);
}
} finally {
if (!cancelled) {
setHasLoadedLive(true);
}
}
return;
}
try {
const currentId = await providersApi.getCurrent(appId);
if (currentId && provider.id === currentId) {