myagent-ai 1.20.13 → 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.13",
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)
@@ -707,13 +710,28 @@ class ApiServer:
707
710
  if not file_id or len(file_id) < 8:
708
711
  return web.Response(status=404, text="File not found")
709
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
710
725
  if not fpath or not fpath.exists():
711
726
  return web.Response(status=404, text="File not found")
712
727
  # [fix] aiohttp FileResponse 不支持 content_type 参数,改用属性赋值
713
728
  resp = web.FileResponse(fpath)
714
729
  if mime:
715
730
  resp.content_type = mime
716
- 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}"
717
735
  return resp
718
736
 
719
737
  async def handle_download_file(self, request):
@@ -722,13 +740,28 @@ class ApiServer:
722
740
  if not file_id or len(file_id) < 8:
723
741
  return web.Response(status=404, text="File not found")
724
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
725
755
  if not fpath or not fpath.exists():
726
756
  return web.Response(status=404, text="File not found")
727
757
  # [fix] aiohttp FileResponse 不支持 content_type 参数,改用属性赋值
728
758
  resp = web.FileResponse(fpath)
729
759
  if mime:
730
760
  resp.content_type = mime
731
- 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}"
732
765
  return resp
733
766
 
734
767
  # --- [v1.17.0] Remote Desktop (VNC) ---
@@ -793,7 +826,8 @@ class ApiServer:
793
826
  """GET /vnc/{path} - 代理 noVNC 客户端页面
794
827
 
795
828
  如果 websockify 已配置 --web 目录,代理到该目录;
796
- 否则返回内置的轻量 VNC 客户端页面。
829
+ 否则尝试从 data/novnc/lib/ 目录提供本地 noVNC 库文件;
830
+ 最后返回内置的轻量 VNC 客户端页面。
797
831
  """
798
832
  mgr = self._get_vnc_manager()
799
833
  path = request.match_info.get('path', 'vnc.html')
@@ -817,6 +851,25 @@ class ApiServer:
817
851
  resp.content_type = content_type
818
852
  return resp
819
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
+
820
873
  # 方法2: 返回内置的轻量 noVNC 客户端
821
874
  if path in ('vnc.html', 'vnc_lite.html', ''):
822
875
  html = self._get_builtin_novnc_page(request)
@@ -955,22 +1008,139 @@ class ApiServer:
955
1008
  </div>
956
1009
  </div>
957
1010
 
