myagent-ai 1.19.0 → 1.19.1
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/core/vnc_manager.py +127 -11
- package/package.json +1 -1
- package/skills/chromedev_mcp.py +84 -14
- package/web/ui/chat/chat.css +18 -1
- package/web/ui/chat/chat_main.js +24 -1
- package/web/ui/index.html +28 -14
package/core/vnc_manager.py
CHANGED
|
@@ -661,6 +661,38 @@ class VNCManager:
|
|
|
661
661
|
async def _start_x11vnc(self) -> bool:
|
|
662
662
|
"""启动 x11vnc VNC 服务器"""
|
|
663
663
|
try:
|
|
664
|
+
# [v1.18.9] 启动前检查端口是否被占用,清理残留进程
|
|
665
|
+
if self._is_port_listening(self.x11vnc_port):
|
|
666
|
+
logger.warning(f"端口 {self.x11vnc_port} 已被占用,尝试清理残留 x11vnc 进程...")
|
|
667
|
+
try:
|
|
668
|
+
_result = subprocess.run(
|
|
669
|
+
["pgrep", "-f", f"x11vnc.*{self.display}"],
|
|
670
|
+
capture_output=True, text=True, timeout=5,
|
|
671
|
+
)
|
|
672
|
+
if _result.returncode == 0 and _result.stdout.strip():
|
|
673
|
+
for _pid_str in _result.stdout.strip().split("\n"):
|
|
674
|
+
try:
|
|
675
|
+
_pid = int(_pid_str.strip())
|
|
676
|
+
os.kill(_pid, signal.SIGTERM)
|
|
677
|
+
logger.info(f"杀死残留 x11vnc PID={_pid}")
|
|
678
|
+
except (ValueError, ProcessLookupError):
|
|
679
|
+
pass
|
|
680
|
+
await asyncio.sleep(1.0)
|
|
681
|
+
# SIGTERM 后仍占用则 SIGKILL
|
|
682
|
+
if self._is_port_listening(self.x11vnc_port):
|
|
683
|
+
for _pid_str in _result.stdout.strip().split("\n"):
|
|
684
|
+
try:
|
|
685
|
+
os.kill(int(_pid_str.strip()), signal.SIGKILL)
|
|
686
|
+
except (ValueError, ProcessLookupError):
|
|
687
|
+
pass
|
|
688
|
+
await asyncio.sleep(0.5)
|
|
689
|
+
if self._is_port_listening(self.x11vnc_port):
|
|
690
|
+
logger.warning(f"端口 {self.x11vnc_port} 仍被占用,但可能是旧进程,继续尝试启动")
|
|
691
|
+
else:
|
|
692
|
+
logger.debug(f"端口 {self.x11vnc_port} 被占用但未找到 display={self.display} 的 x11vnc 进程")
|
|
693
|
+
except Exception as _e:
|
|
694
|
+
logger.debug(f"清理残留 x11vnc 异常: {_e}")
|
|
695
|
+
|
|
664
696
|
# [v1.17.2] 处理 x11vnc 0.9.17+ 的 shm-helper 兼容问题
|
|
665
697
|
# 新版 x11vnc 编译时默认启用 shm-helper,但运行时需要绝对路径
|
|
666
698
|
# 查找 shm-helper 绝对路径
|
|
@@ -694,6 +726,8 @@ class VNCManager:
|
|
|
694
726
|
"-nocursorshape",
|
|
695
727
|
"-deferupdate", "5", # 延迟更新(降低带宽)
|
|
696
728
|
"-scale", "2/3", # 缩小 2/3(降低带宽)
|
|
729
|
+
# [v1.18.10] 抑制 Xvfb 环境下不支持的扩展警告
|
|
730
|
+
"-noxfixes", # 跳过 XFIXES 检查(Xvfb 可能不支持)
|
|
697
731
|
]
|
|
698
732
|
|
|
699
733
|
# [v1.18.5] 不使用 -threads 模式:与 -scale 和 -no-shm 组合在 0.9.17 中
|
|
@@ -725,7 +759,8 @@ class VNCManager:
|
|
|
725
759
|
)
|
|
726
760
|
|
|
727
761
|
# [v1.18.5] x11vnc 0.9.17 可能 fork 到后台,需要更长等待
|
|
728
|
-
|
|
762
|
+
# [v1.18.10] 增加首次等待时间和重试次数
|
|
763
|
+
await asyncio.sleep(3)
|
|
729
764
|
if self._x11vnc_process.poll() is not None:
|
|
730
765
|
# [v1.18.4] x11vnc 可能 fork 到后台运行,父进程退出但子进程仍在监听端口
|
|
731
766
|
# 等待更长时间让 fork 的子进程完成端口绑定
|
|
@@ -733,19 +768,18 @@ class VNCManager:
|
|
|
733
768
|
logger.warning(f"x11vnc 父进程已退出但端口 {self.x11vnc_port} 仍在监听(fork 到后台),视为启动成功")
|
|
734
769
|
return True
|
|
735
770
|
# 端口未监听,再等几秒(某些系统 fork 后子进程初始化慢)
|
|
736
|
-
logger.info("x11vnc 端口尚未就绪,额外等待
|
|
737
|
-
await asyncio.sleep(
|
|
771
|
+
logger.info("x11vnc 端口尚未就绪,额外等待 5 秒...")
|
|
772
|
+
await asyncio.sleep(5)
|
|
738
773
|
if self._is_port_listening(self.x11vnc_port):
|
|
739
774
|
logger.warning(f"x11vnc fork 延迟启动成功 (port={self.x11vnc_port})")
|
|
740
775
|
return True
|
|
741
|
-
#
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
return False
|
|
776
|
+
# [v1.18.10] 额外重试一次:彻底杀掉残留,重新启动 x11vnc
|
|
777
|
+
logger.warning("x11vnc 首次启动失败,尝试清理并重新启动...")
|
|
778
|
+
await self._kill_process(self._x11vnc_process, "x11vnc")
|
|
779
|
+
self._x11vnc_process = None
|
|
780
|
+
await asyncio.sleep(1)
|
|
781
|
+
# 第二次尝试启动
|
|
782
|
+
return await self._retry_x11vnc()
|
|
749
783
|
|
|
750
784
|
# 验证端口是否在监听
|
|
751
785
|
for _ in range(15):
|
|
@@ -775,6 +809,88 @@ class VNCManager:
|
|
|
775
809
|
logger.error(f"x11vnc 启动异常: {e}")
|
|
776
810
|
return False
|
|
777
811
|
|
|
812
|
+
async def _retry_x11vnc(self) -> bool:
|
|
813
|
+
"""[v1.18.10] x11vnc 首次启动失败后重试(简化参数,增加兼容性)"""
|
|
814
|
+
try:
|
|
815
|
+
# 先杀掉所有残留的 x11vnc 进程
|
|
816
|
+
try:
|
|
817
|
+
_result = subprocess.run(
|
|
818
|
+
["pgrep", "-f", f"x11vnc.*{self.display}"],
|
|
819
|
+
capture_output=True, text=True, timeout=5,
|
|
820
|
+
)
|
|
821
|
+
if _result.returncode == 0 and _result.stdout.strip():
|
|
822
|
+
for _pid_str in _result.stdout.strip().split("\n"):
|
|
823
|
+
try:
|
|
824
|
+
os.kill(int(_pid_str.strip()), signal.SIGKILL)
|
|
825
|
+
except (ValueError, ProcessLookupError):
|
|
826
|
+
pass
|
|
827
|
+
await asyncio.sleep(1)
|
|
828
|
+
except Exception:
|
|
829
|
+
pass
|
|
830
|
+
|
|
831
|
+
# 第二次尝试使用更保守的参数(去掉 -scale、-noxfixes 等可能的问题参数)
|
|
832
|
+
cmd = [
|
|
833
|
+
"x11vnc",
|
|
834
|
+
"-display", self.display,
|
|
835
|
+
"-forever",
|
|
836
|
+
"-shared",
|
|
837
|
+
"-nopw",
|
|
838
|
+
"-rfbport", str(self.x11vnc_port),
|
|
839
|
+
"-listen", "127.0.0.1",
|
|
840
|
+
"-xkb",
|
|
841
|
+
"-noxdamage",
|
|
842
|
+
"-nowf",
|
|
843
|
+
"-deferupdate", "5",
|
|
844
|
+
"-no-shm",
|
|
845
|
+
"-nobell",
|
|
846
|
+
]
|
|
847
|
+
env = {**os.environ, "DISPLAY": self.display}
|
|
848
|
+
env["X11VNC_NO_UNIXPW"] = "1"
|
|
849
|
+
|
|
850
|
+
logger.info(f"x11vnc 重试启动: {' '.join(cmd)}")
|
|
851
|
+
self._x11vnc_process = subprocess.Popen(
|
|
852
|
+
cmd,
|
|
853
|
+
stdin=subprocess.DEVNULL,
|
|
854
|
+
stdout=subprocess.DEVNULL,
|
|
855
|
+
stderr=subprocess.PIPE,
|
|
856
|
+
env=env,
|
|
857
|
+
preexec_fn=os.setpgrp,
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
await asyncio.sleep(5)
|
|
861
|
+
if self._is_port_listening(self.x11vnc_port):
|
|
862
|
+
logger.info(f"x11vnc 重试启动成功 (port={self.x11vnc_port})")
|
|
863
|
+
return True
|
|
864
|
+
|
|
865
|
+
if self._x11vnc_process.poll() is not None:
|
|
866
|
+
stderr = ""
|
|
867
|
+
try:
|
|
868
|
+
stderr = self._x11vnc_process.stderr.read().decode("utf-8", errors="replace")[:3000]
|
|
869
|
+
except Exception:
|
|
870
|
+
pass
|
|
871
|
+
logger.error(f"x11vnc 重试启动失败: {stderr}")
|
|
872
|
+
return False
|
|
873
|
+
|
|
874
|
+
# 进程还活着但端口未就绪,再等一轮
|
|
875
|
+
for _ in range(15):
|
|
876
|
+
try:
|
|
877
|
+
import socket
|
|
878
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
879
|
+
s.settimeout(0.5)
|
|
880
|
+
s.connect(("127.0.0.1", self.x11vnc_port))
|
|
881
|
+
s.close()
|
|
882
|
+
logger.info(f"x11vnc 重试后启动成功 (port={self.x11vnc_port})")
|
|
883
|
+
return True
|
|
884
|
+
except (OSError, ConnectionRefusedError):
|
|
885
|
+
await asyncio.sleep(0.3)
|
|
886
|
+
|
|
887
|
+
logger.error("x11vnc 重试后端口仍超时,放弃")
|
|
888
|
+
return False
|
|
889
|
+
|
|
890
|
+
except Exception as e:
|
|
891
|
+
logger.error(f"x11vnc 重试异常: {e}")
|
|
892
|
+
return False
|
|
893
|
+
|
|
778
894
|
async def _start_websockify(self) -> bool:
|
|
779
895
|
"""启动 websockify WebSocket 代理(VNC → WebSocket)"""
|
|
780
896
|
try:
|
package/package.json
CHANGED
package/skills/chromedev_mcp.py
CHANGED
|
@@ -987,6 +987,76 @@ def _ensure_node_deps() -> Optional[str]:
|
|
|
987
987
|
return None
|
|
988
988
|
|
|
989
989
|
|
|
990
|
+
# [v1.18.9] 共享的 MCP 工具调用函数,带自动重试(所有浏览器技能通用)
|
|
991
|
+
_MCP_CRASH_KEYWORDS = [
|
|
992
|
+
"Target closed", "Target destroyed", "Protocol error",
|
|
993
|
+
"Browser closed", "Connection closed", "Session closed",
|
|
994
|
+
"Connection refused", "net::ERR_CONNECTION_REFUSED",
|
|
995
|
+
]
|
|
996
|
+
|
|
997
|
+
async def _call_mcp_with_retry(
|
|
998
|
+
tool_name: str,
|
|
999
|
+
arguments: dict,
|
|
1000
|
+
timeout: float = 30,
|
|
1001
|
+
max_retries: int = 1,
|
|
1002
|
+
) -> dict:
|
|
1003
|
+
"""调用 MCP 工具,遇到 Chrome 崩溃时自动重建客户端并重试。
|
|
1004
|
+
|
|
1005
|
+
Args:
|
|
1006
|
+
tool_name: MCP 工具名 (navigate_page, click, fill, etc.)
|
|
1007
|
+
arguments: 工具参数字典
|
|
1008
|
+
timeout: 单次调用超时秒数
|
|
1009
|
+
max_retries: 额外重试次数 (默认 1)
|
|
1010
|
+
|
|
1011
|
+
Returns:
|
|
1012
|
+
MCP 工具返回的 dict
|
|
1013
|
+
"""
|
|
1014
|
+
last_error = None
|
|
1015
|
+
for attempt in range(max_retries + 1):
|
|
1016
|
+
client = await get_mcp_client()
|
|
1017
|
+
if not client or not client.is_running() or not client._initialized:
|
|
1018
|
+
if attempt < max_retries:
|
|
1019
|
+
logger.warning("[MCP] 客户端不可用,正在重建...")
|
|
1020
|
+
try:
|
|
1021
|
+
await rebuild_mcp_client()
|
|
1022
|
+
except Exception:
|
|
1023
|
+
pass
|
|
1024
|
+
await asyncio.sleep(0.5)
|
|
1025
|
+
continue
|
|
1026
|
+
return {"error": "MCP 客户端不可用,请重试"}
|
|
1027
|
+
|
|
1028
|
+
try:
|
|
1029
|
+
result = await client.call_tool(tool_name, arguments, timeout=timeout)
|
|
1030
|
+
# 检查返回值中是否有崩溃指示
|
|
1031
|
+
result_str = str(result.get("error", "")) if isinstance(result, dict) else str(result)
|
|
1032
|
+
if any(kw in result_str for kw in _MCP_CRASH_KEYWORDS):
|
|
1033
|
+
if attempt < max_retries:
|
|
1034
|
+
logger.warning(f"[MCP] Chrome 连接丢失 ({result_str[:120]}),正在重建 MCP 客户端并重试 ({attempt+1}/{max_retries})...")
|
|
1035
|
+
try:
|
|
1036
|
+
await rebuild_mcp_client()
|
|
1037
|
+
except Exception:
|
|
1038
|
+
pass
|
|
1039
|
+
await asyncio.sleep(1.0)
|
|
1040
|
+
continue
|
|
1041
|
+
return result
|
|
1042
|
+
except Exception as e:
|
|
1043
|
+
err_str = str(e)
|
|
1044
|
+
last_error = err_str
|
|
1045
|
+
if any(kw in err_str for kw in _MCP_CRASH_KEYWORDS):
|
|
1046
|
+
if attempt < max_retries:
|
|
1047
|
+
logger.warning(f"[MCP] Chrome 异常 ({err_str[:120]}),正在重建 MCP 客户端并重试 ({attempt+1}/{max_retries})...")
|
|
1048
|
+
try:
|
|
1049
|
+
await rebuild_mcp_client()
|
|
1050
|
+
except Exception:
|
|
1051
|
+
pass
|
|
1052
|
+
await asyncio.sleep(1.0)
|
|
1053
|
+
continue
|
|
1054
|
+
# 非崩溃类错误,直接抛出
|
|
1055
|
+
raise
|
|
1056
|
+
|
|
1057
|
+
return {"error": f"MCP 调用失败: {last_error or '重试耗尽'}"}
|
|
1058
|
+
|
|
1059
|
+
|
|
990
1060
|
def _parse_json_response(text: str) -> Any:
|
|
991
1061
|
"""尝试从 MCP 工具返回的文本中解析 JSON"""
|
|
992
1062
|
if not text:
|
|
@@ -1244,7 +1314,7 @@ class BrowserClickSkill(Skill):
|
|
|
1244
1314
|
elif text:
|
|
1245
1315
|
params["ref"] = text # MCP click 支持 ref 参数
|
|
1246
1316
|
|
|
1247
|
-
click_result = await
|
|
1317
|
+
click_result = await _call_mcp_with_retry("click", params, timeout=30)
|
|
1248
1318
|
if click_result.get("isError") or click_result.get("error"):
|
|
1249
1319
|
error_msg = click_result.get("error") or click_result.get("text", "点击失败")
|
|
1250
1320
|
return SkillResult(success=False, error=f"点击元素失败: {error_msg}")
|
|
@@ -1254,7 +1324,7 @@ class BrowserClickSkill(Skill):
|
|
|
1254
1324
|
await asyncio.sleep(wait_after / 1000)
|
|
1255
1325
|
|
|
1256
1326
|
# 获取点击后页面状态
|
|
1257
|
-
eval_result = await
|
|
1327
|
+
eval_result = await _call_mcp_with_retry("evaluate_script", {
|
|
1258
1328
|
"script": "() => ({ title: document.title, url: window.location.href, text: document.body.innerText.substring(0, 3000) })"
|
|
1259
1329
|
}, timeout=10)
|
|
1260
1330
|
|
|
@@ -1317,14 +1387,14 @@ class BrowserFillSkill(Skill):
|
|
|
1317
1387
|
elif selector:
|
|
1318
1388
|
params["selector"] = selector
|
|
1319
1389
|
|
|
1320
|
-
fill_result = await
|
|
1390
|
+
fill_result = await _call_mcp_with_retry("fill", params, timeout=30)
|
|
1321
1391
|
if fill_result.get("isError") or fill_result.get("error"):
|
|
1322
1392
|
error_msg = fill_result.get("error") or fill_result.get("text", "填写失败")
|
|
1323
1393
|
return SkillResult(success=False, error=f"填写输入框失败: {error_msg}")
|
|
1324
1394
|
|
|
1325
1395
|
# 按回车(可选)
|
|
1326
1396
|
if press_enter:
|
|
1327
|
-
await
|
|
1397
|
+
await _call_mcp_with_retry("press_key", {"key": "Enter"}, timeout=10)
|
|
1328
1398
|
await asyncio.sleep(1)
|
|
1329
1399
|
|
|
1330
1400
|
loc_desc = f"ref '{ref}'" if ref else f"选择器 '{selector}'"
|
|
@@ -1372,7 +1442,7 @@ class BrowserScreenshotSkill(Skill):
|
|
|
1372
1442
|
if selector:
|
|
1373
1443
|
params["selector"] = selector
|
|
1374
1444
|
|
|
1375
|
-
shot_result = await
|
|
1445
|
+
shot_result = await _call_mcp_with_retry("take_screenshot", params, timeout=30)
|
|
1376
1446
|
if shot_result.get("isError") or shot_result.get("error"):
|
|
1377
1447
|
error_msg = shot_result.get("error") or shot_result.get("text", "截图失败")
|
|
1378
1448
|
return SkillResult(success=False, error=f"截图失败: {error_msg}")
|
|
@@ -1432,7 +1502,7 @@ class BrowserEvalSkill(Skill):
|
|
|
1432
1502
|
try:
|
|
1433
1503
|
client = await get_mcp_client()
|
|
1434
1504
|
|
|
1435
|
-
eval_result = await
|
|
1505
|
+
eval_result = await _call_mcp_with_retry("evaluate_script", {"script": code}, timeout=30)
|
|
1436
1506
|
if eval_result.get("isError") or eval_result.get("error"):
|
|
1437
1507
|
error_msg = eval_result.get("error") or eval_result.get("text", "执行失败")
|
|
1438
1508
|
return SkillResult(success=False, error=f"JavaScript 执行失败: {error_msg}")
|
|
@@ -1483,7 +1553,7 @@ class BrowserNavigateSkill(Skill):
|
|
|
1483
1553
|
client = await get_mcp_client()
|
|
1484
1554
|
|
|
1485
1555
|
if action == "list_tabs":
|
|
1486
|
-
result = await
|
|
1556
|
+
result = await _call_mcp_with_retry("list_pages", {}, timeout=15)
|
|
1487
1557
|
text = result.get("text", "")
|
|
1488
1558
|
return SkillResult(
|
|
1489
1559
|
success=True,
|
|
@@ -1492,12 +1562,12 @@ class BrowserNavigateSkill(Skill):
|
|
|
1492
1562
|
)
|
|
1493
1563
|
|
|
1494
1564
|
elif action == "new_tab":
|
|
1495
|
-
result = await
|
|
1565
|
+
result = await _call_mcp_with_retry("new_page", {}, timeout=15)
|
|
1496
1566
|
if result.get("error"):
|
|
1497
1567
|
return SkillResult(success=False, error=f"打开新标签页失败: {result['error']}")
|
|
1498
1568
|
text = result.get("text", "")
|
|
1499
1569
|
if url:
|
|
1500
|
-
nav_result = await
|
|
1570
|
+
nav_result = await _call_mcp_with_retry("navigate_page", {"url": url}, timeout=30)
|
|
1501
1571
|
if nav_result.get("error"):
|
|
1502
1572
|
return SkillResult(success=False, error=f"导航失败: {nav_result['error']}")
|
|
1503
1573
|
text += f" → {url}"
|
|
@@ -1510,7 +1580,7 @@ class BrowserNavigateSkill(Skill):
|
|
|
1510
1580
|
elif action == "switch_tab":
|
|
1511
1581
|
if not page_id:
|
|
1512
1582
|
return SkillResult(success=False, error="switch_tab 需要指定 page_id 参数")
|
|
1513
|
-
result = await
|
|
1583
|
+
result = await _call_mcp_with_retry("select_page", {"pageId": page_id}, timeout=15)
|
|
1514
1584
|
if result.get("error"):
|
|
1515
1585
|
return SkillResult(success=False, error=f"切换标签页失败: {result['error']}")
|
|
1516
1586
|
return SkillResult(
|
|
@@ -1520,7 +1590,7 @@ class BrowserNavigateSkill(Skill):
|
|
|
1520
1590
|
)
|
|
1521
1591
|
|
|
1522
1592
|
elif action == "close_tab":
|
|
1523
|
-
result = await
|
|
1593
|
+
result = await _call_mcp_with_retry("close_page", {}, timeout=15)
|
|
1524
1594
|
return SkillResult(
|
|
1525
1595
|
success=True,
|
|
1526
1596
|
data={},
|
|
@@ -1529,7 +1599,7 @@ class BrowserNavigateSkill(Skill):
|
|
|
1529
1599
|
|
|
1530
1600
|
elif action == "back":
|
|
1531
1601
|
# 通过 JS 实现后退
|
|
1532
|
-
result = await
|
|
1602
|
+
result = await _call_mcp_with_retry("evaluate_script", {
|
|
1533
1603
|
"script": "() => { window.history.back(); return document.title; }"
|
|
1534
1604
|
}, timeout=15)
|
|
1535
1605
|
await asyncio.sleep(1)
|
|
@@ -1541,7 +1611,7 @@ class BrowserNavigateSkill(Skill):
|
|
|
1541
1611
|
)
|
|
1542
1612
|
|
|
1543
1613
|
elif action == "forward":
|
|
1544
|
-
result = await
|
|
1614
|
+
result = await _call_mcp_with_retry("evaluate_script", {
|
|
1545
1615
|
"script": "() => { window.history.forward(); return document.title; }"
|
|
1546
1616
|
}, timeout=15)
|
|
1547
1617
|
await asyncio.sleep(1)
|
|
@@ -1588,7 +1658,7 @@ class BrowserCloseSkill(Skill):
|
|
|
1588
1658
|
)
|
|
1589
1659
|
else:
|
|
1590
1660
|
client = await get_mcp_client()
|
|
1591
|
-
result = await
|
|
1661
|
+
result = await _call_mcp_with_retry("close_page", {}, timeout=15)
|
|
1592
1662
|
return SkillResult(
|
|
1593
1663
|
success=True,
|
|
1594
1664
|
data={},
|
package/web/ui/chat/chat.css
CHANGED
|
@@ -2253,7 +2253,19 @@ body.popout-mode #popoutBtn{display:none !important}
|
|
|
2253
2253
|
display:flex;
|
|
2254
2254
|
}
|
|
2255
2255
|
|
|
2256
|
-
/* ──
|
|
2256
|
+
/* ── Override desktop collapsed state on mobile: always full-width overlay, never icon-only ── */
|
|
2257
|
+
.sidebar.collapsed{
|
|
2258
|
+
width:85vw;min-width:0;max-width:320px;
|
|
2259
|
+
}
|
|
2260
|
+
.sidebar.collapsed .sidebar-header .sidebar-title,
|
|
2261
|
+
.sidebar.collapsed .sidebar-header .sidebar-subtitle,
|
|
2262
|
+
.sidebar.collapsed .new-chat-btn span,
|
|
2263
|
+
.sidebar.collapsed .sidebar-header .sidebar-logo + div,
|
|
2264
|
+
.sidebar.collapsed #sidebarAgentIndicator{display:flex!important}
|
|
2265
|
+
.sidebar.collapsed .sidebar-header{justify-content:flex-start;padding:16px 20px}
|
|
2266
|
+
.sidebar.collapsed .new-chat-btn{padding:12px 16px;justify-content:center}
|
|
2267
|
+
|
|
2268
|
+
/* ── Show sidebar content on mobile (even when collapsed from desktop) ── */
|
|
2257
2269
|
.sidebar.collapsed .sidebar-footer,
|
|
2258
2270
|
.sidebar.collapsed .sidebar-search,
|
|
2259
2271
|
.sidebar.collapsed .session-list{
|
|
@@ -2264,6 +2276,11 @@ body.popout-mode #popoutBtn{display:none !important}
|
|
|
2264
2276
|
.sidebar.collapsed .sidebar-footer{flex-direction:column}
|
|
2265
2277
|
.sidebar-footer{padding-bottom:max(16px, calc(env(safe-area-inset-bottom) + 8px))}
|
|
2266
2278
|
|
|
2279
|
+
/* [v1.18.9] 确保移动端 sidebar 不影响主内容区域布局 */
|
|
2280
|
+
.app{display:flex;flex-direction:column;width:100%;height:100vh;height:100dvh}
|
|
2281
|
+
.chat-main{flex:1;overflow:hidden;display:flex;flex-direction:column;width:100%}
|
|
2282
|
+
.content-area{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
|
2283
|
+
|
|
2267
2284
|
/* ── Header ── */
|
|
2268
2285
|
.main-header{
|
|
2269
2286
|
height:50px;min-height:50px;
|
package/web/ui/chat/chat_main.js
CHANGED
|
@@ -34,7 +34,9 @@ function toggleSidebar() {
|
|
|
34
34
|
const sidebar = document.getElementById('sidebar');
|
|
35
35
|
const toggleBtn = document.getElementById('sidebarToggle');
|
|
36
36
|
if (!sidebar) return;
|
|
37
|
+
// [v1.18.9] 移动端始终移除 collapsed 状态,确保不会残留桌面端的折叠样式
|
|
37
38
|
if (isMobile()) {
|
|
39
|
+
sidebar.classList.remove('collapsed');
|
|
38
40
|
// Mobile: toggle as overlay
|
|
39
41
|
const overlay = document.getElementById('chatMobileOverlay');
|
|
40
42
|
const isOpen = sidebar.classList.contains('mobile-open');
|
|
@@ -169,6 +171,15 @@ setTimeout(function() {
|
|
|
169
171
|
if (sidebar) sidebar.classList.add('collapsed');
|
|
170
172
|
if (toggleBtn) toggleBtn.textContent = '▶';
|
|
171
173
|
}
|
|
174
|
+
// [v1.18.9] 窗口大小变化时清理 collapsed 状态:移动端不应用桌面折叠
|
|
175
|
+
window.addEventListener('resize', function() {
|
|
176
|
+
const sidebar = document.getElementById('sidebar');
|
|
177
|
+
if (!sidebar) return;
|
|
178
|
+
if (isMobile()) {
|
|
179
|
+
sidebar.classList.remove('collapsed');
|
|
180
|
+
closeMobileSidebar();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
172
183
|
})();
|
|
173
184
|
|
|
174
185
|
// ── State ──
|
|
@@ -186,6 +197,7 @@ const state = {
|
|
|
186
197
|
execTimerInterval: null, // polling interval ID for execution progress
|
|
187
198
|
systemStatus: null,
|
|
188
199
|
_sessionLoadSeq: 0, // 防止 selectSession 并发竞态的序号
|
|
200
|
+
_selectedSessionLabel: null, // [v1.18.9] 保存选中的会话名称,防止竞态覆盖
|
|
189
201
|
// ── Execution mode escalation ──
|
|
190
202
|
escalated: false, // 当前会话是否临时提权到 local
|
|
191
203
|
execLockInfo: null, // 全局锁信息 {locked, locked_by, locked_at}
|
|
@@ -1410,7 +1422,9 @@ async function selectAgent(agentPath) {
|
|
|
1410
1422
|
|
|
1411
1423
|
// loadSessions 内部会 auto-select 最新 session;
|
|
1412
1424
|
// 如果没有 session,则保持 "新对话" 空窗状态
|
|
1425
|
+
// [v1.18.9] 如果 loadSessions 已成功选中了 session,不要覆盖其标题
|
|
1413
1426
|
if (!state.activeSessionId || state.activeSessionId === '__new__') {
|
|
1427
|
+
state._selectedSessionLabel = null;
|
|
1414
1428
|
var selAgent = findAgentByPath(agentPath);
|
|
1415
1429
|
var agentLabel = selAgent ? (selAgent.avatar_emoji + ' ' + selAgent.name) : agentPath;
|
|
1416
1430
|
document.getElementById('headerTitle').textContent = '新对话';
|
|
@@ -1908,6 +1922,7 @@ function filterSessions() {
|
|
|
1908
1922
|
|
|
1909
1923
|
function newChat() {
|
|
1910
1924
|
state.activeSessionId = '__new__';
|
|
1925
|
+
state._selectedSessionLabel = null; // [v1.18.9] 清除选中的会话名称
|
|
1911
1926
|
// ── 更新 URL(新对话移除 session 参数) ──
|
|
1912
1927
|
try {
|
|
1913
1928
|
const url = new URL(window.location.href);
|
|
@@ -2015,6 +2030,8 @@ async function selectSession(id) {
|
|
|
2015
2030
|
var selAgent = findAgentByPath(state.activeAgent);
|
|
2016
2031
|
var agentLabel = selAgent ? (selAgent.avatar_emoji + ' ' + selAgent.name) : '';
|
|
2017
2032
|
var sessionLabel = session ? session.name : formatSessionName(id);
|
|
2033
|
+
// [v1.18.9] 保存选中的会话名称,防止后续竞态覆盖
|
|
2034
|
+
state._selectedSessionLabel = sessionLabel;
|
|
2018
2035
|
document.getElementById('headerTitle').textContent = sessionLabel;
|
|
2019
2036
|
renderSessions();
|
|
2020
2037
|
|
|
@@ -2064,8 +2081,13 @@ async function selectSession(id) {
|
|
|
2064
2081
|
} catch (e) {
|
|
2065
2082
|
if (mySeq !== state._sessionLoadSeq) return;
|
|
2066
2083
|
state.messages = [];
|
|
2084
|
+
console.error('[selectSession] 加载消息失败:', e);
|
|
2067
2085
|
toast('加载消息失败', 'error');
|
|
2068
2086
|
}
|
|
2087
|
+
// [v1.18.9] 最终确认:如果当前仍是该会话,确保标题正确(防止竞态覆盖)
|
|
2088
|
+
if (state.activeSessionId === id && state._selectedSessionLabel) {
|
|
2089
|
+
document.getElementById('headerTitle').textContent = state._selectedSessionLabel;
|
|
2090
|
+
}
|
|
2069
2091
|
renderMessages();
|
|
2070
2092
|
// 恢复滚动位置(刷新后保持用户之前的阅读位置)
|
|
2071
2093
|
restoreScrollPosition();
|
|
@@ -3019,7 +3041,8 @@ function openFileViewer(fileId, src, fileName) {
|
|
|
3019
3041
|
'</style></head><body>' +
|
|
3020
3042
|
'<div class="toolbar">' +
|
|
3021
3043
|
'<span class="fname">' + escapeHtml(fileName || '文件') + '</span>' +
|
|
3022
|
-
|
|
3044
|
+
// [v1.18.9] 下载按钮:优先使用 fileId,其次尝试从 src 提取
|
|
3045
|
+
(function() { var _dl = '', _fid = fileId; if (!_fid && src && src.indexOf('/api/file/') === 0) { _fid = src.replace('/api/file/', '').split('/')[0]; } if (_fid) { _dl = '<button onclick="location.href=\'/api/file/' + _fid + '/download\'">⬇ 下载</button>'; } return _dl; })() +
|
|
3023
3046
|
'<button class="close-btn" onclick="window.close()">✕ 关闭</button>' +
|
|
3024
3047
|
'</div><div class="viewer">';
|
|
3025
3048
|
|
package/web/ui/index.html
CHANGED
|
@@ -177,9 +177,10 @@ tr:hover{background:var(--surface2)}
|
|
|
177
177
|
[data-theme="claude"] .badge-blue{background:#4a7fc922}
|
|
178
178
|
/* ── Mobile Responsive ── */
|
|
179
179
|
@media(max-width:768px){
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
/* [v1.18.10] 移动端侧边栏:收起时与内容同平面(flex行布局),展开时才浮层覆盖 */
|
|
181
|
+
body{flex-direction:row;height:100vh;overflow:hidden}
|
|
182
|
+
/* 默认收起状态:同一平面,56px窄栏,不浮动,不溢出 */
|
|
183
|
+
.sidebar{position:relative;left:auto;top:auto;height:auto;width:56px;z-index:auto;flex-shrink:0;transition:width .3s ease;overflow:hidden}
|
|
183
184
|
.sidebar .nav-item{justify-content:center;padding:10px 0;position:relative}
|
|
184
185
|
.sidebar .nav-item .icon-text{display:none}
|
|
185
186
|
.sidebar .logo .logo-text{display:none}
|
|
@@ -192,21 +193,22 @@ tr:hover{background:var(--surface2)}
|
|
|
192
193
|
white-space:nowrap;opacity:0;visibility:hidden;pointer-events:none;transition:opacity .15s,visibility .15s;z-index:100;box-shadow:0 2px 8px rgba(0,0,0,.15);
|
|
193
194
|
}
|
|
194
195
|
.sidebar .nav-item:hover::after{opacity:1;visibility:visible}
|
|
195
|
-
/*
|
|
196
|
-
.sidebar.mobile-open{width:260px;max-width:80vw}
|
|
196
|
+
/* 展开状态:浮层覆盖 + 遮罩 */
|
|
197
|
+
.sidebar.mobile-open{position:fixed;left:0;top:0;height:100vh;width:260px;max-width:80vw;z-index:50}
|
|
197
198
|
.sidebar.mobile-open .nav-item{justify-content:flex-start;padding:8px 12px}
|
|
198
199
|
.sidebar.mobile-open .nav-item .icon-text{display:inline!important}
|
|
199
200
|
.sidebar.mobile-open .nav-item::after{display:none}
|
|
200
201
|
.sidebar.mobile-open .logo .logo-text{display:inline!important}
|
|
201
202
|
.sidebar.mobile-open .logo{justify-content:flex-start;padding:16px 12px}
|
|
202
203
|
.sidebar.mobile-open .sidebar-footer-text{display:block!important}
|
|
203
|
-
.sidebar.collapsed{width:56px;transform:
|
|
204
|
-
.sidebar.collapsed.mobile-open{width:260px;max-width:80vw;transform:
|
|
204
|
+
.sidebar.collapsed{width:56px;position:relative;left:auto;top:auto;z-index:auto;transform:none}
|
|
205
|
+
.sidebar.collapsed.mobile-open{width:260px;max-width:80vw;position:fixed;left:0;top:0;z-index:50;transform:none}
|
|
205
206
|
.sidebar-toggle{display:none!important}
|
|
206
207
|
.sidebar-footer-text{padding-bottom:max(12px, calc(env(safe-area-inset-bottom) + 8px))}
|
|
207
208
|
.mobile-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:45;display:none}
|
|
208
209
|
.mobile-overlay.active{display:block}
|
|
209
|
-
.
|
|
210
|
+
.main{flex:1;min-width:0;min-height:0;overflow:hidden}
|
|
211
|
+
.header{padding:12px 16px;padding-left:12px}
|
|
210
212
|
.header h2{font-size:16px}
|
|
211
213
|
.content{padding:16px}
|
|
212
214
|
.grid{grid-template-columns:1fr}
|
|
@@ -228,10 +230,10 @@ tr:hover{background:var(--surface2)}
|
|
|
228
230
|
.toast{left:16px;right:16px;bottom:16px}
|
|
229
231
|
.agent-card{flex-direction:column;align-items:flex-start}
|
|
230
232
|
.agent-card .flex.flex-col{flex-direction:row;gap:4px}
|
|
231
|
-
/*
|
|
233
|
+
/* 技能行移动端允许换行 */
|
|
232
234
|
.skill-row{flex-wrap:wrap}
|
|
233
235
|
.skill-row .flex{flex-wrap:wrap;gap:4px}
|
|
234
|
-
/*
|
|
236
|
+
/* 任务表格优化 */
|
|
235
237
|
.table-wrap table{min-width:auto}
|
|
236
238
|
.table-wrap td[style*="max-width:300px"]{max-width:150px!important;white-space:normal!important;word-break:break-all}
|
|
237
239
|
}
|
|
@@ -362,17 +364,29 @@ if(localStorage.getItem('myagent-admin-sidebar-collapsed')==='true'){document.ge
|
|
|
362
364
|
function toggleMobileSidebar(){
|
|
363
365
|
const s=document.getElementById('adminSidebar');
|
|
364
366
|
const o=document.getElementById('adminMobileOverlay');
|
|
365
|
-
s.classList.
|
|
366
|
-
o.classList.
|
|
367
|
+
const isOpen=s.classList.contains('mobile-open');
|
|
368
|
+
if(isOpen){closeMobileSidebar();}else{s.classList.add('mobile-open');o.classList.add('active');}
|
|
367
369
|
}
|
|
368
370
|
function closeMobileSidebar(){
|
|
369
371
|
document.getElementById('adminSidebar').classList.remove('mobile-open');
|
|
370
372
|
document.getElementById('adminMobileOverlay').classList.remove('active');
|
|
371
373
|
}
|
|
372
|
-
// Show hamburger on mobile
|
|
373
|
-
function checkMobile(){
|
|
374
|
+
// Show hamburger on mobile + tap sidebar to expand on mobile
|
|
375
|
+
function checkMobile(){
|
|
376
|
+
const btn=document.getElementById('mobileMenuBtn');
|
|
377
|
+
if(btn)btn.style.display=window.innerWidth<=768?'grid':'none';
|
|
378
|
+
}
|
|
374
379
|
window.addEventListener('resize',checkMobile);
|
|
375
380
|
checkMobile();
|
|
381
|
+
// [v1.18.10] 移动端点击侧滑栏窄栏区域也可展开
|
|
382
|
+
document.getElementById('adminSidebar')?.addEventListener('click',function(e){
|
|
383
|
+
if(window.innerWidth>768)return;
|
|
384
|
+
// 如果已经是展开状态,不处理(让 nav-item 的 onclick 正常执行)
|
|
385
|
+
if(this.classList.contains('mobile-open'))return;
|
|
386
|
+
// 只在点击侧滑栏本身(非具体按钮/链接)时展开
|
|
387
|
+
if(e.target.closest('.nav-item')||e.target.closest('a')||e.target.closest('button'))return;
|
|
388
|
+
toggleMobileSidebar();
|
|
389
|
+
});
|
|
376
390
|
|
|
377
391
|
loadVersion();
|
|
378
392
|
setTimeout(()=>checkUpdate(false),30000);
|