771 lines
26 KiB
Python
771 lines
26 KiB
Python
"""
|
||
MaiSaka - 工具调用处理器
|
||
处理 LLM 循环中各工具(say/wait/stop/file/MCP/QQ)的执行逻辑。
|
||
"""
|
||
|
||
import json as _json
|
||
import asyncio
|
||
import os
|
||
from datetime import datetime
|
||
from typing import TYPE_CHECKING, Optional
|
||
from pathlib import Path
|
||
import importlib.util
|
||
|
||
# 检查 aiohttp 是否可用
|
||
AIOHTTP_AVAILABLE = importlib.util.find_spec("aiohttp") is not None
|
||
if AIOHTTP_AVAILABLE:
|
||
import aiohttp
|
||
|
||
from rich.panel import Panel
|
||
from rich.markdown import Markdown
|
||
|
||
from config import console
|
||
from input_reader import InputReader
|
||
from llm_service import BaseLLMService
|
||
from replyer import Replyer
|
||
|
||
if TYPE_CHECKING:
|
||
from mcp_client import MCPManager
|
||
|
||
|
||
# mai_files 目录路径
|
||
MAI_FILES_DIR = Path(os.path.join(os.path.dirname(os.path.abspath(__file__)), "mai_files"))
|
||
|
||
# 全局回复器
|
||
_replyer: Optional[Replyer] = None
|
||
|
||
|
||
def get_replyer(llm_service: BaseLLMService) -> Replyer:
|
||
"""获取回复器实例(单例模式)"""
|
||
global _replyer
|
||
if _replyer is None:
|
||
_replyer = Replyer(llm_service)
|
||
elif _replyer._llm_service is None:
|
||
_replyer.set_llm_service(llm_service)
|
||
return _replyer
|
||
|
||
|
||
class ToolHandlerContext:
|
||
"""工具处理器所需的共享上下文。"""
|
||
|
||
def __init__(
|
||
self,
|
||
llm_service: BaseLLMService,
|
||
reader: InputReader,
|
||
user_input_times: list[datetime],
|
||
):
|
||
self.llm_service = llm_service
|
||
self.reader = reader
|
||
self.user_input_times = user_input_times
|
||
self.last_user_input_time: Optional[datetime] = None
|
||
|
||
|
||
async def handle_say(tc, chat_history: list, ctx: ToolHandlerContext):
|
||
"""处理 say 工具:根据想法和上下文生成回复后展示给用户。"""
|
||
reason = tc.arguments.get("reason", "")
|
||
console.print("[accent]🔧 调用工具: say(...)[/accent]")
|
||
|
||
if reason:
|
||
# 想法以淡色展示
|
||
console.print(
|
||
Panel(
|
||
Markdown(reason),
|
||
title="💭 回复想法",
|
||
border_style="dim",
|
||
padding=(0, 1),
|
||
style="dim",
|
||
)
|
||
)
|
||
# 根据想法和上下文生成回复
|
||
with console.status(
|
||
"[info]✏️ 生成回复中...[/info]",
|
||
spinner="dots",
|
||
):
|
||
replyer = get_replyer(ctx.llm_service)
|
||
reply = await replyer.reply(reason, chat_history)
|
||
console.print(
|
||
Panel(
|
||
Markdown(reply),
|
||
title="💬 MaiSaka",
|
||
border_style="magenta",
|
||
padding=(1, 2),
|
||
)
|
||
)
|
||
# 生成的回复作为 tool 结果写入上下文
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": f"已向用户展示(实际输出):{reply}",
|
||
})
|
||
else:
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": "reason 内容为空,未展示",
|
||
})
|
||
|
||
|
||
async def handle_stop(tc, chat_history: list):
|
||
"""处理 stop 工具:结束对话循环。"""
|
||
console.print("[accent]🔧 调用工具: stop()[/accent]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": "对话循环已停止,等待用户下次输入。",
|
||
})
|
||
|
||
|
||
async def handle_wait(tc, chat_history: list, ctx: ToolHandlerContext) -> str:
|
||
"""
|
||
处理 wait 工具:等待用户输入或超时。
|
||
|
||
Returns:
|
||
工具结果字符串。以 "[[QUIT]]" 开头表示用户要求退出对话。
|
||
"""
|
||
seconds = tc.arguments.get("seconds", 30)
|
||
seconds = max(5, min(seconds, 300)) # 限制 5-300 秒
|
||
console.print(f"[accent]🔧 调用工具: wait({seconds})[/accent]")
|
||
|
||
tool_result = await _do_wait(seconds, ctx)
|
||
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": tool_result,
|
||
})
|
||
return tool_result
|
||
|
||
|
||
async def _do_wait(seconds: int, ctx: ToolHandlerContext) -> str:
|
||
"""实际执行等待逻辑。"""
|
||
console.print(f"[muted]⏳ 等待回复 (最多 {seconds} 秒)...[/muted]")
|
||
console.print("[bold magenta]💬 > [/bold magenta]", end="")
|
||
|
||
user_input = await ctx.reader.get_line(timeout=seconds)
|
||
|
||
if user_input is None:
|
||
# 超时
|
||
console.print() # 换行
|
||
console.print("[muted]⏳ 等待超时[/muted]")
|
||
return "等待超时,用户未输入任何内容"
|
||
|
||
user_input = user_input.strip()
|
||
|
||
if not user_input:
|
||
return "用户发送了空消息"
|
||
|
||
# 更新 timing 时间戳
|
||
now = datetime.now()
|
||
ctx.last_user_input_time = now
|
||
ctx.user_input_times.append(now)
|
||
|
||
if user_input.lower() in ("/quit", "/exit", "/q"):
|
||
return "[[QUIT]] 用户主动退出了对话"
|
||
|
||
return f"用户说:{user_input}"
|
||
|
||
|
||
async def handle_mcp_tool(tc, chat_history: list, mcp_manager: "MCPManager"):
|
||
"""
|
||
处理 MCP 工具调用。
|
||
|
||
将调用转发到 MCPManager,展示结果并写入对话上下文。
|
||
"""
|
||
# 格式化参数预览
|
||
args_str = _json.dumps(tc.arguments, ensure_ascii=False)
|
||
args_preview = args_str if len(args_str) <= 120 else args_str[:120] + "..."
|
||
console.print(f"[accent]🔌 调用 MCP 工具: {tc.name}({args_preview})[/accent]")
|
||
|
||
with console.status(
|
||
f"[info]🔌 MCP 工具 {tc.name} 执行中...[/info]",
|
||
spinner="dots",
|
||
):
|
||
result = await mcp_manager.call_tool(tc.name, tc.arguments)
|
||
|
||
# 展示结果(截断过长内容)
|
||
display_text = result if len(result) <= 800 else result[:800] + "\n... (已截断)"
|
||
console.print(
|
||
Panel(
|
||
display_text,
|
||
title=f"🔌 MCP: {tc.name}",
|
||
border_style="bright_green",
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": result,
|
||
})
|
||
|
||
|
||
async def handle_unknown_tool(tc, chat_history: list):
|
||
"""处理未知工具调用。"""
|
||
console.print(f"[accent]🔧 调用工具: {tc.name}({tc.arguments})[/accent]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": f"未知工具: {tc.name}",
|
||
})
|
||
|
||
|
||
async def handle_write_file(tc, chat_history: list):
|
||
"""处理 write_file 工具:在 mai_files 目录下写入文件。"""
|
||
filename = tc.arguments.get("filename", "")
|
||
content = tc.arguments.get("content", "")
|
||
console.print(f"[accent]🔧 调用工具: write_file(\"{filename}\")[/accent]")
|
||
|
||
# 确保目录存在
|
||
MAI_FILES_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 构建完整文件路径
|
||
file_path = MAI_FILES_DIR / filename
|
||
|
||
try:
|
||
# 创建父目录(如果需要)
|
||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 写入文件
|
||
with open(file_path, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
|
||
# 获取文件大小
|
||
file_size = file_path.stat().st_size
|
||
|
||
console.print(
|
||
Panel(
|
||
f"文件已写入: {filename}\n大小: {file_size} 字符",
|
||
title="📁 文件已保存",
|
||
border_style="green",
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": f"文件「{filename}」已成功写入,共 {file_size} 个字符。",
|
||
})
|
||
except Exception as e:
|
||
error_msg = f"写入文件失败: {e}"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
|
||
|
||
async def handle_read_file(tc, chat_history: list):
|
||
"""处理 read_file 工具:读取 mai_files 目录下的文件。"""
|
||
filename = tc.arguments.get("filename", "")
|
||
console.print(f"[accent]🔧 调用工具: read_file(\"{filename}\")[/accent]")
|
||
|
||
# 构建完整文件路径
|
||
file_path = MAI_FILES_DIR / filename
|
||
|
||
try:
|
||
if not file_path.exists():
|
||
error_msg = f"文件「{filename}」不存在。"
|
||
console.print(f"[warning]{error_msg}[/warning]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
return
|
||
|
||
if not file_path.is_file():
|
||
error_msg = f"「{filename}」不是一个文件。"
|
||
console.print(f"[warning]{error_msg}[/warning]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
return
|
||
|
||
# 读取文件内容
|
||
with open(file_path, "r", encoding="utf-8") as f:
|
||
file_content = f.read()
|
||
|
||
# 截断过长内容用于显示
|
||
display_content = file_content
|
||
if len(file_content) > 1000:
|
||
display_content = file_content[:1000] + "\n... (内容已截断)"
|
||
|
||
console.print(
|
||
Panel(
|
||
display_content,
|
||
title=f"📄 文件内容: {filename}",
|
||
border_style="blue",
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": f"文件「{filename}」内容:\n{file_content}",
|
||
})
|
||
except Exception as e:
|
||
error_msg = f"读取文件失败: {e}"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
|
||
|
||
async def handle_list_files(tc, chat_history: list):
|
||
"""处理 list_files 工具:获取 mai_files 目录下所有文件的元信息。"""
|
||
console.print("[accent]🔧 调用工具: list_files()[/accent]")
|
||
|
||
try:
|
||
# 确保目录存在
|
||
MAI_FILES_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 获取所有文件
|
||
files_info = []
|
||
for item in MAI_FILES_DIR.rglob("*"):
|
||
if item.is_file():
|
||
# 获取相对路径
|
||
rel_path = item.relative_to(MAI_FILES_DIR)
|
||
stat = item.stat()
|
||
files_info.append({
|
||
"name": str(rel_path),
|
||
"size": stat.st_size,
|
||
"modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
||
})
|
||
|
||
if not files_info:
|
||
result_text = "mai_files 目录为空,没有任何文件。"
|
||
else:
|
||
# 按名称排序
|
||
files_info.sort(key=lambda x: x["name"])
|
||
# 格式化输出
|
||
lines = [f"📁 mai_files 目录下共有 {len(files_info)} 个文件:\n"]
|
||
for info in files_info:
|
||
lines.append(f" • {info['name']} ({info['size']} 字节, 修改于 {info['modified']})")
|
||
result_text = "\n".join(lines)
|
||
|
||
console.print(
|
||
Panel(
|
||
result_text,
|
||
title="📁 文件列表",
|
||
border_style="cyan",
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": result_text,
|
||
})
|
||
except Exception as e:
|
||
error_msg = f"获取文件列表失败: {e}"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
|
||
|
||
async def handle_store_context(tc, chat_history: list, ctx: ToolHandlerContext):
|
||
"""
|
||
处理 store_context 工具:将指定范围的对话上下文存入记忆系统,然后从对话中移除。
|
||
|
||
参数:
|
||
- count: 要存入记忆的消息数量(从最早的消息开始)
|
||
- reason: 存入的原因
|
||
"""
|
||
count = tc.arguments.get("count", 0)
|
||
reason = tc.arguments.get("reason", "")
|
||
console.print(f"[accent]🔧 调用工具: store_context(count={count}, reason=\"{reason}\")[/accent]")
|
||
|
||
if count <= 0:
|
||
error_msg = "count 参数必须大于 0"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
return
|
||
|
||
# 计算实际消息数量(排除 role=tool 的工具返回消息)
|
||
actual_messages = [m for m in chat_history if m.get("role") != "tool"]
|
||
|
||
if count > len(actual_messages):
|
||
error_msg = f"count({count}) 超过了当前对话消息数量({len(actual_messages)})"
|
||
console.print(f"[warning]{error_msg}[/warning]")
|
||
count = len(actual_messages)
|
||
|
||
# 找到要移除的消息索引(确保 tool_calls 和 tool 响应成对)
|
||
indices_to_remove = []
|
||
removed_count = 0
|
||
i = 0
|
||
|
||
while i < len(chat_history) and removed_count < count:
|
||
msg = chat_history[i]
|
||
role = msg.get("role", "")
|
||
|
||
# 跳过 role=tool 的消息(它们会被对应的 assistant 消息一起处理)
|
||
if role == "tool":
|
||
i += 1
|
||
continue
|
||
|
||
# 检查这是否是一个带 tool_calls 的 assistant 消息
|
||
if role == "assistant" and "tool_calls" in msg:
|
||
# 检查这个消息是否包含当前的 tool_call(store_context 自己)
|
||
# 如果包含,跳过不删除(否则会导致 tool 响应孤儿)
|
||
contains_current_call = any(
|
||
tc.get("id") == tc.id for tc in msg.get("tool_calls", [])
|
||
)
|
||
if contains_current_call:
|
||
i += 1
|
||
continue
|
||
|
||
# 收集这个 assistant 消息及其后续的 tool 响应消息
|
||
block_indices = [i]
|
||
j = i + 1
|
||
while j < len(chat_history):
|
||
next_msg = chat_history[j]
|
||
if next_msg.get("role") == "tool":
|
||
block_indices.append(j)
|
||
j += 1
|
||
else:
|
||
break
|
||
indices_to_remove.extend(block_indices)
|
||
removed_count += 1
|
||
i = j
|
||
elif role in ["user", "assistant"]:
|
||
# 普通消息,可以直接删除
|
||
indices_to_remove.append(i)
|
||
removed_count += 1
|
||
i += 1
|
||
else:
|
||
i += 1
|
||
|
||
if not indices_to_remove:
|
||
result_msg = "没有找到可存入记忆的消息"
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": result_msg,
|
||
})
|
||
return
|
||
|
||
# 收集要总结的消息(在删除前)
|
||
to_compress = []
|
||
for i in sorted(indices_to_remove):
|
||
if 0 <= i < len(chat_history):
|
||
to_compress.append(chat_history[i])
|
||
|
||
# 总结上下文并压缩
|
||
try:
|
||
with console.status(
|
||
"[info]📝 正在总结上下文...[/info]",
|
||
spinner="dots",
|
||
):
|
||
summary = await ctx.llm_service.summarize_context(to_compress)
|
||
|
||
if summary:
|
||
console.print(
|
||
Panel(
|
||
Markdown(summary),
|
||
title="📝 上下文已压缩",
|
||
border_style="green",
|
||
padding=(0, 1),
|
||
style="dim",
|
||
)
|
||
)
|
||
result_msg = f"✅ 已压缩 {len(to_compress)} 条消息\n原因: {reason}"
|
||
else:
|
||
result_msg = "⚠️ 上下文总结失败"
|
||
console.print(f"[warning]{result_msg}[/warning]")
|
||
|
||
except Exception as e:
|
||
result_msg = f"❌ 总结上下文时出错: {e}"
|
||
console.print(f"[error]{result_msg}[/error]")
|
||
|
||
# 从后往前删除消息
|
||
for i in sorted(indices_to_remove, reverse=True):
|
||
if 0 <= i < len(chat_history):
|
||
chat_history.pop(i)
|
||
|
||
# 清理"孤儿" tool 消息(没有对应 tool_calls 的 tool 消息)
|
||
# 收集所有有效的 tool_call_id
|
||
valid_tool_call_ids = set()
|
||
for msg in chat_history:
|
||
if msg.get("role") == "assistant" and "tool_calls" in msg:
|
||
for tool_call in msg["tool_calls"]:
|
||
valid_tool_call_ids.add(tool_call.get("id", ""))
|
||
|
||
# 删除无效的 tool 消息(从后往前)
|
||
i = len(chat_history) - 1
|
||
while i >= 0:
|
||
msg = chat_history[i]
|
||
if msg.get("role") == "tool":
|
||
tool_call_id = msg.get("tool_call_id", "")
|
||
if tool_call_id not in valid_tool_call_ids:
|
||
chat_history.pop(i)
|
||
i -= 1
|
||
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": result_msg,
|
||
})
|
||
|
||
|
||
async def handle_get_qq_chat_info(tc, chat_history: list):
|
||
"""处理 get_qq_chat_info 工具:通过 HTTP 获取 QQ 聊天内容。"""
|
||
chat = tc.arguments.get("chat", "")
|
||
limit = tc.arguments.get("limit", 20)
|
||
console.print(f"[accent]🔧 调用工具: get_qq_chat_info(\"{chat}\", limit={limit})[/accent]")
|
||
|
||
if not AIOHTTP_AVAILABLE:
|
||
error_msg = "aiohttp 模块未安装,请运行: pip install aiohttp"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
return
|
||
|
||
from config import QQ_API_BASE_URL, QQ_API_KEY
|
||
if not QQ_API_BASE_URL:
|
||
error_msg = "QQ_API_BASE_URL 未配置,请在 .env 中设置"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
return
|
||
|
||
try:
|
||
# 构建 API 端点
|
||
url = f"{QQ_API_BASE_URL.rstrip('/')}/api/external/chat/history"
|
||
|
||
# 构建请求头(如果配置了 API Key)
|
||
headers = {}
|
||
if QQ_API_KEY:
|
||
headers["Authorization"] = f"Bearer {QQ_API_KEY}"
|
||
|
||
# 发送 HTTP 请求
|
||
async with aiohttp.ClientSession() as session:
|
||
params = {"chat": chat, "limit": limit}
|
||
async with session.get(url, params=params, headers=headers) as response:
|
||
if response.status == 200:
|
||
# 获取纯文本响应
|
||
text = await response.text()
|
||
|
||
# 格式化显示
|
||
console.print(
|
||
Panel(
|
||
f"聊天标识: {chat}\n获取数量: {limit}\n\n{text if text.strip() else '暂无聊天记录'}",
|
||
title="💬 QQ 聊天记录",
|
||
border_style="cyan",
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": text if text.strip() else "暂无聊天记录",
|
||
})
|
||
else:
|
||
error_text = await response.text()
|
||
error_msg = f"HTTP 请求失败 (状态码 {response.status}): {error_text}"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
except Exception as e:
|
||
error_msg = f"获取 QQ 聊天记录失败: {e}"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
|
||
|
||
async def handle_send_info(tc, chat_history: list):
|
||
"""处理 send_info 工具:通过 HTTP 发送消息到 QQ。"""
|
||
chat = tc.arguments.get("chat", "")
|
||
message = tc.arguments.get("message", "")
|
||
console.print(f"[accent]🔧 调用工具: send_info(\"{chat}\")[/accent]")
|
||
|
||
if not AIOHTTP_AVAILABLE:
|
||
error_msg = "aiohttp 模块未安装,请运行: pip install aiohttp"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
return
|
||
|
||
from config import QQ_API_BASE_URL, QQ_API_KEY
|
||
if not QQ_API_BASE_URL:
|
||
error_msg = "QQ_API_BASE_URL 未配置,请在 .env 中设置"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
return
|
||
|
||
try:
|
||
# 构建 API 端点
|
||
url = f"{QQ_API_BASE_URL.rstrip('/')}/api/external/chat/send"
|
||
|
||
# 构建请求头(如果配置了 API Key)
|
||
headers = {}
|
||
if QQ_API_KEY:
|
||
headers["Authorization"] = f"Bearer {QQ_API_KEY}"
|
||
|
||
# 发送 HTTP 请求
|
||
async with aiohttp.ClientSession() as session:
|
||
payload = {"chat": chat, "message": message}
|
||
async with session.post(url, json=payload, headers=headers) as response:
|
||
data = await response.json()
|
||
|
||
if response.status == 200 and data.get("success"):
|
||
# 格式化显示
|
||
console.print(
|
||
Panel(
|
||
f"目标: {chat}\n消息: {message}\n\n结果: {data.get('message', '发送成功')}",
|
||
title="📤 消息已发送",
|
||
border_style="green",
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": f"消息发送成功: {data.get('message', '发送成功')}",
|
||
})
|
||
else:
|
||
error_msg = f"发送失败: {data.get('message', '未知错误')}"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
except Exception as e:
|
||
error_msg = f"发送消息失败: {e}"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
|
||
|
||
async def handle_list_qq_chats(tc, chat_history: list):
|
||
"""处理 list_qq_chats 工具:获取所有可用的 QQ 聊天列表。"""
|
||
console.print("[accent]🔧 调用工具: list_qq_chats()[/accent]")
|
||
|
||
if not AIOHTTP_AVAILABLE:
|
||
error_msg = "aiohttp 模块未安装,请运行: pip install aiohttp"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
return
|
||
|
||
from config import QQ_API_BASE_URL, QQ_API_KEY
|
||
if not QQ_API_BASE_URL:
|
||
error_msg = "QQ_API_BASE_URL 未配置,请在 .env 中设置"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
return
|
||
|
||
try:
|
||
# 构建 API 端点
|
||
url = f"{QQ_API_BASE_URL.rstrip('/')}/api/external/chat/list"
|
||
|
||
# 构建请求头(如果配置了 API Key)
|
||
headers = {}
|
||
if QQ_API_KEY:
|
||
headers["Authorization"] = f"Bearer {QQ_API_KEY}"
|
||
|
||
# 发送 HTTP 请求
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.get(url, headers=headers) as response:
|
||
data = await response.json()
|
||
|
||
if response.status == 200 and data.get("success"):
|
||
chats = data.get("chats", [])
|
||
|
||
# 格式化聊天列表
|
||
if chats:
|
||
chat_list_text = "\n".join([
|
||
f" • [{c.get('platform', 'qq')}] {c.get('name', '未知')} (chat: {c.get('chat', 'N/A')})"
|
||
for c in chats
|
||
])
|
||
result_text = f"可用的聊天 (共 {len(chats)} 个):\n{chat_list_text}"
|
||
else:
|
||
result_text = "没有可用的聊天"
|
||
|
||
console.print(
|
||
Panel(
|
||
result_text,
|
||
title="💬 QQ 聊天列表",
|
||
border_style="cyan",
|
||
padding=(0, 1),
|
||
)
|
||
)
|
||
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": result_text,
|
||
})
|
||
else:
|
||
error_msg = f"获取失败: {data.get('message', '未知错误')}"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
except Exception as e:
|
||
error_msg = f"获取聊天列表失败: {e}"
|
||
console.print(f"[error]{error_msg}[/error]")
|
||
chat_history.append({
|
||
"role": "tool",
|
||
"tool_call_id": tc.id,
|
||
"content": error_msg,
|
||
})
|
||
|
||
|
||
# ──────────────────── 初始化 mai_files 目录 ────────────────────
|
||
|
||
# 确保程序启动时 mai_files 目录存在
|
||
try:
|
||
MAI_FILES_DIR.mkdir(parents=True, exist_ok=True)
|
||
except Exception as e:
|
||
console.print(f"[warning]创建 mai_files 目录失败: {e}[/warning]")
|