feat(openclaw): add Env/Tools/Agents config panels

- Migrate OpenClaw commands from provider.rs to dedicated commands/openclaw.rs
- Add backend types and read/write for env, tools, agents.defaults sections
- Create EnvPanel (API key + custom vars KV editor)
- Create ToolsPanel (profile selector + allow/deny lists)
- Create AgentsDefaultsPanel (default model + runtime parameters)
- Extend App.tsx menu bar with Env/Tools/Agents buttons
- Remove Prompts button for OpenClaw (overlaps with Workspace AGENTS.md)
This commit is contained in:
Jason
2026-02-07 23:52:54 +08:00
parent 9035784aa4
commit 2b1f0f0f7e
15 changed files with 1017 additions and 81 deletions
@@ -0,0 +1,223 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Save } from "lucide-react";
import { toast } from "sonner";
import { openclawApi } from "@/lib/api/openclaw";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { OpenClawAgentsDefaults } from "@/types";
const AgentsDefaultsPanel: React.FC = () => {
const { t } = useTranslation();
const [defaults, setDefaults] = useState<OpenClawAgentsDefaults | null>(null);
const [primaryModel, setPrimaryModel] = useState("");
const [fallbacks, setFallbacks] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Extra known fields from agents.defaults
const [workspace, setWorkspace] = useState("");
const [timeout, setTimeout_] = useState("");
const [contextTokens, setContextTokens] = useState("");
const [maxConcurrent, setMaxConcurrent] = useState("");
const loadDefaults = useCallback(async () => {
try {
setLoading(true);
const data = await openclawApi.getAgentsDefaults();
setDefaults(data);
if (data) {
setPrimaryModel(data.model?.primary ?? "");
setFallbacks((data.model?.fallbacks ?? []).join(", "));
// Extract known extra fields
setWorkspace(String(data.workspace ?? ""));
setTimeout_(String(data.timeout ?? ""));
setContextTokens(String(data.contextTokens ?? ""));
setMaxConcurrent(String(data.maxConcurrent ?? ""));
}
} catch (err) {
toast.error(t("openclaw.agents.loadFailed"));
console.error("Failed to load agents defaults:", err);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadDefaults();
}, [loadDefaults]);
const handleSave = async () => {
try {
setSaving(true);
// Preserve all unknown fields from original data
const updated: OpenClawAgentsDefaults = { ...defaults };
// Model configuration
const fallbackList = fallbacks
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (primaryModel.trim()) {
updated.model = {
primary: primaryModel.trim(),
...(fallbackList.length > 0 ? { fallbacks: fallbackList } : {}),
};
}
// Optional numeric fields
if (workspace.trim()) updated.workspace = workspace.trim();
else delete updated.workspace;
if (timeout.trim()) updated.timeout = Number(timeout);
else delete updated.timeout;
if (contextTokens.trim()) updated.contextTokens = Number(contextTokens);
else delete updated.contextTokens;
if (maxConcurrent.trim()) updated.maxConcurrent = Number(maxConcurrent);
else delete updated.maxConcurrent;
await openclawApi.setAgentsDefaults(updated);
toast.success(t("openclaw.agents.saveSuccess"));
await loadDefaults();
} catch (err) {
toast.error(t("openclaw.agents.saveFailed"));
console.error("Failed to save agents defaults:", err);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
{t("common.loading")}
</div>
</div>
);
}
return (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-6">
{t("openclaw.agents.description")}
</p>
{/* Model Configuration Card */}
<div className="rounded-xl border border-border bg-card p-5 mb-4">
<h3 className="text-sm font-medium mb-4">
{t("openclaw.agents.modelSection")}
</h3>
<div className="space-y-4">
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.primaryModel")}
</Label>
<Input
value={primaryModel}
onChange={(e) => setPrimaryModel(e.target.value)}
placeholder="provider/model-id"
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("openclaw.agents.primaryModelHint")}
</p>
</div>
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.fallbackModels")}
</Label>
<Input
value={fallbacks}
onChange={(e) => setFallbacks(e.target.value)}
placeholder="provider/model-a, provider/model-b"
className="font-mono text-xs"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("openclaw.agents.fallbackModelsHint")}
</p>
</div>
</div>
</div>
{/* Runtime Parameters Card */}
<div className="rounded-xl border border-border bg-card p-5 mb-4">
<h3 className="text-sm font-medium mb-4">
{t("openclaw.agents.runtimeSection")}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.workspace")}
</Label>
<Input
value={workspace}
onChange={(e) => setWorkspace(e.target.value)}
placeholder="~/projects"
className="font-mono text-xs"
/>
</div>
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.timeout")}
</Label>
<Input
type="number"
value={timeout}
onChange={(e) => setTimeout_(e.target.value)}
placeholder="300"
className="font-mono text-xs"
/>
</div>
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.contextTokens")}
</Label>
<Input
type="number"
value={contextTokens}
onChange={(e) => setContextTokens(e.target.value)}
placeholder="200000"
className="font-mono text-xs"
/>
</div>
<div>
<Label className="mb-1.5 block">
{t("openclaw.agents.maxConcurrent")}
</Label>
<Input
type="number"
value={maxConcurrent}
onChange={(e) => setMaxConcurrent(e.target.value)}
placeholder="4"
className="font-mono text-xs"
/>
</div>
</div>
</div>
{/* Save button */}
<div className="flex justify-end">
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="w-4 h-4 mr-1" />
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
);
};
export default AgentsDefaultsPanel;
+179
View File
@@ -0,0 +1,179 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, Save, Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { openclawApi } from "@/lib/api/openclaw";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { OpenClawEnvConfig } from "@/types";
interface EnvEntry {
key: string;
value: string;
isNew?: boolean;
}
const EnvPanel: React.FC = () => {
const { t } = useTranslation();
const [entries, setEntries] = useState<EnvEntry[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set());
const loadEnv = useCallback(async () => {
try {
setLoading(true);
const env = await openclawApi.getEnv();
const items: EnvEntry[] = Object.entries(env).map(([key, value]) => ({
key,
value: String(value ?? ""),
}));
setEntries(items.length > 0 ? items : []);
} catch (err) {
toast.error(t("openclaw.env.loadFailed"));
console.error("Failed to load env config:", err);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadEnv();
}, [loadEnv]);
const handleSave = async () => {
try {
setSaving(true);
const env: OpenClawEnvConfig = {};
for (const entry of entries) {
const trimmedKey = entry.key.trim();
if (trimmedKey) {
env[trimmedKey] = entry.value;
}
}
await openclawApi.setEnv(env);
toast.success(t("openclaw.env.saveSuccess"));
// Reload to normalize
await loadEnv();
} catch (err) {
toast.error(t("openclaw.env.saveFailed"));
console.error("Failed to save env config:", err);
} finally {
setSaving(false);
}
};
const addEntry = () => {
setEntries((prev) => [...prev, { 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 (loading) {
return (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
{t("common.loading")}
</div>
</div>
);
}
return (
<div className="px-6 pt-4 pb-8">
<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 visible = visibleKeys.has(`${index}`);
return (
<div key={index} 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(`${index}`)}
>
{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>
);
})}
</div>
<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" />
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="w-4 h-4 mr-1" />
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
);
};
export default EnvPanel;
+204
View File
@@ -0,0 +1,204 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, Save } from "lucide-react";
import { toast } from "sonner";
import { openclawApi } from "@/lib/api/openclaw";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { OpenClawToolsConfig } from "@/types";
const PROFILE_OPTIONS = ["default", "strict", "permissive", "custom"];
const ToolsPanel: React.FC = () => {
const { t } = useTranslation();
const [config, setConfig] = useState<OpenClawToolsConfig>({});
const [allowList, setAllowList] = useState<string[]>([]);
const [denyList, setDenyList] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const loadTools = useCallback(async () => {
try {
setLoading(true);
const tools = await openclawApi.getTools();
setConfig(tools);
setAllowList(tools.allow ?? []);
setDenyList(tools.deny ?? []);
} catch (err) {
toast.error(t("openclaw.tools.loadFailed"));
console.error("Failed to load tools config:", err);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadTools();
}, [loadTools]);
const handleSave = async () => {
try {
setSaving(true);
const { profile, allow, deny, ...other } = config;
const newConfig: OpenClawToolsConfig = {
...other,
profile: config.profile,
allow: allowList.filter((s) => s.trim()),
deny: denyList.filter((s) => s.trim()),
};
await openclawApi.setTools(newConfig);
toast.success(t("openclaw.tools.saveSuccess"));
await loadTools();
} catch (err) {
toast.error(t("openclaw.tools.saveFailed"));
console.error("Failed to save tools config:", err);
} finally {
setSaving(false);
}
};
const updateListItem = (
list: string[],
setList: React.Dispatch<React.SetStateAction<string[]>>,
index: number,
value: string,
) => {
setList(list.map((item, i) => (i === index ? value : item)));
};
const removeListItem = (
list: string[],
setList: React.Dispatch<React.SetStateAction<string[]>>,
index: number,
) => {
setList(list.filter((_, i) => i !== index));
};
if (loading) {
return (
<div className="px-6 pt-4 pb-8 flex items-center justify-center min-h-[200px]">
<div className="text-sm text-muted-foreground">
{t("common.loading")}
</div>
</div>
);
}
return (
<div className="px-6 pt-4 pb-8">
<p className="text-sm text-muted-foreground mb-6">
{t("openclaw.tools.description")}
</p>
{/* Profile selector */}
<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 }))
}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROFILE_OPTIONS.map((opt) => (
<SelectItem key={opt} value={opt}>
{t(`openclaw.tools.profiles.${opt}`)}
</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">
{allowList.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={item}
onChange={(e) =>
updateListItem(allowList, setAllowList, index, e.target.value)
}
placeholder={t("openclaw.tools.patternPlaceholder")}
className="font-mono text-xs"
/>
<Button
variant="ghost"
size="icon"
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => removeListItem(allowList, setAllowList, index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => setAllowList((prev) => [...prev, ""])}
>
<Plus className="w-4 h-4 mr-1" />
{t("openclaw.tools.addAllow")}
</Button>
</div>
</div>
{/* Deny list */}
<div className="mb-6">
<Label className="mb-2 block">{t("openclaw.tools.denyList")}</Label>
<div className="space-y-2">
{denyList.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<Input
value={item}
onChange={(e) =>
updateListItem(denyList, setDenyList, index, e.target.value)
}
placeholder={t("openclaw.tools.patternPlaceholder")}
className="font-mono text-xs"
/>
<Button
variant="ghost"
size="icon"
className="flex-shrink-0 h-9 w-9 text-muted-foreground hover:text-destructive"
onClick={() => removeListItem(denyList, setDenyList, index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => setDenyList((prev) => [...prev, ""])}
>
<Plus className="w-4 h-4 mr-1" />
{t("openclaw.tools.addDeny")}
</Button>
</div>
</div>
{/* Save button */}
<div className="flex justify-end">
<Button size="sm" onClick={handleSave} disabled={saving}>
<Save className="w-4 h-4 mr-1" />
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
);
};
export default ToolsPanel;