Files

374 lines
12 KiB
Python
Raw Permalink Normal View History

"""
元宝全自动代理 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())