自用体验(同时以人类和被接入 AI 的视角)暴露出三个问题,一并处理:
1. 启动太吵:117 行 / 16983 字符的开屏,人还没开始用就先刷屏
- 完整 ASCII 开机动画改为 --logo / --banner 按需召唤,默认两行紧凑标识
- 21 行命令说明折叠成一行「/help 全部命令 · /quit 退出 · ...」,
完整速查表挪进 /help(新增 show_quick_commands)
- 插件/引擎/工具的逐条加载日志改走 vprint,由 --verbose / -v /
XCLI_VERBOSE=1 召唤;工具加载汇总成一行
- 日志控制台 handler 降到 WARNING(文件仍留全量 INFO),并静音
httpx/httpcore/urllib3 等第三方心跳日志
=> 默认启动 10 行 / 475 字符,--verbose 仍可看到全部 36 行细节
2. 不支持 FC 的模型每次会话都白撞一发 500
讯飞 xophunyuan7bmt 不支持 Function Calling,请求带 tools 字段必报
500 Invalid Params,虽有降级重试兜底,但每个新会话都要先错一次。
- 新增 xcli_core/fc_tools 能力缓存(按 base_url::model 落盘 .fc_support.json)
- 降级重试成功后记住该模型不支持 FC,后续直接不发 tools
- 降级也失败时不写缓存,避免把别的故障误判成 FC 问题
- 新增 /fc [reset] 查看与清除缓存
3. diff 弹窗模式每次启动都要重新问一遍
- 首次选择后经 config.json 持久化,新增 /diff [popup|inline] 随时切换
测试:
- 新增 tests/test_startup_and_fc.py(27 项,含假 HTTP 验证降级与缓存链路)
- 修复 tests/test_plugins.py 长期失效:插件入口类早已由 Plugin 改名 Liugin,
8 处 fixture 未跟进导致 65 个用例直接 error;断言也未适配 ToolResult 返回值
与「❌」错误前缀。改为 plugin_class() / as_text() / is_error() 语义判断。
- 全量:217 passed(此前 152 passed + 65 error + 27 failed)
331 lines
11 KiB
Python
331 lines
11 KiB
Python
"""
|
|
Function Calling 工具格式转换
|
|
将 MCP 插件定义转为 OpenAI FC 格式,收集所有可用工具
|
|
"""
|
|
import os
|
|
import json
|
|
import threading
|
|
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": ...}
|
|
注意: 官方 OpenAI 的 tool 消息不含 name 字段(严格服务端如讯飞会拒),故不写入。
|
|
"""
|
|
return {
|
|
"role": "tool",
|
|
"tool_call_id": tool_call_id,
|
|
"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"]
|
|
}
|
|
}
|
|
}
|
|
]
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════
|
|
# FC 能力探测缓存
|
|
#
|
|
# 部分渠道/模型(如讯飞星火经 one-api 转发的 xophunyuan* 系列)
|
|
# 不支持 function calling,请求体里只要带上非空 tools 字段就会直接
|
|
# 返回 500 RequestParamsError:Invalid Params。
|
|
# 引擎捕获到这种错误后会去掉 tools 降级重试;本缓存把"该模型不支持 FC"
|
|
# 这件事记下来,后续请求直接跳过 tools,避免每次冷启动都白撞一次 500。
|
|
# ══════════════════════════════════════════════════════════════════
|
|
|
|
_FC_CACHE_FILE = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".fc_support.json"
|
|
)
|
|
_fc_cache = None
|
|
_fc_lock = threading.Lock()
|
|
|
|
|
|
def _fc_key(base_url, model) -> str:
|
|
"""缓存键:base_url + model(同一渠道下不同模型能力可能不同)"""
|
|
return f"{(base_url or '').rstrip('/')}::{model or ''}"
|
|
|
|
|
|
def _load_fc_cache() -> dict:
|
|
global _fc_cache
|
|
if _fc_cache is not None:
|
|
return _fc_cache
|
|
try:
|
|
with open(_FC_CACHE_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
_fc_cache = data if isinstance(data, dict) else {}
|
|
except (FileNotFoundError, json.JSONDecodeError, OSError, ValueError):
|
|
_fc_cache = {}
|
|
return _fc_cache
|
|
|
|
|
|
def _save_fc_cache(cache: dict):
|
|
try:
|
|
with open(_FC_CACHE_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(cache, f, ensure_ascii=False, indent=2)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def supports_fc(base_url, model) -> bool:
|
|
"""该 (base_url, model) 是否支持 FC。未探测过时默认 True(乐观,先试一次)"""
|
|
return _load_fc_cache().get(_fc_key(base_url, model)) is not False
|
|
|
|
|
|
def mark_fc_unsupported(base_url, model) -> bool:
|
|
"""标记该 (base_url, model) 不支持 FC 并持久化。首次标记返回 True"""
|
|
with _fc_lock:
|
|
cache = _load_fc_cache()
|
|
key = _fc_key(base_url, model)
|
|
if cache.get(key) is False:
|
|
return False
|
|
cache[key] = False
|
|
_save_fc_cache(cache)
|
|
return True
|
|
|
|
|
|
def reset_fc_cache(base_url=None, model=None) -> int:
|
|
"""清除 FC 探测缓存。不传参数则全清,返回清除的条目数"""
|
|
with _fc_lock:
|
|
cache = _load_fc_cache()
|
|
if base_url is None and model is None:
|
|
count = len(cache)
|
|
cache.clear()
|
|
else:
|
|
count = 1 if cache.pop(_fc_key(base_url, model), None) is not None else 0
|
|
_save_fc_cache(cache)
|
|
return count
|
|
|
|
|
|
def get_fc_cache() -> dict:
|
|
"""返回 FC 探测缓存副本(供 /fc 命令展示)"""
|
|
return dict(_load_fc_cache())
|