rename: yuanbao_agent_chat.py -> deepseek_agent.py
This commit is contained in:
@@ -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 实时通信
|
- `web_server.py` - Flask Web 控制台,SocketIO 实时通信
|
||||||
- `detect_generating.py` - 调试工具,探测页面生成状态
|
- `detect_generating.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())
|
||||||
Binary file not shown.
+1
-1
@@ -15,7 +15,7 @@ from flask import Flask, render_template_string
|
|||||||
from flask_socketio import SocketIO, emit
|
from flask_socketio import SocketIO, emit
|
||||||
|
|
||||||
# 导入 DeepSeek Agent 的核心类
|
# 导入 DeepSeek Agent 的核心类
|
||||||
from yuanbao_agent_chat import SoloAgent, ExecutionEngine
|
from deepseek_agent import SoloAgent, ExecutionEngine
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['SECRET_KEY'] = 'deepseek-agent-secret'
|
app.config['SECRET_KEY'] = 'deepseek-agent-secret'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
omitted
|
||||||
@@ -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())
|
||||||
@@ -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] 已停止")
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user