rename: yuanbao_agent_chat.py -> deepseek_agent.py

This commit is contained in:
admin
2026-04-17 09:18:22 +08:00
parent a7f669ef82
commit 9a349dae8b
9 changed files with 1520 additions and 2 deletions
+1 -1
View File
@@ -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` - 调试工具,探测页面生成状态
+216
View File
@@ -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())
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -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'
+1
View File
@@ -0,0 +1 @@
omitted
+373
View File
@@ -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())
+406
View File
@@ -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`: 任务完成
## 输出格式(严格按此格式)
<<<ACTION
{"type": "命令类型", "cmd": "具体命令", "explanation": "解释"}
ACTION>>>
任务完成时:
<<<ACTION
{"type": "complete", "explanation": "总结"}
ACTION>>>
## 规则
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 "<<<ACTION" in text:
print(f"[Debug] Found <<<ACTION marker")
# 支持多行格式,包括空行
# 格式1: <<<ACTION\n{...}\nACTION>>>
# 格式2: <<<ACTION{...}ACTION>>>
pattern = r'<<<ACTION\s*\n?(\{[\s\S]*?\})\s*ACTION>>>'
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 "<<<ACTION" in text and "ACTION>>>" in text:
try:
start = text.find("<<<ACTION") + len("<<<ACTION")
end = text.find("ACTION>>>")
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] 已停止")
+522
View File
@@ -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 = "<<<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())