mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user