myagent-ai 1.20.12 → 1.21.0

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/main.py CHANGED
@@ -122,6 +122,10 @@ class MyAgentApp:
122
122
  # 聊天平台
123
123
  self.chat_manager: ChatBotManager | None = None
124
124
 
125
+ # [v1.20.13] 会话级锁:防止聊天平台消息与 Web 请求并发覆盖 MainAgent 共享状态
126
+ self._session_locks: dict[str, asyncio.Lock] = {}
127
+ self._session_locks_mutex = asyncio.Lock()
128
+
125
129
  # 配置热加载广播器
126
130
  self.config_broadcaster: ConfigBroadcaster | None = None
127
131
 
@@ -449,6 +453,13 @@ class MyAgentApp:
449
453
  self.logger.error(f"处理消息异常: {e}", exc_info=True)
450
454
  return f"❌ 处理失败: {str(e)}"
451
455
 
456
+ async def _get_session_lock(self, session_id: str) -> asyncio.Lock:
457
+ """获取或创建会话级锁"""
458
+ async with self._session_locks_mutex:
459
+ if session_id not in self._session_locks:
460
+ self._session_locks[session_id] = asyncio.Lock()
461
+ return self._session_locks[session_id]
462
+
452
463
  async def _handle_chat_message(self, message: ChatMessage, bot):
453
464
  """
454
465
  处理来自聊天平台的消息。
@@ -473,7 +484,12 @@ class MyAgentApp:
473
484
  return
474
485
 
475
486
  # 处理消息
476
- response_text = await self.process_message(message.text, session_id)
487
+ # [v1.20.13] 使用会话级锁防止并发处理同一会话
488
+ session_lock = await self._get_session_lock(session_id)
489
+ if session_lock.locked():
490
+ return # 同一会话正在处理中,跳过(聊天平台消息有去重机制)
491
+ async with session_lock:
492
+ response_text = await self.process_message(message.text, session_id)
477
493
 
478
494
  # [v1.20.8] 将回复中的相对路径文件链接替换为绝对 URL
479
495
  # (file_send 生成的 /api/file/xxx 链接在 Telegram/Discord 等外部平台无法访问)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.20.12",
3
+ "version": "1.21.0",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
package/requirements.txt CHANGED
@@ -70,6 +70,8 @@ chardet>=5.0.0
70
70
  xlrd>=2.0.0
71
71
  # tzdata: Windows 上 ZoneInfo 所需的时区数据
72
72
  tzdata>=2024.1
73
+ # pyyaml: SKILL.md 格式技能加载(技能系统必需)
74
+ pyyaml>=6.0.0
73
75
 
74
76
  # ============================================================
75
77
  # Anthropic Claude (可选)
package/web/api_server.py CHANGED
@@ -212,6 +212,9 @@ class ApiServer:
212
212
  self._executor_wrapped = False
213
213
  # 全局执行锁:同一时间只有一个 agent 可运行 local 模式
214
214
  self._execution_lock = {"locked": False, "locked_by": None, "locked_at": None}
215
+ # [v1.20.13] 会话级锁:防止同一会话并发请求导致 MainAgent 共享状态被覆盖
216
+ self._session_locks: Dict[str, asyncio.Lock] = {}
217
+ self._session_locks_mutex = asyncio.Lock()
215
218
  # 消息队列(用于存放待执行的消息,key: session_id)
216
219
  self._msg_queues: Dict[str, List[Dict]] = {}
217
220
  # 任务列表内存存储(exec 模式,替代 task.md)
@@ -279,11 +282,12 @@ class ApiServer:
279
282
  mgr.config.auto_accept = cfg.auto_accept
280
283
  logger.info("通信管理器已热更新")
281
284
 
282
- def _hot_reload_chat_platforms(self):
285
+ async def _hot_reload_chat_platforms(self):
283
286
  """热更新聊天平台:重新加载所有平台配置到 ChatBotManager
284
287
 
285
- [v1.20.7] 修复: 调用 setup_platforms 时会自动停止已禁用的平台并启动新启用的平台。
288
+ [v1.20.7] 修复: 调用 setup_platforms 时会自动停止被禁用的平台并启动新启用的平台。
286
289
  [v1.20.9] 修复: chat_manager 为 None 时懒创建,修复首次启用平台不生效的问题。
