自用体验(同时以人类和被接入 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)
105 lines
3.6 KiB
Python
105 lines
3.6 KiB
Python
import os
|
||
import re
|
||
import json
|
||
import logging
|
||
from colorama import Fore, Style
|
||
|
||
# 配置文件路径
|
||
CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json")
|
||
|
||
# <input> 特殊语法:配置模型时可用它交互式询问用户并获取输入
|
||
# <input> → 用默认提示询问
|
||
# <input:提示语> → 用自定义提示语询问
|
||
_INPUT_PATTERN = re.compile(r"<input(?::([^>]*))?>")
|
||
|
||
|
||
def resolve_input_value(value, field_name="", default_prompt=None):
|
||
"""解析配置值中的 <input> 特殊语法,交互式询问用户并返回其输入。
|
||
|
||
用法(写在 config.json 的字段值里):
|
||
"model": "<input>" → 启动时提示"请输入model: "
|
||
"model": "<input:请输入模型名>" → 启动时提示自定义文案
|
||
也可以夹在字符串中间: "prefix<input>suffix"
|
||
|
||
不含 <input> 的值原样返回;用户直接回车/中断时返回空串,由调用方兜底。
|
||
"""
|
||
if not isinstance(value, str) or "<input" not in value:
|
||
return value
|
||
|
||
def _ask(match):
|
||
label = (match.group(1) or "").strip()
|
||
prompt = label or default_prompt or (f"请输入{field_name}" if field_name else "请输入")
|
||
prompt = prompt.rstrip()
|
||
if prompt and not prompt.endswith((":", ":", "?", "?", "!", "!", " ")):
|
||
prompt += ": "
|
||
try:
|
||
answer = input(f"{Fore.CYAN}{prompt}{Style.RESET_ALL}").strip()
|
||
return answer
|
||
except (EOFError, KeyboardInterrupt):
|
||
return ""
|
||
|
||
return _INPUT_PATTERN.sub(_ask, value)
|
||
|
||
|
||
def load_config():
|
||
"""加载配置文件"""
|
||
try:
|
||
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
except FileNotFoundError:
|
||
return {"api": {"engines": {}}, "system": {}, "plugins": {}}
|
||
except json.JSONDecodeError:
|
||
return {"api": {"engines": {}}, "system": {}, "plugins": {}}
|
||
|
||
|
||
def save_config(config):
|
||
"""保存配置文件"""
|
||
try:
|
||
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
|
||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||
except Exception as e:
|
||
print(f"保存配置失败: {e}")
|
||
|
||
|
||
def get_system_config(key, default=None):
|
||
"""获取系统配置"""
|
||
config = load_config()
|
||
return config.get("system", {}).get(key, default)
|
||
|
||
|
||
def set_system_config(key, value):
|
||
"""设置系统配置"""
|
||
config = load_config()
|
||
if "system" not in config:
|
||
config["system"] = {}
|
||
config["system"][key] = value
|
||
save_config(config)
|
||
|
||
|
||
# 配置日志:文件留全量 INFO,控制台默认只报 WARNING 以上(--verbose 时放开到 INFO)
|
||
try:
|
||
from .verbose import is_verbose as _is_verbose
|
||
except ImportError: # 被当作顶层模块单独导入时
|
||
try:
|
||
from verbose import is_verbose as _is_verbose
|
||
except ImportError:
|
||
def _is_verbose():
|
||
return False
|
||
|
||
_file_handler = logging.FileHandler('xiaoli_cli.log', encoding='utf-8')
|
||
_file_handler.setLevel(logging.INFO)
|
||
_console_handler = logging.StreamHandler()
|
||
_console_handler.setLevel(logging.INFO if _is_verbose() else logging.WARNING)
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||
handlers=[_file_handler, _console_handler]
|
||
)
|
||
|
||
# 第三方库的心跳日志(httpx 每发一次请求就打一行)不上屏也不进日志文件
|
||
for _noisy in ('httpx', 'httpcore', 'urllib3', 'requests', 'openai', 'PIL', 'markdown_it'):
|
||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||
|
||
logger = logging.getLogger(__name__)
|