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 +17 -1
- package/package.json +1 -1
- package/requirements.txt +2 -0
- package/web/api_server.py +245 -16
- package/web/ui/chat/chat.css +2 -1
- package/web/ui/chat/chat_main.js +53 -10
- package/web/ui/chat/flow_engine.js +8 -2
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
|
-
|
|
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
package/requirements.txt
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
967
|
-
RFB = mod.default || mod.RFB;
|
|
1135
|
+
RFB = await loadLocalNoVNC();
|
|
968
1136
|
}} catch (localErr) {{
|
|
969
1137
|
loadError = localErr.message;
|
|
970
|
-
|
|
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
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
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))
|
package/web/ui/chat/chat.css
CHANGED
|
@@ -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
|
|
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;
|
package/web/ui/chat/chat_main.js
CHANGED
|
@@ -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">' +
|
|
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
|
|
1330
|
-
|
|
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 + '">' +
|
|
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
|
-
|
|
1410
|
-
var
|
|
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 + '">' +
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1863
|
-
|
|
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)
|