""" 元宝全自动代理 v2 - 智能工具调用 核心改进: 1. 元宝主动输出工具调用标记(JSON 格式) 2. Agent 拦截标记,执行工具,将结果返回给元宝 3. 支持多轮工具调用直到任务完成 4. 更自然的对话体验 工作流程: 用户提问 → 元宝分析 → 输出工具调用标记 → Agent 拦截执行 → 结果返回元宝 → 元宝继续分析/输出新标记 → ... → 最终答案 """ import asyncio import json import re import subprocess import time from dataclasses import dataclass, field from pathlib import Path from typing import Optional, Callable, Any from playwright.async_api import async_playwright, Page, BrowserContext YUANBAO_URL = "https://yuanbao.tencent.com/chat" # 工具调用标记格式 TOOL_CALL_START = "<< ToolResult: """执行工具""" if tool_name not in self._tools: return ToolResult( call_id="", success=False, content=f"未知工具: {tool_name}。可用工具: {list(self._tools.keys())}" ) try: result = self._tools[tool_name](*args) return ToolResult(call_id="", success=True, content=str(result)) except Exception as e: return ToolResult(call_id="", success=False, content=f"执行失败: {e}") def get_schemas(self) -> dict: """获取所有工具 schema""" return self._schemas # 全局工具注册表 registry = ToolRegistry() # ============ 工具实现 ============ @registry.register("read_file", { "description": "读取本地文件内容", "parameters": [ {"name": "path", "type": "string", "description": "文件路径"} ] }) def tool_read_file(path: str) -> str: try: file_path = Path(path).expanduser().resolve() if not file_path.exists(): return f"[错误] 文件不存在: {path}" content = file_path.read_text(encoding="utf-8", errors="ignore") if len(content) > 8000: content = content[:8000] + f"\n... (已截断,共 {len(content)} 字符)" return content except Exception as e: return f"[错误] {e}" @registry.register("write_file", { "description": "写入内容到本地文件", "parameters": [ {"name": "path", "type": "string", "description": "文件路径"}, {"name": "content", "type": "string", "description": "文件内容"} ] }) def tool_write_file(path: str, content: str) -> str: try: file_path = Path(path).expanduser().resolve() file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(content, encoding="utf-8") return f"[成功] 文件已写入: {path}" except Exception as e: return f"[错误] {e}" @registry.register("list_dir", { "description": "列出目录内容", "parameters": [ {"name": "path", "type": "string", "description": "目录路径", "default": "."} ] }) def tool_list_dir(path: str = ".") -> str: try: dir_path = Path(path).expanduser().resolve() if not dir_path.exists(): return f"[错误] 目录不存在: {path}" items = [] for item in sorted(dir_path.iterdir()): prefix = "[DIR]" if item.is_dir() else "[FILE]" size = "" if item.is_file(): try: size_bytes = item.stat().st_size size = f" ({size_bytes:,} bytes)" except: pass items.append(f"{prefix} {item.name}{size}") return "\n".join(items[:100]) except Exception as e: return f"[错误] {e}" @registry.register("run_command", { "description": "执行本地命令", "parameters": [ {"name": "command", "type": "string", "description": "要执行的命令"} ] }) def tool_run_command(command: str) -> str: try: result = subprocess.run( command, shell=True, capture_output=True, text=True, timeout=30, encoding="utf-8", errors="ignore" ) output = result.stdout if result.stderr: output += f"\n[stderr] {result.stderr}" if len(output) > 8000: output = output[:8000] + f"\n... (已截断)" return f"[退出码: {result.returncode}]\n{output}" except subprocess.TimeoutExpired: return "[错误] 命令执行超时" except Exception as e: return f"[错误] {e}" @registry.register("search_files", { "description": "搜索文件内容", "parameters": [ {"name": "pattern", "type": "string", "description": "搜索模式"}, {"name": "path", "type": "string", "description": "搜索路径", "default": "."} ] }) def tool_search_files(pattern: str, path: str = ".") -> str: """简单文件内容搜索""" try: import fnmatch search_path = Path(path).expanduser().resolve() results = [] for file_path in search_path.rglob("*"): if file_path.is_file() and not any(p.startswith(".") for p in file_path.parts): try: content = file_path.read_text(encoding="utf-8", errors="ignore") if pattern in content: lines = [i+1 for i, line in enumerate(content.split("\n")) if pattern in line] results.append(f"{file_path.relative_to(search_path)}: 行 {lines[:5]}") if len(results) >= 20: results.append("... (结果过多,已截断)") break except: continue return "\n".join(results) if results else "未找到匹配内容" except Exception as e: return f"[错误] {e}" # ============ 系统提示词 ============ SYSTEM_PROMPT = f"""你是一个具有本地操作能力的 AI 助手。你可以调用本地工具来帮助用户完成任务。 可用工具及其 schema: {json.dumps(registry.get_schemas(), indent=2, ensure_ascii=False)} 工具调用规则: 1. 当需要调用工具时,输出以下格式的标记: <<>> 2. 每次只能调用一个工具 3. 等待工具执行结果后再继续 4. 最终结果以自然语言回复用户 示例对话: 用户:帮我看看桌面有什么文件 你:我来查看一下桌面文件。 <<>> [系统注入工具结果...] 你:桌面有以下文件:... """ # ============ 元宝页面控制器 ============ class YuanbaoController: """控制元宝网页交互""" def __init__(self, page: Page): self.page = page self._last_ai_message: Optional[str] = None async def find_input_box(self): """查找输入框""" selectors = [ "div[contenteditable='true'][data-placeholder]", "div[contenteditable='true']", "textarea[placeholder]", "textarea", ] for sel in selectors: try: el = await self.page.wait_for_selector(sel, timeout=3000) if el: return el except Exception: continue return None async def send_message(self, content: str): """发送消息到元宝""" input_box = await self.find_input_box() if not input_box: print("[v2] 未找到输入框") return False await input_box.click() await asyncio.sleep(0.2) # 清空 await self.page.keyboard.press("Control+a") await self.page.keyboard.press("Delete") await asyncio.sleep(0.2) # 使用 fill 方法一次性填充内容(比分段 type 更可靠) try: await input_box.fill(content) await asyncio.sleep(0.3) except Exception as e: # 兜底:逐字输入 print(f"[v2] fill 失败,使用 type: {e}") await input_box.type(content, delay=1) await asyncio.sleep(0.3) # 发送 await self._click_send(input_box) return True async def _click_send(self, input_box): """点击发送""" send_selectors = [ "button[class*='send']", "button[aria-label*='发送']", "button[title*='发送']", ] for sel in send_selectors: try: btn = self.page.locator(sel).last if await btn.is_visible(): await btn.click() return except Exception: continue await input_box.press("Enter") async def get_latest_ai_message(self) -> Optional[str]: """获取最新 AI 回复""" try: text = await self.page.evaluate(""" () => { const items = document.querySelectorAll('.agent-chat__list__item--ai'); if (items.length === 0) return null; // 找最后一个有 speech-text-box 的 let lastItem = null; for (let i = items.length - 1; i >= 0; i--) { if (items[i].querySelector('.agent-chat__speech-text-box')) { lastItem = items[i]; break; } } if (!lastItem) lastItem = items[items.length - 1]; const textBox = lastItem.querySelector('.agent-chat__speech-text'); return textBox ? textBox.innerText?.trim() : lastItem.innerText?.trim(); } """) return text except Exception as e: print(f"[v2] 获取 AI 消息失败: {e}") return None async def wait_for_new_ai_message(self, previous: Optional[str] = None, timeout: int = 120) -> Optional[str]: """等待新的 AI 消息""" deadline = time.time() + timeout while time.time() < deadline: current = await self.get_latest_ai_message() if current and current != previous: return current await asyncio.sleep(1) return None def parse_tool_call(self, text: str) -> Optional[ToolCall]: """解析工具调用标记""" # 支持多行格式,允许标记和 JSON 之间有换行 pattern = rf'{re.escape(TOOL_CALL_START)}\s*(\{{.*?\}})\s*{re.escape(TOOL_CALL_END)}' match = re.search(pattern, text, re.DOTALL) if match: try: json_str = match.group(1).strip() data = json.loads(json_str) return ToolCall( tool=data.get("tool", ""), args=data.get("args", []), call_id=data.get("call_id", "") ) except json.JSONDecodeError as e: print(f"[v2] JSON 解析失败: {e}, 内容: {match.group(1)[:200]}") return None return None # ============ 主代理 ============ class YuanbaoAgent: """元宝全自动代理""" def __init__(self): self.context: Optional[BrowserContext] = None self.page: Optional[Page] = None self.controller: Optional[YuanbaoController] = None self.conversation_history: list[dict] = [] async def run(self): """启动代理""" print("=" * 60) print(" 元宝全自动代理 v2") print("=" * 60) print("\n工作流程:") print(" 1. 用户在元宝中提问") print(" 2. 元宝输出工具调用标记") print(" 3. Agent 拦截并执行本地操作") print(" 4. 结果返回元宝,继续对话") print("\n按 Ctrl+C 退出\n") async with async_playwright() as p: print("[Agent] 启动浏览器...") user_data_dir = str(Path.home() / ".yuanbao_agent_profile") self.context = await p.chromium.launch_persistent_context( user_data_dir=user_data_dir, channel="chrome", headless=False, args=["--start-maximized"], no_viewport=True, ) self.page = self.context.pages[0] if self.context.pages else await self.context.new_page() await self.page.goto(YUANBAO_URL) print(f"[Agent] 已打开元宝: {YUANBAO_URL}") print("[Agent] 请手动登录元宝...") # 等待登录完成 await self._wait_for_login() self.controller = YuanbaoController(self.page) # 发送系统提示词(作为第一条消息) print("[Agent] 发送系统提示词...") await self._send_system_prompt() print("[Agent] 开始监听对话...") await self._conversation_loop() async def _wait_for_login(self): """等待用户登录""" print("[Agent] 登录完成后按回车继续...") loop = asyncio.get_event_loop() await loop.run_in_executor(None, input) async def _send_system_prompt(self): """发送系统提示词""" # 使用特殊前缀让元宝知道这是系统指令 prompt = f"""[系统初始化] {SYSTEM_PROMPT} --- 现在你可以开始帮助用户了。当需要操作本地文件或执行命令时,请使用工具调用标记。""" await self.controller.send_message(prompt) print("[Agent] 系统提示词已发送") # 等待元宝确认 await asyncio.sleep(3) async def _conversation_loop(self): """对话主循环""" last_ai_msg = None while True: try: # 等待新的 AI 消息 print("[Agent] 等待元宝回复...") ai_msg = await self.controller.wait_for_new_ai_message(last_ai_msg, timeout=180) if not ai_msg: print("[Agent] 等待超时,继续监听...") continue last_ai_msg = ai_msg print(f"[Agent] 收到元宝消息 ({len(ai_msg)} 字符)") # 检查是否包含工具调用 tool_call = self.controller.parse_tool_call(ai_msg) if tool_call: print(f"[Agent] 检测到工具调用: {tool_call.tool}") # 执行工具 result = registry.execute(tool_call.tool, tool_call.args) # 构造结果消息 result_msg = f"""[工具执行结果] 工具: {tool_call.tool} 参数: {tool_call.args} 成功: {result.success} {result.content} 请基于以上结果继续。""" print(f"[Agent] 工具执行完成,返回结果...") # 发送结果回元宝 await asyncio.sleep(1) await self.controller.send_message(result_msg) # 更新 last_ai_msg 避免重复处理 last_ai_msg = None else: # 普通回复,记录到历史 self.conversation_history.append({ "role": "assistant", "content": ai_msg }) except KeyboardInterrupt: print("\n[Agent] 用户中断") break except Exception as e: print(f"[Agent] 错误: {e}") await asyncio.sleep(3) async def main(): agent = YuanbaoAgent() try: await agent.run() except Exception as e: print(f"[Agent] 启动失败: {e}") raise if __name__ == "__main__": asyncio.run(main())