mirror of
https://github.com/MrFadiAi/openclaw-manager.git
synced 2026-08-14 00:57:59 +00:00
init
This commit is contained in:
+30
@@ -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
@@ -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>
|
||||
Generated
+3132
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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 |
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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 |
@@ -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))
|
||||
}
|
||||
@@ -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 存在(但不设置 enabled,WhatsApp 不支持这个键)
|
||||
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)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod diagnostics;
|
||||
pub mod process;
|
||||
pub mod service;
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 应用时发生错误");
|
||||
}
|
||||
@@ -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 应用时发生错误");
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod config;
|
||||
pub mod status;
|
||||
|
||||
pub use config::*;
|
||||
pub use status::*;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod file;
|
||||
pub mod platform;
|
||||
pub mod shell;
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { Header } from './Header';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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),
|
||||
})),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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_'],
|
||||
});
|
||||
Reference in New Issue
Block a user