958
- <!-- [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>
959
1128
  <script type="module">
960
1129
  try {{
961
- // 优先从本地服务器加载 noVNC(/vnc/lib/rfb.js),无需外网
962
1130
  let RFB = null;
963
1131
  let loadError = '';
964
1132
 
1133
+ // [v1.20.11] 优先从本地服务器加载 noVNC(data/novnc/lib/)
965
1134
  try {{
966
- const mod = await import('/vnc/lib/rfb.js');
967
- RFB = mod.default || mod.RFB;
1135
+ RFB = await loadLocalNoVNC();
968
1136
  }} catch (localErr) {{
969
1137
  loadError = localErr.message;
970
- // 本地加载失败,尝试 CDN 回退
1138
+ console.warn('[VNC] 本地加载失败,尝试 CDN:', localErr.message);
1139
+ // 本地加载失败,尝试 CDN 回退(含国内镜像)
971
1140
  const CDN_SOURCES = [
972
1141
  'https://cdn.jsdelivr.net/npm/@novnc/novnc@1.5.0/core/rfb.js',
973
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',
974
1144
  ];
975
1145
  for (const src of CDN_SOURCES) {{
976
1146
  try {{
@@ -1669,6 +1839,16 @@ window.addEventListener('beforeunload', function() {{
1669
1839
  chat_mode = data.get("mode", "") # "exec" = 执行模式
1670
1840
  escalated = data.get("escalated", False) # 临时提权到 local
1671
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
+
1672
1852
  # ── 全局执行锁检查 + 获取(原子操作,check+set 之间无 await)──
1673
1853
  agent_cfg_early = self._read_agent_config(agent_path)
1674
1854
  execution_mode = agent_cfg_early.get("execution_mode", "sandbox") if agent_cfg_early else "sandbox"
@@ -1740,10 +1920,39 @@ window.addEventListener('beforeunload', function() {{
1740
1920
  self._execution_lock["locked"] = False
1741
1921
  self._execution_lock["locked_by"] = None
1742
1922
  self._execution_lock["locked_at"] = None
1923
+ # [v1.20.13] 释放会话级锁
1924
+ try:
1925
+ session_lock.release()
1926
+ except RuntimeError:
1927
+ pass
1743
1928
 
1744
1929
  # ── 会话运行状态追踪 (用于断线重连) ──
1745
1930
  # {session_id: {running: bool, started_at: float, result: str, done: bool, error: str}}
1746
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)
1747
1956
 
1748
1957
  async def handle_chat_stream(self, request):
1749
1958
  """POST /api/chat/stream - SSE 流式聊天
@@ -1791,14 +2000,26 @@ window.addEventListener('beforeunload', function() {{
1791
2000
  escalated = data.get("escalated", False)
1792
2001
  voice_text = data.get("voice_text", "").strip() # 语音转文字原始文本(用于 usersays_correct)
1793
2002
 
1794
- # ── 检查是否有正在运行的同一会话任务 ──
1795
- running_info = self._running_sessions.get(session_id)
1796
- if running_info and running_info.get("running") and not running_info.get("done"):
1797
- # 返回当前运行状态,让前端恢复
1798
- return web.Response(
1799
- text="data: " + json.dumps({"type": "resume", "session_id": session_id, "running": True, "started_at": running_info.get("started_at")}) + "\n\n",
1800
- content_type="text/event-stream",
1801
- )
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()
1802
2023
 
1803
2024
  # Lock check
1804
2025
  agent_cfg_early = self._read_agent_config(agent_path)
@@ -2007,6 +2228,11 @@ window.addEventListener('beforeunload', function() {{
2007
2228
  self._execution_lock["locked"] = False
2008
2229
  self._execution_lock["locked_by"] = None
2009
2230
  self._execution_lock["locked_at"] = None
2231
+ # [v1.20.13] 释放会话级锁
2232
+ try:
2233
+ session_lock.release()
2234
+ except RuntimeError:
2235
+ pass
2010
2236
 
2011
2237
  # ── 启动后台任务(不等它完成,前端断开也不影响) ──
2012
2238
  logger.info(f"[{session_id}] 准备创建后台任务")
@@ -3103,6 +3329,9 @@ window.addEventListener('beforeunload', function() {{
3103
3329
  agent = {"path": agent_path, "name": d.name, **cfg}
3104
3330
  agent["avatar_color"] = cfg.get("avatar_color") or _agent_color(d.name)
3105
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"
3106
3335
  agents.append(agent)
3107
3336
  # 递归子目录
3108
3337
  agents.extend(self._scan_agents_flat(d, agent_path))
@@ -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> 且没有闭合内容,将其拆出恢复为纯文本
@@ -1854,13 +1854,19 @@ async function sendMessage(opts) {
1854
1854
  } else if (evt.type === 'v2_file') {
1855
1855
  // [v1.16.17] Agent is sending a file to the user
1856
1856
  // [v1.20.12] 修复: 后端发送 file_id,前端渲染代码期望 id
1857
+ // [v1.20.13] 修复: 去重,避免同一文件重复添加
1857
1858
  if (evt.data) {
1858
1859
  if (!state.messages[msgIdx]._files) state.messages[msgIdx]._files = [];
1859
1860
  var fd = evt.data;
1860
1861
  // 归一化: 后端用 file_id,渲染代码用 id
1861
1862
  if (fd.file_id && !fd.id) fd.id = fd.file_id;
1862
- state.messages[msgIdx]._files.push(fd);
1863
- throttledStreamUpdate(msgIdx);
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
+ }
1864
1870
  }
1865
1871
  } else if (evt.type === 'v2_media') {
1866
1872
  // [v1.20.3] Agent is embedding a media player (audio/video)