myagent-ai 1.20.8 → 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.
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.8",
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
@@ -283,16 +283,19 @@ class ApiServer:
283
283
  """热更新聊天平台:重新加载所有平台配置到 ChatBotManager
284
284
 
285
285
  [v1.20.7] 修复: 调用 setup_platforms 时会自动停止已禁用的平台并启动新启用的平台。
286
+ [v1.20.9] 修复: chat_manager 为 None 时懒创建,修复首次启用平台不生效的问题。
286
287
  """
288
+ if not self.core.chat_manager:
289
+ from chatbot.manager import ChatBotManager
290
+ self.core.chat_manager = ChatBotManager()
291
+ logger.info("聊天平台管理器已懒创建")
287
292
  mgr = self.core.chat_manager
288
- if not mgr:
289
- return
290
293
  platform_configs = self.core.config_mgr.config.chat_platforms
291
294
  # setup_platforms 会自动对比新旧配置,停止被移除/禁用的 bot,启动新启用的 bot
292
- mgr.setup_platforms(
293
- platform_configs,
294
- mgr._message_handler if hasattr(mgr, '_message_handler') else None
295
- )
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)
296
299
  logger.info("聊天平台配置已热更新")
297
300
 
298
301
  def _setup_routes(self):
@@ -489,6 +492,8 @@ class ApiServer:
489
492
  r.add_post("/api/vnc/restart", self.handle_vnc_restart)
490
493
  r.add_get("/api/vnc/screenshot", self.handle_vnc_screenshot)
491
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)
492
497
  # [v1.21.0] Web Control API
493
498
  r.add_get("/api/web_control/status", self.handle_wc_status)
494
499
  r.add_post("/api/web_control/create", self.handle_wc_create_session)
@@ -809,20 +814,85 @@ class ApiServer:
809
814
 
810
815
  # 方法3: 对于 WebSocket 连接,代理到 websockify
811
816
  if path == 'websockify' or 'ws' in path:
812
- # WebSocket 代理由 websockify 处理,告诉客户端连接地址
817
+ # [v1.20.8] 返回代理 WebSocket URL(通过主服务器转发)
818
+ ws_scheme = 'wss' if request.scheme == 'https' else 'ws'
813
819
  return web.json_response({
814
- "ws_url": f"ws://{request.host.split(':')[0]}:{mgr.novnc_port}"
820
+ "ws_url": f"{ws_scheme}://{request.host}/api/vnc/ws"
815
821
  })
816
822
 
817
823
  return web.Response(status=404, text="Not found")
818
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
+
819
888
  def _get_builtin_novnc_page(self, request):
820
889
  """生成内置的轻量 noVNC 客户端页面
821
890
 
822
891
  使用 noVNC 的 CDN 版本,连接到本地 websockify 代理。
892
+ [v1.20.8] 通过 /api/vnc/ws 代理连接,解决 HTTPS/WSS 兼容问题。
893
+ [v1.20.9] 添加 CDN 加载超时检测,加载失败时提示用户。
823
894
  """
824
- # 获取 WebSocket URL
825
- host = request.host.split(':')[0]
895
+ # 获取 WebSocket URL — [v1.20.8] 优先使用代理路径
826
896
  ws_port = self._get_vnc_manager().novnc_port
827
897
 
