myagent-ai 1.18.9 → 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.
@@ -1589,13 +1589,17 @@ class MainAgent(BaseAgent):
1589
1589
  _fresult = await _fskill.execute(_fpath, _fdesc, stream_callback=stream_callback)
1590
1590
  result = {"success": True, "output": json.dumps(_fresult, ensure_ascii=False, indent=2), "data": _fresult}
1591
1591
  # [v1.18.5] 追踪发送的文件,用于持久化到会话记忆
1592
- if _fresult.get("success") and _fresult.get("file_id"):
1593
- _sent_files.append({
1594
- "id": _fresult["file_id"],
1595
- "name": _fresult.get("name", ""),
1596
- "type": _fresult.get("type", ""),
1597
- "size": _fresult.get("size", 0),
1598
- })
1592
+ # [v1.18.9] 修复:_sent_files 可能不在作用域内,安全处理
1593
+ try:
1594
+ if _fresult.get("success") and _fresult.get("file_id"):
1595
+ _sent_files.append({
1596
+ "id": _fresult["file_id"],
1597
+ "name": _fresult.get("name", ""),
1598
+ "type": _fresult.get("type", ""),
1599
+ "size": _fresult.get("size", 0),
1600
+ })
1601
+ except NameError:
1602
+ pass # _sent_files 不在当前作用域,跳过文件追踪
1599
1603
  except Exception as _fse:
1600
1604
  result = {"success": False, "error": f"文件发送失败: {_fse}"}
1601
1605
  logger.warning(f"[{task_id}] file_send 工具异常: {_fse}")
