myagent-ai 1.20.7 → 1.20.9

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.
@@ -35,7 +35,9 @@ class ChatBotManager:
35
35
 
36
36
  def __init__(self):
37
37
  self._bots: Dict[str, BaseChatBot] = {}
38
+ self._bot_tasks: Dict[str, asyncio.Task] = {} # key -> asyncio.Task
38
39
  self._session_map: Dict[str, str] = {} # session_id -> last_message
40
+ self._message_handler: Optional[Callable] = None
39
41
 
40
42
  def get_bot(self, key: str):
41
43
  """根据 key (id 或 platform 名) 获取 bot 实例"""
@@ -49,20 +51,62 @@ class ChatBotManager:
49
51
  """
50
52
  初始化所有聊天平台。
51
53
 
54
+ [v1.20.7] 修复: 先停止并移除已不存在的/被禁用的 bot,
55
+ 再创建新启用的 bot。之前只做添加不做移除,导致禁用平台后
56
+ bot 仍在后台运行。
57
+
52
58
  Args:
53
59
  platform_configs: 平台配置列表
54
60
  message_handler: 统一消息处理回调
55
61
  """
62
+ self._message_handler = message_handler
63
+
64
+ # 计算当前应该启用的平台 key 集合
65
+ new_keys = set()
66
+ for cfg in platform_configs:
67
+ if cfg.enabled:
68
+ key = cfg.id or cfg.platform
69
+ new_keys.add(key)
70
+
71
+ # 找出需要移除的(旧的 key 不在新的 key 集合中)
72
+ removed_keys = [k for k in self._bots if k not in new_keys]
73
+ for key in removed_keys:
74
+ bot = self._bots.pop(key, None)
75
+ task = self._bot_tasks.pop(key, None)
76
+ if bot:
77
+ logger.info(f"聊天平台已移除(禁用/删除): {key}")
78
+ # 尝试停止 bot(同步包装异步)
79
+ try:
80
+ loop = asyncio.get_event_loop()
81
+ if loop.is_running():
82
+ asyncio.ensure_future(self._safe_stop(key, bot))
83
+ else:
84
+ loop.run_until_complete(bot.stop())
85
+ except RuntimeError:
86
+ pass
87
+ if task and not task.done():
88
+ task.cancel()
89
+ logger.info(f"聊天平台 {key} 后台任务已取消")
90
+
91
+ # 创建或更新启用的平台
56
92
  for cfg in platform_configs:
57
93
  if not cfg.enabled:
58
94
  continue
95
+ key = cfg.id or cfg.platform
96
+ # 如果已经存在且配置没变,跳过重建
97
+ if key in self._bots:
98
+ continue
59
99
  try:
60
100
  bot = self._create_bot(cfg, message_handler)
61
101
  if bot:
62
- # 使用唯一 id 作为 key,支持多实例
63
- key = cfg.id or cfg.platform
64
102
  self._bots[key] = bot
65
103
  logger.info(f"聊天平台已配置: {cfg.display_name or key}")
104
+ # 如果已经在运行中(start_all 已调用),自动启动新 bot
105
+ loop = asyncio.get_event_loop()
106
+ if loop.is_running() and not any(
107
+ t for t in asyncio.all_tasks(loop) if t.get_name() == f"bot_{key}"
108
+ ):
109
+ asyncio.ensure_future(self._run_bot(key, bot))
66
110
  except Exception as e:
67
111
  logger.error(f"平台 {cfg.display_name or cfg.platform} 初始化失败: {e}")
68
112
 
@@ -143,7 +187,8 @@ class ChatBotManager:
143
187
  tasks = []
144
188
  for name, bot in self._bots.items():
145
189
  logger.info(f"启动聊天平台: {name}")
146
- task = asyncio.create_task(self._run_bot(name, bot))
190
+ task = asyncio.create_task(self._run_bot(name, bot), name=f"bot_{name}")
191
+ self._bot_tasks[name] = task
147
192
  tasks.append(task)
148
193
  await asyncio.gather(*tasks, return_exceptions=True)
149
194
 
@@ -151,16 +196,39 @@ class ChatBotManager:
151
196
  """安全运行单个聊天平台"""
152
197
  try:
153
198
  await bot.start()
199
+ except asyncio.CancelledError:
200
+ logger.info(f"聊天平台 {name} 已取消")
154
201
  except Exception as e:
155
202
  logger.error(f"聊天平台 {name} 运行异常: {e}")
156
203
 
204
+ async def _safe_stop(self, name: str, bot: BaseChatBot):
205
+ """安全停止单个 bot(不抛异常)"""
206
+ try:
207
+ await bot.stop()
208
+ logger.info(f"聊天平台 {name} 已停止")
209
+ except Exception as e:
210
+ logger.warning(f"停止聊天平台 {name} 异常: {e}")
211
+
212
+ async def stop_platform(self, key: str) -> bool:
213
+ """[v1.20.7] 停止并移除单个聊天平台"""
214
+ bot = self._bots.pop(key, None)
215
+ task = self._bot_tasks.pop(key, None)
216
+ if not bot:
217
+ return False
218
+ await self._safe_stop(key, bot)
219
+ if task and not task.done():
220
+ task.cancel()
221
+ return True
222
+
157
223
  async def stop_all(self):
158
224
  """停止所有聊天平台"""
159
- for name, bot in self._bots.items():
225
+ for name, bot in list(self._bots.items()):
160
226
  try:
161
227
  await bot.stop()
162
228
  except Exception as e:
163
229
  logger.error(f"停止 {name} 失败: {e}")
230
+ self._bots.clear()
231
+ self._bot_tasks.clear()
164
232
  logger.info("所有聊天平台已停止")
165
233
 
166
234
  async def send_to_all(self, text: str):
package/config.py CHANGED
@@ -155,6 +155,7 @@ class AppConfig:
155
155
  data_dir: str = "" # 数据目录,默认 ~/.myagent/
