feat: Function Calling 兼容

新增 xcli_core/fc_tools.py:
- MCP 定义 → OpenAI FC 格式转换
- FC 响应解析 (tool_calls 提取)
- 自动收集所有插件 FC 定义
- 400 错误自动降级为普通模式

引擎层改动 (openai_engine + ollama_engine):
- generate_response 新增 tools 参数
- 请求体自动注入 tools/tool_choice
- 响应检测 tool_calls 并返回 FC 标记
- 对话历史支持 tool 角色消息和 assistant.tool_calls
- _build_messages_with_history 兼容 FC 消息格式

cli_core.py 集成:
- _handle_fc_response: FC 响应 → 工具执行 → 结果回传
- _execute_tool_by_name: FC 调用路径的安全检查+工具分发
- process_conversation 循环自动处理 FC 工具调用
- TUI/CLI 双模式 FC 支持
This commit is contained in:
shuiyu1123 committed 2026-04-27 00:29:34 +08:00
1 parent 15e35ca8ad
commit 6de5c6f202
5 files changed
+559 -57

No files matched your search

+121 -2
View File
@@ -6,6 +6,7 @@ import time
import shutil
import threading
import random
from typing import Optional
from colorama import Fore, Style
from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY
@@ -473,6 +474,14 @@ multi 操作支持一次修改多处:
processed_response = self.process_thinking_response(response)
processed_response = self._process_code_blocks(processed_response)
# ── Function Calling 响应检测 ──
fc_handled = self._handle_fc_response(processed_response, user_input)
if fc_handled:
# FC 工具已执行,用结果继续循环
current_input = fc_handled
loop_count += 1
continue
try:
text_content, json_data = self._parse_mixed_response(processed_response)
@@ -707,6 +716,99 @@ multi 操作支持一次修改多处:
summary = summary[:77] + "..."
return summary
def _handle_fc_response(self, response: str, original_input: str) -> Optional[str]:
"""
处理 Function Calling 响应
如果响应是 FC 格式,执行工具并返回结果(供下一轮循环使用)
如果不是 FC 响应,返回 None
"""
if not response or '_fc' not in response:
return None
try:
data = json.loads(response)
if not data.get("_fc"):
return None
tool_calls = data.get("tool_calls", [])
if not tool_calls:
return None
# 记录 assistant 的 FC 消息到历史
fc_history_entry = {
"role": "assistant",
"content": None,
"tool_calls": [{
"id": tc.get("id", ""),
"type": "function",
"function": {
"name": tc.get("name", ""),
"arguments": json.dumps(tc.get("arguments", {}), ensure_ascii=False)
}
} for tc in tool_calls]
}
self.shared_conversation_history.append(fc_history_entry)
# 执行每个工具调用
results = []
for tc in tool_calls:
tool_name = tc.get("tool", tc.get("name", ""))
tool_args = tc.get("args", "")
call_id = tc.get("id", "")
print(f"\n{Fore.GREEN} FC 调用: {tool_name}({tool_args[:60]}){Style.RESET_ALL}")
# 通过插件管理器执行
result = self._execute_tool_by_name(tool_name, tool_args)
# 记录工具结果到历史
tool_result_msg = {
"role": "tool",
"tool_call_id": call_id,
"name": tool_name,
"content": result[:5000] if result else "执行完成"
}
self.shared_conversation_history.append(tool_result_msg)
results.append(f"[{tool_name}]: {result[:200] if result else '完成'}")
# 返回工具结果,供下一轮 AI 处理
return "工具执行结果:\n" + "\n".join(results)
except (ValueError, KeyError, TypeError):
return None
def _execute_tool_by_name(self, tool_name: str, tool_args: str) -> str:
"""根据名称执行工具(FC 调用路径)"""
# 安全检查
safety = get_safety()
allowed, msg = safety.check(tool_name, tool_args)
if not allowed:
return f"操作被拒绝: {msg}"
# 查找工具
tool = self.liugin_manager.get_tool_by_name(tool_name)
if tool and 'handler' in tool:
try:
return tool['handler'](tool_args)
except Exception as e:
return f"工具执行失败: {e}"
# memory 内置工具
if tool_name == 'memory' and hasattr(self, 'memory_manager'):
return self.memory_manager.handle_tool(tool_args)
# scheduler 内置工具
if tool_name == 'scheduler':
for t in self.liugin_manager.tools:
if t.get('name') == 'scheduler' and 'handler' in t:
try:
return t['handler'](tool_args)
except Exception as e:
return f"工具执行失败: {e}"
return f"未找到工具: {tool_name}"
def _generate_response_with_animation(self, current_input, liugin_prompts=None):
"""生成AI响应并显示等待动画 — 支持 ESC 跨平台取消"""
# TUI 模式:不做动画
@@ -714,7 +816,14 @@ multi 操作支持一次修改多处:
system_prompt = self._build_system_prompt(liugin_prompts)
if not self.current_engine:
return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
response = self.current_engine.generate_response(current_input, system_prompt=system_prompt)
# TUI 模式也支持 FC
fc_tools = None
try:
from .fc_tools import mcp_to_openai_tools
fc_tools = mcp_to_openai_tools(self.liugin_manager)
except Exception:
pass
response = self.current_engine.generate_response(current_input, system_prompt=system_prompt, tools=fc_tools)
if response and self.shared_conversation_history is not None:
self.shared_conversation_history.append({
"role": "assistant",
@@ -801,8 +910,18 @@ multi 操作支持一次修改多处:
if not self.current_engine:
response_result[0] = "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
else:
# ── 收集 FC 工具定义 ──
fc_tools = None
try:
from .fc_tools import mcp_to_openai_tools
fc_tools = mcp_to_openai_tools(self.liugin_manager)
if fc_tools:
fc_tools = fc_tools # 已经是列表
except Exception:
fc_tools = None
response_result[0] = self.current_engine.generate_response(
current_input, system_prompt=system_prompt
current_input, system_prompt=system_prompt, tools=fc_tools
)
except Exception as e:
response_result[0] = f"AI 响应生成失败: {e}"
+250
View File
@@ -0,0 +1,250 @@
"""
Function Calling 工具格式转换
将 MCP 插件定义转为 OpenAI FC 格式,收集所有可用工具
"""
import json
from typing import List, Dict, Optional
def mcp_to_openai_tools(plugin_manager) -> List[Dict]:
"""
从插件管理器收集所有工具定义,转为 OpenAI function calling 格式
Returns:
[{"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}}, ...]
"""
tools = []
# 从插件管理器获取工具
if not plugin_manager:
return tools
# 统一工具管理器
if hasattr(plugin_manager, 'plugins'):
for plugin in plugin_manager.plugins:
fc_def = _extract_fc_def(plugin)
if fc_def:
tools.append(fc_def)
# 普通插件管理器
elif hasattr(plugin_manager, 'tools'):
for tool_info in plugin_manager.tools:
fc_def = _tool_info_to_fc(tool_info)
if fc_def:
tools.append(fc_def)
# 追加内置工具(memory 等)
builtin_tools = _get_builtin_tools()
tools.extend(builtin_tools)
return tools
def _extract_fc_def(plugin) -> Optional[Dict]:
"""从插件实例提取 FC 定义"""
if not hasattr(plugin, 'get_mcp_definition'):
return None
try:
mcp_def = plugin.get_mcp_definition()
return mcp_to_fc(mcp_def)
except Exception:
return None
def _tool_info_to_fc(tool_info: dict) -> Optional[Dict]:
"""从 tool_info 字典转为 FC 定义"""
name = tool_info.get('name', '')
desc = tool_info.get('description', '')
if not name:
return None
return {
"type": "function",
"function": {
"name": name,
"description": desc,
"parameters": {
"type": "object",
"properties": {
"args": {
"type": "string",
"description": f"操作参数。格式参见 {name} 工具说明"
}
},
"required": ["args"]
}
}
}
def mcp_to_fc(mcp_def: dict) -> Optional[Dict]:
"""
MCP 定义 → OpenAI FC 定义
MCP 格式: {"name": ..., "description": ..., "inputSchema": {...}}
OpenAI FC: {"type": "function", "function": {"name": ..., "description": ..., "parameters": {...}}}
"""
name = mcp_def.get('name', '')
desc = mcp_def.get('description', '')
schema = mcp_def.get('inputSchema', mcp_def.get('parameters', {}))
if not name:
return None
# 如果 schema 为空,生成默认的 args 参数
if not schema or not schema.get('properties'):
schema = {
"type": "object",
"properties": {
"args": {
"type": "string",
"description": f"操作参数字符串,格式参见 {name} 工具说明"
}
},
"required": ["args"]
}
return {
"type": "function",
"function": {
"name": name,
"description": desc,
"parameters": schema
}
}
def fc_result_to_message(tool_call_id: str, tool_name: str, result: str) -> Dict:
"""
工具执行结果 → OpenAI 消息格式
Returns:
{"role": "tool", "tool_call_id": ..., "content": ...}
"""
return {
"role": "tool",
"tool_call_id": tool_call_id,
"name": tool_name,
"content": result[:10000] if result else "" # 截断过长结果
}
def parse_fc_response(response_data: dict) -> Optional[List[Dict]]:
"""
解析 OpenAI FC 响应,提取 tool_calls
Returns:
[{"id": "call_xxx", "name": "tool_name", "arguments": {...}}, ...]
或 None(如果不是 FC 响应)
"""
choices = response_data.get("choices", [])
if not choices:
return None
message = choices[0].get("message", {})
tool_calls_raw = message.get("tool_calls")
if not tool_calls_raw:
return None
tool_calls = []
for tc in tool_calls_raw:
func = tc.get("function", {})
name = func.get("name", "")
args_str = func.get("arguments", "{}")
# 解析 arguments JSON
try:
if isinstance(args_str, str):
args = json.loads(args_str)
else:
args = args_str
except json.JSONDecodeError:
args = {"raw": args_str}
# 统一为小狸内部格式: {"action": "use_tool", "tool": name, "args": "..." }
# 如果 args 里有 args 字段,直接用;否则把整个 args 序列化
if "args" in args and isinstance(args["args"], str):
final_args = args["args"]
elif "args" in args:
final_args = json.dumps(args["args"], ensure_ascii=False)
else:
# 把所有参数拼成字符串
parts = []
for k, v in args.items():
if isinstance(v, str):
parts.append(f"{v}")
else:
parts.append(json.dumps(v, ensure_ascii=False))
final_args = " ".join(parts) if parts else ""
tool_calls.append({
"id": tc.get("id", f"call_{name}"),
"name": name,
"arguments": args,
# 小狸内部格式
"action": "use_tool",
"tool": name,
"args": final_args,
})
return tool_calls
def _get_builtin_tools() -> List[Dict]:
"""内置工具定义(不依赖插件系统)"""
return [
{
"type": "function",
"function": {
"name": "memory",
"description": "持久化记忆系统。写日记、记录长期记忆、搜索历史记忆和聊天记录。",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["write", "diary", "remember", "search", "context",
"diary_list", "diary_read", "chat_list", "chat_search"],
"description": "操作类型"
},
"content": {
"type": "string",
"description": "内容或关键词"
}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "scheduler",
"description": "定时任务工具,设定定时提醒。支持相对时间(30m/2h)、今日时间(14:30)、绝对时间、重复任务(r:前缀)。",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "list", "delete", "clear"],
"description": "操作类型"
},
"time": {
"type": "string",
"description": "时间: 30m / 14:30 / 2026-04-28 10:00 / r:30m"
},
"message": {
"type": "string",
"description": "任务描述"
},
"task_id": {
"type": "integer",
"description": "任务ID (用于 delete)"
}
},
"required": ["action"]
}
}
}
]