290
+ [v1.20.12] 改为 async 并补启未运行的 bot,修复首次启用平台后 bot 不启动的问题。
287
291
  """
288
292
  if not self.core.chat_manager:
289
293
  from chatbot.manager import ChatBotManager
@@ -291,11 +295,20 @@ class ApiServer:
291
295
  logger.info("聊天平台管理器已懒创建")
292
296
  mgr = self.core.chat_manager
293
297
  platform_configs = self.core.config_mgr.config.chat_platforms
294
- # setup_platforms 会自动对比新旧配置,停止被移除/禁用的 bot,启动新启用的 bot
295
298
  handler = mgr._message_handler if hasattr(mgr, '_message_handler') else None
296
299
  if not handler and hasattr(self.core, '_handle_chat_message'):
297
300
  handler = self.core._handle_chat_message
301
+ if not handler:
302
+ logger.warning("聊天平台消息处理器为 None,平台可能无法处理消息")
298
303
  mgr.setup_platforms(platform_configs, handler)
304
+ # [v1.20.12] 补启未运行的 bot
305
+ enabled_keys = [cfg.id or cfg.platform for cfg in platform_configs if cfg.enabled]
306
+ for key in enabled_keys:
307
+ if key in mgr._bots and key not in mgr._bot_tasks:
308
+ bot = mgr._bots.get(key)
309
+ if bot:
310
+ logger.info(f"补启动聊天平台: {key}")
311
+ asyncio.create_task(mgr._run_bot(key, bot), name=f"bot_{key}")
299
312
  logger.info("聊天平台配置已热更新")
300
313
 
301
314
  def _setup_routes(self):
@@ -697,13 +710,28 @@ class ApiServer:
697
710
  if not file_id or len(file_id) < 8:
698
711
  return web.Response(status=404, text="File not found")
699
712
  fpath, mime = _find_upload_file(file_id)
713
+ if not fpath or not fpath.exists():
714
+ # [v1.20.13] 回退:尝试在工作目录中查找
715
+ try:
716
+ from urllib.parse import unquote
717
+ wd = self.core.config_mgr.data_dir / "workspace"
718
+ if wd.exists():
719
+ for f in wd.rglob("*"):
720
+ if f.is_file() and f.name.startswith(file_id + "_"):
721
+ fpath = f
722
+ break
723
+ except Exception:
724
+ pass
700
725
  if not fpath or not fpath.exists():
701
726
  return web.Response(status=404, text="File not found")
702
727
  # [fix] aiohttp FileResponse 不支持 content_type 参数,改用属性赋值
703
728
  resp = web.FileResponse(fpath)
704
729
  if mime:
705
730
  resp.content_type = mime
706
- resp.headers["Content-Disposition"] = f"inline; filename=\"{fpath.name}\""
731
+ # [v1.20.13] 修复非 ASCII 文件名编码
732
+ from urllib.parse import quote as _url_quote
733
+ _safe_name = _url_quote(fpath.name)
734
+ resp.headers["Content-Disposition"] = f"inline; filename=\"{_safe_name}\"; filename*=UTF-8''{_safe_name}"
707
735
  return resp
708
736
 
709
737
  async def handle_download_file(self, request):
@@ -712,13 +740,28 @@ class ApiServer:
712
740
  if not file_id or len(file_id) < 8:
713
741
  return web.Response(status=404, text="File not found")
714
742
  fpath, mime = _find_upload_file(file_id)
743
+ if not fpath or not fpath.exists():
744
+ # [v1.20.13] 回退:尝试在工作目录中查找
745
+ try:
746
+ from urllib.parse import unquote
747
+ wd = self.core.config_mgr.data_dir / "workspace"
748
+ if wd.exists():
749
+ for f in wd.rglob("*"):
750
+ if f.is_file() and f.name.startswith(file_id + "_"):
751
+ fpath = f
752
+ break
753
+ except Exception:
754
+ pass
715
755
  if not fpath or not fpath.exists():
716
756
  return web.Response(status=404, text="File not found")
717
757
  # [fix] aiohttp FileResponse 不支持 content_type 参数,改用属性赋值
718
758
  resp = web.FileResponse(fpath)
719
759
  if mime:
720
760
  resp.content_type = mime
721
- resp.headers["Content-Disposition"] = f"attachment; filename=\"{fpath.name}\""
761
+ # [v1.20.13] 修复非 ASCII 文件名编码
762
+ from urllib.parse import quote as _url_quote
763
+ _safe_name = _url_quote(fpath.name)
764
+ resp.headers["Content-Disposition"] = f"attachment; filename=\"{_safe_name}\"; filename*=UTF-8''{_safe_name}"
722
765
  return resp
723
766
 
724
767
  # --- [v1.17.0] Remote Desktop (VNC) ---
@@ -783,7 +826,8 @@ class ApiServer:
783
826
  """GET /vnc/{path} - 代理 noVNC 客户端页面
784
827
 
785
828
  如果 websockify 已配置 --web 目录,代理到该目录;