156
156
  language: str = "zh-CN"
157
157
  timezone: str = "Asia/Shanghai" # 时区,用于生成时间戳和提示词中的当前时间
158
+ public_url: str = "" # [v1.20.8] 公开访问 URL(用于生成文件下载链接),如 http://your-server.com:8767
158
159
 
159
160
 
160
161
  # ==============================================================================
@@ -238,12 +238,29 @@ class ContextBuilder:
238
238
  f"描述: {safe_desc}",
239
239
  ]
240
240
 
241
+ # [v1.20.8] 注入工作目录信息,让 Agent 知道文件应保存到哪里
242
+ work_dir = self._get_workspace_dir()
243
+ if work_dir:
244
+ parts.append(f"工作目录: {_xml_escape(str(work_dir))}")
245
+ parts.append(f"文件保存说明: 通过 file_write 或代码生成的文件请保存到工作目录下的 userfiles 子目录。发送文件给用户请使用 file_send 工具。")
246
+
241
247
  if agent_override_prompt:
242
248
  parts.append(f"附加指令: {_xml_escape(agent_override_prompt)}")
243
249
 
244
250
  parts.append("</whomi>")
245
251
  return "\n".join(parts)
246
252
 
253
+ def _get_workspace_dir(self) -> Optional[str]:
254
+ """[v1.20.8] 获取工作目录路径(~/.myagent/data/workspace)"""
255
+ try:
256
+ from config import ConfigManager
257
+ cm = ConfigManager()
258
+ wd = cm.data_dir / "workspace"
259
+ wd.mkdir(parents=True, exist_ok=True)
260
+ return str(wd)
261
+ except Exception:
262
+ return None
263
+
247
264
  def _build_memory(self, query: str, session_id: str, recall: str = "", memory_context_prompt: str = "") -> str:
248
265
  """
249
266
  构建 <automemory> 和 <recall_memory> 段落 —— 双层记忆检索结果。
@@ -126,6 +126,17 @@ DEPENDENCIES: List[DepInfo] = [
126
126
  # ── 远程桌面 (VNC) [v1.17.0] ──
127
127
  # VNC 依赖通过 apt 安装 (xvfb, x11vnc),websockify 通过 pip 安装
128
128
  # 实际检测和安装在 VNCManager.ensure_dependencies() 中完成
129
+
130
+ # ── 系统工具 [v1.20.9] ──
131
+ # ffmpeg: SenseVoice 音频解码必需 (WAV→16kHz转换)
132
+ ]
133
+
134
+ # [v1.20.9] 系统级工具依赖列表(非 Python 包,需通过系统包管理器安装)
135
+ SYSTEM_TOOLS = [
136
+ {"name": "ffmpeg", "note": "FFmpeg 多媒体处理工具 (SenseVoice STT 必需, 用于音频格式转换)",
137
+ "install": {"linux": "sudo apt install ffmpeg", "macos": "brew install ffmpeg"}},
138
+ {"name": "convert", "note": "ImageMagick 图像处理工具 (VNC 截图备用)",
139
+ "install": {"linux": "sudo apt install imagemagick", "macos": "brew install imagemagick"}},
129
140
  ]
130
141
 
131
142
 
@@ -415,12 +415,30 @@ class WebControlManager:
415
415
  """
416
416
  向客户端下发命令并等待结果。
417
417
  Agent 工具调用线程阻塞在此, 直到客户端返回结果或超时。
418
+
419
+ [v1.20.9] 修复: 如果面板尚未打开(刚执行了 open 但客户端还没加载完成),
420
+ 会等待最多 15 秒让面板打开,而不是立即报错。
418
421
  """
419
422
  session = self.get_session(session_id)
420
423
  if not session:
421
424
  return {"success": False, "error": f"Session {session_id} not found or expired"}
425
+
426
+ # [v1.20.9] 等待面板打开(最多 15 秒)
422
427
  if not session.is_panel_open:
423
- return {"success": False, "error": "Panel is not open. Use 'open' action first."}
428
+ logger.info(f"[WebControl] 等待面板打开 (session: {session_id}, action: {action})")
429
+ waited = 0
430
+ while waited < 15:
431
+ await asyncio.sleep(1)
432
+ waited += 1
433
+ if session.is_panel_open:
434
+ logger.info(f"[WebControl] 面板已打开 (等待了 {waited}s)")
435
+ break
436
+ # 检查会话是否已关闭
437
+ if session._closed:
438
+ return {"success": False, "error": "Session closed while waiting for panel"}
439
+
440
+ if not session.is_panel_open:
441
+ return {"success": False, "error": "Panel is not open. Use 'open' action first. (waited 15s)"}
424
442
 
425
443
  cmd_id = uuid.uuid4().hex[:10]
426
444
  command = {
package/main.py CHANGED
@@ -183,6 +183,9 @@ class MyAgentApp:
183
183
 
184
184
  # 4. 执行引擎
185
185
  exe_cfg = self.config.executor
186
+ # [v1.20.8] 设置工作目录为 workspace,Agent 生成的文件默认保存到这里
187
+ workspace_dir = str(self.config_mgr.data_dir / "workspace")
188
+ os.makedirs(workspace_dir, exist_ok=True)
186
189
  self.executor = ExecutionEngine(
187
190
  timeout=exe_cfg.timeout,
188
191
  max_retries=exe_cfg.max_retries,
@@ -192,8 +195,9 @@ class MyAgentApp:
192
195
  sandbox_image=exe_cfg.sandbox_image,
193
196
  sandbox_network=exe_cfg.sandbox_network,
194
197
  sandbox_memory=exe_cfg.sandbox_memory,
198
+ work_dir=workspace_dir,
195
199
  )
196
- self.logger.info(f"执行引擎: timeout={exe_cfg.timeout}s, auto_fix={exe_cfg.auto_fix}")
200
+ self.logger.info(f"执行引擎: timeout={exe_cfg.timeout}s, auto_fix={exe_cfg.auto_fix}, work_dir={workspace_dir}")
197
201
 
198
202
  # 5. 技能系统
199
203
  self.skill_registry = SkillRegistry()
@@ -443,6 +447,8 @@ class MyAgentApp:
443
447
  """
