myagent-ai 1.20.1 → 1.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,10 +55,12 @@ class ChatBotManager:
55
55
  try:
56
56
  bot = self._create_bot(cfg, message_handler)
57
57
  if bot:
58
- self._bots[cfg.platform] = bot
59
- logger.info(f"聊天平台已配置: {cfg.platform}")
58
+ # 使用唯一 id 作为 key,支持多实例
59
+ key = cfg.id or cfg.platform
60
+ self._bots[key] = bot
61
+ logger.info(f"聊天平台已配置: {cfg.display_name or key}")
60
62
  except Exception as e:
61
- logger.error(f"平台 {cfg.platform} 初始化失败: {e}")
63
+ logger.error(f"平台 {cfg.display_name or cfg.platform} 初始化失败: {e}")
62
64
 
63
65
  def _create_bot(
64
66
  self,
@@ -115,6 +117,17 @@ class ChatBotManager:
115
117
  **config.extra,
116
118
  )
117
119
 
120
+ elif platform == "whatsapp":
121
+ from chatbot.whatsapp_bot import WhatsAppBot
122
+ return WhatsAppBot(
123
+ token=config.token,
124
+ app_id=config.app_id,
125
+ app_secret=config.app_secret,
126
+ allowed_users=config.allowed_users,
127
+ message_handler=message_handler,
128
+ **config.extra,
129
+ )
130
+
118
131
  else:
119
132
  logger.warning(f"不支持的平台: {platform}")
120
133
  return None
@@ -0,0 +1,179 @@
1
+ """
2
+ chatbot/whatsapp_bot.py - WhatsApp 机器人
3
+ ===========================================
4
+ 使用 whatsapp-web.js 或 Baileys 库接入 WhatsApp。
5
+ 支持二维码绑定。
6
+ 纯 Python 实现,异步运行。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import time
13
+ from typing import Optional, List
14
+
15
+ from chatbot.base import BaseChatBot, ChatMessage, ChatResponse
16
+
17
+ try:
18
+ import aiohttp
19
+ HAS_AIOHTTP = True
20
+ except ImportError:
21
+ HAS_AIOHTTP = False
22
+
23
+
24
+ class WhatsAppBot(BaseChatBot):
25
+ """
26
+ WhatsApp 机器人适配器。
27
+
28
+ 配置要求:
29
+ - token: Access Token (从 Meta Developer Portal 获取)
30
+ - app_id: App ID
31
+ - app_secret: App Secret
32
+ - webhook_url: Webhook URL (可选,用于接收消息)
33
+ - phone_number_id: Phone Number ID (从 Meta 获取)
34
+ - verify_token: Webhook 验证 Token (自动生成)
35
+
36
+ 支持两种模式:
37
+ 1. Cloud API 模式: 使用 Meta WhatsApp Business API
38
+ 2. 二维码绑定模式: 通过 QR Code 绑定(需要 Baileys 库)
39
+ """
40
+
41
+ platform_name = "whatsapp"
42
+
43
+ def __init__(self, **kwargs):
44
+ super().__init__(**kwargs)
45
+ self._app_id = kwargs.get("app_id", "")
46
+ self._app_secret = kwargs.get("app_secret", "")
47
+ self._phone_number_id = self.config.get("phone_number_id", "")
48
+ self._verify_token = self.config.get("verify_token", "")
49
+ self._webhook_base = self.config.get("webhook_base", "")
50
+ self._qrcode_callback = self.config.get("qrcode_callback", None)
51
+ self._qr_code = ""
52
+ self._connected = False
53
+
54
+ async def start(self):
55
+ """启动 WhatsApp 机器人"""
56
+ if not self.token:
57
+ self.logger.error("WhatsApp Access Token 未配置")
58
+ return
59
+
60
+ # Meta Cloud API 模式
61
+ if self._phone_number_id:
62
+ self.logger.info("WhatsApp Cloud API 模式启动")
63
+ self._connected = True
64
+ # Cloud API 模式通过 Webhook 接收消息,不需要主动轮询
65
+ # 保持运行
66
+ while self._running:
67
+ await asyncio.sleep(1)
68
+ else:
69
+ self.logger.warning(
70
+ "WhatsApp 需要配置 phone_number_id 才能使用 Cloud API 模式。"
71
+ "请访问 Meta Developer Portal 获取 WhatsApp Business API 凭据。"
72
+ )
73
+ self._connected = False
74
+
75
+ async def stop(self):
76
+ """停止 WhatsApp 机器人"""
77
+ self._running = False
78
+ self._connected = False
79
+ self.logger.info("WhatsApp 机器人已停止")
80
+
81
+ async def send_message(self, response: ChatResponse) -> bool:
82
+ """发送消息到 WhatsApp"""
83
+ if not self._connected or not response.chat_id:
84
+ return False
85
+
86
+ try:
87
+ if not HAS_AIOHTTP:
88
+ self.logger.error("请安装 aiohttp: pip install aiohttp")
89
+ return False
90
+
91
+ url = f"https://graph.facebook.com/v18.0/{self._phone_number_id}/messages"
92
+ headers = {
93
+ "Authorization": f"Bearer {self.token}",
94
+ "Content-Type": "application/json",
95
+ }
96
+ payload = {
97
+ "messaging_product": "whatsapp",
98
+ "to": response.chat_id,
99
+ "type": "text",
100
+ "text": {"body": response.text[:4096]},
101
+ }
102
+
103
+ async with aiohttp.ClientSession() as session:
104
+ async with session.post(url, headers=headers, json=payload) as resp:
105
+ if resp.status == 200:
106
+ return True
107
+ error_text = await resp.text()
108
+ self.logger.error(f"WhatsApp 发送失败 ({resp.status}): {error_text}")
109
+ return False
110
+ except Exception as e:
111
+ self.logger.error(f"发送消息失败: {e}")
112
+ return False
113
+
114
+ # ==========================================================================
115
+ # Webhook 处理
116
+ # ==========================================================================
117
+
118
+ def verify_webhook(self, mode: str, token: str, challenge: str) -> Optional[str]:
119
+ """验证 Webhook"""
120
+ if mode == "subscribe" and token == self._verify_token:
121
+ return challenge
122
+ return None
123
+
124
+ async def handle_webhook_event(self, event_data: dict):
125
+ """处理 Webhook 事件"""
126
+ try:
127
+ for entry in event_data.get("entry", []):
128
+ for change in entry.get("changes", []):
129
+ value = change.get("value", {})
130
+ messages = value.get("messages", [])
131
+ contacts = value.get("contacts", [])
132
+
133
+ # 构建联系人映射
134
+ contact_map = {}
135
+ for c in contacts:
136
+ wa_id = c.get("wa_id", "")
137
+ name = c.get("profile", {}).get("name", "")
138
+ contact_map[wa_id] = name
139
+
140
+ for msg in messages:
141
+ msg_type = msg.get("type", "")
142
+ if msg_type != "text":
143
+ continue
144
+
145
+ from_id = msg.get("from", "")
146
+ text_body = msg.get("text", {}).get("body", "")
147
+ msg_id = msg.get("id", "")
148
+ timestamp = msg.get("timestamp", "")
149
+
150
+ username = contact_map.get(from_id, from_id)
151
+
152
+ message = ChatMessage(
153
+ platform=self.platform_name,
154
+ chat_id=from_id,
155
+ user_id=from_id,
156
+ username=username,
157
+ text=text_body,
158
+ is_group=False,
159
+ reply_to=msg_id,
160
+ raw_data={"timestamp": timestamp, "msg_id": msg_id},
161
+ )
162
+ await self._handle_message(message)
163
+ except Exception as e:
164
+ self.logger.error(f"处理 Webhook 事件失败: {e}")
165
+
166
+ # ==========================================================================
167
+ # 二维码绑定
168
+ # ==========================================================================
169
+
170
+ def get_qr_code(self) -> str:
171
+ """获取当前 QR Code(用于绑定模式)"""
172
+ return self._qr_code
173
+
174
+ async def generate_qr_code(self) -> str:
175
+ """生成绑定二维码"""
176
+ # Cloud API 模式不需要 QR Code
177
+ # 如需 Baileys 模式,需额外安装 node.js 依赖
178
+ self.logger.info("WhatsApp Cloud API 模式不需要 QR Code 绑定,使用 Webhook 接收消息")
179
+ return ""
package/config.py CHANGED
@@ -105,15 +105,19 @@ class ModelEntry:
105
105
 
106
106
  @dataclass
107
107
  class ChatPlatformConfig:
108
- """单个聊天平台配置"""
108
+ """单个聊天平台配置(支持多实例,每个实例有唯一 id)"""
109
+ id: str = "" # 唯一标识符,如 "telegram_8616478723" 或自动生成
109
110
  enabled: bool = False
110
- platform: str = "" # telegram | discord | feishu | qq | wechat
111
+ platform: str = "" # telegram | discord | feishu | qq | wechat | whatsapp
111
112
  token: str = "" # Bot Token
112
113
  app_id: str = "" # App ID (某些平台需要)
113
114
  app_secret: str = "" # App Secret
114
115
  webhook_url: str = "" # Webhook URL
115
116
  allowed_users: List[str] = field(default_factory=list) # 允许的用户白名单(空=全部)
116
117
  extra: Dict[str, Any] = field(default_factory=dict) # 平台特有配置
118
+ bind_agent: str = "" # 绑定的 Agent path(空=不绑定/使用默认)
119
+ bind_agents: List[str] = field(default_factory=list) # 绑定的多个 Agent path(v1.20.2: 支持多 agent 绑定)
120
+ display_name: str = "" # 显示名称,如 "Telegram:8616478723"
117
121
 
118
122
 
119
123
  @dataclass
@@ -263,6 +267,7 @@ class ConfigManager:
263
267
  "MYAGENT_FEISHU_APP_SECRET": "feishu",
264
268
  "MYAGENT_QQ_TOKEN": "qq",
265
269
  "MYAGENT_WECHAT_TOKEN": "wechat",
270
+ "MYAGENT_WHATSAPP_TOKEN": "whatsapp",
266
271
  }
267
272
  for env_key, platform_name in platform_env.items():
268
273
  token = os.environ.get(env_key)
@@ -277,10 +282,39 @@ class ConfigManager:
277
282
  cp.enabled = True
278
283
  return
279
284
  cp = ChatPlatformConfig(platform=platform, token=token, enabled=True)
285
+ self._auto_platform_id(cp)
280
286
  self._config.chat_platforms.append(cp)
281
287
 
288
+ def _auto_platform_id(self, cp: ChatPlatformConfig):
289
+ """为平台配置自动生成唯一 ID 和显示名称"""
290
+ import re
291
+ if not cp.id:
292
+ if cp.platform == 'telegram' and cp.token:
293
+ # 从 token 提取 bot_id: 8616478723:AAFKir8DQ4nzYUiddhifLCKUc5K2MfHcv9U
294
+ match = re.match(r'^(\d+):', cp.token)
295
+ bot_id = match.group(1) if match else str(len(self._config.chat_platforms))
296
+ cp.id = f"telegram_{bot_id}"
297
+ cp.display_name = f"Telegram:{bot_id}"
298
+ elif cp.platform == 'whatsapp' and cp.token:
299
+ match = re.match(r'^(\d+)', cp.token)
300
+ phone_id = match.group(1) if match else str(len(self._config.chat_platforms))
301
+ cp.id = f"whatsapp_{phone_id}"
302
+ cp.display_name = f"WhatsApp:{phone_id}"
303
+ elif cp.token:
304
+ cp.id = f"{cp.platform}_{cp.token[:8]}"
305
+ cp.display_name = f"{cp.platform.capitalize()}:{cp.token[:8]}"
306
+ else:
307
+ idx = sum(1 for p in self._config.chat_platforms if p.platform == cp.platform)
308
+ cp.id = f"{cp.platform}_{idx}"
309
+ cp.display_name = f"{cp.platform.capitalize()} {idx + 1}"
310
+ if not cp.display_name:
311
+ cp.display_name = cp.id or cp.platform
312
+
282
313
  def _apply_defaults(self):
283
314
  """应用平台相关的默认值"""
315
+ # [v1.20.2] 为旧配置自动填充平台 id 和 display_name
316
+ for cp in self._config.chat_platforms:
317
+ self._auto_platform_id(cp)
284
318
  if not self._config.memory.db_path:
285
319
  self._config.memory.db_path = str(self.data_dir / "memory.db")
286
320
  # data_dir 由 property 自动计算为 _data_dir / "data",无需在此设置
@@ -341,12 +375,19 @@ class ConfigManager:
341
375
  setattr(target, key, value)
342
376
 
343
377
  def get_chat_platform(self, platform: str) -> Optional[ChatPlatformConfig]:
344
- """获取指定聊天平台配置"""
378
+ """获取指定聊天平台配置(兼容旧接口,返回第一个匹配的)"""
345
379
  for cp in self._config.chat_platforms:
346
380
  if cp.platform == platform:
347
381
  return cp
348
382
  return None
349
383
 
384
+ def get_chat_platform_by_id(self, platform_id: str) -> Optional[ChatPlatformConfig]:
385
+ """通过唯一 ID 获取聊天平台配置"""
386
+ for cp in self._config.chat_platforms:
387
+ if cp.id == platform_id:
388
+ return cp
389
+ return None
390
+
350
391
  def get_enabled_platforms(self) -> List[ChatPlatformConfig]:
351
392
  """获取所有启用的聊天平台"""
352
393
  return [cp for cp in self._config.chat_platforms if cp.enabled]
package/memory/manager.py CHANGED
@@ -306,10 +306,11 @@ class MemoryManager:
306
306
  """