786
- 否则返回内置的轻量 VNC 客户端页面。
829
+ 否则尝试从 data/novnc/lib/ 目录提供本地 noVNC 库文件;
830
+ 最后返回内置的轻量 VNC 客户端页面。
787
831
  """
788
832
  mgr = self._get_vnc_manager()
789
833
  path = request.match_info.get('path', 'vnc.html')
@@ -807,6 +851,25 @@ class ApiServer:
807
851
  resp.content_type = content_type
808
852
  return resp
809
853
 
854
+ # [v1.20.11] 方法1.5: 从本地 data/novnc/lib/ 目录提供 noVNC 库文件
855
+ _novnc_lib_dir = Path(__file__).parent.parent / "data" / "novnc" / "lib"
856
+ if _novnc_lib_dir.exists() and path.startswith("lib/"):
857
+ _lib_path = _novnc_lib_dir / path[4:] # strip "lib/" prefix
858
+ if _lib_path.is_file():
859
+ ext = _lib_path.suffix.lower()
860
+ mime_map = {
861
+ '.html': 'text/html', '.htm': 'text/html',
862
+ '.js': 'application/javascript', '.css': 'text/css',
863
+ '.json': 'application/json', '.png': 'image/png',
864
+ '.svg': 'image/svg+xml', '.woff': 'font/woff',
865
+ '.woff2': 'font/woff2', '.wasm': 'application/wasm',
866
+ }
867
+ content_type = mime_map.get(ext, 'application/octet-stream')
868
+ resp = web.FileResponse(str(_lib_path))
869
+ if content_type:
870
+ resp.content_type = content_type
871
+ return resp
872
+
810
873
  # 方法2: 返回内置的轻量 noVNC 客户端
811
874
  if path in ('vnc.html', 'vnc_lite.html', ''):
812
875
  html = self._get_builtin_novnc_page(request)
@@ -945,22 +1008,139 @@ class ApiServer:
945
1008
  </div>
946
1009
  </div>
947
1010
 
948
- <!-- [v1.20.9] 本地加载 noVNC 组件,不依赖外部 CDN -->
1011
+ <!-- [v1.20.11] noVNC 组件加载:本地优先,CDN 回退 -->
1012
+ <script>
1013
+ // [v1.20.11] 本地 noVNC 加载器:通过动态 <script> 标签加载 CommonJS 模块
1014
+ // 本地 data/novnc/lib/ 中的文件是 CommonJS 格式,不能直接用 import()
1015
+ async function loadLocalNoVNC() {{
1016
+ const basePath = '/vnc/lib/';
1017
+ // 加载 noVNC 依赖模块(按依赖顺序)
1018
+ const depFiles = [
1019
+ 'vendor/pako/lib/inflate.js',
1020
+ 'vendor/pako/lib/deflate.js',
1021
+ 'base64.js',
1022
+ 'websock.js',
1023
+ 'display.js',
1024
+ 'inflator.js',
1025
+ 'deflator.js',
1026
+ 'decoders/copyrect.js',
1027
+ 'decoders/raw.js',
1028
+ 'decoders/rre.js',
1029
+ 'decoders/hextile.js',
1030
+ 'decoders/tight.js',
1031
+ 'decoders/tightpng.js',
1032
+ 'decoders/zrle.js',
1033
+ 'decoders/jpeg.js',
1034
+ 'util/events.js',
1035
+ 'util/eventtarget.js',
1036
+ 'util/logging.js',
1037
+ 'util/strings.js',
1038
+ 'util/browser.js',
1039
+ 'util/int.js',
1040
+ 'util/element.js',
1041
+ 'util/cursor.js',
1042
+ 'input/keysym.js',
1043
+ 'input/xtscancodes.js',
1044
+ 'input/util.js',
1045
+ 'input/domkeytable.js',
1046
+ 'input/vkeys.js',
1047
+ 'input/keyboard.js',
1048
+ 'input/gesturehandler.js',
1049
+ 'encodings.js',
1050
+ ];
1051
+ // 加载所有依赖文件为全局变量
1052
+ for (const file of depFiles) {{
1053
+ const resp = await fetch(basePath + file);
1054
+ if (!resp.ok) throw new Error('Failed to fetch ' + file + ': ' + resp.status);
1055
+ const code = await resp.text();
1056
+ // 使用 Function 构造器执行 CommonJS 代码,注入 require 和 exports
1057
+ const _modules = window.__novnc_modules = window.__novnc_modules || {{}};
1058
+ const modId = file.replace('.js', '');
1059
+ const modExports = {{}};
1060
+ _modules[modId] = modExports;
1061
+ try {{
1062
+ const fn = new Function('exports', 'require', 'module', code);
1063
+ fn(modExports, function(depId) {{
1064
+ // 解析相对路径
1065
+ let resolved = depId;
1066
+ if (depId.startsWith('./')) {{
1067
+ resolved = (modId.substring(0, modId.lastIndexOf('/') + 1) || '') + depId.substring(2);
1068
+ }}
1069
+ return _modules[resolved] || {{}};
1070
+ }}, {{ exports: modExports }});
1071
+ }} catch(e) {{
1072
+ console.warn('[noVNC] Failed to load module:', file, e);
1073
+ }}
1074
+ }}
1075
+ // 最后加载 rfb.js
1076
+ const resp = await fetch(basePath + 'rfb.js');
1077
+ if (!resp.ok) throw new Error('Failed to fetch rfb.js: ' + resp.status);
1078
+ const code = await resp.text();
1079
+ const _modules = window.__novnc_modules || {{}};
1080
+ const rfbExports = {{}};
1081
+ _modules['rfb'] = rfbExports;
1082
+ const fn = new Function('exports', 'require', 'module', code);
1083
+ fn(rfbExports, function(depId) {{
1084
+ let resolved = depId;
1085
+ if (depId.startsWith('./')) {{
1086
+ resolved = 'rfb/' + depId.substring(2);
1087
+ // 映射到实际模块路径
1088
+ const pathMap = {{
1089
+ './util/int': 'util/int',
1090
+ './util/logging': 'util/logging',
1091
+ './util/strings': 'util/strings',
1092
+ './util/browser': 'util/browser',
1093
+ './util/element': 'util/element',
1094
+ './util/events': 'util/events',
1095
+ './util/eventtarget': 'util/eventtarget',
1096
+ './display': 'display',
1097
+ './inflator': 'inflator',
1098
+ './deflator': 'deflator',
1099
+ './websock': 'websock',
1100
+ './decoders/copyrect': 'decoders/copyrect',
1101
+ './decoders/raw': 'decoders/raw',
1102
+ './decoders/rre': 'decoders/rre',
1103
+ './decoders/hextile': 'decoders/hextile',
1104
+ './decoders/tight': 'decoders/tight',
1105
+ './decoders/tightpng': 'decoders/tightpng',
1106
+ './decoders/zrle': 'decoders/zrle',
1107
+ './decoders/jpeg': 'decoders/jpeg',
1108
+ './encodings': 'encodings',
1109
+ './input/keysym': 'input/keysym',
1110
+ './input/keyboard': 'input/keyboard',
1111
+ './input/xtscancodes': 'input/xtscancodes',
1112
+ './input/util': 'input/util',
1113
+ './input/domkeytable': 'input/domkeytable',
1114
+ './input/vkeys': 'input/vkeys',
1115
+ './input/gesturehandler': 'input/gesturehandler',
1116
+ './base64': 'base64',
1117
+ }};
1118
+ if (depId.startsWith('./')) {{
1119
+ const mapped = pathMap[depId];
1120
+ if (mapped) resolved = mapped;
1121
+ }}
1122
+ }}
1123
+ return _modules[resolved] || {{}};
1124
+ }}, {{ exports: rfbExports }});
1125
+ return rfbExports.default || rfbExports;
1126
+ }}
1127
+ </script>
949
1128
  <script type="module">
950
1129
  try {{
951
- // 优先从本地服务器加载 noVNC(/vnc/lib/rfb.js),无需外网
952
1130
  let RFB = null;
953
1131
  let loadError = '';
954
1132
 
1133
+ // [v1.20.11] 优先从本地服务器加载 noVNC(data/novnc/lib/)
955
1134
  try {{
956
- const mod = await import('/vnc/lib/rfb.js');
957
- RFB = mod.default || mod.RFB;
1135
+ RFB = await loadLocalNoVNC();
958
1136
  }} catch (localErr) {{
959
1137
  loadError = localErr.message;
960
- // 本地加载失败,尝试 CDN 回退
1138
+ console.warn('[VNC] 本地加载失败,尝试 CDN:', localErr.message);
1139
+ // 本地加载失败,尝试 CDN 回退(含国内镜像)
961
1140
  const CDN_SOURCES = [
962
1141
  'https://cdn.jsdelivr.net/npm/@novnc/novnc@1.5.0/core/rfb.js',
963
1142
  'https://unpkg.com/@novnc/novnc@1.5.0/core/rfb.js',
1143
+ 'https://registry.npmmirror.com/@novnc/novnc/1.5.0/files/core/rfb.js',
964
1144
  ];
965
1145
  for (const src of CDN_SOURCES) {{
966
1146
  try {{
@@ -1659,6 +1839,16 @@ window.addEventListener('beforeunload', function() {{
1659
1839
  chat_mode = data.get("mode", "") # "exec" = 执行模式
1660
1840
  escalated = data.get("escalated", False) # 临时提权到 local
1661
1841
 
1842
+ # ── [v1.20.13] 会话级锁:防止同一会话并发请求 ──
1843
+ session_lock = await self._get_session_lock(session_id)
1844
+ if session_lock.locked():
1845
+ try:
1846
+ await asyncio.wait_for(session_lock.acquire(), timeout=2.0)
1847
+ session_lock.release()
1848
+ except asyncio.TimeoutError:
1849
+ return web.json_response({"error": "该会话正忙,请稍后重试"}, status=429)
1850
+ await session_lock.acquire()
1851
+
1662
1852
  # ── 全局执行锁检查 + 获取(原子操作,check+set 之间无 await)──
1663
1853
  agent_cfg_early = self._read_agent_config(agent_path)
1664
1854
  execution_mode = agent_cfg_early.get("execution_mode", "sandbox") if agent_cfg_early else "sandbox"
@@ -1730,10 +1920,39 @@ window.addEventListener('beforeunload', function() {{
1730
1920
  self._execution_lock["locked"] = False
1731
1921
  self._execution_lock["locked_by"] = None
1732
1922
  self._execution_lock["locked_at"] = None
1923
+ # [v1.20.13] 释放会话级锁
1924
+ try:
1925
+ session_lock.release()
1926
+ except RuntimeError:
1927
+ pass
1733
1928
 
1734
1929
  # ── 会话运行状态追踪 (用于断线重连) ──
1735
1930
  # {session_id: {running: bool, started_at: float, result: str, done: bool, error: str}}
1736
1931
  _running_sessions: Dict[str, Dict] = {}
1932
+ _running_sessions_ttl = 300 # 过期时间(秒),用于自动清理已完成会话
1933
+
1934
+ async def _get_session_lock(self, session_id: str) -> asyncio.Lock:
1935
+ """获取或创建会话级锁(每个 session_id 一个锁,防并发覆盖 MainAgent 共享状态)"""
1936
+ async with self._session_locks_mutex:
1937
+ if session_id not in self._session_locks:
1938
+ self._session_locks[session_id] = asyncio.Lock()
1939
+ return self._session_locks[session_id]
1940
+
1941
+ def _cleanup_stale_sessions(self):
1942
+ """清理已完成的过期会话状态,防止 _running_sessions 无限增长"""
1943
+ import time as _time
1944
+ now = _time.time()
1945
+ stale = []
1946
+ for sid, info in self._running_sessions.items():
1947
+ if info.get("done") and now - info.get("completed_at", now) > self._running_sessions_ttl:
1948
+ stale.append(sid)
1949
+ for sid in stale:
1950
+ del self._running_sessions[sid]
1951
+ logger.debug(f"清理过期会话状态: {sid}")
1952
+ # 同步清理对应的 session lock
1953
+ if stale:
1954
+ for sid in stale:
1955
+ self._session_locks.pop(sid, None)
1737
1956
 
1738
1957
  async def handle_chat_stream(self, request):
1739
1958
  """POST /api/chat/stream - SSE 流式聊天
