myagent-ai 1.47.19 → 1.47.21

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.
@@ -16,7 +16,6 @@ from core.llm import LLMClient, LLMResponse, Message
16
16
  from agents.base import BaseAgent, AgentContext
17
17
  from core.utils import generate_id, timestamp, truncate_str
18
18
  from core.context_builder import ContextBuilder
19
- from core.output_parser import ParsedOutput, parse_output, validate_output, extract_surrounding_text
20
19
  from core.tool_dispatcher import ToolDispatcher
21
20
 
22
21
  logger = get_logger("myagent.agent.main")
@@ -501,79 +500,20 @@ class MainAgent(BaseAgent):
501
500
  logger.debug(f"V2 SSE 事件发送失败 ({event_type}): {e}")
502
501
 
503
502
  def _try_extract_partial_response(self, llm_raw: str) -> str:
504
- """[v1.15.73] 从不完整的 LLM 输出中提取部分回复内容。
505
-
506
- <output> 块被截断(缺少 </output>)时,尝试:
507
- 1. 提取 <reply>...</reply> 中已闭合的内容
508
- 2. 提取未闭合的 <reply> 后的内容(宽松模式)
509
- 3. 提取 <knowledge>...</knowledge> 中已闭合的内容(兜底)
510
- 4. 提取 <output> 后到截断点之间的纯文本
511
- 5. 去除 XML 标签后的残余文本(跳过工具执行状态文本)
503
+ """[v1.47.21] 从不完整的 LLM 输出中提取纯文本回复。
504
+
505
+ 完全依赖原生 tool_calling,不再解析 XML 格式。
506
+ 仅做简单的 XML 标签清理(兜底,防止模型意外输出 XML)。
512
507
  """
513
508
  if not llm_raw:
514
509
  return ""
515
510
 
516
511
  import re
517
- _parts = []
518
-
519
- # 策略1: 尝试提取已闭合的 <reply> 内容
520
- reply_match = re.search(
521
- r"<reply[^>]*>(.*?)</reply>",
522
- llm_raw,
523
- re.DOTALL | re.IGNORECASE,
524
- )
525
- if reply_match:
526
- text = reply_match.group(1).strip()
527
- if text:
528
- _parts.append(text)
529
-
530
- # 策略2: 尝试提取未闭合的 <reply> 内容(LLM 截断时 <reply> 常未闭合)
531
- if not _parts:
532
- reply_open_match = re.search(
533
- r"<reply[^>]*>(.*?)$",
534
- llm_raw,
535
- re.DOTALL | re.IGNORECASE,
536
- )
537
- if reply_open_match:
538
- text = reply_open_match.group(1).strip()
539
- # 去除尾部可能的不完整标签
540
- text = re.sub(r"<[^>]*$", "", text).strip()
541
- if text and len(text) > 5:
542
- _parts.append(text)
543
-
544
- # 策略3: 尝试提取已闭合的 <knowledge> 内容(兜底)
545
- if not _parts:
546
- knowledge_match = re.search(
547
- r"<knowledge[^>]*>(.*?)</knowledge>",
548
- llm_raw,
549
- re.DOTALL | re.IGNORECASE,
550
- )
551
- if knowledge_match:
552
- text = knowledge_match.group(1).strip()
553
- if text and len(text) > 20:
554
- _parts.append(text)
555
-
556
- if _parts:
557
- return "\n".join(_parts)
558
-
559
- # 策略4: 提取 <output> 标签后的内容(可能包含未闭合的标签)
560
- output_match = re.search(r"<output[^>]*>", llm_raw, re.IGNORECASE)
561
- if output_match:
562
- after_output = llm_raw[output_match.end():].strip()
563
- if after_output:
564
- cleaned = re.sub(r"<[^>]+>", "", after_output).strip()
565
- cleaned = re.sub(r"^(reasoning|assistant)\s*", "", cleaned, flags=re.IGNORECASE).strip()
566
- # 跳过工具执行状态文本(如"执行工具 task_plan:...")
567
- if cleaned and len(cleaned) > 5 and not re.match(
568
- r"^(执行工具|调用工具|Running tool|Calling tool)", cleaned, re.IGNORECASE
569
- ):
570
- return cleaned
571
-
572
- # 策略5: 提取去除 XML 标签后的整体文本
512
+ # 去除所有 XML 标签
573
513
  cleaned = re.sub(r"<[^>]+>", "", llm_raw).strip()
574
514
  cleaned = re.sub(r"^(reasoning|assistant)\s*", "", cleaned, flags=re.IGNORECASE).strip()
575
515
  # 跳过工具执行状态文本
576
- if cleaned and len(cleaned) > 10 and not re.match(
516
+ if cleaned and len(cleaned) > 5 and not re.match(
577
517
  r"^(执行工具|调用工具|Running tool|Calling tool)", cleaned, re.IGNORECASE
578
518
  ):
579
519
  return cleaned
@@ -783,13 +723,13 @@ class MainAgent(BaseAgent):
783
723
  agent_path: Optional[str] = None,
784
724
  ) -> AgentContext:
785
725
  """
