Files

523 lines
17 KiB
Python
Raw Permalink Normal View History

"""
元宝全自动代理 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 = "<<<TOOL_CALL"
TOOL_CALL_END = "TOOL_CALL>>>"
# ============ 数据模型 ============
@dataclass
class ToolCall:
"""工具调用请求"""
tool: str
args: list
call_id: str = ""
@dataclass
class ToolResult:
"""工具执行结果"""
call_id: str
success: bool
content: str
# ============ 工具注册表 ============
class ToolRegistry:
"""工具注册与管理"""
def __init__(self):
self._tools: dict[str, Callable] = {}
self._schemas: dict[str, dict] = {}
def register(self, name: str, schema: dict):
"""注册工具装饰器"""
def decorator(func: Callable):
self._tools[name] = func
self._schemas[name] = schema
return func
return decorator
def execute(self, tool_name: str, args: list) -> 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. 当需要调用工具时,输出以下格式的标记:
<<<TOOL_CALL
{{"tool": "工具名", "args": ["参数1", "参数2", ...]}}
TOOL_CALL>>>
2. 每次只能调用一个工具
3. 等待工具执行结果后再继续
4. 最终结果以自然语言回复用户
示例对话:
用户:帮我看看桌面有什么文件
你:我来查看一下桌面文件。
<<<TOOL_CALL
{{"tool": "list_dir", "args": ["~/Desktop"]}}
TOOL_CALL>>>
[系统注入工具结果...]
你:桌面有以下文件:...
"""
# ============ 元宝页面控制器 ============
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())