This commit is contained in:
miao
2026-02-01 14:19:52 +08:00
parent 62c510b838
commit d658296092
55 changed files with 21045 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Dependencies
node_modules/
# Build outputs
dist/
src-tauri/target/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.local
.env.*.local
# Tauri
src-tauri/target/
src-tauri/Cargo.lock
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/claw.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenClaw Manager</title>
<style>
/* 防止 FOUC */
html {
background-color: #0a0a0b;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a1d;
}
::-webkit-scrollbar-thumb {
background: #3d3d44;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4d4d55;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3132
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "openclaw-manager",
"version": "1.0.0",
"description": "🦞 OpenClaw 跨平台管理工具 - 高性能 AI 助手配置与服务管理",
"author": "OpenClaw Team",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
"@tauri-apps/plugin-process": "^2.2.0",
"@tauri-apps/plugin-notification": "^2.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.3",
"lucide-react": "^0.468.0",
"clsx": "^2.1.1",
"framer-motion": "^11.15.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.2.4",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="clawGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#ff7a6b"/>
<stop offset="50%" style="stop-color:#f94d3a"/>
<stop offset="100%" style="stop-color:#c1241a"/>
</linearGradient>
</defs>
<!-- 龙虾钳子图标 -->
<g fill="url(#clawGrad)">
<!-- 左钳 -->
<path d="M16 20 C8 16, 4 24, 8 32 C12 40, 20 44, 28 40 L24 32 C20 34, 14 32, 12 28 C10 24, 14 20, 16 20 Z"/>
<!-- 右钳 -->
<path d="M48 20 C56 16, 60 24, 56 32 C52 40, 44 44, 36 40 L40 32 C44 34, 50 32, 52 28 C54 24, 50 20, 48 20 Z"/>
<!-- 身体 -->
<ellipse cx="32" cy="44" rx="12" ry="8"/>
<ellipse cx="32" cy="36" rx="8" ry="6"/>
</g>
<!-- 眼睛 -->
<circle cx="26" cy="34" r="2" fill="#0a0a0b"/>
<circle cx="38" cy="34" r="2" fill="#0a0a0b"/>
</svg>

After

Width:  |  Height:  |  Size: 903 B

+39
View File
@@ -0,0 +1,39 @@
[package]
name = "openclaw-manager"
version = "1.0.0"
description = "OpenClaw 跨平台管理工具"
authors = ["OpenClaw Team"]
edition = "2021"
[lib]
name = "openclaw_manager_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-fs = "2"
tauri-plugin-process = "2"
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
chrono = { version = "0.4", features = ["serde"] }
dirs = "5"
thiserror = "1"
log = "0.4"
env_logger = "0.11"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.26"
objc = "0.2"
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+378
View File
@@ -0,0 +1,378 @@
use crate::models::{AIModelOption, AIProviderOption, ChannelConfig};
use crate::utils::{file, platform};
use serde_json::{json, Value};
use std::collections::HashMap;
use tauri::command;
/// 获取 openclaw.json 配置
fn load_openclaw_config() -> Result<Value, String> {
let config_path = platform::get_config_file_path();
if !file::file_exists(&config_path) {
return Ok(json!({}));
}
let content = file::read_file(&config_path)
.map_err(|e| format!("读取配置文件失败: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("解析配置文件失败: {}", e))
}
/// 保存 openclaw.json 配置
fn save_openclaw_config(config: &Value) -> Result<(), String> {
let config_path = platform::get_config_file_path();
let content = serde_json::to_string_pretty(config)
.map_err(|e| format!("序列化配置失败: {}", e))?;
file::write_file(&config_path, &content)
.map_err(|e| format!("写入配置文件失败: {}", e))
}
/// 获取完整配置
#[command]
pub async fn get_config() -> Result<Value, String> {
load_openclaw_config()
}
/// 保存配置
#[command]
pub async fn save_config(config: Value) -> Result<String, String> {
save_openclaw_config(&config)?;
Ok("配置已保存".to_string())
}
/// 获取环境变量值
#[command]
pub async fn get_env_value(key: String) -> Result<Option<String>, String> {
let env_path = platform::get_env_file_path();
Ok(file::read_env_value(&env_path, &key))
}
/// 保存环境变量值
#[command]
pub async fn save_env_value(key: String, value: String) -> Result<String, String> {
let env_path = platform::get_env_file_path();
file::set_env_value(&env_path, &key, &value)
.map_err(|e| format!("保存环境变量失败: {}", e))?;
Ok("环境变量已保存".to_string())
}
/// 获取所有支持的 AI Provider
#[command]
pub async fn get_ai_providers() -> Result<Vec<AIProviderOption>, String> {
Ok(vec![
AIProviderOption {
id: "anthropic".to_string(),
name: "Anthropic Claude".to_string(),
icon: "🟣".to_string(),
default_base_url: Some("https://api.anthropic.com".to_string()),
requires_api_key: true,
models: vec![
AIModelOption {
id: "claude-sonnet-4-5-20250929".to_string(),
name: "Claude Sonnet 4.5".to_string(),
description: Some("最新平衡版本,推荐使用".to_string()),
recommended: true,
},
AIModelOption {
id: "claude-opus-4-5-20251101".to_string(),
name: "Claude Opus 4.5".to_string(),
description: Some("最强大版本".to_string()),
recommended: false,
},
AIModelOption {
id: "claude-haiku-4-5-20251001".to_string(),
name: "Claude Haiku 4.5".to_string(),
description: Some("快速经济版本".to_string()),
recommended: false,
},
],
},
AIProviderOption {
id: "openai".to_string(),
name: "OpenAI GPT".to_string(),
icon: "🟢".to_string(),
default_base_url: Some("https://api.openai.com/v1".to_string()),
requires_api_key: true,
models: vec![
AIModelOption {
id: "gpt-4o".to_string(),
name: "GPT-4o".to_string(),
description: Some("最新多模态模型".to_string()),
recommended: true,
},
AIModelOption {
id: "gpt-4o-mini".to_string(),
name: "GPT-4o Mini".to_string(),
description: Some("经济实惠版本".to_string()),
recommended: false,
},
AIModelOption {
id: "gpt-4-turbo".to_string(),
name: "GPT-4 Turbo".to_string(),
description: Some("高性能版本".to_string()),
recommended: false,
},
],
},
AIProviderOption {
id: "deepseek".to_string(),
name: "DeepSeek".to_string(),
icon: "🔵".to_string(),
default_base_url: Some("https://api.deepseek.com".to_string()),
requires_api_key: true,
models: vec![
AIModelOption {
id: "deepseek-chat".to_string(),
name: "DeepSeek V3".to_string(),
description: Some("最新对话模型".to_string()),
recommended: true,
},
AIModelOption {
id: "deepseek-reasoner".to_string(),
name: "DeepSeek R1".to_string(),
description: Some("推理增强模型".to_string()),
recommended: false,
},
],
},
AIProviderOption {
id: "kimi".to_string(),
name: "Kimi (Moonshot)".to_string(),
icon: "🌙".to_string(),
default_base_url: Some("https://api.moonshot.cn/v1".to_string()),
requires_api_key: true,
models: vec![
AIModelOption {
id: "moonshot-v1-auto".to_string(),
name: "Moonshot Auto".to_string(),
description: Some("自动选择最佳上下文".to_string()),
recommended: true,
},
AIModelOption {
id: "moonshot-v1-128k".to_string(),
name: "Moonshot 128K".to_string(),
description: Some("超长上下文".to_string()),
recommended: false,
},
],
},
AIProviderOption {
id: "google".to_string(),
name: "Google Gemini".to_string(),
icon: "🔴".to_string(),
default_base_url: None,
requires_api_key: true,
models: vec![
AIModelOption {
id: "gemini-2.0-flash".to_string(),
name: "Gemini 2.0 Flash".to_string(),
description: Some("最新快速模型".to_string()),
recommended: true,
},
AIModelOption {
id: "gemini-1.5-pro".to_string(),
name: "Gemini 1.5 Pro".to_string(),
description: Some("专业版本".to_string()),
recommended: false,
},
],
},
AIProviderOption {
id: "openrouter".to_string(),
name: "OpenRouter".to_string(),
icon: "🔄".to_string(),
default_base_url: Some("https://openrouter.ai/api/v1".to_string()),
requires_api_key: true,
models: vec![
AIModelOption {
id: "anthropic/claude-sonnet-4".to_string(),
name: "Claude Sonnet 4".to_string(),
description: Some("通过 OpenRouter 访问".to_string()),
recommended: true,
},
AIModelOption {
id: "openai/gpt-4o".to_string(),
name: "GPT-4o".to_string(),
description: Some("通过 OpenRouter 访问".to_string()),
recommended: false,
},
],
},
AIProviderOption {
id: "groq".to_string(),
name: "Groq".to_string(),
icon: "".to_string(),
default_base_url: Some("https://api.groq.com/openai/v1".to_string()),
requires_api_key: true,
models: vec![
AIModelOption {
id: "llama-3.3-70b-versatile".to_string(),
name: "Llama 3.3 70B".to_string(),
description: Some("超快推理".to_string()),
recommended: true,
},
],
},
AIProviderOption {
id: "ollama".to_string(),
name: "Ollama (本地)".to_string(),
icon: "🟠".to_string(),
default_base_url: Some("http://localhost:11434".to_string()),
requires_api_key: false,
models: vec![
AIModelOption {
id: "llama3".to_string(),
name: "Llama 3".to_string(),
description: Some("本地运行".to_string()),
recommended: true,
},
AIModelOption {
id: "mistral".to_string(),
name: "Mistral".to_string(),
description: Some("本地运行".to_string()),
recommended: false,
},
],
},
])
}
/// 获取渠道配置 - 从 openclaw.json 和 env 文件读取
#[command]
pub async fn get_channels_config() -> Result<Vec<ChannelConfig>, String> {
let config = load_openclaw_config()?;
let channels_obj = config.get("channels").cloned().unwrap_or(json!({}));
let env_path = platform::get_env_file_path();
let mut channels = Vec::new();
// 支持的渠道类型列表及其测试字段
let channel_types = vec![
("telegram", "telegram", vec!["userId"]),
("discord", "discord", vec!["testChannelId"]),
("slack", "slack", vec!["testChannelId"]),
("feishu", "feishu", vec!["testChatId"]),
("whatsapp", "whatsapp", vec![]),
("imessage", "imessage", vec![]),
("wechat", "wechat", vec![]),
("dingtalk", "dingtalk", vec![]),
];
for (channel_id, channel_type, test_fields) in channel_types {
let channel_config = channels_obj.get(channel_id);
let enabled = channel_config
.and_then(|c| c.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
// 将渠道配置转换为 HashMap
let mut config_map: HashMap<String, Value> = if let Some(cfg) = channel_config {
if let Some(obj) = cfg.as_object() {
obj.iter()
.filter(|(k, _)| *k != "enabled") // 排除 enabled 字段
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
} else {
HashMap::new()
}
} else {
HashMap::new()
};
// 从 env 文件读取测试字段
for field in test_fields {
let env_key = format!("OPENCLAW_{}_{}", channel_id.to_uppercase(), field.to_uppercase());
if let Some(value) = file::read_env_value(&env_path, &env_key) {
config_map.insert(field.to_string(), json!(value));
}
}
// 判断是否已配置(有任何非空配置项)
let has_config = !config_map.is_empty() || enabled;
channels.push(ChannelConfig {
id: channel_id.to_string(),
channel_type: channel_type.to_string(),
enabled: has_config,
config: config_map,
});
}
Ok(channels)
}
/// 保存渠道配置 - 保存到 openclaw.json
/// 注意:某些字段(如 userId, testChatId 等)只用于测试,保存到 env 文件
#[command]
pub async fn save_channel_config(channel: ChannelConfig) -> Result<String, String> {
let mut config = load_openclaw_config()?;
let env_path = platform::get_env_file_path();
// 确保 channels 对象存在
if config.get("channels").is_none() {
config["channels"] = json!({});
}
// 确保 plugins 对象存在
if config.get("plugins").is_none() {
config["plugins"] = json!({
"allow": [],
"entries": {}
});
}
if config["plugins"].get("allow").is_none() {
config["plugins"]["allow"] = json!([]);
}
if config["plugins"].get("entries").is_none() {
config["plugins"]["entries"] = json!({});
}
// 这些字段只用于测试,不保存到 openclaw.json,而是保存到 env 文件
let test_only_fields = vec!["userId", "testChatId", "testChannelId"];
// 构建渠道配置
let mut channel_obj = json!({
"enabled": true
});
// 添加渠道特定配置
for (key, value) in &channel.config {
if test_only_fields.contains(&key.as_str()) {
// 保存到 env 文件
let env_key = format!("OPENCLAW_{}_{}", channel.id.to_uppercase(), key.to_uppercase());
if let Some(val_str) = value.as_str() {
let _ = file::set_env_value(&env_path, &env_key, val_str);
}
} else {
// 保存到 openclaw.json
channel_obj[key] = value.clone();
}
}
// 更新 channels 配置
config["channels"][&channel.id] = channel_obj;
// 更新 plugins.allow 数组 - 确保渠道在白名单中
if let Some(allow_arr) = config["plugins"]["allow"].as_array_mut() {
let channel_id_val = json!(&channel.id);
if !allow_arr.contains(&channel_id_val) {
allow_arr.push(channel_id_val);
}
}
// 更新 plugins.entries - 确保插件已启用
config["plugins"]["entries"][&channel.id] = json!({
"enabled": true
});
// 保存配置
save_openclaw_config(&config)?;
Ok(format!("{} 配置已保存", channel.channel_type))
}
+805
View File
@@ -0,0 +1,805 @@
use crate::models::{AITestResult, ChannelTestResult, DiagnosticResult, SystemInfo};
use crate::utils::{platform, shell};
use tauri::command;
/// 运行诊断
#[command]
pub async fn run_doctor() -> Result<Vec<DiagnosticResult>, String> {
let mut results = Vec::new();
// 检查 OpenClaw 是否安装
let openclaw_installed = shell::command_exists("openclaw");
results.push(DiagnosticResult {
name: "OpenClaw 安装".to_string(),
passed: openclaw_installed,
message: if openclaw_installed {
"OpenClaw 已安装".to_string()
} else {
"OpenClaw 未安装".to_string()
},
suggestion: if openclaw_installed {
None
} else {
Some("运行: npm install -g openclaw".to_string())
},
});
// 检查 Node.js
let node_check = shell::run_command_output("node", &["--version"]);
results.push(DiagnosticResult {
name: "Node.js".to_string(),
passed: node_check.is_ok(),
message: node_check
.clone()
.unwrap_or_else(|_| "未安装".to_string()),
suggestion: if node_check.is_err() {
Some("请安装 Node.js 22+".to_string())
} else {
None
},
});
// 检查配置文件
let config_path = platform::get_config_file_path();
let config_exists = std::path::Path::new(&config_path).exists();
results.push(DiagnosticResult {
name: "配置文件".to_string(),
passed: config_exists,
message: if config_exists {
format!("配置文件存在: {}", config_path)
} else {
"配置文件不存在".to_string()
},
suggestion: if config_exists {
None
} else {
Some("运行 openclaw 初始化配置".to_string())
},
});
// 检查环境变量文件
let env_path = platform::get_env_file_path();
let env_exists = std::path::Path::new(&env_path).exists();
results.push(DiagnosticResult {
name: "环境变量".to_string(),
passed: env_exists,
message: if env_exists {
format!("环境变量文件存在: {}", env_path)
} else {
"环境变量文件不存在".to_string()
},
suggestion: if env_exists {
None
} else {
Some("请配置 AI API Key".to_string())
},
});
// 运行 openclaw doctor
if openclaw_installed {
let doctor_result = shell::run_bash_output("source ~/.openclaw/env 2>/dev/null; openclaw doctor 2>&1 | head -20");
results.push(DiagnosticResult {
name: "OpenClaw Doctor".to_string(),
passed: doctor_result.is_ok() && !doctor_result.as_ref().unwrap().contains("invalid"),
message: doctor_result.unwrap_or_else(|e| e),
suggestion: None,
});
}
Ok(results)
}
/// 测试 AI 连接
#[command]
pub async fn test_ai_connection() -> Result<AITestResult, String> {
let env_path = platform::get_env_file_path();
// 获取当前配置的 provider
let start = std::time::Instant::now();
let result = shell::run_bash_output(&format!(
"source {} 2>/dev/null; openclaw agent --local --to '+1234567890' --message '回复 OK' 2>&1 | head -10",
env_path
));
let latency = start.elapsed().as_millis() as u64;
match result {
Ok(output) => {
// 过滤掉警告信息
let filtered: String = output
.lines()
.filter(|l: &&str| !l.contains("ExperimentalWarning"))
.collect::<Vec<&str>>()
.join("\n");
let success = !filtered.to_lowercase().contains("error")
&& !filtered.contains("401")
&& !filtered.contains("403");
Ok(AITestResult {
success,
provider: "current".to_string(),
model: "default".to_string(),
response: if success { Some(filtered.clone()) } else { None },
error: if success { None } else { Some(filtered) },
latency_ms: Some(latency),
})
}
Err(e) => Ok(AITestResult {
success: false,
provider: "current".to_string(),
model: "default".to_string(),
response: None,
error: Some(e),
latency_ms: Some(latency),
}),
}
}
/// 测试渠道连接
#[command]
pub async fn test_channel(channel_type: String) -> Result<ChannelTestResult, String> {
let config_path = platform::get_config_file_path();
// 从 openclaw.json 读取渠道配置
let config_content = crate::utils::file::read_file(&config_path)
.unwrap_or_else(|_| "{}".to_string());
let config: serde_json::Value = serde_json::from_str(&config_content)
.unwrap_or(serde_json::json!({}));
let channels = config.get("channels").cloned().unwrap_or(serde_json::json!({}));
let channel_config = channels.get(&channel_type);
// 检查渠道是否已配置
if channel_config.is_none() {
return Ok(ChannelTestResult {
success: false,
channel: channel_type.clone(),
message: "渠道未配置".to_string(),
error: Some(format!("请先在消息渠道页面配置 {}", channel_type)),
});
}
let channel_cfg = channel_config.unwrap();
match channel_type.as_str() {
"telegram" => {
let token = channel_cfg.get("botToken")
.and_then(|v| v.as_str())
.unwrap_or("");
if token.is_empty() {
return Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "Bot Token 未配置".to_string(),
error: Some("请配置 Telegram Bot Token".to_string()),
});
}
// 先验证 Token
let verify_cmd = format!(
"curl -s 'https://api.telegram.org/bot{}/getMe'",
token
);
let verify_result = shell::run_bash_output(&verify_cmd);
let token_valid = verify_result.as_ref()
.map(|o| o.contains("\"ok\":true"))
.unwrap_or(false);
if !token_valid {
return Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "Token 无效".to_string(),
error: verify_result.err().or(Some("Bot Token 验证失败".to_string())),
});
}
// 获取 bot 用户名
let bot_name = verify_result.as_ref()
.ok()
.and_then(|o| o.split("\"username\":\"").nth(1))
.and_then(|s| s.split('"').next())
.map(|s| format!("@{}", s))
.unwrap_or_else(|| "Bot".to_string());
// 从 env 文件读取 userId (用于测试发送消息)
let env_path = platform::get_env_file_path();
let user_id = crate::utils::file::read_env_value(&env_path, "OPENCLAW_TELEGRAM_USERID");
if let Some(chat_id) = user_id {
// 发送测试消息
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let message = format!("🤖 OpenClaw 测试消息\\n\\n✅ 连接成功!\\n⏰ {}", timestamp);
let send_cmd = format!(
r#"curl -s -X POST 'https://api.telegram.org/bot{}/sendMessage' -H 'Content-Type: application/json' -d '{{"chat_id":"{}","text":"{}","parse_mode":"HTML"}}'"#,
token, chat_id, message
);
let send_result = shell::run_bash_output(&send_cmd);
match send_result {
Ok(output) => {
let success = output.contains("\"ok\":true");
Ok(ChannelTestResult {
success,
channel: channel_type,
message: if success {
format!("{} 消息已发送", bot_name)
} else {
"消息发送失败".to_string()
},
error: if success { None } else { Some(output) },
})
}
Err(e) => Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "发送失败".to_string(),
error: Some(e),
}),
}
} else {
// 没有配置 User ID
Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: format!("{} Token 有效,但未配置 User ID", bot_name),
error: Some("请配置 User ID 以发送测试消息".to_string()),
})
}
}
"discord" => {
let token = channel_cfg.get("botToken")
.and_then(|v| v.as_str())
.unwrap_or("");
if token.is_empty() {
return Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "Bot Token 未配置".to_string(),
error: Some("请配置 Discord Bot Token".to_string()),
});
}
// 先验证 Token
let verify_cmd = format!(
"curl -s -H 'Authorization: Bot {}' https://discord.com/api/v10/users/@me",
token
);
let verify_result = shell::run_bash_output(&verify_cmd);
let token_valid = verify_result.as_ref()
.map(|o| o.contains("\"id\":"))
.unwrap_or(false);
if !token_valid {
return Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "Token 无效".to_string(),
error: verify_result.err().or(Some("Bot Token 验证失败".to_string())),
});
}
let bot_name = verify_result.as_ref()
.ok()
.and_then(|o| o.split("\"username\":\"").nth(1))
.and_then(|s| s.split('"').next())
.unwrap_or("Bot")
.to_string();
// 从 env 文件读取测试 Channel ID
let env_path = platform::get_env_file_path();
let test_channel_id = crate::utils::file::read_env_value(&env_path, "OPENCLAW_DISCORD_TESTCHANNELID");
if let Some(channel_id) = test_channel_id {
// 发送测试消息
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let message = format!("🤖 OpenClaw 测试消息\\n\\n✅ 连接成功!\\n⏰ {}", timestamp);
let send_cmd = format!(
r#"curl -s -X POST 'https://discord.com/api/v10/channels/{}/messages' -H 'Authorization: Bot {}' -H 'Content-Type: application/json' -d '{{"content":"{}"}}'| head -1"#,
channel_id, token, message
);
let send_result = shell::run_bash_output(&send_cmd);
match send_result {
Ok(output) => {
let success = output.contains("\"id\":");
Ok(ChannelTestResult {
success,
channel: channel_type,
message: if success {
format!("{} 消息已发送", bot_name)
} else {
"消息发送失败".to_string()
},
error: if success { None } else { Some(output) },
})
}
Err(e) => Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "发送失败".to_string(),
error: Some(e),
}),
}
} else {
Ok(ChannelTestResult {
success: true,
channel: channel_type,
message: format!("{} Token 有效 (未配置测试 Channel ID)", bot_name),
error: None,
})
}
}
"feishu" => {
let app_id = channel_cfg.get("appId")
.and_then(|v| v.as_str())
.unwrap_or("");
let app_secret = channel_cfg.get("appSecret")
.and_then(|v| v.as_str())
.unwrap_or("");
let domain = channel_cfg.get("domain")
.and_then(|v| v.as_str())
.unwrap_or("feishu");
if app_id.is_empty() || app_secret.is_empty() {
return Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "App ID 或 App Secret 未配置".to_string(),
error: Some("请配置飞书 App ID 和 App Secret".to_string()),
});
}
// 根据 domain 确定 API 地址
let api_host = if domain == "lark" {
"open.larksuite.com"
} else {
"open.feishu.cn"
};
// 获取 tenant_access_token
let token_cmd = format!(
r#"curl -s -X POST 'https://{}/open-apis/auth/v3/tenant_access_token/internal' -H 'Content-Type: application/json' -d '{{"app_id":"{}","app_secret":"{}"}}'"#,
api_host, app_id, app_secret
);
let token_result = shell::run_bash_output(&token_cmd);
let access_token = match &token_result {
Ok(output) => {
if output.contains("\"code\":0") {
output.split("\"tenant_access_token\":\"")
.nth(1)
.and_then(|s| s.split('"').next())
.map(|s| s.to_string())
} else {
None
}
}
Err(_) => None,
};
if access_token.is_none() {
return Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "认证失败".to_string(),
error: token_result.err().or(Some("无法获取 access_token".to_string())),
});
}
let token = access_token.unwrap();
// 从 env 文件读取测试 Chat ID
let env_path = platform::get_env_file_path();
let test_chat_id = crate::utils::file::read_env_value(&env_path, "OPENCLAW_FEISHU_TESTCHATID");
if let Some(chat_id) = test_chat_id {
// 发送测试消息
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
// 飞书 content 字段需要是 JSON 字符串的字符串形式
let content_json = format!(r#"{{"text":"🤖 OpenClaw 测试消息\n\n✅ 连接成功!\n⏰ {}"}}"#, timestamp);
// 需要对 content_json 进行转义,使其成为 JSON 字符串中的值
let escaped_content = content_json.replace('\\', "\\\\").replace('"', "\\\"");
let send_cmd = format!(
r#"curl -s -X POST 'https://{}/open-apis/im/v1/messages?receive_id_type=chat_id' -H 'Authorization: Bearer {}' -H 'Content-Type: application/json' -d '{{"receive_id":"{}","msg_type":"text","content":"{}"}}'"#,
api_host, token, chat_id, escaped_content
);
let send_result = shell::run_bash_output(&send_cmd);
match send_result {
Ok(output) => {
let success = output.contains("\"code\":0");
Ok(ChannelTestResult {
success,
channel: channel_type,
message: if success {
"消息已发送".to_string()
} else {
"消息发送失败".to_string()
},
error: if success { None } else { Some(output) },
})
}
Err(e) => Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "发送失败".to_string(),
error: Some(e),
}),
}
} else {
// 没有配置 Chat ID,只验证凭证
Ok(ChannelTestResult {
success: true,
channel: channel_type,
message: "认证成功 (未配置测试 Chat ID)".to_string(),
error: None,
})
}
}
"slack" => {
let token = channel_cfg.get("botToken")
.and_then(|v| v.as_str())
.unwrap_or("");
if token.is_empty() {
return Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "Bot Token 未配置".to_string(),
error: Some("请配置 Slack Bot Token".to_string()),
});
}
// 先验证 Token
let verify_cmd = format!(
"curl -s -H 'Authorization: Bearer {}' https://slack.com/api/auth.test",
token
);
let verify_result = shell::run_bash_output(&verify_cmd);
let token_valid = verify_result.as_ref()
.map(|o| o.contains("\"ok\":true"))
.unwrap_or(false);
if !token_valid {
return Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "Token 无效".to_string(),
error: verify_result.err().or(Some("Bot Token 验证失败".to_string())),
});
}
let bot_name = verify_result.as_ref()
.ok()
.and_then(|o| o.split("\"user\":\"").nth(1))
.and_then(|s| s.split('"').next())
.unwrap_or("Bot")
.to_string();
// 从 env 文件读取测试 Channel ID
let env_path = platform::get_env_file_path();
let test_channel_id = crate::utils::file::read_env_value(&env_path, "OPENCLAW_SLACK_TESTCHANNELID");
if let Some(channel_id) = test_channel_id {
// 发送测试消息
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let message = format!("🤖 OpenClaw 测试消息\\n\\n✅ 连接成功!\\n⏰ {}", timestamp);
let send_cmd = format!(
r#"curl -s -X POST 'https://slack.com/api/chat.postMessage' -H 'Authorization: Bearer {}' -H 'Content-Type: application/json' -d '{{"channel":"{}","text":"{}"}}'"#,
token, channel_id, message
);
let send_result = shell::run_bash_output(&send_cmd);
match send_result {
Ok(output) => {
let success = output.contains("\"ok\":true");
Ok(ChannelTestResult {
success,
channel: channel_type,
message: if success {
format!("{} 消息已发送", bot_name)
} else {
"消息发送失败".to_string()
},
error: if success { None } else { Some(output) },
})
}
Err(e) => Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "发送失败".to_string(),
error: Some(e),
}),
}
} else {
Ok(ChannelTestResult {
success: true,
channel: channel_type,
message: format!("{} Token 有效 (未配置测试 Channel ID)", bot_name),
error: None,
})
}
}
"whatsapp" => {
// WhatsApp 需要通过 openclaw status 检查
let check_cmd = "openclaw status 2>/dev/null | grep -i whatsapp || echo 'not_configured'";
let result = shell::run_bash_output(check_cmd);
match result {
Ok(output) => {
let output_lower = output.to_lowercase();
// WhatsApp 状态可能显示: connected, online, linked, OK
let connected = output_lower.contains("connected")
|| output_lower.contains("online")
|| output_lower.contains("linked")
|| output.contains("OK");
let not_configured = output.contains("not_configured")
|| output_lower.contains("未配置")
|| output_lower.contains("disabled");
if not_configured {
Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "未登录".to_string(),
error: Some("请运行: openclaw channels login --channel whatsapp".to_string()),
})
} else {
// 提取手机号信息
let phone_info = if output.contains("+") {
// 尝试提取手机号
output.split("·").nth(1).map(|s| s.trim().to_string()).unwrap_or_default()
} else {
String::new()
};
let message = if connected {
if !phone_info.is_empty() {
format!("已连接 ({})", phone_info)
} else {
"已连接".to_string()
}
} else {
output.clone()
};
Ok(ChannelTestResult {
success: connected,
channel: channel_type,
message,
error: if connected { None } else { Some(output) },
})
}
}
Err(e) => Ok(ChannelTestResult {
success: false,
channel: channel_type,
message: "检查失败".to_string(),
error: Some(e),
}),
}
}
_ => Ok(ChannelTestResult {
success: false,
channel: channel_type.clone(),
message: "不支持的渠道".to_string(),
error: Some(format!("暂不支持测试 {} 渠道", channel_type)),
}),
}
}
/// 获取系统信息
#[command]
pub async fn get_system_info() -> Result<SystemInfo, String> {
let os = platform::get_os();
let arch = platform::get_arch();
// 获取 OS 版本
let os_version = if platform::is_macos() {
shell::run_command_output("sw_vers", &["-productVersion"])
.unwrap_or_else(|_| "unknown".to_string())
} else if platform::is_linux() {
shell::run_bash_output("cat /etc/os-release | grep VERSION_ID | cut -d'=' -f2 | tr -d '\"'")
.unwrap_or_else(|_| "unknown".to_string())
} else {
"unknown".to_string()
};
let openclaw_installed = shell::command_exists("openclaw");
let openclaw_version = if openclaw_installed {
shell::run_command_output("openclaw", &["--version"]).ok()
} else {
None
};
let node_version = shell::run_command_output("node", &["--version"]).ok();
Ok(SystemInfo {
os,
os_version,
arch,
openclaw_installed,
openclaw_version,
node_version,
config_dir: platform::get_config_dir(),
})
}
/// 启动渠道登录(如 WhatsApp 扫码)
#[command]
pub async fn start_channel_login(channel_type: String) -> Result<String, String> {
let env_path = platform::get_env_file_path();
match channel_type.as_str() {
"whatsapp" => {
// 先在后台启用插件
let enable_cmd = format!(
"source {} 2>/dev/null; openclaw plugins enable whatsapp 2>/dev/null",
env_path
);
let _ = shell::run_bash_output(&enable_cmd);
#[cfg(target_os = "macos")]
{
// 创建一个临时脚本文件
// 流程:1. 启用插件 2. 重启 Gateway 3. 登录
let script_content = format!(
r#"#!/bin/bash
source {} 2>/dev/null
clear
echo "╔════════════════════════════════════════════════════════╗"
echo "║ 📱 WhatsApp 登录向导 ║"
echo "╚════════════════════════════════════════════════════════╝"
echo ""
echo "步骤 1/3: 启用 WhatsApp 插件..."
openclaw plugins enable whatsapp 2>/dev/null || true
# 确保 whatsapp 在 plugins.allow 数组中
python3 << 'PYEOF'
import json
import os
config_path = os.path.expanduser("~/.openclaw/openclaw.json")
plugin_id = "whatsapp"
try:
with open(config_path, 'r') as f:
config = json.load(f)
# 设置 plugins.allow 和 plugins.entries
if 'plugins' not in config:
config['plugins'] = {{'allow': [], 'entries': {{}}}}
if 'allow' not in config['plugins']:
config['plugins']['allow'] = []
if 'entries' not in config['plugins']:
config['plugins']['entries'] = {{}}
if plugin_id not in config['plugins']['allow']:
config['plugins']['allow'].append(plugin_id)
config['plugins']['entries'][plugin_id] = {{'enabled': True}}
# 确保 channels.whatsapp 存在(但不设置 enabledWhatsApp 不支持这个键)
if 'channels' not in config:
config['channels'] = {{}}
if plugin_id not in config['channels']:
config['channels'][plugin_id] = {{'dmPolicy': 'pairing', 'groupPolicy': 'allowlist'}}
with open(config_path, 'w') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
print("配置已更新")
except Exception as e:
print(f"Warning: {{e}}")
PYEOF
echo "✅ 插件已启用"
echo ""
echo "步骤 2/3: 重启 Gateway 使插件生效..."
# 停止现有 gateway
pkill -f "openclaw.*gateway" 2>/dev/null || true
sleep 2
# 后台启动 gateway
nohup openclaw gateway --port 18789 > /tmp/openclaw-gateway.log 2>&1 &
sleep 3
echo "✅ Gateway 已重启"
echo ""
echo "步骤 3/3: 启动 WhatsApp 登录..."
echo "请使用 WhatsApp 手机 App 扫描下方二维码"
echo ""
openclaw channels login --channel whatsapp --verbose
echo ""
echo "════════════════════════════════════════════════════════"
echo "登录完成!"
echo ""
read -p "按回车键关闭此窗口..."
"#,
env_path
);
let script_path = "/tmp/openclaw_whatsapp_login.command";
std::fs::write(script_path, script_content)
.map_err(|e| format!("创建脚本失败: {}", e))?;
// 设置可执行权限
std::process::Command::new("chmod")
.args(["+x", script_path])
.output()
.map_err(|e| format!("设置权限失败: {}", e))?;
// 使用 open 命令打开 .command 文件(会自动在新终端窗口中执行)
std::process::Command::new("open")
.arg(script_path)
.spawn()
.map_err(|e| format!("启动终端失败: {}", e))?;
}
#[cfg(target_os = "linux")]
{
// 创建脚本
let script_content = format!(
r#"#!/bin/bash
source {} 2>/dev/null
clear
echo "📱 WhatsApp 登录向导"
echo ""
openclaw channels login --channel whatsapp --verbose
echo ""
read -p "按回车键关闭..."
"#,
env_path
);
let script_path = "/tmp/openclaw_whatsapp_login.sh";
std::fs::write(script_path, &script_content)
.map_err(|e| format!("创建脚本失败: {}", e))?;
std::process::Command::new("chmod")
.args(["+x", script_path])
.output()
.map_err(|e| format!("设置权限失败: {}", e))?;
// 尝试不同的终端模拟器
let terminals = ["gnome-terminal", "xfce4-terminal", "konsole", "xterm"];
let mut launched = false;
for term in terminals {
let result = std::process::Command::new(term)
.args(["--", script_path])
.spawn();
if result.is_ok() {
launched = true;
break;
}
}
if !launched {
return Err("无法启动终端,请手动运行: openclaw channels login --channel whatsapp".to_string());
}
}
#[cfg(target_os = "windows")]
{
return Err("Windows 暂不支持自动启动终端,请手动运行: openclaw channels login --channel whatsapp".to_string());
}
Ok("已在新终端窗口中启动 WhatsApp 登录,请查看弹出的终端窗口并扫描二维码".to_string())
}
_ => Err(format!("不支持 {} 的登录向导", channel_type)),
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod config;
pub mod diagnostics;
pub mod process;
pub mod service;
+41
View File
@@ -0,0 +1,41 @@
use crate::utils::shell;
use tauri::command;
/// 检查 OpenClaw 是否已安装
#[command]
pub async fn check_openclaw_installed() -> Result<bool, String> {
Ok(shell::command_exists("openclaw"))
}
/// 获取 OpenClaw 版本
#[command]
pub async fn get_openclaw_version() -> Result<Option<String>, String> {
if !shell::command_exists("openclaw") {
return Ok(None);
}
match shell::run_command_output("openclaw", &["--version"]) {
Ok(version) => Ok(Some(version)),
Err(_) => Ok(None),
}
}
/// 检查端口是否被占用
#[command]
pub async fn check_port_in_use(port: u16) -> Result<bool, String> {
let result = shell::run_bash_output(&format!("lsof -ti :{}", port));
Ok(result.is_ok() && !result.unwrap().is_empty())
}
/// 获取 Node.js 版本
#[command]
pub async fn get_node_version() -> Result<Option<String>, String> {
if !shell::command_exists("node") {
return Ok(None);
}
match shell::run_command_output("node", &["--version"]) {
Ok(version) => Ok(Some(version)),
Err(_) => Ok(None),
}
}
+130
View File
@@ -0,0 +1,130 @@
use crate::models::ServiceStatus;
use crate::utils::{file, platform, shell};
use tauri::command;
/// 获取服务状态
#[command]
pub async fn get_service_status() -> Result<ServiceStatus, String> {
// 检查 openclaw gateway 进程
let result = shell::run_command_output("pgrep", &["-f", "openclaw.*gateway"]);
let (running, pid) = match result {
Ok(ref output) => {
let pid = output.lines().next().and_then(|s: &str| s.parse::<u32>().ok());
(true, pid)
}
Err(_) => (false, None),
};
// 获取内存使用(仅在运行时)
let memory_mb = if let Some(p) = pid {
shell::run_bash_output(&format!("ps -o rss= -p {}", p))
.ok()
.and_then(|s: String| s.trim().parse::<f64>().ok())
.map(|kb| kb / 1024.0)
} else {
None
};
Ok(ServiceStatus {
running,
pid,
port: 18789,
uptime_seconds: None,
memory_mb,
cpu_percent: None,
})
}
/// 启动服务
#[command]
pub async fn start_service() -> Result<String, String> {
// 检查是否已经运行
let status = get_service_status().await?;
if status.running {
return Err("服务已在运行中".to_string());
}
let env_file = platform::get_env_file_path();
let log_file = platform::get_log_file_path();
// 构建启动命令
let start_cmd = if file::file_exists(&env_file) {
format!(
"source {} && nohup openclaw gateway --port 18789 > {} 2>&1 &",
env_file, log_file
)
} else {
format!(
"nohup openclaw gateway --port 18789 > {} 2>&1 &",
log_file
)
};
// 后台启动
shell::spawn_background(&start_cmd)
.map_err(|e| format!("启动服务失败: {}", e))?;
// 等待一秒后检查状态
std::thread::sleep(std::time::Duration::from_secs(1));
let new_status = get_service_status().await?;
if new_status.running {
Ok(format!("服务已启动,PID: {:?}", new_status.pid))
} else {
Err("服务启动失败,请查看日志".to_string())
}
}
/// 停止服务
#[command]
pub async fn stop_service() -> Result<String, String> {
// 先尝试正常停止
let _ = shell::run_command_output("openclaw", &["gateway", "stop"]);
// 等待一秒
std::thread::sleep(std::time::Duration::from_millis(500));
// 强制杀死进程
let _ = shell::run_command("pkill", &["-f", "openclaw.*gateway"]);
// 再次检查
std::thread::sleep(std::time::Duration::from_millis(500));
let status = get_service_status().await?;
if status.running {
// 强制杀死
let _ = shell::run_command("pkill", &["-9", "-f", "openclaw.*gateway"]);
std::thread::sleep(std::time::Duration::from_millis(500));
let status = get_service_status().await?;
if status.running {
return Err("无法停止服务,请手动处理".to_string());
}
}
Ok("服务已停止".to_string())
}
/// 重启服务
#[command]
pub async fn restart_service() -> Result<String, String> {
// 先停止
let _ = stop_service().await;
// 等待端口释放
std::thread::sleep(std::time::Duration::from_secs(2));
// 再启动
start_service().await
}
/// 获取日志
#[command]
pub async fn get_logs(lines: Option<u32>) -> Result<Vec<String>, String> {
let log_file = platform::get_log_file_path();
let n = lines.unwrap_or(100) as usize;
file::read_last_lines(&log_file, n)
.map_err(|e| format!("读取日志失败: {}", e))
}
+41
View File
@@ -0,0 +1,41 @@
mod commands;
mod models;
mod utils;
use commands::{config, diagnostics, process, service};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_notification::init())
.invoke_handler(tauri::generate_handler![
// 服务管理
service::get_service_status,
service::start_service,
service::stop_service,
service::restart_service,
service::get_logs,
// 进程管理
process::check_openclaw_installed,
process::get_openclaw_version,
process::check_port_in_use,
// 配置管理
config::get_config,
config::save_config,
config::get_env_value,
config::save_env_value,
config::get_ai_providers,
config::get_channels_config,
config::save_channel_config,
// 诊断测试
diagnostics::run_doctor,
diagnostics::test_ai_connection,
diagnostics::test_channel,
diagnostics::get_system_info,
])
.run(tauri::generate_context!())
.expect("运行 Tauri 应用时发生错误");
}
+50
View File
@@ -0,0 +1,50 @@
// 防止 Windows 系统显示控制台窗口
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
mod commands;
mod models;
mod utils;
use commands::{config, diagnostics, process, service};
fn main() {
// 初始化日志
env_logger::init();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_notification::init())
.invoke_handler(tauri::generate_handler![
// 服务管理
service::get_service_status,
service::start_service,
service::stop_service,
service::restart_service,
service::get_logs,
// 进程管理
process::check_openclaw_installed,
process::get_openclaw_version,
process::check_port_in_use,
// 配置管理
config::get_config,
config::save_config,
config::get_env_value,
config::save_env_value,
config::get_ai_providers,
config::get_channels_config,
config::save_channel_config,
// 诊断测试
diagnostics::run_doctor,
diagnostics::test_ai_connection,
diagnostics::test_channel,
diagnostics::get_system_info,
diagnostics::start_channel_login,
])
.run(tauri::generate_context!())
.expect("运行 Tauri 应用时发生错误");
}
+131
View File
@@ -0,0 +1,131 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// OpenClaw 完整配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenClawConfig {
/// 模型配置
#[serde(default)]
pub models: ModelsConfig,
/// 网关配置
#[serde(default)]
pub gateway: GatewayConfig,
/// 身份配置
#[serde(default)]
pub identity: IdentityConfig,
}
/// 模型配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ModelsConfig {
/// 默认模型
#[serde(default)]
pub default: Option<String>,
/// 自定义 Provider
#[serde(default)]
pub providers: HashMap<String, ProviderConfig>,
}
/// Provider 配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
/// API 地址
#[serde(rename = "baseUrl")]
pub base_url: String,
/// API Key
#[serde(rename = "apiKey")]
pub api_key: Option<String>,
/// 模型列表
#[serde(default)]
pub models: Vec<ModelInfo>,
}
/// 模型信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub name: String,
#[serde(default)]
pub api: Option<String>,
#[serde(default)]
pub input: Vec<String>,
#[serde(rename = "contextWindow", default)]
pub context_window: Option<u32>,
#[serde(rename = "maxTokens", default)]
pub max_tokens: Option<u32>,
}
/// 网关配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GatewayConfig {
/// 模式:local 或 cloud
#[serde(default)]
pub mode: Option<String>,
/// 端口
#[serde(default)]
pub port: Option<u16>,
}
/// 身份配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IdentityConfig {
/// Bot 名称
#[serde(default)]
pub bot_name: Option<String>,
/// 用户称呼
#[serde(default)]
pub user_name: Option<String>,
/// 时区
#[serde(default)]
pub timezone: Option<String>,
}
/// AI Provider 选项(用于前端展示)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AIProviderOption {
/// Provider ID
pub id: String,
/// 显示名称
pub name: String,
/// 图标(emoji
pub icon: String,
/// 官方 API 地址
pub default_base_url: Option<String>,
/// 推荐模型列表
pub models: Vec<AIModelOption>,
/// 是否需要 API Key
pub requires_api_key: bool,
}
/// AI 模型选项
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AIModelOption {
/// 模型 ID
pub id: String,
/// 显示名称
pub name: String,
/// 描述
pub description: Option<String>,
/// 是否推荐
pub recommended: bool,
}
/// 渠道配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelConfig {
/// 渠道 ID
pub id: String,
/// 渠道类型
pub channel_type: String,
/// 是否启用
pub enabled: bool,
/// 配置详情
pub config: HashMap<String, serde_json::Value>,
}
/// 环境变量配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvConfig {
pub key: String,
pub value: String,
}
+5
View File
@@ -0,0 +1,5 @@
pub mod config;
pub mod status;
pub use config::*;
pub use status::*;
+93
View File
@@ -0,0 +1,93 @@
use serde::{Deserialize, Serialize};
/// 服务运行状态
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceStatus {
/// 是否正在运行
pub running: bool,
/// 进程 ID
pub pid: Option<u32>,
/// 监听端口
pub port: u16,
/// 运行时长(秒)
pub uptime_seconds: Option<u64>,
/// 内存使用(MB
pub memory_mb: Option<f64>,
/// CPU 使用率
pub cpu_percent: Option<f64>,
}
impl Default for ServiceStatus {
fn default() -> Self {
Self {
running: false,
pid: None,
port: 18789,
uptime_seconds: None,
memory_mb: None,
cpu_percent: None,
}
}
}
/// 系统信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemInfo {
/// 操作系统类型
pub os: String,
/// 操作系统版本
pub os_version: String,
/// 系统架构
pub arch: String,
/// OpenClaw 是否已安装
pub openclaw_installed: bool,
/// OpenClaw 版本
pub openclaw_version: Option<String>,
/// Node.js 版本
pub node_version: Option<String>,
/// 配置目录
pub config_dir: String,
}
/// 诊断结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticResult {
/// 检查项名称
pub name: String,
/// 是否通过
pub passed: bool,
/// 详细信息
pub message: String,
/// 修复建议
pub suggestion: Option<String>,
}
/// AI 连接测试结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AITestResult {
/// 是否成功
pub success: bool,
/// 提供商名称
pub provider: String,
/// 模型名称
pub model: String,
/// 响应内容
pub response: Option<String>,
/// 错误信息
pub error: Option<String>,
/// 响应时间(毫秒)
pub latency_ms: Option<u64>,
}
/// 渠道测试结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelTestResult {
/// 是否成功
pub success: bool,
/// 渠道名称
pub channel: String,
/// 消息
pub message: String,
/// 错误信息
pub error: Option<String>,
}
+86
View File
@@ -0,0 +1,86 @@
use std::fs;
use std::io::{self, BufRead, BufReader};
use std::path::Path;
/// 读取文件内容
pub fn read_file(path: &str) -> io::Result<String> {
fs::read_to_string(path)
}
/// 写入文件内容
pub fn write_file(path: &str, content: &str) -> io::Result<()> {
// 确保父目录存在
if let Some(parent) = Path::new(path).parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)
}
/// 追加文件内容
pub fn append_file(path: &str, content: &str) -> io::Result<()> {
use std::fs::OpenOptions;
use std::io::Write;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "{}", content)
}
/// 检查文件是否存在
pub fn file_exists(path: &str) -> bool {
Path::new(path).exists()
}
/// 读取文件最后 N 行
pub fn read_last_lines(path: &str, n: usize) -> io::Result<Vec<String>> {
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let lines: Vec<String> = reader.lines().filter_map(|l| l.ok()).collect();
let start = if lines.len() > n { lines.len() - n } else { 0 };
Ok(lines[start..].to_vec())
}
/// 从环境变量文件读取值
pub fn read_env_value(env_file: &str, key: &str) -> Option<String> {
let content = read_file(env_file).ok()?;
for line in content.lines() {
let line = line.trim();
if line.starts_with(&format!("export {}=", key)) {
let value = line
.trim_start_matches(&format!("export {}=", key))
.trim_matches('"')
.trim_matches('\'');
return Some(value.to_string());
}
}
None
}
/// 设置环境变量文件中的值
pub fn set_env_value(env_file: &str, key: &str, value: &str) -> io::Result<()> {
let content = read_file(env_file).unwrap_or_default();
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let new_line = format!("export {}=\"{}\"", key, value);
let mut found = false;
for line in &mut lines {
if line.starts_with(&format!("export {}=", key)) {
*line = new_line.clone();
found = true;
break;
}
}
if !found {
lines.push(new_line);
}
write_file(env_file, &lines.join("\n"))
}
+3
View File
@@ -0,0 +1,3 @@
pub mod file;
pub mod platform;
pub mod shell;
+50
View File
@@ -0,0 +1,50 @@
use std::env;
/// 获取操作系统类型
pub fn get_os() -> String {
env::consts::OS.to_string()
}
/// 获取系统架构
pub fn get_arch() -> String {
env::consts::ARCH.to_string()
}
/// 获取配置目录路径
pub fn get_config_dir() -> String {
if let Some(home) = dirs::home_dir() {
format!("{}/.openclaw", home.display())
} else {
String::from("~/.openclaw")
}
}
/// 获取环境变量文件路径
pub fn get_env_file_path() -> String {
format!("{}/env", get_config_dir())
}
/// 获取 openclaw.json 配置文件路径
pub fn get_config_file_path() -> String {
format!("{}/openclaw.json", get_config_dir())
}
/// 获取日志文件路径
pub fn get_log_file_path() -> String {
String::from("/tmp/openclaw-gateway.log")
}
/// 检测当前平台是否为 macOS
pub fn is_macos() -> bool {
env::consts::OS == "macos"
}
/// 检测当前平台是否为 Windows
pub fn is_windows() -> bool {
env::consts::OS == "windows"
}
/// 检测当前平台是否为 Linux
pub fn is_linux() -> bool {
env::consts::OS == "linux"
}
+68
View File
@@ -0,0 +1,68 @@
use std::process::{Command, Output};
use std::io;
/// 执行 Shell 命令
pub fn run_command(cmd: &str, args: &[&str]) -> io::Result<Output> {
Command::new(cmd)
.args(args)
.output()
}
/// 执行 Shell 命令并获取输出字符串
pub fn run_command_output(cmd: &str, args: &[&str]) -> Result<String, String> {
match run_command(cmd, args) {
Ok(output) => {
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
Err(e) => Err(e.to_string()),
}
}
/// 执行 Bash 命令
pub fn run_bash(script: &str) -> io::Result<Output> {
Command::new("bash")
.arg("-c")
.arg(script)
.output()
}
/// 执行 Bash 命令并获取输出
pub fn run_bash_output(script: &str) -> Result<String, String> {
match run_bash(script) {
Ok(output) => {
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
Err(format!("Command failed with exit code: {:?}", output.status.code()))
} else {
Err(stderr)
}
}
}
Err(e) => Err(e.to_string()),
}
}
/// 后台执行命令(不等待结果)
pub fn spawn_background(script: &str) -> io::Result<()> {
Command::new("bash")
.arg("-c")
.arg(script)
.spawn()?;
Ok(())
}
/// 检查命令是否存在
pub fn command_exists(cmd: &str) -> bool {
Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
+66
View File
@@ -0,0 +1,66 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenClaw Manager",
"version": "1.0.0",
"identifier": "com.openclaw.manager",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "OpenClaw Manager",
"label": "main",
"width": 1200,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"center": true,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"category": "Utility",
"shortDescription": "OpenClaw AI 助手管理工具",
"longDescription": "OpenClaw Manager 是一个跨平台的高性能管理工具,用于配置和管理 OpenClaw AI 助手服务。",
"copyright": "Copyright © 2024 OpenClaw Team",
"macOS": {
"minimumSystemVersion": "10.15",
"entitlements": null,
"exceptionDomain": null
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
},
"linux": {
"appimage": {
"bundleMediaFramework": true
},
"deb": {
"depends": []
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Sidebar } from './components/Layout/Sidebar';
import { Header } from './components/Layout/Header';
import { Dashboard } from './components/Dashboard';
import { AIConfig } from './components/AIConfig';
import { Channels } from './components/Channels';
import { ServiceManager } from './components/Service';
import { Settings } from './components/Settings';
import { Testing } from './components/Testing';
export type PageType = 'dashboard' | 'ai' | 'channels' | 'service' | 'testing' | 'settings';
function App() {
const [currentPage, setCurrentPage] = useState<PageType>('dashboard');
const renderPage = () => {
const pageVariants = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
};
const pages: Record<PageType, JSX.Element> = {
dashboard: <Dashboard />,
ai: <AIConfig />,
channels: <Channels />,
service: <ServiceManager />,
testing: <Testing />,
settings: <Settings />,
};
return (
<AnimatePresence mode="wait">
<motion.div
key={currentPage}
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2 }}
className="h-full"
>
{pages[currentPage]}
</motion.div>
</AnimatePresence>
);
};
return (
<div className="flex h-screen bg-dark-900 overflow-hidden">
{/* 背景装饰 */}
<div className="fixed inset-0 bg-gradient-radial pointer-events-none" />
{/* 侧边栏 */}
<Sidebar currentPage={currentPage} onNavigate={setCurrentPage} />
{/* 主内容区 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 标题栏(macOS 拖拽区域) */}
<Header currentPage={currentPage} />
{/* 页面内容 */}
<main className="flex-1 overflow-hidden p-6">
{renderPage()}
</main>
</div>
</div>
);
}
export default App;
+373
View File
@@ -0,0 +1,373 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { invoke } from '@tauri-apps/api/core';
import { Check, Eye, EyeOff, Loader2, RefreshCw } from 'lucide-react';
import clsx from 'clsx';
interface AIModelOption {
id: string;
name: string;
description: string | null;
recommended: boolean;
}
interface AIProviderOption {
id: string;
name: string;
icon: string;
default_base_url: string | null;
models: AIModelOption[];
requires_api_key: boolean;
}
// 环境变量 Key 映射
const ENV_KEY_MAP: Record<string, { apiKey: string; baseUrl: string }> = {
anthropic: { apiKey: 'ANTHROPIC_API_KEY', baseUrl: 'ANTHROPIC_BASE_URL' },
openai: { apiKey: 'OPENAI_API_KEY', baseUrl: 'OPENAI_BASE_URL' },
deepseek: { apiKey: 'DEEPSEEK_API_KEY', baseUrl: 'DEEPSEEK_BASE_URL' },
kimi: { apiKey: 'MOONSHOT_API_KEY', baseUrl: 'MOONSHOT_BASE_URL' },
google: { apiKey: 'GOOGLE_API_KEY', baseUrl: 'GOOGLE_BASE_URL' },
openrouter: { apiKey: 'OPENAI_API_KEY', baseUrl: 'OPENAI_BASE_URL' },
groq: { apiKey: 'OPENAI_API_KEY', baseUrl: 'OPENAI_BASE_URL' },
ollama: { apiKey: 'OLLAMA_HOST', baseUrl: 'OLLAMA_HOST' },
};
export function AIConfig() {
const [providers, setProviders] = useState<AIProviderOption[]>([]);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<string>('');
const [apiKey, setApiKey] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [showApiKey, setShowApiKey] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// 存储已保存的配置
const [savedConfigs, setSavedConfigs] = useState<Record<string, { apiKey: string; baseUrl: string }>>({});
// 加载所有 provider 的已保存配置
const loadSavedConfigs = async () => {
const configs: Record<string, { apiKey: string; baseUrl: string }> = {};
for (const [providerId, keys] of Object.entries(ENV_KEY_MAP)) {
try {
const savedApiKey = await invoke<string | null>('get_env_value', { key: keys.apiKey });
const savedBaseUrl = await invoke<string | null>('get_env_value', { key: keys.baseUrl });
configs[providerId] = {
apiKey: savedApiKey || '',
baseUrl: savedBaseUrl || '',
};
} catch (e) {
configs[providerId] = { apiKey: '', baseUrl: '' };
}
}
setSavedConfigs(configs);
return configs;
};
useEffect(() => {
const init = async () => {
try {
// 获取 Provider 列表
const result = await invoke<AIProviderOption[]>('get_ai_providers');
setProviders(result);
// 加载已保存的配置
const configs = await loadSavedConfigs();
// 自动选择已配置的 provider
for (const [providerId, config] of Object.entries(configs)) {
if (config.apiKey) {
setSelectedProvider(providerId);
setApiKey(config.apiKey);
setBaseUrl(config.baseUrl);
// 设置默认模型
const provider = result.find((p) => p.id === providerId);
if (provider) {
const recommended = provider.models.find((m) => m.recommended);
setSelectedModel(recommended?.id || provider.models[0]?.id || '');
}
break;
}
}
} catch (e) {
console.error('初始化失败:', e);
} finally {
setLoading(false);
}
};
init();
}, []);
const currentProvider = providers.find((p) => p.id === selectedProvider);
const handleProviderSelect = (providerId: string) => {
setSelectedProvider(providerId);
const provider = providers.find((p) => p.id === providerId);
if (provider) {
// 优先使用已保存的配置
const saved = savedConfigs[providerId];
if (saved?.apiKey) {
setApiKey(saved.apiKey);
setBaseUrl(saved.baseUrl);
} else {
// 没有保存的配置时,清空并使用默认值作为 placeholder
setApiKey('');
setBaseUrl('');
}
// 设置推荐模型
const recommended = provider.models.find((m) => m.recommended);
setSelectedModel(recommended?.id || provider.models[0]?.id || '');
}
};
const handleSave = async () => {
if (!selectedProvider || !selectedModel) return;
setSaving(true);
try {
const keys = ENV_KEY_MAP[selectedProvider];
// 保存 API Key
if (apiKey) {
await invoke('save_env_value', { key: keys.apiKey, value: apiKey });
}
// 保存 Base URL(即使是空的也保存,以便清除旧配置)
await invoke('save_env_value', { key: keys.baseUrl, value: baseUrl });
// 更新本地缓存
setSavedConfigs((prev) => ({
...prev,
[selectedProvider]: { apiKey, baseUrl },
}));
alert('配置已保存!请重启服务使配置生效。');
} catch (e) {
console.error('保存失败:', e);
alert('保存失败: ' + e);
} finally {
setSaving(false);
}
};
// 重置为默认地址
const handleResetBaseUrl = () => {
if (currentProvider) {
setBaseUrl('');
}
};
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-claw-500" />
</div>
);
}
return (
<div className="h-full overflow-y-auto scroll-container pr-2">
<div className="max-w-4xl space-y-6">
{/* Provider 选择 */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<h3 className="text-lg font-semibold text-white mb-4">
AI
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{providers.map((provider) => {
const hasSavedConfig = !!savedConfigs[provider.id]?.apiKey;
return (
<button
key={provider.id}
onClick={() => handleProviderSelect(provider.id)}
className={clsx(
'relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all',
selectedProvider === provider.id
? 'bg-claw-500/20 border-claw-500 text-white'
: 'bg-dark-600 border-dark-500 text-gray-400 hover:border-dark-400'
)}
>
<span className="text-2xl">{provider.icon}</span>
<span className="text-sm font-medium">{provider.name}</span>
{/* 已配置指示器 */}
{hasSavedConfig && (
<div className="absolute top-2 right-2">
<div className="w-2 h-2 rounded-full bg-green-500" title="已配置" />
</div>
)}
{selectedProvider === provider.id && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute bottom-2 right-2"
>
<Check size={14} className="text-claw-400" />
</motion.div>
)}
</button>
);
})}
</div>
</div>
{/* 配置表单 */}
{currentProvider && (
<motion.div
key={selectedProvider}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-dark-700 rounded-2xl p-6 border border-dark-500"
>
<h3 className="text-lg font-semibold text-white mb-4">
{currentProvider.name}
</h3>
<div className="space-y-4">
{/* API Key */}
{currentProvider.requires_api_key && (
<div>
<label className="block text-sm text-gray-400 mb-2">
API Key
{savedConfigs[currentProvider.id]?.apiKey && (
<span className="ml-2 text-green-500 text-xs"> </span>
)}
</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="输入 API Key"
className="input-base pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
{showApiKey ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
)}
{/* Base URL */}
<div>
<label className="block text-sm text-gray-400 mb-2">
API
{savedConfigs[currentProvider.id]?.baseUrl ? (
<span className="ml-2 text-cyan-400 text-xs"> </span>
) : (
<span className="text-gray-600 ml-2">(使)</span>
)}
</label>
<div className="relative">
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={currentProvider.default_base_url || '输入自定义 API 地址'}
className="input-base pr-10"
/>
{baseUrl && (
<button
type="button"
onClick={handleResetBaseUrl}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
title="重置为默认地址"
>
<RefreshCw size={16} />
</button>
)}
</div>
{/* 当前生效的地址 */}
<p className="text-xs text-gray-500 mt-1">
: {baseUrl || currentProvider.default_base_url || '(使用 SDK 默认)'}
</p>
</div>
{/* 模型选择 */}
<div>
<label className="block text-sm text-gray-400 mb-2">
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{currentProvider.models.map((model) => (
<button
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={clsx(
'flex items-center justify-between p-3 rounded-lg border transition-all text-left',
selectedModel === model.id
? 'bg-claw-500/20 border-claw-500'
: 'bg-dark-600 border-dark-500 hover:border-dark-400'
)}
>
<div>
<p
className={clsx(
'text-sm font-medium',
selectedModel === model.id
? 'text-white'
: 'text-gray-300'
)}
>
{model.name}
{model.recommended && (
<span className="ml-2 text-xs text-claw-400">
</span>
)}
</p>
{model.description && (
<p className="text-xs text-gray-500 mt-1">
{model.description}
</p>
)}
</div>
{selectedModel === model.id && (
<Check size={16} className="text-claw-400" />
)}
</button>
))}
</div>
</div>
{/* 保存按钮 */}
<div className="pt-4 border-t border-dark-500 flex items-center justify-between">
<button
onClick={handleSave}
disabled={saving || (!apiKey && currentProvider.requires_api_key)}
className="btn-primary flex items-center gap-2"
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Check size={16} />
)}
</button>
{savedConfigs[currentProvider.id]?.apiKey && (
<span className="text-xs text-gray-500">
</span>
)}
</div>
</div>
</motion.div>
)}
</div>
</div>
);
}
+676
View File
@@ -0,0 +1,676 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { invoke } from '@tauri-apps/api/core';
import {
MessageCircle,
Hash,
Slack,
MessagesSquare,
MessageSquare,
Check,
X,
Loader2,
ChevronRight,
Apple,
Bell,
Eye,
EyeOff,
Play,
QrCode,
CheckCircle,
XCircle,
} from 'lucide-react';
import clsx from 'clsx';
interface ChannelConfig {
id: string;
channel_type: string;
enabled: boolean;
config: Record<string, unknown>;
}
// 渠道配置字段定义
interface ChannelField {
key: string;
label: string;
type: 'text' | 'password' | 'select';
placeholder?: string;
options?: { value: string; label: string }[];
required?: boolean;
}
const channelInfo: Record<
string,
{
name: string;
icon: React.ReactNode;
color: string;
fields: ChannelField[];
helpText?: string;
}
> = {
telegram: {
name: 'Telegram',
icon: <MessageCircle size={20} />,
color: 'text-blue-400',
fields: [
{ key: 'botToken', label: 'Bot Token', type: 'password', placeholder: '从 @BotFather 获取', required: true },
{ key: 'userId', label: 'User ID', type: 'text', placeholder: '你的 Telegram User ID', required: true },
{ key: 'dmPolicy', label: '私聊策略', type: 'select', options: [
{ value: 'pairing', label: '配对模式' },
{ value: 'open', label: '开放模式' },
{ value: 'disabled', label: '禁用' },
]},
{ key: 'groupPolicy', label: '群组策略', type: 'select', options: [
{ value: 'allowlist', label: '白名单' },
{ value: 'open', label: '开放' },
{ value: 'disabled', label: '禁用' },
]},
],
helpText: '1. 搜索 @BotFather 发送 /newbot 获取 Token 2. 搜索 @userinfobot 获取 User ID',
},
discord: {
name: 'Discord',
icon: <Hash size={20} />,
color: 'text-indigo-400',
fields: [
{ key: 'botToken', label: 'Bot Token', type: 'password', placeholder: 'Discord Bot Token', required: true },
{ key: 'testChannelId', label: '测试 Channel ID', type: 'text', placeholder: '用于发送测试消息的频道 ID (可选)' },
{ key: 'dmPolicy', label: '私聊策略', type: 'select', options: [
{ value: 'pairing', label: '配对模式' },
{ value: 'open', label: '开放模式' },
{ value: 'disabled', label: '禁用' },
]},
],
helpText: '从 Discord Developer Portal 获取,开启开发者模式可复制 Channel ID',
},
slack: {
name: 'Slack',
icon: <Slack size={20} />,
color: 'text-purple-400',
fields: [
{ key: 'botToken', label: 'Bot Token', type: 'password', placeholder: 'xoxb-...', required: true },
{ key: 'appToken', label: 'App Token', type: 'password', placeholder: 'xapp-...' },
{ key: 'testChannelId', label: '测试 Channel ID', type: 'text', placeholder: '用于发送测试消息的频道 ID (可选)' },
],
helpText: '从 Slack API 后台获取,Channel ID 可从频道详情复制',
},
feishu: {
name: '飞书',
icon: <MessagesSquare size={20} />,
color: 'text-blue-500',
fields: [
{ key: 'appId', label: 'App ID', type: 'text', placeholder: '飞书应用 App ID', required: true },
{ key: 'appSecret', label: 'App Secret', type: 'password', placeholder: '飞书应用 App Secret', required: true },
{ key: 'testChatId', label: '测试 Chat ID', type: 'text', placeholder: '用于发送测试消息的群聊/用户 ID (可选)' },
{ key: 'connectionMode', label: '连接模式', type: 'select', options: [
{ value: 'websocket', label: 'WebSocket (推荐)' },
{ value: 'webhook', label: 'Webhook' },
]},
{ key: 'domain', label: '部署区域', type: 'select', options: [
{ value: 'feishu', label: '国内 (feishu.cn)' },
{ value: 'lark', label: '海外 (larksuite.com)' },
]},
{ key: 'requireMention', label: '需要 @提及', type: 'select', options: [
{ value: 'true', label: '是' },
{ value: 'false', label: '否' },
]},
],
helpText: '从飞书开放平台获取凭证,Chat ID 可从群聊设置中获取',
},
imessage: {
name: 'iMessage',
icon: <Apple size={20} />,
color: 'text-green-400',
fields: [
{ key: 'dmPolicy', label: '私聊策略', type: 'select', options: [
{ value: 'pairing', label: '配对模式' },
{ value: 'open', label: '开放模式' },
{ value: 'disabled', label: '禁用' },
]},
{ key: 'groupPolicy', label: '群组策略', type: 'select', options: [
{ value: 'allowlist', label: '白名单' },
{ value: 'open', label: '开放' },
{ value: 'disabled', label: '禁用' },
]},
],
helpText: '仅支持 macOS,需要授权消息访问权限',
},
whatsapp: {
name: 'WhatsApp',
icon: <MessageCircle size={20} />,
color: 'text-green-500',
fields: [
{ key: 'dmPolicy', label: '私聊策略', type: 'select', options: [
{ value: 'pairing', label: '配对模式' },
{ value: 'open', label: '开放模式' },
{ value: 'disabled', label: '禁用' },
]},
{ key: 'groupPolicy', label: '群组策略', type: 'select', options: [
{ value: 'allowlist', label: '白名单' },
{ value: 'open', label: '开放' },
{ value: 'disabled', label: '禁用' },
]},
],
helpText: '需要扫描二维码登录,运行: openclaw channels login --channel whatsapp',
},
wechat: {
name: '微信',
icon: <MessageSquare size={20} />,
color: 'text-green-600',
fields: [
{ key: 'appId', label: 'App ID', type: 'text', placeholder: '微信开放平台 App ID' },
{ key: 'appSecret', label: 'App Secret', type: 'password', placeholder: '微信开放平台 App Secret' },
],
helpText: '微信公众号/企业微信配置',
},
dingtalk: {
name: '钉钉',
icon: <Bell size={20} />,
color: 'text-blue-600',
fields: [
{ key: 'appKey', label: 'App Key', type: 'text', placeholder: '钉钉应用 App Key' },
{ key: 'appSecret', label: 'App Secret', type: 'password', placeholder: '钉钉应用 App Secret' },
],
helpText: '从钉钉开放平台获取',
},
};
interface TestResult {
success: boolean;
message: string;
error: string | null;
}
export function Channels() {
const [channels, setChannels] = useState<ChannelConfig[]>([]);
const [loading, setLoading] = useState(true);
const [selectedChannel, setSelectedChannel] = useState<string | null>(null);
const [configForm, setConfigForm] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [loginLoading, setLoginLoading] = useState(false);
// 跟踪哪些密码字段显示明文
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
const togglePasswordVisibility = (fieldKey: string) => {
setVisiblePasswords((prev) => {
const next = new Set(prev);
if (next.has(fieldKey)) {
next.delete(fieldKey);
} else {
next.add(fieldKey);
}
return next;
});
};
// 快速测试
const handleQuickTest = async () => {
if (!selectedChannel) return;
setTesting(true);
setTestResult(null);
try {
const result = await invoke<{
success: boolean;
channel: string;
message: string;
error: string | null;
}>('test_channel', { channelType: selectedChannel });
setTestResult({
success: result.success,
message: result.message,
error: result.error,
});
} catch (e) {
setTestResult({
success: false,
message: '测试失败',
error: String(e),
});
} finally {
setTesting(false);
}
};
// WhatsApp 扫码登录
const handleWhatsAppLogin = async () => {
setLoginLoading(true);
try {
// 调用后端命令启动 WhatsApp 登录
await invoke('start_channel_login', { channelType: 'whatsapp' });
// 开始轮询检查登录状态
const pollInterval = setInterval(async () => {
try {
const result = await invoke<{
success: boolean;
message: string;
}>('test_channel', { channelType: 'whatsapp' });
if (result.success) {
clearInterval(pollInterval);
setLoginLoading(false);
// 刷新渠道列表
await fetchChannels();
setTestResult({
success: true,
message: 'WhatsApp 登录成功!',
error: null,
});
}
} catch {
// 继续轮询
}
}, 3000); // 每3秒检查一次
// 60秒后停止轮询
setTimeout(() => {
clearInterval(pollInterval);
setLoginLoading(false);
}, 60000);
alert('请在弹出的终端窗口中扫描二维码完成登录\n\n登录成功后界面会自动更新');
} catch (e) {
alert('启动登录失败: ' + e);
setLoginLoading(false);
}
};
const fetchChannels = async () => {
try {
const result = await invoke<ChannelConfig[]>('get_channels_config');
setChannels(result);
return result;
} catch (e) {
console.error('获取渠道配置失败:', e);
return [];
}
};
useEffect(() => {
const init = async () => {
try {
const result = await fetchChannels();
// 自动选择第一个已配置的渠道
const configured = result.find((c) => c.enabled);
if (configured) {
handleChannelSelect(configured.id, result);
}
} finally {
setLoading(false);
}
};
init();
}, []);
const handleChannelSelect = (channelId: string, channelList?: ChannelConfig[]) => {
setSelectedChannel(channelId);
setTestResult(null); // 清除测试结果
const list = channelList || channels;
const channel = list.find((c) => c.id === channelId);
if (channel) {
const form: Record<string, string> = {};
Object.entries(channel.config).forEach(([key, value]) => {
// 处理布尔值
if (typeof value === 'boolean') {
form[key] = value ? 'true' : 'false';
} else {
form[key] = String(value ?? '');
}
});
setConfigForm(form);
} else {
setConfigForm({});
}
};
const handleSave = async () => {
if (!selectedChannel) return;
setSaving(true);
try {
const channel = channels.find((c) => c.id === selectedChannel);
if (!channel) return;
// 转换表单值
const config: Record<string, unknown> = {};
Object.entries(configForm).forEach(([key, value]) => {
if (value === 'true') {
config[key] = true;
} else if (value === 'false') {
config[key] = false;
} else if (value) {
config[key] = value;
}
});
await invoke('save_channel_config', {
channel: {
...channel,
config,
},
});
// 刷新列表
await fetchChannels();
alert('渠道配置已保存!');
} catch (e) {
console.error('保存失败:', e);
alert('保存失败: ' + e);
} finally {
setSaving(false);
}
};
const currentChannel = channels.find((c) => c.id === selectedChannel);
const currentInfo = currentChannel ? channelInfo[currentChannel.channel_type] : null;
// 检查渠道是否有有效配置
const hasValidConfig = (channel: ChannelConfig) => {
const info = channelInfo[channel.channel_type];
if (!info) return channel.enabled;
// 检查是否有必填字段已填写
const requiredFields = info.fields.filter((f) => f.required);
if (requiredFields.length === 0) return channel.enabled;
return requiredFields.some((field) => {
const value = channel.config[field.key];
return value !== undefined && value !== null && value !== '';
});
};
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-claw-500" />
</div>
);
}
return (
<div className="h-full overflow-y-auto scroll-container pr-2">
<div className="max-w-4xl">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* 渠道列表 */}
<div className="md:col-span-1 space-y-2">
<h3 className="text-sm font-medium text-gray-400 mb-3 px-1">
</h3>
{channels.map((channel) => {
const info = channelInfo[channel.channel_type] || {
name: channel.channel_type,
icon: <MessageSquare size={20} />,
color: 'text-gray-400',
fields: [],
};
const isSelected = selectedChannel === channel.id;
const isConfigured = hasValidConfig(channel);
return (
<button
key={channel.id}
onClick={() => handleChannelSelect(channel.id)}
className={clsx(
'w-full flex items-center gap-3 p-4 rounded-xl border transition-all',
isSelected
? 'bg-dark-600 border-claw-500'
: 'bg-dark-700 border-dark-500 hover:border-dark-400'
)}
>
<div
className={clsx(
'w-10 h-10 rounded-lg flex items-center justify-center',
isConfigured ? 'bg-dark-500' : 'bg-dark-600'
)}
>
<span className={info.color}>{info.icon}</span>
</div>
<div className="flex-1 text-left">
<p
className={clsx(
'text-sm font-medium',
isSelected ? 'text-white' : 'text-gray-300'
)}
>
{info.name}
</p>
<div className="flex items-center gap-2 mt-1">
{isConfigured ? (
<>
<Check size={12} className="text-green-400" />
<span className="text-xs text-green-400"></span>
</>
) : (
<>
<X size={12} className="text-gray-500" />
<span className="text-xs text-gray-500"></span>
</>
)}
</div>
</div>
<ChevronRight
size={16}
className={isSelected ? 'text-claw-400' : 'text-gray-600'}
/>
</button>
);
})}
</div>
{/* 配置面板 */}
<div className="md:col-span-2">
{currentChannel && currentInfo ? (
<motion.div
key={selectedChannel}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="bg-dark-700 rounded-2xl p-6 border border-dark-500"
>
<div className="flex items-center gap-3 mb-4">
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center bg-dark-500', currentInfo.color)}>
{currentInfo.icon}
</div>
<div>
<h3 className="text-lg font-semibold text-white">
{currentInfo.name}
</h3>
{currentInfo.helpText && (
<p className="text-xs text-gray-500">{currentInfo.helpText}</p>
)}
</div>
</div>
<div className="space-y-4">
{currentInfo.fields.map((field) => (
<div key={field.key}>
<label className="block text-sm text-gray-400 mb-2">
{field.label}
{field.required && <span className="text-red-400 ml-1">*</span>}
{configForm[field.key] && (
<span className="ml-2 text-green-500 text-xs"></span>
)}
</label>
{field.type === 'select' ? (
<select
value={configForm[field.key] || ''}
onChange={(e) =>
setConfigForm({ ...configForm, [field.key]: e.target.value })
}
className="input-base"
>
<option value="">...</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : field.type === 'password' ? (
<div className="relative">
<input
type={visiblePasswords.has(field.key) ? 'text' : 'password'}
value={configForm[field.key] || ''}
onChange={(e) =>
setConfigForm({ ...configForm, [field.key]: e.target.value })
}
placeholder={field.placeholder}
className="input-base pr-10"
/>
<button
type="button"
onClick={() => togglePasswordVisibility(field.key)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white transition-colors"
title={visiblePasswords.has(field.key) ? '隐藏' : '显示'}
>
{visiblePasswords.has(field.key) ? (
<EyeOff size={18} />
) : (
<Eye size={18} />
)}
</button>
</div>
) : (
<input
type={field.type}
value={configForm[field.key] || ''}
onChange={(e) =>
setConfigForm({ ...configForm, [field.key]: e.target.value })
}
placeholder={field.placeholder}
className="input-base"
/>
)}
</div>
))}
{/* WhatsApp 特殊处理:扫码登录按钮 */}
{currentChannel.channel_type === 'whatsapp' && (
<div className="p-4 bg-green-500/10 rounded-xl border border-green-500/30">
<div className="flex items-center gap-3 mb-3">
<QrCode size={24} className="text-green-400" />
<div>
<p className="text-white font-medium"></p>
<p className="text-xs text-gray-400">WhatsApp </p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleWhatsAppLogin}
disabled={loginLoading}
className="flex-1 btn-secondary flex items-center justify-center gap-2"
>
{loginLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<QrCode size={16} />
)}
{loginLoading ? '等待登录...' : '启动扫码登录'}
</button>
<button
onClick={async () => {
await fetchChannels();
handleQuickTest();
}}
disabled={testing}
className="btn-secondary flex items-center justify-center gap-2 px-4"
title="刷新状态"
>
{testing ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Check size={16} />
)}
</button>
</div>
<p className="text-xs text-gray-500 mt-2 text-center">
或运行: openclaw channels login --channel whatsapp
</p>
</div>
)}
{/* 操作按钮 */}
<div className="pt-4 border-t border-dark-500 flex flex-wrap items-center gap-3">
<button
onClick={handleSave}
disabled={saving}
className="btn-primary flex items-center gap-2"
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Check size={16} />
)}
</button>
{/* 快速测试按钮 */}
<button
onClick={handleQuickTest}
disabled={testing}
className="btn-secondary flex items-center gap-2"
>
{testing ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Play size={16} />
)}
</button>
</div>
{/* 测试结果显示 */}
{testResult && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={clsx(
'mt-4 p-4 rounded-xl flex items-start gap-3',
testResult.success ? 'bg-green-500/10' : 'bg-red-500/10'
)}
>
{testResult.success ? (
<CheckCircle size={20} className="text-green-400 mt-0.5" />
) : (
<XCircle size={20} className="text-red-400 mt-0.5" />
)}
<div className="flex-1">
<p className={clsx(
'font-medium',
testResult.success ? 'text-green-400' : 'text-red-400'
)}>
{testResult.success ? '测试成功' : '测试失败'}
</p>
<p className="text-sm text-gray-400 mt-1">{testResult.message}</p>
{testResult.error && (
<p className="text-xs text-red-300 mt-2 whitespace-pre-wrap">
{testResult.error}
</p>
)}
</div>
</motion.div>
)}
</div>
</motion.div>
) : (
<div className="h-full flex items-center justify-center text-gray-500">
<p></p>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { Play, Square, RotateCcw, FileText, Stethoscope } from 'lucide-react';
import clsx from 'clsx';
interface ServiceStatus {
running: boolean;
pid: number | null;
port: number;
}
interface QuickActionsProps {
status: ServiceStatus | null;
loading: boolean;
onStart: () => void;
onStop: () => void;
onRestart: () => void;
}
export function QuickActions({
status,
loading,
onStart,
onStop,
onRestart,
}: QuickActionsProps) {
const isRunning = status?.running || false;
return (
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<h3 className="text-lg font-semibold text-white mb-4"></h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* 启动按钮 */}
<button
onClick={onStart}
disabled={loading || isRunning}
className={clsx(
'flex flex-col items-center gap-3 p-4 rounded-xl transition-all',
'border border-dark-500',
isRunning
? 'bg-dark-600 opacity-50 cursor-not-allowed'
: 'bg-dark-600 hover:bg-green-500/20 hover:border-green-500/50'
)}
>
<div
className={clsx(
'w-12 h-12 rounded-full flex items-center justify-center',
isRunning ? 'bg-dark-500' : 'bg-green-500/20'
)}
>
<Play
size={20}
className={isRunning ? 'text-gray-500' : 'text-green-400'}
/>
</div>
<span
className={clsx(
'text-sm font-medium',
isRunning ? 'text-gray-500' : 'text-gray-300'
)}
>
</span>
</button>
{/* 停止按钮 */}
<button
onClick={onStop}
disabled={loading || !isRunning}
className={clsx(
'flex flex-col items-center gap-3 p-4 rounded-xl transition-all',
'border border-dark-500',
!isRunning
? 'bg-dark-600 opacity-50 cursor-not-allowed'
: 'bg-dark-600 hover:bg-red-500/20 hover:border-red-500/50'
)}
>
<div
className={clsx(
'w-12 h-12 rounded-full flex items-center justify-center',
!isRunning ? 'bg-dark-500' : 'bg-red-500/20'
)}
>
<Square
size={20}
className={!isRunning ? 'text-gray-500' : 'text-red-400'}
/>
</div>
<span
className={clsx(
'text-sm font-medium',
!isRunning ? 'text-gray-500' : 'text-gray-300'
)}
>
</span>
</button>
{/* 重启按钮 */}
<button
onClick={onRestart}
disabled={loading}
className={clsx(
'flex flex-col items-center gap-3 p-4 rounded-xl transition-all',
'border border-dark-500',
'bg-dark-600 hover:bg-amber-500/20 hover:border-amber-500/50'
)}
>
<div className="w-12 h-12 rounded-full flex items-center justify-center bg-amber-500/20">
<RotateCcw
size={20}
className={clsx('text-amber-400', loading && 'animate-spin')}
/>
</div>
<span className="text-sm font-medium text-gray-300"></span>
</button>
{/* 诊断按钮 */}
<button
disabled={loading}
className={clsx(
'flex flex-col items-center gap-3 p-4 rounded-xl transition-all',
'border border-dark-500',
'bg-dark-600 hover:bg-purple-500/20 hover:border-purple-500/50'
)}
>
<div className="w-12 h-12 rounded-full flex items-center justify-center bg-purple-500/20">
<Stethoscope size={20} className="text-purple-400" />
</div>
<span className="text-sm font-medium text-gray-300"></span>
</button>
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Activity, Cpu, HardDrive, Clock } from 'lucide-react';
import clsx from 'clsx';
interface ServiceStatus {
running: boolean;
pid: number | null;
port: number;
uptime_seconds: number | null;
memory_mb: number | null;
cpu_percent: number | null;
}
interface StatusCardProps {
status: ServiceStatus | null;
loading: boolean;
}
export function StatusCard({ status, loading }: StatusCardProps) {
const formatUptime = (seconds: number | null) => {
if (!seconds) return '--';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
return (
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white"></h3>
<div className="flex items-center gap-2">
<div
className={clsx(
'status-dot',
loading ? 'warning' : status?.running ? 'running' : 'stopped'
)}
/>
<span
className={clsx(
'text-sm font-medium',
loading
? 'text-yellow-400'
: status?.running
? 'text-green-400'
: 'text-red-400'
)}
>
{loading ? '检测中...' : status?.running ? '运行中' : '已停止'}
</span>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-dark-600 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Activity size={16} className="text-accent-cyan" />
<span className="text-xs text-gray-400"></span>
</div>
<p className="text-xl font-semibold text-white">
{status?.port || 18789}
</p>
</div>
<div className="bg-dark-600 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Cpu size={16} className="text-accent-purple" />
<span className="text-xs text-gray-400"> ID</span>
</div>
<p className="text-xl font-semibold text-white">
{status?.pid || '--'}
</p>
</div>
<div className="bg-dark-600 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<HardDrive size={16} className="text-accent-green" />
<span className="text-xs text-gray-400"></span>
</div>
<p className="text-xl font-semibold text-white">
{status?.memory_mb ? `${status.memory_mb.toFixed(1)} MB` : '--'}
</p>
</div>
<div className="bg-dark-600 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Clock size={16} className="text-accent-amber" />
<span className="text-xs text-gray-400"></span>
</div>
<p className="text-xl font-semibold text-white">
{formatUptime(status?.uptime_seconds || null)}
</p>
</div>
</div>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Monitor, Package, Folder, CheckCircle, XCircle } from 'lucide-react';
interface SystemInfoData {
os: string;
os_version: string;
arch: string;
openclaw_installed: boolean;
openclaw_version: string | null;
node_version: string | null;
config_dir: string;
}
export function SystemInfo() {
const [info, setInfo] = useState<SystemInfoData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchInfo = async () => {
try {
const result = await invoke<SystemInfoData>('get_system_info');
setInfo(result);
} catch (e) {
console.error('获取系统信息失败:', e);
} finally {
setLoading(false);
}
};
fetchInfo();
}, []);
const getOSLabel = (os: string) => {
switch (os) {
case 'macos':
return 'macOS';
case 'windows':
return 'Windows';
case 'linux':
return 'Linux';
default:
return os;
}
};
if (loading) {
return (
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<h3 className="text-lg font-semibold text-white mb-4"></h3>
<div className="animate-pulse space-y-3">
<div className="h-4 bg-dark-500 rounded w-1/2"></div>
<div className="h-4 bg-dark-500 rounded w-2/3"></div>
<div className="h-4 bg-dark-500 rounded w-1/3"></div>
</div>
</div>
);
}
return (
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<h3 className="text-lg font-semibold text-white mb-4"></h3>
<div className="space-y-4">
{/* 操作系统 */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-dark-500 flex items-center justify-center">
<Monitor size={16} className="text-gray-400" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-500"></p>
<p className="text-sm text-white">
{info ? `${getOSLabel(info.os)} ${info.os_version}` : '--'}{' '}
<span className="text-gray-500">({info?.arch})</span>
</p>
</div>
</div>
{/* OpenClaw */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-dark-500 flex items-center justify-center">
{info?.openclaw_installed ? (
<CheckCircle size={16} className="text-green-400" />
) : (
<XCircle size={16} className="text-red-400" />
)}
</div>
<div className="flex-1">
<p className="text-xs text-gray-500">OpenClaw</p>
<p className="text-sm text-white">
{info?.openclaw_installed
? info.openclaw_version || '已安装'
: '未安装'}
</p>
</div>
</div>
{/* Node.js */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-dark-500 flex items-center justify-center">
<Package size={16} className="text-green-500" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-500">Node.js</p>
<p className="text-sm text-white">{info?.node_version || '--'}</p>
</div>
</div>
{/* 配置目录 */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-dark-500 flex items-center justify-center">
<Folder size={16} className="text-amber-400" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-500"></p>
<p className="text-sm text-white font-mono text-xs truncate">
{info?.config_dir || '--'}
</p>
</div>
</div>
</div>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { invoke } from '@tauri-apps/api/core';
import {
Play,
Square,
RotateCcw,
Activity,
Cpu,
HardDrive,
Clock,
CheckCircle,
XCircle,
AlertTriangle,
} from 'lucide-react';
import { StatusCard } from './StatusCard';
import { QuickActions } from './QuickActions';
import { SystemInfo } from './SystemInfo';
interface ServiceStatus {
running: boolean;
pid: number | null;
port: number;
uptime_seconds: number | null;
memory_mb: number | null;
cpu_percent: number | null;
}
export function Dashboard() {
const [status, setStatus] = useState<ServiceStatus | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const fetchStatus = async () => {
try {
const result = await invoke<ServiceStatus>('get_service_status');
setStatus(result);
} catch (e) {
console.error('获取状态失败:', e);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 3000);
return () => clearInterval(interval);
}, []);
const handleStart = async () => {
setActionLoading(true);
try {
await invoke('start_service');
await fetchStatus();
} catch (e) {
console.error('启动失败:', e);
} finally {
setActionLoading(false);
}
};
const handleStop = async () => {
setActionLoading(true);
try {
await invoke('stop_service');
await fetchStatus();
} catch (e) {
console.error('停止失败:', e);
} finally {
setActionLoading(false);
}
};
const handleRestart = async () => {
setActionLoading(true);
try {
await invoke('restart_service');
await fetchStatus();
} catch (e) {
console.error('重启失败:', e);
} finally {
setActionLoading(false);
}
};
const containerVariants = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
};
return (
<div className="h-full overflow-y-auto scroll-container pr-2">
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="space-y-6"
>
{/* 服务状态卡片 */}
<motion.div variants={itemVariants}>
<StatusCard status={status} loading={loading} />
</motion.div>
{/* 快捷操作 */}
<motion.div variants={itemVariants}>
<QuickActions
status={status}
loading={actionLoading}
onStart={handleStart}
onStop={handleStop}
onRestart={handleRestart}
/>
</motion.div>
{/* 系统信息 */}
<motion.div variants={itemVariants}>
<SystemInfo />
</motion.div>
</motion.div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { PageType } from '../../App';
import { RefreshCw, ExternalLink } from 'lucide-react';
interface HeaderProps {
currentPage: PageType;
}
const pageTitles: Record<PageType, { title: string; description: string }> = {
dashboard: { title: '概览', description: '服务状态与快捷操作' },
ai: { title: 'AI 模型配置', description: '配置 AI 提供商和模型' },
channels: { title: '消息渠道', description: '配置 Telegram、Discord、飞书等' },
service: { title: '服务管理', description: '启动、停止、查看日志' },
testing: { title: '测试诊断', description: '连接测试与问题诊断' },
settings: { title: '设置', description: '身份配置与高级选项' },
};
export function Header({ currentPage }: HeaderProps) {
const { title, description } = pageTitles[currentPage];
const handleOpenDashboard = async () => {
// 调用 Tauri 打开 Dashboard URL
try {
const { invoke } = await import('@tauri-apps/api/core');
// 这里可以调用后端获取带 token 的 dashboard URL
const { open } = await import('@tauri-apps/plugin-shell');
await open('http://localhost:18789');
} catch (e) {
console.error('打开 Dashboard 失败:', e);
}
};
return (
<header className="h-14 bg-dark-800/50 border-b border-dark-600 flex items-center justify-between px-6 titlebar-drag backdrop-blur-sm">
{/* 左侧:页面标题 */}
<div className="titlebar-no-drag">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<p className="text-xs text-gray-500">{description}</p>
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2 titlebar-no-drag">
<button
onClick={() => window.location.reload()}
className="icon-button text-gray-400 hover:text-white"
title="刷新"
>
<RefreshCw size={16} />
</button>
<button
onClick={handleOpenDashboard}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-dark-600 hover:bg-dark-500 text-sm text-gray-300 hover:text-white transition-colors"
title="打开 Web Dashboard"
>
<ExternalLink size={14} />
<span>Dashboard</span>
</button>
</div>
</header>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { motion } from 'framer-motion';
import {
LayoutDashboard,
Bot,
MessageSquare,
Zap,
FlaskConical,
Settings,
} from 'lucide-react';
import { PageType } from '../../App';
import clsx from 'clsx';
interface SidebarProps {
currentPage: PageType;
onNavigate: (page: PageType) => void;
}
const menuItems: { id: PageType; label: string; icon: React.ElementType }[] = [
{ id: 'dashboard', label: '概览', icon: LayoutDashboard },
{ id: 'ai', label: 'AI 配置', icon: Bot },
{ id: 'channels', label: '消息渠道', icon: MessageSquare },
{ id: 'service', label: '服务管理', icon: Zap },
{ id: 'testing', label: '测试诊断', icon: FlaskConical },
{ id: 'settings', label: '设置', icon: Settings },
];
export function Sidebar({ currentPage, onNavigate }: SidebarProps) {
return (
<aside className="w-64 bg-dark-800 border-r border-dark-600 flex flex-col">
{/* Logo 区域(macOS 标题栏拖拽) */}
<div className="h-14 flex items-center px-6 titlebar-drag border-b border-dark-600">
<div className="flex items-center gap-3 titlebar-no-drag">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-claw-400 to-claw-600 flex items-center justify-center">
<span className="text-lg">🦞</span>
</div>
<div>
<h1 className="text-sm font-semibold text-white">OpenClaw</h1>
<p className="text-xs text-gray-500">Manager</p>
</div>
</div>
</div>
{/* 导航菜单 */}
<nav className="flex-1 py-4 px-3">
<ul className="space-y-1">
{menuItems.map((item) => {
const isActive = currentPage === item.id;
const Icon = item.icon;
return (
<li key={item.id}>
<button
onClick={() => onNavigate(item.id)}
className={clsx(
'w-full flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-all relative',
isActive
? 'text-white bg-dark-600'
: 'text-gray-400 hover:text-white hover:bg-dark-700'
)}
>
{isActive && (
<motion.div
layoutId="activeIndicator"
className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-claw-500 rounded-r-full"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
<Icon size={18} className={isActive ? 'text-claw-400' : ''} />
<span>{item.label}</span>
</button>
</li>
);
})}
</ul>
</nav>
{/* 底部信息 */}
<div className="p-4 border-t border-dark-600">
<div className="px-4 py-3 bg-dark-700 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="status-dot running" />
<span className="text-xs text-gray-400"></span>
</div>
<p className="text-xs text-gray-500">端口: 18789</p>
</div>
</div>
</aside>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { Sidebar } from './Sidebar';
export { Header } from './Header';
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useState, useRef } from 'react';
import { motion } from 'framer-motion';
import { invoke } from '@tauri-apps/api/core';
import {
Play,
Square,
RotateCcw,
FileText,
RefreshCw,
Terminal,
Loader2,
} from 'lucide-react';
import clsx from 'clsx';
export function ServiceManager() {
const [logs, setLogs] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const logsEndRef = useRef<HTMLDivElement>(null);
const fetchLogs = async () => {
try {
const result = await invoke<string[]>('get_logs', { lines: 100 });
setLogs(result);
} catch (e) {
console.error('获取日志失败:', e);
}
};
useEffect(() => {
fetchLogs();
if (autoRefresh) {
const interval = setInterval(fetchLogs, 2000);
return () => clearInterval(interval);
}
}, [autoRefresh]);
useEffect(() => {
if (logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs]);
const handleAction = async (action: 'start' | 'stop' | 'restart') => {
setActionLoading(action);
try {
await invoke(`${action}_service`);
await fetchLogs();
} catch (e) {
console.error(`${action} 失败:`, e);
alert(`操作失败: ${e}`);
} finally {
setActionLoading(null);
}
};
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';
};
return (
<div className="h-full flex flex-col overflow-hidden">
{/* 操作按钮栏 */}
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<button
onClick={() => handleAction('start')}
disabled={actionLoading !== null}
className={clsx(
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all',
'bg-green-500/20 text-green-400 border border-green-500/30',
'hover:bg-green-500/30 disabled:opacity-50'
)}
>
{actionLoading === 'start' ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Play size={16} />
)}
</button>
<button
onClick={() => handleAction('stop')}
disabled={actionLoading !== null}
className={clsx(
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all',
'bg-red-500/20 text-red-400 border border-red-500/30',
'hover:bg-red-500/30 disabled:opacity-50'
)}
>
{actionLoading === 'stop' ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Square size={16} />
)}
</button>
<button
onClick={() => handleAction('restart')}
disabled={actionLoading !== null}
className={clsx(
'flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all',
'bg-amber-500/20 text-amber-400 border border-amber-500/30',
'hover:bg-amber-500/30 disabled:opacity-50'
)}
>
{actionLoading === 'restart' ? (
<Loader2 size={16} className="animate-spin" />
) : (
<RotateCcw size={16} />
)}
</button>
</div>
<div className="flex-1" />
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm text-gray-400">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="w-4 h-4 rounded border-dark-500 bg-dark-600 text-claw-500 focus:ring-claw-500"
/>
</label>
<button
onClick={fetchLogs}
className="icon-button text-gray-400 hover:text-white"
>
<RefreshCw size={16} />
</button>
</div>
</div>
{/* 日志查看器 */}
<div className="flex-1 bg-dark-800 rounded-xl border border-dark-600 overflow-hidden flex flex-col">
{/* 日志标题栏 */}
<div className="flex items-center gap-2 px-4 py-2 bg-dark-700 border-b border-dark-600">
<Terminal size={14} className="text-gray-500" />
<span className="text-xs text-gray-400 font-medium">
/tmp/openclaw-gateway.log
</span>
<div className="flex-1" />
<span className="text-xs text-gray-500">
{logs.length}
</span>
</div>
{/* 日志内容 */}
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs leading-relaxed">
{logs.length === 0 ? (
<div className="h-full flex items-center justify-center text-gray-500">
<div className="text-center">
<FileText size={32} className="mx-auto mb-2 opacity-50" />
<p></p>
</div>
</div>
) : (
<>
{logs.map((line, index) => (
<div
key={index}
className={clsx('py-0.5', getLogLineClass(line))}
>
<span className="text-gray-600 mr-3 select-none">
{String(index + 1).padStart(4, ' ')}
</span>
{line}
</div>
))}
<div ref={logsEndRef} />
</>
)}
</div>
</div>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { invoke } from '@tauri-apps/api/core';
import {
User,
Globe,
Shield,
Save,
Loader2,
FolderOpen,
FileCode,
} from 'lucide-react';
import clsx from 'clsx';
export function Settings() {
const [identity, setIdentity] = useState({
botName: 'Clawd',
userName: '主人',
timezone: 'Asia/Shanghai',
});
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
// TODO: 保存身份配置
await new Promise((resolve) => setTimeout(resolve, 500));
alert('设置已保存!');
} catch (e) {
console.error('保存失败:', e);
} finally {
setSaving(false);
}
};
const openConfigDir = 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);
} catch (e) {
console.error('打开目录失败:', e);
}
};
return (
<div className="h-full overflow-y-auto scroll-container pr-2">
<div className="max-w-2xl space-y-6">
{/* 身份配置 */}
<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"></h3>
<p className="text-xs text-gray-500"> AI </p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">
AI
</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">
</label>
<input
type="text"
value={identity.userName}
onChange={(e) =>
setIdentity({ ...identity, userName: e.target.value })
}
placeholder="主人"
className="input-base"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2"></label>
<select
value={identity.timezone}
onChange={(e) =>
setIdentity({ ...identity, 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>
</div>
{/* 安全设置 */}
<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"></h3>
<p className="text-xs text-gray-500">访</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"></p>
<p className="text-xs text-gray-500">访</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">访</p>
<p className="text-xs text-gray-500"> AI </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>
{/* 高级设置 */}
<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"></h3>
<p className="text-xs text-gray-500"></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"></p>
<p className="text-xs text-gray-500">~/.openclaw</p>
</div>
</button>
</div>
</div>
{/* 保存按钮 */}
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className="btn-primary flex items-center gap-2"
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
</button>
</div>
</div>
</div>
);
}
+388
View File
@@ -0,0 +1,388 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { invoke } from '@tauri-apps/api/core';
import {
CheckCircle,
XCircle,
AlertCircle,
Play,
Loader2,
Zap,
MessageCircle,
Stethoscope,
Hash,
Slack,
MessagesSquare,
} from 'lucide-react';
import clsx from 'clsx';
interface DiagnosticResult {
name: string;
passed: boolean;
message: string;
suggestion: string | null;
}
interface AITestResult {
success: boolean;
provider: string;
model: string;
response: string | null;
error: string | null;
latency_ms: number | null;
}
interface ChannelTestResult {
success: boolean;
channel: string;
message: string;
error: string | null;
}
// 渠道配置
const channelConfigs = [
{ id: 'telegram', name: 'Telegram', icon: <MessageCircle size={20} />, color: 'text-blue-400' },
{ id: 'discord', name: 'Discord', icon: <Hash size={20} />, color: 'text-indigo-400' },
{ id: 'whatsapp', name: 'WhatsApp', icon: <MessageCircle size={20} />, color: 'text-green-500' },
{ id: 'slack', name: 'Slack', icon: <Slack size={20} />, color: 'text-purple-400' },
{ id: 'feishu', name: '飞书', icon: <MessagesSquare size={20} />, color: 'text-blue-500' },
];
export function Testing() {
const [diagnosticResults, setDiagnosticResults] = useState<DiagnosticResult[]>([]);
const [aiTestResult, setAiTestResult] = useState<AITestResult | null>(null);
const [channelResults, setChannelResults] = useState<Record<string, ChannelTestResult>>({});
const [loading, setLoading] = useState<string | null>(null);
const runDiagnostics = async () => {
setLoading('diagnostics');
setDiagnosticResults([]);
try {
const results = await invoke<DiagnosticResult[]>('run_doctor');
setDiagnosticResults(results);
} catch (e) {
console.error('诊断失败:', e);
setDiagnosticResults([{
name: '诊断执行',
passed: false,
message: String(e),
suggestion: '请检查 OpenClaw 是否正确安装',
}]);
} finally {
setLoading(null);
}
};
const runAITest = async () => {
setLoading('ai');
setAiTestResult(null);
try {
const result = await invoke<AITestResult>('test_ai_connection');
setAiTestResult(result);
} catch (e) {
console.error('AI 测试失败:', e);
setAiTestResult({
success: false,
provider: 'unknown',
model: 'unknown',
response: null,
error: String(e),
latency_ms: null,
});
} finally {
setLoading(null);
}
};
const runChannelTest = async (channelId: string) => {
setLoading(`channel-${channelId}`);
// 清除之前的结果
setChannelResults((prev) => {
const next = { ...prev };
delete next[channelId];
return next;
});
try {
const result = await invoke<ChannelTestResult>('test_channel', {
channelType: channelId
});
setChannelResults((prev) => ({
...prev,
[channelId]: result,
}));
} catch (e) {
console.error(`${channelId} 测试失败:`, e);
setChannelResults((prev) => ({
...prev,
[channelId]: {
success: false,
channel: channelId,
message: '测试失败',
error: String(e),
},
}));
} finally {
setLoading(null);
}
};
// 获取渠道测试状态的图标和颜色
const getChannelStatus = (channelId: string) => {
const result = channelResults[channelId];
const isLoading = loading === `channel-${channelId}`;
if (isLoading) {
return {
icon: <Loader2 size={20} className="animate-spin text-gray-400" />,
statusText: '测试中...',
statusColor: 'text-gray-400',
};
}
if (!result) {
return {
icon: <AlertCircle size={20} className="text-gray-500" />,
statusText: '点击测试',
statusColor: 'text-gray-600',
};
}
if (result.success) {
return {
icon: <CheckCircle size={20} className="text-green-400" />,
statusText: '连接成功',
statusColor: 'text-green-400',
};
}
return {
icon: <XCircle size={20} className="text-red-400" />,
statusText: '连接失败',
statusColor: 'text-red-400',
};
};
return (
<div className="h-full overflow-y-auto scroll-container pr-2">
<div className="max-w-4xl space-y-6">
{/* 诊断测试 */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
<Stethoscope size={20} className="text-purple-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white"></h3>
<p className="text-xs text-gray-500">
OpenClaw
</p>
</div>
</div>
<button
onClick={runDiagnostics}
disabled={loading === 'diagnostics'}
className="btn-secondary flex items-center gap-2"
>
{loading === 'diagnostics' ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Play size={16} />
)}
</button>
</div>
{diagnosticResults.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-2 mt-4"
>
{diagnosticResults.map((result, index) => (
<div
key={index}
className={clsx(
'flex items-start gap-3 p-3 rounded-lg',
result.passed ? 'bg-green-500/10' : 'bg-red-500/10'
)}
>
{result.passed ? (
<CheckCircle size={18} className="text-green-400 mt-0.5" />
) : (
<XCircle size={18} className="text-red-400 mt-0.5" />
)}
<div className="flex-1">
<p
className={clsx(
'text-sm font-medium',
result.passed ? 'text-green-400' : 'text-red-400'
)}
>
{result.name}
</p>
<p className="text-xs text-gray-400 mt-1 whitespace-pre-wrap">{result.message}</p>
{result.suggestion && (
<p className="text-xs text-amber-400 mt-1">
💡 {result.suggestion}
</p>
)}
</div>
</div>
))}
</motion.div>
)}
</div>
{/* AI 连接测试 */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-cyan-500/20 flex items-center justify-center">
<Zap size={20} className="text-cyan-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">AI </h3>
<p className="text-xs text-gray-500">
AI
</p>
</div>
</div>
<button
onClick={runAITest}
disabled={loading === 'ai'}
className="btn-secondary flex items-center gap-2"
>
{loading === 'ai' ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Play size={16} />
)}
</button>
</div>
{aiTestResult && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={clsx(
'p-4 rounded-xl mt-4',
aiTestResult.success ? 'bg-green-500/10' : 'bg-red-500/10'
)}
>
<div className="flex items-center gap-3 mb-3">
{aiTestResult.success ? (
<CheckCircle size={24} className="text-green-400" />
) : (
<XCircle size={24} className="text-red-400" />
)}
<div>
<p
className={clsx(
'font-semibold',
aiTestResult.success ? 'text-green-400' : 'text-red-400'
)}
>
{aiTestResult.success ? '连接成功' : '连接失败'}
</p>
{aiTestResult.latency_ms && (
<p className="text-xs text-gray-400">
: {aiTestResult.latency_ms}ms
</p>
)}
</div>
</div>
{aiTestResult.response && (
<div className="mt-3 p-3 bg-dark-600 rounded-lg">
<p className="text-xs text-gray-400 mb-1">AI :</p>
<p className="text-sm text-white whitespace-pre-wrap">
{aiTestResult.response}
</p>
</div>
)}
{aiTestResult.error && (
<div className="mt-3 p-3 bg-red-500/10 rounded-lg">
<p className="text-xs text-red-400 mb-1">:</p>
<p className="text-sm text-red-300 whitespace-pre-wrap">
{aiTestResult.error}
</p>
</div>
)}
</motion.div>
)}
</div>
{/* 渠道测试 */}
<div className="bg-dark-700 rounded-2xl p-6 border border-dark-500">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-blue-500/20 flex items-center justify-center">
<MessageCircle size={20} className="text-blue-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white"></h3>
<p className="text-xs text-gray-500">
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{channelConfigs.map((channel) => {
const status = getChannelStatus(channel.id);
const result = channelResults[channel.id];
const isLoading = loading === `channel-${channel.id}`;
return (
<button
key={channel.id}
onClick={() => runChannelTest(channel.id)}
disabled={isLoading}
className={clsx(
'flex flex-col items-center gap-2 p-4 rounded-xl border transition-all',
result?.success
? 'bg-green-500/10 border-green-500/30'
: result?.error
? 'bg-red-500/10 border-red-500/30'
: 'bg-dark-600 border-dark-500 hover:border-dark-400',
isLoading && 'opacity-70 cursor-wait'
)}
>
<div className={channel.color}>{channel.icon}</div>
{status.icon}
<span className="text-sm text-gray-300">{channel.name}</span>
<span className={clsx('text-xs', status.statusColor)}>
{status.statusText}
</span>
</button>
);
})}
</div>
{/* 显示渠道测试详情 */}
{Object.entries(channelResults).map(([channelId, result]) => (
result.error && (
<motion.div
key={channelId}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 p-3 bg-red-500/10 rounded-lg"
>
<p className="text-xs text-red-400 mb-1">
{channelConfigs.find(c => c.id === channelId)?.name} :
</p>
<p className="text-sm text-red-300 whitespace-pre-wrap">
{result.error}
</p>
</motion.div>
)
))}
</div>
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useCallback } from 'react';
import { useAppStore } from '../stores/appStore';
import { api } from '../lib/tauri';
export function useService() {
const { serviceStatus, setServiceStatus } = useAppStore();
const fetchStatus = useCallback(async () => {
try {
const status = await api.getServiceStatus();
setServiceStatus(status);
} catch (error) {
console.error('获取服务状态失败:', error);
}
}, [setServiceStatus]);
const start = useCallback(async () => {
try {
await api.startService();
await fetchStatus();
return true;
} catch (error) {
console.error('启动服务失败:', error);
throw error;
}
}, [fetchStatus]);
const stop = useCallback(async () => {
try {
await api.stopService();
await fetchStatus();
return true;
} catch (error) {
console.error('停止服务失败:', error);
throw error;
}
}, [fetchStatus]);
const restart = useCallback(async () => {
try {
await api.restartService();
await fetchStatus();
return true;
} catch (error) {
console.error('重启服务失败:', error);
throw error;
}
}, [fetchStatus]);
// 自动刷新状态
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 3000);
return () => clearInterval(interval);
}, [fetchStatus]);
return {
status: serviceStatus,
isRunning: serviceStatus?.running ?? false,
fetchStatus,
start,
stop,
restart,
};
}
+101
View File
@@ -0,0 +1,101 @@
import { invoke } from '@tauri-apps/api/core';
// 服务状态
export interface ServiceStatus {
running: boolean;
pid: number | null;
port: number;
uptime_seconds: number | null;
memory_mb: number | null;
cpu_percent: number | null;
}
// 系统信息
export interface SystemInfo {
os: string;
os_version: string;
arch: string;
openclaw_installed: boolean;
openclaw_version: string | null;
node_version: string | null;
config_dir: string;
}
// AI Provider 选项
export interface AIProviderOption {
id: string;
name: string;
icon: string;
default_base_url: string | null;
models: AIModelOption[];
requires_api_key: boolean;
}
export interface AIModelOption {
id: string;
name: string;
description: string | null;
recommended: boolean;
}
// 渠道配置
export interface ChannelConfig {
id: string;
channel_type: string;
enabled: boolean;
config: Record<string, unknown>;
}
// 诊断结果
export interface DiagnosticResult {
name: string;
passed: boolean;
message: string;
suggestion: string | null;
}
// AI 测试结果
export interface AITestResult {
success: boolean;
provider: string;
model: string;
response: string | null;
error: string | null;
latency_ms: number | null;
}
// API 封装
export const api = {
// 服务管理
getServiceStatus: () => invoke<ServiceStatus>('get_service_status'),
startService: () => invoke<string>('start_service'),
stopService: () => invoke<string>('stop_service'),
restartService: () => invoke<string>('restart_service'),
getLogs: (lines?: number) => invoke<string[]>('get_logs', { lines }),
// 系统信息
getSystemInfo: () => invoke<SystemInfo>('get_system_info'),
checkOpenclawInstalled: () => invoke<boolean>('check_openclaw_installed'),
getOpenclawVersion: () => invoke<string | null>('get_openclaw_version'),
// 配置管理
getConfig: () => invoke<unknown>('get_config'),
saveConfig: (config: unknown) => invoke<string>('save_config', { config }),
getEnvValue: (key: string) => invoke<string | null>('get_env_value', { key }),
saveEnvValue: (key: string, value: string) =>
invoke<string>('save_env_value', { key, value }),
// AI Provider
getAIProviders: () => invoke<AIProviderOption[]>('get_ai_providers'),
// 渠道
getChannelsConfig: () => invoke<ChannelConfig[]>('get_channels_config'),
saveChannelConfig: (channel: ChannelConfig) =>
invoke<string>('save_channel_config', { channel }),
// 诊断测试
runDoctor: () => invoke<DiagnosticResult[]>('run_doctor'),
testAIConnection: () => invoke<AITestResult>('test_ai_connection'),
testChannel: (channelType: string) =>
invoke<unknown>('test_channel', { channelType }),
};
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+56
View File
@@ -0,0 +1,56 @@
import { create } from 'zustand';
import type { ServiceStatus, SystemInfo } from '../lib/tauri';
interface AppState {
// 服务状态
serviceStatus: ServiceStatus | null;
setServiceStatus: (status: ServiceStatus | null) => void;
// 系统信息
systemInfo: SystemInfo | null;
setSystemInfo: (info: SystemInfo | null) => void;
// UI 状态
loading: boolean;
setLoading: (loading: boolean) => void;
// 通知
notifications: Notification[];
addNotification: (notification: Omit<Notification, 'id'>) => void;
removeNotification: (id: string) => void;
}
interface Notification {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message?: string;
}
export const useAppStore = create<AppState>((set) => ({
// 服务状态
serviceStatus: null,
setServiceStatus: (status) => set({ serviceStatus: status }),
// 系统信息
systemInfo: null,
setSystemInfo: (info) => set({ systemInfo: info }),
// UI 状态
loading: false,
setLoading: (loading) => set({ loading }),
// 通知
notifications: [],
addNotification: (notification) =>
set((state) => ({
notifications: [
...state.notifications,
{ ...notification, id: Date.now().toString() },
],
})),
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
})),
}));
+196
View File
@@ -0,0 +1,196 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
overflow: hidden;
}
body {
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background-color: #0a0a0b;
color: #e5e5e5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* macOS 标题栏拖拽区域 */
.titlebar-drag {
-webkit-app-region: drag;
}
.titlebar-no-drag {
-webkit-app-region: no-drag;
}
/* 自定义选择样式 */
::selection {
background-color: rgba(249, 77, 58, 0.3);
color: white;
}
/* 输入框焦点样式 */
input:focus, textarea:focus, select:focus {
outline: none;
}
/* 按钮禁用状态 */
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 动画工具类 */
@layer utilities {
.animate-in {
animation: fadeIn 0.3s ease-out;
}
.slide-in-right {
animation: slideInRight 0.3s ease-out;
}
.slide-in-up {
animation: slideUp 0.3s ease-out;
}
@keyframes slideInRight {
from {
transform: translateX(20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 发光脉冲 */
.glow-pulse {
animation: glowPulse 2s ease-in-out infinite;
}
@keyframes glowPulse {
0%, 100% {
box-shadow: 0 0 5px rgba(249, 77, 58, 0.4);
}
50% {
box-shadow: 0 0 20px rgba(249, 77, 58, 0.6);
}
}
}
/* 滚动条容器 */
.scroll-container {
overflow-y: auto;
scrollbar-gutter: stable;
}
/* 代码块样式 */
.code-block {
font-family: 'SF Mono', 'JetBrains Mono', 'Fira Code', monospace;
background: #1a1a1d;
border: 1px solid #2e2e33;
border-radius: 8px;
padding: 12px 16px;
font-size: 13px;
line-height: 1.5;
overflow-x: auto;
}
/* 卡片悬浮效果 */
.card-hover {
transition: all 0.2s ease;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
/* 玻璃效果 */
.glass {
background: rgba(26, 26, 29, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* 状态指示器 */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.running {
background: #4ade80;
box-shadow: 0 0 8px rgba(74, 222, 128, 0.6);
animation: pulse 2s infinite;
}
.status-dot.stopped {
background: #ef4444;
}
.status-dot.warning {
background: #fbbf24;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
/* 渐变背景 */
.bg-gradient-radial {
background: radial-gradient(ellipse at top, rgba(249, 77, 58, 0.1) 0%, transparent 50%);
}
/* 图标按钮 */
.icon-button {
@apply p-2 rounded-lg transition-colors;
@apply hover:bg-dark-500 active:bg-dark-400;
}
/* 输入框通用样式 */
.input-base {
@apply w-full px-4 py-3 rounded-lg;
@apply bg-dark-700 border border-dark-500;
@apply text-white placeholder-gray-500;
@apply focus:border-claw-500 focus:ring-1 focus:ring-claw-500/50;
@apply transition-all duration-200;
}
/* 按钮通用样式 */
.btn-primary {
@apply px-6 py-3 rounded-lg font-medium;
@apply bg-claw-500 text-white;
@apply hover:bg-claw-600 active:bg-claw-700;
@apply transition-all duration-200;
@apply disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply px-6 py-3 rounded-lg font-medium;
@apply bg-dark-600 text-white border border-dark-400;
@apply hover:bg-dark-500 active:bg-dark-400;
@apply transition-all duration-200;
}
.btn-ghost {
@apply px-4 py-2 rounded-lg font-medium;
@apply text-gray-400 hover:text-white hover:bg-dark-600;
@apply transition-all duration-200;
}
+96
View File
@@ -0,0 +1,96 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// OpenClaw 品牌色
claw: {
50: '#fef3f2',
100: '#fee4e2',
200: '#ffccc7',
300: '#ffa8a0',
400: '#ff7a6b',
500: '#f94d3a', // 主色 - 龙虾红
600: '#e63024',
700: '#c1241a',
800: '#a02119',
900: '#84221c',
950: '#480d09',
},
// 深色主题背景
dark: {
900: '#0a0a0b',
800: '#111113',
700: '#1a1a1d',
600: '#242428',
500: '#2e2e33',
400: '#3d3d44',
},
// 强调色
accent: {
cyan: '#22d3ee',
purple: '#a78bfa',
green: '#4ade80',
amber: '#fbbf24',
}
},
fontFamily: {
sans: [
'SF Pro Display',
'-apple-system',
'BlinkMacSystemFont',
'PingFang SC',
'Hiragino Sans GB',
'Microsoft YaHei',
'sans-serif',
],
mono: [
'SF Mono',
'JetBrains Mono',
'Fira Code',
'Menlo',
'monospace',
],
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'glow': 'glow 2s ease-in-out infinite alternate',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
'fade-in': 'fadeIn 0.2s ease-out',
},
keyframes: {
glow: {
'0%': { boxShadow: '0 0 5px rgba(249, 77, 58, 0.5)' },
'100%': { boxShadow: '0 0 20px rgba(249, 77, 58, 0.8)' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
slideDown: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
},
boxShadow: {
'glow-claw': '0 0 30px rgba(249, 77, 58, 0.3)',
'glow-cyan': '0 0 30px rgba(34, 211, 238, 0.3)',
'glow-green': '0 0 30px rgba(74, 222, 128, 0.3)',
'inner-light': 'inset 0 1px 0 0 rgba(255, 255, 255, 0.05)',
},
backdropBlur: {
xs: '2px',
},
},
},
plugins: [],
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+42
View File
@@ -0,0 +1,42 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// 防止 Vite 清除 Rust 错误信息
clearScreen: false,
// Tauri 期望使用固定端口,如果端口不可用则失败
server: {
port: 1420,
strictPort: true,
watch: {
// 监听 src-tauri 目录变化
ignored: ['**/src-tauri/**'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
// 生产构建配置
build: {
// Tauri 在 Windows 上使用 Chromium,在 macOS 和 Linux 上使用 WebKit
target: process.env.TAURI_ENV_PLATFORM === 'windows'
? 'chrome105'
: 'safari14',
// 不压缩以便调试
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
// 生成 sourcemap 以便调试
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
// 环境变量
envPrefix: ['VITE_', 'TAURI_ENV_'],
});