786
- V2 主处理循环 — 使用结构化输出格式。
726
+ V2 主处理循环 — 使用原生 tool_calling。
787
727
 
788
728
  核心流程:
789
729
  1. 使用 ContextBuilder 构建 <context> XML
790
730
  2. 将 context 注入 SYSTEM_PROMPT,调用 LLM
791
- 3. 使用 OutputParser 解析 <output> XML
792
- 4. 根据 parsed.tools_to_call 依次执行工具
731
+ 3. LLM 通过原生 tool_calling 返回工具调用
732
+ 4. 根据 tool_calls 依次执行工具
793
733
  5. 任一工具超时 → 强制回调 LLM
794
734
  6. 根据 callback 标志决定是否回调 LLM
795
735
  7. 处理 remember/recall
@@ -924,6 +864,24 @@ class MainAgent(BaseAgent):
924
864
 
925
865
  messages.append(Message(role="system", content=_system_content))
926
866
 
867
+ # [v1.47.20] VNC 模式下注入浏览器工具使用提示
868
+ try:
869
+ from core.vnc_manager import get_vnc_manager
870
+ vnc_mgr = get_vnc_manager()
871
+ if vnc_mgr.is_running:
872
+ vnc_hint = (
873
+ "\n\n## VNC 远程桌面模式提示\n"
874
+ "当前运行在 VNC 远程桌面环境,浏览器为 Firefox(不支持 Chromium/CDP)。\n"
875
+ "- **网页浏览**: 优先使用 stealth_browser_start → stealth_browser_navigate → stealth_browser_content\n"
876
+ "- **获取页面内容**: stealth_browser_content(返回截图+标签页信息),不要使用 browser_open\n"
877
+ "- **交互操作**: stealth_browser_click / stealth_browser_fill / stealth_browser_key\n"
878
+ "- **不要使用**: browser_open(需要 Chromium)、web_control(需要前端面板)\n"
879
+ "- **不要关闭 Firefox**: stealth_browser_close 在 VNC 模式下只释放会话,不关闭浏览器"
880
+ )
881
+ messages[0] = Message(role="system", content=messages[0].content + vnc_hint)
882
+ except (ImportError, Exception):
883
+ pass
884
+
927
885
  # 注入对话历史
928
886
  if conversation_history:
929
887
  _history_budget = int(self.context_builder.context_window * 0.25) if self.context_builder else 50000
@@ -1222,199 +1180,19 @@ class MainAgent(BaseAgent):
1222
1180
  continue
1223
1181
 
1224
1182
  else:
1225
- # 没有原生工具调用 → 检查是否为旧格式 <output> XML(某些模型不支持 tool_calling)
1183
+ # [v1.47.21] 没有原生工具调用 → 纯文本回复
1184
+ # 完全依赖 tool_calling,不再解析 <output> XML
1226
1185
  raw_content = (response.content or "").strip()
1227
1186
 
