feat: enhance settings UI, add gateway/config management commands, remove testing tab, and clean up dashboard logs

This commit is contained in:
MrFadiAi
2026-02-20 01:44:32 +01:00
parent 7f4c332aa8
commit e31c35849b
15 changed files with 501 additions and 583 deletions
+12 -2
View File
@@ -1,15 +1,16 @@
{
"name": "openclaw-manager",
"version": "0.0.11",
"version": "0.0.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openclaw-manager",
"version": "0.0.11",
"version": "0.0.12",
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-process": "^2.3.1",
@@ -1443,6 +1444,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
+2 -1
View File
@@ -15,6 +15,7 @@
},
"dependencies": {
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-process": "^2.3.1",
@@ -38,4 +39,4 @@
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}
}
+1
View File
@@ -13,6 +13,7 @@ tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-fs = "2"
tauri-plugin-process = "2"
tauri-plugin-dialog = "2"
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+2 -1
View File
@@ -17,6 +17,7 @@
"fs:allow-write",
"process:default",
"notification:default",
"updater:default"
"updater:default",
"dialog:default"
]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"OpenClaw Manager 默认权限配置","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","shell:allow-spawn","shell:allow-stdin-write","shell:allow-kill","fs:default","fs:allow-read","fs:allow-write","process:default","notification:default","updater:default"]}}
{"default":{"identifier":"default","description":"OpenClaw Manager 默认权限配置","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","shell:allow-execute","shell:allow-spawn","shell:allow-stdin-write","shell:allow-kill","fs:default","fs:allow-read","fs:allow-write","process:default","notification:default","updater:default","dialog:default"]}}
+66
View File
@@ -4154,6 +4154,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This set of permissions describes the what kind of\nfile system access the `fs` plugin has enabled or denied by default.\n\n#### Granted Permissions\n\nThis default permission set enables read access to the\napplication specific directories (AppConfig, AppData, AppLocalData, AppCache,\nAppLog) and all files and sub directories created in it.\nThe location of these directories depends on the operating system,\nwhere the application is run.\n\nIn general these directories need to be manually created\nby the application at runtime, before accessing files or folders\nin it is possible.\n\nTherefore, it is also allowed to create all of these folders via\nthe `mkdir` command.\n\n#### Denied Permissions\n\nThis default permission set prevents access to critical components\nof the Tauri application by default.\nOn Windows the webview data folder access is denied.\n\n#### This default permission set includes:\n\n- `create-app-specific-dirs`\n- `read-app-specific-dirs-recursive`\n- `deny-default`",
"type": "string",
+66
View File
@@ -4154,6 +4154,72 @@
"const": "core:window:deny-unminimize",
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
},
{
"description": "Enables the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-message",
"markdownDescription": "Enables the message command without any pre-configured scope."
},
{
"description": "Enables the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-open",
"markdownDescription": "Enables the open command without any pre-configured scope."
},
{
"description": "Enables the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:allow-save",
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
},
{
"description": "Denies the message command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-message",
"markdownDescription": "Denies the message command without any pre-configured scope."
},
{
"description": "Denies the open command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-open",
"markdownDescription": "Denies the open command without any pre-configured scope."
},
{
"description": "Denies the save command without any pre-configured scope.",
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This set of permissions describes the what kind of\nfile system access the `fs` plugin has enabled or denied by default.\n\n#### Granted Permissions\n\nThis default permission set enables read access to the\napplication specific directories (AppConfig, AppData, AppLocalData, AppCache,\nAppLog) and all files and sub directories created in it.\nThe location of these directories depends on the operating system,\nwhere the application is run.\n\nIn general these directories need to be manually created\nby the application at runtime, before accessing files or folders\nin it is possible.\n\nTherefore, it is also allowed to create all of these folders via\nthe `mkdir` command.\n\n#### Denied Permissions\n\nThis default permission set prevents access to critical components\nof the Tauri application by default.\nOn Windows the webview data folder access is denied.\n\n#### This default permission set includes:\n\n- `create-app-specific-dirs`\n- `read-app-specific-dirs-recursive`\n- `deny-default`",
"type": "string",
+85
View File
@@ -3498,6 +3498,91 @@ pub async fn save_web_config(brave_api_key: Option<String>) -> Result<String, St
}
}
save_openclaw_config(&config)?;
Ok("Web search configuration saved".to_string())
}
// ============ Gateway Configuration ============
/// Gateway configuration for frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GatewayConfig {
pub port: u16,
pub log_level: String,
}
/// Get gateway configuration
#[command]
pub async fn get_gateway_config() -> Result<GatewayConfig, String> {
info!("[Gateway] Getting gateway config...");
let config = load_openclaw_config()?;
let port = config.pointer("/gateway/port")
.and_then(|v| v.as_u64())
.map(|v| v as u16)
.unwrap_or(3000);
let log_level = config.pointer("/gateway/logLevel")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "info".to_string());
Ok(GatewayConfig { port, log_level })
}
/// Save gateway configuration
#[command]
pub async fn save_gateway_config(port: u16, log_level: String) -> Result<String, String> {
info!("[Gateway] Saving gateway config: port={}, level={}", port, log_level);
let mut config = load_openclaw_config()?;
if config.get("gateway").is_none() {
config["gateway"] = json!({});
}
if let Some(gateway) = config.get_mut("gateway").and_then(|v| v.as_object_mut()) {
gateway.insert("port".to_string(), json!(port));
gateway.insert("logLevel".to_string(), json!(log_level));
}
save_openclaw_config(&config)?;
Ok("Gateway configuration saved".to_string())
}
// ============ Configuration Management ============
/// Export configuration
#[command]
pub async fn export_config(path: String) -> Result<String, String> {
info!("[Config] Exporting config to: {}", path);
let config = load_openclaw_config()?;
let content = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
file::write_file(&path, &content)
.map_err(|e| format!("Failed to write export file: {}", e))?;
Ok(format!("Configuration exported to {}", path))
}
/// Import configuration
#[command]
pub async fn import_config(path: String) -> Result<String, String> {
info!("[Config] Importing config from: {}", path);
let content = file::read_file(&path)
.map_err(|e| format!("Failed to read import file: {}", e))?;
let new_config: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Invalid JSON file: {}", e))?;
if !new_config.is_object() {
return Err("Imported file is not a valid configuration object".to_string());
}
save_openclaw_config(&new_config)?;
Ok("Configuration imported successfully".to_string())
}
+7
View File
@@ -34,6 +34,7 @@ fn main() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.invoke_handler(tauri::generate_handler![
// Service management
@@ -139,6 +140,12 @@ fn main() {
// Web Search
config::get_web_config,
config::save_web_config,
// Gateway Configuration
config::get_gateway_config,
config::save_gateway_config,
// Configuration Management
config::export_config,
config::import_config,
])
.run(tauri::generate_context!())
.expect("Error occurred while running Tauri application");
+3 -3
View File
@@ -9,7 +9,7 @@ import { Channels } from './components/Channels';
import { MCP } from './components/MCP';
import { Skills } from './components/Skills';
import { Settings } from './components/Settings';
import { Testing } from './components/Testing';
import { Logs } from './components/Logs';
import { appLogger } from './lib/logger';
import { isTauri } from './lib/tauri';
@@ -17,7 +17,7 @@ import { Download, X, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { Agents } from './components/Agents';
export type PageType = 'dashboard' | 'mcp' | 'skills' | 'ai' | 'channels' | 'agents' | 'testing' | 'logs' | 'settings';
export type PageType = 'dashboard' | 'mcp' | 'skills' | 'ai' | 'channels' | 'agents' | 'logs' | 'settings';
export interface EnvironmentStatus {
node_installed: boolean;
@@ -254,7 +254,7 @@ function App() {
ai: <AIConfig />,
channels: <Channels />,
agents: <Agents />,
testing: <Testing />,
logs: <Logs />,
settings: <Settings onEnvironmentChange={checkEnvironment} />,
};
+2 -187
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { invoke } from '@tauri-apps/api/core';
import { StatusCard } from './StatusCard';
@@ -6,8 +6,6 @@ import { QuickActions } from './QuickActions';
import { SystemInfo } from './SystemInfo';
import { Setup } from '../Setup';
import { api, ServiceStatus, isTauri } from '../../lib/tauri';
import { Terminal, RefreshCw, ChevronDown, ChevronUp, AlertTriangle, Wrench, Loader2 } from 'lucide-react';
import clsx from 'clsx';
import { EnvironmentStatus } from '../../App';
interface DashboardProps {
@@ -19,13 +17,6 @@ export function Dashboard({ envStatus, onSetupComplete }: DashboardProps) {
const [status, setStatus] = useState<ServiceStatus | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
const [logsExpanded, setLogsExpanded] = useState(true);
const [autoRefreshLogs, setAutoRefreshLogs] = useState(true);
const logsEndRef = useRef<HTMLDivElement>(null);
const [tokenMismatch, setTokenMismatch] = useState(false);
const [repairing, setRepairing] = useState(false);
const [repairDismissed, setRepairDismissed] = useState(false);
const fetchStatus = async () => {
if (!isTauri()) {
@@ -42,43 +33,16 @@ export function Dashboard({ envStatus, onSetupComplete }: DashboardProps) {
}
};
const fetchLogs = async () => {
if (!isTauri()) return;
try {
const result = await invoke<string[]>('get_logs', { lines: 50 });
setLogs(result);
} catch {
// Handle silently
}
};
useEffect(() => {
fetchStatus();
fetchLogs();
if (!isTauri()) return;
const statusInterval = setInterval(fetchStatus, 3000);
const logsInterval = autoRefreshLogs ? setInterval(fetchLogs, 2000) : null;
return () => {
clearInterval(statusInterval);
if (logsInterval) clearInterval(logsInterval);
};
}, [autoRefreshLogs]);
// Detect device_token_mismatch from logs
useEffect(() => {
if (repairDismissed) return;
const hasMismatch = logs.some(line => line.includes('device_token_mismatch'));
setTokenMismatch(hasMismatch);
}, [logs, repairDismissed]);
// Auto scroll to bottom of logs
useEffect(() => {
if (logsExpanded && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs, logsExpanded]);
}, []);
const handleStart = async () => {
if (!isTauri()) return;
@@ -86,7 +50,6 @@ export function Dashboard({ envStatus, onSetupComplete }: DashboardProps) {
try {
await api.startService();
await fetchStatus();
await fetchLogs();
} catch (e) {
console.error('Start failed:', e);
} finally {
@@ -100,7 +63,6 @@ export function Dashboard({ envStatus, onSetupComplete }: DashboardProps) {
try {
await api.stopService();
await fetchStatus();
await fetchLogs();
} catch (e) {
console.error('Stop failed:', e);
} finally {
@@ -114,7 +76,6 @@ export function Dashboard({ envStatus, onSetupComplete }: DashboardProps) {
try {
await api.restartService();
await fetchStatus();
await fetchLogs();
} catch (e) {
console.error('Restart failed:', e);
} finally {
@@ -128,7 +89,6 @@ export function Dashboard({ envStatus, onSetupComplete }: DashboardProps) {
try {
await invoke<string>('kill_all_port_processes');
await fetchStatus();
await fetchLogs();
} catch (e) {
console.error('Kill All failed:', e);
} finally {
@@ -136,37 +96,6 @@ export function Dashboard({ envStatus, onSetupComplete }: DashboardProps) {
}
};
const handleRepairDeviceToken = async () => {
if (!isTauri()) return;
setRepairing(true);
try {
await invoke<string>('repair_device_token');
await api.restartService();
setRepairDismissed(true);
setTokenMismatch(false);
await fetchStatus();
await fetchLogs();
} catch (e) {
console.error('Device token repair failed:', e);
alert(`Repair failed: ${e}`);
} finally {
setRepairing(false);
}
};
const getLogLineClass = (line: string) => {
if (line.includes('error') || line.includes('Error') || line.includes('ERROR')) {
return 'text-red-400';
}
if (line.includes('warn') || line.includes('Warn') || line.includes('WARN')) {
return 'text-yellow-400';
}
if (line.includes('info') || line.includes('Info') || line.includes('INFO')) {
return 'text-green-400';
}
return 'text-gray-400';
};
const containerVariants = {
hidden: { opacity: 0 },
show: {
@@ -200,45 +129,6 @@ export function Dashboard({ envStatus, onSetupComplete }: DashboardProps) {
</motion.div>
)}
{/* Device token mismatch warning banner */}
{tokenMismatch && !repairDismissed && (
<motion.div variants={itemVariants}>
<div className="bg-amber-500/10 rounded-2xl p-5 border border-amber-500/30">
<div className="flex items-start gap-4">
<div className="p-2 bg-amber-500/20 rounded-lg shrink-0 mt-0.5">
<AlertTriangle size={20} className="text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-amber-300 mb-1">
Device Token Mismatch Detected
</h3>
<p className="text-xs text-amber-200/70 leading-relaxed">
The gateway is rejecting connections because the device identity is out of sync.
This usually happens after a reinstall or config migration. Click the button below
to reset the device identity and restart the service.
</p>
</div>
<button
onClick={handleRepairDeviceToken}
disabled={repairing}
className={clsx(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all shrink-0',
'bg-amber-500/20 text-amber-300 border border-amber-500/40',
'hover:bg-amber-500/30 disabled:opacity-50'
)}
>
{repairing ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Wrench size={14} />
)}
{repairing ? 'Fixing...' : 'Fix & Restart'}
</button>
</div>
</div>
</motion.div>
)}
{/* Service status card */}
<motion.div variants={itemVariants}>
<StatusCard status={status} loading={loading} />
@@ -256,81 +146,6 @@ export function Dashboard({ envStatus, onSetupComplete }: DashboardProps) {
/>
</motion.div>
{/* Real-time logs */}
<motion.div variants={itemVariants}>
<div className="bg-dark-700 rounded-2xl border border-dark-500 overflow-hidden">
{/* Log title bar */}
<div
className="flex items-center justify-between px-4 py-3 bg-dark-600/50 cursor-pointer"
onClick={() => setLogsExpanded(!logsExpanded)}
>
<div className="flex items-center gap-2">
<Terminal size={16} className="text-gray-500" />
<span className="text-sm font-medium text-white">Real-time Logs</span>
<span className="text-xs text-gray-500">
({logs.length} lines)
</span>
</div>
<div className="flex items-center gap-3">
{logsExpanded && (
<>
<label
className="flex items-center gap-2 text-xs text-gray-400"
onClick={e => e.stopPropagation()}
>
<input
type="checkbox"
checked={autoRefreshLogs}
onChange={(e) => setAutoRefreshLogs(e.target.checked)}
className="w-3 h-3 rounded border-dark-500 bg-dark-600 text-claw-500"
/>
Auto Refresh
</label>
<button
onClick={(e) => {
e.stopPropagation();
fetchLogs();
}}
className="text-gray-500 hover:text-white"
title="Refresh logs"
>
<RefreshCw size={14} />
</button>
</>
)}
{logsExpanded ? (
<ChevronUp size={16} className="text-gray-500" />
) : (
<ChevronDown size={16} className="text-gray-500" />
)}
</div>
</div>
{/* Log content */}
{logsExpanded && (
<div className="h-64 overflow-y-auto p-4 font-mono text-xs leading-relaxed bg-dark-800">
{logs.length === 0 ? (
<div className="h-full flex items-center justify-center text-gray-500">
<p>No logs yet, please start the service first</p>
</div>
) : (
<>
{logs.map((line, index) => (
<div
key={index}
className={clsx('py-0.5 whitespace-pre-wrap break-all', getLogLineClass(line))}
>
{line}
</div>
))}
<div ref={logsEndRef} />
</>
)}
</div>
)}
</div>
</motion.div>
{/* System info */}
<motion.div variants={itemVariants}>
<SystemInfo />
+1 -1
View File
@@ -15,7 +15,7 @@ const pageTitles: Record<PageType, { title: string; description: string }> = {
ai: { title: 'AI Model Configuration', description: 'Configure AI providers and models' },
channels: { title: 'Message Channels', description: 'Configure Telegram, Discord, Lark, etc.' },
agents: { title: 'Agent Routing', description: 'Manage agents and binding rules' },
testing: { title: 'Testing & Diagnostics', description: 'System diagnostics and troubleshooting' },
logs: { title: 'Application Logs', description: 'View Manager application console logs' },
settings: { title: 'Settings', description: 'Identity configuration and advanced options' },
};
+2 -2
View File
@@ -3,7 +3,7 @@ import {
LayoutDashboard,
Bot,
MessageSquare,
FlaskConical,
ScrollText,
Settings,
Blocks,
@@ -32,7 +32,7 @@ const menuItems: { id: PageType; label: string; icon: React.ElementType }[] = [
{ id: 'agents', label: 'Agents', icon: Users },
{ id: 'ai', label: 'AI Config', icon: Bot },
{ id: 'channels', label: 'Channels', icon: MessageSquare },
{ id: 'testing', label: 'Testing', icon: FlaskConical },
{ id: 'logs', label: 'Logs', icon: ScrollText },
{ id: 'settings', label: 'Settings', icon: Settings },
];
+250 -384
View File
@@ -1,24 +1,23 @@
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { save, open } from '@tauri-apps/plugin-dialog';
import {
User,
Shield,
Save,
Loader2,
FolderOpen,
FileCode,
Trash2,
AlertTriangle,
X,
Activity,
Globe,
FileText,
Download,
Upload,
RefreshCw,
CheckCircle,
ArrowUpCircle
ArrowUpCircle,
Database,
Clock,
Server,
FileJson
} from 'lucide-react';
import clsx from 'clsx';
import { appLogger } from '../../lib/logger';
import { isTauri } from '../../lib/tauri';
@@ -32,10 +31,13 @@ interface SettingsProps {
onEnvironmentChange?: () => void;
}
// Config Types
interface HeartbeatConfig {
every: string | null;
target: string | null;
interface BrowserConfig {
enabled: boolean;
color: string | null;
}
interface WebConfig {
brave_api_key: string | null;
}
interface CompactionConfig {
@@ -53,25 +55,16 @@ interface WorkspaceConfig {
bootstrap_max_chars: number | null;
}
interface BrowserConfig {
enabled: boolean;
color: string | null;
}
interface WebConfig {
brave_api_key: string | null;
interface GatewayConfig {
port: number;
log_level: string;
}
export function Settings({ onEnvironmentChange }: SettingsProps) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
// Existing states
const [identity, setIdentity] = useState({
botName: 'Clawd',
userName: 'Master',
timezone: 'Asia/Shanghai',
});
const [showUninstallConfirm, setShowUninstallConfirm] = useState(false);
const [uninstalling, setUninstalling] = useState(false);
const [uninstallResult, setUninstallResult] = useState<InstallResult | null>(null);
@@ -88,35 +81,30 @@ export function Settings({ onEnvironmentChange }: SettingsProps) {
const [managerUpdateObj, setManagerUpdateObj] = useState<any>(null);
const [managerCheckDone, setManagerCheckDone] = useState(false);
// New Feature States
const [heartbeat, setHeartbeat] = useState<HeartbeatConfig>({ every: null, target: null });
const [compaction, setCompaction] = useState<CompactionConfig>({ enabled: false, threshold: null, context_pruning: false, max_context_messages: null });
const [workspace, setWorkspace] = useState<WorkspaceConfig>({ workspace: null, timezone: null, time_format: null, skip_bootstrap: false, bootstrap_max_chars: null });
// Config States
const [browser, setBrowser] = useState<BrowserConfig>({ enabled: true, color: null });
const [webConfig, setWebConfig] = useState<WebConfig>({ brave_api_key: null });
// Personality Editor State
const [selectedFile, setSelectedFile] = useState<'AGENTS.md' | 'SOUL.md' | 'TOOLS.md'>('AGENTS.md');
const [fileContent, setFileContent] = useState('');
const [fileLoading, setFileLoading] = useState(false);
const [compaction, setCompaction] = useState<CompactionConfig>({ enabled: false, threshold: null, context_pruning: false, max_context_messages: null });
const [workspace, setWorkspace] = useState<WorkspaceConfig>({ workspace: null, timezone: null, time_format: null, skip_bootstrap: false, bootstrap_max_chars: null });
const [gateway, setGateway] = useState<GatewayConfig>({ port: 3000, log_level: 'info' });
// Load initial data
useEffect(() => {
const loadConfig = async () => {
setLoading(true);
try {
const [hb, cmp, ws, br, web] = await Promise.all([
invoke<HeartbeatConfig>('get_heartbeat_config'),
invoke<CompactionConfig>('get_compaction_config'),
invoke<WorkspaceConfig>('get_workspace_config'),
const [br, web, comp, ws, gw] = await Promise.all([
invoke<BrowserConfig>('get_browser_config'),
invoke<WebConfig>('get_web_config'),
invoke<CompactionConfig>('get_compaction_config'),
invoke<WorkspaceConfig>('get_workspace_config'),
invoke<GatewayConfig>('get_gateway_config'),
]);
setHeartbeat(hb);
setCompaction(cmp);
setWorkspace(ws);
setBrowser(br);
setWebConfig(web);
setCompaction(comp);
setWorkspace(ws);
setGateway(gw);
} catch (e) {
appLogger.error('Failed to load settings', e);
} finally {
@@ -126,29 +114,13 @@ export function Settings({ onEnvironmentChange }: SettingsProps) {
loadConfig();
}, []);
// Load personality file content when tab changes
useEffect(() => {
const loadFile = async () => {
setFileLoading(true);
try {
const content = await invoke<string>('get_personality_file', { filename: selectedFile });
setFileContent(content);
} catch (e) {
appLogger.error(`Failed to load ${selectedFile}`, e);
setFileContent('');
} finally {
setFileLoading(false);
}
};
loadFile();
}, [selectedFile]);
const handleSave = async () => {
setSaving(true);
setSaveSuccess(false);
try {
// Save all configurations in parallel
await Promise.all([
invoke('save_heartbeat_config', { every: heartbeat.every, target: heartbeat.target }),
invoke('save_browser_config', { enabled: browser.enabled, color: browser.color }),
invoke('save_web_config', { braveApiKey: webConfig.brave_api_key }),
invoke('save_compaction_config', {
enabled: compaction.enabled,
threshold: compaction.threshold,
@@ -162,20 +134,11 @@ export function Settings({ onEnvironmentChange }: SettingsProps) {
skipBootstrap: workspace.skip_bootstrap,
bootstrapMaxChars: workspace.bootstrap_max_chars
}),
invoke('save_browser_config', { enabled: browser.enabled, color: browser.color }),
invoke('save_web_config', { braveApiKey: webConfig.brave_api_key }),
invoke('save_personality_file', { filename: selectedFile, content: fileContent })
invoke('save_gateway_config', { port: gateway.port, logLevel: gateway.log_level }),
]);
// Show success feedback
const btn = document.getElementById('save-btn');
if (btn) {
// const originalText = btn.innerText; // Unused
btn.innerHTML = '<span class="flex items-center gap-2"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg> Saved!</span>';
setTimeout(() => {
btn.innerHTML = '<span class="flex items-center gap-2"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg> Save Settings</span>';
}, 2000);
}
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (e) {
console.error('Failed to save:', e);
alert('Failed to save settings: ' + String(e));
@@ -184,13 +147,46 @@ export function Settings({ onEnvironmentChange }: SettingsProps) {
}
};
const openConfigDir = async () => {
const handleExport = async () => {
try {
const { open } = await import('@tauri-apps/plugin-shell');
const home = await invoke<{ config_dir: string }>('get_system_info');
await open(home.config_dir);
const path = await save({
filters: [{
name: 'JSON',
extensions: ['json']
}],
defaultPath: 'openclaw-config.json'
});
if (path) {
await invoke('export_config', { path });
alert('Configuration exported successfully!');
}
} catch (e) {
console.error('Failed to open directory:', e);
console.error('Export failed:', e);
alert('Failed to export configuration: ' + String(e));
}
};
const handleImport = async () => {
if (!confirm('Importing configuration will overwrite your current settings. Continue?')) return;
try {
const path = await open({
filters: [{
name: 'JSON',
extensions: ['json']
}]
});
if (path) {
await invoke('import_config', { path });
alert('Configuration imported successfully! Please restart the manager to apply all changes.');
// Reload settings to reflect changes
window.location.reload();
}
} catch (e) {
console.error('Import failed:', e);
alert('Failed to import configuration: ' + String(e));
}
};
@@ -296,295 +292,187 @@ export function Settings({ onEnvironmentChange }: SettingsProps) {
<div className="h-full overflow-y-auto scroll-container pr-2 pb-20">
<div className="max-w-3xl space-y-6 mx-auto">
{/* Identity Configuration */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-claw-500/20 flex items-center justify-center">
<User size={20} className="text-claw-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Identity Configuration</h3>
<p className="text-xs text-gray-500">Set the AI assistant's name and how it addresses you</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">
AI Assistant Name
</label>
<input
type="text"
value={identity.botName}
onChange={(e) =>
setIdentity({ ...identity, botName: e.target.value })
}
placeholder="Clawd"
className="input-base"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">
Your Name
</label>
<input
type="text"
value={identity.userName}
onChange={(e) =>
setIdentity({ ...identity, userName: e.target.value })
}
placeholder="Master"
className="input-base"
/>
</div>
</div>
</div>
{/* Security Settings */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center">
<Shield size={20} className="text-amber-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Security Settings</h3>
<p className="text-xs text-gray-500">Permissions and access control</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-dark-600 rounded-lg">
<div>
<p className="text-sm text-white">Enable Whitelist</p>
<p className="text-xs text-gray-500">Only allow whitelisted users to access</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" />
<div className="w-11 h-6 bg-dark-500 peer-focus:ring-2 peer-focus:ring-claw-500/50 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-claw-500"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 bg-dark-600 rounded-lg">
<div>
<p className="text-sm text-white">File Access Permission</p>
<p className="text-xs text-gray-500">Allow AI to read and write local files</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" />
<div className="w-11 h-6 bg-dark-500 peer-focus:ring-2 peer-focus:ring-claw-500/50 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-claw-500"></div>
</label>
</div>
</div>
</div>
{/* Heartbeat & Compaction */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-red-500/20 flex items-center justify-center">
<Activity size={20} className="text-red-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Heartbeat & Compaction</h3>
<p className="text-xs text-gray-500">Manage agent lifecycle and memory optimization</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-300 border-b border-dark-600 pb-2">Heartbeat</h4>
<div>
<label className="block text-sm text-gray-400 mb-2">Interval (Every)</label>
<input
type="text"
value={heartbeat.every || ''}
onChange={e => setHeartbeat({ ...heartbeat, every: e.target.value || null })}
placeholder="e.g. 30m, 1h"
className="input-base"
/>
<p className="text-xs text-gray-500 mt-1">Leave empty to disable heartbeat.</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Target Channel</label>
<select
value={heartbeat.target || ''}
onChange={e => setHeartbeat({ ...heartbeat, target: e.target.value || null })}
className="input-base"
>
<option value="">None / Last Active</option>
<option value="whatsapp">WhatsApp</option>
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
</select>
</div>
</div>
<div className="space-y-4">
<h4 className="text-sm font-medium text-gray-300 border-b border-dark-600 pb-2">Memory</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Enable Compaction</span>
<input
type="checkbox"
checked={compaction.enabled}
onChange={e => setCompaction({ ...compaction, enabled: e.target.checked })}
className="w-4 h-4 rounded bg-dark-600 border-dark-500 text-claw-500 focus:ring-claw-500/50"
/>
</div>
{compaction.enabled && (
<div>
<label className="block text-xs text-gray-500 mb-1">Threshold (Tokens)</label>
<input
type="number"
value={compaction.threshold || ''}
onChange={e => setCompaction({ ...compaction, threshold: parseInt(e.target.value) || null })}
placeholder="e.g. 8000"
className="input-base text-sm py-1"
/>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-dark-600">
<span className="text-sm text-gray-400">Context Pruning</span>
<input
type="checkbox"
checked={compaction.context_pruning}
onChange={e => setCompaction({ ...compaction, context_pruning: e.target.checked })}
className="w-4 h-4 rounded bg-dark-600 border-dark-500 text-claw-500 focus:ring-claw-500/50"
/>
</div>
{compaction.context_pruning && (
<div>
<label className="block text-xs text-gray-500 mb-1">Max Messages</label>
<input
type="number"
value={compaction.max_context_messages || ''}
onChange={e => setCompaction({ ...compaction, max_context_messages: parseInt(e.target.value) || null })}
placeholder="e.g. 50"
className="input-base text-sm py-1"
/>
</div>
)}
</div>
</div>
</div>
</div>
{/* Workspace & Personality */}
{/* Compaction & Memory */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
<User size={20} className="text-purple-400" />
<Database size={20} className="text-purple-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Workspace & Personality</h3>
<p className="text-xs text-gray-500">Define agent identity and workspace settings</p>
<h3 className="text-lg font-semibold text-white">Compaction & Memory</h3>
<p className="text-xs text-gray-500">Manage agent memory optimization</p>
</div>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm text-gray-400 mb-2">Workspace Path</label>
<div className="flex gap-2">
<input
type="text"
value={workspace.workspace || ''}
onChange={e => setWorkspace({ ...workspace, workspace: e.target.value || null })}
placeholder="/path/to/custom/workspace"
className="input-base flex-1"
/>
<button title="Browse" className="btn-secondary px-3"><FolderOpen size={16} /></button>
</div>
<p className="text-xs text-gray-500 mt-1">Leave empty to use default (~/.openclaw)</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-dark-600 rounded-lg">
<div>
<label className="block text-sm text-gray-400 mb-2">Timezone</label>
<select
value={workspace.timezone || 'Asia/Shanghai'}
onChange={e => setWorkspace({ ...workspace, timezone: e.target.value })}
className="input-base"
>
<option value="Asia/Shanghai">Asia/Shanghai</option>
<option value="Asia/Hong_Kong">Asia/Hong_Kong</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="Europe/London">Europe/London</option>
<option value="UTC">UTC</option>
</select>
<p className="text-sm text-white">Enable Compaction</p>
<p className="text-xs text-gray-500">Compress conversation history when it gets too long</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Time Format</label>
<select
value={workspace.time_format || ''}
onChange={e => setWorkspace({ ...workspace, time_format: e.target.value || null })}
className="input-base"
>
<option value="">Default (24h)</option>
<option value="12h">12h (AM/PM)</option>
<option value="24h">24h</option>
</select>
</div>
</div>
<div className="flex items-center gap-4 pt-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="skipBootstrap"
checked={workspace.skip_bootstrap}
onChange={e => setWorkspace({ ...workspace, skip_bootstrap: e.target.checked })}
/>
<label htmlFor="skipBootstrap" className="text-sm text-gray-400">Skip Bootstrap</label>
</div>
<div>
<input
type="number"
value={workspace.bootstrap_max_chars || ''}
onChange={e => setWorkspace({ ...workspace, bootstrap_max_chars: parseInt(e.target.value) || null })}
placeholder="Max Chars"
className="input-base py-1 px-2 w-24 text-sm inline-block mr-2"
/>
<span className="text-xs text-gray-500">Bootstrap Max Chars</span>
</div>
</div>
</div>
{/* Configuration File Editor */}
<div className="border-t border-dark-600 pt-4">
<div className="flex gap-1 mb-2 bg-dark-800 p-1 rounded-lg w-fit">
{(['AGENTS.md', 'SOUL.md', 'TOOLS.md'] as const).map(file => (
<button
key={file}
onClick={() => setSelectedFile(file)}
className={clsx(
'px-3 py-1.5 rounded-md text-xs font-medium transition-colors flex items-center gap-2',
selectedFile === file ? 'bg-dark-600 text-white shadow-sm' : 'text-gray-400 hover:text-white'
)}
>
<FileText size={14} />
{file}
</button>
))}
</div>
<div className="relative">
{fileLoading && (
<div className="absolute inset-0 bg-dark-800/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
<Loader2 className="animate-spin text-claw-400" size={24} />
</div>
)}
<textarea
value={fileContent}
onChange={e => setFileContent(e.target.value)}
className="w-full h-64 bg-dark-800 border border-dark-600 rounded-lg p-4 font-mono text-sm text-gray-300 focus:ring-2 focus:ring-claw-500/50 focus:border-claw-500 outline-none resize-none"
placeholder={`Content for ${selectedFile}...`}
spellCheck={false}
<input
type="checkbox"
checked={compaction.enabled}
onChange={e => setCompaction({ ...compaction, enabled: e.target.checked })}
className="w-5 h-5 rounded bg-dark-500 border-dark-400 text-claw-500 focus:ring-claw-500/50"
/>
</div>
{compaction.enabled && (
<div className="pl-4 border-l-2 border-dark-600">
<label className="block text-sm text-gray-400 mb-2">Token Threshold</label>
<input
type="number"
value={compaction.threshold || ''}
onChange={e => setCompaction({ ...compaction, threshold: parseInt(e.target.value) || null })}
placeholder="e.g. 8000"
className="input-base"
/>
<p className="text-xs text-gray-500 mt-1">Number of tokens before compaction triggers.</p>
</div>
)}
<div className="flex items-center justify-between p-4 bg-dark-600 rounded-lg">
<div>
<p className="text-sm text-white">Context Pruning</p>
<p className="text-xs text-gray-500">Limit the number of recent messages kept in context</p>
</div>
<input
type="checkbox"
checked={compaction.context_pruning}
onChange={e => setCompaction({ ...compaction, context_pruning: e.target.checked })}
className="w-5 h-5 rounded bg-dark-500 border-dark-400 text-claw-500 focus:ring-claw-500/50"
/>
</div>
{compaction.context_pruning && (
<div className="pl-4 border-l-2 border-dark-600">
<label className="block text-sm text-gray-400 mb-2">Max Messages</label>
<input
type="number"
value={compaction.max_context_messages || ''}
onChange={e => setCompaction({ ...compaction, max_context_messages: parseInt(e.target.value) || null })}
placeholder="e.g. 50"
className="input-base"
/>
<p className="text-xs text-gray-500 mt-1">Maximum number of recent messages to keep.</p>
</div>
)}
</div>
</div>
{/* Workspace */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
<Clock size={20} className="text-orange-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Workspace</h3>
<p className="text-xs text-gray-500">Time and localization settings</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Timezone</label>
<select
value={workspace.timezone || 'Asia/Shanghai'}
onChange={e => setWorkspace({ ...workspace, timezone: e.target.value })}
className="input-base"
>
<option value="Asia/Shanghai">Asia/Shanghai</option>
<option value="Asia/Hong_Kong">Asia/Hong_Kong</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
<option value="America/New_York">America/New_York</option>
<option value="America/Los_Angeles">America/Los_Angeles</option>
<option value="Europe/London">Europe/London</option>
<option value="UTC">UTC</option>
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Time Format</label>
<select
value={workspace.time_format || ''}
onChange={e => setWorkspace({ ...workspace, time_format: e.target.value || null })}
className="input-base"
>
<option value="">Default (24h)</option>
<option value="12h">12h (AM/PM)</option>
<option value="24h">24h</option>
</select>
</div>
</div>
</div>
{/* Gateway Settings */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-cyan-500/20 flex items-center justify-center">
<Server size={20} className="text-cyan-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Gateway Settings</h3>
<p className="text-xs text-gray-500">Network and logging configuration</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Gateway Port</label>
<input
type="number"
value={gateway.port}
onChange={e => setGateway({ ...gateway, port: parseInt(e.target.value) || 3000 })}
className="input-base"
/>
<p className="text-xs text-yellow-500/80 mt-1 flex items-center gap-1">
<AlertTriangle size={12} /> Requires restart
</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Log Level</label>
<select
value={gateway.log_level}
onChange={e => setGateway({ ...gateway, log_level: e.target.value })}
className="input-base"
>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
</div>
</div>
</div>
{/* Configuration Management */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-slate-500/20 flex items-center justify-center">
<FileJson size={20} className="text-slate-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Configuration Management</h3>
<p className="text-xs text-gray-500">Backup and restore settings</p>
</div>
</div>
<div className="flex gap-4">
<button
onClick={handleExport}
className="flex-1 flex items-center justify-center gap-2 p-3 bg-dark-600 hover:bg-dark-500 rounded-lg transition-colors text-sm text-white border border-dark-500 hover:border-dark-400"
>
<Download size={16} />
Export Config
</button>
<button
onClick={handleImport}
className="flex-1 flex items-center justify-center gap-2 p-3 bg-dark-600 hover:bg-dark-500 rounded-lg transition-colors text-sm text-white border border-dark-500 hover:border-dark-400"
>
<Upload size={16} />
Import Config
</button>
</div>
</div>
@@ -659,32 +547,6 @@ export function Settings({ onEnvironmentChange }: SettingsProps) {
</div>
</div>
{/* Advanced Settings */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
<FileCode size={20} className="text-purple-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Advanced Settings</h3>
<p className="text-xs text-gray-500">Configuration files and directories</p>
</div>
</div>
<div className="space-y-3">
<button
onClick={openConfigDir}
className="w-full flex items-center gap-3 p-4 bg-dark-600 rounded-lg hover:bg-dark-500 transition-colors text-left"
>
<FolderOpen size={18} className="text-gray-400" />
<div className="flex-1">
<p className="text-sm text-white">Open Configuration Directory</p>
<p className="text-xs text-gray-500">~/.openclaw</p>
</div>
</button>
</div>
</div>
{/* Manager Update */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center gap-3 mb-6">
@@ -811,17 +673,21 @@ export function Settings({ onEnvironmentChange }: SettingsProps) {
{/* Global Save Button (Floating) */}
<div className="fixed bottom-6 right-6 z-40">
<button
id="save-btn"
onClick={handleSave}
disabled={saving}
className="btn-primary shadow-xl flex items-center gap-2 px-6 py-3 rounded-full text-base font-medium"
disabled={saving || saveSuccess}
className={`shadow-xl flex items-center gap-2 px-6 py-3 rounded-full text-base font-medium transition-all ${saveSuccess
? 'bg-green-600 text-white hover:bg-green-700'
: 'btn-primary'
}`}
>
{saving ? (
<Loader2 size={20} className="animate-spin" />
) : saveSuccess ? (
<CheckCircle size={20} />
) : (
<Save size={20} />
)}
Save Settings
{saveSuccess ? 'Saved!' : 'Save Settings'}
</button>
</div>
</div>