diff --git a/README.md b/README.md index 075dc9c..cfc6ca1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,6 @@ python web_server.py ## 项目结构 -- `yuanbao_agent_chat.py` - Agent 核心,Playwright 控制 DeepSeek + 本地命令执行 +- `deepseek_agent.py` - Agent 核心,Playwright 控制 DeepSeek + 本地命令执行 - `web_server.py` - Flask Web 控制台,SocketIO 实时通信 - `detect_generating.py` - 调试工具,探测页面生成状态 diff --git a/yuanbao_agent_chat.py b/deepseek_agent.py similarity index 100% rename from yuanbao_agent_chat.py rename to deepseek_agent.py diff --git a/test_agent_tools.py b/test_agent_tools.py new file mode 100644 index 0000000..28e32a1 --- /dev/null +++ b/test_agent_tools.py @@ -0,0 +1,216 @@ +""" +元宝 Agent 工具测试脚本 +测试本地工具调用功能,包含 conda 命令 +""" + +import subprocess +import sys +from pathlib import Path + +# 测试工具注册表 +class ToolTester: + def __init__(self): + self.results = [] + + def test(self, name: str, func, *args): + """运行单个测试""" + print(f"\n{'='*50}") + print(f"测试: {name}") + print(f"参数: {args}") + print(f"{'='*50}") + + try: + result = func(*args) + success = "[错误]" not in result and "失败" not in result + status = "[PASS]" if success else "[FAIL]" + + print(f"结果:\n{result[:500]}{'...' if len(result) > 500 else ''}") + print(f"状态: {status}") + + self.results.append({ + "name": name, + "success": success, + "result": result + }) + return success + + except Exception as e: + print(f"异常: {e}") + self.results.append({ + "name": name, + "success": False, + "result": str(e) + }) + return False + + def report(self): + """输出测试报告""" + print(f"\n{'='*50}") + print("测试报告") + print(f"{'='*50}") + + passed = sum(1 for r in self.results if r["success"]) + total = len(self.results) + + for r in self.results: + icon = "[OK]" if r["success"] else "[FAIL]" + print(f"{icon} {r['name']}") + + print(f"\n总计: {passed}/{total} 通过") + return passed == total + + +# ============ 工具实现 ============ + +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) > 3000: + content = content[:3000] + f"\n... (已截断,共 {len(content)} 字符)" + return content + except Exception as e: + return f"[错误] {e}" + + +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 f"目录: {dir_path}\n" + "\n".join(items[:30]) + except Exception as e: + return f"[错误] {e}" + + +def tool_run_command(command: str) -> str: + """执行本地命令""" + try: + # 使用 shell=True 以支持 conda 等需要 shell 环境的命令 + 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) > 3000: + output = output[:3000] + "\n... (已截断)" + + return f"命令: {command}\n退出码: {result.returncode}\n\n{output}" + except subprocess.TimeoutExpired: + return f"[错误] 命令执行超时: {command}" + except Exception as e: + return f"[错误] {e}" + + +def tool_conda_env_list() -> str: + """列出所有 conda 环境""" + return tool_run_command("conda env list") + + +def tool_conda_info() -> str: + """显示 conda 信息""" + return tool_run_command("conda info") + + +def tool_conda_list(env: str = "base") -> str: + """列出指定环境的包""" + return tool_run_command(f"conda list -n {env}") + + +def tool_python_version() -> str: + """显示 Python 版本""" + return tool_run_command("python --version") + + +def tool_pip_list() -> str: + """列出 pip 包""" + return tool_run_command("pip list") + + +# ============ 主测试 ============ + +def main(): + print("=" * 60) + print("元宝 Agent 工具测试") + print("=" * 60) + + tester = ToolTester() + + # 1. 基础文件操作测试 + tester.test("读取当前脚本", tool_read_file, __file__) + tester.test("列出当前目录", tool_list_dir, ".") + tester.test("列出用户目录", tool_list_dir, "~") + + # 2. 基础命令测试 + tester.test("Python 版本", tool_python_version) + tester.test("系统信息 (systeminfo)", tool_run_command, "systeminfo | findstr /B /C:\"OS\" | head -5") + tester.test("当前目录", tool_run_command, "cd") + + # 3. Conda 相关测试 + print("\n" + "=" * 60) + print("Conda 测试") + print("=" * 60) + + # 先检查 conda 是否可用 + conda_check = tool_run_command("conda --version") + print(f"\n检查 conda:\n{conda_check}") + + if "错误" not in conda_check and "conda" in conda_check.lower(): + tester.test("conda 环境列表", tool_conda_env_list) + tester.test("conda 信息", tool_conda_info) + tester.test("base 环境包列表 (前20行)", tool_run_command, "conda list -n base | head -20") + else: + print("⚠️ 未检测到 conda,跳过 conda 测试") + tester.results.append({ + "name": "conda 环境列表", + "success": False, + "result": "conda 未安装或未添加到 PATH" + }) + + # 4. pip 测试 + tester.test("pip 包列表 (前20行)", tool_run_command, "pip list | head -20") + + # 5. 复杂命令测试 + tester.test("查找 Python 文件", tool_run_command, "dir /s /b *.py 2>nul | head -10") + + # 输出报告 + all_passed = tester.report() + + print("\n" + "=" * 60) + if all_passed: + print("[SUCCESS] 所有测试通过!") + else: + print("[WARNING] 部分测试失败,请检查环境配置") + print("=" * 60) + + return 0 if all_passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000..da3a8f7 Binary files /dev/null and b/test_output.txt differ diff --git a/web_server.py b/web_server.py index a54eb2e..60809a4 100644 --- a/web_server.py +++ b/web_server.py @@ -15,7 +15,7 @@ from flask import Flask, render_template_string from flask_socketio import SocketIO, emit # 导入 DeepSeek Agent 的核心类 -from yuanbao_agent_chat import SoloAgent, ExecutionEngine +from deepseek_agent import SoloAgent, ExecutionEngine app = Flask(__name__) app.config['SECRET_KEY'] = 'deepseek-agent-secret' diff --git a/yuanbao_agent_chat_new.py b/yuanbao_agent_chat_new.py new file mode 100644 index 0000000..12dfce0 --- /dev/null +++ b/yuanbao_agent_chat_new.py @@ -0,0 +1 @@ +omitted \ No newline at end of file diff --git a/yuanbao_agent_mvp.py b/yuanbao_agent_mvp.py new file mode 100644 index 0000000..a25aab0 --- /dev/null +++ b/yuanbao_agent_mvp.py @@ -0,0 +1,373 @@ +""" +元宝全自动代理 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()) diff --git a/yuanbao_agent_solo.py b/yuanbao_agent_solo.py new file mode 100644 index 0000000..cf5771c --- /dev/null +++ b/yuanbao_agent_solo.py @@ -0,0 +1,406 @@ +""" +元宝全自动代理 - Solo 监听模式 + +使用方式: +1. 启动 Agent +2. 在元宝网页中直接对话 +3. Agent 自动拦截 ACTION 标记并执行 +4. 结果自动返回给元宝 + +无需终端交互,完全后台运行。 +""" + +import asyncio +import json +import re +import subprocess +import time +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional, Any +from enum import Enum, auto + +from playwright.async_api import async_playwright, Page, BrowserContext + +YUANBAO_URL = "https://yuanbao.tencent.com/chat" + + +SOLO_SYSTEM_PROMPT = """你是一个自主任务执行助手。 + +## 可用操作 +- `command`: 执行 shell 命令 +- `powershell`: 执行 PowerShell +- `read_file`: 读取文件,cmd为路径 +- `write_file`: 写入文件,cmd格式为 "路径::内容" +- `list_dir`: 列出目录 +- `search`: 搜索文件内容 +- `complete`: 任务完成 + +## 输出格式(严格按此格式) +<<>> + +任务完成时: +<<>> + +## 规则 +1. 每次只输出一个 ACTION 块 +2. 等待系统返回结果后再继续 +3. 不要输出 ACTION 块以外的操作指令 +""" + + +class ExecutionEngine: + async def execute(self, action_type: str, command: str, working_dir: str = "") -> dict: + cwd = Path(working_dir).expanduser() if working_dir else Path.home() + + handlers = { + "command": self._run_shell, + "powershell": self._run_powershell, + "read_file": self._read_file, + "write_file": self._write_file, + "list_dir": self._list_dir, + "search": self._search, + } + + handler = handlers.get(action_type, self._run_shell) + + try: + if asyncio.iscoroutinefunction(handler): + result = await handler(command, cwd) + else: + result = handler(command, cwd) + return {"success": True, "output": result} + except Exception as e: + return {"success": False, "output": f"Error: {e}"} + + async def _run_shell(self, cmd: str, cwd: Path) -> str: + if sys.platform == "win32": + proc = await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, cwd=cwd, + creationflags=subprocess.CREATE_NO_WINDOW + ) + else: + proc = await asyncio.create_subprocess_shell( + cmd, stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, cwd=cwd + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60) + output = stdout.decode('utf-8', errors='replace') + if stderr: + output += "\n[stderr] " + stderr.decode('utf-8', errors='replace') + return output + + async def _run_powershell(self, cmd: str, cwd: Path) -> str: + ps_cmd = f'pwsh -NoProfile -Command "{cmd.replace(chr(34), chr(92)+chr(34))}"' + return await self._run_shell(ps_cmd, cwd) + + def _read_file(self, path: str, cwd: Path) -> str: + file_path = Path(path).expanduser() + if not file_path.is_absolute(): + file_path = cwd / file_path + return file_path.read_text(encoding='utf-8', errors='replace') + + def _write_file(self, path_content: str, cwd: Path) -> str: + if "::" in path_content: + path, content = path_content.split("::", 1) + file_path = Path(path).expanduser() + if not file_path.is_absolute(): + file_path = cwd / file_path + file_path.write_text(content, encoding='utf-8') + return f"Written: {file_path}" + return "Format error, use: path::content" + + def _list_dir(self, path: str, cwd: Path) -> str: + dir_path = Path(path).expanduser() if path else cwd + if not dir_path.is_absolute(): + dir_path = cwd / dir_path + + lines = [f"Dir: {dir_path}"] + try: + for item in sorted(dir_path.iterdir()): + prefix = "[D]" if item.is_dir() else "[F]" + size = "" + if item.is_file(): + size_bytes = item.stat().st_size + size = f" ({self._format_size(size_bytes)})" + lines.append(f" {prefix} {item.name}{size}") + except Exception as e: + lines.append(f" [Error] {e}") + return "\n".join(lines) + + def _format_size(self, size: int) -> str: + for unit in ['B', 'KB', 'MB', 'GB']: + if size < 1024: + return f"{size:.1f}{unit}" + size /= 1024 + return f"{size:.1f}TB" + + async def _search(self, pattern: str, cwd: Path) -> str: + results = [] + try: + for file_path in cwd.rglob("*"): + if file_path.is_file() and file_path.stat().st_size < 10*1024*1024: + 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(cwd)}: lines {lines[:3]}") + if len(results) >= 10: + break + except: + continue + except Exception as e: + return f"Search failed: {e}" + return "\n".join(results) if results else "No matches" + + +class YuanbaoController: + def __init__(self, page: Page): + self.page = page + self.last_message = "" + + async def send(self, content: str): + selectors = [ + "div[contenteditable='true']", + "div[contenteditable='true'][data-placeholder]", + "textarea", + "[role='textbox']", + ] + input_box = None + for sel in selectors: + try: + input_box = await self.page.wait_for_selector(sel, timeout=2000) + if input_box: + break + except: + continue + + if not input_box: + print("[Error] Input not found") + 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) + + try: + await input_box.fill(content) + except Exception as e: + print(f"[Warning] fill failed: {e}, using type") + await input_box.type(content, delay=1) + + await asyncio.sleep(0.3) + + try: + send_btn = self.page.locator("button[class*='send']").last + if await send_btn.count() > 0 and await send_btn.is_visible(): + await send_btn.click() + else: + await input_box.press("Enter") + except: + await input_box.press("Enter") + + return True + + async def get_latest_ai_message(self) -> Optional[str]: + """获取最新 AI 消息""" + try: + text = await self.page.evaluate(""" + () => { + const selectors = [ + '.agent-chat__list__item--ai', + '[class*="agent-chat"] [class*="ai"]', + '[class*="message"][class*="ai"]', + '[class*="assistant"]', + '.chat-item-ai', + ]; + + for (const sel of selectors) { + const items = document.querySelectorAll(sel); + if (items.length > 0) { + const last = items[items.length - 1]; + const textSelectors = [ + '.agent-chat__speech-text', + '[class*="text"]', + '[class*="content"]', + 'p', + 'div', + ]; + for (const ts of textSelectors) { + const el = last.querySelector(ts); + if (el && el.innerText) { + return el.innerText.trim(); + } + } + if (last.innerText) { + return last.innerText.trim(); + } + } + } + return null; + } + """) + return text + except Exception as e: + print(f"[Error] get_latest_ai_message: {e}") + return None + + +class SoloAgent: + def __init__(self): + self.page: Optional[Page] = None + self.controller: Optional[YuanbaoController] = None + self.engine = ExecutionEngine() + self.last_ai_msg = "" + self.is_running = False + + async def run(self): + print("=" * 60) + print(" Yuanbao Solo Agent - 监听模式") + print("=" * 60) + print("\n使用方式:") + print(" 1. 在元宝网页中直接对话") + print(" 2. Agent 自动拦截并执行") + print(" 3. 结果自动返回") + print("\n按 Ctrl+C 退出\n") + + async with async_playwright() as p: + user_data = str(Path.home() / ".yuanbao_solo_profile") + context = await p.chromium.launch_persistent_context( + user_data, channel="chrome", headless=False, + args=["--start-maximized"], no_viewport=True + ) + + self.page = context.pages[0] if context.pages else await context.new_page() + await self.page.goto(YUANBAO_URL) + + print(f"[Agent] 已打开元宝: {YUANBAO_URL}") + print("[Agent] 请登录并创建新对话\n") + + input("登录完成后按回车继续...") + + self.controller = YuanbaoController(self.page) + + # 发送系统提示词 + print("[Agent] 发送系统提示词...") + await self.controller.send(f"[系统初始化]\n\n{SOLO_SYSTEM_PROMPT}") + print("[Agent] 系统提示词已发送") + print("[Agent] 开始监听...\n") + + # 初始化 last_ai_msg + await asyncio.sleep(3) + self.last_ai_msg = await self.controller.get_latest_ai_message() or "" + + # 开始监听循环 + self.is_running = True + await self._listen_loop() + + async def _listen_loop(self): + """持续监听元宝输出""" + while self.is_running: + try: + # 获取当前最新消息 + current_msg = await self.controller.get_latest_ai_message() + + if current_msg and current_msg != self.last_ai_msg: + print(f"\n[检测到新消息] {current_msg[:200]}...") + self.last_ai_msg = current_msg + + # 检查是否包含 ACTION + action = self._parse_action(current_msg) + + if action: + print(f"[解析成功] 动作: {action.get('type')}") + + if action.get('type') == 'complete': + print("[任务完成]") + continue + + # 执行动作 + print(f"[执行] {action.get('type')}: {action.get('cmd', '')}") + result = await self.engine.execute( + action.get('type', 'command'), + action.get('cmd', ''), + "" + ) + + print(f"[执行结果] Success: {result['success']}") + + # 发送结果回元宝 + result_msg = f"""[系统执行结果] +操作: {action.get('type')} +命令: {action.get('cmd', '')} +成功: {result['success']} + +输出: +``` +{result['output'][:1500]} +``` + +请继续下一步或完成任务。""" + + await asyncio.sleep(1) + await self.controller.send(result_msg) + print("[已发送结果到元宝]") + else: + print("[无ACTION标记,忽略]") + + await asyncio.sleep(1) + + except Exception as e: + print(f"[错误] {e}") + await asyncio.sleep(2) + + def _parse_action(self, text: str) -> Optional[dict]: + """解析 ACTION 块""" + if not text: + return None + + # 调试输出 + if "<<>> + # 格式2: <<>> + pattern = r'<<>>' + match = re.search(pattern, text, re.DOTALL) + if match: + try: + json_str = match.group(1).strip() + print(f"[Debug] Extracted JSON: {json_str[:100]}...") + return json.loads(json_str) + except json.JSONDecodeError as e: + print(f"[Debug] JSON parse error: {e}") + pass + + # 备用:更宽松的匹配 + if "<<>>" in text: + try: + start = text.find("<<>>") + content = text[start:end].strip() + if content.startswith("{") and content.endswith("}"): + print(f"[Debug] Fallback extraction: {content[:100]}...") + return json.loads(content) + except Exception as e: + print(f"[Debug] Fallback error: {e}") + + return None + + +if __name__ == "__main__": + agent = SoloAgent() + try: + asyncio.run(agent.run()) + except KeyboardInterrupt: + print("\n[Agent] 已停止") diff --git a/yuanbao_agent_v2.py b/yuanbao_agent_v2.py new file mode 100644 index 0000000..651fdd2 --- /dev/null +++ b/yuanbao_agent_v2.py @@ -0,0 +1,522 @@ +""" +元宝全自动代理 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())