""" 元宝全自动代理 MVP 验证思路:通过 Playwright 拦截元宝对话,识别本地调用意图, 执行本地操作后将结果注入回对话。 核心机制: 1. 监听元宝输入框,检测特殊指令标记(如 /local 或 @agent) 2. 拦截用户输入,在本地执行 3. 将执行结果作为"用户消息"注入回对话 4. 元宝基于结果继续回复 """ import asyncio import json import re import subprocess import sys import time from pathlib import Path from typing import Optional, Callable from playwright.async_api import async_playwright, Page, BrowserContext YUANBAO_URL = "https://yuanbao.tencent.com/chat" # 本地工具注册表 TOOLS: dict[str, Callable] = {} def register_tool(name: str): """工具装饰器""" def decorator(func: Callable): TOOLS[name] = func return func return decorator # ============ 本地工具实现 ============ @register_tool("read_file") 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) > 5000: content = content[:5000] + f"\n... (已截断,共 {len(content)} 字符)" return f"[文件内容: {path}]\n```\n{content}\n```" except Exception as e: return f"[错误] 读取文件失败: {e}" @register_tool("list_dir") 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()): item_type = "📁" if item.is_dir() else "📄" items.append(f"{item_type} {item.name}") return f"[目录列表: {path}]\n" + "\n".join(items[:50]) except Exception as e: return f"[错误] 列出目录失败: {e}" @register_tool("run_command") 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]\n{result.stderr}" # 截断过长输出 if len(output) > 5000: output = output[:5000] + f"\n... (已截断,共 {len(output)} 字符)" return f"[命令执行: {command}]\n返回码: {result.returncode}\n```\n{output}\n```" except subprocess.TimeoutExpired: return f"[错误] 命令执行超时: {command}" except Exception as e: return f"[错误] 执行命令失败: {e}" @register_tool("web_search") def tool_web_search(query: str) -> str: """模拟网络搜索(MVP 阶段返回提示)""" return f"[搜索: {query}]\nMVP 阶段暂未接入真实搜索,请直接询问元宝。" # ============ 指令解析器 ============ class CommandParser: """解析用户输入中的本地调用指令""" # 指令模式:@agent:tool_name(args) 或 /local tool_name args PATTERNS = [ # @agent:read_file("/path/to/file") r'@agent:(\w+)\s*\(\s*["\']?([^"\')]*)["\']?\s*\)', # /local read_file /path/to/file r'/local\s+(\w+)\s+(.+?)(?:\n|$)', # ```local # read_file /path/to/file # ``` r'```local\s*\n(\w+)\s+(.+?)\n```', ] def parse(self, text: str) -> Optional[dict]: """解析指令,返回工具调用信息或 None""" text = text.strip() # 模式 1: @agent:tool_name(args) match = re.search(self.PATTERNS[0], text) if match: return { "tool": match.group(1), "args": [match.group(2)], "raw": match.group(0) } # 模式 2: /local tool_name args match = re.search(self.PATTERNS[1], text) if match: return { "tool": match.group(1), "args": [match.group(2).strip()], "raw": match.group(0) } # 模式 3: ```local ... ``` match = re.search(self.PATTERNS[2], text, re.DOTALL) if match: return { "tool": match.group(1), "args": [match.group(2).strip()], "raw": match.group(0) } return None # ============ 元宝页面操作 ============ class YuanbaoController: """控制元宝网页的输入输出""" def __init__(self, page: Page): self.page = page self.parser = CommandParser() 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 inject_message(self, content: str): """将内容注入到输入框并发送""" input_box = await self.find_input_box() if not input_box: print("[MVP] 未找到输入框") return False # 点击输入框 await input_box.click() await asyncio.sleep(0.3) # 清空现有内容 await self.page.keyboard.press("Control+a") await self.page.keyboard.press("Delete") await asyncio.sleep(0.2) # 输入内容 # 分段输入避免过长 max_chunk = 500 for i in range(0, len(content), max_chunk): chunk = content[i:i+max_chunk] await input_box.type(chunk, delay=5) await asyncio.sleep(0.5) # 发送 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*='发送']", "div[class*='send-btn']", ] for sel in send_selectors: try: btn = self.page.locator(sel).last if await btn.is_visible(): await btn.click() return except Exception: continue # 回退到 Enter await input_box.press("Enter") async def get_last_user_message(self) -> Optional[str]: """获取最后一条用户消息""" try: # 查找用户消息 messages = await self.page.evaluate(""" () => { const items = document.querySelectorAll('.agent-chat__list__item--user, [class*="user"]'); const last = items[items.length - 1]; return last ? last.innerText?.trim() : null; } """) return messages except Exception as e: print(f"[MVP] 获取消息失败: {e}") return None async def intercept_and_execute(self, user_input: str) -> Optional[str]: """ 拦截用户输入,如果包含本地指令则执行并返回结果 返回: 要注入回对话的内容,或 None(无需拦截) """ cmd = self.parser.parse(user_input) if not cmd: return None tool_name = cmd["tool"] args = cmd["args"] print(f"[MVP] 检测到本地指令: {tool_name}({args})") # 执行工具 if tool_name not in TOOLS: return f"[错误] 未知工具: {tool_name}。可用工具: {list(TOOLS.keys())}" try: result = TOOLS[tool_name](*args) # 包装成自然对话格式 return f"[本地执行结果]\n{result}\n\n请基于以上结果继续回答。" except Exception as e: return f"[错误] 工具执行失败: {e}" # ============ 主循环 ============ class YuanbaoAgentMVP: """元宝全自动代理 MVP""" def __init__(self): self.context: Optional[BrowserContext] = None self.page: Optional[Page] = None self.controller: Optional[YuanbaoController] = None self._last_processed: Optional[str] = None async def run(self): """启动 MVP""" print("=" * 60) print(" 元宝全自动代理 MVP") print("=" * 60) print("\n使用说明:") print(" 1. 在元宝对话中输入特殊指令触发本地调用:") print(" - @agent:read_file('/path/to/file')") print(" - /local list_dir /path/to/dir") print(" - ```local\nrun_command ls -la\n```") print(" 2. 系统将拦截指令,执行本地操作") print(" 3. 执行结果将注入回对话,元宝基于结果继续") print("\n按 Ctrl+C 退出\n") async with async_playwright() as p: # 启动浏览器 print("[MVP] 启动浏览器...") 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"[MVP] 已打开元宝: {YUANBAO_URL}") print("[MVP] 请手动登录元宝,然后按回车继续...") loop = asyncio.get_event_loop() await loop.run_in_executor(None, input) self.controller = YuanbaoController(self.page) print("[MVP] 开始监听对话...") await self._monitor_loop() async def _monitor_loop(self): """监听循环""" while True: try: await asyncio.sleep(2) # 每 2 秒检查一次 # 获取最后一条用户消息 last_msg = await self.controller.get_last_user_message() if not last_msg: continue # 避免重复处理 if last_msg == self._last_processed: continue # 检查是否包含本地指令 result = await self.controller.intercept_and_execute(last_msg) if result: print(f"[MVP] 准备注入执行结果...") # 等待元宝开始回复(可选) await asyncio.sleep(1) # 注入结果 success = await self.controller.inject_message(result) if success: print(f"[MVP] 结果已注入对话") self._last_processed = last_msg else: print(f"[MVP] 注入失败") else: # 无指令,只是记录 self._last_processed = last_msg except KeyboardInterrupt: print("\n[MVP] 用户中断") break except Exception as e: print(f"[MVP] 监听错误: {e}") await asyncio.sleep(5) async def main(): agent = YuanbaoAgentMVP() try: await agent.run() except Exception as e: print(f"[MVP] 错误: {e}") raise if __name__ == "__main__": asyncio.run(main())