1228
- # [v1.47.16] 兼容旧格式:当 LLM 输出 <output> XML 时,用 output_parser 解析
1229
- if raw_content.startswith("<output") or ("<output>" in raw_content and "<toolstocal>" in raw_content):
1230
- logger.info(f"[{task_id}] 检测到旧格式 <output> XML 输出,启用 output_parser 解析")
1231
- parsed = parse_output(raw_content)
1232
-
1233
- if parsed.parse_success:
1234
- # 1) 处理 mainsubject → 更新会话标题
1235
- if parsed.mainsubject and self.dispatcher:
1236
- try:
1237
- await self.dispatcher.dispatch(
1238
- tool_name="update_conversation_title",
1239
- params={"title": parsed.mainsubject, "session_id": context.session_id},
1240
- timeout=10,
1241
- )
1242
- except Exception:
1243
- pass
1244
-
1245
- # 2) 处理 remember → 保存记忆
1246
- if parsed.remember and self.dispatcher:
1247
- try:
1248
- await self.dispatcher.dispatch(
1249
- tool_name="save_memory",
1250
- params={
1251
- "content": parsed.remember,
1252
- "type": parsed.remember_type or "session",
1253
- "session_id": context.session_id,
1254
- },
1255
- timeout=10,
1256
- )
1257
- except Exception:
1258
- pass
1259
-
1260
- # 3) 处理 task_plan
1261
- if parsed.task_plan and self.dispatcher:
1262
- try:
1263
- await self.dispatcher.dispatch(
1264
- tool_name="task_plan",
1265
- params={"action": "create", "plan": parsed.task_plan},
1266
- timeout=10,
1267
- )
1268
- current_task_plan = parsed.task_plan
1269
- await self._emit_v2_event(
1270
- "v2_task_plan",
1271
- {"plan": truncate_str(current_task_plan, 2000)},
1272
- stream_callback,
1273
- )
1274
- except Exception:
1275
- pass
1276
-
1277
- # 4) 处理 tools_to_call → 执行工具
1278
- if parsed.tools_to_call:
1279
- logger.info(f"[{task_id}] 从 <output> XML 提取到 {len(parsed.tools_to_call)} 个工具调用")
1280
-
1281
- # 添加 assistant 消息到消息列表
1282
- messages.append(Message(
1283
- role="assistant",
1284
- content=raw_content,
1285
- ))
1286
-
1287
- # 保存 LLM 原始输出
1288
- if self.memory:
1289
- self.memory.add_session(agent_id=_effective_agent_id,
1290
- session_id=context.session_id,
1291
- role="assistant",
1292
- content=raw_content,
1293
- key="llm_output",
1294
- importance=0.3,
1295
- )
1296
-
1297
- for tool_desc in parsed.tools_to_call:
1298
- _tc_name = tool_desc.get("toolname", "")
1299
- _tc_parms = tool_desc.get("parms", "{}")
1300
- _tc_timeout = int(tool_desc.get("timeout", 120))
1301
-
1302
- if not _tc_name:
1303
- continue
1304
-
1305
- # 注入 session_id
1306
- if _tc_name in ("save_memory", "recall_memory", "update_conversation_title"):
1307
- if isinstance(_tc_parms, str):
1308
- try:
1309
- _tc_parms_dict = json.loads(_tc_parms)
1310
- except (json.JSONDecodeError, TypeError):
1311
- _tc_parms_dict = {"raw_input": _tc_parms}
1312
- else:
1313
- _tc_parms_dict = _tc_parms
1314
- _tc_parms_dict.setdefault("session_id", context.session_id)
1315
- _tc_parms = json.dumps(_tc_parms_dict, ensure_ascii=False)
1316
-
1317
- # 发送工具开始事件
1318
- await self._emit_v2_event(
1319
- "v2_tool_start",
1320
- {"tool": {"toolname": _tc_name, "parms": truncate_str(str(_tc_parms), 500)}},
1321
- stream_callback,
1322
- )
1323
-
1324
- self._add_exec_event("tool_call", {
1325
- "title": f"调用工具: {_tc_name}",
1326
- "tool_name": _tc_name,
1327
- "arguments": str(_tc_parms),
1328
- })
1329
-
1330
- # 执行工具
1331
- tool_result = await self._execute_v2_tool(
1332
- _tc_name, str(_tc_parms), _tc_timeout,
1333
- context, task_id,
1334
- stream_callback=stream_callback,
1335
- sent_files=_sent_files,
1336
- agent_path=agent_path,
1337
- )
1338
-
1339
- # 提取输出
1340
- if tool_result is None:
1341
- tool_result = {"success": False, "error": "工具返回了空结果"}
1342
- _output_text = (
1343
- tool_result.get("output", "")
1344
- or tool_result.get("message", "")
1345
- or tool_result.get("stdout", "")
1346
- or tool_result.get("error", "")
1347
- )
1348
- if not _output_text and tool_result.get("data"):
1349
- try:
1350
- _output_text = json.dumps(tool_result["data"], ensure_ascii=False, default=str)[:30000]
1351
- except Exception:
1352
- _output_text = str(tool_result["data"])[:30000]
1353
-
1354
- # 发送工具结果事件
1355
- await self._emit_v2_event(
1356
- "v2_tool_result",
1357
- {"tool": {"toolname": _tc_name}, "result": {
1358
- "success": tool_result.get("success", False),
1359
- "output": truncate_str(_output_text, 30000),
1360
- "error": truncate_str(tool_result.get("error", ""), 30000),
1361
- }},
1362
- stream_callback,
1363
- )
1364
-
1365
- self._add_exec_event("tool_result", {
1366
- "title": f"工具结果: {_tc_name}",
1367
- "tool_name": _tc_name,
1368
- "success": tool_result.get("success", False),
1369
- "summary": truncate_str(_output_text, 30000),
1370
- })
1371
-
1372
- # 添加 tool result 消息
1373
- messages.append(Message(
1374
- role="user",
1375
- content=f"[工具结果: {_tc_name}] {truncate_str(_output_text, 5000)}",
1376
- ))
1377
-
1378
- # 工具执行完毕 → 继续循环让 LLM 处理结果
1379
- continue
1380
-
1381
- # 5) 没有工具但有 reply → 提取纯文本回复
1382
- if parsed.reply:
1383
- reply_text = parsed.reply.strip()
1384
- else:
1385
- # 兜底:去除所有 XML 标签
1386
- import re as _re_xml
1387
- reply_text = _re_xml.sub(r'<[^>]+>', '', raw_content).strip()
1388
-
1389
- if not reply_text:
1390
- reply_text = "处理完毕。"
1391
-
1392
- context.working_memory["final_response"] = reply_text
1393
- await self._emit_v2_event("v2_reasoning", {"content": truncate_str(reply_text, 3000)}, stream_callback)
1394
-
1395
- # 保存回复到会话记忆
1396
- if self.memory:
1397
- self.memory.add_session(agent_id=_effective_agent_id,
1398
- session_id=context.session_id,
1399
- role="assistant",
1400
- content=reply_text,
1401
- key="reply",
1402
- importance=0.5,
1403
- )
1404
-
1405
- # 保存 LLM 原始输出
1406
- if self.memory:
1407
- self.memory.add_session(agent_id=_effective_agent_id,
1408
- session_id=context.session_id,
1409
- role="assistant",
1410
- content=raw_content,
1411
- key="llm_output",
1412
- importance=0.3,
1413
- )
1414
-
1415
- break
1187
+ # 如果模型意外输出了 XML 标签,清理掉
1188
+ import re as _re_clean
1189
+ if raw_content.startswith("<") and "</" in raw_content:
1190
+ # 清除 XML 标签,提取纯文本
1191
+ cleaned = _re_clean.sub(r'<[^>]+>', '', raw_content).strip()
1192
+ if cleaned:
1193
+ raw_content = cleaned
1194
+ logger.info(f"[{task_id}] 清理了 LLM 输出中的 XML 标签")
1416
1195
 