828
898
  return f"""<!DOCTYPE html>
@@ -840,11 +910,14 @@ class ApiServer:
840
910
  .toolbar-btn {{ padding: 4px 12px; border-radius: 4px; border: 1px solid #0f3460; background: #0f3460; color: #eee; cursor: pointer; font-size: 12px; }}
841
911
  .toolbar-btn:hover {{ background: #1a1a5e; }}
842
912
  #screen {{ width: 100%; height: calc(100% - 44px); }}
843
- .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; }}
844
914
  .status h2 {{ margin-bottom: 12px; color: #e94560; }}
845
- .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; }}
846
916
  .connecting {{ color: #ffd700; }}
847
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); }} }}
848
921
  </style>
849
922
  </head>
850
923
  <body>
@@ -862,113 +935,215 @@ class ApiServer:
862
935
  </div>
863
936
  <div id="screen">
864
937
  <div class="status" id="statusOverlay">
938
+ <div class="spinner" id="statusSpinner"></div>
865
939
  <h2 id="statusTitle" class="connecting">正在连接远程桌面...</h2>
866
- <p id="statusMsg">请稍候</p>
867
- <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>
868
945
  </div>
869
946
  </div>
870
947
 
871
- <!-- 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 — 多源回退 -->
872
965
  <script type="module">
873
- import RFB from 'https://cdn.jsdelivr.net/npm/@novnc/novnc@1.5.0/core/rfb.js';
874
-
875
- const host = window.location.hostname;
876
- const wsPort = {ws_port};
877
- const url = 'ws://' + host + ':' + wsPort;
878
-
879
- let rfb = null;
880
- let isConnected = false;
881
-
882
- function updateStatus(connected, title, msg) {{
883
- const titleEl = document.getElementById('statusTitle');
884
- const msgEl = document.getElementById('statusMsg');
885
- const overlay = document.getElementById('statusOverlay');
886
- const textEl = document.getElementById('statusText');
887
-
888
- if (connected) {{
889
- overlay.style.display = 'none';
890
- titleEl.className = 'connected';
891
- textEl.textContent = '已连接';
892
- isConnected = true;
893
- }} else {{
894
- overlay.style.display = '';
895
- titleEl.className = 'connecting';
896
- titleEl.textContent = title || '连接中断';
897
- msgEl.textContent = msg || '';
898
- textEl.textContent = '已断开';
899
- 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);
900
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);
901
1004
  }}
902
1005
 
903
- function connect() {{
904
- try {{
905
- rfb = new RFB(document.getElementById('screen'), url, {{
906
- credentials: {{ password: '' }},
907
- shared: true,
908
- wsProtocols: ['binary'],
909
- }});
910
-
911
- rfb.scaleViewport = true;
912
- rfb.resizeSession = false;
913
- rfb.background = '#1a1a2e';
914
- rfb.qualityLevel = 6;
915
- rfb.compressionLevel = 2;
916
-
917
- rfb.addEventListener('connect', function() {{
918
- updateStatus(true, '', '');
919
- }});
920
-
921
- rfb.addEventListener('disconnect', function(e) {{
922
- const clean = e.detail.clean;
923
- if (clean) {{
924
- updateStatus(false, '连接已关闭', '远程桌面服务已停止');
925
- }} else {{
926
- updateStatus(false, '连接中断', '正在尝试重新连接...');
927
- setTimeout(connect, 3000);
928
- }}
929
- }});
930
-
931
- rfb.addEventListener('credentialsrequired', function() {{
932
- // 无需密码
933
- }});
934
-
935
- rfb.addEventListener('desktopname', function(e) {{
936
- document.title = e.detail.name + ' - MyAgent';
937
- }});
938
-
939
- }} catch (e) {{
940
- 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
+ }}
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
+ }}
941
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();
942
1138
  }}
943
1139
 
944
- connect();
945
-
946
- // 暴露给全局
947
- window.rfb = rfb;
948
- window.sendCtrlAltDel = function() {{
949
- if (rfb) rfb.sendCtrlAltDel();
950
- }};
951
- window.takeScreenshot = function() {{
952
- // 下载当前屏幕截图
953
- fetch('/api/vnc/screenshot').then(r => {{
954
- if (r.ok) return r.blob();
955
- throw new Error('截图失败');
956
- }}).then(blob => {{
957
- const url = URL.createObjectURL(blob);
958
- const a = document.createElement('a');
959
- a.href = url;
960
- a.download = 'desktop_' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
961
- a.click();
962
- URL.revokeObjectURL(url);
963
- }}).catch(e => alert('截图失败: ' + e.message));
964
- }};
965
- window.toggleFullscreen = function() {{
966
- if (!document.fullscreenElement) {{
967
- document.documentElement.requestFullscreen();
968
- }} else {{
969
- document.exitFullscreen();
1140
+ // 直连按钮
1141
+ function tryDirectConnect() {{
1142
+ if (window._vncConnect) {{
1143
+ window._vncUseProxy = false;
1144
+ location.reload();
970
1145
  }}
971
- }};
1146
+ }}
972
1147
  </script>
973
1148
  </body>
974
1149
  </html>"""
@@ -2137,7 +2312,15 @@ window.addEventListener('beforeunload', function() {{
2137
2312
  except ImportError:
2138
2313
  logger.debug("SenseVoice (funasr) 未安装,跳过。安装: pip install funasr torch torchaudio")
2139
2314
  except Exception as e:
2140
- 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}")
2141
2324
 
2142
2325
  # ── 尝试 vosk ──
2143
2326
  try:
@@ -3642,7 +3825,7 @@ window.addEventListener('beforeunload', function() {{
3642
3825
  status = "unknown"
3643
3826
 
3644
3827
  # 从 bot 实例获取 QR 码
3645
- 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
3646
3829
  if bot and hasattr(bot, 'get_qr_code'):
3647
3830
  qr_code = bot.get_qr_code() or ""
3648
3831
  if bot and hasattr(bot, '_connected'):
@@ -3679,7 +3862,7 @@ window.addEventListener('beforeunload', function() {{
3679
3862
  if cp.platform not in ("whatsapp", "wechat"):
3680
3863
  return web.json_response({"error": f"{cp.platform} 不支持 QR 码绑定"}, status=400)
3681
3864
 
3682
- 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
3683
3866
  if not bot:
3684
3867
  return web.json_response({"error": "Bot 实例未创建"}, status=400)
3685
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;