@@ -1781,14 +2000,26 @@ window.addEventListener('beforeunload', function() {{
1781
2000
  escalated = data.get("escalated", False)
1782
2001
  voice_text = data.get("voice_text", "").strip() # 语音转文字原始文本(用于 usersays_correct)
1783
2002
 
1784
- # ── 检查是否有正在运行的同一会话任务 ──
1785
- running_info = self._running_sessions.get(session_id)
1786
- if running_info and running_info.get("running") and not running_info.get("done"):
1787
- # 返回当前运行状态,让前端恢复
1788
- return web.Response(
1789
- text="data: " + json.dumps({"type": "resume", "session_id": session_id, "running": True, "started_at": running_info.get("started_at")}) + "\n\n",
1790
- content_type="text/event-stream",
1791
- )
2003
+ # ── [v1.20.13] 清理过期的会话状态,防止内存泄漏 ──
2004
+ self._cleanup_stale_sessions()
2005
+
2006
+ # ── [v1.20.13] 获取会话级锁:防止同一会话并发请求导致 MainAgent 共享状态被覆盖 ──
2007
+ session_lock = await self._get_session_lock(session_id)
2008
+ if session_lock.locked():
2009
+ # 同一会话已有请求在处理
2010
+ running_info = self._running_sessions.get(session_id)
2011
+ if running_info and running_info.get("running") and not running_info.get("done"):
2012
+ return web.Response(
2013
+ text="data: " + json.dumps({"type": "resume", "session_id": session_id, "running": True, "started_at": running_info.get("started_at")}) + "\n\n",
2014
+ content_type="text/event-stream",
2015
+ )
2016
+ # 锁被持有但无 running 记录(异常残留),等待后继续
2017
+ try:
2018
+ await asyncio.wait_for(session_lock.acquire(), timeout=2.0)
2019
+ session_lock.release()
2020
+ except asyncio.TimeoutError:
2021
+ return web.Response(text="data: " + json.dumps({"error": "会话正忙,请稍后重试"}) + "\n\n", content_type="text/event-stream")
2022
+ await session_lock.acquire()
1792
2023
 
1793
2024
  # Lock check
1794
2025
  agent_cfg_early = self._read_agent_config(agent_path)
@@ -1997,6 +2228,11 @@ window.addEventListener('beforeunload', function() {{
1997
2228
  self._execution_lock["locked"] = False
1998
2229
  self._execution_lock["locked_by"] = None
1999
2230
  self._execution_lock["locked_at"] = None
2231
+ # [v1.20.13] 释放会话级锁
2232
+ try:
2233
+ session_lock.release()
2234
+ except RuntimeError:
2235
+ pass
2000
2236
 
2001
2237
  # ── 启动后台任务(不等它完成,前端断开也不影响) ──
2002
2238
  logger.info(f"[{session_id}] 准备创建后台任务")
@@ -3093,6 +3329,9 @@ window.addEventListener('beforeunload', function() {{
3093
3329
  agent = {"path": agent_path, "name": d.name, **cfg}
3094
3330
  agent["avatar_color"] = cfg.get("avatar_color") or _agent_color(d.name)
3095
3331
  agent["depth"] = agent_path.count("/")
3332
+ # [v1.20.13] 自动检测 avatar.png 文件,补全 avatar_image 字段
3333
+ if not agent.get("avatar_image") and (d / "avatar.png").exists():
3334
+ agent["avatar_image"] = f"/api/agents/{agent_path}/avatar.png"
3096
3335
  agents.append(agent)
3097
3336
  # 递归子目录
3098
3337
  agents.extend(self._scan_agents_flat(d, agent_path))
@@ -3711,7 +3950,7 @@ window.addEventListener('beforeunload', function() {{
3711
3950
  self.core.config_mgr.config.chat_platforms.append(cp)
3712
3951
  self.core.config_mgr.save()
3713
3952
  # 热更新聊天平台
3714
- self._hot_reload_chat_platforms()
3953
+ await self._hot_reload_chat_platforms()
3715
3954
  logger.info(f"新增聊天平台: {cp.display_name} (id={cp.id})")
3716
3955
  return web.json_response({"ok": True, "id": cp.id, "platform": platform_name, "display_name": cp.display_name, "hot_reload": True})
3717
3956
 
@@ -3753,7 +3992,7 @@ window.addEventListener('beforeunload', function() {{
3753
3992
  logger.info(f"平台 ID 变更: {old_id} -> {cp.id}")
3754
3993
  self.core.config_mgr.save()
3755
3994
  # 热更新聊天平台
3756
- self._hot_reload_chat_platforms()
3995
+ await self._hot_reload_chat_platforms()
3757
3996
  logger.info(f"聊天平台配置已更新: {cp.display_name} (id={cp.id})")
3758
3997
  return web.json_response({"ok": True, "id": cp.id, "hot_reload": True})
3759
3998
 
@@ -3766,7 +4005,7 @@ window.addEventListener('beforeunload', function() {{
3766
4005
  platforms.pop(i)
3767
4006
  self.core.config_mgr.save()
3768
4007
  # 热更新聊天平台
3769
- self._hot_reload_chat_platforms()
4008
+ await self._hot_reload_chat_platforms()
3770
4009
  logger.info(f"已删除聊天平台: {cp.display_name} (id={cp.id})")
3771
4010
  return web.json_response({"ok": True, "hot_reload": True})
3772
4011
  return web.json_response({"error": f"平台 {name} 不存在"}, status=404)
@@ -3783,7 +4022,7 @@ window.addEventListener('beforeunload', function() {{
3783
4022
  cp.enabled = data.get("enabled", not cp.enabled)
3784
4023
  self.core.config_mgr.save()
3785
4024
  # 热更新聊天平台
3786
- self._hot_reload_chat_platforms()
4025
+ await self._hot_reload_chat_platforms()
3787
4026
  logger.info(f"聊天平台 {cp.display_name} 已{'启用' if cp.enabled else '禁用'}")
3788
4027
  return web.json_response({"ok": True, "enabled": cp.enabled, "id": cp.id, "hot_reload": True})
3789
4028
 
@@ -6566,7 +6805,7 @@ window.addEventListener('beforeunload', function() {{
6566
6805
  self._hot_reload_llm()
6567
6806
  self._hot_reload_executor()
6568
6807
  self._hot_reload_communication()
6569
- self._hot_reload_chat_platforms()
6808
+ await self._hot_reload_chat_platforms()
6570
6809
 
6571
6810
  logger.info(f"安全保存配置: ok={result.get('ok')}")
6572
6811
  return web.json_response(result)
@@ -2031,7 +2031,8 @@ input,textarea,select{font:inherit}
2031
2031
  margin-top:4px;opacity:0;
2032
2032
  transition:opacity .2s ease;
2033
2033
  }
2034
- .message-row.assistant:hover .msg-actions{opacity:1}
2034
+ .message-row.assistant:hover .msg-actions,
2035
+ .message-row.user:hover .msg-actions{opacity:1}
2035
2036
  .msg-action-btn{
2036
2037
  width:28px;height:28px;border-radius:var(--radius-xs);
2037
2038
  display:grid;place-items:center;
@@ -1277,9 +1277,17 @@ function renderHelperAgentCard() {
1277
1277
  var name = agent ? agent.name : '配置助手';
1278
1278
  var emoji = agent ? (agent.avatar_emoji || '🛡️') : '🛡️';
1279
1279
  var desc = agent ? (agent.description || '智能配置助手') : '智能配置助手';
1280
+ var avatarImage = agent ? (agent.avatar_image || '') : '';
1281
+ // [v1.20.13] 优先使用上传的头像图片
1282
+ var avatarContent;
1283
+ if (avatarImage) {
1284
+ avatarContent = '<img src="' + escapeHtml(avatarImage) + '" style="width:100%;height:100%;object-fit:cover;border-radius:10px" onerror="this.outerHTML=\'' + escapeHtml(emoji) + '\'">';
1285
+ } else {
1286
+ avatarContent = emoji;
1287
+ }
1280
1288
 
1281
1289
  el.innerHTML = '<div class="rp-helper-card ' + (isActive ? 'active' : '') + '" onclick="selectAgent(\'配置助手\')">'
1282
- + '<div class="rp-helper-avatar">' + emoji + '</div>'
1290
+ + '<div class="rp-helper-avatar" style="' + (avatarImage ? 'background:transparent' : '') + '">' + avatarContent + '</div>'
1283
1291
  + '<div class="rp-master-info">'
1284
1292
  + '<div class="rp-master-name" style="font-size:13px">' + escapeHtml(name) + '</div>'
1285
1293
  + '<div class="rp-master-desc">' + escapeHtml(desc) + '</div>'
@@ -1306,6 +1314,7 @@ async function renderRecentAgents() {
1306
1314
  emoji: agent.avatar_emoji || '',
1307
1315
  color: agent.avatar_color || '',
1308
1316
  colorClass: agent.avatar_color ? '' : getAgentColorClass(agent.name),
1317
+ avatarImage: agent.avatar_image || '',
1309
1318
  lastTime: sessions[0].last || '',
1310
1319
  count: sessions.length
1311
1320
  });
@@ -1326,10 +1335,16 @@ async function renderRecentAgents() {
1326
1335
  var html = '';
1327
1336
  recentAgents.forEach(function(a) {
1328
1337
  var isActive = state.activeAgent === a.path;
1329
- var bgStyle = a.color && !a.color.includes('gradient') ? 'background:' + a.color : '';
1330
- var bgClass = a.color ? '' : a.colorClass;
1338
+ var avatarContent;
1339
+ if (a.avatarImage) {
1340
+ avatarContent = '<img src="' + escapeHtml(a.avatarImage) + '" style="width:100%;height:100%;object-fit:cover;border-radius:50%" onerror="this.outerHTML=\'' + escapeHtml(a.emoji || getAgentInitials(a.name)) + '\'">';
1341
+ } else {
1342
+ avatarContent = a.emoji || getAgentInitials(a.name);
1343
+ }
1344
+ var bgStyle = a.avatarImage ? '' : (a.color && !a.color.includes('gradient') ? 'background:' + a.color : '');
1345
+ var bgClass = a.avatarImage ? '' : (a.color ? '' : a.colorClass);
1331
1346
  html += '<div class="rp-recent-chip ' + (isActive ? 'active' : '') + '" onclick="selectAgent(\'' + escapeHtml(a.path) + '\')" title="' + escapeHtml(a.name) + '">'
1332
- + '<div class="rp-recent-chip-avatar ' + bgClass + '" style="' + bgStyle + '">' + (a.emoji || getAgentInitials(a.name)) + '</div>'
1347
+ + '<div class="rp-recent-chip-avatar ' + bgClass + '" style="' + bgStyle + '">' + avatarContent + '</div>'
1333
1348
  + '<span class="rp-recent-chip-name">' + escapeHtml(a.name) + '</span>'
1334
1349
  + '</div>';
1335
1350
  });
@@ -1399,18 +1414,27 @@ function renderRpDeptNode(dept, depth) {
1399
1414
  var agentName = typeof a === 'string' ? a : (a.name || a.path || agentPath);
1400
1415
  var agentEmoji = typeof a === 'object' ? (a.avatar_emoji || '') : '';
1401
1416
  var agentColor = typeof a === 'object' ? (a.avatar_color || '') : '';
1417
+ var agentImage = typeof a === 'object' ? (a.avatar_image || '') : '';
1402
1418
  var agentInfo = findAgentByPath(agentPath);
1403
1419
  if (agentInfo) {
1404
1420
  agentEmoji = agentInfo.avatar_emoji || agentEmoji;
1405
1421
  agentColor = agentInfo.avatar_color || agentColor;
1422
+ agentImage = agentInfo.avatar_image || agentImage;
1406
1423
  agentName = agentInfo.name || agentName;
1407
1424
  }
1408
1425
  var isActive = state.activeAgent === agentPath;
1409
- var bgStyle = agentColor ? 'background:' + agentColor : '';
1410
- var bgClass = agentColor ? '' : getAgentColorClass(agentName);
1426
+ // [v1.20.13] 优先使用上传的头像图片
1427
+ var avatarHtmlContent;
1428
+ if (agentImage) {
1429
+ avatarHtmlContent = '<img src="' + escapeHtml(agentImage) + '" style="width:24px;height:24px;border-radius:6px;object-fit:cover;display:block" onerror="this.style.display=\'none\';this.parentNode.innerHTML=\'' + escapeHtml(agentEmoji || getAgentInitials(agentName)) + '\'">';
1430
+ } else {
1431
+ avatarHtmlContent = (agentEmoji || getAgentInitials(agentName));
1432
+ }
1433
+ var bgStyle = agentImage ? '' : (agentColor ? 'background:' + agentColor : '');
1434
+ var bgClass = agentImage ? '' : (agentColor ? '' : getAgentColorClass(agentName));
1411
1435
 
1412
1436
  html += '<div class="rp-dept-agent-item ' + (isActive ? 'active' : '') + '" style="--depth:' + (depth + 1) + '" onclick="selectAgent(\'' + escapeHtml(agentPath) + '\')">'
1413
- + '<div class="rp-dept-agent-avatar ' + bgClass + '" style="' + bgStyle + '">' + (agentEmoji || getAgentInitials(agentName)) + '</div>'
1437
+ + '<div class="rp-dept-agent-avatar ' + bgClass + '" style="' + bgStyle + '">' + avatarHtmlContent + '</div>'
1414
1438
  + '<span>' + escapeHtml(agentName) + '</span>'
1415
1439
  + '</div>';
1416
1440
  });
@@ -2849,10 +2873,25 @@ function _renderMessagesInner() {
2849
2873
  if (ymMatch) { embedUrl = 'https://music.youtube.com/embed?list=' + ymMatch[1] + '&layout=full'; }
2850
2874
  }
2851
2875
  if (isAudio) {
2876
+ // [v1.20.11] 音频播放器:优先使用已知可嵌入平台的 iframe,否则使用原生 <audio> 标签
2877
+ // 避免 iframe 加载失败时显示断裂图片图标
2878
+ var _knownAudioEmbed = embedUrl && (
2879
+ embedUrl.indexOf('music.163.com/outchain') >= 0 ||
2880
+ embedUrl.indexOf('music.youtube.com/embed') >= 0 ||
2881
+ embedUrl.indexOf('player.bilibili.com') >= 0
2882
+ );
2883
+ var _audioPlayerHtml;
2884
+ if (_knownAudioEmbed) {
2885
+ _audioPlayerHtml = '<iframe src="' + escapeHtml(embedUrl) + '" style="width:100%;max-width:480px;height:80px;border:none;border-radius:8px" loading="lazy" allow="autoplay" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>';
2886
+ } else {
2887
+ // 不可嵌入的音频链接:使用原生 audio 播放器,避免 iframe 断裂图标
2888
+ _audioPlayerHtml = '<audio controls src="' + escapeHtml(origUrl) + '" style="width:100%;max-width:480px;border-radius:8px" preload="metadata" onplay="if(typeof muteTTS===\'function\')muteTTS()" onended="if(typeof unmuteTTS===\'function\')unmuteTTS()"></audio>' +
2889
+ '<div style="font-size:11px;color:var(--text3);margin-top:4px">部分平台可能因跨域限制无法直接播放,可点击右上角 ↗ 在新窗口打开</div>';
2890
+ }
2852
2891
  parts.push('<div class="msg-media-embed msg-media-audio">' +
2853
2892
  '<div class="msg-media-header"><span class="msg-media-icon">🎵</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2854
2893
  '<a class="msg-media-link" href="' + escapeHtml(origUrl) + '" target="_blank" rel="noopener" title="在新窗口打开">↗</a></div>' +
2855
- '<iframe src="' + escapeHtml(embedUrl) + '" style="width:100%;max-width:480px;height:80px;border:none;border-radius:8px" loading="lazy" allow="autoplay" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>' +
2894
+ _audioPlayerHtml +
2856
2895
  '</div>');
2857
2896
  } else {
2858
2897
  parts.push('<div class="msg-media-embed msg-media-video">' +
@@ -3148,11 +3187,15 @@ function renderMarkdown(text) {
3148
3187
  // 6c. 有序列表
3149
3188
  text = text.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>');
3150
3189
  // 6d. 连续 <li> 包装为 <ul>
3151
- text = text.replace(/(<li class="task-item">[\s\S]*?<\/li>\n?)+/g, '<ul class="task-list">$&</ul>');
3190
+ text = text.replace(/(<li class="task-item">[\s\S]*?<\/li>\n?)+/g, function(match) {
3191
+ // 去除 <li> 之间的换行,避免后续被转换为 <br> 导致行间距过大
3192
+ return '<ul class="task-list">' + match.replace(/<\/li>\n+/g, '</li>') + '</ul>';
3193
+ });
3152
3194
  text = text.replace(/(<li>[\s\S]*?<\/li>\n?)+/g, function(match) {
3153
3195
  // 避免重复包装已含 class 的 li
3154
3196
  if (match.indexOf('class=') >= 0) return match;
3155
- return '<ul>' + match + '</ul>';
3197
+ // 去除 <li> 之间的换行
3198
+ return '<ul>' + match.replace(/<\/li>\n+/g, '</li>') + '</ul>';
3156
3199
  });
3157
3200
  // 6e. 流式保护:检测末尾不完整的列表行(以 <li> 开头但后面紧接 <br> 或截断的行)
3158
3201
  // 如果文本末尾的最后一个 <li> 后面紧跟 <br> 且没有闭合内容,将其拆出恢复为纯文本
@@ -1853,10 +1853,20 @@ async function sendMessage(opts) {
1853
1853
  (evt.content ? evt.content.substring(0, 100) : '');
1854
1854
  } else if (evt.type === 'v2_file') {
1855
1855
  // [v1.16.17] Agent is sending a file to the user
1856
+ // [v1.20.12] 修复: 后端发送 file_id,前端渲染代码期望 id
1857
+ // [v1.20.13] 修复: 去重,避免同一文件重复添加
1856
1858
  if (evt.data) {
1857
1859
  if (!state.messages[msgIdx]._files) state.messages[msgIdx]._files = [];
1858
- state.messages[msgIdx]._files.push(evt.data);
1859
- throttledStreamUpdate(msgIdx);
1860
+ var fd = evt.data;
1861
+ // 归一化: 后端用 file_id,渲染代码用 id
1862
+ if (fd.file_id && !fd.id) fd.id = fd.file_id;
1863
+ // 去重: 检查是否已存在相同 file_id 的文件
1864
+ var _fid = fd.id || fd.file_id;
1865
+ var _dup = _fid && state.messages[msgIdx]._files.some(function(f) { return (f.id || f.file_id) === _fid; });
1866
+ if (!_dup) {
1867
+ state.messages[msgIdx]._files.push(fd);
1868
+ throttledStreamUpdate(msgIdx);
1869
+ }
1860
1870
  }
1861
1871
  } else if (evt.type === 'v2_media') {
1862
1872
  // [v1.20.3] Agent is embedding a media player (audio/video)