1417
- # 纯文本回复(非 XML 格式)
1418
1196
  reply_text = raw_content
1419
1197
  logger.info(f"[{task_id}] 无工具调用,任务完成 (reply长度={len(reply_text)})")
1420
1198
 
@@ -956,10 +956,15 @@ class StealthBrowser:
956
956
  """关闭浏览器"""
957
957
  self._started = False
958
958
 
959
- # [v1.47.16] Firefox+VNC 模式
959
+ # [v1.47.20] Firefox+VNC 模式:VNC 桌面的浏览器不能杀,只清理内部状态
960
960
  if self._firefox_mode:
961
961
  try:
962
- if self._firefox_process and self._firefox_process.poll() is None:
962
+ if self._vnc_used:
963
+ # VNC 模式:Firefox 由 vnc_manager 管理,不能杀进程
964
+ # 只清理本实例的内部状态
965
+ logger.info("Firefox+VNC 模式: 不关闭 VNC 浏览器(由 vnc_manager 管理),仅释放内部状态")
966
+ elif self._firefox_process and self._firefox_process.poll() is None:
967
+ # 非 VNC 模式(独立启动的 Firefox):可以关闭
963
968
  self._firefox_process.terminate()
964
969
  try:
965
970
  self._firefox_process.wait(timeout=5)
