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.
- package/chatbot/manager.py +72 -4
- 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 +298 -112
- package/web/ui/chat/chat_main.js +27 -14
package/chatbot/manager.py
CHANGED
|
@@ -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
|
# ==============================================================================
|
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
|
@@ -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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
#
|
|
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"
|
|
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"
|
|
864
|
-
<
|
|
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
|
-
<!--
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
let
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
}} 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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
window.
|
|
945
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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;
|