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
+6 -1
View File
@@ -735,7 +735,12 @@ impl MultiAppConfig {
let mut conflicts = Vec::new();
// 收集所有应用的 MCP
for app in [AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode] {
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
] {
let old_servers = match app {
AppType::Claude => &self.mcp.claude.servers,
AppType::Codex => &self.mcp.codex.servers,
+2
View File
@@ -9,6 +9,7 @@ mod import_export;
mod mcp;
mod misc;
mod omo;
mod openclaw;
mod plugin;
mod prompt;
mod provider;
@@ -31,6 +32,7 @@ pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use omo::*;
pub use openclaw::*;
pub use plugin::*;
pub use prompt::*;
pub use provider::*;
+108
View File
@@ -0,0 +1,108 @@
use std::collections::HashMap;
use tauri::State;
use crate::openclaw_config;
use crate::store::AppState;
// ============================================================================
// OpenClaw Provider Commands (migrated from provider.rs)
// ============================================================================
/// Import providers from OpenClaw live config to database.
///
/// OpenClaw uses additive mode — users may already have providers
/// configured in openclaw.json.
#[tauri::command]
pub fn import_openclaw_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_openclaw_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// Get provider IDs in the OpenClaw live config.
#[tauri::command]
pub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {
openclaw_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
// ============================================================================
// Agents Configuration Commands
// ============================================================================
/// Get OpenClaw default model config (agents.defaults.model)
#[tauri::command]
pub fn get_openclaw_default_model() -> Result<Option<openclaw_config::OpenClawDefaultModel>, String>
{
openclaw_config::get_default_model().map_err(|e| e.to_string())
}
/// Set OpenClaw default model config (agents.defaults.model)
#[tauri::command]
pub fn set_openclaw_default_model(
model: openclaw_config::OpenClawDefaultModel,
) -> Result<(), String> {
openclaw_config::set_default_model(&model).map_err(|e| e.to_string())
}
/// Get OpenClaw model catalog/allowlist (agents.defaults.models)
#[tauri::command]
pub fn get_openclaw_model_catalog(
) -> Result<Option<HashMap<String, openclaw_config::OpenClawModelCatalogEntry>>, String> {
openclaw_config::get_model_catalog().map_err(|e| e.to_string())
}
/// Set OpenClaw model catalog/allowlist (agents.defaults.models)
#[tauri::command]
pub fn set_openclaw_model_catalog(
catalog: HashMap<String, openclaw_config::OpenClawModelCatalogEntry>,
) -> Result<(), String> {
openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())
}
/// Get full agents.defaults config (all fields)
#[tauri::command]
pub fn get_openclaw_agents_defaults(
) -> Result<Option<openclaw_config::OpenClawAgentsDefaults>, String> {
openclaw_config::get_agents_defaults().map_err(|e| e.to_string())
}
/// Set full agents.defaults config (all fields)
#[tauri::command]
pub fn set_openclaw_agents_defaults(
defaults: openclaw_config::OpenClawAgentsDefaults,
) -> Result<(), String> {
openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string())
}
// ============================================================================
// Env Configuration Commands
// ============================================================================
/// Get OpenClaw env config (env section of openclaw.json)
#[tauri::command]
pub fn get_openclaw_env() -> Result<openclaw_config::OpenClawEnvConfig, String> {
openclaw_config::get_env_config().map_err(|e| e.to_string())
}
/// Set OpenClaw env config (env section of openclaw.json)
#[tauri::command]
pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> {
openclaw_config::set_env_config(&env).map_err(|e| e.to_string())
}
// ============================================================================
// Tools Configuration Commands
// ============================================================================
/// Get OpenClaw tools config (tools section of openclaw.json)
#[tauri::command]
pub fn get_openclaw_tools() -> Result<openclaw_config::OpenClawToolsConfig, String> {
openclaw_config::get_tools_config().map_err(|e| e.to_string())
}
/// Set OpenClaw tools config (tools section of openclaw.json)
#[tauri::command]
pub fn set_openclaw_tools(tools: openclaw_config::OpenClawToolsConfig) -> Result<(), String> {
openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string())
}
+1 -56
View File
@@ -320,60 +320,5 @@ pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
}
// ============================================================================
// OpenClaw 专属命令
// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs
// ============================================================================
/// 从 OpenClaw live 配置导入供应商到数据库
///
/// 这是 OpenClaw 特有的功能,因为 OpenClaw 使用累加模式,
/// 用户可能已经在 openclaw.json 中配置了供应商。
#[tauri::command]
pub fn import_openclaw_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_openclaw_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// 获取 OpenClaw live 配置中的供应商 ID 列表
///
/// 用于前端判断供应商是否已添加到 openclaw.json
#[tauri::command]
pub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {
crate::openclaw_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
// ============================================================================
// OpenClaw Agents Configuration Commands
// ============================================================================
/// 获取 OpenClaw 默认模型配置(agents.defaults.model
#[tauri::command]
pub fn get_openclaw_default_model(
) -> Result<Option<crate::openclaw_config::OpenClawDefaultModel>, String> {
crate::openclaw_config::get_default_model().map_err(|e| e.to_string())
}
/// 设置 OpenClaw 默认模型配置(agents.defaults.model
#[tauri::command]
pub fn set_openclaw_default_model(
model: crate::openclaw_config::OpenClawDefaultModel,
) -> Result<(), String> {
crate::openclaw_config::set_default_model(&model).map_err(|e| e.to_string())
}
/// 获取 OpenClaw 模型目录/允许列表(agents.defaults.models
#[tauri::command]
pub fn get_openclaw_model_catalog(
) -> Result<Option<std::collections::HashMap<String, crate::openclaw_config::OpenClawModelCatalogEntry>>, String>
{
crate::openclaw_config::get_model_catalog().map_err(|e| e.to_string())
}
/// 设置 OpenClaw 模型目录/允许列表(agents.defaults.models
#[tauri::command]
pub fn set_openclaw_model_catalog(
catalog: std::collections::HashMap<String, crate::openclaw_config::OpenClawModelCatalogEntry>,
) -> Result<(), String> {
crate::openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())
}
+7 -1
View File
@@ -13,8 +13,8 @@ mod gemini_config;
mod gemini_mcp;
mod init_status;
mod mcp;
mod opencode_config;
mod openclaw_config;
mod opencode_config;
mod panic_hook;
mod prompt;
mod prompt_files;
@@ -1013,6 +1013,12 @@ pub fn run() {
commands::set_openclaw_default_model,
commands::get_openclaw_model_catalog,
commands::set_openclaw_model_catalog,
commands::get_openclaw_agents_defaults,
commands::set_openclaw_agents_defaults,
commands::get_openclaw_env,
commands::set_openclaw_env,
commands::get_openclaw_tools,
commands::set_openclaw_tools,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
+129 -6
View File
@@ -220,12 +220,8 @@ pub fn read_openclaw_config() -> Result<Value, AppError> {
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
// 尝试 JSON5 解析(支持注释和尾随逗号)
json5::from_str(&content).map_err(|e| {
AppError::Config(format!(
"Failed to parse OpenClaw config as JSON5: {}",
e
))
})
json5::from_str(&content)
.map_err(|e| AppError::Config(format!("Failed to parse OpenClaw config as JSON5: {}", e)))
}
/// 写入 OpenClaw 配置文件(原子写入)
@@ -420,3 +416,130 @@ fn ensure_agents_defaults_path(config: &mut Value) {
config["agents"]["defaults"] = json!({});
}
}
// ============================================================================
// Full Agents Defaults Functions
// ============================================================================
/// Read the full agents.defaults config
pub fn get_agents_defaults() -> Result<Option<OpenClawAgentsDefaults>, AppError> {
let config = read_openclaw_config()?;
let Some(defaults_value) = config.get("agents").and_then(|a| a.get("defaults")) else {
return Ok(None);
};
let defaults = serde_json::from_value(defaults_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse agents.defaults: {e}")))?;
Ok(Some(defaults))
}
/// Write the full agents.defaults config
pub fn set_agents_defaults(defaults: &OpenClawAgentsDefaults) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
if config.get("agents").is_none() {
config["agents"] = json!({});
}
let value =
serde_json::to_value(defaults).map_err(|e| AppError::JsonSerialize { source: e })?;
config["agents"]["defaults"] = value;
write_openclaw_config(&config)
}
// ============================================================================
// Env Configuration
// ============================================================================
/// OpenClaw env configuration (env section of openclaw.json)
///
/// Stores environment variables like API keys and custom vars.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawEnvConfig {
/// All environment variable key-value pairs
#[serde(flatten)]
pub vars: HashMap<String, Value>,
}
/// Read the env config section
pub fn get_env_config() -> Result<OpenClawEnvConfig, AppError> {
let config = read_openclaw_config()?;
let Some(env_value) = config.get("env") else {
return Ok(OpenClawEnvConfig {
vars: HashMap::new(),
});
};
serde_json::from_value(env_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse env config: {e}")))
}
/// Write the env config section
pub fn set_env_config(env: &OpenClawEnvConfig) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
let value = serde_json::to_value(env).map_err(|e| AppError::JsonSerialize { source: e })?;
config["env"] = value;
write_openclaw_config(&config)
}
// ============================================================================
// Tools Configuration
// ============================================================================
/// OpenClaw tools configuration (tools section of openclaw.json)
///
/// Controls tool permissions with profile-based allow/deny lists.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenClawToolsConfig {
/// Active permission profile (e.g. "default", "strict", "permissive")
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
/// Allowed tool patterns
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
/// Denied tool patterns
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny: Vec<String>,
/// Other custom fields (preserve unknown fields)
#[serde(flatten)]
pub other: HashMap<String, Value>,
}
/// Read the tools config section
pub fn get_tools_config() -> Result<OpenClawToolsConfig, AppError> {
let config = read_openclaw_config()?;
let Some(tools_value) = config.get("tools") else {
return Ok(OpenClawToolsConfig {
profile: None,
allow: Vec::new(),
deny: Vec::new(),
other: HashMap::new(),
});
};
serde_json::from_value(tools_value.clone())
.map_err(|e| AppError::Config(format!("Failed to parse tools config: {e}")))
}
/// Write the tools config section
pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<(), AppError> {
let mut config = read_openclaw_config()?;
let value = serde_json::to_value(tools).map_err(|e| AppError::JsonSerialize { source: e })?;
config["tools"] = value;
write_openclaw_config(&config)
}
+1 -1
View File
@@ -5,8 +5,8 @@ use crate::codex_config::get_codex_auth_path;
use crate::config::get_claude_settings_path;
use crate::error::AppError;
use crate::gemini_config::get_gemini_dir;
use crate::opencode_config::get_opencode_dir;
use crate::openclaw_config::get_openclaw_dir;
use crate::opencode_config::get_opencode_dir;
/// 返回指定应用所使用的提示词文件路径。
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
+4 -1
View File
@@ -216,7 +216,10 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|| provider.settings_config.get("api").is_some()
|| provider.settings_config.get("models").is_some()
{
openclaw_config::set_provider(&provider.id, provider.settings_config.clone())?;
openclaw_config::set_provider(
&provider.id,
provider.settings_config.clone(),
)?;
log::info!(
"OpenClaw provider '{}' written as raw JSON to live config",
provider.id
+2 -2
View File
@@ -21,8 +21,8 @@ use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{
import_default_config, import_openclaw_providers_from_live, import_opencode_providers_from_live,
read_live_settings, sync_current_to_live,
import_default_config, import_openclaw_providers_from_live,
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
};
// Internal re-exports (pub(crate))
+61 -10
View File
@@ -17,6 +17,9 @@ import {
FolderArchive,
Search,
FolderOpen,
KeyRound,
Shield,
Cpu,
} from "lucide-react";
import type { Provider, VisibleApps } from "@/types";
import type { EnvConflict } from "@/types/env";
@@ -58,6 +61,9 @@ import { Button } from "@/components/ui/button";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import { useDisableCurrentOmo } from "@/lib/query/omo";
import WorkspaceFilesPanel from "@/components/workspace/WorkspaceFilesPanel";
import EnvPanel from "@/components/openclaw/EnvPanel";
import ToolsPanel from "@/components/openclaw/ToolsPanel";
import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel";
type View =
| "providers"
@@ -69,7 +75,10 @@ type View =
| "agents"
| "universal"
| "sessions"
| "workspace";
| "workspace"
| "openclawEnv"
| "openclawTools"
| "openclawAgents";
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const HEADER_HEIGHT = 64; // px
@@ -104,6 +113,9 @@ const VALID_VIEWS: View[] = [
"universal",
"sessions",
"workspace",
"openclawEnv",
"openclawTools",
"openclawAgents",
];
const getInitialView = (): View => {
@@ -637,6 +649,12 @@ function App() {
return <SessionManagerPage />;
case "workspace":
return <WorkspaceFilesPanel />;
case "openclawEnv":
return <EnvPanel />;
case "openclawTools":
return <ToolsPanel />;
case "openclawAgents":
return <AgentsDefaultsPanel />;
default:
return (
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
@@ -796,6 +814,10 @@ function App() {
})}
{currentView === "sessions" && t("sessionManager.title")}
{currentView === "workspace" && t("workspace.title")}
{currentView === "openclawEnv" && t("openclaw.env.title")}
{currentView === "openclawTools" && t("openclaw.tools.title")}
{currentView === "openclawAgents" &&
t("openclaw.agents.title")}
</h1>
</div>
) : (
@@ -975,15 +997,44 @@ function App() {
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
{activeApp === "openclaw" ? (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("workspace")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("workspace.manage")}
>
<FolderOpen className="w-4 h-4" />
</Button>
<>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("workspace")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("workspace.manage")}
>
<FolderOpen className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawEnv")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.env.title")}
>
<KeyRound className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawTools")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.tools.title")}
>
<Shield className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("openclawAgents")}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("openclaw.agents.title")}
>
<Cpu className="w-4 h-4" />
</Button>
</>
) : (
<>
<Button
@@ -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;
+70 -3
View File
@@ -1,12 +1,25 @@
import { invoke } from "@tauri-apps/api/core";
import type { OpenClawDefaultModel, OpenClawModelCatalogEntry } from "@/types";
import type {
OpenClawDefaultModel,
OpenClawModelCatalogEntry,
OpenClawAgentsDefaults,
OpenClawEnvConfig,
OpenClawToolsConfig,
} from "@/types";
/**
* OpenClaw agents configuration API
* OpenClaw configuration API
*
* Manages agents.defaults configuration in ~/.openclaw/openclaw.json
* Manages ~/.openclaw/openclaw.json sections:
* - agents.defaults (model, catalog)
* - env (environment variables)
* - tools (permissions)
*/
export const openclawApi = {
// ============================================================
// Agents Configuration
// ============================================================
/**
* Get default model configuration (agents.defaults.model)
*/
@@ -40,6 +53,24 @@ export const openclawApi = {
return await invoke("set_openclaw_model_catalog", { catalog });
},
/**
* Get full agents.defaults config (all fields)
*/
async getAgentsDefaults(): Promise<OpenClawAgentsDefaults | null> {
return await invoke("get_openclaw_agents_defaults");
},
/**
* Set full agents.defaults config (all fields)
*/
async setAgentsDefaults(defaults: OpenClawAgentsDefaults): Promise<void> {
return await invoke("set_openclaw_agents_defaults", { defaults });
},
// ============================================================
// Provider Import
// ============================================================
/**
* Import providers from live config (openclaw.json) to database
*/
@@ -53,4 +84,40 @@ export const openclawApi = {
async getLiveProviderIds(): Promise<string[]> {
return await invoke("get_openclaw_live_provider_ids");
},
// ============================================================
// Env Configuration
// ============================================================
/**
* Get env config (env section of openclaw.json)
*/
async getEnv(): Promise<OpenClawEnvConfig> {
return await invoke("get_openclaw_env");
},
/**
* Set env config (env section of openclaw.json)
*/
async setEnv(env: OpenClawEnvConfig): Promise<void> {
return await invoke("set_openclaw_env", { env });
},
// ============================================================
// Tools Configuration
// ============================================================
/**
* Get tools config (tools section of openclaw.json)
*/
async getTools(): Promise<OpenClawToolsConfig> {
return await invoke("get_openclaw_tools");
},
/**
* Set tools config (tools section of openclaw.json)
*/
async setTools(tools: OpenClawToolsConfig): Promise<void> {
return await invoke("set_openclaw_tools", { tools });
},
};
+20
View File
@@ -467,3 +467,23 @@ export interface OpenClawProviderConfig {
api?: string; // API 协议类型(如 "openai-completions"、"anthropic"
models?: OpenClawModel[]; // 可用模型列表
}
// OpenClaw agents.defaults 完整配置
export interface OpenClawAgentsDefaults {
model?: OpenClawDefaultModel;
models?: Record<string, OpenClawModelCatalogEntry>;
[key: string]: unknown; // preserve unknown fields
}
// OpenClaw env 配置(openclaw.json 的 env 节点)
export interface OpenClawEnvConfig {
[key: string]: unknown;
}
// OpenClaw tools 配置(openclaw.json 的 tools 节点)
export interface OpenClawToolsConfig {
profile?: string;
allow?: string[];
deny?: string[];
[key: string]: unknown; // preserve unknown fields
}