307
307
  conn = self._get_conn()
308
308
  # 只排除纯内部审计条目,保留 tool_call/tool_result 供前端展示
309
+ # [v1.20.2] 使用 rowid 作为二级排序,防止同一秒内的消息顺序不确定
309
310
  sql = """SELECT * FROM memories
310
311
  WHERE session_id = ? AND category = 'session' AND role != ''
311
312
  AND key NOT IN ('llm_output', 'llm_input', 'tool_result_raw', 'conversation_insight')
312
- ORDER BY created_at ASC LIMIT ?"""
313
+ ORDER BY created_at ASC, rowid ASC LIMIT ?"""
313
314
  rows = conn.execute(sql, (session_id, limit)).fetchall()
314
315
  entries = [MemoryEntry.from_row(row) for row in rows]
315
316
  if include_roles:
@@ -319,9 +320,9 @@ class MemoryManager:
319
320
  def get_conversation_all(self, session_id, limit=5000) -> List[MemoryEntry]:
320
321
  """获取全量对话历史(包含所有内部条目),用于完整回溯。"""
321
322
  conn = self._get_conn()
322
- sql = """SELECT * FROM memories
323
+ sql = """SELECT * FROM memories
323
324
  WHERE session_id = ? AND category = 'session' AND role != ''
324
- ORDER BY created_at ASC LIMIT ?"""
325
+ ORDER BY created_at ASC, rowid ASC LIMIT ?"""
325
326
  rows = conn.execute(sql, (session_id, limit)).fetchall()
326
327
  return [MemoryEntry.from_row(row) for row in rows]
327
328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.20.1",
3
+ "version": "1.20.2",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
@@ -43,4 +43,4 @@
43
43
  "python": ">=3.10",
44
44
  "node": ">=18"
45
45
  }
46
- }
46
+ }
package/web/api_server.py CHANGED
@@ -2743,16 +2743,28 @@ window.toggleFullscreen = function() {{
2743
2743
  if "user" in data: (ad / "user.md").write_text(data["user"], encoding="utf-8")
2744
2744
  # 如果 name 改变,需要重命名目录
2745
2745
  new_name = data.get("name")
2746
+ renamed_to = None
2746
2747
  if new_name and new_name != path and not is_system:
2747
2748
  agents_dir = self._agents_dir()
2748
2749
  new_ad = agents_dir / new_name
2749
2750
  if not new_ad.exists():
2750
2751
  ad.rename(new_ad)
2751
2752
  logger.info(f"Agent 目录已重命名: {path} -> {new_name}")
2753
+ renamed_to = new_name
2754
+ # [v1.20.2] 重命名后更新 avatar_image URL 中的路径
2755
+ old_avatar = cfg.get("avatar_image", "")
2756
+ if old_avatar and f"/api/agents/{path}/" in old_avatar:
2757
+ cfg["avatar_image"] = old_avatar.replace(f"/api/agents/{path}/", f"/api/agents/{new_name}/")
2758
+ (new_ad / "config.json").write_text(
2759
+ json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8"
2760
+ )
2752
2761
  else:
2753
2762
  logger.warning(f"目标目录已存在,无法重命名: {new_name}")
2754
2763
  logger.info(f"更新 Agent: {path}")
2755
- return web.json_response({"ok": True, "hot_reload": True})
2764
+ result = {"ok": True, "hot_reload": True}
2765
+ if renamed_to:
2766
+ result["renamed_to"] = renamed_to
2767
+ return web.json_response(result)
2756
2768
 
2757
2769
  async def handle_delete_agent(self, request):
2758
2770
  """DELETE /api/agents/{path} - 删除 agent 及其所有子 agent"""
@@ -2961,7 +2973,9 @@ window.toggleFullscreen = function() {{
2961
2973
  platforms = []
2962
2974
  for cp in self.core.config_mgr.config.chat_platforms:
2963
2975
  platforms.append({
2976
+ "id": cp.id,
2964
2977
  "platform": cp.platform,
2978
+ "display_name": cp.display_name,
2965
2979
  "enabled": cp.enabled,
2966
2980
  "token": cp.token[:8] + "****" if cp.token else "",
2967
2981
  "app_id": cp.app_id,
@@ -2969,58 +2983,75 @@ window.toggleFullscreen = function() {{
2969
2983
  "webhook_url": cp.webhook_url,
2970
2984
  "allowed_users": cp.allowed_users,
2971
2985
  "extra": cp.extra,
2986
+ "bind_agent": cp.bind_agent,
2987
+ "bind_agents": cp.bind_agents,
2972
2988
  })
2973
2989
  return web.json_response(platforms)
2974
2990
 
2975
2991
  async def handle_get_platform(self, request):
2976
- """获取单个平台配置详情"""
2992
+ """获取单个平台配置详情(支持按 id 或 platform 查找)"""
2977
2993
  name = request.match_info["name"]
2978
- for cp in self.core.config_mgr.config.chat_platforms:
2979
- if cp.platform == name:
2980
- return web.json_response({
2981
- "platform": cp.platform,
2982
- "enabled": cp.enabled,
2983
- "token": cp.token,
2984
- "app_id": cp.app_id,
2985
- "app_secret": cp.app_secret,
2986
- "webhook_url": cp.webhook_url,
2987
- "allowed_users": cp.allowed_users,
2988
- "extra": cp.extra,
2989
- })
2990
- return web.json_response({"error": f"平台 {name} 不存在"}, status=404)
2994
+ # 优先按 id 查找,兼容按 platform 查找
2995
+ cp = self.core.config_mgr.get_chat_platform_by_id(name)
2996
+ if not cp:
2997
+ cp = self.core.config_mgr.get_chat_platform(name)
2998
+ if not cp:
2999
+ return web.json_response({"error": f"平台 {name} 不存在"}, status=404)
3000
+ return web.json_response({
3001
+ "id": cp.id,
3002
+ "platform": cp.platform,
3003
+ "display_name": cp.display_name,
3004
+ "enabled": cp.enabled,
3005
+ "token": cp.token,
3006
+ "app_id": cp.app_id,
3007
+ "app_secret": cp.app_secret,
3008
+ "webhook_url": cp.webhook_url,
3009
+ "allowed_users": cp.allowed_users,
3010
+ "extra": cp.extra,
3011
+ "bind_agent": cp.bind_agent,
3012
+ "bind_agents": cp.bind_agents,
3013
+ })
2991
3014
 
2992
3015
  async def handle_add_platform(self, request):
2993
- """新增聊天平台配置"""
3016
+ """新增聊天平台配置(支持多实例)"""
2994
3017
  data = await request.json()
2995
3018
  platform_name = data.get("platform", "")
2996
3019
  if not platform_name:
2997
3020
  return web.json_response({"error": "缺少 platform 字段"}, status=400)
2998
- # 检查是否已存在
2999
- existing = self.core.config_mgr.get_chat_platform(platform_name)
3000
- if existing:
3001
- return web.json_response({"error": f"平台 {platform_name} 已存在"}, status=409)
3021
+ # 不再限制同类型只能有一个,支持多 token 实例
3002
3022
  cp = ChatPlatformConfig(
3003
3023
  platform=platform_name,
3004
- enabled=data.get("enabled", False),
3024
+ enabled=data.get("enabled", True),
3005
3025
  token=data.get("token", ""),
3006
3026
  app_id=data.get("app_id", ""),
3007
3027
  app_secret=data.get("app_secret", ""),
3008
3028
  webhook_url=data.get("webhook_url", ""),
3009
3029
  allowed_users=data.get("allowed_users", []),
3010
3030
  extra=data.get("extra", {}),
3031
+ bind_agent=data.get("bind_agent", ""),
3032
+ bind_agents=data.get("bind_agents", []),
3011
3033
  )
3034
+ # 自动生成唯一 id 和 display_name
3035
+ self.core.config_mgr._auto_platform_id(cp)
3036
+ # 检查 id 唯一性
3037
+ existing_ids = {p.id for p in self.core.config_mgr.config.chat_platforms}
3038
+ if cp.id in existing_ids:
3039
+ return web.json_response({"error": f"平台实例 {cp.display_name} 已存在(ID: {cp.id})"}, status=409)
3012
3040
  self.core.config_mgr.config.chat_platforms.append(cp)
3013
3041
  self.core.config_mgr.save()
3014
3042
  # 热更新聊天平台
3015
3043
  self._hot_reload_chat_platforms()
3016
- logger.info(f"新增聊天平台: {platform_name}")
3017
- return web.json_response({"ok": True, "platform": platform_name, "hot_reload": True})
3044
+ logger.info(f"新增聊天平台: {cp.display_name} (id={cp.id})")
3045
+ return web.json_response({"ok": True, "id": cp.id, "platform": platform_name, "display_name": cp.display_name, "hot_reload": True})
3018
3046
 
3019
3047
  async def handle_update_platform(self, request):
3020
3048
  """更新聊天平台配置"""
3021
3049
  name = request.match_info["name"]
3022
3050
  data = await request.json()
3023
- cp = self.core.config_mgr.get_chat_platform(name)
3051
+ # 优先按 id 查找
3052
+ cp = self.core.config_mgr.get_chat_platform_by_id(name)
3053
+ if not cp:
3054
+ cp = self.core.config_mgr.get_chat_platform(name)
3024
3055
  if not cp:
3025
3056
  return web.json_response({"error": f"平台 {name} 不存在"}, status=404)
3026
3057
  if "enabled" in data:
@@ -3037,30 +3068,44 @@ window.toggleFullscreen = function() {{
3037
3068
  cp.allowed_users = data["allowed_users"]
3038
3069
  if "extra" in data:
3039
3070
  cp.extra = data["extra"]
3071
+ if "bind_agent" in data:
3072
+ cp.bind_agent = data["bind_agent"]
3073
+ if "bind_agents" in data:
3074
+ cp.bind_agents = data["bind_agents"]
3075
+ if "display_name" in data:
3076
+ cp.display_name = data["display_name"]
3077
+ # 如果 token 变了,重新生成 id/display_name
3078
+ if "token" in data and data["token"]:
3079
+ old_id = cp.id
3080
+ self.core.config_mgr._auto_platform_id(cp)
3081
+ if cp.id != old_id:
3082
+ logger.info(f"平台 ID 变更: {old_id} -> {cp.id}")
3040
3083
  self.core.config_mgr.save()
3041
3084
  # 热更新聊天平台
3042
3085
  self._hot_reload_chat_platforms()
3043
- logger.info(f"聊天平台配置已更新: {name}")
3044
- return web.json_response({"ok": True, "hot_reload": True})
3086
+ logger.info(f"聊天平台配置已更新: {cp.display_name} (id={cp.id})")
3087
+ return web.json_response({"ok": True, "id": cp.id, "hot_reload": True})
3045
3088
 
3046
3089
  async def handle_delete_platform(self, request):
3047
3090
  """删除聊天平台配置"""
3048
3091
  name = request.match_info["name"]
3049
3092
  platforms = self.core.config_mgr.config.chat_platforms
3050
3093
  for i, cp in enumerate(platforms):
3051
- if cp.platform == name:
3094
+ if cp.id == name or cp.platform == name:
3052
3095
  platforms.pop(i)
3053
3096
  self.core.config_mgr.save()
3054
3097
  # 热更新聊天平台
3055
3098
  self._hot_reload_chat_platforms()
3056
- logger.info(f"已删除聊天平台: {name}")
3099
+ logger.info(f"已删除聊天平台: {cp.display_name} (id={cp.id})")
3057
3100
  return web.json_response({"ok": True, "hot_reload": True})
3058
3101
  return web.json_response({"error": f"平台 {name} 不存在"}, status=404)
3059
3102
 
3060
3103
  async def handle_toggle_platform(self, request):
3061
3104
  """切换平台启用/禁用"""
3062
3105
  name = request.match_info["name"]
3063
- cp = self.core.config_mgr.get_chat_platform(name)
3106
+ cp = self.core.config_mgr.get_chat_platform_by_id(name)
3107
+ if not cp:
3108
+ cp = self.core.config_mgr.get_chat_platform(name)
3064
3109
  if not cp:
3065
3110
  return web.json_response({"error": f"平台 {name} 不存在"}, status=404)
3066
3111
  data = await request.json()
@@ -3068,14 +3113,18 @@ window.toggleFullscreen = function() {{
3068
3113
  self.core.config_mgr.save()
3069
3114
  # 热更新聊天平台
3070
3115
  self._hot_reload_chat_platforms()
3071
- logger.info(f"聊天平台 {name} 已{'启用' if cp.enabled else '禁用'}")
3072
- return web.json_response({"ok": True, "enabled": cp.enabled, "hot_reload": True})
3116
+ logger.info(f"聊天平台 {cp.display_name} 已{'启用' if cp.enabled else '禁用'}")
3117
+ return web.json_response({"ok": True, "enabled": cp.enabled, "id": cp.id, "hot_reload": True})
3073
3118
 
3074
3119
  async def handle_restart_platform(self, request):
3075
3120
  """重启指定平台Bot"""
3076
3121
  name = request.match_info["name"]
3122
+ cp = self.core.config_mgr.get_chat_platform_by_id(name)
3123
+ if not cp:
3124
+ cp = self.core.config_mgr.get_chat_platform(name)
3125
+ display = cp.display_name if cp else name
3077
3126
  # TODO: 实现平台 Bot 热重启
3078
- return web.json_response({"ok": True, "message": f"平台 {name} 重启请求已发送(重启需服务端支持)"})
3127
+ return web.json_response({"ok": True, "message": f"平台 {display} 重启请求已发送(重启需服务端支持)"})
3079
3128
 
3080
3129
  async def handle_platform_agents(self, request):
3081
3130
  """获取绑定到指定平台的所有 Agent"""
@@ -6271,7 +6320,10 @@ window.toggleFullscreen = function() {{
6271
6320
  avatar_file = ad / "avatar.png"
6272
6321
  if not avatar_file.exists():
6273
6322
  return web.json_response({"error": "头像不存在"}, status=404)
6274
- return web.FileResponse(str(avatar_file))
6323
+ # [v1.20.2] 添加缓存控制,防止浏览器缓存旧的 404 响应
6324
+ resp = web.FileResponse(str(avatar_file))
6325
+ resp.headers["Cache-Control"] = "no-cache"
6326
+ return resp
6275
6327
 
6276
6328
  # ── 知识库 RAG 搜索 ──
6277
6329
 
@@ -2138,6 +2138,15 @@ input,textarea,select{font:inherit}
2138
2138
  .inline-exec-result-btn{background:none;border:1px solid var(--border);color:var(--text2);font-size:11px;padding:2px 8px;border-radius:4px;cursor:pointer;transition:var(--transition);flex-shrink:0}
2139
2139
  .inline-exec-result-btn:hover{background:var(--bg2);border-color:var(--accent);color:var(--accent)}
2140
2140
 
2141
+ /* ── Tool Result Collapsible (合并卡片折叠区域) ── */
2142
+ .tool-result-collapsible{border-top:1px dashed var(--border);margin-top:2px;padding-top:2px}
2143
+ .tool-collapsible-toggle{font-size:11px;color:var(--text3);cursor:pointer;padding:2px 0;user-select:none;transition:var(--transition)}
2144
+ .tool-collapsible-toggle:hover{color:var(--accent)}
2145
+ .tool-collapsible-body{overflow:hidden;max-height:0;transition:max-height .25s ease}
2146
+ .tool-result-collapsible:not(.collapsed) .tool-collapsible-body{max-height:300px;overflow-y:auto}
2147
+ .tool-result-collapsible.collapsed .tool-collapsible-toggle::after{content:' ▸'}
2148
+ .tool-result-collapsible:not(.collapsed) .tool-collapsible-toggle::after{content:' ▾'}
2149
+
2141
2150
  /* ── Execution Result Modal ── */
2142
2151
  .exec-result-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;animation:fadeIn .15s ease}
2143
2152
  .exec-result-modal{background:var(--bg);border:1px solid var(--border);border-radius:12px;width:min(680px,90vw);max-height:80vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.25);animation:slideUp .2s ease}
@@ -225,10 +225,10 @@ var rpSections = {
225
225
 
226
226
  // ── 平台图标映射 ──
227
227
  const PLATFORM_ICONS = {
228
- telegram: '✈️', discord: '🎮', feishu: '🐦', qq: '🐧', wechat: '💬',
228
+ telegram: '✈️', discord: '🎮', feishu: '🐦', qq: '🐧', wechat: '💬', whatsapp: '📱',
229
229
  };
230
230
  const PLATFORM_LABELS = {
231
- telegram: 'Telegram', discord: 'Discord', feishu: '飞书', qq: 'QQ', wechat: '微信',
231
+ telegram: 'Telegram', discord: 'Discord', feishu: '飞书', qq: 'QQ', wechat: '微信', whatsapp: 'WhatsApp',
232
232
  };
233
233
 
234
234
  // ── Utility: deterministic color from agent name ──
@@ -1638,19 +1638,20 @@ function showAgentModal(editPath, parentPath, data) {
1638
1638
  + '<option value="feishu"' + (platform === 'feishu' ? ' selected' : '') + '>🐦 飞书</option>'
1639
1639
  + '<option value="qq"' + (platform === 'qq' ? ' selected' : '') + '>🐧 QQ</option>'
1640
1640
  + '<option value="wechat"' + (platform === 'wechat' ? ' selected' : '') + '>💬 微信</option>'
1641
+ + '<option value="whatsapp"' + (platform === 'whatsapp' ? ' selected' : '') + '>📱 WhatsApp</option>'
1641
1642
  + '</select>'
1642
1643
  + '<button class="config-action-btn" style="padding:6px 10px;font-size:11px" onclick="window.location.href=\'/ui/index.html?page=platforms\'" title="管理聊天平台">管理平台</button>'
1643
1644
  + '</div>'
1644
- + '<div class="hint">选择 Agent 对接的聊天平台,或点击"管理平台"新增接入</div></div>'
1645
+ + '<div class="hint">选择 Agent 对接的聊天平台。多实例绑定和 Agent 选择请在"管理平台"中配置。</div></div>'
1645
1646
  + '<div class="agent-form-group" id="platformTokenGroup" style="display:' + (platform ? '' : 'none') + '"><label>平台 Token</label>'
1646
1647
  + '<input type="text" id="agentFormPlatformToken" placeholder="Bot Token" value="' + escapeHtml(platform_token) + '">'
1647
- + '<div class="hint">聊天平台的 Bot Token</div></div>'
1648
- + '<div class="agent-form-group" id="platformAppIdGroup" style="display:' + (platform === 'feishu' ? '' : 'none') + '"><label>App ID</label>'
1649
- + '<input type="text" id="agentFormPlatformAppId" placeholder="飞书 App ID" value="' + escapeHtml(platform_app_id) + '">'
1650
- + '<div class="hint">飞书应用的 App ID</div></div>'
1651
- + '<div class="agent-form-group" id="platformAppSecretGroup" style="display:' + (platform === 'feishu' ? '' : 'none') + '"><label>App Secret</label>'
1652
- + '<input type="text" id="agentFormPlatformAppSecret" placeholder="飞书 App Secret" value="' + escapeHtml(platform_app_secret) + '">'
1653
- + '<div class="hint">飞书应用的 App Secret</div></div>'
1648
+ + '<div class="hint">聊天平台的 Bot Token(也可在"管理平台"中配置)</div></div>'
1649
+ + '<div class="agent-form-group" id="platformAppIdGroup" style="display:' + ((platform === 'feishu' || platform === 'whatsapp') ? '' : 'none') + '"><label>App ID</label>'
1650
+ + '<input type="text" id="agentFormPlatformAppId" placeholder="App ID" value="' + escapeHtml(platform_app_id) + '">'
1651
+ + '<div class="hint">应用 App ID</div></div>'
1652
+ + '<div class="agent-form-group" id="platformAppSecretGroup" style="display:' + ((platform === 'feishu' || platform === 'whatsapp') ? '' : 'none') + '"><label>App Secret</label>'
1653
+ + '<input type="text" id="agentFormPlatformAppSecret" placeholder="App Secret" value="' + escapeHtml(platform_app_secret) + '">'
1654
+ + '<div class="hint">应用 App Secret</div></div>'
1654
1655
  + '</div>'
1655
1656
  + '<div class="agent-form-group"><label>Identity (身份)</label>'
1656
1657
  + '<textarea id="agentFormIdentity" placeholder="你是一个... 你擅长... 你的风格是..." rows="3">' + escapeHtml(identity) + '</textarea>'
@@ -2446,11 +2447,11 @@ function groupHistoryMessages(messages) {
2446
2447
  if (key === 'tool_call') {
2447
2448
  var tc = m._parsedToolCall || parseToolCallContent(m.content);
2448
2449
  m._parsedToolCall = tc;
2449
- return { type: 'exec', data: { id: evtId++, type: 'tool_call', title: tc.title, tool_name: tc.toolName, params: tc.params || undefined, status: 'done' } };
2450
+ return { type: 'exec', data: { id: evtId, type: 'tool_call', title: tc.title, tool_name: tc.toolName, params: tc.params || undefined, status: 'done' } };
2450
2451
  }
2451
2452
  if (m.role === 'tool') {
2452
2453
  var tr = parseToolResultContent(m.content);
2453
- return { type: 'exec', data: { id: evtId++, type: 'tool_result', title: tr.title, tool_name: tr.toolName, success: tr.success, summary: tr.body.substring(0, 500).trim(), result: { output: tr.body.substring(0, 2000) } } };
2454
+ return { type: 'exec_result', data: { id: evtId, type: 'tool_result', title: tr.title, tool_name: tr.toolName, success: tr.success, summary: tr.body.substring(0, 500).trim(), result: { output: tr.body.substring(0, 2000) } } };
2454
2455
  }
2455
2456
  if (m.content && m.content.trim() && m.content !== '(无回复)') {
2456
2457
  return { type: 'text', content: m.content };
@@ -2458,6 +2459,47 @@ function groupHistoryMessages(messages) {
2458
2459
  return null;
2459
2460
  }
2460
2461
 
2462
+ // 合并连续的 tool_call + tool_result 为单个 exec 卡片(一个工具一个框)
2463
+ // [v1.20.2] 修复:向前搜索匹配的 tool_result,不再仅检查紧邻的下一个 part
2464
+ function mergeToolParts(parts) {
2465
+ var merged = [];
2466
+ var consumed = {}; // 记录已被 tool_call 消费的 exec_result 索引
2467
+ for (var pi = 0; pi < parts.length; pi++) {
2468
+ if (consumed[pi]) continue; // 已被前面的 tool_call 消费
2469
+ var p = parts[pi];
2470
+ if (p.type === 'exec' && p.data.type === 'tool_call') {
2471
+ var callData = p.data;
2472
+ var mergedPart = { type: 'exec', data: Object.assign({}, callData, { has_result: false, result_data: null }) };
2473
+ // 向前搜索匹配的 tool_result(允许中间有 text 等其他 part)
2474
+ for (var ri = pi + 1; ri < parts.length; ri++) {
2475
+ if (parts[ri].type === 'exec_result') {
2476
+ var nextData = parts[ri].data;
2477
+ if (nextData.tool_name === callData.tool_name || nextData.tool_call_id === callData.id) {
2478
+ mergedPart.data.has_result = true;
2479
+ mergedPart.data.success = nextData.success;
2480
+ mergedPart.data.summary = nextData.summary;
2481
+ mergedPart.data.result = nextData.result;
2482
+ if (nextData.title) mergedPart.data.title = nextData.title;
2483
+ consumed[ri] = true; // 标记为已消费
2484
+ break;
2485
+ }
2486
+ }
2487
+ // 遇到下一个 tool_call 就停止搜索(tool_result 不会跨越 tool_call)
2488
+ if (parts[ri].type === 'exec' && parts[ri].data.type === 'tool_call') break;
2489
+ }
2490
+ evtId++;
2491
+ merged.push(mergedPart);
2492
+ } else if (p.type === 'exec_result') {
2493
+ // 孤立的 tool_result(没有对应的 tool_call),保留为失败卡片
2494
+ evtId++;
2495
+ merged.push({ type: 'exec', data: Object.assign({}, p.data, { type: 'tool_result', has_result: true }) });
2496
+ } else {
2497
+ merged.push(p);
2498
+ }
2499
+ }
2500
+ return merged;
2501
+ }
2502
+
2461
2503
  var i = 0;
2462
2504
  while (i < messages.length) {
2463
2505
  var msg = messages[i];
@@ -2507,6 +2549,9 @@ function groupHistoryMessages(messages) {
2507
2549
  i++;
2508
2550
  }
2509
2551
 
2552
+ // [v1.20.2] 合并连续 tool_call + tool_result 为单个卡片
2553
+ parts = mergeToolParts(parts);
2554
+
2510
2555
  // 组装: content 取最后一段文本(用于搜索/纯文本回退),parts 展示完整时间线
2511
2556
  var textParts = parts.filter(function(p) { return p.type === 'text'; });
2512
2557
  var hasExecParts = parts.some(function(p) { return p.type === 'exec'; });
@@ -925,7 +925,12 @@ function renderInlineExecEvent(data, msgIdx) {
925
925
 
926
926
  // Determine event state class for legacy events
927
927
  let stateClass = '';
928
- if (data.type === 'tool_call' || data.type === 'skill_call') stateClass = 'tool-call-pending';
928
+ // [v1.20.2] 合并卡片:tool_call + has_result 显示完成状态
929
+ if (data.has_result) {
930
+ stateClass = data.success === false ? 'tool-failed' : 'tool-success';
931
+ } else if (data.type === 'tool_call' || data.type === 'skill_call') {
932
+ stateClass = 'tool-call-pending';
933
+ }
929
934
  if (data.type === 'tool_result') stateClass = data.success === false ? 'tool-failed' : 'tool-success';
930
935
  if (data.type === 'code_result') stateClass = data.timed_out ? 'tool-failed' : (data.success ? 'tool-success' : 'tool-failed');
931
936
 
@@ -942,8 +947,8 @@ function renderInlineExecEvent(data, msgIdx) {
942
947
 
943
948
  // Build body content
944
949
  let bodyHtml = '';
945
- // Params for tool_call/skill_call
946
- if (data.params && (data.type === 'tool_call' || data.type === 'skill_call')) {
950
+ // Params for tool_call/skill_call (合并卡片中 params 在折叠区域显示)
951
+ if (data.params && (data.type === 'tool_call' || data.type === 'skill_call') && !data.has_result) {
947
952
  let paramPreview = typeof data.params === 'string' ? data.params : JSON.stringify(data.params);
948
953
  if (paramPreview.length > 300) paramPreview = paramPreview.substring(0, 300) + '...';
949
954
  bodyHtml += '<div class="inline-exec-code">' + escapeHtml(paramPreview) + '</div>';
@@ -952,16 +957,33 @@ function renderInlineExecEvent(data, msgIdx) {
952
957
  if (data.code_preview && (data.type === 'code_exec' || data.type === 'code_result')) {
953
958
  bodyHtml += '<div class="inline-exec-code" onclick="showExecResultModal(' + msgIdx + ', \'' + data.id + '\')" title="点击查看完整结果">' + escapeHtml(data.code_preview) + '</div>';
954
959
  }
955
- // Summary for tool_result/skill_result
956
- if (data.summary && (data.type === 'tool_result' || data.type === 'skill_result')) {
960
+ // [v1.20.2] 合并卡片:显示折叠的参数 + 结果摘要 + 查看详情按钮
961
+ if (data.has_result) {
962
+ // 折叠的参数区域
963
+ if (data.params) {
964
+ let paramPreview = typeof data.params === 'string' ? data.params : JSON.stringify(data.params);
965
+ if (paramPreview.length > 300) paramPreview = paramPreview.substring(0, 300) + '...';
966
+ bodyHtml += '<div class="tool-result-collapsible"><div class="tool-collapsible-toggle" onclick="this.parentElement.classList.toggle(\'collapsed\')">📋 参数</div><div class="tool-collapsible-body"><div class="inline-exec-code">' + escapeHtml(paramPreview) + '</div></div></div>';
967
+ }
968
+ // 结果摘要
969
+ if (data.summary) {
970
+ bodyHtml += '<div class="inline-exec-summary">' + escapeHtml(data.summary) + '</div>';
971
+ }
972
+ // 查看详情按钮
973
+ if (data.result) {
974
+ bodyHtml += '<button class="inline-exec-result-btn" onclick="showToolResultModal(' + msgIdx + ', \'' + data.id + '\')">查看详情</button>';
975
+ }
976
+ }
977
+ // Summary for standalone tool_result/skill_result (非合并的孤立结果)
978
+ if (!data.has_result && data.summary && (data.type === 'tool_result' || data.type === 'skill_result')) {
957
979
  bodyHtml += '<div class="inline-exec-summary">' + escapeHtml(data.summary) + '</div>';
958
980
  }
959
981
  // Result button for code_result
960
982
  if (data.type === 'code_result' && (data.stdout || data.stderr || data.error)) {
961
983
  bodyHtml += '<button class="inline-exec-result-btn" onclick="showExecResultModal(' + msgIdx + ', \'' + data.id + '\')">查看详情</button>';
962
984
  }
963
- // Result button for tool_result/skill_result
964
- if ((data.type === 'tool_result' || data.type === 'skill_result') && data.result) {
985
+ // Result button for standalone tool_result/skill_result
986
+ if (!data.has_result && (data.type === 'tool_result' || data.type === 'skill_result') && data.result) {
965
987
  bodyHtml += '<button class="inline-exec-result-btn" onclick="showToolResultModal(' + msgIdx + ', \'' + data.id + '\')">查看详情</button>';
966
988
  }
967
989
 
package/web/ui/index.html CHANGED
@@ -373,7 +373,9 @@ async function api(url,opts={}){
373
373
  }catch(e){showToast('请求失败: '+e.message,'danger');return {error:e.message}}
374
374
  }
375
375
  function $(id){return document.getElementById(id)}
376
- function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
376
+ function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
377
+ /* [v1.20.2] 安全地将任意文本插入 JS 模板字符串中 textarea 元素:转义反引号、${ 和 </textarea */
378
+ function escTpl(s){return String(s||'').replace(/\\/g,'\\\\').replace(/`/g,'\\`').replace(/\$\{/g,'\\${').replace(/<\/textarea/gi,'<\\/textarea')}
377
379
  function fmtDate(d){if(!d)return'-';try{return new Date(d).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})}catch(e){return d.slice(0,16)}}
378
380
  function fmtTimeAgo(d){if(!d)return'-';const s=Math.floor((Date.now()-new Date(d))/1000);if(s<60)return s+'秒前';if(s<3600)return Math.floor(s/60)+'分钟前';if(s<86400)return Math.floor(s/3600)+'小时前';return Math.floor(s/86400)+'天前'}
379
381
  function showToast(msg,type='info',duration=3000){
@@ -752,7 +754,7 @@ async function showWorkdirModal(agentPath){
752
754
  $('modalContainer').innerHTML=`<div class="modal-overlay" onclick="closeModal()"><div class="modal modal-wide" onclick="event.stopPropagation()" style="max-width:600px">
753
755
  <div class="flex justify-between items-center mb-16">
754
756
  <h3>${title}</h3>
755
- <div class="flex gap-8"><button class="btn btn-sm btn-ghost" onclick="loadWorkdirFiles('')">根目录</button><button class="btn btn-sm btn-ghost" onclick="loadWorkdirFiles(_workdirCurrentPath,true)">刷新</button></div>
757
+ <div class="flex gap-8"><button class="btn btn-sm btn-ghost" onclick="loadWorkdirFiles('')">根目录</button><button class="btn btn-sm btn-ghost" onclick="loadWorkdirFiles(_workdirCurrentPath)">刷新</button></div>
756
758
  </div>
757
759
  <div id="workdirBreadcrumb" style="font-size:12px;color:var(--text3);margin-bottom:8px"></div>
758
760
  <div id="workdirContent"><div class="empty">加载中...</div></div>
@@ -936,7 +938,7 @@ async function openEditAgentModal(path){
936
938
  <div class="form-group"><label>创建时间 <span style="color:var(--text2)">(只读)</span></label><input id="eaCreated" value="${fmtDate(a.created_at)}" disabled></div>
937
939
  </div>
938
940
  <div class="form-group"><label>工作目录</label><input id="eaWorkDir" value="${escHtml(a.work_dir||'')}" placeholder="留空使用默认"></div>
939
- <div class="form-group"><label>系统提示</label><textarea id="eaPrompt" rows="4" ${isSys?'disabled':''}>${escHtml(a.system_prompt||'')}</textarea></div>
941
+ <div class="form-group"><label>系统提示</label><textarea id="eaPrompt" rows="4" ${isSys?'disabled':''}></textarea></div>
940
942
  <div class="flex gap-8 mt-16">
941
943
  <button class="btn btn-primary" onclick="doSaveAgent('${escHtml(path)}')">保存</button>
942
944
  <button class="btn btn-ghost" onclick="closeModal()">关闭</button>
@@ -945,11 +947,11 @@ async function openEditAgentModal(path){
945
947
 
946
948
  <!-- 人格系统 -->
947
949
  <div id="atSoul" class="hidden">
948
- <div class="form-group"><label>soul.md</label><textarea id="eaSoul" rows="10" style="min-height:200px">${escHtml(a.soul||'')}</textarea>
950
+ <div class="form-group"><label>soul.md</label><textarea id="eaSoul" rows="10" style="min-height:200px"></textarea>
949
951
  <button class="btn btn-sm btn-primary mt-8" onclick="doSaveSoul('${escHtml(path)}')">保存 Soul</button></div>
950
- <div class="form-group"><label>identity.md</label><textarea id="eaIdentity" rows="10" style="min-height:200px">${escHtml(a.identity||'')}</textarea>
952
+ <div class="form-group"><label>identity.md</label><textarea id="eaIdentity" rows="10" style="min-height:200px"></textarea>
951
953
  <button class="btn btn-sm btn-primary mt-8" onclick="doSaveIdentity('${escHtml(path)}')">保存 Identity</button></div>
952
- <div class="form-group"><label>user.md</label><textarea id="eaUser" rows="6" style="min-height:120px">${escHtml(a.user||'')}</textarea>
954
+ <div class="form-group"><label>user.md</label><textarea id="eaUser" rows="6" style="min-height:120px"></textarea>
953
955
  <button class="btn btn-sm btn-primary mt-8" onclick="doSaveUser('${escHtml(path)}')">保存 User</button></div>
954
956
  </div>
955
957
 
@@ -978,6 +980,11 @@ async function openEditAgentModal(path){
978
980
  <div id="atPermsContent"><div class="empty">加载中...</div></div>
979
981
  </div>
980
982
  </div></div>`;
983
+ // [v1.20.2] 安全设置 textarea 内容(避免模板字符串注入和 HTML 实体问题)
984
+ if($('eaPrompt'))$('eaPrompt').value=a.system_prompt||'';
985
+ if($('eaSoul'))$('eaSoul').value=a.soul||'';
986
+ if($('eaIdentity'))$('eaIdentity').value=a.identity||'';
987
+ if($('eaUser'))$('eaUser').value=a.user||'';
981
988
  }
982
989
 
983
990
  function agentTabSwitch(el,tabId){
@@ -1001,7 +1008,10 @@ async function doSaveAgent(path){
1001
1008
  avatar_image:$('eaAvatarImage')?.value||''
1002
1009
  })});
1003
1010
  if(r.error){showToast(r.error,'danger');return}
1004
- showToast('已保存','success');renderAgents();
1011
+ showToast('已保存','success');
1012
+ // [v1.20.2] 如果 agent 被重命名,更新当前编辑路径,防止后续保存出现 not found
1013
+ if(r.renamed_to){window._currentEditAgentPath=r.renamed_to}
1014
+ renderAgents();
1005
1015
  }
1006
1016
 
1007
1017
  async function doSaveAgentSettings(path){
@@ -1012,9 +1022,9 @@ async function doSaveAgentSettings(path){
1012
1022
  showToast('设置已保存','success');
1013
1023
  }
1014
1024
 
1015
- async function doSaveSoul(path){await api(`/api/agents/${encodeURIComponent(path)}/soul`,{method:'PUT',body:JSON.stringify({soul:$('eaSoul').value})});showToast('Soul.md 已保存','success')}
1016
- async function doSaveIdentity(path){await api(`/api/agents/${encodeURIComponent(path)}/identity`,{method:'PUT',body:JSON.stringify({identity:$('eaIdentity').value})});showToast('Identity.md 已保存','success')}
1017
- async function doSaveUser(path){await api(`/api/agents/${encodeURIComponent(path)}/user`,{method:'PUT',body:JSON.stringify({user:$('eaUser').value})});showToast('User.md 已保存','success')}
1025
+ async function doSaveSoul(path){const r=await api(`/api/agents/${encodeURIComponent(path)}/soul`,{method:'PUT',body:JSON.stringify({soul:$('eaSoul').value})});if(r.error){showToast(r.error,'danger');return}showToast('Soul.md 已保存','success')}
1026
+ async function doSaveIdentity(path){const r=await api(`/api/agents/${encodeURIComponent(path)}/identity`,{method:'PUT',body:JSON.stringify({identity:$('eaIdentity').value})});if(r.error){showToast(r.error,'danger');return}showToast('Identity.md 已保存','success')}
1027
+ async function doSaveUser(path){const r=await api(`/api/agents/${encodeURIComponent(path)}/user`,{method:'PUT',body:JSON.stringify({user:$('eaUser').value})});if(r.error){showToast(r.error,'danger');return}showToast('User.md 已保存','success')}
1018
1028
 
1019
1029
  async function loadAgentKB(){
1020
1030
  // Find current agent path from the modal title or store it
@@ -1223,66 +1233,163 @@ function chatWithAgent(path){
1223
1233
  async function renderPlatforms(){
1224
1234
  const ps=await api('/api/platforms');
1225
1235
  if(ps.error){$('content').innerHTML='<div class="empty" style="color:var(--danger)">加载失败: '+escHtml(ps.error)+'</div>';return}
1226
- const icons={telegram:'📱',discord:'🎮',feishu:'🐦',qq:'🐧',wechat:'💚'};
1236
+ const icons={telegram:'📱',discord:'🎮',feishu:'🐦',qq:'🐧',wechat:'💚',whatsapp:'💬'};
1227
1237
  let html=`<div class="flex justify-between items-center mb-16">
1228
- <div style="color:var(--text2);font-size:13px">共 ${ps.length} 个平台</div>
1238
+ <div style="color:var(--text2);font-size:13px">共 ${ps.length} 个平台实例</div>
1229
1239
  <button class="btn btn-primary" onclick="showAddPlatformModal()">+ 添加平台</button></div>`;
1230
1240
  if(!ps.length){html+='<div class="empty">暂无聊天平台配置,点击上方按钮添加</div>';$('content').innerHTML=html;return}
1231
1241
  html+='<div class="grid">';
1232
1242
  for(const p of ps){
1233
- const name=p.platform||p.name;
1243
+ const pid=p.id||p.platform;
1244
+ const displayName=p.display_name||(icons[p.platform]||'📡')+' '+p.platform;
1245
+ const bindInfo=p.bind_agent?'绑定: '+escHtml(p.bind_agent):(p.bind_agents&&p.bind_agents.length?'绑定: '+p.bind_agents.map(a=>escHtml(a)).join(', '):'');
1234
1246
  html+=`<div class="card"><div class="flex justify-between items-center">
1235
- <h3 style="color:var(--text)">${icons[name]||'📡'} ${escHtml(name)}</h3>
1247
+ <h3 style="color:var(--text)">${icons[p.platform]||'📡'} ${escHtml(p.display_name||p.platform)}</h3>
1236
1248
  <span class="badge ${p.enabled?'badge-green':'badge-red'}">${p.enabled?'已启用':'未启用'}</span>
1237
1249
  </div><p style="font-size:13px;color:var(--text2);margin-top:8px">Token: ${p.token?'已配置':'未配置'} ${p.app_id?'· App ID: '+escHtml(p.app_id):''}</p>
1250
+ ${bindInfo?'<p style="font-size:12px;color:var(--text3);margin-top:4px">'+bindInfo+'</p>':''}
1238
1251
  <div class="mt-8 flex gap-8">
1239
- <button class="btn btn-sm ${p.enabled?'btn-danger':'btn-success'}" onclick="togglePlatform('${escHtml(name)}',${!p.enabled})">${p.enabled?'停用':'启用'}</button>
1240
- <button class="btn btn-sm btn-ghost" onclick="showEditPlatformModal('${escHtml(name)}')">配置</button>
1252
+ <button class="btn btn-sm ${p.enabled?'btn-danger':'btn-success'}" onclick="togglePlatform('${escHtml(pid)}',${!p.enabled})">${p.enabled?'停用':'启用'}</button>
1253
+ <button class="btn btn-sm btn-ghost" onclick="showEditPlatformModal('${escHtml(pid)}')">配置</button>
1241
1254
  </div></div>`;
1242
1255
  }
1243
1256
  html+='</div>';$('content').innerHTML=html;
1244
1257
  }
1245
- async function togglePlatform(name,enable){await api(`/api/platforms/${name}`,{method:'PUT',body:JSON.stringify({enabled})});renderPlatforms()}
1258
+ async function togglePlatform(id,enable){await api(`/api/platforms/${id}`,{method:'PUT',body:JSON.stringify({enabled})});renderPlatforms()}
1246
1259
  function showAddPlatformModal(){
1247
- $('modalContainer').innerHTML=`<div class="modal-overlay" onclick="closeModal()"><div class="modal" onclick="event.stopPropagation()">
1260
+ $('modalContainer').innerHTML=`<div class="modal-overlay" onclick="closeModal()"><div class="modal" onclick="event.stopPropagation()" style="max-width:520px">
1248
1261
  <h3>+ 添加聊天平台</h3>
1249
- <div class="form-group"><label>平台类型</label><select id="apType">
1262
+ <div class="form-group"><label>平台类型</label><select id="apType" onchange="onPlatformTypeChange()">
1250
1263
  <option value="telegram">Telegram</option><option value="discord">Discord</option>
1251
1264
  <option value="feishu">飞书</option><option value="qq">QQ</option><option value="wechat">微信</option>
1265
+ <option value="whatsapp">WhatsApp</option>
1252
1266
  </select></div>
1253
- <div class="form-group"><label>Token</label><input id="apToken" placeholder="Bot Token"></div>
1254
- <div class="form-group"><label>App ID</label><input id="apAppId" placeholder="应用ID (可选)"></div>
1255
- <div class="form-group"><label>Webhook URL</label><input id="apWebhook" placeholder="Webhook 地址 (可选)"></div>
1267
+ <div class="form-group"><label>Token / Access Token</label><input id="apToken" placeholder="Bot Token 或 Access Token"></div>
1268
+ <div id="apAppIdGroup" class="form-group"><label>App ID</label><input id="apAppId" placeholder="应用ID (可选)"></div>
1269
+ <div id="apWebhookGroup" class="form-group"><label>Webhook URL</label><input id="apWebhook" placeholder="Webhook 地址 (可选)"></div>
1270
+ <div class="form-group"><label>绑定 Agent</label>
1271
+ <div class="flex gap-8">
1272
+ <input id="apBindAgent" placeholder="手输 Agent ID (可选)" style="flex:1">
1273
+ <button class="btn btn-sm btn-ghost" onclick="showAgentSelectorForPlatform('apBindAgent','apBindAgents')">选择 Agent</button>
1274
+ </div>
1275
+ <div id="apBindAgents" style="font-size:11px;color:var(--text3);margin-top:4px"></div>
1276
+ <div class="hint">手输 Agent ID 或点击"选择 Agent"从部门中选择。支持绑定多个 Agent。</div>
1277
+ </div>
1256
1278
  <div class="flex gap-8 mt-16"><button class="btn btn-primary" onclick="doAddPlatform()">添加</button><button class="btn btn-ghost" onclick="closeModal()">取消</button></div>
1257
1279
  </div></div>`;
1258
1280
  }
1259
- async function doAddPlatform(){
1260
- const r=await api('/api/platforms',{method:'POST',body:JSON.stringify({platform:$('apType').value,token:$('apToken').value,app_id:$('apAppId').value,webhook_url:$('apWebhook').value})});
1261
- if(r.error){showToast(r.error,'danger');return}closeModal();showToast('已添加','success');renderPlatforms();
1281
+ function onPlatformTypeChange(){
1282
+ var t=$('apType').value;
1283
+ var showAppId=(t==='feishu'||t==='whatsapp');
1284
+ var showWebhook=(t==='whatsapp');
1285
+ if($('apAppIdGroup'))$('apAppIdGroup').style.display=showAppId?'':'none';
1286
+ if($('apWebhookGroup'))$('apWebhookGroup').style.display=showWebhook?'':'none';
1262
1287
  }
1263
- async function showEditPlatformModal(name){
1264
- const p=await api(`/api/platforms/${name}`);
1288
+ async function doAddPlatform(){
1289
+ var bindAgent=$('apBindAgent').value.trim();
1290
+ var bindAgents=[];
1291
+ var selectedEls=document.querySelectorAll('#apBindAgents .selected-agent-tag');
1292
+ selectedEls.forEach(function(el){var v=el.getAttribute('data-agent');if(v)bindAgents.push(v);});
1293
+ if(bindAgent)bindAgents.unshift(bindAgent);
1294
+ bindAgents=[...new Set(bindAgents)];
1295
+ const r=await api('/api/platforms',{method:'POST',body:JSON.stringify({platform:$('apType').value,token:$('apToken').value,app_id:$('apAppId').value,webhook_url:$('apWebhook').value,bind_agent:bindAgents[0]||'',bind_agents:bindAgents})});
1296
+ if(r.error){showToast(r.error,'danger');return}closeModal();showToast('已添加: '+(r.display_name||r.platform),'success');renderPlatforms();
1297
+ }
1298
+ async function showEditPlatformModal(id){
1299
+ const p=await api(`/api/platforms/${id}`);
1265
1300
  if(p.error){showToast(p.error,'danger');return}
1266
- $('modalContainer').innerHTML=`<div class="modal-overlay" onclick="closeModal()"><div class="modal" onclick="event.stopPropagation()">
1267
- <h3>编辑 ${escHtml(p.platform)}</h3>
1268
- <div class="form-group"><label>Token</label><input id="epToken" value="${escHtml(p.token||'')}" placeholder="Bot Token"></div>
1269
- <div class="form-group"><label>App ID</label><input id="epAppId" value="${escHtml(p.app_id||'')}" placeholder="应用ID"></div>
1301
+ const pid=p.id||id;
1302
+ var bindAgentVal=p.bind_agent||'';
1303
+ var bindAgentsArr=p.bind_agents||[];
1304
+ $('modalContainer').innerHTML=`<div class="modal-overlay" onclick="closeModal()"><div class="modal" onclick="event.stopPropagation()" style="max-width:520px">
1305
+ <h3>配置 ${escHtml(p.display_name||p.platform)}</h3>
1306
+ <div class="form-group"><label>显示名称</label><input id="epDisplayName" value="${escHtml(p.display_name||'')}" placeholder="留空自动生成"></div>
1307
+ <div class="form-group"><label>Token / Access Token</label><input id="epToken" value="${escHtml(p.token||'')}" placeholder="Bot Token"></div>
1308
+ <div id="epAppIdGroup" class="form-group"><label>App ID</label><input id="epAppId" value="${escHtml(p.app_id||'')}" placeholder="应用ID"></div>
1270
1309
  <div class="form-group"><label>App Secret</label><input id="epSecret" type="password" placeholder="${p.has_secret?'已配置(留空不修改)':'未配置'}"></div>
1271
- <div class="form-group"><label>Webhook URL</label><input id="epWebhook" value="${escHtml(p.webhook_url||'')}" placeholder="Webhook 地址"></div>
1310
+ <div id="epWebhookGroup" class="form-group"><label>Webhook URL</label><input id="epWebhook" value="${escHtml(p.webhook_url||'')}" placeholder="Webhook 地址"></div>
1272
1311
  <div class="form-group"><label>允许用户 (逗号分隔)</label><input id="epUsers" value="${escHtml((p.allowed_users||[]).join(','))}" placeholder="user1,user2"></div>
1273
- <div class="flex gap-8 mt-16"><button class="btn btn-primary" onclick="doSavePlatform('${escHtml(name)}')">保存</button><button class="btn btn-danger" onclick="doDeletePlatform('${escHtml(name)}')">删除平台</button><button class="btn btn-ghost" onclick="closeModal()">关闭</button></div>
1312
+ <div class="form-group"><label>绑定 Agent</label>
1313
+ <div class="flex gap-8">
1314
+ <input id="epBindAgent" value="${escHtml(bindAgentVal)}" placeholder="手输 Agent ID (可选)" style="flex:1">
1315
+ <button class="btn btn-sm btn-ghost" onclick="showAgentSelectorForPlatform('epBindAgent','epBindAgents')">选择 Agent</button>
1316
+ </div>
1317
+ <div id="epBindAgents" style="font-size:11px;color:var(--text3);margin-top:4px">${bindAgentsArr.map(function(a){return '<span class="selected-agent-tag" data-agent="'+escHtml(a)+'" style="display:inline-block;background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:1px 6px;margin:2px;font-size:11px;cursor:pointer" onclick="this.remove()" title="点击移除">'+escHtml(a)+' ×</span>';}).join('')}</div>
1318
+ <div class="hint">手输 Agent ID 或点击"选择 Agent"从部门中选择。一个平台可以绑定多个 Agent。</div>
1319
+ </div>
1320
+ <div class="flex gap-8 mt-16"><button class="btn btn-primary" onclick="doSavePlatform('${escHtml(pid)}')">保存</button><button class="btn btn-danger" onclick="doDeletePlatform('${escHtml(pid)}')">删除平台</button><button class="btn btn-ghost" onclick="closeModal()">关闭</button></div>
1274
1321
  </div></div>`;
1275
1322
  }
1276
- async function doSavePlatform(name){
1277
- const r=await api(`/api/platforms/${name}`,{method:'PUT',body:JSON.stringify({token:$('epToken').value,app_id:$('epAppId').value,app_secret:$('epSecret').value||undefined,webhook_url:$('epWebhook').value,allowed_users:$('epUsers').value.split(',').map(s=>s.trim()).filter(Boolean)})});
1323
+ async function doSavePlatform(id){
1324
+ var bindAgent=$('epBindAgent').value.trim();
1325
+ var bindAgents=[];
1326
+ var selectedEls=document.querySelectorAll('#epBindAgents .selected-agent-tag');
1327
+ selectedEls.forEach(function(el){var v=el.getAttribute('data-agent');if(v)bindAgents.push(v);});
1328
+ if(bindAgent)bindAgents.unshift(bindAgent);
1329
+ bindAgents=[...new Set(bindAgents)];
1330
+ const r=await api(`/api/platforms/${id}`,{method:'PUT',body:JSON.stringify({display_name:$('epDisplayName').value,token:$('epToken').value,app_id:$('epAppId').value,app_secret:$('epSecret').value||undefined,webhook_url:$('epWebhook').value,allowed_users:$('epUsers').value.split(',').map(s=>s.trim()).filter(Boolean),bind_agent:bindAgents[0]||'',bind_agents:bindAgents})});
1278
1331
  if(r.error){showToast(r.error,'danger');return}closeModal();showToast('已保存','success');renderPlatforms();
1279
1332
  }
1280
- async function doDeletePlatform(name){
1281
- showConfirm('删除平台','确认删除 "'+escHtml(name)+'" 吗?',async()=>{
1282
- const r=await api(`/api/platforms/${name}`,{method:'DELETE'});if(r.error){showToast(r.error,'danger');closeModal();return}closeModal();showToast('已删除','success');renderPlatforms();
1333
+ async function doDeletePlatform(id){
1334
+ showConfirm('删除平台','确认删除该平台实例吗?',async()=>{
1335
+ const r=await api(`/api/platforms/${id}`,{method:'DELETE'});if(r.error){showToast(r.error,'danger');closeModal();return}closeModal();showToast('已删除','success');renderPlatforms();
1283
1336
  });
1284
1337
  }
1285
1338
 
1339
+ // Agent 选择器(从部门树选择 Agent)
1340
+ async function showAgentSelectorForPlatform(inputId,containerId){
1341
+ try{
1342
+ var agents=await api('/api/agents');
1343
+ if(!Array.isArray(agents)){showToast('加载 Agent 列表失败','danger');return}
1344
+ var agentList=agents.filter(function(a){return!a.system&&a.path!=='default'});
1345
+ var deptMap={};
1346
+ agentList.forEach(function(a){
1347
+ var dept=a.department||'未分组';
1348
+ if(!deptMap[dept])deptMap[dept]=[];
1349
+ deptMap[dept].push(a);
1350
+ });
1351
+ var html='<div class="modal-overlay" onclick="closeModal()"><div class="modal" onclick="event.stopPropagation()" style="max-width:480px;max-height:70vh;overflow-y:auto">';
1352
+ html+='<h3>选择 Agent</h3>';
1353
+ for(var dept in deptMap){
1354
+ html+='<div style="margin-bottom:12px"><div style="font-size:13px;font-weight:600;color:var(--text2);margin-bottom:4px">📁 '+escHtml(dept)+'</div>';
1355
+ deptMap[dept].forEach(function(a){
1356
+ var selected=false;
1357
+ var existing=document.querySelectorAll('#'+containerId+' .selected-agent-tag');
1358
+ existing.forEach(function(el){if(el.getAttribute('data-agent')===a.path)selected=true;});
1359
+ html+='<label style="display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer;font-size:13px"><input type="checkbox" value="'+escHtml(a.path)+'" '+(selected?'checked':'')+' onchange="toggleAgentSelection(this,\''+inputId+'\',\''+containerId+'\')"> '+(a.avatar_emoji||'🤖')+' '+escHtml(a.name)+' <span style="color:var(--text3);font-size:11px">('+escHtml(a.path)+')</span></label>';
1360
+ });
1361
+ html+='</div>';
1362
+ }
1363
+ html+='<div class="flex gap-8 mt-16"><button class="btn btn-primary" onclick="closeModal()">确定</button></div></div></div>';
1364
+ $('modalContainer').innerHTML=html;
1365
+ }catch(e){showToast('加载失败: '+e.message,'danger')}
1366
+ }
1367
+ function toggleAgentSelection(cb,inputId,containerId){
1368
+ var agentPath=cb.value;
1369
+ var container=document.getElementById(containerId);
1370
+ if(cb.checked){
1371
+ // 添加 tag
1372
+ if(!container.querySelector('[data-agent="'+agentPath+'"]')){
1373
+ var tag=document.createElement('span');
1374
+ tag.className='selected-agent-tag';
1375
+ tag.setAttribute('data-agent',agentPath);
1376
+ tag.style.cssText='display:inline-block;background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:1px 6px;margin:2px;font-size:11px;cursor:pointer';
1377
+ tag.title='点击移除';
1378
+ tag.textContent=agentPath+' ×';
1379
+ tag.onclick=function(){this.remove();};
1380
+ container.appendChild(tag);
1381
+ }
1382
+ // 同步到输入框
1383
+ var first=container.querySelector('.selected-agent-tag');
1384
+ if(first)document.getElementById(inputId).value=first.getAttribute('data-agent');
1385
+ }else{
1386
+ var existing=container.querySelector('[data-agent="'+agentPath+'"]');
1387
+ if(existing)existing.remove();
1388
+ var first=container.querySelector('.selected-agent-tag');
1389
+ document.getElementById(inputId).value=first?first.getAttribute('data-agent'):'';
1390
+ }
1391
+ }
1392
+
1286
1393
  // ========== Sessions ==========
1287
1394
  async function renderSessions(){
1288
1395
  const ss=await api('/api/sessions');
@@ -2004,19 +2111,22 @@ async function viewSkillDetail(name){
2004
2111
  // ========== Files ==========
2005
2112
  var _workdirAgent=''; // 当前选中的 agent(空=全局工作目录)
2006
2113
  async function renderFiles(){
2007
- const [wd,agents]=await Promise.all([api('/api/workdir'),api('/api/agents').catch(()=>[])]);
2114
+ try{
2115
+ const [wd,agents]=await Promise.all([api('/api/workdir').catch(()=>({})),api('/api/agents').catch(()=>[])]);
2008
2116
  const agentList=Array.isArray(agents)?agents.filter(a=>!a.system&&a.path!=='default'):[];
2117
+ const wdPath=wd&&wd.path?wd.path:'';
2009
2118
  let html=`<div class="flex items-center gap-8 mb-16 flex-wrap">
2010
- <span style="font-size:14px;color:var(--text2)">📁 工作目录: <strong>${escHtml(wd.path||'')}</strong></span>
2119
+ <span style="font-size:14px;color:var(--text2)">📁 工作目录: <strong>${escHtml(wdPath)}</strong></span>
2011
2120
  <select id="workdirAgentSelect" onchange="onWorkdirAgentChange()" style="width:auto">
2012
2121
  <option value="">全局工作目录</option>
2013
- ${agentList.map(a=>`<option value="${escHtml(a.path)}">${escHtml((a.avatar_emoji||'🤖')+' '+a.name)} (${escHtml(a.path)})</option>`).join('')}
2122
+ ${agentList.map(a=>`<option value="${escHtml(a.path)}" ${_workdirAgent===a.path?'selected':''}>${escHtml((a.avatar_emoji||'🤖')+' '+a.name)} (${escHtml(a.path)})</option>`).join('')}
2014
2123
  </select>
2015
2124
  <button class="btn btn-sm btn-ghost" onclick="changeWorkdir()">更改全局</button>
2016
2125
  <button class="btn btn-sm btn-ghost" onclick="renderFiles()">🔄 刷新</button></div>
2017
2126
  <div id="workdirContent">加载中...</div>`;
2018
2127
  $('content').innerHTML=html;
2019
2128
  loadWorkdirContent('');
2129
+ }catch(e){console.error('renderFiles error:',e);$('content').innerHTML='<div class="empty" style="color:var(--danger)">加载失败: '+escHtml(e.message||'未知错误')+'</div>';}
2020
2130
  }
2021
2131
  function onWorkdirAgentChange(){
2022
2132
  _workdirAgent=$('workdirAgentSelect').value;