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 +1 -0
- package/core/context_builder.py +17 -0
- package/core/deps_checker.py +11 -0
- package/core/web_control.py +19 -1
- package/main.py +47 -1
- package/package.json +1 -1
- package/skills/file_send.py +44 -2
- package/web/api_server.py +293 -110
- package/web/ui/chat/chat_main.js +27 -14
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
|
# ==============================================================================
|
package/core/context_builder.py
CHANGED
|
@@ -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> 段落 —— 双层记忆检索结果。
|
package/core/deps_checker.py
CHANGED
|
@@ -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
|
|
package/core/web_control.py
CHANGED
|
@@ -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
|
-
|
|
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
package/skills/file_send.py
CHANGED
|
@@ -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.
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
#
|
|
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"
|
|
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"
|
|
867
|
-
<
|
|
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
|
-
<!--
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
const
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
let
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
window.
|
|
948
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
package/web/ui/chat/chat_main.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
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
|
-
|
|
1586
|
-
avatar.
|
|
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;
|