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 +17 -1
- package/package.json +1 -1
- package/requirements.txt +2 -0
- package/web/api_server.py +263 -24
- package/web/ui/chat/chat.css +2 -1
- package/web/ui/chat/chat_main.js +53 -10
- package/web/ui/chat/flow_engine.js +12 -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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
957
|
-
RFB = mod.default || mod.RFB;
|
|
1135
|
+
RFB = await loadLocalNoVNC();
|
|
958
1136
|
}} catch (localErr) {{
|
|
959
1137
|
loadError = localErr.message;
|
|
960
|
-
|
|
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
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
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)
|
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> 且没有闭合内容,将其拆出恢复为纯文本
|
|
@@ -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
|
-
|
|
1859
|
-
|
|
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)
|