444
448
  处理来自聊天平台的消息。
445
449
 
450
+ [v1.20.8] 修复: 发送到平台前,将相对路径的文件链接替换为绝对 URL。
451
+
446
452
  Args:
447
453
  message: 聊天消息
448
454
  bot: 发送消息的机器人实例
@@ -463,6 +469,10 @@ class MyAgentApp:
463
469
  # 处理消息
464
470
  response_text = await self.process_message(message.text, session_id)
465
471
 
472
+ # [v1.20.8] 将回复中的相对路径文件链接替换为绝对 URL
473
+ # (file_send 生成的 /api/file/xxx 链接在 Telegram/Discord 等外部平台无法访问)
474
+ response_text = self._fix_file_urls_for_platform(response_text)
475
+
466
476
  # 发送回复
467
477
  await bot.send_message(ChatResponse(
468
478
  chat_id=message.chat_id,
@@ -470,6 +480,42 @@ class MyAgentApp:
470
480
  text=response_text,
471
481
  ))
472
482
 
483
+ def _fix_file_urls_for_platform(self, text: str) -> str:
484
+ """[v1.20.8] 将文本中相对路径的文件下载链接替换为绝对 URL。
485
+
486
+ 匹配模式:
487
+ - /api/file/{file_id} → {public_url}/api/file/{file_id}
488
+ - /api/file/{file_id}/download → {public_url}/api/file/{file_id}/download
489
+ - (href="|src=")(/api/file/...) → 加上 public_url 前缀
490
+ """
491
+ import re
492
+ from skills.file_send import get_public_base_url
493
+
494
+ base_url = get_public_base_url()
495
+ if not base_url:
496
+ return text # 没有配置 public_url,不做替换
497
+
498
+ # 匹配 markdown 链接: [text](/api/file/...)
499
+ text = re.sub(
500
+ r'(\[([^\]]*)\]\()(/api/file/[^\)]+)(\))',
501
+ lambda m: f'{m.group(1)}{base_url}{m.group(3)}{m.group(4)}',
502
+ text,
503
+ )
504
+ # 匹配 HTML 属性: href="/api/file/..." 或 src="/api/file/..."
505
+ text = re.sub(
506
+ r'((?:href|src)=["\'])(/api/file/[^"\']*)(["\'])',
507
+ lambda m: f'{m.group(1)}{base_url}{m.group(2)}{m.group(3)}',
508
+ text,
509
+ )
510
+ # 匹配裸链接: /api/file/{12位ID}(可能后面有 /download)
511
+ text = re.sub(
512
+ r'(?<!["\(/a-zA-Z])(/api/file/[a-f0-9]{12}(?:/download)?)',
513
+ lambda m: f'{base_url}{m.group(1)}',
514
+ text,
515
+ )
516
+
517
+ return text
518
+
473
519
  async def run_cli(self):
474
520
  """运行交互式命令行"""
475
521
  if not self.main_agent:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.20.7",
3
+ "version": "1.20.9",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
@@ -2,6 +2,11 @@
2
2
  skills/file_send.py - Agent 文件发送工具
3
3
  ========================================
4
4
  让 Agent 可以向用户发送文件(图片、PDF、文档等)。
