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
+17
View File
@@ -714,6 +714,7 @@ dependencies = [
"futures",
"hyper",
"indexmap 2.11.4",
"json-five",
"json5",
"log",
"objc2 0.5.2",
@@ -2510,6 +2511,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "json-five"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "865f2d01a4549c1fd8c60640c03ae5249eb374cd8cde8b905628d4b1af95c87c"
dependencies = [
"serde",
"unicode-general-category",
]
[[package]]
name = "json-patch"
version = "3.0.1"
@@ -6001,6 +6012,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicode-general-category"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
version = "1.0.19"
+1
View File
@@ -63,6 +63,7 @@ rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
sha2 = "0.10"
json5 = "0.4"
json-five = "0.3.1"
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"
+24 -5
View File
@@ -26,6 +26,21 @@ pub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {
.map_err(|e| e.to_string())
}
/// Get a single OpenClaw provider fragment from live config.
#[tauri::command]
pub fn get_openclaw_live_provider(
#[allow(non_snake_case)] providerId: String,
) -> Result<Option<serde_json::Value>, String> {
openclaw_config::get_provider(&providerId).map_err(|e| e.to_string())
}
/// Scan openclaw.json for known configuration hazards.
#[tauri::command]
pub fn scan_openclaw_config_health(
) -> Result<Vec<openclaw_config::OpenClawHealthWarning>, String> {
openclaw_config::scan_openclaw_config_health().map_err(|e| e.to_string())
}
// ============================================================================
// Agents Configuration Commands
// ============================================================================
@@ -41,7 +56,7 @@ pub fn get_openclaw_default_model() -> Result<Option<openclaw_config::OpenClawDe
#[tauri::command]
pub fn set_openclaw_default_model(
model: openclaw_config::OpenClawDefaultModel,
) -> Result<(), String> {
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
openclaw_config::set_default_model(&model).map_err(|e| e.to_string())
}
@@ -56,7 +71,7 @@ pub fn get_openclaw_model_catalog(
#[tauri::command]
pub fn set_openclaw_model_catalog(
catalog: HashMap<String, openclaw_config::OpenClawModelCatalogEntry>,
) -> Result<(), String> {
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())
}
@@ -71,7 +86,7 @@ pub fn get_openclaw_agents_defaults(
#[tauri::command]
pub fn set_openclaw_agents_defaults(
defaults: openclaw_config::OpenClawAgentsDefaults,
) -> Result<(), String> {
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string())
}
@@ -87,7 +102,9 @@ pub fn get_openclaw_env() -> Result<openclaw_config::OpenClawEnvConfig, String>
/// Set OpenClaw env config (env section of openclaw.json)
#[tauri::command]
pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> {
pub fn set_openclaw_env(
env: openclaw_config::OpenClawEnvConfig,
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
openclaw_config::set_env_config(&env).map_err(|e| e.to_string())
}
@@ -103,6 +120,8 @@ pub fn get_openclaw_tools() -> Result<openclaw_config::OpenClawToolsConfig, Stri
/// Set OpenClaw tools config (tools section of openclaw.json)
#[tauri::command]
pub fn set_openclaw_tools(tools: openclaw_config::OpenClawToolsConfig) -> Result<(), String> {
pub fn set_openclaw_tools(
tools: openclaw_config::OpenClawToolsConfig,
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string())
}
+2
View File
@@ -1054,6 +1054,8 @@ pub fn run() {
// OpenClaw specific
commands::import_openclaw_providers_from_live,
commands::get_openclaw_live_provider_ids,
commands::get_openclaw_live_provider,
commands::scan_openclaw_config_health,
commands::get_openclaw_default_model,
commands::set_openclaw_default_model,
commands::get_openclaw_model_catalog,
File diff suppressed because it is too large Load Diff
+19 -1
View File
@@ -33,7 +33,7 @@ import {
} from "@/lib/api";
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
import { useProviderActions } from "@/hooks/useProviderActions";
import { openclawKeys } from "@/hooks/useOpenClaw";
import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useAutoCompact } from "@/hooks/useAutoCompact";
import { useLastValidValue } from "@/hooks/useLastValidValue";
@@ -70,6 +70,7 @@ import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
import EnvPanel from "@/components/openclaw/EnvPanel";
import ToolsPanel from "@/components/openclaw/ToolsPanel";
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner";
type View =
| "providers"
@@ -229,6 +230,17 @@ function App() {
});
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const isOpenClawView =
activeApp === "openclaw" &&
(currentView === "providers" ||
currentView === "workspace" ||
currentView === "sessions" ||
currentView === "openclawEnv" ||
currentView === "openclawTools" ||
currentView === "openclawAgents");
const { data: openclawHealthWarnings = [] } = useOpenClawHealth(
isOpenClawView,
);
const hasSkillsSupport = true;
const hasSessionSupport =
activeApp === "claude" ||
@@ -544,6 +556,9 @@ function App() {
await queryClient.invalidateQueries({
queryKey: openclawKeys.liveProviderIds,
});
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
toast.success(
t("notifications.removeFromConfigSuccess", {
@@ -1225,6 +1240,9 @@ function App() {
</header>
<main className="flex-1 min-h-0 flex flex-col overflow-y-auto animate-fade-in">
{isOpenClawView && openclawHealthWarnings.length > 0 && (
<OpenClawHealthBanner warnings={openclawHealthWarnings} />
)}
{renderContent()}
</main>
@@ -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) {
+13
View File
@@ -18,6 +18,7 @@ export const openclawKeys = {
env: ["openclaw", "env"] as const,
tools: ["openclaw", "tools"] as const,
agentsDefaults: ["openclaw", "agentsDefaults"] as const,
health: ["openclaw", "health"] as const,
};
// ============================================================
@@ -81,6 +82,15 @@ export function useOpenClawAgentsDefaults() {
});
}
export function useOpenClawHealth(enabled: boolean) {
return useQuery({
queryKey: openclawKeys.health,
queryFn: () => openclawApi.scanHealth(),
staleTime: 30_000,
enabled,
});
}
// ============================================================
// Mutation hooks
// ============================================================
@@ -95,6 +105,7 @@ export function useSaveOpenClawEnv() {
mutationFn: (env: OpenClawEnvConfig) => openclawApi.setEnv(env),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: openclawKeys.env });
queryClient.invalidateQueries({ queryKey: openclawKeys.health });
},
});
}
@@ -109,6 +120,7 @@ export function useSaveOpenClawTools() {
mutationFn: (tools: OpenClawToolsConfig) => openclawApi.setTools(tools),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: openclawKeys.tools });
queryClient.invalidateQueries({ queryKey: openclawKeys.health });
},
});
}
@@ -126,6 +138,7 @@ export function useSaveOpenClawAgentsDefaults() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: openclawKeys.agentsDefaults });
queryClient.invalidateQueries({ queryKey: openclawKeys.defaultModel });
queryClient.invalidateQueries({ queryKey: openclawKeys.health });
},
});
}
+20 -3
View File
@@ -80,6 +80,9 @@ export function useProviderActions(activeApp: AppId) {
const existingCatalog = (await openclawApi.getModelCatalog()) || {};
const mergedCatalog = { ...existingCatalog, ...modelCatalog };
await openclawApi.setModelCatalog(mergedCatalog);
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
modelsRegistered = true;
}
@@ -88,6 +91,9 @@ export function useProviderActions(activeApp: AppId) {
const existingDefault = await openclawApi.getDefaultModel();
if (!existingDefault?.primary) {
await openclawApi.setDefaultModel(model);
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
}
@@ -109,7 +115,7 @@ export function useProviderActions(activeApp: AppId) {
}
}
},
[addProviderMutation, activeApp, t],
[addProviderMutation, activeApp, queryClient, t],
);
// 更新供应商
@@ -255,15 +261,26 @@ export function useProviderActions(activeApp: AppId) {
};
try {
await openclawApi.setDefaultModel(model);
const outcome = await openclawApi.setDefaultModel(model);
await queryClient.invalidateQueries({
queryKey: openclawKeys.defaultModel,
});
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
toast.success(
t("notifications.openclawDefaultModelSet", {
defaultValue: "已设为默认模型",
}),
{ closeButton: true },
{
closeButton: true,
description: outcome.backupPath
? t("openclaw.backupCreated", {
path: outcome.backupPath,
defaultValue: "Backup created: {{path}}",
})
: undefined,
},
);
} catch (error) {
const detail =
+19 -5
View File
@@ -5,6 +5,8 @@ import type {
OpenClawAgentsDefaults,
OpenClawEnvConfig,
OpenClawToolsConfig,
OpenClawHealthWarning,
OpenClawWriteOutcome,
} from "@/types";
/**
@@ -30,7 +32,7 @@ export const openclawApi = {
/**
* Set default model configuration (agents.defaults.model)
*/
async setDefaultModel(model: OpenClawDefaultModel): Promise<void> {
async setDefaultModel(model: OpenClawDefaultModel): Promise<OpenClawWriteOutcome> {
return await invoke("set_openclaw_default_model", { model });
},
@@ -49,7 +51,7 @@ export const openclawApi = {
*/
async setModelCatalog(
catalog: Record<string, OpenClawModelCatalogEntry>,
): Promise<void> {
): Promise<OpenClawWriteOutcome> {
return await invoke("set_openclaw_model_catalog", { catalog });
},
@@ -63,7 +65,9 @@ export const openclawApi = {
/**
* Set full agents.defaults config (all fields)
*/
async setAgentsDefaults(defaults: OpenClawAgentsDefaults): Promise<void> {
async setAgentsDefaults(
defaults: OpenClawAgentsDefaults,
): Promise<OpenClawWriteOutcome> {
return await invoke("set_openclaw_agents_defaults", { defaults });
},
@@ -81,7 +85,7 @@ export const openclawApi = {
/**
* Set env config (env section of openclaw.json)
*/
async setEnv(env: OpenClawEnvConfig): Promise<void> {
async setEnv(env: OpenClawEnvConfig): Promise<OpenClawWriteOutcome> {
return await invoke("set_openclaw_env", { env });
},
@@ -99,7 +103,17 @@ export const openclawApi = {
/**
* Set tools config (tools section of openclaw.json)
*/
async setTools(tools: OpenClawToolsConfig): Promise<void> {
async setTools(tools: OpenClawToolsConfig): Promise<OpenClawWriteOutcome> {
return await invoke("set_openclaw_tools", { tools });
},
async scanHealth(): Promise<OpenClawHealthWarning[]> {
return await invoke("scan_openclaw_config_health");
},
async getLiveProvider(
providerId: string,
): Promise<Record<string, unknown> | null> {
return await invoke("get_openclaw_live_provider", { providerId });
},
};
+20
View File
@@ -65,6 +65,12 @@ export const useAddProviderMutation = (appId: AppId) => {
});
}
if (appId === "openclaw") {
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
try {
await providersApi.updateTrayMenu();
} catch (trayError) {
@@ -106,6 +112,11 @@ export const useUpdateProviderMutation = (appId: AppId) => {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["providers", appId] });
if (appId === "openclaw") {
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
toast.success(
t("notifications.updateSuccess", {
defaultValue: "供应商更新成功",
@@ -153,6 +164,12 @@ export const useDeleteProviderMutation = (appId: AppId) => {
});
}
if (appId === "openclaw") {
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
try {
await providersApi.updateTrayMenu();
} catch (trayError) {
@@ -213,6 +230,9 @@ export const useSwitchProviderMutation = (appId: AppId) => {
await queryClient.invalidateQueries({
queryKey: openclawKeys.defaultModel,
});
await queryClient.invalidateQueries({
queryKey: openclawKeys.health,
});
}
try {
+20 -1
View File
@@ -483,6 +483,23 @@ export interface OpenClawModelCatalogEntry {
alias?: string;
}
export interface OpenClawHealthWarning {
code: string;
message: string;
path?: string;
}
export interface OpenClawWriteOutcome {
backupPath?: string;
warnings: OpenClawHealthWarning[];
}
export type OpenClawToolsProfile =
| "minimal"
| "coding"
| "messaging"
| "full";
// OpenClaw 供应商配置(settings_config 结构)
// 对应 OpenClaw 的 models.providers.<provider-id> 配置
export interface OpenClawProviderConfig {
@@ -496,6 +513,8 @@ export interface OpenClawProviderConfig {
export interface OpenClawAgentsDefaults {
model?: OpenClawDefaultModel;
models?: Record<string, OpenClawModelCatalogEntry>;
timeoutSeconds?: number;
timeout?: number;
[key: string]: unknown; // preserve unknown fields
}
@@ -506,7 +525,7 @@ export interface OpenClawEnvConfig {
// OpenClaw tools 配置(openclaw.json 的 tools 节点)
export interface OpenClawToolsConfig {
profile?: string;
profile?: OpenClawToolsProfile | string;
allow?: string[];
deny?: string[];
[key: string]: unknown; // preserve unknown fields
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
getOpenClawTimeoutInputValue,
getOpenClawToolsProfileSelectValue,
getOpenClawUnsupportedProfile,
OPENCLAW_UNSUPPORTED_PROFILE,
parseOpenClawEnvEditorValue,
} from "@/components/openclaw/utils";
describe("OpenClaw utils", () => {
it("parses nested env objects without stringifying them", () => {
const env = parseOpenClawEnvEditorValue(`{
"API_KEY": "secret",
"vars": { "HTTP_PROXY": "http://127.0.0.1:8080" },
"shellEnv": { "NODE_OPTIONS": "--max-old-space-size=4096" }
}`);
expect(env).toEqual({
API_KEY: "secret",
vars: { HTTP_PROXY: "http://127.0.0.1:8080" },
shellEnv: { NODE_OPTIONS: "--max-old-space-size=4096" },
});
});
it("rejects non-object env payloads", () => {
expect(() => parseOpenClawEnvEditorValue(`["not", "an object"]`)).toThrow(
"OPENCLAW_ENV_OBJECT_REQUIRED",
);
});
it("flags unsupported tools profiles without silently normalizing them", () => {
expect(getOpenClawToolsProfileSelectValue("default")).toBe(
OPENCLAW_UNSUPPORTED_PROFILE,
);
expect(getOpenClawUnsupportedProfile("default")).toBe("default");
expect(getOpenClawUnsupportedProfile("coding")).toBeNull();
});
it("prefers timeoutSeconds and falls back to legacy timeout", () => {
expect(
getOpenClawTimeoutInputValue({ timeoutSeconds: 120, timeout: 30 }),
).toBe("120");
expect(getOpenClawTimeoutInputValue({ timeout: 45 })).toBe("45");
expect(getOpenClawTimeoutInputValue({})).toBe("");
});
});