mirror of
https://github.com/MrFadiAi/openclaw-manager.git
synced 2026-08-14 00:57:59 +00:00
Parrel agents
This commit is contained in:
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1581,6 +1581,247 @@ pub async fn clear_channel_config(channel_id: String) -> Result<String, String>
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 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<String>,
|
||||
#[serde(alias = "dmPolicy", alias = "dm_policy")]
|
||||
pub dm_policy: Option<String>,
|
||||
#[serde(alias = "streamMode", alias = "stream_mode")]
|
||||
pub stream_mode: Option<String>,
|
||||
#[serde(alias = "exclusiveTopics", alias = "exclusive_topics")]
|
||||
pub exclusive_topics: Option<Vec<String>>,
|
||||
pub groups: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Get all Telegram bot accounts
|
||||
#[command]
|
||||
pub async fn get_telegram_accounts() -> Result<Vec<TelegramAccount>, 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<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ OpenClaw Home Directory ============
|
||||
|
||||
/// Get the OpenClaw home directory path (~/.openclaw)
|
||||
#[command]
|
||||
pub async fn get_openclaw_home_dir() -> Result<String, String> {
|
||||
Ok(platform::get_config_dir())
|
||||
}
|
||||
|
||||
// ============ Multi-Agent Routing ============
|
||||
|
||||
/// Agent configuration for the frontend
|
||||
@@ -1690,24 +1939,26 @@ pub async fn install_feishu_plugin() -> Result<String, String> {
|
||||
pub struct AgentInfo {
|
||||
pub id: String,
|
||||
pub workspace: Option<String>,
|
||||
#[serde(rename = "agentDir")]
|
||||
#[serde(alias = "agentDir", alias = "agent_dir")]
|
||||
pub agent_dir: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub sandbox: Option<bool>,
|
||||
pub heartbeat: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(rename = "accountId")]
|
||||
#[serde(alias = "accountId", alias = "account_id")]
|
||||
pub account_id: Option<String>,
|
||||
pub peer: Option<serde_json::Value>,
|
||||
}
|
||||
@@ -1728,21 +1979,38 @@ pub async fn get_agents_config() -> Result<AgentsConfigResponse, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<String> = 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<String> = 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<String, String> {
|
||||
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<String, String>
|
||||
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<String, String>
|
||||
"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<String, String> {
|
||||
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<String>) -> Result<String, String> {
|
||||
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<String>, content: String) -> Result<String, String> {
|
||||
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<serde_json::Value, String> {
|
||||
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 ============
|
||||
|
||||
@@ -197,19 +197,59 @@ pub async fn stop_service() -> Result<String, String> {
|
||||
pub async fn restart_service() -> Result<String, String> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+508
-107
@@ -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<string, any>;
|
||||
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<AgentInfo[]>([]);
|
||||
const [bindings, setBindings] = useState<AgentBinding[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openclawHomeDir, setOpenclawHomeDir] = useState<string>('');
|
||||
const [telegramAccounts, setTelegramAccounts] = useState<TelegramAccount[]>([]);
|
||||
|
||||
// Dialog states
|
||||
const [showAgentDialog, setShowAgentDialog] = useState(false);
|
||||
const [editingAgent, setEditingAgent] = useState<AgentInfo | null>(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<RoutingTestResult | null>(null);
|
||||
const [testingAccount, setTestingAccount] = useState<string | null>(null);
|
||||
|
||||
// Form states
|
||||
const [agentForm, setAgentForm] = useState<AgentInfo>({
|
||||
@@ -60,7 +96,8 @@ export function Agents() {
|
||||
workspace: null,
|
||||
agent_dir: null,
|
||||
model: null,
|
||||
sandbox: null
|
||||
sandbox: null,
|
||||
heartbeat: null
|
||||
});
|
||||
|
||||
const [bindingForm, setBindingForm] = useState<AgentBinding>({
|
||||
@@ -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<TelegramAccount[]>('get_telegram_accounts');
|
||||
setTelegramAccounts(accts);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
invoke<string>('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<string>('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<RoutingTestResult>('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 (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -154,6 +298,64 @@ export function Agents() {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto scroll-container pr-2 space-y-8">
|
||||
|
||||
{/* Visual Routing Diagram */}
|
||||
{bindings.length > 0 && (
|
||||
<section>
|
||||
<button
|
||||
onClick={() => setShowRoutingFlow(!showRoutingFlow)}
|
||||
className="w-full flex items-center justify-between mb-4 group cursor-pointer"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
|
||||
<Sparkles className="text-amber-400" size={24} />
|
||||
Routing Flow
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-gray-500 group-hover:text-gray-300 transition-colors">
|
||||
<span className="text-xs">{showRoutingFlow ? 'Hide' : 'Show'}</span>
|
||||
<ChevronDown size={16} className={`transition-transform ${showRoutingFlow ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showRoutingFlow && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="bg-dark-700 rounded-xl border border-dark-600 p-5 overflow-x-auto">
|
||||
<div className="flex flex-col gap-3">
|
||||
{bindings.map((binding, idx) => {
|
||||
const agent = agents.find(a => a.id === binding.agent_id);
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-0 text-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-500/10 border border-blue-500/30 text-blue-300 min-w-[120px]">
|
||||
<Bot size={14} />
|
||||
<span className="font-medium">{binding.match_rule?.account_id || 'Any'}</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-gray-600 mx-1 flex-shrink-0" />
|
||||
<div className="px-3 py-2 rounded-lg bg-dark-600 border border-dark-500 text-gray-400 text-xs">
|
||||
{binding.match_rule?.channel || 'any'}
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-gray-600 mx-1 flex-shrink-0" />
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-claw-500/10 border border-claw-500/30 text-claw-300 min-w-[120px]">
|
||||
<Users size={14} />
|
||||
<span className="font-medium">{binding.agent_id}</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-gray-600 mx-1 flex-shrink-0" />
|
||||
<div className="px-3 py-2 rounded-lg bg-purple-500/10 border border-purple-500/30 text-purple-300 text-xs max-w-[200px] truncate">
|
||||
{agent?.model || 'Default Model'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Agents Section */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -164,17 +366,36 @@ export function Agents() {
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">Manage agent definitions and overrides</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingAgent(null);
|
||||
setAgentForm({ id: '', workspace: null, agent_dir: null, model: null, sandbox: null });
|
||||
setShowAgentDialog(true);
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Agent
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setWizardStep(0);
|
||||
setWizardForm({
|
||||
botAccountId: telegramAccounts[0]?.id || '',
|
||||
agentId: '',
|
||||
systemPrompt: '',
|
||||
model: ''
|
||||
});
|
||||
setShowWizardDialog(true);
|
||||
}}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Zap size={16} />
|
||||
Quick Setup
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingAgent(null);
|
||||
setAgentForm({ id: '', workspace: openclawHomeDir || null, agent_dir: null, model: null, sandbox: null, heartbeat: null });
|
||||
setSystemPrompt('');
|
||||
setShowAgentDialog(true);
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -195,20 +416,31 @@ export function Agents() {
|
||||
{agent.sandbox && <span className="text-xs text-amber-400 bg-amber-500/10 px-1.5 rounded">Sandbox</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<button
|
||||
onClick={() => handleCloneAgent(agent)}
|
||||
className="p-1.5 hover:bg-dark-600 rounded text-gray-400 hover:text-blue-400"
|
||||
title="Clone Agent"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingAgent(agent);
|
||||
setAgentForm(agent);
|
||||
setSystemPrompt('');
|
||||
loadSystemPrompt(agent.id, agent.workspace);
|
||||
setShowAgentDialog(true);
|
||||
}}
|
||||
className="p-1.5 hover:bg-dark-600 rounded text-gray-400 hover:text-white"
|
||||
title="Edit Agent"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteAgent(agent.id)}
|
||||
className="p-1.5 hover:bg-dark-600 rounded text-gray-400 hover:text-red-400"
|
||||
title="Delete Agent"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -223,12 +455,6 @@ export function Agents() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{agent.workspace && (
|
||||
<div className="flex items-center gap-2" title="Workspace Override">
|
||||
<Hash size={14} />
|
||||
<span className="truncate">{agent.workspace}</span>
|
||||
</div>
|
||||
)}
|
||||
{agent.model && (
|
||||
<div className="flex items-center gap-2" title="Model Override">
|
||||
<MessageSquare size={14} />
|
||||
@@ -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}
|
||||
</span>
|
||||
)}
|
||||
{binding.match_rule?.peer && (
|
||||
<span className="px-2 py-1 rounded bg-amber-500/20 text-amber-300 text-xs border border-amber-500/30">
|
||||
{typeof binding.match_rule.peer === 'object' && binding.match_rule.peer.kind === 'group'
|
||||
? `Group: ${binding.match_rule.peer.id}`
|
||||
: `Peer: ${binding.match_rule.peer}`}
|
||||
</span>
|
||||
)}
|
||||
{!binding.match_rule?.channel && !binding.match_rule?.account_id && !binding.match_rule?.peer && (
|
||||
<span className="text-gray-500 italic">Catch-all</span>
|
||||
)}
|
||||
@@ -320,12 +537,26 @@ export function Agents() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDeleteBinding(idx)}
|
||||
className="p-1.5 hover:bg-dark-500 rounded text-gray-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleTestRouting(binding.match_rule?.account_id || binding.agent_id)}
|
||||
disabled={testingAccount === (binding.match_rule?.account_id || binding.agent_id)}
|
||||
className="p-1.5 hover:bg-dark-500 rounded text-gray-400 hover:text-green-400 transition-colors"
|
||||
title="Test Routing"
|
||||
>
|
||||
{testingAccount === (binding.match_rule?.account_id || binding.agent_id)
|
||||
? <Loader2 className="animate-spin" size={14} />
|
||||
: <Zap size={14} />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBinding(idx)}
|
||||
className="p-1.5 hover:bg-dark-500 rounded text-gray-400 hover:text-red-400 transition-colors"
|
||||
title="Delete Rule"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
@@ -335,6 +566,54 @@ export function Agents() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Test Routing Result */}
|
||||
<AnimatePresence>
|
||||
{testResult && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="fixed bottom-4 right-4 z-40 max-w-md"
|
||||
>
|
||||
<div className={`rounded-xl border shadow-2xl p-4 ${testResult.matched ? 'bg-dark-800 border-green-500/30' : 'bg-dark-800 border-amber-500/30'}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{testResult.matched
|
||||
? <CheckCircle2 size={18} className="text-green-400" />
|
||||
: <AlertCircle size={18} className="text-amber-400" />
|
||||
}
|
||||
<span className="text-sm font-semibold text-white">Routing Test Result</span>
|
||||
</div>
|
||||
<button onClick={() => setTestResult(null)} className="text-gray-500 hover:text-white"><X size={14} /></button>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400">Agent:</span>
|
||||
<span className="text-white font-medium">{testResult.agent_id}</span>
|
||||
</div>
|
||||
{testResult.model && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-400">Model:</span>
|
||||
<span className="text-purple-300">{testResult.model}</span>
|
||||
</div>
|
||||
)}
|
||||
{testResult.system_prompt_preview && (
|
||||
<div>
|
||||
<span className="text-gray-400 text-xs">System Prompt:</span>
|
||||
<div className="mt-1 p-2 bg-dark-700 rounded text-xs text-gray-300 font-mono max-h-24 overflow-auto">
|
||||
{testResult.system_prompt_preview}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{testResult.message && (
|
||||
<p className="text-amber-300 text-xs">{testResult.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Agent Dialog */}
|
||||
<AnimatePresence>
|
||||
{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()}
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-dark-600 flex justify-between items-center">
|
||||
<div className="px-6 py-4 border-b border-dark-600 flex justify-between items-center flex-shrink-0">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{editingAgent ? 'Edit Agent' : 'Add New Agent'}
|
||||
</h3>
|
||||
<button onClick={() => setShowAgentDialog(false)} className="text-gray-500 hover:text-white"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-6 space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Agent ID *</label>
|
||||
<input
|
||||
@@ -372,8 +651,9 @@ export function Agents() {
|
||||
value={agentForm.workspace || ''}
|
||||
onChange={e => setAgentForm({ ...agentForm, workspace: e.target.value || null })}
|
||||
className="input-base"
|
||||
placeholder="/path/to/workspace"
|
||||
placeholder={openclawHomeDir || '/path/to/workspace'}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Default: <code className="text-gray-400">{openclawHomeDir || '~/.openclaw'}</code></p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Agent Directory (Optional)</label>
|
||||
@@ -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"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Subdirectory for agent-specific files. Relative to workspace.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Model Override (Optional)</label>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System Prompt Editor */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1 flex items-center gap-2">
|
||||
<FileText size={14} />
|
||||
System Prompt (SYSTEM.md)
|
||||
</label>
|
||||
{loadingPrompt ? (
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm py-4">
|
||||
<Loader2 className="animate-spin" size={14} /> Loading...
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={e => setSystemPrompt(e.target.value)}
|
||||
className="input-base font-mono text-sm"
|
||||
rows={6}
|
||||
placeholder="You are a coding expert specializing in..."
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">Defines the agent's personality. Saved to <code className="text-gray-400">agents/{agentForm.id || '...'}/SYSTEM.md</code></p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -405,9 +709,19 @@ export function Agents() {
|
||||
/>
|
||||
<label htmlFor="sandbox" className="text-sm text-gray-300 select-none">Enable Sandbox</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Heartbeat Interval (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentForm.heartbeat || ''}
|
||||
onChange={e => setAgentForm({ ...agentForm, heartbeat: e.target.value || null })}
|
||||
className="input-base"
|
||||
placeholder='e.g. "1h" or "0m" to disable'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-dark-600 flex justify-end gap-3">
|
||||
<div className="px-6 py-4 border-t border-dark-600 flex justify-end gap-3 flex-shrink-0">
|
||||
<button onClick={() => setShowAgentDialog(false)} className="btn-secondary">Cancel</button>
|
||||
<button
|
||||
onClick={handleSaveAgent}
|
||||
@@ -452,77 +766,33 @@ export function Agents() {
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-dark-600">
|
||||
<p className="text-xs text-gray-500 mb-3 uppercase font-semibold">Match Criteria (Leave empty to ignore)</p>
|
||||
|
||||
<p className="text-xs text-gray-500 mb-3 uppercase font-semibold">Match Criteria</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Channel (e.g. whatsapp)</label>
|
||||
<label className="block text-sm text-gray-400 mb-1">Channel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bindingForm.match_rule.channel || ''}
|
||||
onChange={e => setBindingForm({
|
||||
...bindingForm,
|
||||
match_rule: { ...bindingForm.match_rule, channel: e.target.value || null }
|
||||
})}
|
||||
className="input-base"
|
||||
placeholder="Any channel"
|
||||
value={bindingForm.match_rule.channel || 'telegram'}
|
||||
readOnly
|
||||
className="input-base bg-dark-700 text-gray-400 cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Currently only Telegram supports multi-agent routing.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Account ID (e.g. email or phone)</label>
|
||||
<input
|
||||
type="text"
|
||||
<label className="block text-sm text-gray-400 mb-1">Bot Account *</label>
|
||||
<select
|
||||
value={bindingForm.match_rule.account_id || ''}
|
||||
onChange={e => setBindingForm({
|
||||
...bindingForm,
|
||||
match_rule: { ...bindingForm.match_rule, account_id: e.target.value || null }
|
||||
})}
|
||||
className="input-base"
|
||||
placeholder="Any account"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Peer Type Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-gray-400">Peer Match</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<button
|
||||
onClick={() => { setPeerType('any'); setBindingForm({ ...bindingForm, match_rule: { ...bindingForm.match_rule, peer: null } }); }}
|
||||
className={`px-3 py-1.5 text-xs rounded border ${peerType === 'any' ? 'bg-claw-500/20 border-claw-500 text-claw-400' : 'bg-dark-600 border-dark-500 text-gray-400'}`}
|
||||
>
|
||||
Any
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPeerType('user')}
|
||||
className={`px-3 py-1.5 text-xs rounded border ${peerType === 'user' ? 'bg-claw-500/20 border-claw-500 text-claw-400' : 'bg-dark-600 border-dark-500 text-gray-400'}`}
|
||||
>
|
||||
User ID
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPeerType('group')}
|
||||
className={`px-3 py-1.5 text-xs rounded border ${peerType === 'group' ? 'bg-claw-500/20 border-claw-500 text-claw-400' : 'bg-dark-600 border-dark-500 text-gray-400'}`}
|
||||
>
|
||||
Group ID
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{peerType !== 'any' && (
|
||||
<input
|
||||
type="text"
|
||||
value={peerId}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setPeerId(val);
|
||||
if (peerType === 'user') {
|
||||
setBindingForm({ ...bindingForm, match_rule: { ...bindingForm.match_rule, peer: val || null } });
|
||||
} else {
|
||||
setBindingForm({ ...bindingForm, match_rule: { ...bindingForm.match_rule, peer: val ? { kind: 'group', id: val } : null } });
|
||||
}
|
||||
}}
|
||||
className="input-base"
|
||||
placeholder={peerType === 'group' ? "e.g. -100123456789" : "e.g. user_123"}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<option value="">Select a bot account...</option>
|
||||
{telegramAccounts.map(acct => (
|
||||
<option key={acct.id} value={acct.id}>{acct.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,8 +814,139 @@ export function Agents() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Quick Setup Wizard Dialog */}
|
||||
<AnimatePresence>
|
||||
{showWizardDialog && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={() => setShowWizardDialog(false)}>
|
||||
<motion.div
|
||||
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-lg overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-dark-600 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="text-amber-400" size={20} />
|
||||
<h3 className="text-lg font-semibold text-white">Quick Agent Setup</h3>
|
||||
</div>
|
||||
<button onClick={() => setShowWizardDialog(false)} className="text-gray-500 hover:text-white"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="px-6 pt-4 flex gap-2">
|
||||
{['Bot Account', 'Agent Config', 'Personality'].map((label, i) => (
|
||||
<div key={i} className="flex-1">
|
||||
<div className={`h-1 rounded-full transition-colors ${i <= wizardStep ? 'bg-claw-500' : 'bg-dark-600'}`} />
|
||||
<p className={`text-xs mt-1 ${i <= wizardStep ? 'text-claw-400' : 'text-gray-600'}`}>{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4 min-h-[220px]">
|
||||
{wizardStep === 0 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-300">Select the Telegram bot account to link with a new agent.</p>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Bot Account</label>
|
||||
<select
|
||||
value={wizardForm.botAccountId}
|
||||
onChange={e => {
|
||||
const val = e.target.value;
|
||||
setWizardForm({ ...wizardForm, botAccountId: val, agentId: val });
|
||||
}}
|
||||
className="input-base"
|
||||
>
|
||||
<option value="">Select a bot...</option>
|
||||
{telegramAccounts.map(acct => (
|
||||
<option key={acct.id} value={acct.id}>{acct.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-300">Configure the agent that will handle messages for <span className="text-claw-400 font-medium">{wizardForm.botAccountId}</span>.</p>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Agent ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={wizardForm.agentId}
|
||||
onChange={e => setWizardForm({ ...wizardForm, agentId: e.target.value })}
|
||||
className="input-base"
|
||||
placeholder="e.g. coder"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Will be saved to <code className="text-gray-400">agents/{wizardForm.agentId || '...'}/</code></p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Model Override (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={wizardForm.model}
|
||||
onChange={e => setWizardForm({ ...wizardForm, model: e.target.value })}
|
||||
className="input-base"
|
||||
placeholder="Leave empty for default model"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-300">Define the personality for <span className="text-claw-400 font-medium">{wizardForm.agentId}</span>.</p>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1 flex items-center gap-2">
|
||||
<FileText size={14} />
|
||||
System Prompt
|
||||
</label>
|
||||
<textarea
|
||||
value={wizardForm.systemPrompt}
|
||||
onChange={e => setWizardForm({ ...wizardForm, systemPrompt: e.target.value })}
|
||||
className="input-base font-mono text-sm"
|
||||
rows={6}
|
||||
placeholder="You are a coding expert. You help users write clean, efficient code..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-dark-600 flex justify-between">
|
||||
<button
|
||||
onClick={() => wizardStep === 0 ? setShowWizardDialog(false) : setWizardStep(wizardStep - 1)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{wizardStep === 0 ? 'Cancel' : 'Back'}
|
||||
</button>
|
||||
{wizardStep < 2 ? (
|
||||
<button
|
||||
onClick={() => setWizardStep(wizardStep + 1)}
|
||||
disabled={wizardStep === 0 ? !wizardForm.botAccountId : !wizardForm.agentId}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleWizardSubmit}
|
||||
disabled={saving || !wizardForm.agentId}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{saving ? <Loader2 className="animate-spin" size={16} /> : <Zap size={16} />}
|
||||
Create Agent & Binding
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{error && (
|
||||
<div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-bottom-2">
|
||||
<div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-bottom-2 z-50">
|
||||
<AlertCircle size={18} />
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 hover:bg-white/20 p-1 rounded"><X size={14} /></button>
|
||||
|
||||
+603
-202
@@ -24,6 +24,8 @@ import {
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
Plus,
|
||||
Bot,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@@ -232,8 +234,33 @@ export function Channels() {
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
|
||||
// Per-group settings type
|
||||
interface GroupSettings {
|
||||
requireMention: boolean;
|
||||
enabled: boolean;
|
||||
groupPolicy: string;
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
// Telegram multi-account state
|
||||
interface TelegramAccountInfo {
|
||||
id: string;
|
||||
bot_token: string;
|
||||
group_policy?: string;
|
||||
dm_policy?: string;
|
||||
stream_mode?: string;
|
||||
exclusive_topics?: string[];
|
||||
groups?: Record<string, unknown>;
|
||||
}
|
||||
const [telegramAccounts, setTelegramAccounts] = useState<TelegramAccountInfo[]>([]);
|
||||
const [showAddAccountDialog, setShowAddAccountDialog] = useState(false);
|
||||
const [newAccountId, setNewAccountId] = useState('');
|
||||
const [newAccountToken, setNewAccountToken] = useState('');
|
||||
const [expandedAccount, setExpandedAccount] = useState<string | null>(null);
|
||||
const [savingAccount, setSavingAccount] = useState(false);
|
||||
|
||||
// OpenClaw channel access control state
|
||||
const [allowedGroups, setAllowedGroups] = useState<Record<string, boolean>>({}); // { groupId: requireMention }
|
||||
const [allowedGroups, setAllowedGroups] = useState<Record<string, GroupSettings>>({});
|
||||
const [allowFromUsers, setAllowFromUsers] = useState<string[]>([]); // allowFrom (DM user IDs)
|
||||
const [groupAllowFromUsers, setGroupAllowFromUsers] = useState<string[]>([]); // groupAllowFrom (group sender IDs)
|
||||
const [newGroupInput, setNewGroupInput] = useState('');
|
||||
@@ -450,11 +477,16 @@ export function Channels() {
|
||||
});
|
||||
setConfigForm(form);
|
||||
|
||||
// Load groups (object: { groupId: { requireMention: bool } })
|
||||
const groupsObj = (channel.config.groups as Record<string, { requireMention?: boolean }>) || {};
|
||||
const groupMap: Record<string, boolean> = {};
|
||||
// Load groups (object: { groupId: { requireMention, enabled, groupPolicy, systemPrompt } })
|
||||
const groupsObj = (channel.config.groups as Record<string, Record<string, unknown>>) || {};
|
||||
const groupMap: Record<string, GroupSettings> = {};
|
||||
for (const [gid, settings] of Object.entries(groupsObj)) {
|
||||
groupMap[gid] = settings?.requireMention !== false; // default true
|
||||
groupMap[gid] = {
|
||||
requireMention: settings?.requireMention !== false,
|
||||
enabled: settings?.enabled !== false,
|
||||
groupPolicy: (settings?.groupPolicy as string) || 'open',
|
||||
systemPrompt: (settings?.systemPrompt as string) || '',
|
||||
};
|
||||
}
|
||||
setAllowedGroups(groupMap);
|
||||
|
||||
@@ -470,11 +502,46 @@ export function Channels() {
|
||||
if (channel.channel_type === 'feishu') {
|
||||
checkFeishuPlugin();
|
||||
}
|
||||
|
||||
// If Telegram, fetch accounts
|
||||
if (channel.channel_type === 'telegram') {
|
||||
fetchTelegramAccounts();
|
||||
}
|
||||
} else {
|
||||
setConfigForm({});
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTelegramAccounts = async () => {
|
||||
try {
|
||||
const accounts: TelegramAccountInfo[] = await invoke('get_telegram_accounts');
|
||||
setTelegramAccounts(accounts);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch telegram accounts:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAccount = async (account: TelegramAccountInfo) => {
|
||||
setSavingAccount(true);
|
||||
try {
|
||||
await invoke('save_telegram_account', { account });
|
||||
await fetchTelegramAccounts();
|
||||
} catch (e) {
|
||||
console.error('Failed to save telegram account:', e);
|
||||
} finally {
|
||||
setSavingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async (accountId: string) => {
|
||||
try {
|
||||
await invoke('delete_telegram_account', { accountId });
|
||||
await fetchTelegramAccounts();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete telegram account:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedChannel) return;
|
||||
|
||||
@@ -495,11 +562,21 @@ export function Channels() {
|
||||
}
|
||||
});
|
||||
|
||||
// Save groups as object: { "groupId": { requireMention: bool } }
|
||||
// Save groups as object with all per-group settings
|
||||
if (Object.keys(allowedGroups).length > 0) {
|
||||
const groupsObj: Record<string, { requireMention: boolean }> = {};
|
||||
for (const [gid, reqMention] of Object.entries(allowedGroups)) {
|
||||
groupsObj[gid] = { requireMention: reqMention };
|
||||
const groupsObj: Record<string, Record<string, unknown>> = {};
|
||||
for (const [gid, settings] of Object.entries(allowedGroups)) {
|
||||
const entry: Record<string, unknown> = {
|
||||
requireMention: settings.requireMention,
|
||||
enabled: settings.enabled,
|
||||
};
|
||||
if (settings.groupPolicy && settings.groupPolicy !== 'open') {
|
||||
entry.groupPolicy = settings.groupPolicy;
|
||||
}
|
||||
if (settings.systemPrompt) {
|
||||
entry.systemPrompt = settings.systemPrompt;
|
||||
}
|
||||
groupsObj[gid] = entry;
|
||||
}
|
||||
config['groups'] = groupsObj;
|
||||
}
|
||||
@@ -712,167 +789,279 @@ export function Channels() {
|
||||
</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>
|
||||
{/* Multi-account mode banner */}
|
||||
{currentChannel.channel_type === 'telegram' && telegramAccounts.length > 0 && (
|
||||
<div className="p-3 bg-blue-500/10 rounded-xl border border-blue-500/30 flex items-start gap-2 mb-4">
|
||||
<Bot size={16} className="text-blue-400 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-gray-300">
|
||||
<strong className="text-blue-400">Multi-bot mode active.</strong> Bot Token, DM Policy, Group Policy, and Stream Mode are now configured per-account below.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === 'select' ? (
|
||||
<select
|
||||
value={configForm[field.key] || ''}
|
||||
onChange={(e) =>
|
||||
setConfigForm({ ...configForm, [field.key]: e.target.value })
|
||||
}
|
||||
className="input-base"
|
||||
>
|
||||
<option value="">Please select...</option>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === 'password' ? (
|
||||
<div className="relative">
|
||||
<div className="space-y-4">
|
||||
{currentInfo.fields
|
||||
.filter(field => {
|
||||
// In multi-account mode, hide per-account fields (they're in Bot Accounts section)
|
||||
if (currentChannel.channel_type === 'telegram' && telegramAccounts.length > 0) {
|
||||
const accountFields = ['botToken', 'dmPolicy', 'groupPolicy', 'streamMode'];
|
||||
return !accountFields.includes(field.key);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.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="">Please select...</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) ? 'Hide' : 'Show'}
|
||||
>
|
||||
{visiblePasswords.has(field.key) ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type={visiblePasswords.has(field.key) ? 'text' : 'password'}
|
||||
type={field.type}
|
||||
value={configForm[field.key] || ''}
|
||||
onChange={(e) =>
|
||||
setConfigForm({ ...configForm, [field.key]: e.target.value })
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
className="input-base pr-10"
|
||||
className="input-base"
|
||||
/>
|
||||
<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) ? 'Hide' : 'Show'}
|
||||
>
|
||||
{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"
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Groups UI: shown when groupPolicy is 'allowlist' */}
|
||||
{field.key === 'groupPolicy' && configForm[field.key] === 'allowlist' && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* Allowed Groups */}
|
||||
<div className="p-4 bg-dark-600 rounded-xl border border-dark-500">
|
||||
<label className="block text-sm text-gray-400 mb-2">Allowed Groups (Chat ID)</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupInput}
|
||||
onChange={(e) => setNewGroupInput(e.target.value)}
|
||||
placeholder="e.g. -1001234567890"
|
||||
className="input-base text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
{/* Groups UI: shown when groupPolicy is 'allowlist' */}
|
||||
{field.key === 'groupPolicy' && configForm[field.key] === 'allowlist' && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* Allowed Groups */}
|
||||
<div className="p-4 bg-dark-600 rounded-xl border border-dark-500">
|
||||
<label className="block text-sm text-gray-400 mb-2">Allowed Groups (Chat ID)</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupInput}
|
||||
onChange={(e) => setNewGroupInput(e.target.value)}
|
||||
placeholder="e.g. -1001234567890"
|
||||
className="input-base text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (newGroupInput && !(newGroupInput in allowedGroups)) {
|
||||
setAllowedGroups({ ...allowedGroups, [newGroupInput]: { requireMention: false, enabled: true, groupPolicy: 'open', systemPrompt: '' } });
|
||||
setNewGroupInput('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newGroupInput && !(newGroupInput in allowedGroups)) {
|
||||
setAllowedGroups({ ...allowedGroups, [newGroupInput]: false });
|
||||
setAllowedGroups({ ...allowedGroups, [newGroupInput]: { requireMention: false, enabled: true, groupPolicy: 'open', systemPrompt: '' } });
|
||||
setNewGroupInput('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newGroupInput && !(newGroupInput in allowedGroups)) {
|
||||
setAllowedGroups({ ...allowedGroups, [newGroupInput]: false });
|
||||
setNewGroupInput('');
|
||||
}
|
||||
}}
|
||||
className="btn-secondary p-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
}}
|
||||
className="btn-secondary p-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{Object.entries(allowedGroups).map(([id, settings]) => (
|
||||
<div key={id} className="bg-dark-500 rounded-lg border border-dark-400 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<span className="font-mono text-sm text-gray-300">{id}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setAllowedGroups({ ...allowedGroups, [id]: { ...settings, enabled: !settings.enabled } })}
|
||||
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${settings.enabled
|
||||
? 'border-green-500/50 bg-green-500/10 text-green-400'
|
||||
: 'border-red-500/50 bg-red-500/10 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{settings.enabled ? 'enabled' : 'disabled'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAllowedGroups({ ...allowedGroups, [id]: { ...settings, requireMention: !settings.requireMention } })}
|
||||
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${settings.requireMention
|
||||
? 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400'
|
||||
: 'border-green-500/50 bg-green-500/10 text-green-400'
|
||||
}`}
|
||||
title={settings.requireMention ? 'Bot only responds when @mentioned' : 'Bot responds to all messages'}
|
||||
>
|
||||
{settings.requireMention ? '@mention' : 'all msgs'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = { ...allowedGroups };
|
||||
delete next[id];
|
||||
setAllowedGroups(next);
|
||||
}}
|
||||
className="text-gray-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Group Policy</label>
|
||||
<select
|
||||
value={settings.groupPolicy}
|
||||
onChange={(e) => setAllowedGroups({ ...allowedGroups, [id]: { ...settings, groupPolicy: e.target.value } })}
|
||||
className="input-base text-xs py-1"
|
||||
>
|
||||
<option value="open">open — anyone can talk</option>
|
||||
<option value="allowlist">allowlist — only allowed users</option>
|
||||
<option value="disabled">disabled — no one</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">System Prompt (per-group)</label>
|
||||
<textarea
|
||||
value={settings.systemPrompt}
|
||||
onChange={(e) => setAllowedGroups({ ...allowedGroups, [id]: { ...settings, systemPrompt: e.target.value } })}
|
||||
placeholder="e.g. You are an investment analyst. Your research is in investments/."
|
||||
className="input-base text-xs min-h-[60px] resize-y"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(allowedGroups).length === 0 && (
|
||||
<div className="text-xs text-gray-500 text-center py-2 italic">
|
||||
No groups added. Bot will ignore all groups.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Each group gets its own settings. Use system prompts to give each group a unique personality/domain.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{Object.entries(allowedGroups).map(([id, requireMention]) => (
|
||||
<div key={id} className="flex items-center justify-between text-sm bg-dark-500 px-3 py-1.5 rounded-lg border border-dark-400">
|
||||
<span className="font-mono text-gray-300">{id}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{/* Group Allowed Senders (groupAllowFrom) */}
|
||||
<div className="p-4 bg-dark-600 rounded-xl border border-dark-500">
|
||||
<label className="block text-sm text-gray-400 mb-2">Allowed Senders in Groups (User ID)</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupAllowFromInput}
|
||||
onChange={(e) => setNewGroupAllowFromInput(e.target.value)}
|
||||
placeholder="e.g. 123456789"
|
||||
className="input-base text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (newGroupAllowFromInput && !groupAllowFromUsers.includes(newGroupAllowFromInput)) {
|
||||
setGroupAllowFromUsers([...groupAllowFromUsers, newGroupAllowFromInput]);
|
||||
setNewGroupAllowFromInput('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newGroupAllowFromInput && !groupAllowFromUsers.includes(newGroupAllowFromInput)) {
|
||||
setGroupAllowFromUsers([...groupAllowFromUsers, newGroupAllowFromInput]);
|
||||
setNewGroupAllowFromInput('');
|
||||
}
|
||||
}}
|
||||
className="btn-secondary p-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{groupAllowFromUsers.map(id => (
|
||||
<div key={id} className="flex items-center justify-between text-sm bg-dark-500 px-3 py-1.5 rounded-lg border border-dark-400">
|
||||
<span className="font-mono text-gray-300">{id}</span>
|
||||
<button
|
||||
onClick={() => setAllowedGroups({ ...allowedGroups, [id]: !requireMention })}
|
||||
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${requireMention
|
||||
? 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400'
|
||||
: 'border-green-500/50 bg-green-500/10 text-green-400'
|
||||
}`}
|
||||
title={requireMention ? 'Bot only responds when @mentioned' : 'Bot responds to all messages'}
|
||||
>
|
||||
{requireMention ? '@mention required' : 'responds to all'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = { ...allowedGroups };
|
||||
delete next[id];
|
||||
setAllowedGroups(next);
|
||||
}}
|
||||
onClick={() => setGroupAllowFromUsers(groupAllowFromUsers.filter(u => u !== id))}
|
||||
className="text-gray-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(allowedGroups).length === 0 && (
|
||||
<div className="text-xs text-gray-500 text-center py-2 italic">
|
||||
No groups added. Bot will ignore all groups.
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
{groupAllowFromUsers.length === 0 && (
|
||||
<div className="text-xs text-gray-500 text-center py-2 italic">
|
||||
No senders restricted. All group members can interact.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Saved as <code className="px-1 py-0.5 bg-dark-500 rounded">channels.telegram.groupAllowFrom</code>. Numeric Telegram user IDs.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
New groups default to <code className="px-1 py-0.5 bg-dark-500 rounded">responds to all</code>. Click the badge to toggle <code className="px-1 py-0.5 bg-dark-500 rounded">requireMention</code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group Allowed Senders (groupAllowFrom) */}
|
||||
<div className="p-4 bg-dark-600 rounded-xl border border-dark-500">
|
||||
<label className="block text-sm text-gray-400 mb-2">Allowed Senders in Groups (User ID)</label>
|
||||
{/* DM allowFrom: shown when dmPolicy is 'pairing' or 'allowlist' */}
|
||||
{field.key === 'dmPolicy' && (configForm[field.key] === 'pairing' || configForm[field.key] === 'allowlist') && (
|
||||
<div className="mt-3 p-4 bg-dark-600 rounded-xl border border-dark-500">
|
||||
<label className="block text-sm text-gray-400 mb-2">Allowed DM Users (User ID)</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupAllowFromInput}
|
||||
onChange={(e) => setNewGroupAllowFromInput(e.target.value)}
|
||||
value={newAllowFromInput}
|
||||
onChange={(e) => setNewAllowFromInput(e.target.value)}
|
||||
placeholder="e.g. 123456789"
|
||||
className="input-base text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (newGroupAllowFromInput && !groupAllowFromUsers.includes(newGroupAllowFromInput)) {
|
||||
setGroupAllowFromUsers([...groupAllowFromUsers, newGroupAllowFromInput]);
|
||||
setNewGroupAllowFromInput('');
|
||||
if (newAllowFromInput && !allowFromUsers.includes(newAllowFromInput)) {
|
||||
setAllowFromUsers([...allowFromUsers, newAllowFromInput]);
|
||||
setNewAllowFromInput('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newGroupAllowFromInput && !groupAllowFromUsers.includes(newGroupAllowFromInput)) {
|
||||
setGroupAllowFromUsers([...groupAllowFromUsers, newGroupAllowFromInput]);
|
||||
setNewGroupAllowFromInput('');
|
||||
if (newAllowFromInput && !allowFromUsers.includes(newAllowFromInput)) {
|
||||
setAllowFromUsers([...allowFromUsers, newAllowFromInput]);
|
||||
setNewAllowFromInput('');
|
||||
}
|
||||
}}
|
||||
className="btn-secondary p-2"
|
||||
@@ -881,90 +1070,32 @@ export function Channels() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{groupAllowFromUsers.map(id => (
|
||||
{allowFromUsers.map(id => (
|
||||
<div key={id} className="flex items-center justify-between text-sm bg-dark-500 px-3 py-1.5 rounded-lg border border-dark-400">
|
||||
<span className="font-mono text-gray-300">{id}</span>
|
||||
<button
|
||||
onClick={() => setGroupAllowFromUsers(groupAllowFromUsers.filter(u => u !== id))}
|
||||
onClick={() => setAllowFromUsers(allowFromUsers.filter(u => u !== id))}
|
||||
className="text-gray-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{groupAllowFromUsers.length === 0 && (
|
||||
{allowFromUsers.length === 0 && (
|
||||
<div className="text-xs text-gray-500 text-center py-2 italic">
|
||||
No senders restricted. All group members can interact.
|
||||
{configForm[field.key] === 'pairing'
|
||||
? 'Users will be added automatically via pairing flow.'
|
||||
: 'No users allowed. Add user IDs above.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Saved as <code className="px-1 py-0.5 bg-dark-500 rounded">channels.telegram.groupAllowFrom</code>. Numeric Telegram user IDs.
|
||||
Saved as <code className="px-1 py-0.5 bg-dark-500 rounded">channels.telegram.allowFrom</code>. Numeric Telegram user IDs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DM allowFrom: shown when dmPolicy is 'pairing' or 'allowlist' */}
|
||||
{field.key === 'dmPolicy' && (configForm[field.key] === 'pairing' || configForm[field.key] === 'allowlist') && (
|
||||
<div className="mt-3 p-4 bg-dark-600 rounded-xl border border-dark-500">
|
||||
<label className="block text-sm text-gray-400 mb-2">Allowed DM Users (User ID)</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newAllowFromInput}
|
||||
onChange={(e) => setNewAllowFromInput(e.target.value)}
|
||||
placeholder="e.g. 123456789"
|
||||
className="input-base text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (newAllowFromInput && !allowFromUsers.includes(newAllowFromInput)) {
|
||||
setAllowFromUsers([...allowFromUsers, newAllowFromInput]);
|
||||
setNewAllowFromInput('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (newAllowFromInput && !allowFromUsers.includes(newAllowFromInput)) {
|
||||
setAllowFromUsers([...allowFromUsers, newAllowFromInput]);
|
||||
setNewAllowFromInput('');
|
||||
}
|
||||
}}
|
||||
className="btn-secondary p-2"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{allowFromUsers.map(id => (
|
||||
<div key={id} className="flex items-center justify-between text-sm bg-dark-500 px-3 py-1.5 rounded-lg border border-dark-400">
|
||||
<span className="font-mono text-gray-300">{id}</span>
|
||||
<button
|
||||
onClick={() => setAllowFromUsers(allowFromUsers.filter(u => u !== id))}
|
||||
className="text-gray-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{allowFromUsers.length === 0 && (
|
||||
<div className="text-xs text-gray-500 text-center py-2 italic">
|
||||
{configForm[field.key] === 'pairing'
|
||||
? 'Users will be added automatically via pairing flow.'
|
||||
: 'No users allowed. Add user IDs above.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Saved as <code className="px-1 py-0.5 bg-dark-500 rounded">channels.telegram.allowFrom</code>. Numeric Telegram user IDs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* WhatsApp special handling: QR code login button */}
|
||||
{currentChannel.channel_type === 'whatsapp' && (
|
||||
@@ -1011,6 +1142,276 @@ export function Channels() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Telegram Multi-Bot Accounts */}
|
||||
{currentChannel.channel_type === 'telegram' && (
|
||||
<div className="mt-6 p-4 bg-dark-600 rounded-xl border border-dark-500">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot size={18} className="text-blue-400" />
|
||||
<h4 className="text-sm font-semibold text-white">Bot Accounts</h4>
|
||||
<span className="text-xs text-gray-500">Multi-agent routing</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddAccountDialog(true)}
|
||||
className="btn-secondary text-xs flex items-center gap-1 py-1 px-2"
|
||||
>
|
||||
<Plus size={14} /> Add Bot
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{telegramAccounts.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 text-center py-4 italic">
|
||||
No bot accounts configured. Your existing bot token is used as a single agent.
|
||||
<br />Add multiple bots to route each to a different agent.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{telegramAccounts.map(acct => (
|
||||
<div key={acct.id} className="bg-dark-500 rounded-lg border border-dark-400 overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-dark-400/50 transition-colors"
|
||||
onClick={() => setExpandedAccount(expandedAccount === acct.id ? null : acct.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot size={14} className="text-blue-400" />
|
||||
<span className="font-mono text-sm text-gray-200">{acct.id}</span>
|
||||
<span className="text-xs text-gray-500 font-mono">{'•••' + acct.bot_token.slice(-6)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings size={14} className={expandedAccount === acct.id ? 'text-claw-400' : 'text-gray-500'} />
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteAccount(acct.id); }}
|
||||
className="text-gray-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedAccount === acct.id && (
|
||||
<div className="px-3 pb-3 space-y-3 border-t border-dark-400 pt-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Bot Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={acct.bot_token}
|
||||
onChange={(e) => {
|
||||
const updated = telegramAccounts.map(a => a.id === acct.id ? { ...a, bot_token: e.target.value } : a);
|
||||
setTelegramAccounts(updated);
|
||||
}}
|
||||
className="input-base text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Group Policy</label>
|
||||
<select
|
||||
value={acct.group_policy || 'open'}
|
||||
onChange={(e) => {
|
||||
const updated = telegramAccounts.map(a => a.id === acct.id ? { ...a, group_policy: e.target.value } : a);
|
||||
setTelegramAccounts(updated);
|
||||
}}
|
||||
className="input-base text-xs py-1"
|
||||
>
|
||||
<option value="open">open</option>
|
||||
<option value="allowlist">allowlist</option>
|
||||
<option value="disabled">disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">DM Policy</label>
|
||||
<select
|
||||
value={acct.dm_policy || 'pairing'}
|
||||
onChange={(e) => {
|
||||
const updated = telegramAccounts.map(a => a.id === acct.id ? { ...a, dm_policy: e.target.value } : a);
|
||||
setTelegramAccounts(updated);
|
||||
}}
|
||||
className="input-base text-xs py-1"
|
||||
>
|
||||
<option value="pairing">pairing</option>
|
||||
<option value="open">open</option>
|
||||
<option value="disabled">disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Exclusive Topics (Allowlist)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={acct.exclusive_topics?.join(', ') || ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const topics = val ? val.split(',').map(s => s.trim()).filter(Boolean) : undefined;
|
||||
const updated = telegramAccounts.map(a => a.id === acct.id ? { ...a, exclusive_topics: topics } : a);
|
||||
setTelegramAccounts(updated);
|
||||
}}
|
||||
placeholder="Comma-separated Topic IDs (e.g. 42, 45). Leave empty to allow all."
|
||||
className="input-base text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-gray-500 mt-1">If set, bot will <strong>only</strong> respond in these topics and ignore all others.</p>
|
||||
</div>
|
||||
|
||||
{/* Groups Management (shown when allowlist) */}
|
||||
{acct.group_policy === 'allowlist' && (() => {
|
||||
const groups = (acct.groups || {}) as Record<string, { enabled?: boolean; requireMention?: boolean; topics?: Record<string, { requireMention?: boolean }> }>;
|
||||
const updateGroups = (newGroups: typeof groups) => {
|
||||
const updated = telegramAccounts.map(a => a.id === acct.id ? { ...a, groups: newGroups } : a);
|
||||
setTelegramAccounts(updated);
|
||||
};
|
||||
return (
|
||||
<div className="p-3 bg-dark-600 rounded-lg border border-dark-500 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400 font-semibold">Allowed Groups</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Group Chat ID, e.g. -1003726801732"
|
||||
className="input-base text-xs flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const val = (e.target as HTMLInputElement).value.trim();
|
||||
if (val && !(val in groups)) {
|
||||
updateGroups({ ...groups, [val]: { enabled: true, requireMention: true } });
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}
|
||||
}}
|
||||
id={`add-group-${acct.id}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const input = document.getElementById(`add-group-${acct.id}`) as HTMLInputElement;
|
||||
const val = input?.value.trim();
|
||||
if (val && !(val in groups)) {
|
||||
updateGroups({ ...groups, [val]: { enabled: true, requireMention: true } });
|
||||
input.value = '';
|
||||
}
|
||||
}}
|
||||
className="btn-secondary p-1.5"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{Object.entries(groups).map(([gid, gsettings]) => (
|
||||
<div key={gid} className="bg-dark-700 rounded-lg border border-dark-500 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className="font-mono text-xs text-gray-300">{gid}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => updateGroups({ ...groups, [gid]: { ...gsettings, enabled: !gsettings.enabled } })}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full border ${gsettings.enabled !== false
|
||||
? 'border-green-500/50 bg-green-500/10 text-green-400'
|
||||
: 'border-red-500/50 bg-red-500/10 text-red-400'}`}
|
||||
>
|
||||
{gsettings.enabled !== false ? 'on' : 'off'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateGroups({ ...groups, [gid]: { ...gsettings, requireMention: !gsettings.requireMention } })}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full border ${gsettings.requireMention
|
||||
? 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400'
|
||||
: 'border-green-500/50 bg-green-500/10 text-green-400'}`}
|
||||
title={gsettings.requireMention ? 'Only responds when @mentioned' : 'Responds to all messages'}
|
||||
>
|
||||
{gsettings.requireMention ? '@mention' : 'all msgs'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = { ...groups };
|
||||
delete next[gid];
|
||||
updateGroups(next);
|
||||
}}
|
||||
className="text-gray-500 hover:text-red-400"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* (Topic configuration moved to Exclusive Topics above) */}
|
||||
<div className="px-2 pb-1">
|
||||
<p className="text-[10px] text-gray-600 italic">
|
||||
Use <strong>Exclusive Topics</strong> above to restrict this bot to specific topics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Object.keys(groups).length === 0 && (
|
||||
<p className="text-[10px] text-gray-500 italic text-center py-1">No groups added. Bot will ignore all groups.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<button
|
||||
onClick={() => handleSaveAccount(acct)}
|
||||
disabled={savingAccount}
|
||||
className="btn-primary text-xs py-1 px-3 flex items-center gap-1"
|
||||
>
|
||||
{savingAccount ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}
|
||||
Save Account
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Each bot account can be bound to a different agent via <strong>Agents → Routing Rules</strong> using Account ID.
|
||||
</p>
|
||||
|
||||
{/* Add Account Dialog */}
|
||||
{showAddAccountDialog && (
|
||||
<div className="mt-3 p-3 bg-dark-700 rounded-lg border border-claw-500/30">
|
||||
<h5 className="text-sm font-medium text-white mb-2">Add Bot Account</h5>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newAccountId}
|
||||
onChange={e => setNewAccountId(e.target.value)}
|
||||
placeholder="Account ID (e.g. researchbot)"
|
||||
className="input-base text-sm"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newAccountToken}
|
||||
onChange={e => setNewAccountToken(e.target.value)}
|
||||
placeholder="Bot Token from @BotFather"
|
||||
className="input-base text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (newAccountId && newAccountToken) {
|
||||
await handleSaveAccount({ id: newAccountId, bot_token: newAccountToken });
|
||||
setNewAccountId('');
|
||||
setNewAccountToken('');
|
||||
setShowAddAccountDialog(false);
|
||||
}
|
||||
}}
|
||||
disabled={!newAccountId || !newAccountToken || savingAccount}
|
||||
className="btn-primary text-xs py-1.5 px-3"
|
||||
>
|
||||
{savingAccount ? 'Saving...' : 'Add'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowAddAccountDialog(false); setNewAccountId(''); setNewAccountToken(''); }}
|
||||
className="btn-secondary text-xs py-1.5 px-3"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="pt-4 border-t border-dark-500 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user