@@ -970,19 +975,15 @@ class StealthBrowser:
970
975
  pass
971
976
  logger.info("Firefox 已关闭")
972
977
  else:
973
- # 可能是复用的 Firefox 进程,尝试通过 pkill 关闭
974
- try:
975
- subprocess.run(["pkill", "-f", "firefox"], capture_output=True, timeout=5)
976
- logger.info("Firefox 进程已终止 (pkill)")
977
- except Exception:
978
- pass
978
+ # 可能是复用的非 VNC Firefox 进程
979
+ logger.info("Firefox 进程非本实例启动,跳过关闭")
979
980
  except Exception as e:
980
981
  logger.error(f"关闭 Firefox 异常: {e}")
981
982
  finally:
982
983
  self._firefox_process = None
983
984
  self._firefox_mode = False
984
985
  self._vnc_used = False
985
- return SkillResult(success=True, message="Firefox 已关闭")
986
+ return SkillResult(success=True, message="浏览器会话已释放(VNC 浏览器保持运行)")
986
987
 
987
988
  try:
988
989
  if self._browser:
@@ -1475,11 +1476,13 @@ class StealthBrowser:
1475
1476
  if not self._ensure_page():
1476
1477
  return SkillResult(success=False, error="浏览器未启动")
1477
1478
 
1478
- # [v1.47.16] Firefox+VNC 模式:无法通过 CDP 执行 JS
1479
+ # [v1.47.20] Firefox+VNC 模式:无法通过 CDP 执行 JS
1479
1480
  if self._firefox_mode:
1480
1481
  return SkillResult(
1481
1482
  success=False,
1482
- error="Firefox+VNC 模式下不支持 JS 执行。请在 VNC 中手动操作,或切换到桌面环境使用 Chromium。",
1483
+ error="Firefox+VNC 模式下不支持 JS 执行。请使用 stealth_browser_navigate "
1484
+ "导航页面,用 stealth_browser_content(截图+标签页信息)获取内容,"
1485
+ "用 xdotool 相关操作(click/fill/key)进行交互。",
1483
1486
  )
1484
1487
 
1485
1488
  try:
@@ -1538,12 +1541,9 @@ class StealthBrowser:
1538
1541
  if not self._ensure_page():
1539
1542
  return SkillResult(success=False, error="浏览器未启动")
1540
1543
 
1541
- # [v1.47.16] Firefox+VNC 模式:无法获取页面内容
1544
+ # [v1.47.20] Firefox+VNC 模式:截图 + sessionstore 读取
1542
1545
  if self._firefox_mode:
1543
- return SkillResult(
1544
- success=False,
1545
- error="Firefox+VNC 模式下不支持获取页面内容。请在 VNC 中手动查看,或切换到桌面环境使用 Chromium。",
1546
- )
1546
+ return self._firefox_get_content()
1547
1547
 
1548
1548
  try:
1549
1549
  # Bug Fix: DrissionPage 没有 page.text 属性
@@ -1583,12 +1583,9 @@ class StealthBrowser:
1583
1583
  if not self._ensure_page():
1584
1584
  return SkillResult(success=False, error="浏览器未启动")
1585
1585
 