5
+
6
+ [v1.20.8] 修复:
7
+ - 新增 get_public_base_url() 函数,生成文件下载的绝对 URL
8
+ - 支持 MYAGENT_PUBLIC_URL 环境变量和 config.json 中 public_url 字段
9
+ - 平台消息中的文件链接可正确访问
5
10
  """
6
11
  import os
7
12
  import json
@@ -17,6 +22,42 @@ UPLOADS_DIR = Path(__file__).parent.parent / "data" / "uploads"
17
22
  UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
18
23
 
19
24
 
25
+ def get_public_base_url() -> str:
26
+ """[v1.20.8] 获取公开访问的基础 URL(带协议头)。
27
+
28
+ 优先级: 环境变量 MYAGENT_PUBLIC_URL > config.json public_url 字段 > 空(仅返回相对路径)
29
+ 用法示例: http://your-server.com:8767 或 https://your-domain.com
30
+
31
+ 结果会被缓存,避免重复读取配置文件。
32
+ """
33
+ # 缓存: 同一进程生命周期内只读取一次配置
34
+ if get_public_base_url._cached is not None:
35
+ return get_public_base_url._cached
36
+
37
+ # 1. 环境变量
38
+ env_url = os.environ.get("MYAGENT_PUBLIC_URL", "").strip().rstrip("/")
39
+ if env_url:
40
+ get_public_base_url._cached = env_url
41
+ return env_url
42
+
43
+ # 2. config.json
44
+ try:
45
+ from config import ConfigManager
46
+ cm = ConfigManager()
47
+ cfg = cm.config
48
+ public_url = getattr(cfg, "public_url", "").strip().rstrip("/")
49
+ if public_url:
50
+ get_public_base_url._cached = public_url
51
+ return public_url
52
+ except Exception:
53
+ pass
54
+
55
+ get_public_base_url._cached = ""
56
+ return ""
57
+
58
+ get_public_base_url._cached = None # type: ignore
59
+
60
+
20
61
  class FileSendSkill:
21
62
  """文件发送技能 — Agent 可通过此技能向用户发送文件"""
22
63
 
@@ -68,6 +109,7 @@ class FileSendSkill:
68
109
  mime = mime_map.get(fpath.suffix.lower(), "application/octet-stream")
69
110
  size = stored_path.stat().st_size
70
111
 
112
+ base_url = get_public_base_url()
71
113
  result = {
72
114
  "success": True,
73
115
  "file_id": file_id,
@@ -75,8 +117,8 @@ class FileSendSkill:
75
117
  "type": mime,
76
118
  "size": size,
77
119
  "description": description or f"文件: {fpath.name}",
78
- "url": f"/api/file/{file_id}",
79
- "download_url": f"/api/file/{file_id}/download",
120
+ "url": f"{base_url}/api/file/{file_id}",
121
+ "download_url": f"{base_url}/api/file/{file_id}/download",
80
122
  }
81
123
 
82
124
  # Send v2_file SSE event so frontend can display it
package/web/api_server.py CHANGED
@@ -280,16 +280,22 @@ class ApiServer:
280
280
  logger.info("通信管理器已热更新")
281
281
 
282
282
  def _hot_reload_chat_platforms(self):
283
- """热更新聊天平台:重新加载所有平台配置到 ChatBotManager"""
283
+ """热更新聊天平台:重新加载所有平台配置到 ChatBotManager
284
+
285
+ [v1.20.7] 修复: 调用 setup_platforms 时会自动停止已禁用的平台并启动新启用的平台。
286
+ [v1.20.9] 修复: chat_manager 为 None 时懒创建,修复首次启用平台不生效的问题。
287
+ """
288
+ if not self.core.chat_manager:
289
+ from chatbot.manager import ChatBotManager
290
+ self.core.chat_manager = ChatBotManager()
291
+ logger.info("聊天平台管理器已懒创建")
284
292
  mgr = self.core.chat_manager
285
- if not mgr:
286
- return
287
- # 重新设置平台(会重建bot实例)
288
293
  platform_configs = self.core.config_mgr.config.chat_platforms
289
- # 停止旧的平台
290
- # 注意:这里不直接调用 async stop_all,仅更新配置引用
291
- # 实际的平台重启需要异步操作,在 handle_restart_platform 中处理
292
- mgr.setup_platforms(platform_configs, mgr._message_handler if hasattr(mgr, '_message_handler') else None)
294
+ # setup_platforms 会自动对比新旧配置,停止被移除/禁用的 bot,启动新启用的 bot
295
+ handler = mgr._message_handler if hasattr(mgr, '_message_handler') else None
296
+ if not handler and hasattr(self.core, '_handle_chat_message'):
297
+ handler = self.core._handle_chat_message
298
+ mgr.setup_platforms(platform_configs, handler)
293
299
  logger.info("聊天平台配置已热更新")
294
300
 
295
301
  def _setup_routes(self):
@@ -486,6 +492,8 @@ class ApiServer:
486
492
  r.add_post("/api/vnc/restart", self.handle_vnc_restart)
487
493
  r.add_get("/api/vnc/screenshot", self.handle_vnc_screenshot)
488
494
  r.add_get("/vnc/{path:.*}", self.handle_novnc_proxy) # noVNC 客户端页面代理
495
+ # [v1.20.8] VNC WebSocket 代理 — 通过主服务器转发 VNC WebSocket,解决 HTTPS/WSS 问题
496
+ r.add_get("/api/vnc/ws", self.handle_vnc_websocket)
489
497
  # [v1.21.0] Web Control API
490
498
  r.add_get("/api/web_control/status", self.handle_wc_status)
491
499
  r.add_post("/api/web_control/create", self.handle_wc_create_session)
@@ -806,20 +814,85 @@ class ApiServer:
806
814
 
807
815
  # 方法3: 对于 WebSocket 连接,代理到 websockify
808
816
  if path == 'websockify' or 'ws' in path:
809
- # WebSocket 代理由 websockify 处理,告诉客户端连接地址
817
+ # [v1.20.8] 返回代理 WebSocket URL(通过主服务器转发)
818
+ ws_scheme = 'wss' if request.scheme == 'https' else 'ws'
810
819
  return web.json_response({
811
- "ws_url": f"ws://{request.host.split(':')[0]}:{mgr.novnc_port}"
820
+ "ws_url": f"{ws_scheme}://{request.host}/api/vnc/ws"
812
821
  })
813
822
 
814
823
  return web.Response(status=404, text="Not found")
815
824
 
825
+ async def handle_vnc_websocket(self, request):
826
+ """[v1.20.8] GET /api/vnc/ws - VNC WebSocket 代理
827
+
828
+ 通过主服务器的 HTTP/HTTPS 通道代理 VNC WebSocket 连接。
829
+ 解决 HTTPS 页面无法连接 ws://localhost:6080 的问题。
830
+
831
+ 浏览器 ←(wss://host/api/vnc/ws)→ aiohttp代理 ←(ws://localhost:6080)→ websockify ←(VNC)→ x11vnc
832
+ """
833
+ import aiohttp
834
+
835
+ mgr = self._get_vnc_manager()
836
+ if not mgr.is_running:
837
+ return web.Response(status=503, text="VNC 服务未运行")
838
+
839
+ # 准备 WebSocket upgrade
840
+ ws_server = web.WebSocketResponse()
841
+ await ws_server.prepare(request)
842
+
843
+ # 连接到后端 websockify
844
+ target_url = f"ws://127.0.0.1:{mgr.novnc_port}"
845
+ try:
846
+ async with aiohttp.ClientSession() as session:
847
+ async with session.ws_connect(target_url) as ws_backend:
848
+ # 双向转发: 客户端 ↔ 后端 websockify
849
+ async def client_to_backend():
850
+ async for msg in ws_server:
851
+ if msg.type == aiohttp.WSMsgType.TEXT:
852
+ await ws_backend.send_str(msg.data)
853
+ elif msg.type == aiohttp.WSMsgType.BINARY:
854
+ await ws_backend.send_bytes(msg.data)
855
+ elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
856
+ await ws_backend.close()
857
+ break
858
+ elif msg.type == aiohttp.WSMsgType.ERROR:
859
+ break
860
+
861
+ async def backend_to_client():
862
+ async for msg in ws_backend:
863
+ if msg.type == aiohttp.WSMsgType.TEXT:
864
+ await ws_server.send_str(msg.data)
865
+ elif msg.type == aiohttp.WSMsgType.BINARY:
866
+ await ws_server.send_bytes(msg.data)
867
+ elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
868
+ await ws_server.close()
869
+ break
870
+ elif msg.type == aiohttp.WSMsgType.ERROR:
871
+ break
872
+
873
+ await asyncio.gather(
874
+ client_to_backend(),
875
+ backend_to_client(),
876
+ return_exceptions=True,
877
+ )
878
+ except Exception as e:
879
+ logger.warning(f"VNC WebSocket 代理错误: {e}")
880
+ finally:
881
+ try:
882
+ await ws_server.close()
883
+ except Exception:
884
+ pass
885
+
886
+ return ws_server
887
+
816
888
  def _get_builtin_novnc_page(self, request):
817
889
  """生成内置的轻量 noVNC 客户端页面
818
890
 
819
891
  使用 noVNC 的 CDN 版本,连接到本地 websockify 代理。
892
+ [v1.20.8] 通过 /api/vnc/ws 代理连接,解决 HTTPS/WSS 兼容问题。
893
+ [v1.20.9] 添加 CDN 加载超时检测,加载失败时提示用户。
820
894
  """
821
- # 获取 WebSocket URL
822
- host = request.host.split(':')[0]
895
+ # 获取 WebSocket URL — [v1.20.8] 优先使用代理路径
823
896
  ws_port = self._get_vnc_manager().novnc_port
824
897
 
825
898
  return f"""<!DOCTYPE html>
@@ -837,11 +910,14 @@ class ApiServer:
837
910
  .toolbar-btn {{ padding: 4px 12px; border-radius: 4px; border: 1px solid #0f3460; background: #0f3460; color: #eee; cursor: pointer; font-size: 12px; }}
838
911
  .toolbar-btn:hover {{ background: #1a1a5e; }}
839
912
  #screen {{ width: 100%; height: calc(100% - 44px); }}
840
- .status {{ position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; }}
913
+ .status {{ position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; max-width: 500px; }}
841
914
  .status h2 {{ margin-bottom: 12px; color: #e94560; }}
842
- .status p {{ color: #888; margin-bottom: 16px; font-size: 14px; }}
915
+ .status p {{ color: #888; margin-bottom: 16px; font-size: 14px; line-height: 1.6; }}
843
916
  .connecting {{ color: #ffd700; }}
844
917
  .connected {{ color: #00ff88; }}
918
+ .error {{ color: #ff4444; }}
919
+ .spinner {{ display: inline-block; width: 24px; height: 24px; border: 3px solid #333; border-top-color: #ffd700; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 12px; }}
920
+ @keyframes spin {{ to {{ transform: rotate(360deg); }} }}
845
921
  </style>
846
922
  </head>
847
923
  <body>
@@ -859,113 +935,215 @@ class ApiServer:
859
935
  </div>
860
936
  <div id="screen">
861
937
  <div class="status" id="statusOverlay">
938
+ <div class="spinner" id="statusSpinner"></div>
862
939
  <h2 id="statusTitle" class="connecting">正在连接远程桌面...</h2>
863
- <p id="statusMsg">请稍候</p>
864
- <button class="toolbar-btn" onclick="location.reload()">重新连接</button>
940
+ <p id="statusMsg">正在加载 VNC 客户端组件...</p>
941
+ <div id="statusActions" style="display:none">
942
+ <button class="toolbar-btn" onclick="location.reload()" style="margin-right:8px">重新连接</button>
943
+ <button class="toolbar-btn" onclick="tryDirectConnect()" style="margin-right:8px">尝试直连</button>
944
+ </div>
865
945
  </div>
866
946
  </div>
867
947
 
868
- <!-- noVNC from CDN -->
948
+ <!-- [v1.20.9] CDN 加载超时检测 -->
949
+ <script>
950
+ var _vncLoaded = false;
951
+ var _vncLoadTimeout = setTimeout(function() {{
952
+ if (!_vncLoaded) {{
953
+ document.getElementById('statusSpinner').style.display = 'none';
954
+ document.getElementById('statusTitle').className = 'error';
955
+ document.getElementById('statusTitle').textContent = 'VNC 组件加载超时';
956
+ document.getElementById('statusMsg').innerHTML =
957
+ '无法从 CDN 加载 noVNC 组件(可能网络不通)。<br>' +
958
+ '请检查网络连接,或尝试直连模式。';
959
+ document.getElementById('statusActions').style.display = '';
960
+ }}
961
+ }}, 15000); // 15秒超时
962
+ </script>
963
+
964
+ <!-- noVNC from CDN — 多源回退 -->
869
965
  <script type="module">
870
- import RFB from 'https://cdn.jsdelivr.net/npm/@novnc/novnc@1.5.0/core/rfb.js';
871
-
872
- const host = window.location.hostname;
873
- const wsPort = {ws_port};
874
- const url = 'ws://' + host + ':' + wsPort;
875
-
876
- let rfb = null;
877
- let isConnected = false;
878
-
879
- function updateStatus(connected, title, msg) {{
880
- const titleEl = document.getElementById('statusTitle');
881
- const msgEl = document.getElementById('statusMsg');
882
- const overlay = document.getElementById('statusOverlay');
883
- const textEl = document.getElementById('statusText');
884
-
885
- if (connected) {{
886
- overlay.style.display = 'none';
887
- titleEl.className = 'connected';
888
- textEl.textContent = '已连接';
889
- isConnected = true;
890
- }} else {{
891
- overlay.style.display = '';
892
- titleEl.className = 'connecting';
893
- titleEl.textContent = title || '连接中断';
894
- msgEl.textContent = msg || '';
895
- textEl.textContent = '已断开';
896
- isConnected = false;
966
+ try {{
967
+ // 尝试多个 CDN 源
968
+ const CDN_SOURCES = [
969
+ 'https://cdn.jsdelivr.net/npm/@novnc/novnc@1.5.0/core/rfb.js',
970
+ 'https://unpkg.com/@novnc/novnc@1.5.0/core/rfb.js',
971
+ 'https://esm.sh/@novnc/novnc@1.5.0/core/rfb.js',
972
+ ];
973
+ let RFB = null;
974
+ let lastError = '';
975
+
976
+ for (const src of CDN_SOURCES) {{
977
+ try {{
978
+ const mod = await import(src);
979
+ RFB = mod.default || mod.RFB;
980
+ if (RFB) break;
981
+ }} catch (e) {{
982
+ lastError = e.message;
983
+ continue;
984
+ }}
985
+ }}
986
+
987
+ if (!RFB) {{
988
+ throw new Error('所有 CDN 源均加载失败: ' + lastError);
897
989
  }}
990
+
991
+ _vncLoaded = true;
992
+ clearTimeout(_vncLoadTimeout);
993
+ initVNC(RFB);
994
+ }} catch (e) {{
995
+ clearTimeout(_vncLoadTimeout);
996
+ document.getElementById('statusSpinner').style.display = 'none';
997
+ document.getElementById('statusTitle').className = 'error';
998
+ document.getElementById('statusTitle').textContent = 'VNC 组件加载失败';
999
+ document.getElementById('statusMsg').innerHTML =
1000
+ 'noVNC 组件加载失败: ' + e.message + '<br>' +
1001
+ '请检查网络连接后刷新页面重试。';
1002
+ document.getElementById('statusActions').style.display = '';
1003
+ console.error('[VNC] 组件加载失败:', e);
898
1004
  }}
899
1005
 
900
- function connect() {{
901
- try {{
902
- rfb = new RFB(document.getElementById('screen'), url, {{
903
- credentials: {{ password: '' }},
904
- shared: true,
905
- wsProtocols: ['binary'],
906
- }});
907
-
908
- rfb.scaleViewport = true;
909
- rfb.resizeSession = false;
910
- rfb.background = '#1a1a2e';
911
- rfb.qualityLevel = 6;
912
- rfb.compressionLevel = 2;
913
-
914
- rfb.addEventListener('connect', function() {{
915
- updateStatus(true, '', '');
916
- }});
917
-
918
- rfb.addEventListener('disconnect', function(e) {{
919
- const clean = e.detail.clean;
920
- if (clean) {{
921
- updateStatus(false, '连接已关闭', '远程桌面服务已停止');
922
- }} else {{
923
- updateStatus(false, '连接中断', '正在尝试重新连接...');
924
- setTimeout(connect, 3000);
925
- }}
926
- }});
927
-
928
- rfb.addEventListener('credentialsrequired', function() {{
929
- // 无需密码
930
- }});
931
-
932
- rfb.addEventListener('desktopname', function(e) {{
933
- document.title = e.detail.name + ' - MyAgent';
934
- }});
935
-
936
- }} catch (e) {{
937
- updateStatus(false, '连接失败', '无法连接到 ' + url + '<br>错误: ' + e.message);
1006
+ function initVNC(RFBClass) {{
1007
+ const host = window.location.hostname;
1008
+ const wsPort = {ws_port};
1009
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1010
+ const proxyUrl = wsProtocol + '//' + window.location.host + '/api/vnc/ws';
1011
+ const directUrl = 'ws://' + host + ':' + wsPort;
1012
+
1013
+ let rfb = null;
1014
+ let isConnected = false;
1015
+ let useProxy = true;
1016
+
1017
+ function updateStatus(connected, title, msg) {{
1018
+ const titleEl = document.getElementById('statusTitle');
1019
+ const msgEl = document.getElementById('statusMsg');
1020
+ const overlay = document.getElementById('statusOverlay');
1021
+ const spinner = document.getElementById('statusSpinner');
1022
+ const actions = document.getElementById('statusActions');
1023
+ const textEl = document.getElementById('statusText');
1024
+
1025
+ if (connected) {{
1026
+ overlay.style.display = 'none';
1027
+ titleEl.className = 'connected';
1028
+ textEl.textContent = '已连接';
1029
+ isConnected = true;
1030
+ }} else {{
1031
+ overlay.style.display = '';
1032
+ spinner.style.display = '';
1033
+ actions.style.display = 'none';
1034
+ titleEl.className = 'connecting';
1035
+ titleEl.textContent = title || '连接中断';
1036
+ msgEl.textContent = msg || '';
1037
+ textEl.textContent = '已断开';
1038
+ isConnected = false;
1039
+ }}
938
1040
  }}
1041
+
1042
+ function getConnectUrl() {{
1043
+ return useProxy ? proxyUrl : directUrl;
1044
+ }}
1045
+
1046
+ function connect() {{
1047
+ const connUrl = getConnectUrl();
1048
+ try {{
1049
+ document.getElementById('statusMsg').textContent =
1050
+ (useProxy ? '通过代理连接: ' + connUrl : '直连: ' + connUrl);
1051
+ rfb = new RFBClass(document.getElementById('screen'), connUrl, {{
1052
+ credentials: {{ password: '' }},
1053
+ shared: true,
1054
+ wsProtocols: ['binary'],
1055
+ }});
1056
+
1057
+ rfb.scaleViewport = true;
1058
+ rfb.resizeSession = false;
1059
+ rfb.background = '#1a1a2e';
1060
+ rfb.qualityLevel = 6;
1061
+ rfb.compressionLevel = 2;
1062
+
1063
+ rfb.addEventListener('connect', function() {{
1064
+ updateStatus(true, '', '');
1065
+ }});
1066
+
1067
+ rfb.addEventListener('disconnect', function(e) {{
1068
+ const clean = e.detail.clean;
1069
+ if (clean) {{
1070
+ updateStatus(false, '连接已关闭', '远程桌面服务已停止');
1071
+ document.getElementById('statusSpinner').style.display = 'none';
1072
+ document.getElementById('statusActions').style.display = '';
1073
+ }} else {{
1074
+ if (useProxy) {{
1075
+ useProxy = false;
1076
+ updateStatus(false, '代理连接失败,尝试直连...', '');
1077
+ setTimeout(connect, 1000);
1078
+ }} else {{
1079
+ updateStatus(false, '连接中断', '5秒后重新连接...');
1080
+ setTimeout(connect, 5000);
1081
+ }}
1082
+ }}
1083
+ }});
1084
+
1085
+ rfb.addEventListener('credentialsrequired', function() {{}});
1086
+
1087
+ rfb.addEventListener('desktopname', function(e) {{
1088
+ document.title = e.detail.name + ' - MyAgent';
1089
+ }});
1090
+
1091
+ // [v1.20.9] 连接建立超时检测(30秒)
1092
+ setTimeout(function() {{
1093
+ if (!isConnected && rfb) {{
1094
+ if (useProxy) {{
1095
+ useProxy = false;
1096
+ rfb.disconnect();
1097
+ updateStatus(false, '代理超时,切换直连...', '');
1098
+ setTimeout(connect, 500);
1099
+ }}
1100
+ }}
1101
+ }}, 30000);
1102
+
1103
+ }} catch (e) {{
1104
+ updateStatus(false, '连接失败', '无法连接到 ' + connUrl + ' 错误: ' + e.message);
1105
+ document.getElementById('statusSpinner').style.display = 'none';
1106
+ document.getElementById('statusActions').style.display = '';
1107
+ }}
1108
+ }}
1109
+
1110
+ // 暴露给全局
1111
+ window.rfb = rfb;
1112
+ window._vncConnect = connect;
1113
+ window.sendCtrlAltDel = function() {{
1114
+ if (rfb) rfb.sendCtrlAltDel();
1115
+ }};
1116
+ window.takeScreenshot = function() {{
1117
+ fetch('/api/vnc/screenshot').then(r => {{
1118
+ if (r.ok) return r.blob();
1119
+ throw new Error('截图失败');
1120
+ }}).then(blob => {{
1121
+ const url = URL.createObjectURL(blob);
1122
+ const a = document.createElement('a');
1123
+ a.href = url;
1124
+ a.download = 'desktop_' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
1125
+ a.click();
1126
+ URL.revokeObjectURL(url);
1127
+ }}).catch(e => alert('截图失败: ' + e.message));
1128
+ }};
1129
+ window.toggleFullscreen = function() {{
1130
+ if (!document.fullscreenElement) {{
1131
+ document.documentElement.requestFullscreen();
1132
+ }} else {{
1133
+ document.exitFullscreen();
1134
+ }}
1135
+ }};
1136
+
1137
+ connect();
939
1138
  }}
940
1139
 
941
- connect();
942
-
943
- // 暴露给全局
944
- window.rfb = rfb;
945
- window.sendCtrlAltDel = function() {{
946
- if (rfb) rfb.sendCtrlAltDel();
947
- }};
948
- window.takeScreenshot = function() {{
949
- // 下载当前屏幕截图
950
- fetch('/api/vnc/screenshot').then(r => {{
951
- if (r.ok) return r.blob();
952
- throw new Error('截图失败');
953
- }}).then(blob => {{
954
- const url = URL.createObjectURL(blob);
955
- const a = document.createElement('a');
956
- a.href = url;
957
- a.download = 'desktop_' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
958
- a.click();
959
- URL.revokeObjectURL(url);
960
- }}).catch(e => alert('截图失败: ' + e.message));
961
- }};
962
- window.toggleFullscreen = function() {{
963
- if (!document.fullscreenElement) {{
964
- document.documentElement.requestFullscreen();
965
- }} else {{
966
- document.exitFullscreen();
1140
+ // 直连按钮
1141
+ function tryDirectConnect() {{
1142
+ if (window._vncConnect) {{
1143
+ window._vncUseProxy = false;
1144
+ location.reload();
967
1145
  }}
968
- }};
1146
+ }}
969
1147
  </script>
970
1148
  </body>
971
1149
  </html>"""
@@ -2134,7 +2312,15 @@ window.addEventListener('beforeunload', function() {{
2134
2312
  except ImportError:
2135
2313
  logger.debug("SenseVoice (funasr) 未安装,跳过。安装: pip install funasr torch torchaudio")
2136
2314
  except Exception as e:
2137
- logger.warning(f"SenseVoice 转录失败: {e}")
2315
+ err_str = str(e)
2316
+ if "ffmpeg" in err_str.lower() or "No such file" in err_str:
2317
+ logger.warning(f"SenseVoice 转录失败 (缺少 ffmpeg): {e}")
2318
+ # [v1.20.9] 检测 ffmpeg 并给出安装提示
2319
+ import shutil
2320
+ if not shutil.which("ffmpeg"):
2321
+ logger.warning("⚠ 系统缺少 ffmpeg,SenseVoice 无法转换音频格式。安装: sudo apt install ffmpeg 或 brew install ffmpeg")
2322
+ else:
2323
+ logger.warning(f"SenseVoice 转录失败: {e}")
2138
2324
 
2139
2325
  # ── 尝试 vosk ──
2140
2326
  try:
@@ -3639,7 +3825,7 @@ window.addEventListener('beforeunload', function() {{
3639
3825
  status = "unknown"
3640
3826
 
3641
3827
  # 从 bot 实例获取 QR 码
3642
- bot = self.core.chatbot_manager.get_bot(cp.id or cp.platform)
3828
+ bot = self.core.chat_manager.get_bot(cp.id or cp.platform) if self.core.chat_manager else None
3643
3829
  if bot and hasattr(bot, 'get_qr_code'):
3644
3830
  qr_code = bot.get_qr_code() or ""
3645
3831
  if bot and hasattr(bot, '_connected'):
@@ -3676,7 +3862,7 @@ window.addEventListener('beforeunload', function() {{
3676
3862
  if cp.platform not in ("whatsapp", "wechat"):
3677
3863
  return web.json_response({"error": f"{cp.platform} 不支持 QR 码绑定"}, status=400)
3678
3864
 
3679
- bot = self.core.chatbot_manager.get_bot(cp.id or cp.platform)
3865
+ bot = self.core.chat_manager.get_bot(cp.id or cp.platform) if self.core.chat_manager else None
3680
3866
  if not bot:
3681
3867
  return web.json_response({"error": "Bot 实例未创建"}, status=400)
3682
3868
 
@@ -1258,7 +1258,8 @@ function renderMasterAgentCard() {
1258
1258
  var color = agent ? (agent.avatar_color || 'linear-gradient(135deg,#6366f1,#8b5cf6)') : 'linear-gradient(135deg,#6366f1,#8b5cf6)';
1259
1259
  var bgStyle = color.includes('gradient') ? 'background:' + color : '';
1260
1260
  var bgClass = color.includes('gradient') ? '' : getAgentColorClass('default');
1261
- var avatarContent = (agent && agent.avatar_image) ? '<img src="' + escapeHtml(agent.avatar_image) + '" style="width:100%;height:100%;object-fit:cover;border-radius:12px">' : emoji;
1261
+ // [v1.20.9] 修复: avatar_image 不为空时显示图片,onerror 回退到 emoji
1262
+ var avatarContent = (agent && agent.avatar_image) ? '<img src="' + escapeHtml(agent.avatar_image) + '" style="width:100%;height:100%;object-fit:cover;border-radius:12px" onerror="this.outerHTML=\'' + escapeHtml(emoji) + '\'">' : emoji;
1262
1263
 
1263
1264
  el.innerHTML = '<div class="rp-master-card ' + (isActive ? 'active' : '') + '" onclick="selectAgent(\'default\')">'
1264
1265
  + '<div class="rp-master-avatar ' + bgClass + '" style="' + bgStyle + '">' + avatarContent + '</div>'
@@ -1559,9 +1560,15 @@ function updateSidebarAgentIndicator() {
1559
1560
  return;
1560
1561
  }
1561
1562
  indicator.style.display = 'flex';
1562
- var emoji = agent.avatar_emoji || '';
1563
- avatar.textContent = emoji || getAgentInitials(agent.name);
1564
- avatar.style.background = agent.avatar_color || getAgentGradient(agent.name);
1563
+ // [v1.20.9] 优先显示上传的头像图片
1564
+ if (agent.avatar_image) {
1565
+ avatar.innerHTML = '<img src="' + escapeHtml(agent.avatar_image) + '" style="width:100%;height:100%;object-fit:cover;border-radius:6px" onerror="this.outerHTML=\'' + escapeHtml(agent.avatar_emoji || getAgentInitials(agent.name)) + '\'">';
1566
+ avatar.style.background = 'transparent';
1567
+ } else {
1568
+ var emoji = agent.avatar_emoji || '';
1569
+ avatar.textContent = emoji || getAgentInitials(agent.name);
1570
+ avatar.style.background = agent.avatar_color || getAgentGradient(agent.name);
1571
+ }
1565
1572
  nameEl.textContent = agent.name || agent.path;
1566
1573
  hintEl.textContent = '的对话列表 (' + state.sessions.length + ')';
1567
1574
  }
@@ -1572,18 +1579,24 @@ function updateActiveAgentBadge() {
1572
1579
  var nameEl = document.getElementById('badgeName');
1573
1580
  var agent = findAgentByPath(state.activeAgent);
1574
1581
  if (agent) {
1575
- var emoji = agent.avatar_emoji || '';
1576
- avatar.textContent = emoji || getAgentInitials(agent.name);
1577
- // Use gradient for consistency with sidebar agent avatars
1578
- if (agent.avatar_color && !agent.avatar_color.includes('gradient')) {
1579
- avatar.style.background = agent.avatar_color;
1580
- avatar.className = 'badge-avatar';
1581
- } else if (agent.avatar_color) {
1582
- avatar.style.background = '';
1582
+ // [v1.20.9] 优先显示上传的头像图片
1583
+ if (agent.avatar_image) {
1584
+ avatar.innerHTML = '<img src="' + escapeHtml(agent.avatar_image) + '" style="width:100%;height:100%;object-fit:cover;border-radius:50%" onerror="this.outerHTML=\'' + escapeHtml(agent.avatar_emoji || getAgentInitials(agent.name)) + '\'">';
1585
+ avatar.style.background = 'transparent';
1583
1586
  avatar.className = 'badge-avatar';
1584
1587
  } else {
1585
- avatar.style.background = '';
1586
- avatar.className = 'badge-avatar ' + getAgentColorClass(agent.name);
1588
+ var emoji = agent.avatar_emoji || '';
1589
+ avatar.textContent = emoji || getAgentInitials(agent.name);
1590
+ if (agent.avatar_color && !agent.avatar_color.includes('gradient')) {
1591
+ avatar.style.background = agent.avatar_color;
1592
+ avatar.className = 'badge-avatar';
1593
+ } else if (agent.avatar_color) {
1594
+ avatar.style.background = '';
1595
+ avatar.className = 'badge-avatar';
1596
+ } else {
1597
+ avatar.style.background = '';
1598
+ avatar.className = 'badge-avatar ' + getAgentColorClass(agent.name);
1599
+ }
1587
1600
  }
1588
1601
  avatar.style.borderRadius = '50%';
1589
1602
  nameEl.textContent = agent.name || agent.path;