|
@@ -0,0 +1,892 @@
|
|
|
|
|
+"""
|
|
|
|
|
+飞书实时对话 Agent
|
|
|
|
|
+
|
|
|
|
|
+通过飞书 WebSocket 监听消息,调用 Qwen LLM 生成回复,实现实时对话。
|
|
|
|
|
+支持工具调用:浏览目录、读取文件、执行 bash 命令。
|
|
|
|
|
+
|
|
|
|
|
+用法:
|
|
|
|
|
+ python -m agent.tools.builtin.feishu.feishu_agent
|
|
|
|
|
+
|
|
|
|
|
+环境变量:
|
|
|
|
|
+ FEISHU_APP_ID / FEISHU_APP_SECRET: 飞书应用凭证
|
|
|
|
|
+ QWEN_API_KEY: 通义千问 API Key
|
|
|
|
|
+ QWEN_BASE_URL: 通义千问 API 地址(可选,默认阿里云)
|
|
|
|
|
+ FEISHU_AGENT_MODEL: 模型名称(默认 qwen-plus)
|
|
|
|
|
+ FEISHU_AGENT_SYSTEM_PROMPT: 自定义 system prompt(可选)
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+import os
|
|
|
|
|
+import sys
|
|
|
|
|
+import json
|
|
|
|
|
+import asyncio
|
|
|
|
|
+import logging
|
|
|
|
|
+import subprocess
|
|
|
|
|
+import threading
|
|
|
|
|
+import base64
|
|
|
|
|
+import zipfile
|
|
|
|
|
+from typing import Dict, List, Any, Optional
|
|
|
|
|
+from collections import defaultdict
|
|
|
|
|
+
|
|
|
|
|
+PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
|
|
|
|
|
+if PROJECT_ROOT not in sys.path:
|
|
|
|
|
+ sys.path.insert(0, PROJECT_ROOT)
|
|
|
|
|
+
|
|
|
|
|
+from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuMessageEvent, FeishuDomain, ReceiveIdType
|
|
|
|
|
+from agent.tools.builtin.feishu.chat import (
|
|
|
|
|
+ FEISHU_APP_ID,
|
|
|
|
|
+ FEISHU_APP_SECRET,
|
|
|
|
|
+ get_contact_by_id,
|
|
|
|
|
+ load_chat_history,
|
|
|
|
|
+ save_chat_history,
|
|
|
|
|
+)
|
|
|
|
|
+from agent.llm.qwen import qwen_llm_call
|
|
|
|
|
+
|
|
|
|
|
+logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(name)s] %(levelname)s %(message)s')
|
|
|
|
|
+logger = logging.getLogger("FeishuAgent")
|
|
|
|
|
+
|
|
|
|
|
+# ===== 配置 =====
|
|
|
|
|
+
|
|
|
|
|
+MODEL = os.getenv("FEISHU_AGENT_MODEL", "qwen3.5-397b-a17b")
|
|
|
|
|
+MAX_HISTORY = 50
|
|
|
|
|
+MAX_TOOL_ROUNDS = 10 # 工具调用最大循环次数
|
|
|
|
|
+ALLOWED_CONTACTS = {"关涛"}
|
|
|
|
|
+
|
|
|
|
|
+DEFAULT_SYSTEM_PROMPT = """你是一个友好、有帮助的 AI 助手,正在通过飞书和用户对话。
|
|
|
|
|
+你可以使用工具来浏览本地目录、读取文件内容、执行 bash 命令。
|
|
|
|
|
+请用简洁清晰的中文回复。如果用户使用其他语言,请用对应语言回复。"""
|
|
|
|
|
+
|
|
|
|
|
+SYSTEM_PROMPT = os.getenv("FEISHU_AGENT_SYSTEM_PROMPT", DEFAULT_SYSTEM_PROMPT)
|
|
|
|
|
+
|
|
|
|
|
+# ===== 工具定义 =====
|
|
|
|
|
+
|
|
|
|
|
+TOOLS = [
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": "list_directory",
|
|
|
|
|
+ "description": "列出指定目录下的文件和子目录",
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "path": {"type": "string", "description": "目录路径,默认当前目录"}
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": []
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": "read_file",
|
|
|
|
|
+ "description": "读取指定文件的内容",
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "path": {"type": "string", "description": "文件路径"},
|
|
|
|
|
+ "max_lines": {"type": "integer", "description": "最多读取行数,默认200"}
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": ["path"]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": "run_bash",
|
|
|
|
|
+ "description": "执行 bash/shell 命令并返回输出",
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "command": {"type": "string", "description": "要执行的命令"}
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": ["command"]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": "send_image",
|
|
|
|
|
+ "description": "发送图片到飞书",
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "path": {"type": "string", "description": "本地图片文件路径"}
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": ["path"]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": "send_file",
|
|
|
|
|
+ "description": "发送文件到飞书",
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "path": {"type": "string", "description": "本地文件路径"}
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": ["path"]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": "zip_and_send",
|
|
|
|
|
+ "description": "将本地目录打包成 zip 并发送到飞书",
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "path": {"type": "string", "description": "要打包的目录路径"}
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": ["path"]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": "run_background",
|
|
|
|
|
+ "description": "在后台启动一个长期运行的命令(如服务器、agent),返回进程 ID。不会等待命令结束。",
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "command": {"type": "string", "description": "要执行的命令"},
|
|
|
|
|
+ "name": {"type": "string", "description": "给这个后台进程起个名字,方便后续管理"}
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": ["command"]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": "stop_process",
|
|
|
|
|
+ "description": "停止一个后台运行的进程",
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "pid": {"type": "integer", "description": "进程 ID"}
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": ["pid"]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": "list_processes",
|
|
|
|
|
+ "description": "列出所有通过 run_background 启动的后台进程及其状态",
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {},
|
|
|
|
|
+ "required": []
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": "get_process_output",
|
|
|
|
|
+ "description": "获取后台进程的最新输出日志",
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "pid": {"type": "integer", "description": "进程 ID"},
|
|
|
|
|
+ "tail": {"type": "integer", "description": "获取最后 N 行,默认50"}
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": ["pid"]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": "send_input",
|
|
|
|
|
+ "description": "向后台进程发送输入(如交互式命令)",
|
|
|
|
|
+ "parameters": {
|
|
|
|
|
+ "type": "object",
|
|
|
|
|
+ "properties": {
|
|
|
|
|
+ "pid": {"type": "integer", "description": "进程 ID"},
|
|
|
|
|
+ "text": {"type": "string", "description": "要发送的文本"}
|
|
|
|
|
+ },
|
|
|
|
|
+ "required": ["pid", "text"]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ===== 工具执行 =====
|
|
|
|
|
+
|
|
|
|
|
+# 全局变量:保存当前会话的 chat_id,供工具使用
|
|
|
|
|
+_current_chat_id = None
|
|
|
|
|
+
|
|
|
|
|
+# 后台进程管理
|
|
|
|
|
+_background_processes: Dict[int, Dict[str, Any]] = {} # pid → {proc, name, command, output_lines, started_at}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def execute_tool(name: str, arguments: Dict[str, Any]) -> str:
|
|
|
|
|
+ """执行工具调用,返回结果字符串"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ if name == "list_directory":
|
|
|
|
|
+ return _tool_list_directory(arguments.get("path", "."))
|
|
|
|
|
+ elif name == "read_file":
|
|
|
|
|
+ return _tool_read_file(arguments["path"], arguments.get("max_lines", 200))
|
|
|
|
|
+ elif name == "run_bash":
|
|
|
|
|
+ return _tool_run_bash(arguments["command"])
|
|
|
|
|
+ elif name == "send_image":
|
|
|
|
|
+ return _tool_send_image(arguments["path"])
|
|
|
|
|
+ elif name == "send_file":
|
|
|
|
|
+ return _tool_send_file(arguments["path"])
|
|
|
|
|
+ elif name == "zip_and_send":
|
|
|
|
|
+ return _tool_zip_and_send(arguments["path"])
|
|
|
|
|
+ elif name == "run_background":
|
|
|
|
|
+ return _tool_run_background(arguments["command"], arguments.get("name", ""))
|
|
|
|
|
+ elif name == "stop_process":
|
|
|
|
|
+ return _tool_stop_process(arguments["pid"])
|
|
|
|
|
+ elif name == "list_processes":
|
|
|
|
|
+ return _tool_list_processes()
|
|
|
|
|
+ elif name == "get_process_output":
|
|
|
|
|
+ return _tool_get_process_output(arguments["pid"], arguments.get("tail", 50))
|
|
|
|
|
+ elif name == "send_input":
|
|
|
|
|
+ return _tool_send_input(arguments["pid"], arguments["text"])
|
|
|
|
|
+ else:
|
|
|
|
|
+ return f"未知工具: {name}"
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ return f"工具执行出错: {type(e).__name__}: {e}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _tool_list_directory(path: str) -> str:
|
|
|
|
|
+ path = os.path.abspath(path)
|
|
|
|
|
+ if not os.path.isdir(path):
|
|
|
|
|
+ return f"目录不存在: {path}"
|
|
|
|
|
+ entries = []
|
|
|
|
|
+ for name in sorted(os.listdir(path)):
|
|
|
|
|
+ full = os.path.join(path, name)
|
|
|
|
|
+ suffix = "/" if os.path.isdir(full) else ""
|
|
|
|
|
+ entries.append(f" {name}{suffix}")
|
|
|
|
|
+ header = f"目录: {path} ({len(entries)} 项)\n"
|
|
|
|
|
+ return header + "\n".join(entries) if entries else header + " (空目录)"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _tool_read_file(path: str, max_lines: int = 200) -> str:
|
|
|
|
|
+ path = os.path.abspath(path)
|
|
|
|
|
+ if not os.path.isfile(path):
|
|
|
|
|
+ return f"文件不存在: {path}"
|
|
|
|
|
+ try:
|
|
|
|
|
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
|
|
|
+ lines = []
|
|
|
|
|
+ for i, line in enumerate(f):
|
|
|
|
|
+ if i >= max_lines:
|
|
|
|
|
+ lines.append(f"\n... (截断,共读取 {max_lines} 行)")
|
|
|
|
|
+ break
|
|
|
|
|
+ lines.append(line.rstrip())
|
|
|
|
|
+ return "\n".join(lines)
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ return f"读取失败: {e}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _tool_run_bash(command: str) -> str:
|
|
|
|
|
+ # 将项目 .venv 的 Scripts/bin 目录加到 PATH 最前面,确保 python 指向虚拟环境
|
|
|
|
|
+ env = os.environ.copy()
|
|
|
|
|
+ venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "Scripts") # Windows
|
|
|
|
|
+ if not os.path.isdir(venv_scripts):
|
|
|
|
|
+ venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "bin") # Linux/Mac
|
|
|
|
|
+ env["PATH"] = venv_scripts + os.pathsep + env.get("PATH", "")
|
|
|
|
|
+ env["VIRTUAL_ENV"] = os.path.join(PROJECT_ROOT, ".venv")
|
|
|
|
|
+ env["PYTHONIOENCODING"] = "utf-8"
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ proc = subprocess.Popen(
|
|
|
|
|
+ command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
|
|
|
+ text=True, cwd=PROJECT_ROOT, env=env, encoding="utf-8", errors="replace",
|
|
|
|
|
+ )
|
|
|
|
|
+ try:
|
|
|
|
|
+ stdout, stderr = proc.communicate(timeout=30)
|
|
|
|
|
+ except subprocess.TimeoutExpired:
|
|
|
|
|
+ # 超时:杀掉整个进程树(Windows 需要 taskkill)
|
|
|
|
|
+ try:
|
|
|
|
|
+ if sys.platform == "win32":
|
|
|
|
|
+ subprocess.run(
|
|
|
|
|
+ f"taskkill /F /T /PID {proc.pid}",
|
|
|
|
|
+ shell=True, capture_output=True, timeout=5,
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ import signal
|
|
|
|
|
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ proc.kill()
|
|
|
|
|
+ proc.wait(timeout=5)
|
|
|
|
|
+ return "命令执行超时(30秒),已终止进程"
|
|
|
|
|
+
|
|
|
|
|
+ output = ""
|
|
|
|
|
+ if stdout:
|
|
|
|
|
+ output += stdout
|
|
|
|
|
+ if stderr:
|
|
|
|
|
+ output += ("\n--- stderr ---\n" + stderr) if output else stderr
|
|
|
|
|
+ if proc.returncode != 0:
|
|
|
|
|
+ output += f"\n(exit code: {proc.returncode})"
|
|
|
|
|
+ return output.strip() or "(无输出)"
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ return f"执行失败: {e}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _tool_send_image(path: str) -> str:
|
|
|
|
|
+ """发送图片到飞书"""
|
|
|
|
|
+ from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
|
|
|
|
|
+ from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain, ReceiveIdType
|
|
|
|
|
+
|
|
|
|
|
+ if not _current_chat_id:
|
|
|
|
|
+ return "错误:无法获取当前会话 ID"
|
|
|
|
|
+
|
|
|
|
|
+ path = os.path.abspath(path)
|
|
|
|
|
+ if not os.path.isfile(path):
|
|
|
|
|
+ return f"文件不存在: {path}"
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
|
|
|
|
|
+ client.send_image(to=_current_chat_id, image=path)
|
|
|
|
|
+ return f"图片已发送: {os.path.basename(path)}"
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ return f"发送图片失败: {e}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _tool_send_file(path: str) -> str:
|
|
|
|
|
+ """发送文件到飞书"""
|
|
|
|
|
+ from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
|
|
|
|
|
+ from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain
|
|
|
|
|
+
|
|
|
|
|
+ if not _current_chat_id:
|
|
|
|
|
+ return "错误:无法获取当前会话 ID"
|
|
|
|
|
+
|
|
|
|
|
+ path = os.path.abspath(path)
|
|
|
|
|
+ if not os.path.isfile(path):
|
|
|
|
|
+ return f"文件不存在: {path}"
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
|
|
|
|
|
+ client.send_file(to=_current_chat_id, file=path, file_name=os.path.basename(path))
|
|
|
|
|
+ return f"文件已发送: {os.path.basename(path)}"
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ return f"发送文件失败: {e}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _tool_zip_and_send(path: str) -> str:
|
|
|
|
|
+ """打包目录并发送到飞书"""
|
|
|
|
|
+ from agent.tools.builtin.feishu.chat import FEISHU_APP_ID, FEISHU_APP_SECRET
|
|
|
|
|
+ from agent.tools.builtin.feishu.feishu_client import FeishuClient, FeishuDomain
|
|
|
|
|
+
|
|
|
|
|
+ if not _current_chat_id:
|
|
|
|
|
+ return "错误:无法获取当前会话 ID"
|
|
|
|
|
+
|
|
|
|
|
+ path = os.path.abspath(path)
|
|
|
|
|
+ if not os.path.isdir(path):
|
|
|
|
|
+ return f"目录不存在: {path}"
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ # 创建临时 zip 文件
|
|
|
|
|
+ zip_name = os.path.basename(path.rstrip(os.sep)) + ".zip"
|
|
|
|
|
+ zip_path = os.path.join(PROJECT_ROOT, zip_name)
|
|
|
|
|
+
|
|
|
|
|
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
|
|
|
+ for root, dirs, files in os.walk(path):
|
|
|
|
|
+ for file in files:
|
|
|
|
|
+ file_path = os.path.join(root, file)
|
|
|
|
|
+ arcname = os.path.relpath(file_path, path)
|
|
|
|
|
+ zipf.write(file_path, arcname)
|
|
|
|
|
+
|
|
|
|
|
+ client = FeishuClient(app_id=FEISHU_APP_ID, app_secret=FEISHU_APP_SECRET, domain=FeishuDomain.FEISHU)
|
|
|
|
|
+ client.send_file(to=_current_chat_id, file=zip_path, file_name=zip_name)
|
|
|
|
|
+
|
|
|
|
|
+ # 删除临时文件
|
|
|
|
|
+ os.remove(zip_path)
|
|
|
|
|
+
|
|
|
|
|
+ return f"目录已打包并发送: {zip_name}"
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ return f"打包发送失败: {e}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ===== 后台进程管理 =====
|
|
|
|
|
+
|
|
|
|
|
+def _output_reader(proc: subprocess.Popen, pid: int):
|
|
|
|
|
+ """后台线程:持续读取进程输出并存入缓冲区"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ for line in iter(proc.stdout.readline, ''):
|
|
|
|
|
+ if pid not in _background_processes:
|
|
|
|
|
+ break
|
|
|
|
|
+ _background_processes[pid]["output_lines"].append(line.rstrip())
|
|
|
|
|
+ # 只保留最近 500 行
|
|
|
|
|
+ if len(_background_processes[pid]["output_lines"]) > 500:
|
|
|
|
|
+ _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+ # stderr 也读
|
|
|
|
|
+ try:
|
|
|
|
|
+ for line in iter(proc.stderr.readline, ''):
|
|
|
|
|
+ if pid not in _background_processes:
|
|
|
|
|
+ break
|
|
|
|
|
+ _background_processes[pid]["output_lines"].append(f"[stderr] {line.rstrip()}")
|
|
|
|
|
+ if len(_background_processes[pid]["output_lines"]) > 500:
|
|
|
|
|
+ _background_processes[pid]["output_lines"] = _background_processes[pid]["output_lines"][-500:]
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _tool_run_background(command: str, name: str = "") -> str:
|
|
|
|
|
+ """后台启动命令"""
|
|
|
|
|
+ env = os.environ.copy()
|
|
|
|
|
+ venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "Scripts")
|
|
|
|
|
+ if not os.path.isdir(venv_scripts):
|
|
|
|
|
+ venv_scripts = os.path.join(PROJECT_ROOT, ".venv", "bin")
|
|
|
|
|
+ env["PATH"] = venv_scripts + os.pathsep + env.get("PATH", "")
|
|
|
|
|
+ env["VIRTUAL_ENV"] = os.path.join(PROJECT_ROOT, ".venv")
|
|
|
|
|
+ env["PYTHONIOENCODING"] = "utf-8"
|
|
|
|
|
+ env["PYTHONUNBUFFERED"] = "1" # 强制 Python 无缓冲输出
|
|
|
|
|
+ env["PYTHONIOENCODING"] = "utf-8" # 强制 Python IO 编码为 utf-8
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ proc = subprocess.Popen(
|
|
|
|
|
+ command, shell=True,
|
|
|
|
|
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
|
|
|
+ text=True, cwd=PROJECT_ROOT, env=env, encoding="utf-8", errors="replace",
|
|
|
|
|
+ bufsize=1, # 行缓冲
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ from datetime import datetime
|
|
|
|
|
+ _background_processes[proc.pid] = {
|
|
|
|
|
+ "proc": proc,
|
|
|
|
|
+ "name": name or command[:40],
|
|
|
|
|
+ "command": command,
|
|
|
|
|
+ "output_lines": [],
|
|
|
|
|
+ "started_at": datetime.now().strftime("%H:%M:%S"),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ # 启动输出读取线程
|
|
|
|
|
+ t = threading.Thread(target=_output_reader, args=(proc, proc.pid), daemon=True)
|
|
|
|
|
+ t.start()
|
|
|
|
|
+
|
|
|
|
|
+ return f"后台进程已启动: PID={proc.pid}, name={name or command[:40]}"
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ return f"启动失败: {e}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _tool_stop_process(pid: int) -> str:
|
|
|
|
|
+ """停止后台进程"""
|
|
|
|
|
+ info = _background_processes.get(pid)
|
|
|
|
|
+ if not info:
|
|
|
|
|
+ return f"未找到 PID={pid} 的后台进程"
|
|
|
|
|
+
|
|
|
|
|
+ proc = info["proc"]
|
|
|
|
|
+ name = info["name"]
|
|
|
|
|
+ try:
|
|
|
|
|
+ if sys.platform == "win32":
|
|
|
|
|
+ subprocess.run(f"taskkill /F /T /PID {pid}", shell=True, capture_output=True, timeout=5)
|
|
|
|
|
+ else:
|
|
|
|
|
+ import signal
|
|
|
|
|
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ proc.kill()
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ proc.wait(timeout=5)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+ _background_processes.pop(pid, None)
|
|
|
|
|
+ return f"已停止进程: PID={pid} ({name})"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _tool_list_processes() -> str:
|
|
|
|
|
+ """列出所有后台进程"""
|
|
|
|
|
+ if not _background_processes:
|
|
|
|
|
+ return "当前没有后台进程"
|
|
|
|
|
+
|
|
|
|
|
+ lines = []
|
|
|
|
|
+ for pid, info in _background_processes.items():
|
|
|
|
|
+ proc = info["proc"]
|
|
|
|
|
+ status = "运行中" if proc.poll() is None else f"已退出(code={proc.returncode})"
|
|
|
|
|
+ lines.append(f" PID={pid} | {status} | {info['name']} | 启动于 {info['started_at']}")
|
|
|
|
|
+ return f"后台进程 ({len(lines)} 个):\n" + "\n".join(lines)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _tool_get_process_output(pid: int, tail: int = 50) -> str:
|
|
|
|
|
+ """获取后台进程的最新输出"""
|
|
|
|
|
+ info = _background_processes.get(pid)
|
|
|
|
|
+ if not info:
|
|
|
|
|
+ return f"未找到 PID={pid} 的后台进程"
|
|
|
|
|
+
|
|
|
|
|
+ output_lines = info["output_lines"]
|
|
|
|
|
+ proc = info["proc"]
|
|
|
|
|
+ status = "运行中" if proc.poll() is None else f"已退出(code={proc.returncode})"
|
|
|
|
|
+
|
|
|
|
|
+ if not output_lines:
|
|
|
|
|
+ return f"PID={pid} ({info['name']}) [{status}]: 暂无输出"
|
|
|
|
|
+
|
|
|
|
|
+ recent = output_lines[-tail:]
|
|
|
|
|
+ header = f"PID={pid} ({info['name']}) [{status}] 最近 {len(recent)} 行:\n"
|
|
|
|
|
+ return header + "\n".join(recent)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _tool_send_input(pid: int, text: str) -> str:
|
|
|
|
|
+ """向后台进程发送输入(通过控制文件)"""
|
|
|
|
|
+ info = _background_processes.get(pid)
|
|
|
|
|
+ if not info:
|
|
|
|
|
+ return f"未找到 PID={pid} 的后台进程"
|
|
|
|
|
+
|
|
|
|
|
+ proc = info["proc"]
|
|
|
|
|
+ if proc.poll() is not None:
|
|
|
|
|
+ return f"进程已退出,无法发送输入"
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ # 使用控制文件方式(更可靠)
|
|
|
|
|
+ control_file = os.path.join(PROJECT_ROOT, ".agent_control")
|
|
|
|
|
+ with open(control_file, "w", encoding="utf-8") as f:
|
|
|
|
|
+ f.write(text.strip())
|
|
|
|
|
+ return f"已向 PID={pid} 发送控制指令: {text.strip()}"
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ return f"发送输入失败: {e}"
|
|
|
|
|
+ return f"发送输入失败: {e}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class FeishuAgent:
|
|
|
|
|
+ """飞书实时对话 Agent"""
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.client = FeishuClient(
|
|
|
|
|
+ app_id=FEISHU_APP_ID,
|
|
|
|
|
+ app_secret=FEISHU_APP_SECRET,
|
|
|
|
|
+ domain=FeishuDomain.FEISHU,
|
|
|
|
|
+ )
|
|
|
|
|
+ self.conversations: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
|
|
|
+ self._history_loaded: set = set()
|
|
|
|
|
+ self.loop = asyncio.new_event_loop()
|
|
|
|
|
+ self._loop_thread = threading.Thread(target=self._run_loop, daemon=True)
|
|
|
|
|
+ self._loop_thread.start()
|
|
|
|
|
+
|
|
|
|
|
+ def _run_loop(self):
|
|
|
|
|
+ asyncio.set_event_loop(self.loop)
|
|
|
|
|
+ self.loop.run_forever()
|
|
|
|
|
+
|
|
|
|
|
+ def _get_conversation_key(self, event: FeishuMessageEvent) -> str:
|
|
|
|
|
+ if event.chat_type and event.chat_type.value == "p2p":
|
|
|
|
|
+ return event.sender_open_id
|
|
|
|
|
+ return event.chat_id
|
|
|
|
|
+
|
|
|
|
|
+ def _build_messages(self, conv_key: str) -> List[Dict[str, Any]]:
|
|
|
|
|
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
|
|
|
|
+ conv = self.conversations[conv_key]
|
|
|
|
|
+
|
|
|
|
|
+ # 验证并清理 tool_calls:确保每个 assistant+tool_calls 后面都有对应的 tool response
|
|
|
|
|
+ clean = []
|
|
|
|
|
+ i = 0
|
|
|
|
|
+ while i < len(conv):
|
|
|
|
|
+ msg = conv[i]
|
|
|
|
|
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
|
|
|
|
+ # 收集这个 assistant 消息的所有 tool_call_id
|
|
|
|
|
+ expected_ids = {tc.get("id") for tc in msg.get("tool_calls", [])}
|
|
|
|
|
+ # 检查后续消息是否有对应的 tool response
|
|
|
|
|
+ j = i + 1
|
|
|
|
|
+ found_ids = set()
|
|
|
|
|
+ while j < len(conv) and conv[j].get("role") == "tool":
|
|
|
|
|
+ found_ids.add(conv[j].get("tool_call_id"))
|
|
|
|
|
+ j += 1
|
|
|
|
|
+
|
|
|
|
|
+ # 只有所有 tool_call_id 都有对应 response 才保留
|
|
|
|
|
+ if expected_ids == found_ids:
|
|
|
|
|
+ clean.append(msg)
|
|
|
|
|
+ # 添加对应的 tool response
|
|
|
|
|
+ for k in range(i + 1, j):
|
|
|
|
|
+ clean.append(conv[k])
|
|
|
|
|
+ i = j
|
|
|
|
|
+ else:
|
|
|
|
|
+ # 不完整,跳过这组
|
|
|
|
|
+ i = j
|
|
|
|
|
+ elif msg.get("role") == "tool":
|
|
|
|
|
+ # 孤立的 tool response,跳过
|
|
|
|
|
+ i += 1
|
|
|
|
|
+ else:
|
|
|
|
|
+ clean.append(msg)
|
|
|
|
|
+ i += 1
|
|
|
|
|
+
|
|
|
|
|
+ messages.extend(clean)
|
|
|
|
|
+ return messages
|
|
|
|
|
+
|
|
|
|
|
+ def _append_message(self, conv_key: str, role: str, content: Any, **extra):
|
|
|
|
|
+ msg = {"role": role, "content": content, **extra}
|
|
|
|
|
+ self.conversations[conv_key].append(msg)
|
|
|
|
|
+ if len(self.conversations[conv_key]) > MAX_HISTORY:
|
|
|
|
|
+ self.conversations[conv_key] = self.conversations[conv_key][-MAX_HISTORY:]
|
|
|
|
|
+
|
|
|
|
|
+ def _extract_content(self, event: FeishuMessageEvent) -> Optional[Any]:
|
|
|
|
|
+ """从飞书消息中提取内容(文本/图片/文件),返回 OpenAI 多模态格式"""
|
|
|
|
|
+ if event.content_type == "text":
|
|
|
|
|
+ try:
|
|
|
|
|
+ parsed = json.loads(event.content)
|
|
|
|
|
+ return parsed.get("text", event.content)
|
|
|
|
|
+ except (json.JSONDecodeError, TypeError):
|
|
|
|
|
+ return event.content
|
|
|
|
|
+
|
|
|
|
|
+ elif event.content_type == "image":
|
|
|
|
|
+ # 下载图片并转成 base64
|
|
|
|
|
+ try:
|
|
|
|
|
+ content_dict = json.loads(event.content)
|
|
|
|
|
+ image_key = content_dict.get("image_key")
|
|
|
|
|
+ if image_key and event.message_id:
|
|
|
|
|
+ img_bytes = self.client.download_message_resource(
|
|
|
|
|
+ message_id=event.message_id,
|
|
|
|
|
+ file_key=image_key,
|
|
|
|
|
+ resource_type="image"
|
|
|
|
|
+ )
|
|
|
|
|
+ b64_str = base64.b64encode(img_bytes).decode('utf-8')
|
|
|
|
|
+ # 返回 OpenAI 多模态格式
|
|
|
|
|
+ return [
|
|
|
|
|
+ {"type": "text", "text": "[用户发送了一张图片]"},
|
|
|
|
|
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_str}"}}
|
|
|
|
|
+ ]
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error(f"下载图片失败: {e}")
|
|
|
|
|
+ return "[图片下载失败]"
|
|
|
|
|
+
|
|
|
|
|
+ elif event.content_type == "file":
|
|
|
|
|
+ # 下载文件到本地
|
|
|
|
|
+ try:
|
|
|
|
|
+ content_dict = json.loads(event.content)
|
|
|
|
|
+ file_key = content_dict.get("file_key")
|
|
|
|
|
+ file_name = content_dict.get("file_name", "unknown_file")
|
|
|
|
|
+ if file_key and event.message_id:
|
|
|
|
|
+ file_bytes = self.client.download_message_resource(
|
|
|
|
|
+ message_id=event.message_id,
|
|
|
|
|
+ file_key=file_key,
|
|
|
|
|
+ resource_type="file"
|
|
|
|
|
+ )
|
|
|
|
|
+ # 保存到项目根目录的 downloads 文件夹
|
|
|
|
|
+ download_dir = os.path.join(PROJECT_ROOT, "downloads")
|
|
|
|
|
+ os.makedirs(download_dir, exist_ok=True)
|
|
|
|
|
+ save_path = os.path.join(download_dir, file_name)
|
|
|
|
|
+ with open(save_path, "wb") as f:
|
|
|
|
|
+ f.write(file_bytes)
|
|
|
|
|
+ return f"[用户发送了文件: {file_name},已保存到 {save_path}]"
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error(f"下载文件失败: {e}")
|
|
|
|
|
+ return "[文件下载失败]"
|
|
|
|
|
+
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ def _load_history_from_disk(self, contact_name: str, conv_key: str):
|
|
|
|
|
+ if conv_key in self._history_loaded:
|
|
|
|
|
+ return
|
|
|
|
|
+ self._history_loaded.add(conv_key)
|
|
|
|
|
+ history = load_chat_history(contact_name)
|
|
|
|
|
+ for msg in history[-MAX_HISTORY:]:
|
|
|
|
|
+ role = msg.get("role", "user")
|
|
|
|
|
+ content = msg.get("content", "")
|
|
|
|
|
+ if isinstance(content, list):
|
|
|
|
|
+ text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
|
|
|
|
|
+ content = "\n".join(text_parts)
|
|
|
|
|
+ if isinstance(content, str) and content.strip():
|
|
|
|
|
+ self.conversations[conv_key].append({"role": role, "content": content})
|
|
|
|
|
+
|
|
|
|
|
+ def _save_to_disk(self, contact_name: str, conv_key: str):
|
|
|
|
|
+ # 保存完整对话历史,包括工具调用和结果
|
|
|
|
|
+ saveable = []
|
|
|
|
|
+ for m in self.conversations[conv_key]:
|
|
|
|
|
+ role = m.get("role")
|
|
|
|
|
+ content = m.get("content")
|
|
|
|
|
+
|
|
|
|
|
+ # 保存所有消息类型
|
|
|
|
|
+ if role in ("user", "assistant", "tool"):
|
|
|
|
|
+ msg = {"role": role}
|
|
|
|
|
+
|
|
|
|
|
+ # 处理 content
|
|
|
|
|
+ if isinstance(content, str):
|
|
|
|
|
+ msg["content"] = content
|
|
|
|
|
+ elif isinstance(content, list):
|
|
|
|
|
+ # 多模态消息:提取文本部分保存
|
|
|
|
|
+ text_parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
|
|
|
|
|
+ if text_parts:
|
|
|
|
|
+ msg["content"] = "\n".join(text_parts)
|
|
|
|
|
+ else:
|
|
|
|
|
+ msg["content"] = "[多模态内容]"
|
|
|
|
|
+ else:
|
|
|
|
|
+ msg["content"] = str(content) if content else ""
|
|
|
|
|
+
|
|
|
|
|
+ # 保存 tool_calls(如果有)
|
|
|
|
|
+ if "tool_calls" in m:
|
|
|
|
|
+ msg["tool_calls"] = m["tool_calls"]
|
|
|
|
|
+
|
|
|
|
|
+ # 保存 tool_call_id(如果有)
|
|
|
|
|
+ if "tool_call_id" in m:
|
|
|
|
|
+ msg["tool_call_id"] = m["tool_call_id"]
|
|
|
|
|
+
|
|
|
|
|
+ saveable.append(msg)
|
|
|
|
|
+
|
|
|
|
|
+ save_chat_history(contact_name, saveable)
|
|
|
|
|
+
|
|
|
|
|
+ def handle_message(self, event: FeishuMessageEvent):
|
|
|
|
|
+ global _current_chat_id
|
|
|
|
|
+ contact = get_contact_by_id(event.sender_open_id) or get_contact_by_id(event.chat_id)
|
|
|
|
|
+ if not contact:
|
|
|
|
|
+ logger.debug(f"忽略非白名单消息: {event.sender_open_id}")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ sender_name = contact.get("name", "未知")
|
|
|
|
|
+ if sender_name not in ALLOWED_CONTACTS:
|
|
|
|
|
+ logger.debug(f"忽略非白名单联系人: {sender_name}")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ user_content = self._extract_content(event)
|
|
|
|
|
+ if not user_content:
|
|
|
|
|
+ logger.info(f"[{sender_name}] 发送了不支持的消息类型,跳过")
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # 记录消息类型
|
|
|
|
|
+ if isinstance(user_content, str):
|
|
|
|
|
+ logger.info(f"收到 [{sender_name}]: {user_content[:80]}")
|
|
|
|
|
+ else:
|
|
|
|
|
+ logger.info(f"收到 [{sender_name}]: [多模态消息]")
|
|
|
|
|
+
|
|
|
|
|
+ conv_key = self._get_conversation_key(event)
|
|
|
|
|
+ _current_chat_id = event.chat_id # 设置全局变量供工具使用
|
|
|
|
|
+
|
|
|
|
|
+ self._load_history_from_disk(sender_name, conv_key)
|
|
|
|
|
+ self._append_message(conv_key, "user", user_content)
|
|
|
|
|
+ self._save_to_disk(sender_name, conv_key)
|
|
|
|
|
+
|
|
|
|
|
+ future = asyncio.run_coroutine_threadsafe(
|
|
|
|
|
+ self._generate_and_reply(conv_key, event, sender_name),
|
|
|
|
|
+ self.loop,
|
|
|
|
|
+ )
|
|
|
|
|
+ future.add_done_callback(self._on_reply_done)
|
|
|
|
|
+
|
|
|
|
|
+ async def _generate_and_reply(self, conv_key: str, event: FeishuMessageEvent, sender_name: str):
|
|
|
|
|
+ """调用 Qwen LLM,支持多轮工具调用循环"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ last_tool_calls = [] # 记录最近的工具调用,用于检测循环
|
|
|
|
|
+ for round_i in range(MAX_TOOL_ROUNDS):
|
|
|
|
|
+ messages = self._build_messages(conv_key)
|
|
|
|
|
+
|
|
|
|
|
+ result = await qwen_llm_call(
|
|
|
|
|
+ messages=messages,
|
|
|
|
|
+ model=MODEL,
|
|
|
|
|
+ tools=TOOLS,
|
|
|
|
|
+ temperature=0.7,
|
|
|
|
|
+ max_tokens=4096,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ tool_calls = result.get("tool_calls")
|
|
|
|
|
+
|
|
|
|
|
+ if not tool_calls:
|
|
|
|
|
+ # 没有工具调用,直接回复
|
|
|
|
|
+ reply_text = result.get("content", "").strip()
|
|
|
|
|
+ if not reply_text:
|
|
|
|
|
+ reply_text = "(抱歉,我暂时无法生成回复)"
|
|
|
|
|
+
|
|
|
|
|
+ self._append_message(conv_key, "assistant", reply_text)
|
|
|
|
|
+ self._save_to_disk(sender_name, conv_key)
|
|
|
|
|
+
|
|
|
|
|
+ self.client.send_message(
|
|
|
|
|
+ to=event.chat_id,
|
|
|
|
|
+ text=reply_text,
|
|
|
|
|
+ receive_id_type=ReceiveIdType.CHAT_ID,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ tokens_in = result.get("prompt_tokens", 0)
|
|
|
|
|
+ tokens_out = result.get("completion_tokens", 0)
|
|
|
|
|
+ cost = result.get("cost", 0)
|
|
|
|
|
+ logger.info(
|
|
|
|
|
+ f"回复 [{sender_name}]: {reply_text[:80]}... "
|
|
|
|
|
+ f"(tokens: {tokens_in}+{tokens_out}, cost: ¥{cost:.4f})"
|
|
|
|
|
+ )
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # 检测循环:如果连续 2 次调用相同工具(仅比较工具名),强制退出
|
|
|
|
|
+ current_call_names = [tc.get("function", {}).get("name") for tc in tool_calls]
|
|
|
|
|
+ last_tool_calls.append(current_call_names)
|
|
|
|
|
+ if len(last_tool_calls) >= 2 and last_tool_calls[-1] == last_tool_calls[-2]:
|
|
|
|
|
+ logger.warning(f"[{sender_name}] 检测到工具调用循环: {current_call_names}")
|
|
|
|
|
+ self._append_message(conv_key, "assistant", "(检测到重复调用,已停止)")
|
|
|
|
|
+ self._save_to_disk(sender_name, conv_key)
|
|
|
|
|
+ self.client.send_message(
|
|
|
|
|
+ to=event.chat_id,
|
|
|
|
|
+ text="⚠️ 检测到重复调用相同工具,已停止。请换一个方式或直接告诉我结果。",
|
|
|
|
|
+ receive_id_type=ReceiveIdType.CHAT_ID,
|
|
|
|
|
+ )
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # 有工具调用:追加 assistant 消息(含 tool_calls),然后执行
|
|
|
|
|
+ assistant_msg = {"role": "assistant", "content": result.get("content", "") or ""}
|
|
|
|
|
+ assistant_msg["tool_calls"] = tool_calls
|
|
|
|
|
+ self.conversations[conv_key].append(assistant_msg)
|
|
|
|
|
+
|
|
|
|
|
+ for tc in tool_calls:
|
|
|
|
|
+ func = tc.get("function", {})
|
|
|
|
|
+ tool_name = func.get("name", "")
|
|
|
|
|
+ try:
|
|
|
|
|
+ tool_args = json.loads(func.get("arguments", "{}"))
|
|
|
|
|
+ except json.JSONDecodeError:
|
|
|
|
|
+ tool_args = {}
|
|
|
|
|
+
|
|
|
|
|
+ logger.info(f"[{sender_name}] 调用工具: {tool_name}({tool_args})")
|
|
|
|
|
+ # 在线程池中执行工具,避免阻塞 event loop
|
|
|
|
|
+ loop = asyncio.get_event_loop()
|
|
|
|
|
+ tool_result = await loop.run_in_executor(
|
|
|
|
|
+ None, execute_tool, tool_name, tool_args
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ # 截断过长的工具结果
|
|
|
|
|
+ if len(tool_result) > 4000:
|
|
|
|
|
+ tool_result = tool_result[:4000] + "\n... (结果已截断)"
|
|
|
|
|
+
|
|
|
|
|
+ self.conversations[conv_key].append({
|
|
|
|
|
+ "role": "tool",
|
|
|
|
|
+ "tool_call_id": tc.get("id", ""),
|
|
|
|
|
+ "content": tool_result,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ # 超过最大轮次
|
|
|
|
|
+ self._append_message(conv_key, "assistant", "(工具调用轮次过多,已停止)")
|
|
|
|
|
+ self._save_to_disk(sender_name, conv_key)
|
|
|
|
|
+ self.client.send_message(
|
|
|
|
|
+ to=event.chat_id,
|
|
|
|
|
+ text="⚠️ 工具调用轮次过多,已停止",
|
|
|
|
|
+ receive_id_type=ReceiveIdType.CHAT_ID,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.error(f"生成回复失败: {e}", exc_info=True)
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.client.send_message(
|
|
|
|
|
+ to=event.chat_id,
|
|
|
|
|
+ text=f"⚠️ 生成回复时出错: {type(e).__name__}",
|
|
|
|
|
+ receive_id_type=ReceiveIdType.CHAT_ID,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ logger.error("发送错误消息也失败了", exc_info=True)
|
|
|
|
|
+
|
|
|
|
|
+ def _on_reply_done(self, future):
|
|
|
|
|
+ exc = future.exception()
|
|
|
|
|
+ if exc:
|
|
|
|
|
+ logger.error(f"回复任务异常: {exc}", exc_info=exc)
|
|
|
|
|
+
|
|
|
|
|
+ def start(self):
|
|
|
|
|
+ logger.info(f"启动飞书对话 Agent (model={MODEL})")
|
|
|
|
|
+ logger.info("等待飞书消息... 按 Ctrl+C 退出")
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ self.client.start_websocket(
|
|
|
|
|
+ on_message=self.handle_message,
|
|
|
|
|
+ blocking=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ except KeyboardInterrupt:
|
|
|
|
|
+ logger.info("Agent 已停止")
|
|
|
|
|
+ finally:
|
|
|
|
|
+ self.loop.call_soon_threadsafe(self.loop.stop)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ agent = FeishuAgent()
|
|
|
|
|
+ agent.start()
|