From 1eb734062f815a8bd1ea63517b3c93bc817cdc16 Mon Sep 17 00:00:00 2001 From: MrFadiAi Date: Sun, 15 Feb 2026 23:37:59 +0100 Subject: [PATCH] Parrel agents --- MULTI_AGENT_GUIDE.md | 126 ++++ src-tauri/agents/TheCoder/models.json | 28 + src-tauri/src/commands/config.rs | 523 ++++++++++++++++- src-tauri/src/commands/service.rs | 58 +- src-tauri/src/main.rs | 8 + src/components/Agents/index.tsx | 615 ++++++++++++++++---- src/components/Channels/index.tsx | 805 +++++++++++++++++++------- 7 files changed, 1814 insertions(+), 349 deletions(-) create mode 100644 MULTI_AGENT_GUIDE.md create mode 100644 src-tauri/agents/TheCoder/models.json diff --git a/MULTI_AGENT_GUIDE.md b/MULTI_AGENT_GUIDE.md new file mode 100644 index 0000000..16497b4 --- /dev/null +++ b/MULTI_AGENT_GUIDE.md @@ -0,0 +1,126 @@ +# Multi-Agent Telegram Setup — Multi-Bot Accounts + +Route different forum topics (or groups) to different AI agents by using **separate Telegram bots**, each bound to its own agent. + +--- + +## How It Works + +``` +@researchbot (Account: researchbot) ──→ Agent: research +@coderbot (Account: coderbot) ──→ Agent: coder +``` + +Each bot is a separate Telegram account in OpenClaw. You add both bots to the same group, and each responds independently with its own agent/personality/memory. + +--- + +## Step 1: Create Bots in BotFather + +1. Open Telegram → search **@BotFather** +2. Send `/newbot` → name it "Research Bot" → username: `orbex_research_bot` +3. Copy the **bot token** +4. Repeat for a second bot: "Code Bot" → `orbex_code_bot` +5. For each bot: send `/setprivacy` → select the bot → `Disable` (so it can see group messages) + +--- + +## Step 2: Add Bot Accounts in Channels + +1. Open **OpenClaw Manager** → **Channels** → **Telegram** +2. Scroll down to **Bot Accounts** section +3. Click **+ Add Bot**: + - Account ID: `researchbot` + - Bot Token: *(paste token from step 1)* + - Click **Add** +4. Click **+ Add Bot** again: + - Account ID: `coderbot` + - Bot Token: *(paste second token)* + - Click **Add** +5. Expand each account and set **Group Policy** → `open` (or `allowlist`) +6. Click **Save Account** on each + +--- + +## Step 3: Create Agents + +1. Go to **Agents** → **+ Add Agent** +2. Create agent `research` (Agent ID: `research`) +3. Create agent `coder` (Agent ID: `coder`) +4. Save each + +--- + +## Step 4: Create Routing Rules + +1. In **Routing Rules** → **+ Add Rule** +2. First rule: + - **Route To Agent**: `research` + - **Channel**: `telegram` + - **Account ID**: `researchbot` + - **Peer Match**: Any + - Click **Add Rule** +3. Second rule: + - **Route To Agent**: `coder` + - **Channel**: `telegram` + - **Account ID**: `coderbot` + - **Peer Match**: Any + - Click **Add Rule** + +--- + +## Step 5: Add Bots to Your Group + +1. Open your Telegram group +2. Add both bots as members: `@orbex_research_bot` and `@orbex_code_bot` +3. Make both bots **admins** (so they can see all messages) + +--- + +## Step 6: Verify openclaw.json + +Check `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "list": [ + { "id": "research" }, + { "id": "coder" } + ] + }, + "bindings": [ + { "agentId": "research", "match": { "channel": "telegram", "accountId": "researchbot" } }, + { "agentId": "coder", "match": { "channel": "telegram", "accountId": "coderbot" } } + ], + "channels": { + "telegram": { + "accounts": { + "researchbot": { "botToken": "TOKEN_1", "groupPolicy": "open" }, + "coderbot": { "botToken": "TOKEN_2", "groupPolicy": "open" } + } + } + } +} +``` + +--- + +## Step 7: Restart & Test + +1. **Restart OpenClaw** +2. In your Telegram group, send `@orbex_research_bot what are you?` +3. Then send `@orbex_code_bot what are you?` +4. Each should respond with its own personality + +--- + +## Quick Reference + +| Task | Where | +|---|---| +| Create Telegram bots | BotFather → `/newbot` | +| Add bot accounts | Channels → Telegram → Bot Accounts | +| Create agents | Agents → + Add Agent | +| Route account → agent | Agents → + Add Rule → set Account ID | +| Per-account groups | Channels → expand account → Group Policy | diff --git a/src-tauri/agents/TheCoder/models.json b/src-tauri/agents/TheCoder/models.json new file mode 100644 index 0000000..34937ed --- /dev/null +++ b/src-tauri/agents/TheCoder/models.json @@ -0,0 +1,28 @@ +{ + "providers": { + "glm": { + "baseUrl": "https://open.bigmodel.cn/api/paas/v4", + "apiKey": "3e911333b0dc462dba0b4ce886303cce.4WF3QN7zfirCWXxS", + "models": [ + { + "id": "glm-5", + "name": "glm-5", + "api": "openai-completions", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 8192 + } + ] + } + } +} diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index b0cb05e..03cc290 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -1581,6 +1581,247 @@ pub async fn clear_channel_config(channel_id: String) -> Result } } +// ============ Telegram Multi-Account Management ============ + +/// Telegram account info for frontend +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelegramAccount { + pub id: String, + #[serde(alias = "botToken", alias = "bot_token")] + pub bot_token: String, + #[serde(alias = "groupPolicy", alias = "group_policy")] + pub group_policy: Option, + #[serde(alias = "dmPolicy", alias = "dm_policy")] + pub dm_policy: Option, + #[serde(alias = "streamMode", alias = "stream_mode")] + pub stream_mode: Option, + #[serde(alias = "exclusiveTopics", alias = "exclusive_topics")] + pub exclusive_topics: Option>, + pub groups: Option, +} + +/// Get all Telegram bot accounts +#[command] +pub async fn get_telegram_accounts() -> Result, String> { + info!("[Telegram Accounts] Getting accounts..."); + let config = load_openclaw_config()?; + + let mut accounts = Vec::new(); + + // Check for multi-account structure: channels.telegram.accounts + if let Some(accts) = config.pointer("/channels/telegram/accounts").and_then(|v| v.as_object()) { + for (id, acct_val) in accts { + accounts.push(TelegramAccount { + id: id.clone(), + bot_token: acct_val.get("botToken").and_then(|v| v.as_str()).unwrap_or("").to_string(), + group_policy: acct_val.get("groupPolicy").and_then(|v| v.as_str()).map(|s| s.to_string()), + dm_policy: acct_val.get("dmPolicy").and_then(|v| v.as_str()).map(|s| s.to_string()), + stream_mode: acct_val.get("streamMode").and_then(|v| v.as_str()).map(|s| s.to_string()), + exclusive_topics: { + // Re-infer exclusive topics from group config + // Logic: If a group has requireMention=true and specific topics have requireMention=false, those are exclusive topics. + let mut inferred_topics = Vec::new(); + if let Some(groups_map) = acct_val.get("groups").and_then(|g| g.as_object()) { + for (_, group_val) in groups_map { + // Check if group is muted (requireMention=true) + if group_val.get("requireMention").and_then(|v| v.as_bool()).unwrap_or(false) { + if let Some(topics_map) = group_val.get("topics").and_then(|t| t.as_object()) { + for (tid, tval) in topics_map { + // Check if topic is unmuted (requireMention=false) + if !tval.get("requireMention").and_then(|v| v.as_bool()).unwrap_or(true) { + inferred_topics.push(tid.clone()); + } + } + } + } + } + } + if inferred_topics.is_empty() { None } else { Some(inferred_topics) } + }, + groups: acct_val.get("groups").cloned(), + }); + } + } + + // Fallback: single-bot config (botToken at top level) + if accounts.is_empty() { + if let Some(token) = config.pointer("/channels/telegram/botToken").and_then(|v| v.as_str()) { + if !token.is_empty() { + accounts.push(TelegramAccount { + id: "default".to_string(), + bot_token: token.to_string(), + group_policy: config.pointer("/channels/telegram/groupPolicy").and_then(|v| v.as_str()).map(|s| s.to_string()), + dm_policy: config.pointer("/channels/telegram/dmPolicy").and_then(|v| v.as_str()).map(|s| s.to_string()), + stream_mode: config.pointer("/channels/telegram/streamMode").and_then(|v| v.as_str()).map(|s| s.to_string()), + exclusive_topics: None, + groups: config.pointer("/channels/telegram/groups").cloned(), + }); + } + } + } + + info!("[Telegram Accounts] Found {} accounts", accounts.len()); + Ok(accounts) +} + +/// Save a Telegram bot account +#[command] +pub async fn save_telegram_account(account: TelegramAccount) -> Result { + info!("[Telegram Accounts] Saving account: {}", account.id); + let mut config = load_openclaw_config()?; + + // Ensure channels.telegram exists + if config.get("channels").is_none() { + config["channels"] = json!({}); + } + if config["channels"].get("telegram").is_none() { + config["channels"]["telegram"] = json!({ "enabled": true }); + } + + // Ensure accounts object exists + if config["channels"]["telegram"].get("accounts").is_none() { + config["channels"]["telegram"]["accounts"] = json!({}); + } + + // Migrate single-bot to accounts if this is the first additional account + if let Some(top_token) = config["channels"]["telegram"].get("botToken").and_then(|v| v.as_str()).map(|s| s.to_string()) { + if !top_token.is_empty() { + // Move existing single-bot config to accounts["default"] + let existing = json!({ + "botToken": top_token, + "groupPolicy": config["channels"]["telegram"].get("groupPolicy").cloned().unwrap_or(json!(null)), + "dmPolicy": config["channels"]["telegram"].get("dmPolicy").cloned().unwrap_or(json!(null)), + "streamMode": config["channels"]["telegram"].get("streamMode").cloned().unwrap_or(json!(null)), + "groups": config["channels"]["telegram"].get("groups").cloned().unwrap_or(json!(null)), + }); + config["channels"]["telegram"]["accounts"]["default"] = existing; + // Remove top-level single-bot fields + if let Some(tg) = config["channels"]["telegram"].as_object_mut() { + tg.remove("botToken"); + tg.remove("groupPolicy"); + tg.remove("dmPolicy"); + tg.remove("streamMode"); + tg.remove("groups"); + tg.remove("allowFrom"); + tg.remove("groupAllowFrom"); + } + } + } + + // Build account object + let mut acct_obj = json!({ + "botToken": account.bot_token, + }); + if let Some(gp) = &account.group_policy { + acct_obj["groupPolicy"] = json!(gp); + } + if let Some(dp) = &account.dm_policy { + acct_obj["dmPolicy"] = json!(dp); + } + if let Some(sm) = &account.stream_mode { + acct_obj["streamMode"] = json!(sm); + } + + // Handle groups configuration + // If exclusive_topics is set, we need to modify the group config to enforce it + // 1. Set group-level requireMention = true (default behavior: ignore everything) + // 2. Set topic-level requireMention = false for whitelisted topics (exception: auto-reply) + let mut groups_json = account.groups.clone(); + + if let Some(exclusive_topics) = &account.exclusive_topics { + if !exclusive_topics.is_empty() { + // We also save the raw list so the UI can reload it (using a hidden field or relying on inference) + // However, OpenClaw core rejects unknown fields. So we must ONLY output valid config. + // Strategy: The UI will need to infer exclusive topics from the config structure if we can't save the field. + // OR: We save it as a comment? No, JSON doesn't support comments. + // COMPROMISE: We will NOT save "exclusiveTopics" to the file to avoid validation errors. + // The UI will have to populate the field by checking if a group has topics configured. + // For now, let's just apply the logic to the groups logic. + + if let Some(groups_map) = groups_json.as_mut().and_then(|g| g.as_object_mut()) { + for (_, group_val) in groups_map.iter_mut() { + if let Some(group_obj) = group_val.as_object_mut() { + // Enforce whitelist logic: + // 1. Group requires mention (mute general) + group_obj.insert("requireMention".to_string(), json!(true)); + group_obj.insert("enabled".to_string(), json!(true)); + + // 2. Allow specific topics + let mut topics_map = serde_json::Map::new(); + for topic_id in exclusive_topics { + let mut topic_config = serde_json::Map::new(); + topic_config.insert("requireMention".to_string(), json!(false)); + topics_map.insert(topic_id.clone(), json!(topic_config)); + } + + // 3. Explicitly block topics owned by OTHER bot accounts + // This prevents cross-talk when OpenClaw core doesn't + // fall back to group-level requireMention for unlisted topics. + if let Some(all_accts) = config.pointer("/channels/telegram/accounts").and_then(|v| v.as_object()) { + for (other_id, other_val) in all_accts { + if other_id == &account.id { continue; } + if let Some(other_groups) = other_val.get("groups").and_then(|g| g.as_object()) { + for (_, other_group) in other_groups { + if let Some(other_topics) = other_group.get("topics").and_then(|t| t.as_object()) { + for (other_tid, _) in other_topics { + if !exclusive_topics.contains(other_tid) && !topics_map.contains_key(other_tid) { + let mut block_config = serde_json::Map::new(); + block_config.insert("requireMention".to_string(), json!(true)); + topics_map.insert(other_tid.clone(), json!(block_config)); + } + } + } + } + } + } + } + + group_obj.insert("topics".to_string(), json!(topics_map)); + } + } + } + } + } + + if let Some(g) = groups_json { + acct_obj["groups"] = g; + } + + // NOTE: We do NOT save "exclusiveTopics" field to avoid schema validation errors in OpenClaw core. + // The UI state for this field might be lost on restart unless we infer it back from the topics structure, + // but the *behavior* will be correct. + + config["channels"]["telegram"]["accounts"][&account.id] = acct_obj; + + // Ensure telegram is enabled and in plugins + config["channels"]["telegram"]["enabled"] = json!(true); + if config.get("plugins").is_none() { + config["plugins"] = json!({ "allow": ["telegram"], "entries": { "telegram": { "enabled": true } } }); + } + + save_openclaw_config(&config)?; + Ok(format!("Account '{}' saved", account.id)) +} + +/// Delete a Telegram bot account +#[command] +pub async fn delete_telegram_account(account_id: String) -> Result { + info!("[Telegram Accounts] Deleting account: {}", account_id); + let mut config = load_openclaw_config()?; + + if let Some(accts) = config.pointer_mut("/channels/telegram/accounts").and_then(|v| v.as_object_mut()) { + accts.remove(&account_id); + } + + // Also clean up any bindings referencing this account + if let Some(bindings) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) { + bindings.retain(|b| b.pointer("/match/accountId").and_then(|v| v.as_str()) != Some(&account_id)); + } + + save_openclaw_config(&config)?; + Ok(format!("Account '{}' deleted", account_id)) +} + // ============ Feishu Plugin Management ============ /// Feishu plugin status @@ -1683,6 +1924,14 @@ pub async fn install_feishu_plugin() -> Result { } } +// ============ OpenClaw Home Directory ============ + +/// Get the OpenClaw home directory path (~/.openclaw) +#[command] +pub async fn get_openclaw_home_dir() -> Result { + Ok(platform::get_config_dir()) +} + // ============ Multi-Agent Routing ============ /// Agent configuration for the frontend @@ -1690,24 +1939,26 @@ pub async fn install_feishu_plugin() -> Result { pub struct AgentInfo { pub id: String, pub workspace: Option, - #[serde(rename = "agentDir")] + #[serde(alias = "agentDir", alias = "agent_dir")] pub agent_dir: Option, pub model: Option, pub sandbox: Option, + pub heartbeat: Option, } /// Agent binding rule #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentBinding { - #[serde(rename = "agentId")] + #[serde(alias = "agentId", alias = "agent_id")] pub agent_id: String, + #[serde(alias = "matchRule", alias = "match_rule")] pub match_rule: MatchRule, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MatchRule { pub channel: Option, - #[serde(rename = "accountId")] + #[serde(alias = "accountId", alias = "account_id")] pub account_id: Option, pub peer: Option, } @@ -1728,21 +1979,38 @@ pub async fn get_agents_config() -> Result { let mut agents = Vec::new(); let mut bindings = Vec::new(); - // Read agents.list - if let Some(list) = config.pointer("/agents/list").and_then(|v| v.as_object()) { - for (id, agent_val) in list { + // Read agents.list — supports both array format (correct) and object format (legacy) + if let Some(list_arr) = config.pointer("/agents/list").and_then(|v| v.as_array()) { + // Correct format: array of { id, workspace, agentDir, model, ... } + for agent_val in list_arr { + agents.push(AgentInfo { + id: agent_val.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(), + workspace: agent_val.get("workspace").and_then(|v| v.as_str()).map(|s| s.to_string()), + agent_dir: agent_val.get("agentDir").and_then(|v| v.as_str()).map(|s| s.to_string()), + model: agent_val.pointer("/model/primary").and_then(|v| v.as_str()).map(|s| s.to_string()), + sandbox: agent_val.get("sandbox").and_then(|v| v.as_bool()), + heartbeat: agent_val.pointer("/heartbeat/every").and_then(|v| v.as_str()).map(|s| s.to_string()), + }); + } + } else if let Some(list_obj) = config.pointer("/agents/list").and_then(|v| v.as_object()) { + // Legacy format: object with id as keys + for (id, agent_val) in list_obj { agents.push(AgentInfo { id: id.clone(), workspace: agent_val.get("workspace").and_then(|v| v.as_str()).map(|s| s.to_string()), agent_dir: agent_val.get("agentDir").and_then(|v| v.as_str()).map(|s| s.to_string()), model: agent_val.pointer("/model/primary").and_then(|v| v.as_str()).map(|s| s.to_string()), sandbox: agent_val.get("sandbox").and_then(|v| v.as_bool()), + heartbeat: agent_val.pointer("/heartbeat/every").and_then(|v| v.as_str()).map(|s| s.to_string()), }); } } - // Read bindings - if let Some(bindings_arr) = config.pointer("/agents/bindings").and_then(|v| v.as_array()) { + // Read bindings — check top-level first (correct), then agents.bindings (legacy) + let bindings_arr = config.get("bindings").and_then(|v| v.as_array()) + .or_else(|| config.pointer("/agents/bindings").and_then(|v| v.as_array())); + + if let Some(bindings_arr) = bindings_arr { for binding_val in bindings_arr { let empty_match = json!({}); let match_obj = binding_val.get("match").unwrap_or(&empty_match); @@ -1768,15 +2036,13 @@ pub async fn save_agent(agent: AgentInfo) -> Result { info!("[Agents] Saving agent: {}", agent.id); let mut config = load_openclaw_config()?; - // Ensure agents.list exists - if config.pointer("/agents/list").is_none() { - if config.get("agents").is_none() { - config["agents"] = json!({}); - } - config["agents"]["list"] = json!({}); + // Ensure agents object exists + if config.get("agents").is_none() { + config["agents"] = json!({}); } - let mut agent_obj = json!({}); + // Build agent object (array element format with "id" field) + let mut agent_obj = json!({ "id": agent.id }); if let Some(workspace) = &agent.workspace { if !workspace.is_empty() { agent_obj["workspace"] = json!(workspace); @@ -1795,8 +2061,83 @@ pub async fn save_agent(agent: AgentInfo) -> Result { if let Some(sandbox) = agent.sandbox { agent_obj["sandbox"] = json!(sandbox); } + if let Some(heartbeat) = &agent.heartbeat { + if !heartbeat.is_empty() { + agent_obj["heartbeat"] = json!({ "every": heartbeat }); + } + } + + // Migrate legacy object format to array if needed + let mut list = if let Some(arr) = config["agents"].get("list").and_then(|v| v.as_array()) { + arr.clone() + } else if let Some(obj) = config["agents"].get("list").and_then(|v| v.as_object()) { + // Convert legacy object to array + obj.iter().map(|(id, val)| { + let mut entry = val.clone(); + entry["id"] = json!(id); + entry + }).collect() + } else { + Vec::new() + }; + + // Update or add the agent + if let Some(existing) = list.iter_mut().find(|a| a.get("id").and_then(|v| v.as_str()) == Some(&agent.id)) { + *existing = agent_obj; + } else { + list.push(agent_obj); + } + + config["agents"]["list"] = json!(list); + + // Auto-create binding if a Telegram bot account is available and this agent has no binding yet + let agent_id = agent.id.clone(); + let available_accounts: Vec = config.pointer("/channels/telegram/accounts") + .and_then(|v| v.as_object()) + .map(|accts| accts.keys().cloned().collect()) + .unwrap_or_default(); + + if !available_accounts.is_empty() { + // Check if this agent already has ANY binding + let has_existing_binding = config.get("bindings") + .and_then(|v| v.as_array()) + .map(|bindings| bindings.iter().any(|b| { + b.get("agentId").and_then(|v| v.as_str()) == Some(&agent_id) + })) + .unwrap_or(false); + + if !has_existing_binding { + // Find accounts already bound to other agents + let bound_accounts: Vec = config.get("bindings") + .and_then(|v| v.as_array()) + .map(|bindings| bindings.iter().filter_map(|b| { + b.get("match").and_then(|m| m.get("accountId")).and_then(|v| v.as_str()).map(|s| s.to_string()) + }).collect()) + .unwrap_or_default(); + + // Prefer: exact match > substring match > first unbound account > first account + let best_account = available_accounts.iter() + .find(|a| **a == agent_id) // exact match + .or_else(|| available_accounts.iter().find(|a| a.contains(&agent_id) || agent_id.contains(a.as_str()))) // substring + .or_else(|| available_accounts.iter().find(|a| !bound_accounts.contains(a))) // unbound + .or_else(|| available_accounts.first()) // fallback + .cloned(); + + if let Some(account_id) = best_account { + info!("[Agents] Auto-creating binding for agent '{}' → account '{}'", agent_id, account_id); + if config.get("bindings").is_none() { + config["bindings"] = json!([]); + } + if let Some(bindings) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) { + bindings.push(json!({ + "agentId": agent_id, + "match": { "channel": "telegram", "accountId": account_id } + })); + } + } + } + } - config["agents"]["list"][&agent.id] = agent_obj; save_openclaw_config(&config)?; Ok(format!("Agent '{}' saved", agent.id)) } @@ -1807,12 +2148,16 @@ pub async fn delete_agent(agent_id: String) -> Result { info!("[Agents] Deleting agent: {}", agent_id); let mut config = load_openclaw_config()?; - // Remove from agents.list - if let Some(list) = config.pointer_mut("/agents/list").and_then(|v| v.as_object_mut()) { - list.remove(&agent_id); + // Remove from agents.list (array format) + if let Some(list) = config.pointer_mut("/agents/list").and_then(|v| v.as_array_mut()) { + list.retain(|a| a.get("id").and_then(|v| v.as_str()) != Some(&agent_id)); } - // Remove related bindings + // Remove related bindings (top-level) + if let Some(bindings) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) { + bindings.retain(|b| b.get("agentId").and_then(|v| v.as_str()) != Some(&agent_id)); + } + // Also clean legacy agents.bindings if let Some(bindings) = config.pointer_mut("/agents/bindings").and_then(|v| v.as_array_mut()) { bindings.retain(|b| b.get("agentId").and_then(|v| v.as_str()) != Some(&agent_id)); } @@ -1828,12 +2173,22 @@ pub async fn save_agent_binding(binding: AgentBinding) -> Result info!("[Agents] Saving binding for agent: {}", binding.agent_id); let mut config = load_openclaw_config()?; - // Ensure agents.bindings array exists - if config.get("agents").is_none() { - config["agents"] = json!({}); + // Ensure top-level bindings array exists + if config.get("bindings").is_none() { + config["bindings"] = json!([]); } - if config["agents"].get("bindings").is_none() { - config["agents"]["bindings"] = json!([]); + + // Migrate legacy agents.bindings to top-level if present + if let Some(legacy) = config.pointer("/agents/bindings").and_then(|v| v.as_array()).map(|a| a.clone()) { + if let Some(top) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) { + for b in legacy { + top.push(b); + } + } + // Remove legacy location + if let Some(agents) = config.get_mut("agents").and_then(|v| v.as_object_mut()) { + agents.remove("bindings"); + } } let mut match_obj = json!({}); @@ -1852,7 +2207,7 @@ pub async fn save_agent_binding(binding: AgentBinding) -> Result "match": match_obj }); - if let Some(bindings) = config.pointer_mut("/agents/bindings").and_then(|v| v.as_array_mut()) { + if let Some(bindings) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) { bindings.push(binding_obj); } @@ -1866,18 +2221,124 @@ pub async fn delete_agent_binding(index: usize) -> Result { info!("[Agents] Deleting binding at index: {}", index); let mut config = load_openclaw_config()?; - if let Some(bindings) = config.pointer_mut("/agents/bindings").and_then(|v| v.as_array_mut()) { + // Try top-level bindings first (correct location) + if let Some(bindings) = config.get_mut("bindings").and_then(|v| v.as_array_mut()) { if index < bindings.len() { bindings.remove(index); + save_openclaw_config(&config)?; + return Ok(format!("Binding at index {} deleted", index)); } else { return Err(format!("Binding index {} out of range", index)); } - } else { - return Err("No bindings found".to_string()); } - save_openclaw_config(&config)?; - Ok(format!("Binding at index {} deleted", index)) + // Fallback to legacy agents.bindings + if let Some(bindings) = config.pointer_mut("/agents/bindings").and_then(|v| v.as_array_mut()) { + if index < bindings.len() { + bindings.remove(index); + save_openclaw_config(&config)?; + return Ok(format!("Binding at index {} deleted", index)); + } else { + return Err(format!("Binding index {} out of range", index)); + } + } + + Err("No bindings found".to_string()) +} + +// ============ Agent System Prompt ============ + +/// Read the system prompt (SYSTEM.md) for an agent +#[command] +pub async fn get_agent_system_prompt(agent_id: String, workspace: Option) -> Result { + let base = workspace.unwrap_or_else(|| platform::get_config_dir()); + let sep = if cfg!(windows) { "\\" } else { "/" }; + let path = format!("{}{}agents{}{}{}SYSTEM.md", base, sep, sep, agent_id, sep); + info!("[Agents] Reading system prompt from: {}", path); + + if std::path::Path::new(&path).exists() { + std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read system prompt: {}", e)) + } else { + Ok(String::new()) + } +} + +/// Save the system prompt (SYSTEM.md) for an agent +#[command] +pub async fn save_agent_system_prompt(agent_id: String, workspace: Option, content: String) -> Result { + let base = workspace.unwrap_or_else(|| platform::get_config_dir()); + let sep = if cfg!(windows) { "\\" } else { "/" }; + let dir = format!("{}{}agents{}{}", base, sep, sep, agent_id); + let path = format!("{}{}SYSTEM.md", dir, sep); + info!("[Agents] Writing system prompt to: {}", path); + + // Create directory if it doesn't exist + std::fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create agent directory: {}", e))?; + + std::fs::write(&path, &content) + .map_err(|e| format!("Failed to write system prompt: {}", e))?; + + Ok(format!("System prompt saved for agent '{}'", agent_id)) +} + +/// Test agent routing: given an account ID, find which agent handles it +#[command] +pub async fn test_agent_routing(account_id: String) -> Result { + let config = load_openclaw_config()?; + + // Walk through bindings to find a match + let bindings = config.get("bindings").and_then(|v| v.as_array()); + + if let Some(bindings) = bindings { + let empty_match = json!({}); + for binding in bindings { + let match_obj = binding.get("match").unwrap_or(&empty_match); + let binding_account = match_obj.get("accountId").and_then(|v| v.as_str()); + let binding_channel = match_obj.get("channel").and_then(|v| v.as_str()); + + // Check if this binding matches + let account_matches = binding_account.map(|a| a == account_id).unwrap_or(true); // None = catch-all + let channel_matches = binding_channel.map(|c| c == "telegram").unwrap_or(true); + + if account_matches && channel_matches { + let agent_id = binding.get("agentId").and_then(|v| v.as_str()).unwrap_or("unknown"); + + // Find agent details + let agent_info = config.pointer("/agents/list") + .and_then(|v| v.as_array()) + .and_then(|list| list.iter().find(|a| a.get("id").and_then(|v| v.as_str()) == Some(agent_id))); + + // Read system prompt preview + let base = platform::get_config_dir(); + let sep = if cfg!(windows) { "\\" } else { "/" }; + let prompt_path = format!("{}{}agents{}{}{}SYSTEM.md", base, sep, sep, agent_id, sep); + let prompt_preview = std::fs::read_to_string(&prompt_path) + .unwrap_or_default(); + let prompt_preview = if prompt_preview.len() > 200 { + format!("{}...", &prompt_preview[..200]) + } else { + prompt_preview + }; + + return Ok(json!({ + "matched": true, + "agent_id": agent_id, + "agent_dir": agent_info.and_then(|a| a.get("agentDir").and_then(|v| v.as_str())), + "model": agent_info.and_then(|a| a.pointer("/model/primary").and_then(|v| v.as_str())), + "system_prompt_preview": prompt_preview, + "binding": binding + })); + } + } + } + + Ok(json!({ + "matched": false, + "agent_id": "default", + "message": "No specific binding found. Messages will be handled by the default agent." + })) } // ============ Heartbeat & Compaction ============ diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index 76bfe81..fe6be00 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -197,19 +197,59 @@ pub async fn stop_service() -> Result { pub async fn restart_service() -> Result { info!("[Service] Restarting service..."); - let _ = shell::run_openclaw(&["gateway", "restart"]); - std::thread::sleep(std::time::Duration::from_secs(2)); - + // Step 1: Stop the service if it's running let status = get_service_status().await?; if status.running { - info!("[Service] Successfully restarted, PID: {:?}", status.pid); - Ok(format!("Service restarted, PID: {:?}", status.pid)) + info!("[Service] Service is running, stopping first..."); + let _ = shell::run_openclaw(&["gateway", "stop"]); + std::thread::sleep(std::time::Duration::from_millis(500)); + + // Check if stopped + let status = get_service_status().await?; + if status.running { + info!("[Service] Service still running, trying force stop..."); + let _ = shell::run_openclaw(&["gateway", "stop", "--force"]); + std::thread::sleep(std::time::Duration::from_millis(500)); + } + + // Wait for port to be freed (max 5 seconds) + for i in 1..=10 { + if check_port_listening(SERVICE_PORT).is_none() { + info!("[Service] Port {} freed after {}ms", SERVICE_PORT, i * 500); + break; + } + if i == 10 { + return Err(format!( + "Failed to stop service: port {} still in use after 5s", + SERVICE_PORT + )); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } } else { - // Manually stop then start - let _ = stop_service().await; - std::thread::sleep(std::time::Duration::from_secs(1)); - start_service().await + info!("[Service] Service was not running"); } + + // Step 2: Start the service + info!("[Service] Starting gateway in background..."); + shell::spawn_openclaw_gateway() + .map_err(|e| format!("Failed to start service: {}", e))?; + + // Step 3: Poll and wait for port to start listening (max 15 seconds) + info!("[Service] Waiting for port {} to start listening...", SERVICE_PORT); + for i in 1..=15 { + std::thread::sleep(std::time::Duration::from_secs(1)); + if let Some(pid) = check_port_listening(SERVICE_PORT) { + info!("[Service] Successfully restarted ({}s), PID: {}", i, pid); + return Ok(format!("Service restarted, PID: {}", pid)); + } + if i % 3 == 0 { + debug!("[Service] Waiting... ({}s)", i); + } + } + + info!("[Service] Restart timeout, port still not listening"); + Err("Service restart timeout (15s), please check openclaw logs".to_string()) } /// Get logs diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 73d4095..dc3c72b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -94,11 +94,19 @@ fn main() { skills::uninstall_skill, skills::uninstall_clawhub, // Multi-Agent Routing + config::get_openclaw_home_dir, config::get_agents_config, config::save_agent, config::delete_agent, config::save_agent_binding, config::delete_agent_binding, + config::get_agent_system_prompt, + config::save_agent_system_prompt, + config::test_agent_routing, + // Telegram Multi-Account + config::get_telegram_accounts, + config::save_telegram_account, + config::delete_telegram_account, // Heartbeat & Compaction config::get_heartbeat_config, config::save_heartbeat_config, diff --git a/src/components/Agents/index.tsx b/src/components/Agents/index.tsx index 7d5112c..3b976dc 100644 --- a/src/components/Agents/index.tsx +++ b/src/components/Agents/index.tsx @@ -12,8 +12,15 @@ import { AlertCircle, ArrowRight, MessageSquare, - Hash, - GitMerge + GitMerge, + Copy, + Zap, + CheckCircle2, + ChevronRight, + ChevronDown, + FileText, + Bot, + Sparkles } from 'lucide-react'; import { appLogger } from '../../lib/logger'; @@ -24,12 +31,13 @@ interface AgentInfo { agent_dir: string | null; model: string | null; sandbox: boolean | null; + heartbeat: string | null; } interface MatchRule { channel: string | null; account_id: string | null; - peer: any | null; // Supports string (legacy) or object { kind: 'group', id: '...' } + peer: any | null; } interface AgentBinding { @@ -42,17 +50,45 @@ interface AgentsConfigResponse { bindings: AgentBinding[]; } +interface TelegramAccount { + id: string; + token?: string; + groups?: Record; + exclusive_topics?: string[]; +} + +interface RoutingTestResult { + matched: boolean; + agent_id: string; + agent_dir?: string; + model?: string; + system_prompt_preview?: string; + message?: string; +} + export function Agents() { const [loading, setLoading] = useState(true); const [agents, setAgents] = useState([]); const [bindings, setBindings] = useState([]); const [error, setError] = useState(null); + const [openclawHomeDir, setOpenclawHomeDir] = useState(''); + const [telegramAccounts, setTelegramAccounts] = useState([]); // Dialog states const [showAgentDialog, setShowAgentDialog] = useState(false); const [editingAgent, setEditingAgent] = useState(null); const [showBindingDialog, setShowBindingDialog] = useState(false); + const [showWizardDialog, setShowWizardDialog] = useState(false); const [saving, setSaving] = useState(false); + const [showRoutingFlow, setShowRoutingFlow] = useState(false); + + // System prompt state + const [systemPrompt, setSystemPrompt] = useState(''); + const [loadingPrompt, setLoadingPrompt] = useState(false); + + // Routing test state + const [testResult, setTestResult] = useState(null); + const [testingAccount, setTestingAccount] = useState(null); // Form states const [agentForm, setAgentForm] = useState({ @@ -60,7 +96,8 @@ export function Agents() { workspace: null, agent_dir: null, model: null, - sandbox: null + sandbox: null, + heartbeat: null }); const [bindingForm, setBindingForm] = useState({ @@ -72,9 +109,14 @@ export function Agents() { } }); - // Peer Type State for Binding Form - const [peerType, setPeerType] = useState<'any' | 'user' | 'group'>('any'); - const [peerId, setPeerId] = useState(''); + // Wizard form state + const [wizardStep, setWizardStep] = useState(0); + const [wizardForm, setWizardForm] = useState({ + botAccountId: '', + agentId: '', + systemPrompt: '', + model: '', + }); const fetchData = async () => { setLoading(true); @@ -91,15 +133,50 @@ export function Agents() { } }; + const fetchAccounts = async () => { + try { + const accts = await invoke('get_telegram_accounts'); + setTelegramAccounts(accts); + } catch { /* ignore */ } + }; + useEffect(() => { fetchData(); + invoke('get_openclaw_home_dir').then(dir => setOpenclawHomeDir(dir)).catch(() => { }); + fetchAccounts(); }, []); + // Load system prompt when editing an agent + const loadSystemPrompt = async (agentId: string, workspace: string | null) => { + setLoadingPrompt(true); + try { + const prompt = await invoke('get_agent_system_prompt', { + agentId, + workspace: workspace || null + }); + setSystemPrompt(prompt); + } catch { + setSystemPrompt(''); + } finally { + setLoadingPrompt(false); + } + }; + const handleSaveAgent = async () => { if (!agentForm.id) return; setSaving(true); try { await invoke('save_agent', { agent: agentForm }); + + // Save system prompt if provided + if (systemPrompt.trim()) { + await invoke('save_agent_system_prompt', { + agentId: agentForm.id, + workspace: agentForm.workspace || null, + content: systemPrompt + }); + } + setShowAgentDialog(false); fetchData(); } catch (e) { @@ -143,6 +220,73 @@ export function Agents() { } }; + // Clone agent handler + const handleCloneAgent = (agent: AgentInfo) => { + setEditingAgent(null); + setAgentForm({ + id: `${agent.id}_copy`, + workspace: agent.workspace, + agent_dir: agent.agent_dir ? `${agent.agent_dir}_copy` : null, + model: agent.model, + sandbox: agent.sandbox, + heartbeat: agent.heartbeat + }); + setSystemPrompt(''); + // Load original system prompt for cloning + loadSystemPrompt(agent.id, agent.workspace); + setShowAgentDialog(true); + }; + + // Test routing handler + const handleTestRouting = async (accountId: string) => { + setTestingAccount(accountId); + try { + const result = await invoke('test_agent_routing', { accountId }); + setTestResult(result); + } catch (e) { + setError(String(e)); + } finally { + setTestingAccount(null); + } + }; + + // Wizard submit handler + const handleWizardSubmit = async () => { + setSaving(true); + try { + // 1. Save agent + const agent: AgentInfo = { + id: wizardForm.agentId, + workspace: openclawHomeDir || null, + agent_dir: `agents/${wizardForm.agentId}`, + model: wizardForm.model || null, + sandbox: null, + heartbeat: null + }; + await invoke('save_agent', { agent }); + + // 2. Save system prompt if provided + if (wizardForm.systemPrompt.trim()) { + await invoke('save_agent_system_prompt', { + agentId: wizardForm.agentId, + workspace: openclawHomeDir || null, + content: wizardForm.systemPrompt + }); + } + + // Note: save_agent auto-creates binding if matching account exists + + setShowWizardDialog(false); + setWizardStep(0); + setWizardForm({ botAccountId: '', agentId: '', systemPrompt: '', model: '' }); + fetchData(); + } catch (e) { + setError(String(e)); + } finally { + setSaving(false); + } + }; + if (loading && !agents.length) { return (
@@ -154,6 +298,64 @@ export function Agents() { return (
+ {/* Visual Routing Diagram */} + {bindings.length > 0 && ( +
+ + + {showRoutingFlow && ( + +
+
+ {bindings.map((binding, idx) => { + const agent = agents.find(a => a.id === binding.agent_id); + return ( +
+
+ + {binding.match_rule?.account_id || 'Any'} +
+ +
+ {binding.match_rule?.channel || 'any'} +
+ +
+ + {binding.agent_id} +
+ +
+ {agent?.model || 'Default Model'} +
+
+ ); + })} +
+
+
+ )} +
+
+ )} + {/* Agents Section */}
@@ -164,17 +366,36 @@ export function Agents() {

Manage agent definitions and overrides

- +
+ + +
@@ -195,20 +416,31 @@ export function Agents() { {agent.sandbox && Sandbox}
-
+
+ @@ -223,12 +455,6 @@ export function Agents() {
)} - {agent.workspace && ( -
- - {agent.workspace} -
- )} {agent.model && (
@@ -256,10 +482,8 @@ export function Agents() { onClick={() => { setBindingForm({ agent_id: agents[0]?.id || '', - match_rule: { channel: null, account_id: null, peer: null } + match_rule: { channel: 'telegram', account_id: telegramAccounts[0]?.id || null, peer: null } }); - setPeerType('any'); - setPeerId(''); setShowBindingDialog(true); }} disabled={agents.length === 0} @@ -301,13 +525,6 @@ export function Agents() { Account: {binding.match_rule.account_id} )} - {binding.match_rule?.peer && ( - - {typeof binding.match_rule.peer === 'object' && binding.match_rule.peer.kind === 'group' - ? `Group: ${binding.match_rule.peer.id}` - : `Peer: ${binding.match_rule.peer}`} - - )} {!binding.match_rule?.channel && !binding.match_rule?.account_id && !binding.match_rule?.peer && ( Catch-all )} @@ -320,12 +537,26 @@ export function Agents() {
- +
+ + +
)) @@ -335,6 +566,54 @@ export function Agents() { + {/* Test Routing Result */} + + {testResult && ( + +
+
+
+ {testResult.matched + ? + : + } + Routing Test Result +
+ +
+
+
+ Agent: + {testResult.agent_id} +
+ {testResult.model && ( +
+ Model: + {testResult.model} +
+ )} + {testResult.system_prompt_preview && ( +
+ System Prompt: +
+ {testResult.system_prompt_preview} +
+
+ )} + {testResult.message && ( +

{testResult.message}

+ )} +
+
+
+ )} +
+ {/* Agent Dialog */} {showAgentDialog && ( @@ -343,17 +622,17 @@ export function Agents() { initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} - className="bg-dark-800 rounded-xl border border-dark-600 w-full max-w-md overflow-hidden" + className="bg-dark-800 rounded-xl border border-dark-600 w-full max-w-lg overflow-hidden max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()} > -
+

{editingAgent ? 'Edit Agent' : 'Add New Agent'}

-
+
setAgentForm({ ...agentForm, workspace: e.target.value || null })} className="input-base" - placeholder="/path/to/workspace" + placeholder={openclawHomeDir || '/path/to/workspace'} /> +

Default: {openclawHomeDir || '~/.openclaw'}

@@ -382,8 +662,9 @@ export function Agents() { value={agentForm.agent_dir || ''} onChange={e => setAgentForm({ ...agentForm, agent_dir: e.target.value || null })} className="input-base" - placeholder="e.g. agents/investing" + placeholder="e.g. agents/coder" /> +

Subdirectory for agent-specific files. Relative to workspace.

@@ -392,9 +673,32 @@ export function Agents() { value={agentForm.model || ''} onChange={e => setAgentForm({ ...agentForm, model: e.target.value || null })} className="input-base" - placeholder="provider/model-id" + placeholder="e.g. glm/glm-5" />
+ + {/* System Prompt Editor */} +
+ + {loadingPrompt ? ( +
+ Loading... +
+ ) : ( +