1586
- # [v1.47.16] Firefox+VNC 模式:无法获取页面 HTML
1586
+ # [v1.47.20] Firefox+VNC 模式:截图 + sessionstore 替代
1587
1587
  if self._firefox_mode:
1588
- return SkillResult(
1589
- success=False,
1590
- error="Firefox+VNC 模式下不支持获取页面 HTML。请在 VNC 中手动查看,或切换到桌面环境使用 Chromium。",
1591
- )
1588
+ return self._firefox_get_content()
1592
1589
 
1593
1590
  try:
1594
1591
  html = self._page.html or ""
@@ -1614,11 +1611,17 @@ class StealthBrowser:
1614
1611
  if not self._ensure_page():
1615
1612
  return SkillResult(success=False, error="浏览器未启动")
1616
1613
 
1617
- # [v1.47.16] Firefox+VNC 模式:无法等待元素
1614
+ # [v1.47.20] Firefox+VNC 模式:sleep + 截图替代
1618
1615
  if self._firefox_mode:
1616
+ await asyncio.sleep(min(timeout, 5))
1617
+ # 等待后截图,确认页面状态
1618
+ session_info = self._firefox_read_sessionstore()
1619
+ url = session_info.get("url", "")
1620
+ title = session_info.get("title", "")
1619
1621
  return SkillResult(
1620
- success=False,
1621
- error="Firefox+VNC 模式下不支持等待元素。请在 VNC 中手动操作。",
1622
+ success=True,
1623
+ message=f"Firefox+VNC: 已等待 {min(timeout, 5)}秒,当前页面: {title or url}",
1624
+ data={"url": url, "title": title},
1622
1625
  )
1623
1626
 
1624
1627
  try:
@@ -2174,6 +2177,162 @@ class StealthBrowser:
2174
2177
  except Exception as e:
2175
2178
  return SkillResult(success=False, error=f"Firefox+VNC 截图失败: {e}")
2176
2179
 