@@ -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
- await asyncio.sleep(2.5)
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 端口尚未就绪,额外等待 3 秒...")
737
- await asyncio.sleep(3)
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
- stderr = ""
743
- try:
744
- stderr = self._x11vnc_process.stderr.read().decode("utf-8", errors="replace")[:2000]
745
- except Exception:
746
- pass
747
- logger.error(f"x11vnc 启动失败: {stderr}")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.18.9",
3
+ "version": "1.19.1",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
@@ -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 client.call_tool("click", params, timeout=30)
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 client.call_tool("evaluate_script", {
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 client.call_tool("fill", params, timeout=30)
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 client.call_tool("press_key", {"key": "Enter"}, timeout=10)
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 client.call_tool("take_screenshot", params, timeout=30)
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 client.call_tool("evaluate_script", {"script": code}, timeout=30)
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 client.call_tool("list_pages", {}, timeout=15)
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 client.call_tool("new_page", {}, timeout=15)
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 client.call_tool("navigate_page", {"url": url}, timeout=30)
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 client.call_tool("select_page", {"pageId": page_id}, timeout=15)
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 client.call_tool("close_page", {}, timeout=15)
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 client.call_tool("evaluate_script", {
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 client.call_tool("evaluate_script", {
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 client.call_tool("close_page", {}, timeout=15)
1661
+ result = await _call_mcp_with_retry("close_page", {}, timeout=15)
1592
1662
  return SkillResult(
1593
1663
  success=True,
1594
1664
  data={},
package/web/api_server.py CHANGED
@@ -4422,10 +4422,9 @@ window.toggleFullscreen = function() {{
4422
4422
  # 确保不会残留上次请求的知识库路径
4423
4423
  if agent_path and self.core.main_agent and self.core.main_agent.context_builder:
4424
4424
  agent_kb_dir = self._get_agent_knowledge_dir(agent_path)
4425
- if agent_kb_dir.exists() and any(agent_kb_dir.iterdir()):
4426
- self.core.main_agent.context_builder.agent_knowledge_dir = str(agent_kb_dir)
4427
- else:
4428
- self.core.main_agent.context_builder.agent_knowledge_dir = None
4425
+ # [v1.18.9] 始终设置 agent_knowledge_dir(即使目录为空),确保写入 Agent 专属目录
4426
+ agent_kb_dir.mkdir(parents=True, exist_ok=True)
4427
+ self.core.main_agent.context_builder.agent_knowledge_dir = str(agent_kb_dir)
4429
4428
 
4430
4429
  try:
4431
4430
  response = await self.core.process_message(message, session_id)
@@ -4723,11 +4722,10 @@ window.toggleFullscreen = function() {{
4723
4722
  # ── 设置 Agent 专属知识库目录(优先于组织知识库)──
4724
4723
  if agent_path and agent.context_builder:
4725
4724
  agent_kb_dir = self._get_agent_knowledge_dir(agent_path)
4726
- if agent_kb_dir.exists() and any(agent_kb_dir.iterdir()):
4727
- agent.context_builder.agent_knowledge_dir = str(agent_kb_dir)
4728
- logger.debug(f"[{session_id}] 使用 Agent 专属知识库: {agent_kb_dir}")
4729
- else:
4730
- agent.context_builder.agent_knowledge_dir = None
4725
+ # [v1.18.9] 始终设置 agent_knowledge_dir(即使目录为空),确保写入 Agent 专属目录
4726
+ agent_kb_dir.mkdir(parents=True, exist_ok=True)
4727
+ agent.context_builder.agent_knowledge_dir = str(agent_kb_dir)
4728
+ logger.debug(f"[{session_id}] 使用 Agent 专属知识库: {agent_kb_dir}")
4731
4729
 
4732
4730
  # Clear execution events from previous runs
4733
4731
  agent.clear_execution_events()
@@ -2253,7 +2253,19 @@ body.popout-mode #popoutBtn{display:none !important}
2253
2253
  display:flex;
2254
2254
  }
2255
2255
 
2256
- /* ── Show sidebar footer/version on mobile (even when collapsed from desktop) ── */
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;
@@ -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
- (fileId ? '<button onclick="location.href=\'/api/file/' + fileId + '/download\'">&#11015; 下载</button>' : '') +
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\'">&#11015; 下载</button>'; } return _dl; })() +
3023
3046
  '<button class="close-btn" onclick="window.close()">&#10005; 关闭</button>' +
3024
3047
  '</div><div class="viewer">';
3025
3048
 
package/web/ui/index.html CHANGED
@@ -177,23 +177,38 @@ 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
- body{flex-direction:column}
181
- .sidebar{position:fixed;left:0;top:0;height:100vh;width:260px;max-width:80vw;z-index:50;transform:translateX(-100%);transition:transform .3s ease;flex-shrink:0}
182
- .sidebar.mobile-open{transform:translateX(0)}
183
- .sidebar.collapsed{width:260px;transform:translateX(-100%)}
184
- .sidebar.collapsed.mobile-open{transform:translateX(0)}
185
- .sidebar-toggle{display:none!important}
186
- /* 移动端:即使桌面端折叠了侧边栏,打开时也要显示底部版本信息 */
187
- .sidebar.collapsed.mobile-open .sidebar-footer-text{display:block!important}
188
- /* 移动端:打开侧边栏时,即使桌面端折叠了,也要显示图标文字 */
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}
184
+ .sidebar .nav-item{justify-content:center;padding:10px 0;position:relative}
185
+ .sidebar .nav-item .icon-text{display:none}
186
+ .sidebar .logo .logo-text{display:none}
187
+ .sidebar .logo{justify-content:center;padding:20px 8px}
188
+ .sidebar .sidebar-footer-text{display:none}
189
+ /* 收起时显示 tooltip */
190
+ .sidebar .nav-item::after{
191
+ content:attr(data-tooltip);position:absolute;left:calc(100% + 8px);top:50%;transform:translateY(-50%);
192
+ padding:5px 10px;border-radius:6px;background:var(--text);color:var(--bg);font-size:12px;font-weight:500;
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);
194
+ }
195
+ .sidebar .nav-item:hover::after{opacity:1;visibility:visible}
196
+ /* 展开状态:浮层覆盖 + 遮罩 */
197
+ .sidebar.mobile-open{position:fixed;left:0;top:0;height:100vh;width:260px;max-width:80vw;z-index:50}
198
+ .sidebar.mobile-open .nav-item{justify-content:flex-start;padding:8px 12px}
189
199
  .sidebar.mobile-open .nav-item .icon-text{display:inline!important}
200
+ .sidebar.mobile-open .nav-item::after{display:none}
190
201
  .sidebar.mobile-open .logo .logo-text{display:inline!important}
191
- .sidebar.mobile-open.collapsed .nav-item{justify-content:flex-start;padding:8px 12px}
192
- .sidebar.mobile-open.collapsed .logo{justify-content:flex-start;padding:16px 12px}
202
+ .sidebar.mobile-open .logo{justify-content:flex-start;padding:16px 12px}
203
+ .sidebar.mobile-open .sidebar-footer-text{display:block!important}
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}
206
+ .sidebar-toggle{display:none!important}
193
207
  .sidebar-footer-text{padding-bottom:max(12px, calc(env(safe-area-inset-bottom) + 8px))}
194
208
  .mobile-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:45;display:none}
195
209
  .mobile-overlay.active{display:block}
196
- .header{padding:12px 16px}
210
+ .main{flex:1;min-width:0;min-height:0;overflow:hidden}
211
+ .header{padding:12px 16px;padding-left:12px}
197
212
  .header h2{font-size:16px}
198
213
  .content{padding:16px}
199
214
  .grid{grid-template-columns:1fr}
@@ -215,6 +230,12 @@ tr:hover{background:var(--surface2)}
215
230
  .toast{left:16px;right:16px;bottom:16px}
216
231
  .agent-card{flex-direction:column;align-items:flex-start}
217
232
  .agent-card .flex.flex-col{flex-direction:row;gap:4px}
233
+ /* 技能行移动端允许换行 */
234
+ .skill-row{flex-wrap:wrap}
235
+ .skill-row .flex{flex-wrap:wrap;gap:4px}
236
+ /* 任务表格优化 */
237
+ .table-wrap table{min-width:auto}
238
+ .table-wrap td[style*="max-width:300px"]{max-width:150px!important;white-space:normal!important;word-break:break-all}
218
239
  }
219
240
  @media(max-width:480px){
220
241
  .content{padding:12px}
@@ -343,17 +364,29 @@ if(localStorage.getItem('myagent-admin-sidebar-collapsed')==='true'){document.ge
343
364
  function toggleMobileSidebar(){
344
365
  const s=document.getElementById('adminSidebar');
345
366
  const o=document.getElementById('adminMobileOverlay');
346
- s.classList.toggle('mobile-open');
347
- o.classList.toggle('active');
367
+ const isOpen=s.classList.contains('mobile-open');
368
+ if(isOpen){closeMobileSidebar();}else{s.classList.add('mobile-open');o.classList.add('active');}
348
369
  }
349
370
  function closeMobileSidebar(){
350
371
  document.getElementById('adminSidebar').classList.remove('mobile-open');
351
372
  document.getElementById('adminMobileOverlay').classList.remove('active');
352
373
  }
353
- // Show hamburger on mobile
354
- function checkMobile(){const btn=document.getElementById('mobileMenuBtn');if(btn)btn.style.display=window.innerWidth<=768?'grid':'none'}
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
+ }
355
379
  window.addEventListener('resize',checkMobile);
356
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
+ });
357
390
 
358
391
  loadVersion();
359
392
  setTimeout(()=>checkUpdate(false),30000);
@@ -1228,13 +1261,21 @@ function enterSession(sid,agentName){
1228
1261
 
1229
1262
  // ========== Memory ==========
1230
1263
  let _memCategory='global';
1264
+ let _memAgentFilter=''; // [v1.18.9] agent筛选
1231
1265
  async function renderMemory(){
1266
+ // [v1.18.9] 加载 agent 列表供筛选
1267
+ let agentOpts='<option value="">全部 Agent</option>';
1268
+ try{const ags=await api('/api/agents');
1269
+ if(ags&&ags.length){for(const a of ags){agentOpts+=`<option value="${escHtml(a.path||a.name)}" ${_memAgentFilter===(a.path||a.name)?'selected':''}>${escHtml(a.name||a.path)}</option>`;}
1270
+ }}catch(e){}
1232
1271
  let stats={},lt=[];
1233
1272
  try{stats=await api('/api/memory/stats')}catch(e){stats={error:e.message}}
1234
1273
  try{lt=await api('/api/memory/list?category='+encodeURIComponent(_memCategory))}catch(e){lt=[]}
1235
1274
  if(stats.error){$('content').innerHTML='<div class="empty" style="color:var(--danger)">记忆系统异常: '+escHtml(stats.error)+'</div>';return}
1275
+ // [v1.18.9] 按 agent 筛选
1276
+ if(_memAgentFilter){lt=lt.filter(e=>(e.agent_id||e.session_id||'').includes(_memAgentFilter));}
1236
1277
  const cats=[{k:'global',l:'全局记忆'},{k:'session',l:'会话记忆'}];
1237
- let tabHtml='<div class="flex gap-8 mb-8">';
1278
+ let tabHtml='<div class="flex gap-8 mb-8" style="flex-wrap:wrap">';
1238
1279
  for(const c of cats){
1239
1280
  const active=c.k===_memCategory?'btn-primary':'btn-ghost';
1240
1281
  const count=stats[c.k+'_count']||0;
@@ -1246,7 +1287,7 @@ async function renderMemory(){
1246
1287
  <div class="stat"><div class="label">全局记忆</div><div class="value">${stats.global_count||0}</div></div>
1247
1288
  <div class="stat"><div class="label">会话记忆</div><div class="value">${stats.session_count||0}</div></div></div>`;
1248
1289
  html+=tabHtml;
1249
- html+='<div class="flex gap-8 mb-16"><input id="memSearch" placeholder="搜索记忆..." onkeydown="if(event.key===\'Enter\')searchMemory()" style="max-width:400px"><button class="btn btn-primary" onclick="searchMemory()">搜索</button><button class="btn btn-ghost" onclick="cleanupMemory()">清理过期</button></div>';
1290
+ html+='<div class="flex gap-8 mb-16" style="flex-wrap:wrap"><select id="memAgentFilter" onchange="_memAgentFilter=this.value;renderMemory()" style="width:auto;min-width:140px">'+agentOpts+'</select><input id="memSearch" placeholder="搜索记忆..." onkeydown="if(event.key===\'Enter\')searchMemory()" style="max-width:300px"><button class="btn btn-primary" onclick="searchMemory()">搜索</button><button class="btn btn-ghost" onclick="cleanupMemory()">清理过期</button></div>';
1250
1291
  if(lt&&lt.length){
1251
1292
  const isSession=_memCategory==='session';
1252
1293
  html+='<div class="mem-list">';