2180
+ def _firefox_read_sessionstore(self) -> dict:
2181
+ """[v1.47.20] 读取 Firefox sessionstore 获取当前标签页 URL/标题。
2182
+
2183
+ Firefox 运行时会将当前会话信息写入 sessionstore-backups/recovery.jsonlz4。
2184
+ 该文件使用 mozLz4 格式(8字节自定义头 + LZ4 压缩数据)。
2185
+
2186
+ Returns:
2187
+ dict: {"url": str, "title": str, "tabs": [{"url": str, "title": str}]}
2188
+ """
2189
+ result_info = {"url": "", "title": "", "tabs": []}
2190
+
2191
+ # 搜索可能的 sessionstore 路径
2192
+ search_dirs = []
2193
+ if self._firefox_profile_dir:
2194
+ search_dirs.append(self._firefox_profile_dir)
2195
+ # 也搜索 Firefox 默认 profile 和 vnc_manager 启动的 profile
2196
+ for extra in [
2197
+ os.path.expanduser("~/.mozilla/firefox/default"),
2198
+ os.path.expanduser("~/.mozilla/firefox"),
2199
+ ]:
2200
+ if os.path.isdir(extra) and extra not in search_dirs:
2201
+ search_dirs.append(extra)
2202
+
2203
+ recovery_files = []
2204
+ for base_dir in search_dirs:
2205
+ ss_dir = os.path.join(base_dir, "sessionstore-backups")
2206
+ if os.path.isdir(ss_dir):
2207
+ for fname in ("recovery.jsonlz4", "recovery.baklz4",
2208
+ "previous.jsonlz4"):
2209
+ fpath = os.path.join(ss_dir, fname)
2210
+ if os.path.isfile(fpath):
2211
+ recovery_files.append(fpath)
2212
+ # 也检查 base_dir 本身(有些 Firefox 版本)
2213
+ for fname in ("sessionstore.jsonlz4", "sessionstore-backups/recovery.jsonlz4"):
2214
+ fpath = os.path.join(base_dir, fname)
2215
+ if os.path.isfile(fpath):
2216
+ recovery_files.append(fpath)
2217
+
2218
+ if not recovery_files:
2219
+ logger.debug("[_firefox_read_sessionstore] 未找到 sessionstore 文件")
2220
+ return result_info
2221
+
2222
+ # 读取 mozLz4 格式
2223
+ for fpath in recovery_files:
2224
+ try:
2225
+ import struct
2226
+ with open(fpath, "rb") as f:
2227
+ data = f.read()
2228
+ if len(data) < 8:
2229
+ continue
2230
+ magic = struct.unpack("<I", data[:4])[0]
2231
+ if magic != 0x00080000:
2232
+ continue
2233
+ orig_size = struct.unpack("<I", data[4:8])[0]
2234
+ try:
2235
+ import lz4.block
2236
+ decompressed = lz4.block.decompress(
2237
+ data[8:], uncompressed_size=orig_size
2238
+ )
2239
+ except ImportError:
2240
+ logger.debug("[_firefox_read_sessionstore] lz4 未安装,跳过 mozLz4 解析")
2241
+ continue
2242
+ except Exception as lz4_err:
2243
+ logger.debug(f"[_firefox_read_sessionstore] lz4 解压失败: {lz4_err}")
2244
+ continue
2245
+
2246
+ session = json.loads(decompressed)
2247
+ tabs_info = []
2248
+ for win in session.get("windows", []):
2249
+ for tab in win.get("tabs", []):
2250
+ idx = tab.get("index", 1) - 1
2251
+ entries = tab.get("entries", [])
2252
+ if entries and 0 <= idx < len(entries):
2253
+ entry = entries[idx]
2254
+ tab_info = {
2255
+ "url": entry.get("url", ""),
2256
+ "title": entry.get("title", ""),
2257
+ }
2258
+ tabs_info.append(tab_info)
2259
+
2260
+ result_info["tabs"] = tabs_info
2261
+ if tabs_info:
2262
+ # 取第一个标签页作为当前页面(通常是最活跃的)
2263
+ result_info["url"] = tabs_info[0]["url"]
2264
+ result_info["title"] = tabs_info[0]["title"]
2265
+
2266
+ logger.info(
2267
+ f"[_firefox_read_sessionstore] 读取成功: {len(tabs_info)} 个标签页, "
2268
+ f"当前: {result_info['title'][:50]}"
2269
+ )
2270
+ return result_info
2271
+
2272
+ except Exception as e:
2273
+ logger.debug(f"[_firefox_read_sessionstore] 读取 {fpath} 失败: {e}")
2274
+ continue
2275
+
2276
+ return result_info
2277
+
2278
+ def _firefox_get_content(self) -> SkillResult:
2279
+ """[v1.47.20] Firefox+VNC 模式下获取页面内容。
2280
+
2281
+ 由于无法通过 CDP 获取 Firefox 页面文本,采用以下策略:
2282
+ 1. 截取当前屏幕截图(供 VLM 视觉理解)
2283
+ 2. 读取 Firefox sessionstore 获取 URL/标题
2284
+ 3. 返回截图路径和基本元信息
2285
+
2286
+ Agent 可以使用截图进行视觉理解,或使用 web_search/web_read 获取页面文本。
2287
+ """
2288
+ # 1. 截图
2289
+ screenshot_result = self._firefox_screenshot()
2290
+ screenshot_path = ""
2291
+ if screenshot_result.success and screenshot_result.data:
2292
+ screenshot_path = screenshot_result.data.get("path", "")
2293
+
2294
+ # 2. 读取 sessionstore
2295
+ session_info = self._firefox_read_sessionstore()
2296
+
2297
+ # 3. 组合返回信息
2298
+ url = session_info.get("url", "")
2299
+ title = session_info.get("title", "")
2300
+ tabs = session_info.get("tabs", [])
2301
+ tabs_summary = ""
2302
+ if tabs:
2303
+ tabs_lines = []
2304
+ for i, tab in enumerate(tabs[:10]): # 最多10个标签页
2305
+ tabs_lines.append(f" [{i+1}] {tab.get('title', '?')} - {tab.get('url', '?')}")
2306
+ tabs_summary = "\n".join(tabs_lines)
2307
+
2308
+ content_parts = []
2309
+ if title:
2310
+ content_parts.append(f"标题: {title}")
2311
+ if url:
2312
+ content_parts.append(f"URL: {url}")
2313
+ if tabs_summary:
2314
+ content_parts.append(f"标签页:\n{tabs_summary}")
2315
+ if screenshot_path:
2316
+ content_parts.append(f"截图: {screenshot_path}")
2317
+
2318
+ content_text = "\n".join(content_parts) if content_parts else "无法获取页面内容"
2319
+
2320
+ return SkillResult(
2321
+ success=True,
2322
+ message=f"Firefox+VNC 页面信息: {title or url or '未知'}",
2323
+ data={
2324
+ "url": url,
2325
+ "title": title,
2326
+ "tabs": tabs,
2327
+ "screenshot_path": screenshot_path,
2328
+ "mode": "firefox_vnc",
2329
+ "note": "Firefox+VNC 模式无法直接获取页面文本,已提供截图和标签页信息。"
2330
+ "请使用截图进行视觉理解,或用 web_search/web_read 获取网页文本。",
2331
+ },
2332
+ files=[screenshot_path] if screenshot_path else [],
2333
+ output=content_text,
2334
+ )
2335
+
2177
2336
  def _firefox_get_cookies(self) -> SkillResult:
2178
2337
  """Firefox+VNC 模式下读取 cookies.sqlite。"""
2179
2338
  try:
@@ -3063,8 +3222,25 @@ class StealthBrowserCloseSkill(Skill):
3063
3222
  ]
3064
3223
 
3065
3224
  async def execute(self, profile: str = "", **kw) -> SkillResult:
3066
- # close_stealth_browser 现在是同步函数,直接调用
3225
+ # [v1.47.20] VNC 模式下不关闭 Firefox,只释放会话状态
3226
+ with _browser_lock:
3227
+ is_vnc = False
3228
+ if profile:
3229
+ for key, browser in _browsers.items():
3230
+ if key == profile or key == f"__system__:{profile}":
3231
+ if browser._vnc_used:
3232
+ is_vnc = True
3233
+ break
3234
+ else:
3235
+ is_vnc = any(b._vnc_used for b in _browsers.values())
3236
+
3067
3237
  close_stealth_browser(profile_name=profile)
3238
+
3239
+ if is_vnc:
3240
+ return SkillResult(
3241
+ success=True,
3242
+ message="VNC 模式: 浏览器会话已释放,Firefox 保持运行(VNC 远程桌面需要)",
3243
+ )
3068
3244
  return SkillResult(success=True, message="浏览器已关闭")
3069
3245
 
3070
3246
 
@@ -1372,6 +1372,26 @@ class BrowserOpenSkill(Skill):
1372
1372
  if not url:
1373
1373
  return SkillResult(success=False, error="缺少必需参数: url")
1374
1374
 
1375
+ # [v1.47.20] VNC 模式下:Chromium 不可用时回退到 stealth_browser_navigate
1376
+ try:
1377
+ from core.vnc_manager import get_vnc_manager
1378
+ vnc_mgr = get_vnc_manager()
1379
+ if vnc_mgr.is_running:
1380
+ # 检查是否有可用的 Chromium
1381
+ import shutil
1382
+ has_chrome = bool(shutil.which("chromium-browser") or shutil.which("chromium")
1383
+ or shutil.which("google-chrome"))
1384
+ if not has_chrome:
1385
+ # VNC 模式下无 Chromium,回退到 stealth_browser
1386
+ return SkillResult(
1387
+ success=False,
1388
+ error="VNC 远程桌面模式下没有 Chromium 浏览器,无法使用 browser_open。"
1389
+ "请改用 stealth_browser_start + stealth_browser_navigate 操作 Firefox。"
1390
+ "Firefox 在 VNC 模式下已可用。",
1391
+ )
1392
+ except ImportError:
1393
+ pass # vnc_manager 不可用,跳过检测
1394
+
1375
1395
  # 检查依赖
1376
1396
  dep_err = await asyncio.get_event_loop().run_in_executor(None, _ensure_node_deps)
1377
1397
  if dep_err: