49 Commits
Author SHA1 Message Date
shuiyu67 66de8eea13 feat: TUI 新增 Vim 风格键位(模态输入 + 聊天导航),版本升 8.0.3
- 新增 xcli_core/vim_keys.py:纯逻辑 Vim 状态机(NORMAL/INSERT),h/j/k/l/w/b/0/$/^/gg/G/i/a/o/I/A/O/dd/x/dw/d$/u
- tui.py:VimSendTextArea 子类接入模态编辑;XiaoliTUI 增加 j/k、Ctrl+F/B/D/U 聊天滚动、Ctrl+Space 聚焦切换、i 聚焦插入、/vim 开关、模式提示栏、/help 更新
- 新增 tests/test_vim_keys.py(18 项动作单测全过)
- 版本号 8.0.2 -> 8.0.3(constants/about/README/tui/rich_tui/lsp/mcp)
2026-08-07 23:35:42 +08:00
shuiyu67 6f4fa5076f fix: 修复文件管理器跨平台路径串台(Linux/Mac 风格路径在 Windows 下被拼成 C:/home/user/...)
- 新增 _resolve_cross_platform_path:Windows 下将 /home/<用户>/…、/Users/<用户>/…、/root/… 映射到当前用户家目录对应位置
- file_manager 全部 10 个操作的路径解析改用该归一化函数
- 更新 CHANGELOG v8.0.2 条目(覆盖 8.0.2 发行)
2026-08-07 19:53:16 +08:00
shuiyu67 0aaca9c63d chore: 发布 v8.0.2 小修复版本
- 版本号统一更新至 v8.0.2(README 中英 badge / about.txt / 中央 VERSION / TUI banner / LSP·MCP clientInfo.version)
- CHANGELOG 新增 v8.0.2 条目,记录两个 hotfix:
  - c81914a 加载动画在非 TTY 环境不再污染输出流(isatty 守卫)
  - 1c52482 非 TTY 环境保留一句随机情话保人味(过滤 URL 行)
2026-08-07 19:37:54 +08:00
shuiyu67 1c52482497 fix: 非 TTY 环境保留一句随机情话(修复后保人味)
上一版加载动画修复在非 TTY(聊天前端/管道)只输出静态提示,
导致聊天界面完全看不到情话、没了"人味"。

- 非 TTY 分支改为:请求开始时输出一句随机情话(💭 前缀),
  只打一次、不跑逐帧 \r 动画,零残帧污染
- love.txt 读取时过滤 http/https 行,避免把反战标语/网址当情话甩出
- 真 TTY 逐帧情话+spinner 动画、TUI 模式均不受影响
2026-08-07 19:35:08 +08:00
shuiyu67 c81914ab87 fix: 加载动画在非 TTY 环境不再污染输出流
_generate_response_with_animation 的 show_animation 用 print("\r...")
逐帧输出情话+spinner,仅真终端能原地覆盖;被聊天前端/管道/
日志镜像捕获时每帧(10/s)变成一行垃圾混进对话显示。

- 加 sys.stdout.isatty() 守卫:非 TTY 只输出一行静态
  "AI 正在思考中…",不跑逐帧 \r 动画
- 两处清行动画行的 print("\r" + " "*80 + "\r") 也加 is_tty 守卫
- TUI 模式(tui_output_callback)本就跳过动画,不受影响

已验证:非 TTY 输出单行静态提示、TTY 仍正常逐帧动画。
2026-08-07 19:29:50 +08:00
shuiyu67 273cdc8e76 chore: 发布 v8.0.1
- 版本号统一更新至 v8.0.1(README 中英 badge / about.txt / xcli_core/constants.py 中央 VERSION / TUI banner / LSP·MCP clientInfo.version)
- CHANGELOG 新增 v8.0.1 条目,记录 Operit 转译层落地与 5 个已验证市场插件接入
2026-08-07 18:00:59 +08:00
shuiyu67 388ac493af feat(operit): 增强转译层并接入 5 个 Operit 市场插件
operit_loader.py:
- OkHttp 新增 newClient()(真实市场插件普遍使用,而非 newBuilder)
- OkHttpRequest.execute() 改为 async,与 Operit 运行时异步语义对齐
- OkHttpResponse 新增 json() 与 statusMessage()(基于 http.HTTPStatus)

operit_translate.py(新增):
- Operit .ts 插件 -> xiaoli-cli .py 脚手架生成器
- 自动提取 METADATA、生成 exports 胶水,原 TS 作参考注释保留

plugins/operit_scripts/ 新增 5 个已跑通的真实插件:
- market_time        时间查询/格式化
- market_duckduckgo  网页内容抓取与清洗
- market_crossref    Crossref 学术文献检索
- market_network_test HTTP 连通性测试
- market_reader      本地代码递归搜索

注: 工具需注册为 async 包装函数,同步 lambda 会导致 complete() 不执行的假阳性
2026-08-07 16:38:58 +08:00
shuiyu67 f7f7033e57 feat: 新增 3 个 Operit 安卓 API 示例脚本 (全链路验证通过)
- system_info.py: Environment 存储目录 + Build 构建信息 + Log 日志 + platform (转译层核心 API)
- file_manager.py: Environment 外部存储 + 列目录/写文本/读文本 (真实文件系统操作)
- intent_builder.py: Intent ACTION_VIEW/ACTION_SEND + Uri 解析 + putExtra (组件通信)
- 全链路验证: list 识别 4 脚本 7 工具; run 全部 success (含 Log I/W 输出)
- 配套修复 android_pyapi (Intent.EXTRA_TEXT 挂载, 已推送 Gitee e2f78f3)
2026-08-07 11:24:34 +08:00
shuiyu67 a71daf32c6 feat: 恢复 Operit 适配 (operit_loader 转译层, 从 git 历史找回)
- 从 a49c360 恢复 operit_loader.py (1627 行单文件转译层) + dc77dfd 恢复示例脚本 beeimg_image_uploader
- 修复原代码 3 个 bug:
  ① from android_pyapi import * 星号导入污染 Path → 改为仅 import 模块 (脚本 globals 已逐个注入 API)
  ② Exports 缺 __setattr__ → 脚本 exports.name = func 属性注册不生效, tools 永远为空
  ③ METADATA 逐行 # {json} 注释格式不兼容 → 新增逐行解析
- 追加 Liugin 插件协议适配 (list/run/version) + keywords
- android_pyapi 为用户自研转译层 (gitee.com/shuiyu1123/Android-pyApi, 本地 C:/Users/Administrator/Android-pyApi 已拉最新)
- plugins_config 加 ENABLE_OPERIT_LOADER; 全链路验证: list 出工具/run 执行脚本成功; 308 passed
2026-08-07 09:55:49 +08:00
shuiyu67 af21b766aa chore: 注册 py_detect 进插件系统 (ENABLE_PY_DETECT)
- plugins_config.py 缺 ENABLE_PY_DETECT → py_detect 一直在 plugins/ 目录但从未被 xiaoli 正式加载
- 加 ENABLE_PY_DETECT = True; 验证启用列表 29 项含 py_detect, 加载成功
- launcher discover_plugins 按目录扫描, 重跑启动器不会丢
2026-08-07 09:19:19 +08:00
shuiyu67 260eb23cb1 fix: 修复建议质量打磨 (用户审查发现)
- W004: 修复代码里的「默认值」占位符 → ast.unparse 提取真实默认值 (def f(items=None): items = items if items is not None else [])
- E004 del 场景: 专门建议「删了别再用 / 去掉 del」而非笼统赋初值 (del_flag 通过 analyzer._flow_del_names 传递)
- 拼写纠错保持「你是不是想写」提示性质 (语义上不一定正确, 措辞已保守)
- 304 passed 零回归
2026-08-07 09:14:40 +08:00
shuiyu67 1049370231 fix: 反啃 CPython 标准库全量 (630 文件) 修复函数内 import 漏注册
- _ScopeAnalyzer 缺 visit_Import/visit_ImportFrom → 函数内 import (局部导入) 的名字没注册进作用域
  → aifc.py math/audioop、argparse.py copy/shutil/textwrap 等 763 个 E002 误报消除
- E002 无 jedi 全量: 2332 → 1569 (剩余为 import* 盲区 + 模块级条件导入, jedi 会消大半)
- E004 216 条抽查: enum i(空串真炸)/gzip buf(else 不赋) 为合理保留; functools candidate/dataclasses value 为条件关联盲区
- 新增 2 回归测试 (函数内 import / import from); 304 passed
2026-08-07 09:04:53 +08:00
shuiyu67 e16234aba9 fix: 顶级库压测 (pydantic/fastapi/click) 修复 posonlyargs/常量迭代源/for-else 终止
- posonlyargs (/ 位置专用参数) 漏收集 → pydantic 45 个 E002 归零 (config.py class_、networks.py input_value)
- 常量非空迭代源 for x in ('a','b','c')/range(n>0): 循环体必执行 → 变量确定 (pydantic v1 host)
- for-else else 终止 → 仅 break 路径流出 → 循环变量确定 (click/utils.py i)
- 效果: pydantic E002 45→0 E004 18→7 (剩闭包盲区); Web 栈 E004 27→22; 新增 3 回归测试; 302 passed
2026-08-07 08:50:31 +08:00
shuiyu67 082efd62cc fix: 第三方库压测 (aiohttp) 修复 async/星号/while True/嵌套推导式
- 星号解包 a, *rest = ... 漏收集 → E002 (aiohttp multipart parts); 修复 _collect_target/_collect_tuple_names/_flow_collect_stores 三处
- async with / async for 完全未处理 → E004 一片 (aiohttp client resp/msg); 修复 L1 visit_AsyncWith/AsyncFor + L3 (With,AsyncWith)/(For,AsyncFor) 分支
- while True: 循环体必执行至少一次 → 其赋值确定 (client_reqrep message)
- 嵌套推导式按 gen 交错 (内层 iter 可用外层 target) (cookiejar cookie); 推导式 if 的 walrus 先于 elt 登记 (cookiejar key)
- 效果: aiohttp 67 文件 E004 59→2, E002 8→1; 新增 6 回归测试; 299 passed
2026-08-07 03:27:25 +08:00
shuiyu67 7494d57aec fix: 标准库压测修复 (walrus 登记 + match/case 绑定 + import* 提示)
- _flow_check_loads: NamedExpr(walrus) 先登记 target → ast.py end_lineno / doctest.py loader 误报消除
- _match_bound_names: match/case 模式绑定变量收集 (MatchAs/MatchStar/MatchMapping/MatchSequence/MatchClass), L1+L3 双收集 → dataclasses iterable 误报消除
- import * 文件: E002 补提示「可能来自星号导入」 (ast.py Expression 等)
- 剩余 E004 为固有盲区: 数值条件关联(difflib best_ratio<cutoff 恒真) / 闭包 / locals() 防御, 需符号执行级别, 接受
- 新增 3 项回归测试; 291 passed
2026-08-07 03:16:43 +08:00
shuiyu67 9badbf30b7 fix: L1 元组解包漏收集 + L3 for-else/try-else 路径误报 (外部真实项目扫描)
- L1: a, b = ... 解包赋值目标未收集 → xq_r/xq_i 等误报 E002 (xiaoli_chat.py 27 条误报归零)
- L3: for-else 的 else 在无 break 时必执行(含 0 次) → 其赋值确定 (renpy gamedir)
- L3: try/except/else: else return 终止 → handler 变量确定 (jinja2 asyncsupport); 无 else → handler 变量正常路径不确定保留 (jinja2 debug 真问题)
- 新增 6 项回归测试; 78 passed; xiaoli 自测 E002 误报 27→0, Ren'Py E004 194→182
2026-08-07 03:04:48 +08:00
shuiyu67 08f26cdcfd fix: L3 数据流误报大修 (真实项目扫描发现 E004 从 422 降到 3)
- with ... as f: 上下文变量未登记 → with 块内 f 全误报
- 推导式/生成器变量: target 未登记, elt 里变量全误报 (修复: 独立作用域副本)
- lambda 参数未登记 → key=lambda d 全误报
- except ... as e: handler 变量未登记 → e 全误报
- if 分支 return 提前终止未识别 → else return 后变量误报 (修复: _flow_terminates + 合并三分支)
- 剩余 3 条为已知盲区: locals() 动态检查 / try handler 保守策略 (宁误勿漏)
- 新增 7 项误报回归测试; 相关套件 283 passed
2026-08-07 02:54:13 +08:00
shuiyu67 fa29e0339f fix: 修复 L1+L3 合并时诊断全部重复 (extend 两次)
- analyze() 里 issues.extend(analyzer.issues) 被调用两次 → 每条诊断重复出现
- 综合测试发现 20 个问题实际应为 11 个; 修复为只 extend 一次
- 新增回归测试 test_no_duplicate_diagnostics; 276 passed
2026-08-07 02:44:27 +08:00
shuiyu67 413f66deb8 feat: py_detect L3 数据流引擎 (部分路径未定义就用 E004)
- _dataflow_check: 对每个函数收集局部变量(参数+赋值目标, 排除 global/nonlocal), 按路径合并扫描
  - if/elif: 合并点 defined = 两分支交集 (只有都赋值才确定)
  - for/while: 循环体赋值可能 0 次执行 → 不贡献确定; 循环体内 target 确定有值; orelse 用进入前集合
  - try: 主路径赋值算确定, except 分支独立不合并; with/delete/return/raise 处理
  - 保守原则: 宁漏勿误, 只报确定会炸的路径
- E004 动态修复建议: 建议使用前赋初值 x = None 或确保所有分支赋值
- 版本 1.3.0; 新增 12 项数据流测试; 相关套件 275 passed 零回归
- 至此 L1 规则 + L2 jedi 语义 + L3 数据流 三代引擎齐备
2026-08-07 02:41:33 +08:00
shuiyu67 4fb9392db4 chore: jedi 显式预装 (requirements.txt + launcher 第三方白名单)
- requirements.txt 加 jedi: 装依赖即自动预装 L2 语义验证后端 (纯 Python)
- launcher.third_party 加 jedi: scan_imports 重写 requirements 时不会丢
- 验证: py_detect 内缩进 import jedi 可被扫描正则捕获
- 无 jedi 仍自动降级纯标准库, 低配设备行为不变; 53 passed 零回归
2026-08-07 02:31:16 +08:00
shuiyu67 5008d431c9 feat: py_detect L2 语义验证后端 (可选 jedi, 自动降级)
- _jedi_validate(): 检测到 jedi 时用语义推断二次确认 E002 未定义变量
  - 消除 from x import * / 跨文件符号 / 类型别名 的规则引擎误报
  - 真未定义保留; 无 jedi 时原样返回 (纯标准库模式行为完全不变)
- 关键坑: jedi 的 infer line/column 是 1-based (与 ast 一致, 与 LSP 0-based 不同), 之前按 0-based 传导致位置全错
- 版本 1.1.0 → 1.2.0; 新增 4 项测试 (import* 误报消除/真未定义保留/无 jedi 降级/跨文件无错)
- 相关套件 263 passed 零回归
2026-08-07 02:25:36 +08:00
shuiyu67 917ba25902 feat: py_detect 升级算法智能解释 (零 AI 模型, 纯算法动态生成)
- 拼写纠错: 未定义变量用 difflib 相似度推荐最近名字 (totl -> 你是不是想写「total」)
- 动态修复示例: W004 生成 def f(items=None) 改写; W005 生成 x is None; W006 生成 lst[:] 复制
- 动态注入上下文: 函数名/参数名/列表名/比较对象/首次定义行号/return 行号
- W005 扩展支持 != None; I001 从 type(x) 参数正确提取对象与类型
- 定位: 连 1B 模型都跑不动的设备也秒级运行, 永不联网, 不依赖任何 AI
- 新增 9 项智能解释测试; 相关套件 259 passed 零回归
2026-08-07 02:13:43 +08:00
shuiyu67 56d5ed27e0 feat: 自研本地 LSP 服务器 (jedi 语义 + py_detect 诊断),替代 pylsp
- xcli_core/local_lsp_server.py: LSP over stdio (Content-Length 分帧),兼容现有 lsp_client
  - 语义: jedi 0.20 API (goto/infer/get_references/complete/help) — 补全/跳转/引用/悬停
  - 诊断: 复用 plugins/py_detect 规则引擎,中文诊断 + 修复建议,didOpen/didChange 自动推送
  - 零网络、零外部安装 (无需 pip install pylsp),服务器无响应时客户端可超时跳过
- config.json(.example): python 语言默认切到本地服务器; 换回 pylsp 改 command 即可
- lsp_client.py: command 为裸 python 且 PATH 找不到时回退 sys.executable (防环境坑)
- 16 项测试 (分帧/路径/协议/语义/真实 subprocess 完整握手); 相关套件 277 passed 零回归
2026-08-07 02:00:49 +08:00
shuiyu67 9a133dcf88 feat: py_detect 纯本地 Python 智能检测插件 + code_editor 写 .py 后自动检测
- 零第三方依赖 (仅标准库 ast/tokenize),低配电脑无压力
- 三层检测: 语法错误精确定位 / 作用域语义检测(未定义变量/重复定义/未使用导入) / 反模式扫描(可变默认参数/裸except/==None/迭代中修改/覆盖内置/import*/除零/缺self/死代码)
- 每条诊断带中文解释 + 修复建议 (规则模板, 零 AI)
- code_editor 所有写文件操作 (edit/multi_edit/insert/delete_lines/create/write/append) 改完 .py 自动调用检测, 仅有问题时输出前6条
- 40 项测试 (含 5 项 code_editor 自动检测集成)
2026-08-07 01:41:38 +08:00
shuiyu67 2e4093cc03 perf: ollama 彻底惰性导入(模块级+实例级),AICLI 启动不再卡 ollama 2026-08-07 00:19:49 +08:00
shuiyu67 b7e87e40f3 perf: FC 工具结果续轮不再下发 schema,省每轮重发 token 2026-08-06 23:59:44 +08:00
shuiyu67 46376aa23e feat: 重新打开 FC 开关,AI 可在纯 JSON 与 FC 之间自选(混合模式) 2026-08-06 23:30:53 +08:00
shuiyu67 a70e4b1819 fix: 去掉返回给AI的工具结果截断 + 系统提示增加参数格式统一示例 2026-08-06 23:15:45 +08:00
shuiyu67andWorkBuddy e73744c742 perf: 提升上下文压缩保真度,避免二次失真
- 摘要前单条消息截断上限 200→1500 字,且长内容优先保留代码块(编码场景不丢上下文)
- keep_recent 硬编码 10→可配置(默认 12),适配跨多轮的编码编辑
- 历史摘要标记不再被二次摘要:累积合并为单条 system,新消息才产生新摘要块
- 摘要按角色分条、要求保留工具调用/代码修改点;simple_summarize 额外提取编辑文件
- 新增 tests/test_compress_quality.py 覆盖工具JSON保真/代码块保留/幂等/可配置keep_recent

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-06 22:59:01 +08:00
shuiyu67andWorkBuddy 5fe1a7c868 feat: 统一 max_input_tokens API,所有引擎必须返回最大输入 token 数
- openai/ollama/manual 三引擎均暴露 max_input_tokens 属性(统一契约)
- ollama 额外上报 prompt_eval_count 到 last_prompt_tokens,使本地模型也走 token 感知压缩
- should_compress 改用 max_input_tokens,系统依当前引擎上限的 70%(compress_ratio)自动触发
- 压缩始终调用当前引擎(current_engine)做总结,符合"调用当前引擎总结压缩"
- 新增 tests/test_max_input_tokens.py 覆盖契约 + 70% 触发 + 消息数兜底

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-06 22:50:18 +08:00
shuiyu67andWorkBuddy c87a07706e feat: 废弃 FC,改用纯 JSON action 模式调用工具
- engine.generate_response 不再下发 tools schema(TUI/CLI 分支均 tools=None)
- 系统提示改为纯 JSON 调用格式说明,保留 tool_search 自查用法
- 新增 _loads_json 容错解析,容忍模型写出的 Windows 反斜杠路径(如 C:/Users)
- 新增 tests/test_pure_json.py 覆盖解析容错/引擎无 schema 下发/端到端按名分发

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-06 20:43:39 +08:00
shuiyu67 b577b0ff21 feat: FC 工具去重 + 按场景动态裁剪,降低 AI 每轮负担
方案 A(去重):
- mcp_to_openai_tools 末尾按 function.name 去重,消除 cache+builtin 叠加
  造成的同名重复(memory/scheduler/lsp__* 等),FC 工具 42→36,去掉
  约 14% 的垃圾 schema token
- get_liugin_usage_prompts 的 plugins 段由逐条 name:desc 改为工具名清单,
  消除与 FC tools 字段的双重曝光,保留无 FC 模型的回退清单

方案 B(分层裁剪):
- 新增 prune_tools(fc_tools, user_input): 常驻 ALWAYS(tool_search/memory/
  scheduler) + 外部桥接(mcp__)默认保留 + 按关键词信号追加对应工具
  (代码/文件->code_editor+file_manager+lsp__*; git/shell/浏览器/搜索/子agent/
  工程化各按信号)
- cli_core 两处 FC 构建点(TUI/CLI)接入 prune_tools(current_input)

实测(AICLI): 纯聊天 FC token ~4310->285(降93%),写代码 ~973(降77%)

测试: 新增 tests/test_prune_tools.py (7 passed),全量 324 passed 无回归
2026-08-06 20:06:51 +08:00
shuiyu 1054932921 feat: pylsp 作为工具之一 + AI 写/改 .py 自动触发诊断并注入上下文
- 补装 pylsp 及 lint 后端(pyflakes/pycodestyle/rope/mccabe),真实诊断可用
- code_editor / file_manager 写/改操作后自动调用 LSP 诊断(.py),结果经
  get_lsp_context() 静默注入下一轮模型上下文,仅在有问题时的可见提示
- config.json 中 rust 设为 disabled(本机未装,免 ERROR 噪声)
- requirements.txt / config.json.example 补充 pylsp 配置说明
- 新增 4 个 _auto_lsp_check 钩子单测
2026-08-05 21:23:16 +08:00
shuiyu67 c655d61af4 feat: TUI opencode 风格视觉升级(顶栏/标签页/色条/精简欢迎) 2026-08-05 20:23:31 +08:00
shuiyu67 addf119ac5 feat: TUI 深度(文件树 + 会话列表 + Plan 状态栏,headless 自验) 2026-08-05 19:43:30 +08:00
shuiyu67 d0915ab895 feat: Agents 体系化(markdown 定义 + @委派 + 子会话)+ 多 Provider 抽象路由 2026-08-05 19:07:15 +08:00
shuiyu67andWorkBuddy 160d4e924e feat: 声明式权限(opencode 式 allow/ask/deny 配置化)
- safety.check 在 plan 模式之后、安全模式之前插入声明式权限判定:
  deny(阻断) > ask(强制确认) > allow(自动放行) > 默认(按安全模式)
- 规则语法支持 "*"全匹配 / "tool"精确 / "tool:*"任意操作 / "tool:op"操作前缀
- 读取 config.permissions;提供 set_permissions 显式注入(测试/启动用)
- config.json 顶层加 permissions 段(allow/ask/deny 示例,该文件被 gitignore 不入库)
- 修复:test_normal_mode_does_not_plan_block 隔离真实 config 权限,避免污染
- 新增 tests/test_permissions.py(14 项:规则匹配+deny/ask/allow+优先级)

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-05 17:56:25 +08:00
shuiyu67andWorkBuddy 9dd418ec89 feat: LSP 集成(连接外部语言服务器,真实诊断/智能感知喂给模型)
- 新增 lsp_client.py:LspClient(subprocess stdio,Content-Length 分帧 JSON-RPC,
  reader 线程,initialize/initialized/didOpen/didChange/hover/definition/references/
  completion + publishDiagnostics 通知捕获)+ LspManager(按扩展名→语言→server 路由、
  connect_all 读 lspServers、get_context 返回已分析文件诊断、cleanup 幂等)
- unified_tool_manager.py:initialize 末尾加载 LSP server,注册 5 个 lsp__* 工具
  (diagnostics/hover/definition/references/completion,带真实 inputSchema、protocol='lsp'、
  instance 存 manager 供 shutdown 清理)
- cli_core.py:_build_system_prompt 末尾注入 LSP 诊断上下文(模型持续看到真实诊断)
- config.json:顶层加 lspServers 段(python/rust 示例,含 _说明;该文件被 gitignore 不入库)
- 新增 tests/test_lsp_client.py(9 项:fake server 全链路+路由+桥接+fc_tools 保真+缺失/失败优雅降级)
- 修 LSP 线缆坑:didOpen/didChange 是通知(无响应),初版误当请求等响应导致超时

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-05 17:49:46 +08:00
shuiyu67andWorkBuddy d2ad1000b6 feat: MCP 客户端(连接外部 MCP server,外部工具桥接进统一工具体系)
- 新增 mcp_client.py:McpClient(subprocess stdio JSON-RPC,reader 线程+响应表,
  initialize/list_tools/call_tool/shutdown 超时可控)+ McpClientManager(connect_all
  读 mcpServers、聚合、call_tool、cleanup 幂等)
- unified_tool_manager.py:initialize 末尾加载外部 MCP server,按 server__tool 前缀
  register_tool 带真实 inputSchema mcp_definition,shutdown 经 manager.cleanup 幂等终止子进程
- fc_tools.py:mcp_to_openai_tools 新增 get_mcp_tools 保真分支(同时修现状 liugin 工具
  schema 被压成 args 的坑)
- config.json:顶层加 mcpServers 空段(用户自填,已被 gitignore 不入库,代码 .get 兼容)
- 新增 tests/test_mcp_client.py(5 项,全链路+桥接+保真,不触真实引擎/网络)

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-05 17:40:45 +08:00
shuiyu67andWorkBuddy a3977ecc85 feat: opencode 式 Plan 模式(只读调研 + /build 批准执行)
- safety.check 最高优先级加 Plan 只读拦截:写操作暂挂,只读放行
- BaseAICLI 新增 plan_mode / current_plan 状态
- _build_system_prompt 注入 PLAN 约束;/plan /build 命令路由与 handle 方法
- 速查表与启动提示补充 /plan /build
- 新增 tests/test_plan_mode.py(16 项,纯函数+拦截+注入+状态转换)

Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
2026-08-05 17:25:17 +08:00
shuiyu67 07490c4d38 feat: opencode 式会话持久化 + /resume 一键恢复
此前只有手动的 /chat save|list|open,关终端就丢上下文。现升级为
opencode 式体验:每轮对话自动落盘、启动提示历史会话、/resume 一键恢复。

- 新增 xcli_core/session.py:Session 数据类(id/title/created_at/updated_at/
  engine/model/messages)+ SessionManager(new/save/list/load/resolve,存
  chat_history/<id>.json)。向后兼容旧 /chat 格式(无 id、用 conversation 键,
  读取时回退用文件名作 id)。标题自动取首条 user 消息前若干字。
- HistoryMixin 升级(cli_history.py):新增 init_session_manager /
  _autosave_session / handle_resume_command。自动保存首轮惰性建会话、
  更新标题与 updated_at;/resume 列出(序号|ID)或按序号|ID|前缀恢复,
  恢复时把 messages 载入 shared_conversation_history 并同步引擎。
- 集成:cli_base.__init__ 在 chat_history_dir 就绪后 init_session_manager;
  cli_core 主循环 1395 行 process_conversation 后调用 _autosave_session;
  新增 /resume|/sessions 路由(/sessions 等同 /resume)。
- 体验:启动时若有历史会话提示 /resume(含最近会话标题);run() 紧凑提示
  与 /help 速查表均补 /resume(列出/恢复/开新会话)。
- 修复 cli_history.py 一处历史 typo:{Style.RESET型} -> {Style.RESET_ALL}
  (旧 load_chat_history 在含消息时会抛 AttributeError)。
- 测试:新增 tests/test_session.py(12 项,覆盖兼容/新建/落盘/排序/解析/
  自动保存/恢复/列表/未知token,全程临时目录不污染真实 config/chat_history)。
  全量 242 passed(原 230)。

真实 CLI 冒烟:启动提示识别到历史会话、/resume 正确列出(含标题/时间/引擎/
条数)、/resume <id> 正确恢复并回显;已清理测试种入文件,chat_history 复原为空。
2026-08-05 17:16:49 +08:00
shuiyu67 1383d32f8b feat: OpenAI 多模型注册表 + /model 命令,告别手改配置文件
痛点:很多模型都是 OpenAI 格式,但换模型只能手改 config.json 的单槽位,
且只能存一个。新增模型注册表与 /model 命令族:

- 新增 xcli_core/model_registry.py:在 api.engines.openai 下维护
  models 列表(name + base_url + api_key + model)与 current 指针,全部落盘。
  向后兼容:无 models 时把扁平 api_key/base_url/model 当作单条隐式模型。
- OpenaiAI 接入:__init__ 应用注册表 current 模型;新增
  set_registry_model / list_registry_models / add_model / remove_model /
  current_model_name。注意保留引擎原有的轻量 set_model(只改 model 名)
  与 list_models(查 API /models 端点),新方法与它们不重名、互不干扰。
- 新增 /model 命令族(仅 openai 引擎):
    /model            列出已配置模型(* 标记当前)
    /model <名称>     切换当前模型并持久化
    /model add        交互式录入(名称/base_url/api_key/model),回车即存并切换
    /model rm <名称>  删除(删当前则回退到列表第一个)
- 启动横幅引擎名后显示当前模型;紧凑提示与 /help 速查表均补 /model 入口。
- 测试:新增 tests/test_model_registry.py(13 项,覆盖兼容/增删/切换/落盘/
  引擎接入/CLI 路由,全程临时配置不污染真实 config.json)。
  全量 230 passed(原 217 passed)。

交互实测:/model add → 已添加并切换到 gpt4o;/model gpt4o → 已切换;
/model rm gpt4o → 回退到 xophunyuan7bmt,配置结构与内容完好。
2026-08-05 16:12:35 +08:00
shuiyu67 3a86da7444 perf: 启动输出瘦身 + FC 能力探测缓存,修复测试套件失效
自用体验(同时以人类和被接入 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)
2026-08-05 15:57:38 +08:00
shuiyu67 d2ac6896ad perf: 删除工具执行前的 5 秒等待延迟
_execute_single_tool 原先在真正调用工具前跑 25 次 sleep(0.2) 的
spinner 倒计时(固定 5 秒),仅为提供 ESC 取消窗口,每次工具调用都要白等。

- 删除倒计时循环,改为执行前非阻塞排空键盘缓冲区检测 ESC(零等待):
  若用户在此之前按过 ESC 仍会取消并记入历史,行为不变。
- 执行期间打印一行静态'正在执行: <工具> <参数>'提示,结束后清除。
- TUI 模式行为不变(原本就跳过延迟)。
- 测试: 12 项全过,单次工具调用耗时 5000ms → 0.5ms。
2026-08-05 11:05:06 +08:00
shuiyu67 a63b9d2f97 fix: 修复 'ToolResult' object has no attribute 'get'
根因: cli_tools.process_tool_call 在启用 UnifiedToolManager 时直接返回
ToolResult 对象,而 7 个调用点全部按 dict 用 result.get('result'),触发
AttributeError;FC 路径 _execute_tool_by_name 也可能把 ToolResult 透传出去,
后续 result[:5000] 会再炸 TypeError。

- tool_result.py: 新增 normalize_tool_text / normalize_tool_dict,
  统一收敛 ToolResult / dict / str / None 三种历史返回形态。
- cli_tools.process_tool_call: 统一返回 dict(附带 success/error_code/
  tool_result),回退 handler 路径同样归一。
- cli_core._execute_tool_by_name: handler / memory / scheduler 返回值
  归一成 str,保证 FC 路径可安全切片。
- openai/ollama/manual 引擎、websocket_server、cli_clawli: 取工具结果改用
  normalize_tool_text(带本地兜底实现,避免循环导入)。
- cli_clawli: 顺带修复只回传最后一个工具结果的 bug,改为汇总全部。
- 测试: 23 项全过;纯格式/讯飞 500 降级回归冒烟通过。
2026-08-05 10:54:39 +08:00
shuiyu67 dfc42d8583 fix(v8.0): openai 引擎改纯官方格式 + 讯飞 500 兼容 + <input> 向导
- openai_engine: 请求体严格纯官方 OpenAI /chat/completions 格式(tool 消息去 name、
  tool_calls 重建、tools 统一 type=function);500(含 xunfei/Invalid Params)自动去掉
  tools 降级重试;_friendly_error 友好提示;MODEL_CONTEXT_WINDOWS 补讯飞星火。
- ollama_engine/fc_tools/cli_core: tool 消息去 name。
- config.py resolve_input_value(<input> 语法);ollama max_token_k(默认 <input:...>);
  launcher 向导加 max_token_k 步骤。
2026-08-05 00:26:02 +08:00
shuiyu 539c21be72 chore: relicense to MIT 2026-08-02 18:39:28 +08:00
shuiyu67 db9203687d fix(v8.0): 启用真·全屏 textual TUI 并修复 opacity 崩溃
- 修复崩溃: tui.py MessageBubble.on_mount 用 self.animate('opacity'),但
  textual>=8 的 Widget.opacity 为只读属性会抛 'has no setter',导致每条消息
  气泡崩溃 -> 改 styles.animate 并兜底直接设置。
- run_tui 改为默认真·全屏 textual XiaoliTUI(上方滚动聊天区+底部固定输入框+侧栏),
  rich 面板仅作 textual 不可用时的回退(此前默认的是顺序面板,被指出是'假TUI')。
- 版本统一 v8.0.0: 欢迎框 + /about 改用 VERSION 常量。
- 状态栏联动 token 感知: 显示 used/context_window 进度条(引擎支持时)。
- 新增 /snapshot(/screenshot) 命令: 导出当前屏幕 SVG 到 .tui_snapshots/live.svg,
  让实时 TUI 也能被 AI 自验。
- 新增 tools/tui_real_snapshot.py: textual run_test+Pilot 无头驱动真实 TUI,
  喂对话、断言 DOM(用户/工具成功失败/助手/状态栏/token条/欢迎版本),导出 SVG。
  7 项断言全过。
- 注: launcher 启动会重写 plugins_config.py/requirements.txt(含 tkinter 等错误项),
  提交前已还原,避免误提交漂移。
2026-08-01 10:30:42 +08:00
shuiyu67 79957eba0f feat(v8.0): 基于 v5.4.1 重构立项 v8.0.0
- 版本立项: constants.VERSION=8.0.0,about.txt/README 同步。
- Rich 面板式 TUI (xcli_core/rich_tui.py): 默认 TUI 渲染器,rich 驱动;
  核心创新=快照出口(Console(record=True) -> text/svg/html),AI 开发界面
  时可把渲染读回自验,补上'看不见渲染'闭环。tools/tui_snapshot.py 无头生成
  演示快照。run_tui 优先 RichTUI,textual 作为回退。
- Token 感知压缩: openai_engine 捕获 usage.prompt_tokens -> last_prompt_tokens,
  新增 context_window()(模型名查表 MODEL_CONTEXT_WINDOWS + config 覆盖);
  memory.should_compress 升级为 token 感知(used/context_window >= ratio 触发,
  默认 0.7),消息数阈值兜底;cli_core.process_conversation 自动压缩并提示。
- 记忆模式增强: 保留 v5.4.1 的 MEMORY.md/日记/自动保存/AI 摘要压缩,新增
  /compress 手动压缩 与 /memory mode <companion|work> 切换。
- requirements 加 rich;.gitignore 忽略 .tui_snapshots/。
2026-08-01 09:28:39 +08:00
76 changed files with 13677 additions and 460 deletions

No files matched your search

+9
View File
@@ -47,3 +47,12 @@ screenshots/gen_*.png
# 临时文件
*.tmp
*.bak
# v8.0 TUI 快照产物(运行时生成,供 AI 自验/预览)
.tui_snapshots/
# FC 能力探测缓存(运行时按 base_url+model 生成,机器相关)
.fc_support.json
# pytest 缓存
.pytest_cache/
+64
View File
@@ -4,6 +4,70 @@
---
## v8.0.3 (2026-08-07) — 体验增强
### ✨ 新功能
- **TUI 新增 Vim 风格键位**(默认开,可用 `/vim` 开关)
- 输入框**模态编辑**:`Esc` 在 NORMAL / INSERT 间切换;NORMAL 下
- 移动:`h j k l`、`w / b`(跳词)、`0 / $`(行首/行尾)、`^`(首个非空白)、`gg / G`(文首/文尾)
- 进入插入:`i / a / o`(插入/追加/下方新行)、`I / A / O`(行首/行尾/上方新行)
- 编辑:`dd`(删行)、`x`(删字)、`dw`(删词)、`d$`(删到行尾)、`u`(撤销)
- 普通模式下 `回车` = 下移一行(与 vim 一致)
- 核心状态机抽成纯模块 `xcli_core/vim_keys.py`(`VimInputState`,不依赖 textual),配 `tests/test_vim_keys.py` 单测,18 项动作全部验证通过
- 聊天区导航(输入框未聚焦时生效):`j / k` 逐行滚动、`Ctrl+F / B` 翻页、`Ctrl+D / U` 半页、`Ctrl+Space` 切换焦点、`i` 聚焦并进入插入
- 输入框下方提示栏实时显示 `-- INSERT --` / `-- NORMAL --` 模式;`/help` 已补充 Vim 键位说明
- 可通过 `config.json` 的 `vim_mode: false` 默认关闭
### 🛠 工程
- 版本号统一更新至 v8.0.3(含 README 中英 badge、`about.txt`、`xcli_core/constants.py` 中央 `VERSION`、TUI banner、LSP/MCP `clientInfo.version`)
---
## v8.0.2 (2026-08-07) — 小修复版本
### 🐛 Bug 修复
- **加载动画在非 TTY 环境不再污染输出流**(提交 `c81914a`)
- `_generate_response_with_animation` 的 `show_animation` 用 `print("\r...")` 逐帧(约 10 帧/秒)输出 `love.txt` 情话 + spinner,仅真终端能原地覆盖;被聊天前端 / WebSocket 桥 / 管道 / 日志镜像捕获时每帧变成一行垃圾混进对话显示
- 加 `sys.stdout.isatty()` 守卫:非 TTY 只输出单行提示、不跑逐帧 `\r` 动画
- TUI 模式(`tui_output_callback`)本就跳过动画,不受影响
- **非 TTY 环境保留情话「人味」**(提交 `1c52482`)
- 上一版修复导致聊天界面完全看不到情话,改为:非 TTY 在请求开始输出一句随机情话(`💭` 前缀,只打一次、零残帧)
- `love.txt` 读取时过滤 `http/https` 行,避免把反战标语 / 网址当情话甩出
- 真 TTY 逐帧情话 + spinner 动画、TUI 模式均不受影响
- **修复文件管理器跨平台路径串台**(本次提交)
- 模型在 Windows 主机上常给出 Linux/Mac 风格绝对路径(如 `/home/user/Desktop`、`/Users/<谁>/Desktop`、`/root/Desktop`),`os.path.abspath` 在 Windows 下会错误地拼成 `C:\home\user\Desktop` 并报「路径不存在」
- 新增 `_resolve_cross_platform_path`:Windows 下把 `/home/<用户>/…`、`/Users/<用户>/…`、`/root/…` 统一映射到当前用户家目录对应位置(`~/Desktop` 等);Linux/Mac 下这些路径本身合法,原样保留
- `file_manager` 全部 10 个操作(list/read/write/append/copy/move/delete/search/info/mkdir)的路径解析均改用该归一化函数
### 🛠 工程
- 版本号统一更新至 v8.0.2(含 README 中英 badge、`about.txt`、`xcli_core/constants.py` 中央 `VERSION`、TUI banner、LSP/MCP `clientInfo.version`)
---
## v8.0.1 (2026-08-07) — Release
### ✨ 新增功能
- **Operit 市场插件转译层全面落地**
- `plugins/operit_loader.py` 增强:`OkHttp.newClient()`、`OkHttpRequest.execute()` 改为异步、`OkHttpResponse` 新增 `json()` 与 `statusMessage()`(基于 `http.HTTPStatus`)
- `plugins/operit_translate.py` 新增:Operit `.ts` 插件 → xiaoli-cli `.py` 脚手架生成器(自动提取 METADATA、生成 `exports` 胶水,原 TS 作参考注释保留)
- 接入 5 个已全链路验证的 Operit 市场插件:
- `market_time` — 时间查询 / 格式化
- `market_duckduckgo` — 网页内容抓取与清洗
- `market_crossref` — Crossref 学术文献检索
- `market_network_test` — HTTP 连通性测试
- `market_reader` — 本地代码递归搜索
### 🛠 工程
- 版本号统一更新至 v8.0.1(含 README 中英 badge、`about.txt`、`xcli_core/constants.py` 中央 `VERSION`、TUI banner、LSP/MCP `clientInfo.version`)
---
## v5.4.0 (2026-05-09) — Release
### ✨ 新增功能
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 shuiyu1123
Copyright (c) 2026 shuiyu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+1 -1
View File
@@ -5,7 +5,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-5.4.1-blue" alt="version">
<img src="https://img.shields.io/badge/version-8.0.3-blue" alt="version">
<img src="https://img.shields.io/badge/python-3.10+-green" alt="python">
<img src="https://img.shields.io/badge/license-MIT-orange" alt="license">
<img src="https://img.shields.io/badge/tests-113%20passed-brightgreen" alt="tests">
+7 -1
View File
@@ -9,7 +9,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-5.4.1-blue" alt="version">
<img src="https://img.shields.io/badge/version-8.0.3-blue" alt="version">
<img src="https://img.shields.io/badge/python-3.10+-green" alt="python">
<img src="https://img.shields.io/badge/license-MIT-orange" alt="license">
<img src="https://img.shields.io/badge/tests-150+%20passed-brightgreen" alt="tests">
@@ -22,6 +22,12 @@
小狸 Pro-CLI 是一款基于 Python 的智能编程助手,集成了代码编辑、版本控制、浏览器自动化、图像生成、记忆系统等功能,支持多种 AI 引擎,帮助开发者提升工作效率。
## v8.0 新特性
- **Rich 面板式 TUI(默认)** — `rich` 驱动的可视化对话面板;自带**快照出口**(导出 text/svg/html),AI 开发界面时可把渲染结果读回自验,补上"看不见渲染"的反馈闭环。输入 `/tui` 进入,`/snapshot` 随时导出当前界面。
- **Token 感知压缩** — 自动读取 OpenAI 格式返回的 `usage.prompt_tokens`,当 `已用 token / 模型上下文窗口 ≥ 0.7`(可在 `config.json` 设 `system.compress_ratio` / `system.context_window` 覆盖)即触发上下文压缩,消息数阈值作为兜底,避免超窗截断。
- **记忆模式** — 持久化 `MEMORY.md` + 每日日记 + 自动保存聊天记录 + 关键词检索;新增 `/memory mode <companion|work>` 切换与 `/compress` 手动压缩。
## 特性
- **双 AI 引擎** — Ollama 本地模型 + OpenAI 兼容格式(DeepSeek、Grok、硅基流动等)
+2 -2
View File
@@ -1,7 +1,7 @@
**产品标识**
- **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI)
- **内部版本号**:20260428
- **发布版本**:发行版5.1.3
- **内部版本号**:20260801
- **发布版本**:发行版8.0.3
**技术规格**
- **主要语言**:简体中文(UTF-8)
+16
View File
@@ -0,0 +1,16 @@
---
name: coder
description: 代码实现专家,负责编写、修改、重构代码文件
tools: code_editor, git_tools, file_manager, cmd_executor
model:
---
你是一个资深程序员「coder」,专注于把需求变成可运行的代码。
工作准则:
1. 先读取相关文件,理解现有结构与风格,再动手。
2. 改动保持最小且自洽,新增代码加必要注释。
3. 涉及多文件时,先给出改动清单再逐个落地。
4. 完成后做冒烟验证(语法 / 类型 / 单元测试)。
任务完成后,用如下 JSON 报告:
{"status": "done", "summary": "一句话总结", "details": "关键改动与验证结果"}
+20
View File
@@ -0,0 +1,20 @@
---
name: reviewer
description: 代码审查专家,负责检查质量、安全与一致性
tools: code_editor, git_tools, file_manager
model:
---
你是一个严格的代码审查员「reviewer」,独立于实现者审视代码。
审查维度:
1. 正确性:逻辑漏洞、边界条件、异常处理。
2. 安全性:注入、越权、敏感信息泄露、危险命令。
3. 一致性:命名、风格、是否贴合项目既有约定。
4. 可维护性:重复、复杂度、可读性。
输出格式(按严重度分级):
- 🔴 严重:必须修改
- 🟡 建议:建议修改
- 🟢 亮点:值得保留
最后给出总体评价与是否可合入的结论。
+19 -1
View File
@@ -18,6 +18,17 @@ current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
sys.path.insert(0, project_root)
try:
from xcli_core.tool_result import normalize_tool_text
except Exception:
def normalize_tool_text(result, default="无结果"):
"""兜底归一化:ToolResult / dict / str 统一成字符串"""
if result is None:
return default
if isinstance(result, dict):
return str(result.get("result", default))
return str(result)
# 通信用的临时目录
COMM_DIR = os.path.join(tempfile.gettempdir(), "xiaoli_manual_engine")
@@ -33,6 +44,8 @@ class ManualAI:
self.conversation_history = []
self.max_history = 10
self._subprocess = None
# 最近一次请求的 prompt token 数(手动引擎无真实模型,恒为 None)
self.last_prompt_tokens = None
self._session_id = str(int(time.time()))
self._started = False
@@ -229,6 +242,11 @@ while True:
time.sleep(0.3)
'''
@property
def max_input_tokens(self):
"""当前引擎支持的最大输入 token 数(统一 API)。手动引擎无真实模型,给名义上限。"""
return 128000
def generate_response(self, user_input, tool_results=None, system_prompt=None):
"""生成响应 — 转发给另一个终端的人工"""
paths = self._get_paths()
@@ -246,7 +264,7 @@ while True:
# 把用户消息写入文件
msg = user_input
if tool_results:
msg = f"[工具结果] {tool_results.get('result', '无结果')}\n\n{user_input}"
msg = f"[工具结果] {normalize_tool_text(tool_results)}\n\n{user_input}"
to_human = paths["msg_to_human"]
with open(to_human, 'w', encoding='utf-8') as f:
+149 -32
View File
@@ -1,4 +1,5 @@
import os
import re
import sys
import json
import requests
@@ -6,21 +7,57 @@ import threading
import time
from colorama import Fore, Style
# 延迟导入 ollama,避免程序启动时卡住
# 如果导入失败,ollama_client 将为 None
try:
import ollama
ollama_client = ollama
except Exception as e:
print(f"{Fore.YELLOW}警告: 无法导入 ollama 库: {e}{Style.RESET_ALL}")
print(f"{Fore.YELLOW}请确保已安装 ollama 包 (pip install ollama){Style.RESET_ALL}")
ollama_client = None
# 真正的惰性导入:只有真正用到 ollama 引擎时才 import ollama,
# 避免 xiaoli-cli 启动 / 加载所有引擎时因 ollama SDK 初始化
# (其内部 platform.machine() -> WMI 查询)在某些环境下卡住。
_ollama_client = None
_ollama_import_tried = False
def _ollama():
"""惰性获取 ollama SDK 客户端;导入失败返回 None(仅尝试一次)。"""
global _ollama_client, _ollama_import_tried
if _ollama_import_tried:
return _ollama_client
_ollama_import_tried = True
try:
import ollama
_ollama_client = ollama
except Exception as e:
_ollama_client = None
print(f"{Fore.YELLOW}警告: 无法导入 ollama 库: {e}{Style.RESET_ALL}")
print(f"{Fore.YELLOW}请确保已安装 ollama 包 (pip install ollama){Style.RESET_ALL}")
return _ollama_client
# 添加项目根目录到sys.path
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
sys.path.insert(0, project_root)
try:
from xcli_core.verbose import vprint as _vprint
except Exception:
def _vprint(*args, **kwargs):
pass
# <input> 特殊语法解析(配置模型时可交互式询问用户)
try:
from xcli_core.config import resolve_input_value
except Exception:
def resolve_input_value(value, field_name="", default_prompt=None):
return value
try:
from xcli_core.tool_result import normalize_tool_text
except Exception:
def normalize_tool_text(result, default="无结果"):
"""兜底归一化:ToolResult / dict / str 统一成字符串"""
if result is None:
return default
if isinstance(result, dict):
return str(result.get("result", default))
return str(result)
class OllamaAI:
"""基于Ollama的本地AI引擎"""
@@ -62,6 +99,9 @@ Ollama AI引擎插件帮助信息
self.conversation_history = []
# 直接从 config.json 读取配置
# 模型最大 token 大小(单位 K,如 32 = 32K = 32768 token)
# 支持 <input> 语法:配置里写 <input:提示语> 会在配置模型时询问用户
max_token_k = "<input:请输入模型最大token大小(K)>"
config_file = os.path.join(project_root, "config.json")
try:
with open(config_file, 'r', encoding='utf-8') as f:
@@ -71,42 +111,86 @@ Ollama AI引擎插件帮助信息
self.base_url = engine_config.get("base_url", "")
self.model = engine_config.get("model", "")
self.max_history = config.get("system", {}).get("max_history", 10)
max_token_k = engine_config.get("max_token_k", max_token_k)
except Exception as e:
# 如果读取失败,使用空值
self.api_key = ""
self.base_url = ""
self.model = ""
self.max_history = 10
# 解析 <input> 语法(询问用户)并换算成 num_ctx(token 数)
resolved_k = resolve_input_value(max_token_k, field_name="模型最大token大小(K)")
self.num_ctx = self._parse_num_ctx(resolved_k)
# 共享对话历史引用(由CLI设置)
self.shared_conversation_history = None
# 最近一次请求的 prompt token 数(供 token 感知压缩使用)
self.last_prompt_tokens = None
# 直接启用思考标记,由主程序自动处理深度思考模型的响应
self.thinking_start_marker = "<thinking>"
self.thinking_end_marker = "</thinking>"
# 检测Ollama服务是否运行
self.is_service_running = self.check_ollama_service()
if self.is_service_running:
print(f"{Fore.GREEN}Ollama服务检测成功,引擎已启用{Style.RESET_ALL}")
else:
print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}")
# 惰性服务检测:不在构造时急切点服务(避免无 ollama / 沙箱环境下
# import ollama 卡顿)。首次真正使用时(generate_response 等)才检测并缓存。
self._service_checked = False
self._service_running = False
@staticmethod
def _parse_num_ctx(value):
"""把用户输入的 K 值换算成 token 数:32 / 32K → 32768。非法/空 → 4096"""
if value is None:
return 4096
s = str(value).strip().lower()
digits = re.sub(r"[^\d.]", "", s)
if not digits:
return 4096
try:
k = float(digits)
except ValueError:
return 4096
if k <= 0:
return 4096
return max(1024, int(k * 1024))
def check_ollama_service(self):
"""检测Ollama服务是否运行"""
# 检查 ollama 库是否可用
if ollama_client is None:
if _ollama() is None:
return False
try:
# 尝试调用Ollama API获取版本信息
response = ollama_client.list()
response = _ollama().list()
# 如果没有异常,说明服务运行正常
return True
except Exception as e:
# 如果出现异常,说明服务未运行
return False
@property
def max_input_tokens(self):
"""当前引擎支持的最大输入 token 数(统一 API,供上下文压缩使用)。
本地 Ollama 由配置 max_token_k 换算的 num_ctx 决定。"""
return self.num_ctx
@property
def is_service_running(self):
"""惰性检测 Ollama 服务是否运行:首次访问时才 import ollama 并点服务。
这样 AICLI() 构造 / 加载所有引擎时不会因 import ollama 卡住
(沙箱或 ollama 未安装的环境也能正常启动 CLI,仅真正选用本引擎时才校验)。
"""
if not self._service_checked:
self._service_running = self.check_ollama_service()
self._service_checked = True
if self._service_running:
_vprint(f"{Fore.GREEN}Ollama服务检测成功,引擎已启用{Style.RESET_ALL}")
else:
print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}")
return self._service_running
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
"""
生成AI响应 - 使用Ollama API
@@ -131,27 +215,46 @@ Ollama AI引擎插件帮助信息
# 如果有工具结果,将其作为上下文返回给AI处理
if tool_results:
tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}"
tool_result_text = f"工具执行结果: {normalize_tool_text(tool_results)}"
messages = self._build_messages_with_history(system_prompt, tool_result_text)
else:
messages = self._build_messages_with_history(system_prompt, user_input)
# 构建调用参数
# 构建调用参数(num_ctx: 模型上下文窗口大小,由配置的 max_token_k 决定)
kwargs = {
"model": self.model,
"messages": messages,
"stream": False
"stream": False,
"options": {"num_ctx": self.num_ctx}
}
# ── Function Calling: 注入工具定义 ──
if tools:
# ── Function Calling: 注入工具定义(仅引擎支持 FC 时下发)──
fc_enabled = bool(tools) and self._fc_supported()
if fc_enabled:
kwargs["tools"] = tools
# 使用Ollama Python库调用API
response = ollama_client.chat(**kwargs)
# 使用Ollama Python库调用API(FC 不支持时降级为无工具重试一次)
try:
response = _ollama().chat(**kwargs)
except Exception as _fc_err:
if fc_enabled and "tool" in str(_fc_err).lower():
self._mark_fc_unsupported()
kwargs.pop("tools", None)
response = _ollama().chat(**kwargs)
else:
raise
# ── Function Calling: 检查 tool_calls ──
message = response.get("message", {})
# 上报本轮 prompt token 数(Ollama 返回 prompt_eval_count),供 token 感知压缩
try:
if isinstance(response, dict):
_pet = response.get("prompt_eval_count")
else:
_pet = getattr(response, "prompt_eval_count", None)
self.last_prompt_tokens = int(_pet) if _pet else None
except Exception:
self.last_prompt_tokens = None
tool_calls_raw = message.get("tool_calls")
if tool_calls_raw:
# Ollama 的 tool_calls 格式: [{"function": {"name": ..., "arguments": {...}}}]
@@ -197,6 +300,22 @@ Ollama AI引擎插件帮助信息
except Exception as e:
return f"Ollama API调用失败: {str(e)}"
def _fc_supported(self):
"""该 (base_url, model) 是否支持 FC。未探测过时默认 True(乐观,先试一次)"""
try:
from xcli_core.fc_tools import supports_fc
return supports_fc(self.base_url, self.model)
except Exception:
return False
def _mark_fc_unsupported(self):
"""标记该 (base_url, model) 不支持 FC 并持久化"""
try:
from xcli_core.fc_tools import mark_fc_unsupported
mark_fc_unsupported(self.base_url, self.model)
except Exception:
pass
def _build_messages_with_history(self, system_prompt, user_input):
"""构建包含历史记录的消息列表(支持 FC tool 消息)"""
messages = []
@@ -217,13 +336,11 @@ Ollama AI引擎插件帮助信息
role = msg["role"]
# tool 角色消息(FC 工具结果)
# tool 角色消息(FC 工具结果)—— 官方格式不含 name 字段
if role == "tool":
tool_msg = {"role": "tool", "content": msg.get("content", "")}
if "tool_call_id" in msg:
tool_msg["tool_call_id"] = msg["tool_call_id"]
if "name" in msg:
tool_msg["name"] = msg["name"]
messages.append(tool_msg)
continue
@@ -285,7 +402,7 @@ Ollama AI引擎插件帮助信息
})
# 使用Ollama Python库调用API
response = ollama_client.chat(
response = _ollama().chat(
model=self.model,
messages=messages,
stream=False
@@ -308,7 +425,7 @@ Ollama AI引擎插件帮助信息
return ["错误:Ollama服务未运行"]
try:
response = ollama_client.list()
response = _ollama().list()
models = [model["name"] for model in response["models"]]
return models
except Exception as e:
@@ -388,7 +505,7 @@ Ollama AI引擎插件帮助信息
print(f"{Fore.YELLOW}正在拉取模型 {model_name}...{Style.RESET_ALL}")
# 使用Ollama Python库拉取模型
response = ollama_client.pull(model_name, stream=True)
response = _ollama().pull(model_name, stream=True)
# 逐块处理响应
for part in response:
+260 -15
View File
@@ -13,6 +13,56 @@ import logging
# 设置日志
logger = logging.getLogger(__name__)
try:
from xcli_core.config import get_system_config
except Exception:
def get_system_config(key, default=None):
return default
try:
from xcli_core import model_registry
except Exception:
model_registry = None
try:
from xcli_core.verbose import vprint
except Exception:
def vprint(*args, **kwargs):
pass
try:
from xcli_core.tool_result import normalize_tool_text
except Exception:
def normalize_tool_text(result, default="无结果"):
"""兜底归一化:ToolResult / dict / str 统一成字符串"""
if result is None:
return default
if isinstance(result, dict):
return str(result.get("result", default))
return str(result)
# ── 常见模型的上下文窗口(token 数)──
# OpenAI /chat/completions 协议本身不返回模型窗口,需本地维护。
MODEL_CONTEXT_WINDOWS = {
# 讯飞星火(Spark,经 one-api/new-api 转发时模型名常为 spark-* / general*)
"spark-4.0": 128000, "spark-max": 8192, "spark-pro": 8192, "spark-lite": 8192,
"spark": 8192, "generalv3.5": 8192, "generalv3": 8192, "general": 4096,
"gpt-4o": 128000, "gpt-4o-mini": 128000, "gpt-4": 8192, "gpt-4-turbo": 128000,
"gpt-3.5-turbo": 16385,
"deepseek-chat": 64000, "deepseek-reasoner": 64000,
"qwen2.5": 32768, "qwen2": 32768, "qwen-max": 32768, "qwen-plus": 32768,
"llama3": 8192, "llama3.1": 128000, "llama3.2": 128000, "llama2": 4096,
"claude-3": 200000, "claude-3.5": 200000, "claude-3.7": 200000, "claude": 200000,
"grok": 131072, "grok-2": 131072, "grok-3": 131072,
"glm-4": 128000, "glm-4v": 128000,
"mistral": 32768, "mixtral": 32768, "mistral-large": 128000,
"gemini": 1000000, "gemini-1.5": 1000000, "gemini-2.0": 1000000, "gemini-2.5": 1000000,
"yi": 200000, "yi-large": 200000,
"kimi": 200000, "moonshot": 200000,
"abab": 200000, "minimax": 200000,
"baichuan": 192000, "chatglm": 32768,
}
# 添加项目根目录到sys.path
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
@@ -95,6 +145,12 @@ OpenAI 兼容格式引擎帮助信息
self.conversation_history = []
self.shared_conversation_history = None
# 应用注册表中的当前模型(覆盖扁平默认配置,向后兼容)
self._apply_registry_model()
# 最近一次请求的 token 用量(供 token 感知压缩使用)
self.last_prompt_tokens = None
# 思考标记(兼容深度思考模型)
self.thinking_start_marker = "<think>"
self.thinking_end_marker = "</think>"
@@ -105,7 +161,64 @@ OpenAI 兼容格式引擎帮助信息
if not self.base_url:
print(f"{Fore.YELLOW}警告: openai 引擎的 base_url 未设置{Style.RESET_ALL}")
else:
print(f"{Fore.GREEN}OpenAI 兼容引擎已启用 (base_url: {self.base_url}){Style.RESET_ALL}")
vprint(f"{Fore.GREEN}OpenAI 兼容引擎已启用 (base_url: {self.base_url}){Style.RESET_ALL}")
# ── 模型注册表(多模型在线切换)──
def _apply_registry_model(self):
"""若注册表存在 current 模型,用它覆盖 __init__ 读到的扁平默认配置。"""
if not model_registry:
return
try:
m = model_registry.get_model()
if m:
self.api_key = m.get("api_key", self.api_key)
self.base_url = (m.get("base_url") or self.base_url).rstrip('/')
self.model = m.get("model", self.model)
except Exception:
pass
def list_registry_models(self):
"""返回 (models, current_name),供 CLI 列出已配置模型。"""
if not model_registry:
return [], None
return model_registry.list_models()
def current_model_name(self):
"""当前激活模型在注册表中的名称(兼容单槽位时返回 model 值)。"""
if not model_registry:
return self.model
try:
models, current = model_registry.list_models()
return current or self.model
except Exception:
return self.model
def set_registry_model(self, name):
"""切换到注册表中指定模型(含 base_url/api_key/model)并持久化 current。
成功返回 True。"""
if not model_registry:
return False
m = model_registry.get_model(name)
if not m:
return False
self.api_key = m.get("api_key", "")
self.base_url = (m.get("base_url") or "").rstrip('/')
self.model = m.get("model", "")
model_registry.set_current(name)
return True
def add_model(self, entry):
"""新增/更新模型并持久化。entry: {name, base_url, api_key, model}。"""
if not model_registry:
return None
return model_registry.add_model(entry)
def remove_model(self, name):
"""删除模型并持久化。成功返回 True。"""
if not model_registry:
return False
return model_registry.remove_model(name)
def _get_headers(self):
"""构建请求头"""
@@ -125,6 +238,26 @@ OpenAI 兼容格式引擎帮助信息
url = url.rstrip('/') + '/chat/completions'
return url
# ── FC 能力探测缓存 ──
def _fc_supported(self):
"""当前 (base_url, model) 是否支持 function calling(未探测过默认支持)"""
try:
from xcli_core.fc_tools import supports_fc
return supports_fc(self.base_url, self.model)
except Exception:
return True
def _mark_fc_unsupported(self):
"""降级成功后记住该模型不支持 FC,后续请求不再带 tools"""
try:
from xcli_core.fc_tools import mark_fc_unsupported
if mark_fc_unsupported(self.base_url, self.model):
print(f"{Fore.CYAN}已记住 {self.model} 不支持 FC,后续请求将跳过工具字段"
f"(/fc reset 可清除){Style.RESET_ALL}")
except Exception:
pass
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
"""
生成 AI 响应
@@ -141,7 +274,7 @@ OpenAI 兼容格式引擎帮助信息
# 处理工具结果
if tool_results:
tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}"
tool_result_text = f"工具执行结果: {normalize_tool_text(tool_results)}"
messages = self._build_messages_with_history(system_prompt, tool_result_text)
else:
messages = self._build_messages_with_history(system_prompt, user_input)
@@ -153,10 +286,20 @@ OpenAI 兼容格式引擎帮助信息
"stream": False
}
# ── Function Calling: 注入工具定义 ──
if tools:
body["tools"] = tools
body["tool_choice"] = "auto"
# ── Function Calling: 注入工具定义(统一纯官方格式)──
# 已探测过不支持 FC 的模型直接跳过 tools,省掉每次白撞一次 500
if tools and self._fc_supported():
pure_tools = []
for t in tools:
if not isinstance(t, dict):
continue
if t.get("type") == "function" and isinstance(t.get("function"), dict):
pure_tools.append(t)
elif isinstance(t.get("function"), dict):
pure_tools.append({"type": "function", "function": t["function"]})
if pure_tools:
body["tools"] = pure_tools
body["tool_choice"] = "auto"
# 发送请求
api_url = self._get_api_url()
@@ -169,6 +312,13 @@ OpenAI 兼容格式引擎帮助信息
if response.status_code == 200:
response_data = response.json()
# ── 记录 token 用量(供 token 感知压缩)──
usage = response_data.get("usage", {})
if isinstance(usage, dict) and usage.get("prompt_tokens"):
try:
self.last_prompt_tokens = int(usage["prompt_tokens"])
except (TypeError, ValueError):
self.last_prompt_tokens = None
if "choices" in response_data and len(response_data["choices"]) > 0:
message = response_data["choices"][0].get("message", {})
@@ -202,19 +352,41 @@ OpenAI 兼容格式引擎帮助信息
return f"API 端点不存在,请检查 base_url 是否正确: {api_url}"
elif response.status_code == 400:
# 工具定义可能不兼容,降级为无工具重试
if tools and "tools" in str(response.text).lower():
if body.get("tools") and "tools" in str(response.text).lower():
print(f"{Fore.YELLOW}FC 不支持,降级为普通模式{Style.RESET_ALL}")
body.pop("tools", None)
body.pop("tool_choice", None)
retry = requests.post(url=api_url, json=body, headers=self._get_headers(), timeout=None)
if retry.status_code == 200:
self._mark_fc_unsupported()
rd = retry.json()
if "choices" in rd and rd["choices"]:
ai_response = rd["choices"][0].get("message", {}).get("content", "")
return ai_response.encode('utf-8', 'replace').decode('utf-8') if ai_response else ""
return f"API 调用失败: {response.status_code} - {response.text}"
return self._friendly_error(response.status_code, response)
else:
return f"API 调用失败: {response.status_code} - {response.text}"
# 500 等错误:部分服务(如讯飞星火经 one-api 转发)不支持 FC 工具,
# 会直接报 500 Invalid Params —— 尝试去掉 tools 降级重试一次
lower = str(response.text).lower()
if body.get("tools") and ("xunfei" in lower or "invalid params" in lower or "requestparamserror" in lower):
print(f"{Fore.YELLOW}该接口可能不支持 FC 工具,降级为普通模式重试...{Style.RESET_ALL}")
body.pop("tools", None)
body.pop("tool_choice", None)
retry = requests.post(url=api_url, json=body, headers=self._get_headers(), timeout=None)
if retry.status_code == 200:
self._mark_fc_unsupported()
rd = retry.json()
retry_usage = rd.get("usage", {})
if isinstance(retry_usage, dict) and retry_usage.get("prompt_tokens"):
try:
self.last_prompt_tokens = int(retry_usage["prompt_tokens"])
except (TypeError, ValueError):
pass
if "choices" in rd and rd["choices"]:
ai_response = rd["choices"][0].get("message", {}).get("content", "")
ai_response = self._process_thinking_content(ai_response)
return ai_response.encode('utf-8', 'replace').decode('utf-8') if ai_response else ""
return self._friendly_error(response.status_code, response)
except requests.exceptions.ConnectionError:
return f"无法连接到 API 服务: {self.base_url},请检查服务是否运行"
@@ -241,22 +413,20 @@ OpenAI 兼容格式引擎帮助信息
role = msg["role"]
# tool 角色消息(FC 工具结果)
# tool 角色消息(FC 工具结果)—— 官方 OpenAI 格式不含 name 字段
if role == "tool":
tool_msg = {"role": "tool", "content": msg.get("content", "")}
if "tool_call_id" in msg:
tool_msg["tool_call_id"] = msg["tool_call_id"]
if "name" in msg:
tool_msg["name"] = msg["name"]
messages.append(tool_msg)
continue
# assistant 消息可能包含 tool_calls
# assistant 消息可能包含 tool_calls —— 重建成纯官方结构
if role == "assistant" and "tool_calls" in msg:
assistant_msg = {
"role": "assistant",
"content": msg.get("content"),
"tool_calls": msg["tool_calls"]
"content": msg.get("content") or "",
"tool_calls": self._pure_tool_calls(msg["tool_calls"])
}
messages.append(assistant_msg)
continue
@@ -337,11 +507,86 @@ OpenAI 兼容格式引擎帮助信息
return response
@staticmethod
def _friendly_error(status_code, response):
"""把错误响应里的可读信息提取出来,附带常见原因提示"""
try:
data = response.json()
err = data.get("error", {})
msg = err.get("message", "") if isinstance(err, dict) else ""
except Exception:
msg = ""
if not msg:
return f"API 调用失败: {status_code} - {response.text}"
hint = ""
low = str(msg).lower()
if "invalid params" in low or "requestparamserror" in low or "xunfei" in low:
hint = ("\n提示: 讯飞星火接口报'参数错误',常见原因:"
"\n 1) model 名与 one-api/new-api 渠道里的模型映射不一致(如 spark-4.0-ultra / spark-max / generalv3.5)"
"\n 2) 该模型/渠道不支持 function calling(引擎已尝试去掉 tools 重试)"
"\n 3) base_url 需为 OpenAI 兼容转发地址(如 one-api 的 https://域名/v1)")
return f"API 调用失败: {status_code} - {msg}{hint}"
@staticmethod
def _pure_tool_calls(tool_calls):
"""把历史里的 tool_calls 重建成官方 OpenAI 格式:
[{"id": ..., "type": "function", "function": {"name": ..., "arguments": "json串"}}]
剔除内部字段(action/tool/args 等),arguments 统一为字符串。
"""
pure = []
for tc in (tool_calls or []):
if not isinstance(tc, dict):
continue
func = tc.get("function")
if isinstance(func, dict):
name = func.get("name", tc.get("name", ""))
args = func.get("arguments", "{}")
else:
name = tc.get("name", "")
args = tc.get("arguments", "{}")
if isinstance(args, str):
args_str = args
else:
try:
args_str = json.dumps(args, ensure_ascii=False)
except (TypeError, ValueError):
args_str = "{}"
pure.append({
"id": tc.get("id", f"call_{name}"),
"type": "function",
"function": {"name": name, "arguments": args_str}
})
return pure
def set_model(self, model_name):
"""设置模型"""
self.model = model_name
print(f"{Fore.GREEN}已切换模型为: {model_name}{Style.RESET_ALL}")
def context_window(self):
"""
返回当前模型的上下文窗口(token 数)。
优先级: config.system.context_window > 模型名查表 > 默认 128000。
注: OpenAI /chat/completions 协议本身不返回模型窗口,需本地维护。
"""
cfg = get_system_config("context_window", None)
if cfg:
try:
return int(cfg)
except (TypeError, ValueError):
pass
model = (self.model or "").lower()
for key, win in MODEL_CONTEXT_WINDOWS.items():
if key and key in model:
return win
return 128000
@property
def max_input_tokens(self):
"""当前引擎支持的最大输入 token 数(统一 API,供上下文压缩使用)。"""
return self.context_window()
def list_models(self):
"""列出可用模型(调用 API 的 /models 端点)"""
if not self.base_url:
+10 -1
View File
@@ -4,7 +4,8 @@
"ollama": {
"api_key": "",
"base_url": "http://localhost:11434",
"model": "gemma4:31b"
"model": "gemma4:31b",
"max_token_k": "<input:请输入模型最大token大小(K)>"
},
"openai": {
"api_key": "your-api-key-here",
@@ -16,5 +17,13 @@
"system": {
"default_engine": "ollama",
"max_history": 50
},
"lspServers": {
"_说明": "语言服务器(LSP)。键为语言ID或扩展名,值为启动命令。配置后重启即生效,工具 lsp__diagnostics/hover/definition/references/completion 自动可用,且 AI 写/改 .py 文件后会自动触发诊断并注入模型上下文。python 默认用自研本地服务器(jedi 语义 + py_detect 中文诊断,纯本地零安装);换回 pylsp 则 command 改 \"pylsp\"、args 清空。",
"python": {
"command": "python",
"args": ["-m", "xcli_core.local_lsp_server"],
"disabled": false
}
}
}
+36 -3
View File
@@ -16,6 +16,13 @@ from pathlib import Path
from datetime import datetime
import platform
# <input> 特殊语法:配置模型时可用它交互式询问用户并获取输入
try:
from xcli_core.config import resolve_input_value
except Exception:
def resolve_input_value(value, field_name="", default_prompt=None):
return value
# ── 编码设置 ──
if sys.stdout.encoding != 'utf-8':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
@@ -120,6 +127,7 @@ class Launcher:
'colorama', 'openai', 'requests', 'ollama', 'pygame',
'PIL', 'Pillow', 'cv2', 'opencv-python', 'numpy',
'ascii_magic', 'tkinter', 'matplotlib', 'pandas',
'jedi', # py_detect L2 语义验证后端 / 自研本地 LSP(纯 Python,预装即启用)
}
# ── 清屏 ──
@@ -471,6 +479,12 @@ class Launcher:
entry['base_url'] = 'https://api.deepseek.com/v1'
entry['model'] = 'deepseek-chat'
config['api']['engines'][name] = entry
if name == 'ollama':
# 模型最大 token 大小(单位 K,如 32 = 32K = 32768 token)
# 默认用 <input> 语法:配置模型时交互式询问用户
config['api']['engines'][name].setdefault(
'max_token_k', '<input:请输入模型最大token大小(K)>'
)
self.config_file.write_text(json.dumps(config, ensure_ascii=False, indent=2), 'utf-8')
return config
@@ -498,7 +512,7 @@ class Launcher:
info(f'当前密钥: {masked}')
if prompt_yn(f'配置 {name} 的 API 密钥?', 'n'):
key = self.secret_input(f'{name} API 密钥')
cfg['api_key'] = key
cfg['api_key'] = resolve_input_value(key, field_name='API 密钥')
# URL
current_url = cfg.get('base_url', '')
@@ -506,7 +520,7 @@ class Launcher:
info(f'当前 URL: {current_url}')
if prompt_yn(f'配置 {name} 的 Base URL?', 'n'):
url = prompt('Base URL', current_url)
cfg['base_url'] = url
cfg['base_url'] = resolve_input_value(url, field_name='Base URL')
# Model
current_model = cfg.get('model', '')
@@ -514,7 +528,26 @@ class Launcher:
info(f'当前模型: {current_model}')
if prompt_yn(f'配置 {name} 的模型?', 'n'):
model = prompt('模型名称', current_model)
cfg['model'] = model
cfg['model'] = resolve_input_value(model, field_name='模型')
# 最大 token 大小(仅 ollama;单位 K,如 32 = 32K = 32768 token;支持 <input> 语法)
if name == 'ollama':
current_ctx = cfg.get('max_token_k', '')
is_syntax = isinstance(current_ctx, str) and '<input' in current_ctx
if is_syntax:
info('当前最大 token: (启动时询问)')
elif current_ctx:
shown = str(current_ctx)
info(f'当前最大 token: {shown}K' if shown.replace('.', '', 1).isdigit() else f'当前最大 token: {shown}')
else:
info('当前最大 token: 未设置')
if prompt_yn(f'配置 {name} 的最大 token 大小(K)?', 'n'):
default_ctx = '' if is_syntax else str(current_ctx)
val = prompt('最大 token (K)', default_ctx)
if val.strip():
cfg['max_token_k'] = resolve_input_value(val, field_name='最大 token 大小(K)')
elif not current_ctx:
cfg['max_token_k'] = '<input:请输入模型最大token大小(K)>'
config.setdefault('api', {}).setdefault('engines', {})[name] = cfg
+617
View File
@@ -0,0 +1,617 @@
"""
小狸 LSP 客户端
==============
连接外部语言服务器(Language Server Protocol),把真实的诊断 / 悬停 /
定义跳转 / 引用 / 补全喂给模型——这是对标 opencode 的 P2 收尾能力。
- transport = stdio,使用 LSP 标准的 **Content-Length 分帧** JSON-RPC
(与 MCP 的逐行 JSON 不同!LSP 消息头是 `Content-Length: N\\r\\n\\r\\n` + N 字节 JSON)。
- 同步 + 线程安全:与 mcp_client.py 一致,契合小狸「同步主循环 + ThreadPoolExecutor」。
- 按文件扩展名路由到对应的语言服务器(python→pylsp、rust→rust-analyzer …),
配置驱动,不绑定具体 server;未安装/启动失败不影响主程序与其他 LSP。
- 单个 server 失败不阻断其他,也不阻断主程序启动。
后续可在此文件扩展 workspace 级多文件夹、incremental 文本同步等。
"""
import os
import re
import json
import time
import shutil
import sys
import threading
import subprocess
import logging
from typing import Dict, List, Any, Optional, Tuple
logger = logging.getLogger(__name__)
class LspClientError(RuntimeError):
"""LSP 客户端错误"""
# 扩展名 → LSP languageId
_EXT_TO_LANG = {
".py": "python", ".pyi": "python",
".rs": "rust",
".ts": "typescript", ".tsx": "typescript",
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
".go": "go",
".c": "c", ".h": "c",
".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp",
".java": "java",
".lua": "lua",
".sh": "shellscript", ".bash": "shellscript",
".json": "json", ".jsonc": "json",
".html": "html", ".htm": "html",
".css": "css", ".scss": "scss", ".less": "less",
".md": "markdown", ".markdown": "markdown",
".vue": "vue",
".rb": "ruby",
".php": "php",
".sql": "sql",
".yaml": "yaml", ".yml": "yaml",
".xml": "xml",
".toml": "toml",
}
def ext_to_language(path: str) -> Optional[str]:
"""从文件路径推断 LSP languageId"""
ext = os.path.splitext(path)[1].lower()
return _EXT_TO_LANG.get(ext)
def path_to_uri(path: str) -> str:
"""绝对路径 → file:// URI(Windows 下 file:///C:/...)"""
abspath = os.path.abspath(path)
# 统一为正斜杠
norm = abspath.replace("\\", "/")
if not norm.startswith("/"):
norm = "/" + norm
return "file://" + norm
def _parse_position(pos: Any) -> Tuple[int, int]:
"""兼容 {line,character} / [line,char] / (line,char) → (line, character)(0-based)"""
if isinstance(pos, dict):
return int(pos.get("line", 0)), int(pos.get("character", 0))
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
return int(pos[0]), int(pos[1])
return 0, 0
class LspClient:
"""单个语言服务器的 stdio 客户端(同步、线程安全、Content-Length 分帧)"""
def __init__(self, name: str, command: str, args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None, cwd: Optional[str] = None,
root_uri: Optional[str] = None, timeout: float = 30.0,
language_id: str = ""):
self.name = name
self.command = command
self.args = list(args or [])
self.env = env or {}
self.cwd = cwd
self.root_uri = root_uri
self.timeout = timeout
self.language_id = language_id
self.proc = None
self._lock = threading.RLock()
self._next_id = 1
self._pending: Dict[int, Dict[str, Any]] = {}
self._reader_thread = None
self._started = False
self._opened: set = set()
self._diagnostics: Dict[str, List[Dict]] = {} # uri -> diagnostics
self.capabilities: Dict[str, Any] = {}
self._diag_lock = threading.Lock()
# ── 生命周期 ──
def start(self) -> None:
if self._started:
return
full_env = dict(os.environ)
full_env.update(self.env)
cmd = [self.command] + self.args
# 鲁棒性: command 为裸 "python"/"python3" 但 PATH 找不到时, 回退当前解释器
if cmd and os.path.basename(cmd[0]) in ("python", "python3") and os.path.dirname(cmd[0]) == "":
found = None
for cand in (cmd[0], cmd[0] + ".exe"):
if found is None and shutil.which(cand):
found = cand
if not found:
cmd = [sys.executable] + self.args
try:
# 二进制模式:避免 CJK 字符数与 Content-Length 字节数不一致
self.proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
cwd=self.cwd,
env=full_env,
bufsize=0,
)
except Exception as e:
raise LspClientError(f"启动 LSP server '{self.name}' 失败: {e}")
self._reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
self._reader_thread.start()
self._started = True
def _reader_loop(self):
"""后台读线程:按 Content-Length 分帧解析 JSON-RPC,响应按 id 投递;
通知(publishDiagnostics 等)走回调。"""
try:
while True:
msg = self._read_message()
if msg is None:
break
self._dispatch(msg)
except Exception:
pass
finally:
with self._lock:
pendings = list(self._pending.values())
self._pending.clear()
for pend in pendings:
pend["error"] = {"code": -1, "message": "LSP server 连接断开"}
pend["event"].set()
def _read_message(self) -> Optional[Dict[str, Any]]:
"""从 stdout 读取一条 Content-Length 分帧消息,返回解析后的 dict 或 None(EOF)"""
# 读头部,直到遇到空行(\r\n\r\n)
header = b""
while b"\r\n\r\n" not in header:
chunk = self.proc.stdout.read(1)
if not chunk:
return None
header += chunk
if len(header) > 65536: # 防御:头部异常大
return None
# 解析 Content-Length
m = re.search(rb"Content-Length:\s*(\d+)", header)
if not m:
return None
length = int(m.group(1))
body = b""
while len(body) < length:
chunk = self.proc.stdout.read(length - len(body))
if not chunk:
return None
body += chunk
try:
return json.loads(body.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return None
def _dispatch(self, msg: Dict[str, Any]) -> None:
msg_id = msg.get("id")
method = msg.get("method")
if msg_id is not None and "method" not in msg:
# 响应
with self._lock:
pend = self._pending.pop(msg_id, None)
if pend is not None:
if "error" in msg:
pend["error"] = msg["error"]
else:
pend["value"] = msg.get("result")
pend["event"].set()
return
# 通知(无 id 或 method 在通知里)
if method == "textDocument/publishDiagnostics":
params = msg.get("params", {})
uri = params.get("uri", "")
diags = params.get("diagnostics", [])
with self._diag_lock:
self._diagnostics[uri] = diags
# 其余通知(window/logMessage 等)忽略
def _send(self, method: str, params: Any = None,
is_notification: bool = False) -> Optional[Any]:
if not self._started or self.proc is None:
raise LspClientError(f"LSP client '{self.name}' 未启动")
with self._lock:
pend = None
req_id = None
if not is_notification:
req_id = self._next_id
self._next_id += 1
pend = {"event": threading.Event(), "value": None, "error": None}
self._pending[req_id] = pend
req = {"jsonrpc": "2.0", "method": method}
if not is_notification:
req["id"] = req_id
if params is not None:
req["params"] = params
try:
data = json.dumps(req, ensure_ascii=False).encode("utf-8")
header = f"Content-Length: {len(data)}\r\n\r\n".encode("ascii")
self.proc.stdin.write(header + data)
self.proc.stdin.flush()
except (BrokenPipeError, ValueError, OSError) as e:
raise LspClientError(f"向 LSP server '{self.name}' 写入失败: {e}")
if is_notification:
return None
if not pend["event"].wait(self.timeout):
with self._lock:
self._pending.pop(req_id, None)
raise LspClientError(f"LSP server '{self.name}' 响应超时: {method}")
if pend["error"]:
err = pend["error"]
raise LspClientError(
f"LSP server '{self.name}' 错误 [{err.get('code')}]: {err.get('message')}")
return pend["value"]
# ── LSP 协议方法 ──
def initialize(self, root_uri: Optional[str] = None) -> Dict[str, Any]:
if root_uri:
self.root_uri = root_uri
result = self._send("initialize", {
"processId": os.getpid(),
"rootUri": self.root_uri,
"capabilities": {
"textDocument": {
"synchronization": {"dynamicRegistration": True,
"didSave": True},
"publishDiagnostics": {"relatedInformation": True},
"hover": {"contentFormat": ["markdown", "plaintext"]},
"definition": {"linkSupport": True},
"references": {},
"completion": {"completionItem": {"snippetSupport": True}},
},
"workspace": {"workspaceFolders": True,
"configuration": True},
},
"clientInfo": {"name": "xiaoli-cli", "version": "8.0.3"},
})
if isinstance(result, dict):
self.capabilities = result.get("capabilities", {}) or {}
# 通知服务器初始化完成
self._send("initialized", {}, is_notification=True)
return result or {}
def _ensure_open(self, uri: str, text: str) -> None:
if uri in self._opened:
# 已打开则同步最新内容(full 同步)。didChange 是通知,无需等待响应。
self._send("textDocument/didChange", {
"textDocument": {"uri": uri},
"contentChanges": [{"text": text}],
}, is_notification=True)
return
lang = self.language_id or "plaintext"
# didOpen 是通知,无需等待响应
self._send("textDocument/didOpen", {
"textDocument": {
"uri": uri,
"languageId": lang,
"version": 1,
"text": text,
}
}, is_notification=True)
self._opened.add(uri)
def _read_file(self, file_path: str) -> str:
try:
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except Exception as e:
return f"# 无法读取文件 {file_path}: {e}"
def diagnostics(self, file_path: str, wait: float = 2.0) -> List[Dict]:
"""返回某文件的诊断(先 didOpen 触发分析,等待并收集 publishDiagnostics)"""
uri = path_to_uri(file_path)
text = self._read_file(file_path)
self._ensure_open(uri, text)
# 等待服务器推送诊断(最多 wait 秒)
deadline = time.time() + wait
while time.time() < deadline:
with self._diag_lock:
if uri in self._diagnostics:
return list(self._diagnostics[uri])
time.sleep(0.1)
with self._diag_lock:
return list(self._diagnostics.get(uri, []))
def hover(self, file_path: str, line: int, character: int) -> Any:
uri = path_to_uri(file_path)
text = self._read_file(file_path)
self._ensure_open(uri, text)
return self._send("textDocument/hover", {
"textDocument": {"uri": uri},
"position": {"line": line, "character": character},
})
def definition(self, file_path: str, line: int, character: int) -> Any:
uri = path_to_uri(file_path)
text = self._read_file(file_path)
self._ensure_open(uri, text)
return self._send("textDocument/definition", {
"textDocument": {"uri": uri},
"position": {"line": line, "character": character},
})
def references(self, file_path: str, line: int, character: int) -> Any:
uri = path_to_uri(file_path)
text = self._read_file(file_path)
self._ensure_open(uri, text)
return self._send("textDocument/references", {
"textDocument": {"uri": uri},
"position": {"line": line, "character": character},
"context": {"includeDeclaration": True},
})
def completion(self, file_path: str, line: int, character: int) -> Any:
uri = path_to_uri(file_path)
text = self._read_file(file_path)
self._ensure_open(uri, text)
return self._send("textDocument/completion", {
"textDocument": {"uri": uri},
"position": {"line": line, "character": character},
})
def has_diagnostics(self, file_path: str) -> bool:
uri = path_to_uri(file_path)
with self._diag_lock:
return bool(self._diagnostics.get(uri))
def shutdown(self) -> None:
if self.proc is None:
return
try:
self._send("shutdown", {})
except Exception:
pass
try:
self._send("exit", {}, is_notification=True)
except Exception:
pass
try:
self.proc.stdin.close()
except Exception:
pass
try:
self.proc.wait(timeout=3)
except Exception:
try:
self.proc.kill()
except Exception:
pass
self._started = False
class LspManager:
"""管理多个语言服务器,按文件扩展名路由诊断/智能感知请求"""
def __init__(self):
self.clients: Dict[str, LspClient] = {} # language_id -> client
self._ext_index: Dict[str, str] = {} # ext -> language_id
self._lock = threading.RLock()
self._cleaned = False
self._workspace_diag: Dict[str, List[Dict]] = {} # file -> diagnostics(缓存)
def connect_all(self, servers: Dict[str, Dict[str, Any]],
cwd: Optional[str] = None,
root_uri: Optional[str] = None) -> None:
"""连接所有已声明且未禁用的语言服务器。
servers: {language_id 或扩展名: {"command", "args", "env", "cwd", "disabled", "languageId"}}
键若是扩展名(以 '.' 开头)则直接登记该扩展名;否则视为 languageId,
并把其常见扩展名都映射到它。
"""
for key, spec in servers.items():
if not isinstance(spec, dict):
continue
if spec.get("disabled"):
logger.info(f"LSP server '{key}' 已禁用,跳过")
continue
command = spec.get("command")
if not command:
logger.warning(f"LSP server '{key}' 缺少 command,跳过")
continue
args = spec.get("args") or []
env = spec.get("env") or {}
server_cwd = spec.get("cwd") or cwd
explicit_lang = spec.get("languageId") or ""
if key.startswith("."):
lang_id = explicit_lang or (ext_to_language(key) or key[1:])
self._ext_index[key.lower()] = lang_id
else:
lang_id = key
for ext, lid in _EXT_TO_LANG.items():
if lid == lang_id:
self._ext_index[ext] = lang_id
if lang_id in self.clients:
continue # 同一语言只起一个 server
client = LspClient(lang_id, command, args, env, server_cwd,
root_uri=root_uri, language_id=lang_id)
try:
client.start()
client.initialize()
with self._lock:
self.clients[lang_id] = client
logger.info(f"已连接 LSP server '{lang_id}' ({command})")
except Exception as e:
try:
client.shutdown()
except Exception:
pass
logger.error(f"连接 LSP server '{lang_id}' 失败: {e}")
def _resolve_client(self, file_path: str) -> Optional[LspClient]:
ext = os.path.splitext(file_path)[1].lower()
with self._lock:
lang_id = self._ext_index.get(ext)
if lang_id:
return self.clients.get(lang_id)
# 退化:按 languageId 推断
lid = ext_to_language(file_path)
if lid:
with self._lock:
return self.clients.get(lid)
return None
def _format_diagnostics(self, file_path: str, diags: List[Dict]) -> str:
if not diags:
return f"✅ {file_path}:无诊断(未发现问题)"
lines = [f"🔍 {file_path}:{len(diags)} 条诊断"]
for d in diags:
rng = d.get("range", {}).get("start", {})
ln = rng.get("line", "?")
ch = rng.get("character", "?")
sev = d.get("severity", 1)
sev_map = {1: "错误", 2: "警告", 3: "信息", 4: "提示"}
label = sev_map.get(sev, "问题")
msg = d.get("message", "")
src = d.get("source", "")
src_tag = f" [{src}]" if src else ""
lines.append(f" {label}{src_tag} L{ln + 1}:{ch + 1} — {msg}")
return "\n".join(lines)
def diagnostics(self, file_path: str, wait: float = 2.0) -> str:
client = self._resolve_client(file_path)
if client is None:
return (f"⚠️ 没有为 {file_path} 配置 LSP server"
f"(在 config.json 的 lspServers 中添加对应语言)")
diags = client.diagnostics(file_path, wait)
text = self._format_diagnostics(file_path, diags)
# 缓存,供系统提示自动注入
with self._lock:
self._workspace_diag[file_path] = diags
return text
def hover(self, file_path: str, line: int, character: int) -> str:
client = self._resolve_client(file_path)
if client is None:
return f"⚠️ 没有为 {file_path} 配置 LSP server"
try:
res = client.hover(file_path, line, character)
except Exception as e:
return f"❌ hover 失败: {e}"
return self._format_hover(res)
def definition(self, file_path: str, line: int, character: int) -> str:
client = self._resolve_client(file_path)
if client is None:
return f"⚠️ 没有为 {file_path} 配置 LSP server"
try:
res = client.definition(file_path, line, character)
except Exception as e:
return f"❌ definition 失败: {e}"
return self._format_locations(res, "定义")
def references(self, file_path: str, line: int, character: int) -> str:
client = self._resolve_client(file_path)
if client is None:
return f"⚠️ 没有为 {file_path} 配置 LSP server"
try:
res = client.references(file_path, line, character)
except Exception as e:
return f"❌ references 失败: {e}"
return self._format_locations(res, "引用")
def completion(self, file_path: str, line: int, character: int) -> str:
client = self._resolve_client(file_path)
if client is None:
return f"⚠️ 没有为 {file_path} 配置 LSP server"
try:
res = client.completion(file_path, line, character)
except Exception as e:
return f"❌ completion 失败: {e}"
return self._format_completion(res)
@staticmethod
def _format_hover(res: Any) -> str:
if not res:
return "(无 hover 信息)"
contents = res.get("contents") if isinstance(res, dict) else None
if isinstance(contents, str):
return contents
if isinstance(contents, dict):
return contents.get("value", "") or json.dumps(contents, ensure_ascii=False)
if isinstance(contents, list):
parts = []
for c in contents:
if isinstance(c, str):
parts.append(c)
elif isinstance(c, dict):
parts.append(c.get("value", ""))
return "\n".join(p for p in parts if p)
return json.dumps(res, ensure_ascii=False)
@staticmethod
def _format_locations(res: Any, label: str) -> str:
if not res:
return f"(无{label})"
items = res if isinstance(res, list) else [res]
out = [f"{label}位置({len(items)}):"]
for it in items:
loc = it.get("location", it) if isinstance(it, dict) else {}
uri = loc.get("uri", "")
rng = loc.get("range", {}).get("start", {})
ln = rng.get("line", "?")
ch = rng.get("character", "?")
fp = uri.replace("file://", "") if uri.startswith("file://") else uri
out.append(f" {fp} L{ln + 1}:{ch + 1}")
return "\n".join(out)
@staticmethod
def _format_completion(res: Any) -> str:
if not res:
return "(无补全项)"
if isinstance(res, dict):
items = res.get("items", [])
elif isinstance(res, list):
items = res
else:
items = []
if not items:
return "(无补全项)"
out = [f"补全项({len(items)}):"]
for it in items[:30]:
label = it.get("label", "")
kind = it.get("kind", "")
detail = it.get("detail", "")
txt = f" {label}"
if detail:
txt += f" — {detail}"
elif kind:
txt += f" (kind={kind})"
out.append(txt)
return "\n".join(out)
def get_context(self) -> str:
"""返回所有已分析文件的诊断汇总,用于注入系统提示"""
with self._lock:
entries = list(self._workspace_diag.items())
if not entries:
return ""
blocks = []
for fp, diags in entries:
if diags:
blocks.append(self._format_diagnostics(fp, diags))
return "\n\n".join(blocks)
def get_servers(self) -> List[str]:
with self._lock:
return list(self.clients.keys())
def cleanup(self) -> None:
with self._lock:
if self._cleaned:
return
self._cleaned = True
clients = list(self.clients.values())
self.clients.clear()
self._ext_index.clear()
self._workspace_diag.clear()
for client in clients:
try:
client.shutdown()
except Exception:
pass
+306
View File
@@ -0,0 +1,306 @@
"""
小狸 MCP 客户端
===============
连接外部 MCP (Model Context Protocol) server,将其工具桥接进小狸的统一工具体系。
- transport = stdio(subprocess + 逐行 JSON-RPC 2.0),与项目根 mcp_server.py(暴露侧)对称。
- 同步 + 线程安全:契合小狸「同步主循环 + ThreadPoolExecutor」模型,不引入官方 mcp SDK
(其 asyncio 风格与现有同步调用链不搭)。
- 单个 server 失败不阻断其他 server,也不阻断主程序启动。
后续可在此文件扩展 SSE / HTTP transport(McpClient 子类即可)。
"""
import os
import json
import time
import threading
import subprocess
import logging
from typing import Dict, List, Any, Optional, Tuple
logger = logging.getLogger(__name__)
PROTOCOL_VERSION = "2024-11-05"
class McpClientError(RuntimeError):
"""MCP 客户端错误"""
class McpClient:
"""单个 MCP server 的 stdio 客户端(同步、线程安全)"""
def __init__(self, name: str, command: str, args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None, cwd: Optional[str] = None,
timeout: float = 30.0):
self.name = name
self.command = command
self.args = list(args or [])
self.env = env or {}
self.cwd = cwd
self.timeout = timeout
self.proc = None
self._lock = threading.RLock()
self._next_id = 1
self._pending: Dict[int, Dict[str, Any]] = {}
self._reader_thread = None
self._started = False
# ── 生命周期 ──
def start(self) -> None:
"""启动子进程并开启 reader 线程"""
if self._started:
return
full_env = dict(os.environ)
full_env.update(self.env)
cmd = [self.command] + self.args
try:
self.proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
cwd=self.cwd,
env=full_env,
text=True,
encoding="utf-8",
bufsize=1,
)
except Exception as e:
raise McpClientError(f"启动 MCP server '{self.name}' 失败: {e}")
self._reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
self._reader_thread.start()
self._started = True
def _reader_loop(self):
"""后台读线程:逐行解析 JSON-RPC 响应,按 id 投递到 _pending 表"""
try:
for raw in self.proc.stdout:
line = raw.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
# 容错:跳过 server 可能混入 stdout 的非 JSON 噪声
continue
msg_id = msg.get("id")
if msg_id is None:
# 通知(如进度),忽略
continue
with self._lock:
pend = self._pending.pop(msg_id, None)
if pend is not None:
if "error" in msg:
pend["error"] = msg["error"]
else:
pend["value"] = msg.get("result")
pend["event"].set()
except Exception:
pass
finally:
# 连接断开:唤醒所有等待中的请求,避免永久阻塞
with self._lock:
pendings = list(self._pending.values())
self._pending.clear()
for pend in pendings:
pend["error"] = {"code": -1, "message": "MCP server 连接断开"}
pend["event"].set()
def _send(self, method: str, params: Any = None,
is_notification: bool = False) -> Optional[Any]:
"""发送一个 JSON-RPC 请求;非通知则等待响应(带超时)"""
if not self._started or self.proc is None:
raise McpClientError(f"MCP client '{self.name}' 未启动")
with self._lock:
if is_notification:
req = {"jsonrpc": "2.0", "method": method, "params": params or {}}
pend = None
req_id = None
else:
req_id = self._next_id
self._next_id += 1
pend = {"event": threading.Event(), "value": None, "error": None}
self._pending[req_id] = pend
req = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params or {}}
try:
self.proc.stdin.write(json.dumps(req, ensure_ascii=False) + "\n")
self.proc.stdin.flush()
except (BrokenPipeError, ValueError, OSError) as e:
raise McpClientError(f"向 MCP server '{self.name}' 写入失败: {e}")
if is_notification:
return None
if not pend["event"].wait(self.timeout):
with self._lock:
self._pending.pop(req_id, None)
raise McpClientError(f"MCP server '{self.name}' 响应超时: {method}")
if pend["error"]:
err = pend["error"]
raise McpClientError(
f"MCP server '{self.name}' 错误 [{err.get('code')}]: {err.get('message')}")
return pend["value"]
# ── MCP 协议方法 ──
def initialize(self) -> Dict[str, Any]:
"""握手:协商协议版本并通知初始化完成"""
result = self._send("initialize", {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "xiaoli-cli", "version": "8.0.3"},
})
# 通知 server 初始化完成(无响应,无需等待)
self._send("notifications/initialized", {}, is_notification=True)
return result or {}
def list_tools(self) -> List[Dict[str, Any]]:
"""列出 server 提供的工具定义"""
result = self._send("tools/list", {})
if isinstance(result, dict):
return result.get("tools", [])
return []
def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str:
"""调用一个工具,返回文本结果"""
result = self._send("tools/call", {"name": tool_name, "arguments": arguments or {}})
return self._extract_content(result)
@staticmethod
def _extract_content(result: Any) -> str:
"""从 tools/call 的 result 提取文本结果"""
if result is None:
return ""
if isinstance(result, str):
return result
if isinstance(result, dict):
content = result.get("content")
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text" or "text" in item:
parts.append(str(item.get("text", "")))
return "\n".join(p for p in parts if p)
if isinstance(result.get("content"), str):
return result["content"]
# 部分 server 直接把结果放 result 顶层
if "isError" in result and result.get("isError"):
return f"[工具错误] {json.dumps(result, ensure_ascii=False)}"
return json.dumps(result, ensure_ascii=False)
return str(result)
def ping(self) -> bool:
try:
self._send("ping", {})
return True
except Exception:
return False
def stop(self) -> None:
"""优雅关闭子进程,失败则强杀"""
if self.proc is None:
return
try:
self._send("shutdown", {})
except Exception:
pass
try:
self._send("exit", {}, is_notification=True)
except Exception:
pass
try:
self.proc.stdin.close()
except Exception:
pass
try:
self.proc.wait(timeout=3)
except Exception:
try:
self.proc.kill()
except Exception:
pass
self._started = False
class McpClientManager:
"""管理多个外部 MCP server 客户端(按配置并发连接、聚合工具)"""
def __init__(self):
self.clients: Dict[str, McpClient] = {}
self._tool_index: List[Tuple[str, Dict[str, Any]]] = [] # (server_name, tool_def)
self._lock = threading.RLock()
self._cleaned = False
def connect_all(self, servers: Dict[str, Dict[str, Any]],
cwd: Optional[str] = None) -> None:
"""连接所有已声明且未禁用的 server。单个失败不阻断其他。
servers: {name: {"command", "args", "env", "cwd", "disabled"}}
"""
for name, spec in servers.items():
if not isinstance(spec, dict):
continue
if spec.get("disabled"):
logger.info(f"MCP server '{name}' 已禁用,跳过")
continue
command = spec.get("command")
if not command:
logger.warning(f"MCP server '{name}' 缺少 command,跳过")
continue
args = spec.get("args") or []
env = spec.get("env") or {}
server_cwd = spec.get("cwd") or cwd
client = McpClient(name, command, args, env, server_cwd)
try:
client.start()
client.initialize()
tools = client.list_tools()
with self._lock:
self.clients[name] = client
for tool in tools:
self._tool_index.append((name, tool))
logger.info(f"已连接 MCP server '{name}': {len(tools)} 个工具")
except Exception as e:
try:
client.stop()
except Exception:
pass
logger.error(f"连接 MCP server '{name}' 失败: {e}")
# 不向上抛,继续下一个 server
def get_all_tools(self) -> List[Tuple[str, Dict[str, Any]]]:
"""返回 [(server_name, tool_def), ...]"""
with self._lock:
return list(self._tool_index)
def get_servers(self) -> List[str]:
with self._lock:
return list(self.clients.keys())
def call_tool(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> str:
with self._lock:
client = self.clients.get(server_name)
if client is None:
raise McpClientError(f"未连接的 MCP server: {server_name}")
return client.call_tool(tool_name, arguments)
def cleanup(self) -> None:
"""幂等:终止所有子进程。每个桥接工具的 shutdown 都会调一次,故必须可重入。"""
with self._lock:
if self._cleaned:
return
self._cleaned = True
clients = list(self.clients.values())
self.clients.clear()
self._tool_index.clear()
for client in clients:
try:
client.stop()
except Exception:
pass
+89 -7
View File
@@ -599,6 +599,88 @@ else:
else:
return f"\n{result}"
def _auto_lsp_check(self, filepath: str) -> str:
"""写/改文件后自动让已连接的语言服务器(pylsp 等)重新诊断该文件。
诊断结果会被缓存进 LSP 管理器的 _workspace_diag,由 get_lsp_context()
在下一轮静默注入模型上下文;同时仅在确有问题时给一行可见提示。
无对应 server / 文件干净时静默跳过,绝不影响主流程。
"""
manager = None
try:
cli = self.cli
utm = getattr(cli, 'liugin_manager', None)
if utm is not None:
manager = getattr(utm, '_lsp_manager', None)
except Exception:
manager = None
if manager is None:
return ""
try:
diag_text = manager.diagnostics(filepath, wait=1.5)
except Exception:
return ""
if not diag_text or "没有为" in diag_text:
return "" # 该扩展名未配置 LSP server,跳过
if "无诊断" in diag_text:
return "" # 干净,无需呈现(缓存也为空)
first = diag_text.splitlines()[0] if diag_text else ""
return f"\n LSP 诊断: {first}"
def _load_py_detect(self):
"""延迟加载 py_detect 核心(plugins/py_detect.py),失败返回 None 并缓存"""
cached = getattr(self, "_py_detect_mod", "unset")
if cached != "unset":
return cached
try:
import importlib.util
plugin_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "py_detect.py")
if not os.path.exists(plugin_path):
self._py_detect_mod = None
return None
spec = importlib.util.spec_from_file_location("py_detect_core", plugin_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
self._py_detect_mod = mod
return mod
except Exception:
self._py_detect_mod = None
return None
def _auto_py_detect(self, filepath: str) -> str:
"""写/改 .py 文件后自动智能检测(py_detect,纯本地零依赖)。
仅在 .py 文件且检测到问题时输出(截断为前 6 条,避免刷屏),
干净或非 py 文件静默返回空串——与 _auto_lsp_check 同风格。
"""
if not filepath.lower().endswith(".py"):
return ""
mod = self._load_py_detect()
if mod is None:
return ""
try:
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
source = f.read()
issues = mod.analyze(source, filepath)
if not issues:
return ""
order = {"错误": 0, "警告": 1, "提示": 2}
issues = sorted(issues, key=lambda d: (order.get(d["severity"], 9), d["line"], d["col"]))
n = len(issues)
n_e = sum(1 for d in issues if d["severity"] == "错误")
n_w = sum(1 for d in issues if d["severity"] == "警告")
n_i = sum(1 for d in issues if d["severity"] == "提示")
lines = [f"\n [py_detect] 检测到 {n} 个问题 (错误 {n_e} · 警告 {n_w} · 提示 {n_i}):"]
for d in issues[:6]:
lines.append(f" [{d['code']}] {d['severity']} @{d['line']}:{d['col']} {d['title']}"
+ (f" ({d['extra']})" if d.get("extra") else ""))
if n > 6:
lines.append(f" … 还有 {n - 6} 条,py_detect check {filepath} 查看全部")
return "\n".join(lines)
except Exception:
return ""
# ══════════════════════════════════════
# Diff 弹窗系统
# ══════════════════════════════════════
@@ -975,7 +1057,7 @@ else:
self._spawn_diff_popup(fp, before_content, new_content)
result = f" 已编辑: {fp}\n\n{self._make_diff(old_text, new_text)}"
result += self._auto_syntax_check(fp)
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_multi_edit(self, path: str, rest: str) -> str:
@@ -1013,7 +1095,7 @@ else:
f.write(content)
self._spawn_diff_popup(fp, before_content, content)
result = f" 批量编辑: {fp}\n" + "\n".join(results)
result += self._auto_syntax_check(fp)
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_insert(self, path: str, rest: str) -> str:
@@ -1046,7 +1128,7 @@ else:
after_content = ''.join(lines)
self._spawn_diff_popup(fp, before_content, after_content)
result = f" 已在第 {line_num} 行插入 {len(insert_lines)} 行"
result += self._auto_syntax_check(fp)
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_delete_lines(self, path: str, rest: str) -> str:
@@ -1079,7 +1161,7 @@ else:
after_content = ''.join(lines)
self._spawn_diff_popup(fp, before_content, after_content)
result = f" 已删除第 {start}-{end} 行 ({len(deleted)} 行)"
result += self._auto_syntax_check(fp)
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_create(self, path: str, rest: str) -> str:
@@ -1095,7 +1177,7 @@ else:
f.write(rest or "")
self._spawn_diff_popup(fp, "", rest or "")
result = f" 已创建: {fp} ({len(rest or '')} 字符)"
result += self._auto_syntax_check(fp)
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_write(self, path: str, rest: str) -> str:
@@ -1110,7 +1192,7 @@ else:
f.write(rest)
self._spawn_diff_popup(fp, before_content, rest)
result = f" 已写入: {fp} ({len(rest)} 字符)"
result += self._auto_syntax_check(fp)
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
def _op_append(self, path: str, rest: str) -> str:
@@ -1125,7 +1207,7 @@ else:
after_content = self._read_file_safe(fp)
self._spawn_diff_popup(fp, before_content, after_content)
result = f" 已追加: {fp} ({len(rest)} 字符)"
result += self._auto_syntax_check(fp)
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
return result
# ══════════════════════════════════════
+73 -12
View File
@@ -3,6 +3,7 @@
聚焦文件系统操作,代码编辑交给 code_editor
"""
import os
import re
import json
import shutil
import sys
@@ -10,6 +11,30 @@ from datetime import datetime
from pathlib import Path
from typing import List
# ── 跨平台路径归一化 ──────────────────────────────────────────────
# 模型常给出 Linux/Mac 风格绝对路径(如 /home/<user>/Desktop、
# /Users/<user>/Desktop、/root/Desktop)。在 Windows 下 os.path.abspath 会
# 错误地拼成 C:\home\user\Desktop。这里把这类路径映射到当前用户家目录
# 对应位置;Linux/Mac 下这些路径本身合法,原样保留。
_HOME_OR_USER_RE = re.compile(r'^/(?:home|Users)/[^/]+(/.*)?$')
_ROOT_RE = re.compile(r'^/root(/.*)?$')
def _resolve_cross_platform_path(path: str) -> str:
"""把模型给出的路径归一化到当前平台后再转绝对路径。"""
if os.name == 'nt' and path:
p = path.replace('\\', '/')
m = _HOME_OR_USER_RE.match(p)
if m:
rest = (m.group(1) or '').lstrip('/')
return os.path.join(os.path.expanduser('~'), rest)
m = _ROOT_RE.match(p)
if m:
rest = (m.group(1) or '').lstrip('/')
return os.path.join(os.path.expanduser('~'), rest)
return os.path.abspath(path)
# 添加项目根目录到 path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xcli_core.tool_result import ToolResult, ErrorCode
@@ -38,6 +63,34 @@ class Liugin:
def set_cli(self, cli):
self.cli = cli
def _auto_lsp_check(self, filepath: str) -> str:
"""写/改文件后自动让已连接的语言服务器(pylsp 等)重新诊断该文件。
诊断结果缓存进 LSP 管理器的 _workspace_diag,由 get_lsp_context()
在下一轮静默注入模型上下文;仅在有问题时返回一行提示。
无对应 server / 文件干净时静默跳过。
"""
manager = None
try:
cli = self.cli
utm = getattr(cli, 'liugin_manager', None)
if utm is not None:
manager = getattr(utm, '_lsp_manager', None)
except Exception:
manager = None
if manager is None:
return ""
try:
diag_text = manager.diagnostics(filepath, wait=1.5)
except Exception:
return ""
if not diag_text or "没有为" in diag_text:
return ""
if "无诊断" in diag_text:
return ""
first = diag_text.splitlines()[0] if diag_text else ""
return f"\n LSP 诊断: {first}"
def get_tool_info(self):
return {
"name": "file_manager",
@@ -130,7 +183,7 @@ class Liugin:
path = args[i]
i += 1
path = os.path.abspath(path)
path = _resolve_cross_platform_path(path)
if not os.path.isdir(path):
return ToolResult.not_found(f"目录: {path}")
@@ -165,7 +218,7 @@ class Liugin:
def _op_read(self, args: List[str]):
if not args:
return ToolResult.missing("文件路径")
file_path = os.path.abspath(args[0])
file_path = _resolve_cross_platform_path(args[0])
max_lines = None
i = 1
while i < len(args):
@@ -207,7 +260,7 @@ class Liugin:
def _op_write(self, args: List[str]):
if len(args) < 2:
return ToolResult.fail("格式: write <文件> -c <内容>", ErrorCode.MISSING_ARGS)
fp = os.path.abspath(args[0])
fp = _resolve_cross_platform_path(args[0])
content = None
i = 1
while i < len(args):
@@ -219,12 +272,16 @@ class Liugin:
os.makedirs(os.path.dirname(fp) or ".", exist_ok=True)
with open(fp, 'w', encoding='utf-8') as f:
f.write(content)
return ToolResult.ok(f"已写入: {fp} ({len(content)} 字符)", tool_name="file_manager")
msg = f"已写入: {fp} ({len(content)} 字符)"
lsp_hint = self._auto_lsp_check(fp)
if lsp_hint:
msg += lsp_hint
return ToolResult.ok(msg, tool_name="file_manager")
def _op_append(self, args: List[str]):
if len(args) < 2:
return ToolResult.fail("格式: append <文件> -c <内容>", ErrorCode.MISSING_ARGS)
fp = os.path.abspath(args[0])
fp = _resolve_cross_platform_path(args[0])
content = None
i = 1
while i < len(args):
@@ -237,12 +294,16 @@ class Liugin:
return ToolResult.not_found(f"文件: {fp}")
with open(fp, 'a', encoding='utf-8') as f:
f.write(content)
return ToolResult.ok(f"已追加: {fp}", tool_name="file_manager")
msg = f"已追加: {fp}"
lsp_hint = self._auto_lsp_check(fp)
if lsp_hint:
msg += lsp_hint
return ToolResult.ok(msg, tool_name="file_manager")
def _op_copy(self, args: List[str]):
if len(args) < 2:
return ToolResult.missing("源和目标路径")
src, dst = os.path.abspath(args[0]), os.path.abspath(args[1])
src, dst = _resolve_cross_platform_path(args[0]), _resolve_cross_platform_path(args[1])
if not os.path.exists(src):
return ToolResult.not_found(f"源: {src}")
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
@@ -255,7 +316,7 @@ class Liugin:
def _op_move(self, args: List[str]):
if len(args) < 2:
return ToolResult.missing("源和目标路径")
src, dst = os.path.abspath(args[0]), os.path.abspath(args[1])
src, dst = _resolve_cross_platform_path(args[0]), _resolve_cross_platform_path(args[1])
if not os.path.exists(src):
return ToolResult.not_found(f"源: {src}")
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
@@ -265,7 +326,7 @@ class Liugin:
def _op_delete(self, args: List[str]):
if not args:
return ToolResult.missing("路径")
path = os.path.abspath(args[0])
path = _resolve_cross_platform_path(args[0])
recursive = "-r" in args
if not os.path.exists(path):
return ToolResult.not_found(f"路径: {path}")
@@ -285,7 +346,7 @@ class Liugin:
def _op_search(self, args: List[str]):
if not args:
return ToolResult.missing("搜索目录")
directory = os.path.abspath(args[0])
directory = _resolve_cross_platform_path(args[0])
pattern, content_search = None, None
i = 1
while i < len(args):
@@ -327,7 +388,7 @@ class Liugin:
def _op_info(self, args: List[str]):
if not args:
return ToolResult.missing("路径")
path = os.path.abspath(args[0])
path = _resolve_cross_platform_path(args[0])
if not os.path.exists(path):
return ToolResult.not_found(f"路径: {path}")
@@ -347,7 +408,7 @@ class Liugin:
def _op_mkdir(self, args: List[str]):
if not args:
return ToolResult.missing("目录路径")
path = os.path.abspath(args[0])
path = _resolve_cross_platform_path(args[0])
if os.path.exists(path):
return ToolResult.fail(f"已存在: {path}", ErrorCode.INVALID_ARGS)
os.makedirs(path, exist_ok=True)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,176 @@
# METADATA
# {
# "name": "beeimg_image_uploader_v2",
#
# "display_name": {
# "zh": "Beeimg 图片上传器 V2",
# "en": "Beeimg Image Uploader V2"
# },
# "description": {
# "zh": "BeeIMG(https://beeimg.com/)工具将本地图片上传到图床并返回图片url,配合图片生成工具实现图生图(图生图务必开启)。",
# "en": "Upload a local image to BeeIMG (https://beeimg.com/) and return a public image URL. Useful for image-to-image workflows (make sure img2img is enabled)."
# },
# "env": [
# "BEEIMG_API_KEY"
# ],
# "category": "Media",
# "tools": [
# {
# "name": "upload_image",
# "description": {
# "zh": "使用 multipart 上传将本地图片上传到 BeeIMG 图床并返回图片 url。",
# "en": "Upload a local image to BeeIMG via multipart upload and return the image URL."
# },
# "parameters": [
# { "name": "file_path", "description": { "zh": "要上传的图片文件绝对路径 (建议使用 /sdcard/ 开头的完整路径)。", "en": "Absolute path of the image file to upload (recommended: full path starting with /sdcard/)." }, "type": "string", "required": true },
# { "name": "album_id", "description": { "zh": "相册ID (可选)。", "en": "Album ID (optional)." }, "type": "string", "required": false },
# { "name": "privacy", "description": { "zh": "隐私设置,'public' 或 'private' (可选)。", "en": "Privacy setting: 'public' or 'private' (optional)." }, "type": "string", "required": false }
# ]
# }
# ]
# }
import json
import traceback
API_ENDPOINT = "https://beeimg.com/api/upload/file/json/"
def isRecord(value):
return isinstance(value, dict)
def getErrorMessage(error):
if isinstance(error, Exception):
return str(error)
return str(error)
def getErrorStack(error):
if isinstance(error, Exception):
return traceback.format_exc()
return None
def getApiKey():
return getEnv("BEEIMG_API_KEY") or ""
def guessMimeTypeFromPath(filePath):
lower = filePath.lower()
if lower.endswith(".png"):
return "image/png"
if lower.endswith(".jpg") or lower.endswith(".jpeg"):
return "image/jpeg"
if lower.endswith(".webp"):
return "image/webp"
if lower.endswith(".gif"):
return "image/gif"
return "application/octet-stream"
def safeJsonParseLoose(text):
trimmed = (text or "").strip()
if not trimmed:
return None
try:
return json.loads(trimmed)
except Exception as e:
start = trimmed.find("{")
end = trimmed.rfind("}")
if start != -1 and end != -1 and end > start:
return json.loads(trimmed[start:end + 1])
raise e
async def upload_image(params):
file_path = params.get("file_path") if params else None
album_id = params.get("album_id") if params else None
privacy = params.get("privacy") if params else None
if not file_path or len(str(file_path).strip()) == 0:
raise Exception("参数 file_path 不能为空。")
# 1. 检查文件是否存在
fileExists = await Tools.Files.exists(file_path)
if not fileExists["exists"]:
raise Exception(f"文件未找到: {file_path} (请尝试使用 /sdcard/ 开头的绝对路径)")
# 2. 准备参数
apiKey = getApiKey()
if not apiKey:
raise Exception("BEEIMG_API_KEY 未配置,请在环境变量中设置 BeeIMG 的 API Key。")
form_data = {
"apikey": apiKey
}
if album_id:
form_data["albumid"] = str(album_id)
if privacy:
form_data["privacy"] = str(privacy)
# 3. 发起 multipart 上传
console.log("正在执行上传...")
resp = await Tools.Net.uploadFile({
"url": API_ENDPOINT,
"method": "POST",
"headers": {
# 尽量模拟浏览器 UA,降低被风控概率
"User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36"
},
"form_data": form_data,
"files": [
{
"field_name": "file",
"file_path": file_path,
"content_type": guessMimeTypeFromPath(file_path)
}
]
})
if resp["statusCode"] < 200 or resp["statusCode"] >= 300:
raise Exception(f"上传请求失败 (HTTP {resp['statusCode']})。可能是网络问题或 IP 被屏蔽。\n响应: {resp['content']}")
responseText = (resp.get("content") or "").strip()
if not responseText:
raise Exception("上传失败:服务器返回了空内容。请检查网络连接或尝试关闭 VPN。")
try:
jsonResponse = safeJsonParseLoose(responseText)
except Exception as e:
raise Exception(f"API 响应解析失败。服务器返回内容不是 JSON:\n{responseText[:200]}...")
files = jsonResponse["files"] if isRecord(jsonResponse) and isRecord(jsonResponse.get("files")) else None
ok = bool(files) and (files.get("status") == "Success" or files.get("code") == "200" or files.get("code") == 200)
url = files.get("url") if files else None
if ok and (isinstance(url, str) or isinstance(url, (int, float))) and len(str(url).strip()) > 0:
return {
"url": str(url),
"thumbnail_url": str(files["thumbnail_url"]) if files and files.get("thumbnail_url") else None,
"page_url": str(files["view_url"]) if files and files.get("view_url") else None,
"details": f"上传成功! URL: {str(url)}"
}
else:
if files:
errMsg = files.get("status") or files.get("message") or json.dumps(files, ensure_ascii=False)
else:
errMsg = "未知错误"
raise Exception(f"BeeIMG API 报错: {errMsg}")
async def wrap(func, params):
try:
result = await func(params)
complete({"success": True, "message": "图片上传成功", "data": result})
except Exception as error:
console.error(f"Error: {getErrorMessage(error)}")
complete({"success": False, "message": getErrorMessage(error), "error_stack": getErrorStack(error)})
async def upload_image_wrapper(params):
await wrap(upload_image, params)
exports.upload_image = upload_image_wrapper
+114
View File
@@ -0,0 +1,114 @@
# METADATA
# {
# "name": "file_manager",
# "display_name": {
# "zh": "Android 文件管理器",
# "en": "Android File Manager"
# },
# "description": {
# "zh": "通过 Environment 获取外部存储目录,支持列目录、读文本、写文本(真实文件系统操作)。",
# "en": "Use Environment to get external storage dir; list dir, read/write text files on real filesystem."
# },
# "category": "Files",
# "tools": [
# {
# "name": "list_dir",
# "description": {
# "zh": "列出目录内容",
# "en": "List directory contents"
# },
# "parameters": [
# { "name": "path", "description": { "zh": "目录路径(默认外部存储根)", "en": "Dir path (default external storage root)" }, "type": "string", "required": false }
# ]
# },
# {
# "name": "write_text",
# "description": {
# "zh": "写入文本文件",
# "en": "Write text file"
# },
# "parameters": [
# { "name": "file_path", "description": { "zh": "文件绝对路径", "en": "Absolute file path" }, "type": "string", "required": true },
# { "name": "content", "description": { "zh": "文本内容", "en": "Text content" }, "type": "string", "required": true }
# ]
# },
# {
# "name": "read_text",
# "description": {
# "zh": "读取文本文件",
# "en": "Read text file"
# },
# "parameters": [
# { "name": "file_path", "description": { "zh": "文件绝对路径", "en": "Absolute file path" }, "type": "string", "required": true }
# ]
# }
# ]
# }
TAG = "FileManager"
def _resolve(path):
"""解析路径:默认落到外部存储根"""
if path and path.strip():
return os.path.abspath(path.strip())
return Environment.getExternalStorageDirectory()
async def list_dir(params):
path = _resolve((params or {}).get("path", ""))
if not os.path.isdir(path):
raise Exception(f"目录不存在: {path}")
entries = []
for name in sorted(os.listdir(path)):
full = os.path.join(path, name)
entries.append({
"name": name,
"type": "dir" if os.path.isdir(full) else "file",
"size": os.path.getsize(full) if os.path.isfile(full) else None,
})
Log.i(TAG, f"列出 {path}: {len(entries)} 项")
result = {"path": path, "count": len(entries), "entries": entries[:100]}
complete({"success": True, "message": f"已列出 {len(entries)} 项", "data": result})
return result
async def write_text(params):
file_path = os.path.abspath((params or {}).get("file_path", ""))
content = (params or {}).get("content", "")
if not file_path:
raise Exception("file_path 不能为空")
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
with open(file_path, "w", encoding="utf-8") as fh:
fh.write(content)
size = os.path.getsize(file_path)
Log.w(TAG, f"已写入 {file_path} ({size} 字节)")
result = {"file_path": file_path, "bytes": size}
complete({"success": True, "message": f"写入成功 ({size} 字节)", "data": result})
return result
async def read_text(params):
file_path = os.path.abspath((params or {}).get("file_path", ""))
if not file_path:
raise Exception("file_path 不能为空")
if not os.path.isfile(file_path):
raise Exception(f"文件不存在: {file_path}")
with open(file_path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
result = {"file_path": file_path, "bytes": len(content.encode("utf-8")), "content": content[:20000]}
complete({"success": True, "message": f"读取成功 ({len(content)} 字符)", "data": result})
return result
async def _dispatch(params):
action = (params or {}).get("action", "list_dir")
func = {"list_dir": list_dir, "write_text": write_text, "read_text": read_text}.get(action)
if not func:
raise Exception(f"未知操作: {action}(支持 list_dir/write_text/read_text)")
return await func(params)
exports.list_dir = list_dir
exports.write_text = write_text
exports.read_text = read_text
+76
View File
@@ -0,0 +1,76 @@
# METADATA
# {
# "name": "intent_builder",
# "display_name": {
# "zh": "Intent 意图构建器",
# "en": "Intent Builder"
# },
# "description": {
# "zh": "用 android.content.Intent 构建意图:ACTION_VIEW 打开 URL、ACTION_SEND 分享文本,解析 Uri,并用 Log 输出。",
# "en": "Build intents with android.content.Intent: ACTION_VIEW for URLs, ACTION_SEND for sharing text, parse Uri, log via Log."
# },
# "category": "System",
# "tools": [
# {
# "name": "build_view_intent",
# "description": {
# "zh": "构建 ACTION_VIEW 意图(打开 URL)",
# "en": "Build ACTION_VIEW intent (open URL)"
# },
# "parameters": [
# { "name": "url", "description": { "zh": "要打开的网址", "en": "URL to open" }, "type": "string", "required": true }
# ]
# },
# {
# "name": "build_share_intent",
# "description": {
# "zh": "构建 ACTION_SEND 分享意图",
# "en": "Build ACTION_SEND share intent"
# },
# "parameters": [
# { "name": "text", "description": { "zh": "分享的文本", "en": "Text to share" }, "type": "string", "required": true }
# ]
# }
# ]
# }
TAG = "IntentBuilder"
async def build_view_intent(params):
url = (params or {}).get("url", "")
if not url:
raise Exception("url 不能为空")
intent = Intent(Intent.ACTION_VIEW)
intent.setData(Uri.parse(url))
Log.i(TAG, f"ACTION_VIEW -> {url}")
result = {
"action": intent.getAction(),
"data": intent.getData(),
"flags": intent.getFlags(),
"resolved": intent.toString(),
}
complete({"success": True, "message": "意图构建成功(桌面环境不实际拉起)", "data": result})
return result
async def build_share_intent(params):
text = (params or {}).get("text", "")
if not text:
raise Exception("text 不能为空")
intent = Intent(Intent.ACTION_SEND)
intent.setType("text/plain")
intent.putExtra(Intent.EXTRA_TEXT, text)
Log.i(TAG, f"ACTION_SEND: {text[:40]}")
result = {
"action": intent.getAction(),
"type": intent.getType(),
"extras": intent.getExtras(),
"resolved": intent.toString(),
}
complete({"success": True, "message": "分享意图构建成功", "data": result})
return result
exports.build_view_intent = build_view_intent
exports.build_share_intent = build_share_intent
+205
View File
@@ -0,0 +1,205 @@
# METADATA
# {
# "name": "crossref",
# "display_name": { "zh": "Crossref 学术文献查询", "en": "Crossref Academic Literature Search" },
# "description": { "zh": "Crossref 学术文献查询(DOI/关键词/作者/标题/ISSN)", "en": "Crossref scholarly literature search." },
# "category": "Search",
# "tools": [
# { "name": "search_by_doi", "description": { "zh": "通过 DOI 查询文章", "en": "Query by DOI" } },
# { "name": "search_by_keyword", "description": { "zh": "通过关键词搜索", "en": "Search by keyword" } },
# { "name": "search_by_author", "description": { "zh": "通过作者搜索", "en": "Search by author" } },
# { "name": "search_by_title", "description": { "zh": "通过标题搜索", "en": "Search by title" } },
# { "name": "search_by_issn", "description": { "zh": "通过 ISSN 查询", "en": "Search by ISSN" } }
# ]
# }
import json as _json
from urllib.parse import quote_plus
HEADERS = {'User-Agent': 'Operit/1.0 (mailto:support@example.com)'}
BASE_URL = "https://api.crossref.org"
DEFAULT_ROWS = 10
MAX_ROWS = 100
def getErrorMessage(error):
return str(error) if error else "unknown"
def getErrorStack(error):
import traceback
return traceback.format_exc()
def format_authors(authors):
if not authors:
return "N/A"
return ", ".join(
f"{a.get('given', '')} {a.get('family', '')}".strip()
for a in authors[:5] if a.get('given') or a.get('family')
) or "N/A"
def format_date(date_parts):
if not date_parts or not date_parts[0]:
return "N/A"
p = date_parts[0]
if len(p) == 1:
return str(p[0])
if len(p) == 2:
return f"{p[0]}-{str(p[1]).zfill(2)}"
if len(p) == 3:
return f"{p[0]}-{str(p[1]).zfill(2)}-{str(p[2]).zfill(2)}"
return "N/A"
def format_article(item, index=None):
lines = []
if index is not None:
lines.append(f"\n=== Article {index + 1} ===")
title = item.get("title", ["No Title"])[0] if item.get("title") else "No Title"
lines.append(f"Title: {title}")
if item.get("DOI"):
lines.append(f"DOI: {item['DOI']}")
lines.append(f"URL: https://doi.org/{item['DOI']}")
lines.append(f"Authors: {format_authors(item.get('author'))}")
dp = (item.get("published") or item.get("published-print") or item.get("published-online") or {}).get("date-parts")
lines.append(f"Published: {format_date(dp)}")
if item.get("container-title"):
lines.append(f"Journal/Conference: {item['container-title'][0]}")
if item.get("ISSN"):
lines.append(f"ISSN: {', '.join(item['ISSN'])}")
if item.get("publisher"):
lines.append(f"Publisher: {item['publisher']}")
if item.get("type"):
lines.append(f"Type: {item['type']}")
if item.get("is-referenced-by-count") is not None:
lines.append(f"Citations: {item['is-referenced-by-count']}")
if item.get("abstract"):
ab = __import__("re").sub(r"<[^>]*>", "", item["abstract"])
lines.append(f"Abstract: {ab[:500]}{'...' if len(ab) > 500 else ''}")
return "\n".join(lines)
async def _get_json(url):
client = OkHttp.newClient()
resp = await client.newRequest().url(url).method("GET").headers(HEADERS).build().execute()
if not resp.isSuccessful():
raise RuntimeError(f"HTTP {resp.statusCode} - {resp.statusMessage}")
return resp.json()
async def search_by_doi(params):
doi = (params.get("doi") or "").strip()
if not doi:
return {"success": False, "message": "请提供有效的 DOI"}
try:
data = await _get_json(f"{BASE_URL}/works/{quote_plus(doi)}")
if data.get("status") == "ok" and data.get("message"):
return {"success": True, "message": "查询成功", "data": format_article(data["message"])}
return {"success": False, "message": "未找到该 DOI 对应的文章"}
except Exception as e:
return {"success": False, "message": f"查询失败: {getErrorMessage(e)}"}
async def search_by_keyword(params):
query = (params.get("query") or "").strip()
if not query:
return {"success": False, "message": "请提供有效的搜索关键词"}
rows = max(1, min(int(params.get("rows", DEFAULT_ROWS) or DEFAULT_ROWS), MAX_ROWS))
sort = params.get("sort", "relevance")
order = params.get("order", "desc")
qs = "&".join(f"{k}={quote_plus(v)}" for k, v in
{"query": query, "rows": str(rows), "sort": sort, "order": order}.items())
try:
data = await _get_json(f"{BASE_URL}/works?{qs}")
return _summarize(data, query, f'关键词 "{query}"')
except Exception as e:
return {"success": False, "message": f"搜索失败: {getErrorMessage(e)}"}
async def search_by_author(params):
author = (params.get("author") or "").strip()
if not author:
return {"success": False, "message": "请提供有效的作者名字"}
rows = max(1, min(int(params.get("rows", DEFAULT_ROWS) or DEFAULT_ROWS), MAX_ROWS))
qs = "&".join(f"{k}={quote_plus(v)}" for k, v in
{"query.author": author, "rows": str(rows)}.items())
try:
data = await _get_json(f"{BASE_URL}/works?{qs}")
return _summarize(data, author, f'作者 "{author}"')
except Exception as e:
return {"success": False, "message": f"搜索失败: {getErrorMessage(e)}"}
async def search_by_title(params):
title = (params.get("title") or "").strip()
if not title:
return {"success": False, "message": "请提供有效的文章标题"}
rows = max(1, min(int(params.get("rows", DEFAULT_ROWS) or DEFAULT_ROWS), MAX_ROWS))
qs = "&".join(f"{k}={quote_plus(v)}" for k, v in
{"query.title": title, "rows": str(rows)}.items())
try:
data = await _get_json(f"{BASE_URL}/works?{qs}")
return _summarize(data, title, f'标题 "{title}"')
except Exception as e:
return {"success": False, "message": f"搜索失败: {getErrorMessage(e)}"}
async def search_by_issn(params):
issn = (params.get("issn") or "").strip()
if not issn:
return {"success": False, "message": "请提供有效的 ISSN"}
rows = max(1, min(int(params.get("rows", DEFAULT_ROWS) or DEFAULT_ROWS), MAX_ROWS))
try:
data = await _get_json(f"{BASE_URL}/journals/{quote_plus(issn)}/works?rows={rows}")
return _summarize(data, issn, f'ISSN {issn}')
except Exception as e:
return {"success": False, "message": f"查询失败: {getErrorMessage(e)}"}
def _summarize(data, key, label):
if data.get("status") == "ok" and data.get("message") and data.get("message", {}).get("items"):
items = data["message"]["items"]
total = data["message"].get("total-results")
results = [format_article(it, i) for i, it in enumerate(items)]
return {"success": True, "message": "搜索成功",
"data": f"Found {total} results for {label} (showing {len(items)}):\n\n" + "\n\n".join(results),
"total": total, "count": len(items)}
return {"success": False, "message": f"未找到匹配 {label} 的文章"}
async def wrap(func, params, ok_msg="操作成功", err_msg="操作失败"):
try:
result = await func(params)
complete({"success": True, "message": result.get("message", ok_msg), "data": result.get("data"),
**{k: v for k, v in result.items() if k not in ("success", "message", "data")}})
except Exception as error:
console.error(f"Error: {getErrorMessage(error)}")
complete({"success": False, "message": getErrorMessage(error), "error_stack": getErrorStack(error)})
async def search_by_doi_wrapper(params):
await wrap(search_by_doi, params, "查询成功", "查询失败")
async def search_by_keyword_wrapper(params):
await wrap(search_by_keyword, params, "搜索成功", "搜索失败")
async def search_by_author_wrapper(params):
await wrap(search_by_author, params, "搜索成功", "搜索失败")
async def search_by_title_wrapper(params):
await wrap(search_by_title, params, "搜索成功", "搜索失败")
async def search_by_issn_wrapper(params):
await wrap(search_by_issn, params, "查询成功", "查询失败")
exports.search_by_doi = search_by_doi_wrapper
exports.search_by_keyword = search_by_keyword_wrapper
exports.search_by_author = search_by_author_wrapper
exports.search_by_title = search_by_title_wrapper
exports.search_by_issn = search_by_issn_wrapper
+195
View File
@@ -0,0 +1,195 @@
# METADATA
# {
# "name": "market_duckduckgo",
# "display_name": {
# "zh": "DuckDuckGo 搜索(市场插件翻译)",
# "en": "DuckDuckGo Search (translated from operit market)"
# },
# "description": {
# "zh": "从 operit 市场 duckduckgo.ts 忠实翻译:搜索与网页内容抓取。",
# "en": "Faithful translation of operit market duckduckgo.ts: search & fetch."
# },
# "category": "Search",
# "tools": [
# {
# "name": "search",
# "description": {
# "zh": "搜索 DuckDuckGo 并返回格式化结果",
# "en": "Search DuckDuckGo and return formatted results"
# },
# "parameters": [
# {"name": "query", "type": "string", "required": true},
# {"name": "max_results", "type": "string", "required": false}
# ]
# },
# {
# "name": "fetch_content",
# "description": {
# "zh": "抓取并解析网页内容为纯文本",
# "en": "Fetch and parse webpage content to plain text"
# },
# "parameters": [
# {"name": "url", "type": "string", "required": true}
# ]
# }
# ]
# }
import re
import html
import asyncio
BASE_URL = "https://html.duckduckgo.com/html/"
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
class RateLimiter:
"""轻量异步令牌桶,忠实于 TS 版 RateLimiter.acquire()"""
def __init__(self, max_concurrent: int):
self._sem = asyncio.Semaphore(max_concurrent)
async def acquire(self):
await self._sem.acquire()
def release(self):
self._sem.release()
_search_limiter = RateLimiter(20)
_fetch_limiter = RateLimiter(20)
def getErrorMessage(error):
return str(error) if error else "unknown"
def getErrorStack(error):
import traceback
return traceback.format_exc()
def _decode_entities(text: str) -> str:
return html.unescape(text)
def _format_results_for_llm(results: list) -> str:
lines = []
for r in results:
lines.append(f"{r['position']}. {r['title']}")
lines.append(f" URL: {r['link']}")
lines.append(f" {r['snippet']}")
lines.append("")
return "\n".join(lines).strip()
async def wrap(func, params, ok_msg="操作成功", err_msg="操作失败"):
try:
result = await func(params)
complete({"success": True, "message": ok_msg, "data": result})
except Exception as error:
console.error(f"Error: {getErrorMessage(error)}")
complete({"success": False, "message": getErrorMessage(error), "error_stack": getErrorStack(error)})
async def search(params):
"""忠实翻译 duckduckgo.ts search()"""
query = params.get("query", "")
max_results = 10
if params.get("max_results"):
try:
parsed = int(params["max_results"])
if not str(parsed).startswith("NaN"):
max_results = parsed
except (ValueError, TypeError):
pass
if not query:
raise ValueError("查询不能为空")
await _search_limiter.acquire()
try:
console.log(f"正在从DuckDuckGo搜索: {query}")
client = OkHttp.newClient()
request = (client.newRequest()
.url(BASE_URL)
.method("POST")
.body({"q": query, "b": "", "kl": ""}, "form")
.headers({"User-Agent": _UA}))
response = await request.build().execute()
if not response.isSuccessful():
raise RuntimeError(f"HTTP 错误! 状态码: {response.statusCode}")
html_text = response.content
results = []
result_regex = re.compile(
r'<h2 class="result__title">[\s\S]*?<a.*?href="([^"]+)".*?>([\s\S]+?)</a>'
r'[\s\S]*?</h2>[\s\S]*?<a class="result__snippet".*?>([\s\S]+?)</a>',
re.IGNORECASE,
)
for m in result_regex.finditer(html_text):
if len(results) >= max_results:
break
link = m.group(1)
if "y.js" in link:
continue # 跳过广告
if link.startswith("//duckduckgo.com/l/?uddg="):
try:
link = _decode_entities(link.split("uddg=")[1].split("&")[0])
except Exception:
console.error(f"URL 解码失败: {link}")
title = _decode_entities(re.sub(r"<[^>]*>", "", m.group(2)).strip())
snippet = _decode_entities(re.sub(r"<[^>]*>", "", m.group(3)).strip())
results.append({"title": title, "link": link, "snippet": snippet,
"position": len(results) + 1})
console.log(f"成功找到 {len(results)} 个结果")
return _format_results_for_llm(results)
finally:
_search_limiter.release()
async def fetch_content(params):
"""忠实翻译 duckduckgo.ts fetch_content()"""
url = params.get("url", "")
if not url:
raise ValueError("URL不能为空")
await _fetch_limiter.acquire()
try:
console.log(f"正在抓取内容: {url}")
client = OkHttp.newClient()
request = (client.newRequest()
.url(url)
.method("GET")
.headers({"User-Agent": _UA}))
response = await request.build().execute()
if not response.isSuccessful():
raise RuntimeError(f"无法访问网页 ({response.statusCode})")
text = response.content
for tag in ("style", "script", "nav", "header", "footer"):
text = re.sub(rf"<{tag}[^>]*>[\s\S]*?</{tag}>", "", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
if len(text) > 8000:
text = text[:8000] + "... [内容被截断]"
console.log(f"成功抓取并解析内容 ({len(text)} 字符)")
return text
finally:
_fetch_limiter.release()
async def search_wrapper(params):
await wrap(search, params, "搜索完成", "搜索失败")
async def fetch_content_wrapper(params):
await wrap(fetch_content, params, "抓取完成", "抓取失败")
exports.search = search_wrapper
exports.fetch_content = fetch_content_wrapper
@@ -0,0 +1,90 @@
# METADATA
# {
# "name": "network_test",
# "display_name": { "zh": "网络测试", "en": "Network Test" },
# "description": { "zh": "基于 OkHttp 的网络请求测试(GET/POST)", "en": "Network request testing via OkHttp." },
# "category": "Network",
# "tools": [
# { "name": "http_get", "description": { "zh": "发送 HTTP GET 请求", "en": "Send HTTP GET" } },
# { "name": "http_post", "description": { "zh": "发送 HTTP POST 请求", "en": "Send HTTP POST" } }
# ]
# }
import json as _json
def getErrorMessage(error):
return str(error) if error else "unknown"
def getErrorStack(error):
import traceback
return traceback.format_exc()
def format_response(resp):
content = resp.content or ""
result = {
"status_code": resp.statusCode,
"status_message": resp.statusMessage,
"content_length": len(content),
"headers": resp.headers(),
"content": content[:12000],
}
try:
result["json"] = _json.loads(content)
except Exception:
result["json"] = None
return result
async def http_get(params):
if not params.get("url"):
return {"success": False, "message": "URL不能为空"}
try:
console.log(f"发送GET请求: {params['url']}")
req = OkHttp.newClient().newRequest().url(params["url"]).method("GET")
if params.get("headers"):
req = req.headers(params["headers"])
resp = await req.build().execute()
return {"success": True, "message": "GET请求完成", "data": format_response(resp)}
except Exception as e:
return {"success": False, "message": f"GET请求失败: {getErrorMessage(e)}"}
async def http_post(params):
if not params.get("url"):
return {"success": False, "message": "URL不能为空"}
if params.get("body") is None:
return {"success": False, "message": "请求体不能为空"}
try:
req = OkHttp.newClient().newRequest().url(params["url"]).method("POST")
if params.get("headers"):
req = req.headers(params["headers"])
body_type = params.get("body_type", "json")
req = req.body(params["body"], body_type)
resp = await req.build().execute()
return {"success": True, "message": "POST请求完成", "data": format_response(resp)}
except Exception as e:
return {"success": False, "message": f"POST请求失败: {getErrorMessage(e)}"}
async def wrap(func, params, ok_msg="操作成功", err_msg="操作失败"):
try:
result = await func(params)
complete({"success": True, "message": result.get("message", ok_msg),
"data": result.get("data"), **{k: v for k, v in result.items() if k not in ("success", "message", "data")}})
except Exception as error:
console.error(f"Error: {getErrorMessage(error)}")
complete({"success": False, "message": getErrorMessage(error), "error_stack": getErrorStack(error)})
async def http_get_wrapper(params):
await wrap(http_get, params, "GET请求完成", "GET请求失败")
async def http_post_wrapper(params):
await wrap(http_post, params, "POST请求完成", "POST请求失败")
exports.http_get = http_get_wrapper
exports.http_post = http_post_wrapper
+85
View File
@@ -0,0 +1,85 @@
# METADATA
# {
# "name": "reader",
# "display_name": { "zh": "代码读取器", "en": "Code Reader" },
# "description": { "zh": "在文件夹中搜索匹配的代码(本地文件,无需 Android)", "en": "Search matching code in a folder." },
# "category": "File",
# "tools": [
# { "name": "search_code_in_folder", "description": { "zh": "在文件夹中搜索匹配的代码", "en": "Search matching code in a folder" } }
# ]
# }
import os
import re
def getErrorMessage(error):
return str(error) if error else "unknown"
def getErrorStack(error):
import traceback
return traceback.format_exc()
async def search_code_in_folder(params):
folder_path = params.get("folder_path")
pattern = params.get("pattern")
file_extensions = params.get("file_extensions", ".js,.ts,.jsx,.tsx,.java,.cs,.py")
recursive = str(params.get("recursive", "true")).lower() in ("true", "1", "yes")
if not folder_path:
return {"success": False, "message": "文件夹路径不能为空"}
if not pattern:
return {"success": False, "message": "搜索模式不能为空"}
try:
exts = [e.strip().lower() for e in file_extensions.split(",") if e.strip()]
rx = re.compile(pattern, re.MULTILINE)
files = []
if recursive:
for root, _, fnames in os.walk(folder_path):
for fn in fnames:
if any(fn.lower().endswith(e) for e in exts):
files.append(os.path.join(root, fn))
else:
for fn in os.listdir(folder_path):
fp = os.path.join(folder_path, fn)
if os.path.isfile(fp) and any(fn.lower().endswith(e) for e in exts):
files.append(fp)
results = []
for fp in files:
try:
with open(fp, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
except Exception as e:
console.error(f"处理文件 {fp} 时出错: {getErrorMessage(e)}")
continue
matches = []
for m in rx.finditer(content):
ln = content.count("\n", 0, m.start()) + 1
matches.append({"match": m.group(0), "line": ln,
"content": content.split("\n")[ln - 1] if ln - 1 < len(content.split("\n")) else ""})
if matches:
results.append({"file": fp, "matches": matches})
total = sum(len(r["matches"]) for r in results)
return {"success": True, "message": f"在 {len(files)} 个文件中找到 {total} 处匹配",
"data": {"files_searched": len(files), "files_with_matches": len(results),
"total_matches": total, "results": results[:50]}}
except Exception as e:
return {"success": False, "message": f"搜索失败: {getErrorMessage(e)}"}
async def wrap(func, params, ok_msg="操作成功", err_msg="操作失败"):
try:
result = await func(params)
complete({"success": True, "message": result.get("message", ok_msg),
"data": result.get("data"), **{k: v for k, v in result.items() if k not in ("success", "message", "data")}})
except Exception as error:
console.error(f"Error: {getErrorMessage(error)}")
complete({"success": False, "message": getErrorMessage(error), "error_stack": getErrorStack(error)})
async def search_code_in_folder_wrapper(params):
await wrap(search_code_in_folder, params, "代码搜索完成", "代码搜索失败")
exports.search_code_in_folder = search_code_in_folder_wrapper
+103
View File
@@ -0,0 +1,103 @@
# METADATA
# {
# "name": "market_time",
# "display_name": {
# "zh": "时间(市场插件翻译)",
# "en": "Time (translated from operit market)"
# },
# "description": {
# "zh": "从 operit 市场 time.ts 忠实翻译:获取/格式化当前时间。",
# "en": "Faithful translation of operit market time.ts: get/format current time."
# },
# "category": "Utility",
# "tools": [
# {
# "name": "get_time",
# "description": {
# "zh": "获取当前时间各字段",
# "en": "Get current time fields"
# },
# "parameters": []
# },
# {
# "name": "format_time",
# "description": {
# "zh": "格式化当前时间为 24h/12h 字符串",
# "en": "Format current time as 24h/12h strings"
# },
# "parameters": []
# }
# ]
# }
import datetime
def getErrorMessage(error):
return str(error) if error else "unknown"
def getErrorStack(error):
import traceback
return traceback.format_exc()
async def wrap(func, params, ok_msg="操作成功", err_msg="操作失败"):
try:
result = await func(params)
complete({"success": True, "message": ok_msg, "data": result})
except Exception as error:
console.error(f"Error: {getErrorMessage(error)}")
complete({"success": False, "message": getErrorMessage(error), "error_stack": getErrorStack(error)})
async def get_time(params):
"""获取当前时间各字段(忠实于 time.ts get_time)"""
now = datetime.datetime.now()
return {
"timestamp": int(now.timestamp() * 1000),
"iso": now.isoformat(),
"local": now.strftime("%Y-%m-%d %H:%M:%S"),
"date": {
"year": now.year,
"month": now.month,
"day": now.day,
"weekday": now.strftime("%A"),
},
"time": {
"hours": now.hour,
"minutes": now.minute,
"seconds": now.second,
},
}
async def format_time(params):
"""格式化当前时间为 24h/12h 字符串(忠实于 time.ts format_time)"""
now = datetime.datetime.now()
pad = lambda n: str(n).zfill(2)
hours = now.hour
minutes = now.minute
seconds = now.second
time24h = f"{pad(hours)}:{pad(minutes)}:{pad(seconds)}"
suffix = "PM" if hours >= 12 else "AM"
hour12 = 12 if hours % 12 == 0 else hours % 12
time12h = f"{pad(hour12)}:{pad(minutes)}:{pad(seconds)} {suffix}"
return {
"iso": now.isoformat(),
"date": f"{now.year}-{pad(now.month)}-{pad(now.day)}",
"time24h": time24h,
"time12h": time12h,
}
async def get_time_wrapper(params):
await wrap(get_time, params, "时间获取完成", "时间获取失败")
async def format_time_wrapper(params):
await wrap(format_time, params, "时间格式化完成", "时间格式化失败")
exports.get_time = get_time_wrapper
exports.format_time = format_time_wrapper
+78
View File
@@ -0,0 +1,78 @@
# METADATA
# {
# "name": "system_info",
# "display_name": {
# "zh": "系统信息查看器",
# "en": "System Info Viewer"
# },
# "description": {
# "zh": "通过 Android API 转译层获取系统信息:存储目录、构建信息、平台环境,并用 Log 输出日志。",
# "en": "Get system info via Android API translation layer: storage dirs, build info, platform. Log via android.util.Log."
# },
# "category": "System",
# "tools": [
# {
# "name": "get_info",
# "description": {
# "zh": "获取系统信息",
# "en": "Get system info"
# },
# "parameters": []
# }
# ]
# }
import platform
import sys
TAG = "SystemInfo"
def getErrorMessage(error):
return str(error) if error else "unknown"
def getErrorStack(error):
return traceback.format_exc()
async def wrap(func, params):
try:
result = await func(params)
complete({"success": True, "message": "操作成功", "data": result})
except Exception as error:
console.error(f"Error: {getErrorMessage(error)}")
complete({"success": False, "message": getErrorMessage(error), "error_stack": getErrorStack(error)})
async def get_info(params):
"""通过 Android API 转译层采集系统信息"""
try:
info = {
"storage_external": Environment.getExternalStorageDirectory(),
"storage_data": Environment.getDataDirectory(),
"storage_root": Environment.getRootDirectory(),
"storage_public_pictures": Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
"build_brand": getattr(Build, "BRAND", "unknown"),
"build_model": getattr(Build, "MODEL", "unknown"),
"build_version": getattr(Build, "VERSION", {}).get("RELEASE", "unknown") if isinstance(getattr(Build, "VERSION", {}), dict) else "unknown",
"os_name": os.name,
"sys_platform": sys.platform,
"python_version": platform.python_version(),
"python_platform": platform.platform(),
"sdcard_mounted": Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED,
}
Log.i(TAG, f"采集完成: external={info['storage_external']}")
complete({"success": True, "message": "系统信息采集完成", "data": info})
return info
except Exception as error:
console.error(f"{TAG} 错误: {getErrorMessage(error)}")
complete({"success": False, "message": f"采集失败: {getErrorMessage(error)}", "error_stack": getErrorStack(error)})
raise
async def get_info_wrapper(params):
await wrap(get_info, params)
exports.get_info = get_info_wrapper
+175
View File
@@ -0,0 +1,175 @@
"""operit 市场插件 (.ts) → xiaoli-cli 可运行 .py 的脚手架生成器。
设计原则(不需要 android_pyapi):
operit 插件使用的是「通用平台 API」(OkHttp / Tools / toolCall /
NativeInterface / complete / console / exports),不是 Android API。
xiaoli-cli 的 operit_loader 已经 1:1 镜像了这套 API(全部用 requests/
httpx 在 Python 端原生实现),所以「翻译」只是把 TS 语法换成 Python 语法,
全局名原样保留 —— 不存在 Android 依赖。
本工具只做「可靠」的样板自动化:
1. 从 /* METADATA {json} */ 提取插件元数据,生成为 Python 友好的 # METADATA 块
2. 生成 run_tool 契约所需的胶水:getErrorMessage / getErrorStack / wrap /
exports.x = wrapper
3. 为每个工具生成 async def 骨架,并把原 .ts 函数体作为 `/// TS 参考` 注释嵌入
4. 复杂逻辑用 TODO 标注,绝不像「全量 TS→PY 编译器」那样产生半坏代码
用法:
python operit_translate.py <市场插件.ts> [-o <输出.py>] [-n <插件名>]
"""
import sys
import json
import re
import os
def extract_metadata(ts: str):
"""从 /* METADATA ... */ 提取 JSON 元数据,返回 (meta_dict, cleaned_ts)。"""
m = re.search(r"/\*\s*METADATA\s*(.*?)\*/", ts, re.DOTALL)
if not m:
return None, ts
raw = m.group(1)
try:
meta = json.loads(raw)
except json.JSONDecodeError:
# 宽松:去注释后重试
try:
meta = json.loads(re.sub(r"//.*", "", raw))
except json.JSONDecodeError:
meta = None
cleaned = ts[:m.start()] + ts[m.end():]
return meta, cleaned
def py_meta_block(meta: dict) -> str:
"""把元数据渲染成 # METADATA 块(operit_loader 逐行解析的格式)。"""
js = json.dumps(meta, ensure_ascii=False, indent=2)
lines = ["# METADATA", "# {"] + ["# " + ln for ln in js[1:-1].split("\n")] + ["# }", ""]
return "\n".join(lines)
def strip_ts_types_and_comments(ts: str) -> str:
"""去掉类型注解与注释,仅用于把 .ts 体作为参考注释嵌入(不参与执行)。"""
# 去块注释 / 行注释
ts = re.sub(r"/\*.*?\*/", "", ts, flags=re.DOTALL)
ts = re.sub(r"//[^\n]*", "", ts)
# as 断言
ts = re.sub(r"\bas\s+[\w\[\]]+", "", ts)
# 泛型
ts = re.sub(r"\b\w+<[^>]+>", lambda m: m.group(0).split("<")[0], ts)
# 修饰符
ts = re.sub(r"\b(private|public|protected|readonly|static|export|async|const|let|var)\b", "", ts)
# 参数/返回类型
ts = re.sub(r"\)\s*:\s*[^{]+?(\{)", r")\1", ts)
ts = re.sub(r"\(([^()]*)\)\s*:\s*[^{]+", r"(\1)", ts)
return ts
def indented(block: str, prefix: str = " ") -> str:
return "\n".join((prefix + ln if ln.strip() else ln) for ln in block.split("\n"))
def generate(py_name: str, ts_path: str) -> str:
with open(ts_path, "r", encoding="utf-8") as f:
ts = f.read()
meta, cleaned = extract_metadata(ts)
if not meta:
meta = {"name": py_name, "display_name": {"zh": py_name, "en": py_name},
"description": {"zh": "从 operit 市场翻译", "en": "Translated from operit market"},
"category": "Utility", "tools": []}
tools = meta.get("tools", [])
if not tools:
# 退路:从 exports.x = 猜测工具名
for mm in re.finditer(r"exports\.(\w+)\s*=", cleaned):
tools.append({"name": mm.group(1)})
out = []
out.append(py_meta_block(meta))
out.append("import re")
out.append("import html")
out.append("import asyncio")
out.append("")
out.append("")
out.append("def getErrorMessage(error):")
out.append(" return str(error) if error else \"unknown\"")
out.append("")
out.append("")
out.append("def getErrorStack(error):")
out.append(" import traceback")
out.append(" return traceback.format_exc()")
out.append("")
out.append("")
out.append("async def wrap(func, params, ok_msg=\"操作成功\", err_msg=\"操作失败\"):")
out.append(" \"\"\"operit 的 complete({success,message,data}) 契约封装\"\"\"")
out.append(" try:")
out.append(" result = await func(params)")
out.append(" complete({\"success\": True, \"message\": ok_msg, \"data\": result})")
out.append(" except Exception as error:")
out.append(" console.error(f\"Error: {getErrorMessage(error)}\")")
out.append(" complete({\"success\": False, \"message\": getErrorMessage(error),")
out.append(" \"error_stack\": getErrorStack(error)})")
out.append("")
out.append("")
# 参考注释:原 .ts 去类型后的体
ref = strip_ts_types_and_comments(cleaned).strip()
if ref:
out.append("# ==================== 原 .ts 参考(去类型/注释,仅供翻译对照) ====================")
for ln in ref.split("\n"):
out.append("# " + ln)
out.append("# ========================================================================================")
out.append("")
# 每个工具:骨架 + wrapper + exports
for t in tools:
name = t.get("name", "tool")
desc = t.get("description", {})
desc_zh = desc.get("zh", "") if isinstance(desc, dict) else str(desc)
out.append("")
out.append(f"async def {name}(params):")
out.append(f" \"\"\"{desc_zh or name}(译自 operit 市场 {meta.get('name','?')}.ts)\"\"\"")
out.append(" # TODO: 用下面的 1:1 API 映射把 .ts 逻辑补全(参考上方 /// TS 参考)")
out.append(" # OkHttp.newClient().newRequest().url(u).method('GET'/'POST')")
out.append(" # .body(payload, 'json'|'form').headers({...}).build().execute() # async")
out.append(" # response.isSuccessful() / response.statusCode / response.content")
out.append(" # Tools.System.sleep(ms) -> {'requestedMs':, 'sleptMs':}")
out.append(" # console.log / console.error / complete({success,message,data})")
out.append(f' raise NotImplementedError("{name} 待翻译")')
out.append("")
out.append("")
out.append(f"async def {name}_wrapper(params):")
out.append(f" await wrap({name}, params, \"{name} 完成\", \"{name} 失败\")")
out.append("")
out.append("")
out.append(f"exports.{name} = {name}_wrapper")
out.append("")
return "\n".join(out)
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
ts_path = sys.argv[1]
py_name = None
out_path = None
if "-n" in sys.argv:
py_name = sys.argv[sys.argv.index("-n") + 1]
if "-o" in sys.argv:
out_path = sys.argv[sys.argv.index("-o") + 1]
if not py_name:
py_name = os.path.splitext(os.path.basename(ts_path))[0].replace("-", "_")
if not out_path:
out_path = os.path.join(os.path.dirname(ts_path), py_name + ".py")
code = generate(py_name, ts_path)
with open(out_path, "w", encoding="utf-8") as f:
f.write(code)
print(f"已生成脚手架: {out_path} ({len(code)} 字节)")
print("下一步: 打开文件,把 TODO 处按 1:1 API 映射补全即可(无需 android_pyapi)。")
if __name__ == "__main__":
main()
+1090
View File
File diff suppressed because it is too large. Load diff
+21 -10
View File
@@ -1,20 +1,31 @@
# 小狸插件配置
# 小狸插件配置 - 由启动器自动生成
ENABLE_AI_IMAGE = True
ENABLE_AI_SEARCH = True
ENABLE_AUDIO_PLAYER = True
ENABLE_AUTO_ENGINEER = True
ENABLE_BOOKMARK_MANAGER = True
ENABLE_BROWSER_AUTO = True
ENABLE_CMD_EXECUTOR = True
ENABLE_FILE_MANAGER = True
ENABLE_CODE_EDITOR = True
ENABLE_CODE_SEARCH = True
ENABLE_GIT_TOOLS = True
ENABLE_COLOR_PICKER = True
ENABLE_FILE_MANAGER = True
ENABLE_FRONTEND_TESTER = True
ENABLE_GIT_TOOLS = True
ENABLE_GUI_AUTO = True
ENABLE_ID_GENERATOR = True
ENABLE_IMAGE_GENERATOR = True
ENABLE_JSON_TOOLKIT = True
ENABLE_MARKDOWN_TOOL = True
ENABLE_MEMORY_PLUGIN = True
ENABLE_NETWORK_TOOLS = True
ENABLE_OPERIT_LOADER = True
ENABLE_PY_DETECT = True
ENABLE_REGEX_TESTER = True
ENABLE_SCHEDULER = True
ENABLE_SEND_IMAGE = True
ENABLE_SNIPPET_MANAGER = True
ENABLE_SPEECH_RECOGNITION = True
ENABLE_SUB_AGENT = True
ENABLE_TASK_MANAGER = True
ENABLE_TOOL_SEARCH = True
ENABLE_AUTO_ENGINEER = True
ENABLE_GIT_WORKFLOW = True
ENABLE_PROJECT_ANALYZER = True
ENABLE_DGLAB_WS = False
ENABLE_WORKFLOW = True
+22 -27
View File
@@ -1,30 +1,25 @@
# 小狸 Pro-CLI 依赖库
# 修复后的包名(使用 pip 安装名)
# 小狸 Pro-CLI 依赖
# 生成于 2026-08-05 21:10
# 核心依赖
colorama>=0.4.6
openai>=1.0.0
requests>=2.28.0
ollama>=0.1.0
PIL
ascii_magic
colorama
cv2
numpy
ollama
openai
pygame
requests
tkinter
# 图像处理
Pillow>=9.0.0
opencv-python>=4.5.0
numpy>=1.21.0
ascii-magic>=2.0.0
# LSP 智能感知(pylsp 作为工具之一:lsp__diagnostics/hover/definition/references/completion)
# 写/改 .py 文件后自动触发诊断并注入模型上下文
python-lsp-server
pyflakes
pycodestyle
rope
mccabe
# 数据处理
pandas>=1.3.0
scipy>=1.7.0
# 音频
pygame>=2.0.0
# TUI 模式
textual>=0.40.0
# WebSocket 远程
websockets>=11.0
# 类型提示
typing-extensions>=4.0.0
# 自研本地 LSP 服务器 / py_detect L2 语义验证后端
# 纯 Python 语义引擎,装依赖即自动预装(L2 自动启用;无 jedi 自动降级为纯标准库模式)
jedi
+104
View File
@@ -0,0 +1,104 @@
"""Agents 体系化测试 - AgentManager 加载/解析/委派(不触真实引擎)"""
import os
import tempfile
import textwrap
from xcli_core.agent_manager import AgentManager, AgentDef
def _write(path, text):
with open(path, "w", encoding="utf-8") as f:
f.write(text)
def test_parse_frontmatter():
text = textwrap.dedent("""\
---
name: coder
description: 代码实现专家
tools: code_editor, git_tools, file_manager
model: gpt-4o
---
你是一个资深程序员。
任务完成后报告结果。
""")
d = AgentManager._parse(text, name="coder")
assert isinstance(d, AgentDef)
assert d.name == "coder"
assert d.description == "代码实现专家"
assert d.tools == ["code_editor", "git_tools", "file_manager"]
assert d.model == "gpt-4o"
assert "资深程序员" in d.system_prompt
def test_parse_no_frontmatter_uses_filename_and_body():
text = "纯文本 agent 定义,无 frontmatter。"
d = AgentManager._parse(text, name="plain")
assert d.name == "plain"
assert d.system_prompt == text
assert d.tools == []
def test_load_dir_and_list():
with tempfile.TemporaryDirectory() as td:
_write(os.path.join(td, "coder.md"), textwrap.dedent("""\
---
name: coder
description: 编码
tools: code_editor
---
写代码。
"""))
_write(os.path.join(td, "reviewer.md"), textwrap.dedent("""\
---
name: reviewer
description: 审查
tools: code_editor, git_tools
---
审代码。
"""))
_write(os.path.join(td, "skip.txt"), "不是 md")
mgr = AgentManager()
n = mgr.load_dir(td)
assert n == 2
assert set(mgr.names()) == {"coder", "reviewer"}
assert mgr.get("coder").description == "编码"
assert mgr.get("missing") is None
def test_load_dir_missing_dir_is_noop():
mgr = AgentManager()
assert mgr.load_dir("") == 0
assert mgr.load_dir("/no/such/dir/xyz") == 0
def test_dispatch_calls_runner_with_fields():
mgr = AgentManager()
mgr._agents["coder"] = AgentDef(
name="coder", description="编码",
tools=["code_editor"], model="gpt-4o",
system_prompt="你是 coder。",
)
captured = {}
def runner(system_prompt, task, tools, model):
captured["system_prompt"] = system_prompt
captured["task"] = task
captured["tools"] = tools
captured["model"] = model
return f"done:{task}"
out = mgr.dispatch("coder", "写 hello.py", runner)
assert out == "done:写 hello.py"
assert captured["system_prompt"] == "你是 coder。"
assert captured["tools"] == ["code_editor"]
assert captured["model"] == "gpt-4o"
def test_dispatch_missing_raises():
mgr = AgentManager()
def runner(*a):
return "x"
try:
mgr.dispatch("nope", "t", runner)
assert False, "expected KeyError"
except KeyError:
pass
+103
View File
@@ -0,0 +1,103 @@
"""上下文压缩质量测试:覆盖保真度、可配置 keep_recent、避免二次失真。"""
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xcli_core.memory import MemoryManager
class FakeEngine:
"""捕获发给引擎的摘要 prompt,并返回确定结构化摘要。"""
def __init__(self):
self.last_prompt = None
self.calls = 0
def generate_response(self, user_input, system_prompt=None, tools=None):
self.last_prompt = user_input
self.calls += 1
return "[用户]: 需求A\n[助手]: 调用 code_editor 创建 main.py\n[助手]: 调用 git_tools 提交"
class _Msg(dict):
def __init__(self, role, content):
super().__init__(role=role, content=content)
def _make_convo(n):
msgs = []
for i in range(n):
msgs.append(_Msg("user" if i % 2 == 0 else "assistant",
f"消息{i} 内容占位 " + "x" * 5))
return msgs
def test_prepare_keeps_tool_json_full():
"""工具调用 JSON 不应被 200 字截断,应完整保留。"""
mm = MemoryManager(project_dir="C:/tmp/xiaoli_test_mm")
tool_json = ('{"action": "use_tool", "tool": "code_editor", '
'"args": "edit C:/repo/main.py old_block <<<>>> new_block"}')
msg = _Msg("assistant", "我先调用工具:\n" + tool_json)
out = mm._prepare_for_summary(msg)
assert tool_json in out, "工具调用 JSON 必须完整保留"
assert "[助手] [工具调用]" in out or "[assistant] [工具调用]" in out
def test_prepare_keeps_code_fence_when_long():
"""超长内容但含代码块时,应保留代码块而非简单前 1500 字截断。"""
mm = MemoryManager(project_dir="C:/tmp/xiaoli_test_mm")
long_prose = "p" * 3000
code = "```python\ndef add(a, b):\n return a + b\n```"
msg = _Msg("assistant", long_prose + "\n" + code)
out = mm._prepare_for_summary(msg)
assert "def add" in out, "长内容中的代码块应被保留"
assert "保留代码块" in out
def test_compress_uses_current_engine_and_keeps_recent():
"""压缩调用当前引擎,并保留最近 keep_recent 条。"""
mm = MemoryManager(project_dir="C:/tmp/xiaoli_test_mm")
eng = FakeEngine()
conv = _make_convo(30) # 30 条 > keep_recent(12)
result = mm.compress_context(conv, engine=eng)
# 返回 = 1 条摘要 system + 最近 12 条
assert len(result) == 1 + mm.keep_recent
assert result[0]["role"] == "system"
assert "[上下文压缩]" in result[0]["content"]
# 最近的消息在末尾
assert result[-1] is conv[-1]
assert eng.calls == 1
def test_no_double_compaction_on_growth():
"""历史摘要标记不会被二次摘要;真正新增的消息才会产生新摘要。"""
mm = MemoryManager(project_dir="C:/tmp/xiaoli_test_mm")
eng = FakeEngine()
conv = _make_convo(30)
r1 = mm.compress_context(conv, engine=eng)
assert len(mm.compressed_blobs) == 1
# 幂等:对返回的同一历史再次压缩,不应二次摘要旧摘要、不调引擎
eng2 = FakeEngine()
r2 = mm.compress_context(r1, engine=eng2)
assert r2 is r1, "对已压缩历史再压缩应为幂等 no-op"
assert len(mm.compressed_blobs) == 1, "不应二次摘要旧摘要"
assert eng2.calls == 0
# 真正增长的新消息应被压缩为第二条摘要,且旧摘要标记保留
grown2 = list(r1) + [_Msg("user", f"后续{i}") for i in range(15)]
eng3 = FakeEngine()
r3 = mm.compress_context(grown2, engine=eng3)
assert len(mm.compressed_blobs) == 2, "新增的真实消息应被压缩为第二条摘要"
# 合并摘要里应同时含第一次与第二次的标记
assert "[上下文压缩]" in r3[0]["content"]
def test_keep_recent_configurable():
"""keep_recent 可通过参数覆盖。"""
mm = MemoryManager(project_dir="C:/tmp/xiaoli_test_mm")
eng = FakeEngine()
conv = _make_convo(20)
result = mm.compress_context(conv, engine=eng, keep_recent=5)
assert len(result) == 1 + 5
+121
View File
@@ -0,0 +1,121 @@
"""
FC 混合模式(AI 自选)集成测试
验证「让 AI 自己选纯 JSON 或 FC」的接线:
1. 支持 FC 的引擎会收到下发的 tools schema(tool_choice=auto,模型自选)
2. 引擎返回 _fc JSON 时,code_editor 被按名分发执行(走 _handle_fc_response 路径)
3. 不支持 FC 的引擎(无 _fc_supported)则不收 tools,纯 JSON 照常工作
"""
import os
import sys
import json
import tempfile
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
class FakeFcEngine:
"""模拟一个支持 FC 的引擎:首轮返回 _fc 调用 code_editor,次轮收尾。"""
def __init__(self):
self.calls = 0
self.tools_seen = []
@property
def name(self):
return "fakefc"
def _fc_supported(self):
return True
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
self.calls += 1
self.tools_seen.append(tools)
if self.calls == 1:
return json.dumps({
"_fc": True,
"tool_calls": [{
"id": "c1",
"name": "code_editor",
"tool": "code_editor",
"args": "create %s print(1)" % self._fp,
}],
}, ensure_ascii=False)
return "已完成。"
class FakeNoFcEngine:
"""模拟一个不支持 FC 的引擎(无 _fc_supported):应降级为纯 JSON。"""
def __init__(self):
self.calls = 0
self.tools_seen = []
@property
def name(self):
return "fakenofc"
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
self.calls += 1
self.tools_seen.append(tools)
assert tools is None, f"不支持 FC 的引擎不应收到 tools,实际 {tools!r}"
if self.calls == 1:
return ('好的,我来创建文件。\n'
'{"action": "use_tool", "tool": "code_editor", '
'"args": "create %s print(1)"}' % self._fp)
return "已完成。"
def _run(engine):
from xcli_core.cli_core import AICLI
from xcli_core.safety import get_safety, MODE_UNRESTRICTED
cli = AICLI()
cli.current_engine = engine
cli.tui_output_callback = lambda *a, **k: None # 同步 TUI 分支,免动画线程
get_safety().set_mode(MODE_UNRESTRICTED)
d = tempfile.mkdtemp(prefix="pj_")
fp = os.path.join(d, "demo.py")
engine._fp = fp
cli.process_conversation("帮我创建一个 python 文件")
return engine, fp
def test_fc_engine_receives_tools_and_dispatches():
engine, fp = _run(FakeFcEngine())
# 首轮应向支持 FC 的引擎下发 tools schema
assert engine.tools_seen[0] is not None, "FC 引擎应收到 tools schema"
assert isinstance(engine.tools_seen[0], list) and len(engine.tools_seen[0]) > 0
assert any(
t.get("function", {}).get("name") == "code_editor" for t in engine.tools_seen[0]
), "code_editor 应在下发的 FC 工具集中"
# _fc 调用应被按名分发执行并真实创建文件
assert os.path.exists(fp), "FC 路径应执行 code_editor 并创建文件"
assert "print(1)" in open(fp, encoding="utf-8").read()
assert engine.calls >= 2
def test_no_fc_engine_stays_pure_json():
engine, fp = _run(FakeNoFcEngine())
# 不支持 FC 的引擎不应收到 tools,纯 JSON 照常分发
assert all(t is None for t in engine.tools_seen), "不支持 FC 的引擎不应收到 tools"
assert os.path.exists(fp), "纯 JSON 路径仍应创建文件"
def test_build_fc_tools_returns_none_for_unsupported_engine():
from xcli_core.cli_core import AICLI
cli = AICLI()
cli.current_engine = FakeNoFcEngine()
assert cli._build_fc_tools("帮我创建文件") is None
def test_build_fc_tools_returns_list_for_supported_engine():
from xcli_core.cli_core import AICLI
cli = AICLI()
cli.current_engine = FakeFcEngine()
tools = cli._build_fc_tools("帮我创建 python 文件")
assert isinstance(tools, list) and len(tools) > 0
assert any(t.get("function", {}).get("name") == "code_editor" for t in tools)
+70
View File
@@ -0,0 +1,70 @@
"""
回归测试:ollama 必须惰性导入。
原因:ollama SDK 在 import 时会触发平台探测(platform.machine() → WMI),
在部分沙箱/未安装 ollama 的环境会卡死或拖慢启动。
修复后:
- ai_engines/ollama_engine 模块顶层不得 import ollama;
- OllamaAI.__init__ 不得急切点服务(is_service_running 改为惰性 property),
只有真正选用 ollama 引擎时才 import ollama。
本测试只 import 模块、不构造 AICLI(避免触发 LSP 连接等环境依赖),
因此可在任意沙箱稳定运行,专门守住「ollama 惰性导入」这一改动。
"""
import sys
import types
import builtins
PROJECT_ROOT = sys.path[0] if sys.path else ""
# 兜底:确保能找到仓库根
import os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
def test_ollama_engine_module_import_is_lazy():
"""import ai_engines.ollama_engine 不应触发 import ollama。"""
imported = []
real_import = builtins.__import__
def traced(name, *a, **k):
if name == "ollama" or name.startswith("ollama."):
imported.append(name)
return real_import(name, *a, **k)
builtins.__import__ = traced
try:
import ai_engines.ollama_engine as oe
# 旧顶层 ollama_client 应已移除
assert not hasattr(oe, "ollama_client"), \
"旧的顶层 ollama_client 应已移除,改为惰性 _ollama() getter"
# 应有惰性 getter
assert hasattr(oe, "_ollama"), "应有惰性 _ollama() getter"
# 模块被 import 后不应已有 ollama 真身
assert oe._ollama_import_tried is False, \
"模块加载完成时尚不应尝试 import ollama(惰性)"
finally:
builtins.__import__ = real_import
assert imported == [], \
f"import ai_engines.ollama_engine 不应触发 import ollama,实际 {imported}"
def test_ollama_sdk_only_imported_on_use():
"""调用 _ollama() 才真正 import ollama;未调用前仍是惰性。"""
# 用一个假 ollama 顶替,避免真实 SDK / WMI;只验证「调用时才触发」
fake = types.ModuleType("ollama")
fake.list = lambda *a, **k: []
fake.chat = lambda *a, **k: {}
fake.pull = lambda *a, **k: []
sys.modules["ollama"] = fake
import ai_engines.ollama_engine as oe
assert oe._ollama_import_tried is False
client = oe._ollama() # 首次调用
assert oe._ollama_import_tried is True
assert client is fake # 取回的是我们注入的 ollama
# 第二次调用应命中缓存,不重复 import
again = oe._ollama()
assert again is fake
+229
View File
@@ -0,0 +1,229 @@
"""自研本地 LSP 服务器测试:单元(分帧/协议/引擎)+ 真实 subprocess 集成"""
import io
import json
import os
import queue
import re
import subprocess
import sys
import threading
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, PROJECT_ROOT)
from xcli_core.local_lsp_server import (
LocalLspServer,
path_to_uri,
read_message,
uri_to_path,
write_message,
)
SRC = "import math\ndef add(a, b):\n return a + b\n\nresult = add(1, 2)\nprint(result)\n"
URI = "file:///C:/Users/Administrator/Desktop/xiaoli-cli/_test_tmp.py"
# ── 分帧传输层 ──
class TestFraming:
def test_roundtrip(self):
buf = io.BytesIO()
write_message(buf, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
buf.seek(0)
msg = read_message(buf)
assert msg == {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
def test_cjk_bytes(self):
# 中文消息体: Content-Length 按字节计
obj = {"result": "未定义变量 — 中文诊断"}
buf = io.BytesIO()
write_message(buf, obj)
buf.seek(0)
assert read_message(buf) == obj
def test_empty_stream(self):
assert read_message(io.BytesIO(b"")) is None
# ── 路径工具 ──
class TestPath:
def test_uri_to_path_windows(self):
p = uri_to_path("file:///C:/Users/a/b.py")
assert os.path.abspath(p) == os.path.abspath("C:/Users/a/b.py")
def test_path_to_uri(self):
assert path_to_uri(r"C:\Users\a\b.py").startswith("file:///C:/Users/a/b.py")
# ── 协议层(进程内直接调 handle) ──
class TestProtocol:
def make_server(self):
srv = LocalLspServer()
srv.handle({"jsonrpc": "2.0", "method": "initialize", "id": 1,
"params": {"rootUri": "file:///" + PROJECT_ROOT.replace("\\", "/")}})
return srv
def test_initialize_capabilities(self):
srv = LocalLspServer()
outs = srv.handle({"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}})
assert outs[0]["id"] == 1
caps = outs[0]["result"]["capabilities"]
assert caps["definitionProvider"] is True
assert "completionProvider" in caps
assert caps["textDocumentSync"]["change"] == 1
def test_unknown_method_error(self):
srv = LocalLspServer()
outs = srv.handle({"jsonrpc": "2.0", "method": "textDocument/xxx", "id": 9, "params": {}})
assert outs[0]["error"]["code"] == -32601
def test_shutdown_and_unknown_notification_no_reply(self):
srv = LocalLspServer()
assert srv.handle({"jsonrpc": "2.0", "method": "initialized", "params": {}}) == []
assert srv.handle({"jsonrpc": "2.0", "method": "shutdown", "id": 2,
"params": {}})[0]["result"] is None
def test_did_open_pushes_diagnostics(self):
srv = self.make_server()
outs = srv.handle({"jsonrpc": "2.0", "method": "textDocument/didOpen", "params": {
"textDocument": {"uri": URI, "languageId": "python", "version": 1, "text": SRC}}})
push = [o for o in outs if o.get("method") == "textDocument/publishDiagnostics"]
assert push, "didOpen 应推送诊断"
diags = push[0]["params"]["diagnostics"]
assert diags, "应检测到问题 (import math 未使用)"
first = diags[0]
assert first["source"] == "py_detect"
assert "range" in first and "severity" in first and "message" in first
def test_diagnostics_clean_file_no_push(self):
srv = self.make_server()
clean = "def add(a, b):\n return a + b\nprint(add(1, 2))\n"
outs = srv.handle({"jsonrpc": "2.0", "method": "textDocument/didOpen", "params": {
"textDocument": {"uri": URI, "languageId": "python", "version": 1, "text": clean}}})
assert not [o for o in outs if o.get("method") == "textDocument/publishDiagnostics"]
# ── jedi 语义(进程内,无需真实文件) ──
class TestSemantic:
def open(self):
srv = LocalLspServer()
srv.handle({"jsonrpc": "2.0", "method": "textDocument/didOpen", "params": {
"textDocument": {"uri": URI, "languageId": "python", "version": 1, "text": SRC}}})
return srv
def req(self, srv, method, line, character):
outs = srv.handle({"jsonrpc": "2.0", "method": method, "id": 1, "params": {
"textDocument": {"uri": URI}, "position": {"line": line, "character": character}}})
return outs[0]["result"]
def test_completion(self):
srv = self.open()
res = self.req(srv, "textDocument/completion", 6, 10) # result.
items = res.get("items", []) if isinstance(res, dict) else res
labels = [i["label"] for i in items]
assert "result" in labels, f"应在 result 后补全: {labels}"
def test_definition(self):
srv = self.open()
res = self.req(srv, "textDocument/definition", 5, 10) # add 调用处
assert res, "应找到 add 定义"
loc = res[0]
assert loc["uri"].startswith("file:///")
assert loc["range"]["start"]["line"] == 2 # 定义在第 3 行 (0-based)
def test_references(self):
srv = self.open()
res = self.req(srv, "textDocument/references", 5, 10) # add
assert len(res) >= 2, "应至少含定义处+调用处"
def test_hover(self):
srv = self.open()
res = self.req(srv, "textDocument/hover", 5, 10) # add
assert res is not None
contents = res.get("contents", {})
value = contents.get("value", "") if isinstance(contents, dict) else str(contents)
assert value, "hover 应有内容"
def test_hover_builtin(self):
srv = self.open()
# math 已 import; 悬停 print 是内置, 不崩溃即可
res = self.req(srv, "textDocument/hover", 6, 1)
assert res is None or isinstance(res, dict)
# ── 真实 subprocess 集成(完整握手) ──
class TestSubprocessIntegration:
def test_full_handshake(self):
proc = subprocess.Popen(
[sys.executable, "-m", "xcli_core.local_lsp_server"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, cwd=PROJECT_ROOT, bufsize=0)
try:
q = queue.Queue()
def _reader():
try:
while True:
header = b""
while b"\r\n\r\n" not in header:
chunk = proc.stdout.read(1)
if not chunk:
return
header += chunk
m = re.search(rb"Content-Length:\s*(\d+)", header)
body = proc.stdout.read(int(m.group(1))) if m else b""
if body:
q.put(json.loads(body.decode("utf-8")))
except Exception:
pass
threading.Thread(target=_reader, daemon=True).start()
def send(obj):
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
proc.stdin.write(f"Content-Length: {len(data)}\r\n\r\n".encode() + data)
proc.stdin.flush()
def recv(timeout=25):
return q.get(timeout=timeout)
root_uri = "file:///" + PROJECT_ROOT.replace("\\", "/")
send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"processId": os.getpid(), "rootUri": root_uri,
"capabilities": {"textDocument": {}}}})
r = recv()
assert "capabilities" in r.get("result", {}), r
send({"jsonrpc": "2.0", "method": "initialized", "params": {}})
send({"jsonrpc": "2.0", "method": "textDocument/didOpen", "params": {
"textDocument": {"uri": URI, "languageId": "python", "version": 1,
"text": SRC}}})
diag = recv()
assert diag.get("method") == "textDocument/publishDiagnostics", diag
assert diag["params"]["diagnostics"], "应推送诊断"
send({"jsonrpc": "2.0", "id": 2, "method": "textDocument/completion", "params": {
"textDocument": {"uri": URI}, "position": {"line": 6, "character": 10}}})
c = recv()["result"]
items = c.get("items", []) if isinstance(c, dict) else c
assert any(i["label"] == "result" for i in items)
send({"jsonrpc": "2.0", "id": 3, "method": "textDocument/definition", "params": {
"textDocument": {"uri": URI}, "position": {"line": 5, "character": 10}}})
d = recv()["result"]
assert d and d[0]["range"]["start"]["line"] == 2
send({"jsonrpc": "2.0", "id": 4, "method": "textDocument/hover", "params": {
"textDocument": {"uri": URI}, "position": {"line": 5, "character": 10}}})
h = recv()["result"]
assert h is not None
send({"jsonrpc": "2.0", "id": 5, "method": "shutdown", "params": {}})
assert recv()["result"] is None
send({"jsonrpc": "2.0", "method": "exit", "params": {}})
proc.stdin.close()
proc.wait(timeout=5)
finally:
if proc.poll() is None:
proc.kill()
+375
View File
@@ -0,0 +1,375 @@
"""
LSP 客户端测试
==============
用 inline 的 fake LSP server(Content-Length 分帧 JSON-RPC)验证:
- LspClient 全链路(initialize / didOpen 触发诊断 / hover / definition / shutdown)
- LspManager 按扩展名路由、多 server
- UnifiedToolManager 桥接:5 个 lsp__* 工具注册、get_mcp_tools 可见、execute 返回诊断
- get_lsp_context 注入
- fc_tools 保真(lsp 工具进入模型视野)
全程不触真实引擎 / 网络。
"""
import os
import sys
import json
import time
import threading
import pytest
import lsp_client
from lsp_client import LspClient, LspManager, path_to_uri, ext_to_language
# ── Fake LSP server(Content-Length 分帧)──
FAKE_SERVER_SRC = r'''
import sys, json, threading, time
def send(obj):
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(data))
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
def read_msg():
header = b""
while b"\r\n\r\n" not in header:
c = sys.stdin.buffer.read(1)
if not c:
return None
header += c
m = __import__("re").search(rb"Content-Length:\s*(\d+)", header)
if not m:
return None
n = int(m.group(1))
body = b""
while len(body) < n:
c = sys.stdin.buffer.read(n - len(body))
if not c:
return None
body += c
return json.loads(body.decode("utf-8"))
def main():
initialized = threading.Event()
while True:
msg = read_msg()
if msg is None:
break
mid = msg.get("id")
method = msg.get("method")
if method == "initialize":
send({"jsonrpc": "2.0", "id": mid, "result": {
"capabilities": {"hoverProvider": True,
"definitionProvider": True,
"referencesProvider": True,
"completionProvider": {}}}})
elif method == "initialized":
initialized.set()
elif method == "textDocument/didOpen":
params = msg.get("params", {})
uri = params.get("textDocument", {}).get("uri", "")
# 立即推送一条诊断
send({"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics",
"params": {"uri": uri, "diagnostics": [
{"range": {"start": {"line": 2, "character": 4},
"end": {"line": 2, "character": 7}},
"severity": 1, "source": "fake-lsp",
"message": "Undefined variable 'foo'"}]}})
elif method == "textDocument/hover":
send({"jsonrpc": "2.0", "id": mid, "result": {
"contents": {"kind": "markdown", "value": "def foo(): ..."}}})
elif method == "textDocument/definition":
send({"jsonrpc": "2.0", "id": mid, "result": {
"uri": "file:///x.py", "range": {
"start": {"line": 0, "character": 0},
"end": {"line": 0, "character": 3}}}})
elif method == "textDocument/references":
send({"jsonrpc": "2.0", "id": mid, "result": [
{"uri": "file:///x.py", "range": {
"start": {"line": 1, "character": 0},
"end": {"line": 1, "character": 3}}}]})
elif method == "textDocument/completion":
send({"jsonrpc": "2.0", "id": mid, "result": {
"items": [{"label": "bar"}, {"label": "baz"}]}})
elif method == "shutdown":
send({"jsonrpc": "2.0", "id": mid, "result": None})
elif method == "exit":
break
else:
if mid is not None:
send({"jsonrpc": "2.0", "id": mid, "result": {}})
if __name__ == "__main__":
main()
'''
@pytest.fixture
def fake_server(tmp_path):
p = tmp_path / "fake_lsp_server.py"
p.write_text(FAKE_SERVER_SRC)
return str(p)
def test_path_to_uri_windows():
uri = path_to_uri("C:\\Users\\me\\a.py")
assert uri.startswith("file:///")
assert uri.endswith("/C:/Users/me/a.py")
def test_ext_to_language():
assert ext_to_language("x.py") == "python"
assert ext_to_language("x.rs") == "rust"
assert ext_to_language("x.unknown") is None
def test_lsp_client_lifecycle(fake_server, tmp_path):
code_file = tmp_path / "sample.py"
code_file.write_text("print('hi')\nx = 1\nfoo = 2\n")
client = LspClient("python", sys.executable, [fake_server],
language_id="python", root_uri="file:///tmp")
try:
client.start()
init = client.initialize()
assert isinstance(init, dict)
diags = client.diagnostics(str(code_file), wait=3.0)
assert len(diags) == 1
assert "Undefined variable" in diags[0]["message"]
hover = client.hover(str(code_file), 0, 0)
assert hover and "foo" in json.dumps(hover)
definition = client.definition(str(code_file), 0, 0)
assert definition
references = client.references(str(code_file), 0, 0)
assert references
completion = client.completion(str(code_file), 0, 0)
assert completion
finally:
client.shutdown()
def test_lsp_manager_routing(fake_server, tmp_path):
# 用 .py 扩展名映射到 fake server(languageId=python)
servers = {"python": {"command": sys.executable, "args": [fake_server],
"disabled": False}}
mgr = LspManager()
mgr.connect_all(servers, cwd=str(tmp_path), root_uri="file:///tmp")
assert "python" in mgr.get_servers()
code_file = tmp_path / "sample.py"
code_file.write_text("a = 1\nb = 2\nfoo = 3\n")
out = mgr.diagnostics(str(code_file))
assert "Undefined variable" in out
# 诊断被缓存,供上下文注入
ctx = mgr.get_context()
assert "Undefined variable" in ctx
hover_out = mgr.hover(str(code_file), 0, 0)
assert "foo" in hover_out
def test_lsp_manager_unknown_ext_graceful(fake_server, tmp_path):
servers = {"python": {"command": sys.executable, "args": [fake_server]}}
mgr = LspManager()
mgr.connect_all(servers, cwd=str(tmp_path), root_uri="file:///tmp")
# .rs 没有配置 server,应优雅提示而非崩溃
out = mgr.diagnostics(str(tmp_path / "x.rs"))
assert "没有为" in out or "LSP server" in out
def test_lsp_manager_connect_failure_graceful(tmp_path):
# 命令不存在 → 不应抛异常,仅记录
servers = {"python": {"command": "this_command_does_not_exist_xyz"}}
mgr = LspManager()
mgr.connect_all(servers, cwd=str(tmp_path), root_uri="file:///tmp")
assert mgr.get_servers() == []
def test_unified_manager_bridges_lsp(fake_server, tmp_path, monkeypatch):
code_file = tmp_path / "sample.py"
code_file.write_text("a = 1\nb = 2\nfoo = 3\n")
cfg = {
"mcpServers": {},
"lspServers": {"python": {"command": sys.executable, "args": [fake_server],
"disabled": False}},
}
import xcli_core.config as xcfg
monkeypatch.setattr(xcfg, "load_config", lambda: cfg)
try:
import config as root_cfg
monkeypatch.setattr(root_cfg, "load_config", lambda: cfg)
except ImportError:
pass
import unified_tool_manager as utm
mgr = utm.UnifiedToolManager()
mgr.initialize(
plugins_dir=str(tmp_path / "noplugins"),
skills_dir=str(tmp_path / "noskills"),
)
try:
tools = mgr.get_mcp_tools()
names = [t["name"] for t in tools]
for expected in ("lsp__diagnostics", "lsp__hover", "lsp__definition",
"lsp__references", "lsp__completion"):
assert expected in names, f"缺少 LSP 工具: {expected}"
# inputSchema 保真(不压成 args)
diag_def = next(t for t in tools if t["name"] == "lsp__diagnostics")
assert "file" in diag_def["inputSchema"]["properties"]
# execute 返回真实诊断文本
res = mgr.execute("lsp__diagnostics", json.dumps({"file": str(code_file)}))
assert res.success
assert "Undefined variable" in (res.data or "")
# 上下文注入可用
ctx = mgr.get_lsp_context()
assert "Undefined variable" in ctx
finally:
mgr.shutdown()
def test_lsp_skip_when_no_servers_configured(tmp_path, monkeypatch):
# noch keinen LSP-Server -> keine lsp__ Tools
cfg = {"mcpServers": {}, "lspServers": {}}
import xcli_core.config as xcfg
monkeypatch.setattr(xcfg, "load_config", lambda: cfg)
try:
import config as root_cfg
monkeypatch.setattr(root_cfg, "load_config", lambda: cfg)
except ImportError:
pass
import unified_tool_manager as utm
mgr = utm.UnifiedToolManager()
mgr.initialize(
plugins_dir=str(tmp_path / "noplugins"),
skills_dir=str(tmp_path / "noskills"),
)
try:
names = [t["name"] for t in mgr.get_mcp_tools()]
assert not any(n.startswith("lsp__") for n in names)
finally:
mgr.shutdown()
def test_fc_tools_includes_lsp(tmp_path, monkeypatch, fake_server):
"""验证 lsp__* 工具能经 get_mcp_tools 进入 fc_tools 保真输出"""
import xcli_core.config as xcfg
cfg = {"mcpServers": {},
"lspServers": {"python": {"command": sys.executable, "args": [fake_server]}}}
monkeypatch.setattr(xcfg, "load_config", lambda: cfg)
try:
import config as root_cfg
monkeypatch.setattr(root_cfg, "load_config", lambda: cfg)
except ImportError:
pass
import unified_tool_manager as utm
import xcli_core.fc_tools as fc_tools
mgr = utm.UnifiedToolManager()
mgr.initialize(
plugins_dir=str(tmp_path / "noplugins"),
skills_dir=str(tmp_path / "noskills"),
)
try:
fc = fc_tools.mcp_to_openai_tools(mgr)
lsp_fc = [t for t in fc if t.get("function", {}).get("name", "").startswith("lsp__")]
assert len(lsp_fc) == 5, f"应进入 5 个 lsp 工具,实际 {len(lsp_fc)}"
# 保真:参数应是 file/line/character,而非单个 args
diag = next(t for t in lsp_fc if t["function"]["name"] == "lsp__diagnostics")
assert "file" in diag["function"]["parameters"]["properties"]
finally:
mgr.shutdown()
# ── code_editor / file_manager 的「写 .py 后自动触发 pylsp 诊断」钩子 ──
def _make_fake_lsp_manager(diag_text):
"""构造一个 fake LSP 管理器,记录被调用,并返回固定诊断文本。"""
calls = []
class FakeManager:
def __init__(self):
self.clients = {"python": object()} # 模拟已连接 python server
def diagnostics(self, filepath, wait=1.5):
calls.append(filepath)
return diag_text
fm = FakeManager()
fm._calls = calls
return fm
def _attach(cli, fm):
class UTM:
_lsp_manager = fm
cli.liugin_manager = UTM()
def test_auto_lsp_check_triggers_on_py_write(tmp_path):
"""写/改 .py 后钩子应调用 LSP 诊断,并在有问题时返回概要。"""
from plugins.code_editor import Liugin as CodeEditor
ce = CodeEditor()
fm = _make_fake_lsp_manager("🔍 x.py:2 条诊断\n 错误 L1:1 — undefined name 'foo'")
cli = type("CLI", (), {})()
_attach(cli, fm)
ce.set_cli(cli)
py_file = tmp_path / "mod.py"
py_file.write_text("foo = bar\n", encoding="utf-8")
out = ce._auto_lsp_check(str(py_file))
assert str(py_file) in fm._calls, "应调用 lsp_manager.diagnostics(该 py 文件)"
assert "LSP 诊断" in out and "2 条诊断" in out
def test_auto_lsp_check_silent_when_clean(tmp_path):
"""文件干净(无诊断)时钩子应静默返回空串。"""
from plugins.code_editor import Liugin as CodeEditor
ce = CodeEditor()
fm = _make_fake_lsp_manager("✅ mod.py:无诊断(未发现问题)")
cli = type("CLI", (), {})()
_attach(cli, fm)
ce.set_cli(cli)
py_file = tmp_path / "clean.py"
py_file.write_text("x = 1\n", encoding="utf-8")
assert ce._auto_lsp_check(str(py_file)) == ""
def test_auto_lsp_check_silent_without_lsp_manager(tmp_path):
"""未连接 LSP 管理器(或未配置 server)时钩子应静默跳过,绝不报错。"""
from plugins.code_editor import Liugin as CodeEditor
ce = CodeEditor()
cli = type("CLI", (), {})()
# liugin_manager 没有 _lsp_manager 属性
cli.liugin_manager = type("UTM", (), {})()
ce.set_cli(cli)
py_file = tmp_path / "any.py"
py_file.write_text("pass\n", encoding="utf-8")
assert ce._auto_lsp_check(str(py_file)) == ""
def test_auto_lsp_check_no_server_for_ext(tmp_path):
"""扩展名未配置 LSP server 时(如 .txt)应静默返回空串。"""
from plugins.code_editor import Liugin as CodeEditor
ce = CodeEditor()
fm = _make_fake_lsp_manager("⚠️ 没有为 x.txt 配置 LSP server")
# clients 里只有 python,没有 txt 对应的 server;这里用文本模拟「无 server」返回
cli = type("CLI", (), {})()
_attach(cli, fm)
ce.set_cli(cli)
txt_file = tmp_path / "note.txt"
txt_file.write_text("hello\n", encoding="utf-8")
assert ce._auto_lsp_check(str(txt_file)) == ""
+66
View File
@@ -0,0 +1,66 @@
"""max_input_tokens 统一 API + token 感知压缩 回归测试
验证:
1. 所有引擎(openai / ollama / manual)都暴露 max_input_tokens 且返回正整数(API 契约)。
2. 系统依据 max_input_tokens 的 70% 自动判定压缩(should_compress)。
"""
import os
import sys
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
def _engine_classes():
from ai_engines.openai_engine import OpenaiAI
from ai_engines.ollama_engine import OllamaAI
from ai_engines.manual_engine import ManualAI
return {"openai": OpenaiAI, "ollama": OllamaAI, "manual": ManualAI}
def test_all_engines_expose_max_input_tokens():
"""所有引擎必须返回 max_input_tokens(统一 API 契约)"""
for name, cls in _engine_classes().items():
assert hasattr(cls, "max_input_tokens"), f"{name} 引擎缺少 max_input_tokens 属性"
def test_max_input_tokens_positive_when_instantiated():
"""实例化后 max_input_tokens 应为正整数"""
for name, cls in _engine_classes().items():
try:
inst = cls()
except Exception as e:
pytest.skip(f"{name} 引擎实例化失败(环境/网络),跳过实例检查: {e}")
val = inst.max_input_tokens
assert isinstance(val, int) and val > 0, f"{name} 的 max_input_tokens 非法: {val!r}"
def _mgr():
from xcli_core.memory import MemoryManager
return MemoryManager(project_dir=PROJECT_ROOT)
def _conv(n):
return [{"role": "user", "content": f"msg {i}"} for i in range(n)]
def test_should_compress_token_aware_triggers_at_70pct():
mgr = _mgr()
# 7000 / 10000 = 0.7 >= 0.7 → 触发
assert mgr.should_compress(_conv(5), prompt_tokens=7000, max_input_tokens=10000, ratio=0.7) is True
def test_should_compress_token_aware_below_threshold():
mgr = _mgr()
# 6000 / 10000 = 0.6 < 0.7 → 不触发(消息数也未超阈值)
assert mgr.should_compress(_conv(5), prompt_tokens=6000, max_input_tokens=10000, ratio=0.7) is False
def test_should_compress_message_count_fallback():
mgr = _mgr()
# 无 token 信息,仅消息数 > 50 → 兜底触发
assert mgr.should_compress(_conv(60)) is True
assert mgr.should_compress(_conv(10)) is False
+193
View File
@@ -0,0 +1,193 @@
"""
MCP 客户端测试
==============
用 inline 的 fake MCP server(写入临时文件,subprocess 启动)验证:
1. McpClient 全链路:start → initialize → list_tools → call_tool → stop(进程回收)
2. McpClientManager 多 server 并发连接、聚合、调用、cleanup 幂等
3. 超时:沉默 server 不响应 → call_tool 应在 timeout 内抛错
4. UnifiedToolManager 桥接:外部 MCP 工具按 {server}__{tool} 注册、schema 保真、可被 execute 调用、shutdown 清理子进程
5. fc_tools.mcp_to_openai_tools 优先走 get_mcp_tools 保真分支(不把 schema 压成 args)
全程不依赖真实 AI 引擎、网络或外部 npx。
"""
import os
import sys
import pytest
# 确保项目根在 sys.path(unified_tool_manager / mcp_client / config 均在根目录)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from mcp_client import McpClient, McpClientManager, McpClientError # noqa: E402
import unified_tool_manager as utm # noqa: E402
from xcli_core.fc_tools import mcp_to_openai_tools # noqa: E402
# fake MCP server:实现 initialize / tools/list / tools/call / ping,
# 以及一个 echo 工具(inputSchema 保真)。与 mcp_server.py 协议对称。
FAKE_SERVER_SRC = r'''
import sys, json
def send(obj):
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
sys.stdout.flush()
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except Exception:
continue
method = req.get("method", "")
mid = req.get("id")
if method == "notifications/initialized":
continue
if method == "initialize":
send({"jsonrpc": "2.0", "id": mid, "result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "fake", "version": "1.0"},
}})
elif method == "tools/list":
send({"jsonrpc": "2.0", "id": mid, "result": {"tools": [{
"name": "echo",
"description": "回声工具",
"inputSchema": {
"type": "object",
"properties": {"text": {"type": "string", "description": "要回声的文本"}},
"required": ["text"],
},
}]}})
elif method == "tools/call":
params = req.get("params", {})
name = params.get("name")
args = params.get("arguments", {})
if name == "echo":
send({"jsonrpc": "2.0", "id": mid, "result": {
"content": [{"type": "text", "text": "echo: " + str(args.get("text", ""))}],
}})
else:
send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": "unknown tool"}})
elif method == "ping":
send({"jsonrpc": "2.0", "id": mid, "result": {}})
else:
send({"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": "method not found"}})
if __name__ == "__main__":
main()
'''
@pytest.fixture
def fake_server_path(tmp_path):
p = tmp_path / "fake_mcp_server.py"
p.write_text(FAKE_SERVER_SRC)
return str(p)
def test_mcp_client_lifecycle(fake_server_path):
client = McpClient("fake", sys.executable, [fake_server_path])
try:
client.start()
info = client.initialize()
assert info.get("protocolVersion") == "2024-11-05"
tools = client.list_tools()
assert any(t.get("name") == "echo" for t in tools)
out = client.call_tool("echo", {"text": "hi"})
assert "hi" in out
finally:
client.stop()
# 进程应已被回收
assert client.proc.poll() is not None
def test_manager_multi_server(fake_server_path):
mgr = McpClientManager()
mgr.connect_all({
"a": {"command": sys.executable, "args": [fake_server_path]},
"b": {"command": sys.executable, "args": [fake_server_path]},
})
assert set(mgr.get_servers()) == {"a", "b"}
assert len(mgr.get_all_tools()) == 2
out = mgr.call_tool("a", "echo", {"text": "x"})
assert "x" in out
mgr.cleanup()
mgr.cleanup() # 幂等
def test_client_timeout(tmp_path):
silent = tmp_path / "silent.py"
silent.write_text("import time\nwhile True:\n time.sleep(1)\n")
client = McpClient("silent", sys.executable, [str(silent)], timeout=2)
try:
client.start()
with pytest.raises(Exception):
client.list_tools() # 沉默 server 不响应 → 超时
finally:
client.stop()
def test_unified_manager_bridges_external_mcp(tmp_path, monkeypatch):
fake = tmp_path / "fake_mcp_server.py"
fake.write_text(FAKE_SERVER_SRC)
cfg = {"mcpServers": {"fake": {"command": sys.executable, "args": [str(fake)]}}}
import xcli_core.config as xcfg
monkeypatch.setattr(xcfg, "load_config", lambda: cfg)
try:
import config as root_cfg
monkeypatch.setattr(root_cfg, "load_config", lambda: cfg)
except ImportError:
pass
mgr = utm.UnifiedToolManager()
# 用不存在的插件/技能目录,避免加载真实插件
mgr.initialize(
plugins_dir=str(tmp_path / "noplugins"),
skills_dir=str(tmp_path / "noskills"),
)
try:
tool = mgr.get_tool("fake__echo")
assert tool is not None, "外部 MCP 工具应已桥接为 fake__echo"
assert tool["protocol"] == "mcp"
names = [t["name"] for t in mgr.get_mcp_tools()]
assert "fake__echo" in names
echo_def = next(t for t in mgr.get_mcp_tools() if t["name"] == "fake__echo")
assert "text" in echo_def["inputSchema"]["properties"]
res = mgr.execute("fake__echo", '{"text": "hello"}')
assert res.success
assert "hello" in (res.data or "")
finally:
mgr.shutdown()
def test_fc_tools_uses_get_mcp_tools():
class FakeMgr:
def get_mcp_tools(self):
return [{
"name": "my_tool",
"description": "d",
"inputSchema": {
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"],
},
}]
tools = mcp_to_openai_tools(FakeMgr())
my = next(t for t in tools if t["function"]["name"] == "my_tool")
# schema 保真:不被压成 {"args": string}
assert "q" in my["function"]["parameters"]["properties"]
assert "args" not in my["function"]["parameters"]["properties"]
# 内置工具(memory / scheduler)仍被追加
names = [t["function"]["name"] for t in tools]
assert "memory" in names and "scheduler" in names
+188
View File
@@ -0,0 +1,188 @@
"""模型注册表 + /model 命令的回归测试。
不污染真实 config.json:所有读写都指向临时文件。
"""
import os
import json
import tempfile
import pytest
from xcli_core import model_registry as mr
import xcli_core.config as cfgmod
import ai_engines.openai_engine as oe_mod
@pytest.fixture
def tmp_config():
"""准备临时 config.json,并把注册表与 openai 引擎的读取都重定向过去。"""
d = tempfile.mkdtemp()
path = os.path.join(d, "config.json")
payload = {
"api": {"engines": {
"openai": {"api_key": "K0", "base_url": "https://old/v1", "model": "old-model"}
}},
"system": {},
}
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
saved_path = mr._CONFIG_PATH
saved_proj = oe_mod.project_root
mr._set_config_path(path)
oe_mod.project_root = d
try:
yield path
finally:
mr._set_config_path(saved_path)
oe_mod.project_root = saved_proj
# ── 注册表单元 ──
def test_legacy_flat_config_is_single_implicit_model(tmp_config):
models, current = mr.list_models()
assert len(models) == 1
assert models[0]["model"] == "old-model"
assert current == "old-model"
def test_add_first_model_migrates_flat_and_keeps_current(tmp_config):
mr.add_model({"name": "gpt4o", "base_url": "https://api.openai.com/v1",
"api_key": "sk", "model": "gpt-4o"})
models, current = mr.list_models()
names = [m["name"] for m in models]
assert names == ["old-model", "gpt4o"]
# current 不应跳变到新加的模型
assert current == "old-model"
def test_add_duplicate_name_updates_in_place(tmp_config):
mr.add_model({"name": "gpt4o", "base_url": "u1", "api_key": "k1", "model": "gpt-4o"})
mr.add_model({"name": "gpt4o", "base_url": "u2", "api_key": "k2", "model": "gpt-4o-mini"})
models, _ = mr.list_models()
assert len(models) == 2 # 未重复追加
assert models[1]["base_url"] == "u2"
def test_set_current_and_get_model(tmp_config):
mr.add_model({"name": "gpt4o", "base_url": "https://api.openai.com/v1",
"api_key": "sk", "model": "gpt-4o"})
assert mr.set_current("gpt4o") is True
m = mr.get_model()
assert m["model"] == "gpt-4o"
assert mr.set_current("ghost") is False # 越权返回 False
def test_remove_current_falls_back_to_first(tmp_config):
mr.add_model({"name": "gpt4o", "base_url": "u", "api_key": "k", "model": "gpt-4o"})
mr.set_current("gpt4o")
assert mr.remove_model("gpt4o") is True
_, current = mr.list_models()
assert current == "old-model"
def test_remove_missing_returns_false(tmp_config):
assert mr.remove_model("nope") is False
def test_persisted_to_disk(tmp_config):
mr.add_model({"name": "gpt4o", "base_url": "u", "api_key": "k", "model": "gpt-4o"})
mr.set_current("gpt4o")
with open(tmp_config, "r", encoding="utf-8") as f:
saved = json.load(f)
block = saved["api"]["engines"]["openai"]
assert block["current"] == "gpt4o"
assert any(m["name"] == "gpt4o" for m in block["models"])
# ── 引擎接入 ──
def test_engine_init_applies_registry_current(tmp_config):
mr.add_model({"name": "gpt4o", "base_url": "https://api.openai.com/v1",
"api_key": "sk", "model": "gpt-4o"})
mr.set_current("gpt4o")
eng = oe_mod.OpenaiAI()
assert eng.model == "gpt-4o"
assert "api.openai.com" in eng.base_url
assert eng.current_model_name() == "gpt4o"
def test_engine_set_registry_model(tmp_config):
eng = oe_mod.OpenaiAI()
eng.add_model({"name": "gpt4o", "base_url": "https://api.openai.com/v1",
"api_key": "sk", "model": "gpt-4o"})
assert eng.set_registry_model("gpt4o") is True
assert eng.model == "gpt-4o"
assert eng.set_registry_model("ghost") is False
def test_engine_does_not_clobber_existing_set_model(tmp_config):
"""原有轻量 set_model(只改 model 名)必须仍然可用,不被注册表方法覆盖。"""
eng = oe_mod.OpenaiAI()
eng.set_model("gpt-4o-mini") # 原有方法:只改 self.model
assert eng.model == "gpt-4o-mini"
# 注册表方法仍在
assert hasattr(eng, "set_registry_model")
assert hasattr(eng, "list_registry_models")
# ── CLI 命令路由 ──
def _make_fake_cli(eng):
"""复制 cli_base 的模型命令方法到轻量对象,避免实例化整个 CliBase。"""
from xcli_core import cli_base
cls = type("FakeCli", (), {})
cls.handle_model_command = cli_base.BaseAICLI.handle_model_command
cls._model_list = cli_base.BaseAICLI._model_list
cls._model_switch = cli_base.BaseAICLI._model_switch
cls._model_add_interactive = cli_base.BaseAICLI._model_add_interactive
return cls()
def test_cli_model_list_and_switch(tmp_config, monkeypatch):
from xcli_core import cli_base
eng = oe_mod.OpenaiAI()
eng.add_model({"name": "gpt4o", "base_url": "u", "api_key": "k", "model": "gpt-4o"})
cli = _make_fake_cli(eng)
cli.current_engine = eng
cli.current_model = None
# 列出
captured = {}
monkeypatch.setattr("builtins.print", lambda *a, **k: captured.setdefault("out", []).append(a))
cli.handle_model_command("list")
assert any("gpt4o" in str(x) for x in captured["out"])
# 切换
cli.handle_model_command("gpt4o")
assert cli.current_model == "gpt4o"
assert eng.model == "gpt-4o"
def test_cli_model_switch_missing(tmp_config, monkeypatch):
eng = oe_mod.OpenaiAI()
cli = _make_fake_cli(eng)
cli.current_engine = eng
cli.current_model = None
captured = {}
monkeypatch.setattr("builtins.print", lambda *a, **k: captured.setdefault("out", []).append(a))
cli.handle_model_command("ghost")
assert any("未找到" in str(x) for x in captured["out"])
assert cli.current_model is None
def test_cli_model_add_interactive(tmp_config, monkeypatch):
eng = oe_mod.OpenaiAI()
cli = _make_fake_cli(eng)
cli.current_engine = eng
cli.current_model = None
answers = iter(["mygpt", "https://x/v1", "sk-1", "gpt-x"])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(answers))
captured = {}
monkeypatch.setattr("builtins.print", lambda *a, **k: captured.setdefault("out", []).append(a))
cli.handle_model_command("add")
models, current = eng.list_registry_models()
assert any(m["name"] == "mygpt" for m in models)
assert cli.current_model == "mygpt"
+116
View File
@@ -0,0 +1,116 @@
"""声明式权限(allow/ask/deny)回归测试。不触真实引擎/网络。"""
import pytest
from xcli_core.safety import SafetyLayer, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL
def _new_safety(allow=None, ask=None, deny=None, mode=MODE_NORMAL):
s = SafetyLayer()
s.set_permissions(allow=allow, ask=ask, deny=deny)
s.mode = mode
return s
# ── 纯函数:规则匹配 ──
def test_match_wildcard():
s = SafetyLayer()
assert s._permission_match("*", "anything", "rm -rf /") is True
def test_match_exact_tool():
s = SafetyLayer()
assert s._permission_match("git_tools", "git_tools", "status") is True
assert s._permission_match("git_tools", "cmd_executor", "status") is False
def test_match_tool_star():
s = SafetyLayer()
assert s._permission_match("git_tools:*", "git_tools", "push origin") is True
def test_match_tool_op_prefix():
s = SafetyLayer()
assert s._permission_match("cmd_executor:rm", "cmd_executor", "rm -rf /tmp") is True
assert s._permission_match("cmd_executor:rm", "cmd_executor", "ls -la") is False
# 操作词精确相等也应命中
assert s._permission_match("git_tools:push", "git_tools", "push") is True
def test_match_empty_rule_ignored():
s = SafetyLayer()
assert s._permission_match("", "git_tools", "status") is False
# ── deny 最高优先级 ──
def test_deny_blocks():
s = _new_safety(deny=["git_tools:reset"], mode=MODE_UNRESTRICTED)
ok, msg = s.check("git_tools", "reset --hard HEAD")
assert ok is False
assert "deny" in msg
def test_deny_blocks_even_unrestricted():
s = _new_safety(deny=["cmd_executor:rm"], mode=MODE_UNRESTRICTED)
ok, _ = s.check("cmd_executor", "rm -rf /")
assert ok is False
def test_deny_overrides_allow():
# 同一工具同时出现在 deny 与 allow,deny 胜出
s = _new_safety(deny=["git_tools:reset"], allow=["git_tools:reset"], mode=MODE_NORMAL)
ok, _ = s.check("git_tools", "reset --hard")
assert ok is False
# ── ask 强制确认 ──
def test_ask_forces_confirm():
s = _new_safety(ask=["cmd_executor"], mode=MODE_NORMAL)
confirmed = []
s._ask_user_confirm = lambda *a, **k: (confirmed.append(True) or (True, "ok"))
ok, _ = s.check("cmd_executor", "ls")
assert ok is True
assert confirmed == [True]
def test_ask_overrides_allow():
# 同时 allow 与 ask,ask(确认)胜出,不会自动放行
s = _new_safety(allow=["cmd_executor"], ask=["cmd_executor"], mode=MODE_NORMAL)
called = []
s._ask_user_confirm = lambda *a, **k: (called.append(True) or (True, "ok"))
s.check("cmd_executor", "ls")
assert called == [True]
def test_ask_works_in_unrestricted():
s = _new_safety(ask=["git_tools:push"], mode=MODE_UNRESTRICTED)
called = []
s._ask_user_confirm = lambda *a, **k: (called.append(True) or (False, "no"))
ok, _ = s.check("git_tools", "push origin master")
assert ok is False
assert called == [True]
# ── allow 自动放行 ──
def test_allow_auto_passes():
s = _new_safety(allow=["git_tools:status"], mode=MODE_NORMAL)
# 不应触发 AI 分析 / 确认
s._ask_user_confirm = lambda *a, **k: (False, "SHOULD_NOT_BE_CALLED")
ok, msg = s.check("git_tools", "status")
assert ok is True
assert msg == ""
def test_allow_no_match_falls_through():
s = _new_safety(allow=["git_tools:status"], mode=MODE_UNRESTRICTED)
ok, _ = s.check("cmd_executor", "ls")
# 无规则命中 → 走默认(无限制模式直接放行)
assert ok is True
def test_default_without_rules_unrestricted_passes():
s = _new_safety(mode=MODE_UNRESTRICTED)
ok, _ = s.check("file_manager", "read foo.txt")
assert ok is True
+249
View File
@@ -0,0 +1,249 @@
"""Plan 模式(只读规划 → 审批 → 执行)回归测试。
全程不实例化 AICLI(避免加载真实引擎/插件/进程保护/网络),
通过伪造 cli 对象与轻量 SimpleNamespace 验证核心逻辑。
"""
import types
import pytest
from xcli_core.safety import (
get_safety,
plan_is_write_operation,
PLAN_WRITE_RULES,
)
# ── 纯函数:写操作判定 ──
def test_write_rules_known_and_unknown():
# 整类拦截
assert plan_is_write_operation("cmd_executor", "ls -la") is True
assert plan_is_write_operation("browser_auto", "navigate https://x") is True
assert plan_is_write_operation("network_tools", "ping 1.1.1.1") is True
assert plan_is_write_operation("scheduler", "add 1h do") is True
# 整类放行
assert plan_is_write_operation("tool_search", "code_editor") is False
assert plan_is_write_operation("ai_search", "python 教程") is False
assert plan_is_write_operation("code_search", "find . kw") is False
# 未知工具:保守拦截
assert plan_is_write_operation("mystery_tool", "anything") is True
def test_code_editor_readonly_allowed_write_blocked():
assert plan_is_write_operation("code_editor", "read_range a.py 1 10") is False
assert plan_is_write_operation("code_editor", "find . kw") is False
assert plan_is_write_operation("code_editor", "diff a.py") is False
assert plan_is_write_operation("code_editor", "edit a.py old <<<>>> new") is True
assert plan_is_write_operation("code_editor", "write a.py content") is True
assert plan_is_write_operation("code_editor", "append a.py line") is True
assert plan_is_write_operation("code_editor", "multi a.py [...]") is True
assert plan_is_write_operation("code_editor", "insert a.py 3 line") is True
assert plan_is_write_operation("code_editor", "delete_lines a.py 1 2") is True
def test_git_readonly_allowed_write_blocked():
assert plan_is_write_operation("git_tools", "status") is False
assert plan_is_write_operation("git_tools", "log --oneline 10") is False
assert plan_is_write_operation("git_tools", "diff") is False
assert plan_is_write_operation("git_tools", "show HEAD") is False
assert plan_is_write_operation("git_tools", "branch") is False # 仅列出
assert plan_is_write_operation("git_tools", "commit 修复") is True
assert plan_is_write_operation("git_tools", "smart-commit") is True # 后缀命中
assert plan_is_write_operation("git_tools", "push") is True
assert plan_is_write_operation("git_tools", "checkout main") is True
assert plan_is_write_operation("git_tools", "branch -d old") is True
assert plan_is_write_operation("git_tools", "reset --hard") is True
assert plan_is_write_operation("git_tools", "fetch origin") is True
def test_file_manager_sub_agent_auto_engineer():
assert plan_is_write_operation("file_manager", "list .") is False
assert plan_is_write_operation("file_manager", "read a.txt") is False
assert plan_is_write_operation("file_manager", "search kw") is False
assert plan_is_write_operation("file_manager", "write a.txt x") is True
assert plan_is_write_operation("file_manager", "delete a.txt") is True
assert plan_is_write_operation("sub_agent", "list") is False
assert plan_is_write_operation("sub_agent", "status") is False
assert plan_is_write_operation("sub_agent", "result 12") is False
assert plan_is_write_operation("sub_agent", "run 任务") is True
assert plan_is_write_operation("sub_agent", "dispatch 任务") is True
assert plan_is_write_operation("auto_engineer", "lint") is False # 只读
assert plan_is_write_operation("auto_engineer", "metrics") is False
assert plan_is_write_operation("auto_engineer", "test") is True
assert plan_is_write_operation("auto_engineer", "build") is True
# ── safety.check 在 plan 模式下的拦截 ──
@pytest.fixture
def safety_with_fake_cli():
safety = get_safety()
saved_cli = safety.cli
saved_mode = safety.mode
fake_cli = types.SimpleNamespace(plan_mode=True)
safety.set_cli(fake_cli)
yield safety
safety.set_cli(saved_cli)
safety.set_mode(saved_mode)
def test_plan_mode_blocks_write_at_safety_layer(safety_with_fake_cli):
s = safety_with_fake_cli
allowed, msg = s.check("code_editor", "edit a.py x <<<>>> y")
assert allowed is False
assert "PLAN" in msg
allowed, _ = s.check("git_tools", "commit 修复")
assert allowed is False
allowed, _ = s.check("cmd_executor", "rm -rf /")
assert allowed is False
def test_plan_mode_allows_readonly_at_safety_layer(safety_with_fake_cli):
s = safety_with_fake_cli
allowed, _ = s.check("code_editor", "read_range a.py 1 10")
assert allowed is True
allowed, _ = s.check("tool_search", "code_editor")
assert allowed is True
allowed, _ = s.check("git_tools", "status")
assert allowed is True
def test_normal_mode_does_not_plan_block():
"""非 plan 模式:safety.check 不被 plan 逻辑拦截(用无限制模式避免触发 AI 分析)。"""
safety = get_safety()
saved_cli = safety.cli
saved_mode = safety.mode
saved_perm = safety._permissions_override
fake_cli = types.SimpleNamespace(plan_mode=False)
safety.set_cli(fake_cli)
safety.set_mode(0) # MODE_UNRESTRICTED:直接放行,不消耗 AI
safety.set_permissions(allow=[], ask=[], deny=[]) # 隔离真实 config 的权限规则
try:
allowed, _ = safety.check("code_editor", "write a.py x")
assert allowed is True
allowed, _ = safety.check("cmd_executor", "rm -rf /")
assert allowed is True
finally:
safety.set_cli(saved_cli)
safety.set_mode(saved_mode)
safety._permissions_override = saved_perm
# ── system prompt 注入 ──
def test_build_system_prompt_injects_plan():
from xcli_core.cli_core import AICLI, PLAN_MODE_SYSTEM_APPEND
fake = types.SimpleNamespace(
plan_mode=True,
liugin_manager=types.SimpleNamespace(tools=[]),
memory_manager=None,
)
prompt = AICLI._build_system_prompt(fake)
assert "PLAN" in prompt
assert "实施计划" in prompt
assert PLAN_MODE_SYSTEM_APPEND in prompt
def test_build_system_prompt_no_plan_when_off():
from xcli_core.cli_core import AICLI
fake = types.SimpleNamespace(
plan_mode=False,
liugin_manager=types.SimpleNamespace(tools=[]),
memory_manager=None,
)
prompt = AICLI._build_system_prompt(fake)
assert "实施计划" not in prompt
# ── 命令 handler 状态转换 ──
def _fake_for_plan():
calls = {"process": [], "autosave": 0, "sync": 0}
fake = types.SimpleNamespace(
plan_mode=False,
current_plan="",
process_conversation=lambda x: calls["process"].append(x),
_autosave_session=lambda: calls.__setitem__("autosave", calls["autosave"] + 1),
_sync_current_plan=lambda: calls.__setitem__("sync", calls["sync"] + 1),
)
return fake, calls
def test_handle_plan_command_enters_mode_with_task():
from xcli_core.cli_core import AICLI
fake, calls = _fake_for_plan()
AICLI.handle_plan_command(fake, "重构缓存模块")
assert fake.plan_mode is True
assert calls["process"] == ["重构缓存模块"]
assert calls["autosave"] == 1
assert calls["sync"] == 1
def test_handle_plan_command_enters_mode_without_task():
from xcli_core.cli_core import AICLI
fake, calls = _fake_for_plan()
AICLI.handle_plan_command(fake, "")
assert fake.plan_mode is True
assert calls["process"] == [] # 无任务不主动调研
def test_handle_plan_command_off_exits():
from xcli_core.cli_core import AICLI
fake, calls = _fake_for_plan()
fake.plan_mode = True
AICLI.handle_plan_command(fake, "off")
assert fake.plan_mode is False
assert calls["process"] == []
def test_handle_build_command_executes_plan():
from xcli_core.cli_core import AICLI
fake, calls = _fake_for_plan()
fake.plan_mode = True
fake.current_plan = "## 实施计划\n1. 改 a.py\n2. 改 b.py"
AICLI.handle_build_command(fake)
assert fake.plan_mode is False
assert len(calls["process"]) == 1
assert "已批准的实施计划" in calls["process"][0]
assert "## 实施计划" in calls["process"][0]
def test_handle_build_command_no_plan_warns():
from xcli_core.cli_core import AICLI
fake, calls = _fake_for_plan()
fake.plan_mode = False
fake.current_plan = ""
AICLI.handle_build_command(fake)
assert calls["process"] == [] # 无计划不执行
def test_sync_current_plan_captures_last_assistant():
from xcli_core.cli_core import AICLI
fake = types.SimpleNamespace(
plan_mode=True,
current_plan="",
shared_conversation_history=[
{"role": "user", "content": "任务"},
{"role": "assistant", "content": "调研中..."},
{"role": "tool", "content": "结果"},
{"role": "assistant", "content": "## 实施计划\n1. do x"},
],
)
AICLI._sync_current_plan(fake)
assert "实施计划" in fake.current_plan
def test_sync_current_plan_noop_when_not_plan():
from xcli_core.cli_core import AICLI
fake = types.SimpleNamespace(
plan_mode=False,
current_plan="unchanged",
shared_conversation_history=[{"role": "assistant", "content": "x"}],
)
AICLI._sync_current_plan(fake)
assert fake.current_plan == "unchanged"
+111 -88
View File
@@ -29,6 +29,29 @@ def load_module(name, path):
return mod
def plugin_class(mod):
"""取插件入口类(项目里叫 Liugin,历史上叫 Plugin,两种都认)"""
cls = getattr(mod, "Liugin", None) or getattr(mod, "Plugin", None)
assert cls is not None, f"{mod.__name__}: 缺少 Plugin/Liugin 类"
return cls
def as_text(result):
"""插件返回值统一转文本(可能是 str,也可能是 ToolResult)"""
return result if isinstance(result, str) else str(result)
ERROR_MARKERS = ("❌", "错误", "失败", "缺少", "不存在", "无效", "请提供", "用法")
def is_error(result):
"""判断插件返回是否表示失败(不依赖具体文案措辞)"""
success = getattr(result, "success", None)
if success is not None:
return not success
return any(m in as_text(result) for m in ERROR_MARKERS)
# ══════════════════════════════════════
# 插件加载测试
# ══════════════════════════════════════
@@ -89,7 +112,7 @@ class TestCodeEditor:
@pytest.fixture
def editor(self):
mod = load_module("code_editor", os.path.join(PLUGINS_DIR, "code_editor.py"))
return mod.Plugin()
return plugin_class(mod)()
@pytest.fixture
def tmp_file(self, tmp_path):
@@ -99,47 +122,47 @@ class TestCodeEditor:
def test_read_range(self, editor, tmp_file):
result = editor.handle(f"read_range {tmp_file} 1 2")
assert "hello" in result
assert "1 |" in result
assert "hello" in as_text(result)
assert "1 |" in as_text(result)
def test_edit(self, editor, tmp_file):
result = editor.handle(f"edit {tmp_file} print('hello') <<<>>> print('hi')")
assert "已编辑" in result
assert "已编辑" in as_text(result)
content = open(tmp_file).read()
assert "print('hi')" in content
def test_edit_not_found(self, editor, tmp_file):
result = editor.handle(f"edit {tmp_file} nonexistent_code <<<>>> new_code")
assert "错误" in result
assert is_error(result)
def test_create(self, editor, tmp_path):
new_file = str(tmp_path / "new.py")
result = editor.handle(f"create {new_file} import os")
assert "已创建" in result
assert "已创建" in as_text(result)
assert os.path.exists(new_file)
def test_diff_text(self, editor):
result = editor.handle("diff_text old <<<>>> new")
assert "变更预览" in result
assert "变更预览" in as_text(result)
def test_ast_info(self, editor, tmp_file):
result = editor.handle(f"ast_info {tmp_file}")
assert "def hello" in result
assert "def world" in result
assert "def hello" in as_text(result)
assert "def world" in as_text(result)
def test_stats(self, editor, tmp_path):
result = editor.handle(f"stats {tmp_path}")
assert "代码统计" in result
assert "文件" in result
assert "代码统计" in as_text(result)
assert "文件" in as_text(result)
def test_structure(self, editor, tmp_path):
result = editor.handle(f"structure {tmp_path} 1")
assert "" in result
assert "" in as_text(result)
def test_find(self, editor, tmp_path):
(tmp_path / "a.py").write_text("x = 1\ny = 2\n")
result = editor.handle(f"find {tmp_path} x *.py")
assert "x = 1" in result
assert "x = 1" in as_text(result)
class TestGitTools:
@@ -148,11 +171,11 @@ class TestGitTools:
@pytest.fixture
def git(self):
mod = load_module("git_tools", os.path.join(PLUGINS_DIR, "git_tools.py"))
return mod.Plugin()
return plugin_class(mod)()
def test_status(self, git):
result = git.handle("status")
assert "Git" in result or "干净" in result or "分支" in result
assert "Git" in as_text(result) or "干净" in as_text(result) or "分支" in as_text(result)
def test_log(self, git):
result = git.handle("log --oneline 3")
@@ -161,7 +184,7 @@ class TestGitTools:
def test_branch(self, git):
result = git.handle("branch")
assert "master" in result or "main" in result
assert "master" in as_text(result) or "main" in as_text(result)
class TestCmdExecutor:
@@ -170,27 +193,27 @@ class TestCmdExecutor:
@pytest.fixture
def cmd(self):
mod = load_module("cmd_executor", os.path.join(PLUGINS_DIR, "cmd_executor.py"))
return mod.Plugin()
return plugin_class(mod)()
def test_echo(self, cmd):
result = cmd.handle("run echo test_ok")
assert "test_ok" in result
assert "test_ok" in as_text(result)
def test_python_version(self, cmd):
result = cmd.handle("run python3 --version")
assert "Python" in result
assert "Python" in as_text(result)
def test_blocked_command(self, cmd):
result = cmd.handle("run rm -rf /")
assert "拦截" in result
assert "拦截" in as_text(result)
def test_timeout(self, cmd):
result = cmd.handle("run echo fast --timeout 5")
assert "fast" in result
assert "fast" in as_text(result)
def test_empty_command(self, cmd):
result = cmd.handle("run")
assert "错误" in result
assert is_error(result)
class TestFileManager:
@@ -199,53 +222,53 @@ class TestFileManager:
@pytest.fixture
def fm(self):
mod = load_module("file_manager", os.path.join(PLUGINS_DIR, "file_manager.py"))
return mod.Plugin()
return plugin_class(mod)()
def test_list(self, fm, tmp_path):
(tmp_path / "a.txt").write_text("hi")
result = fm.handle(f"list {tmp_path}")
assert "a.txt" in result
assert "a.txt" in as_text(result)
def test_read(self, fm, tmp_path):
f = tmp_path / "test.txt"
f.write_text("hello world")
result = fm.handle(f"read {f}")
assert "hello world" in result
assert "hello world" in as_text(result)
def test_write_and_read(self, fm, tmp_path):
f = tmp_path / "new.txt"
result = fm.handle(f"write {f} -c test_content")
assert "已写入" in result
assert "已写入" in as_text(result)
result = fm.handle(f"read {f}")
assert "test_content" in result
assert "test_content" in as_text(result)
def test_copy(self, fm, tmp_path):
src = tmp_path / "src.txt"
src.write_text("data")
dst = tmp_path / "dst.txt"
result = fm.handle(f"copy {src} {dst}")
assert "已复制" in result
assert "已复制" in as_text(result)
assert dst.exists()
def test_delete(self, fm, tmp_path):
f = tmp_path / "del.txt"
f.write_text("bye")
result = fm.handle(f"delete {f}")
assert "已删除" in result
assert "已删除" in as_text(result)
assert not f.exists()
def test_mkdir(self, fm, tmp_path):
d = tmp_path / "newdir"
result = fm.handle(f"mkdir {d}")
assert "已创建" in result
assert "已创建" in as_text(result)
assert d.is_dir()
def test_info(self, fm, tmp_path):
f = tmp_path / "info.txt"
f.write_text("x")
result = fm.handle(f"info {f}")
assert "路径" in result
assert "大小" in result
assert "路径" in as_text(result)
assert "大小" in as_text(result)
class TestAutoEngineer:
@@ -254,23 +277,23 @@ class TestAutoEngineer:
@pytest.fixture
def eng(self):
mod = load_module("auto_engineer", os.path.join(PLUGINS_DIR, "auto_engineer.py"))
return mod.Plugin()
return plugin_class(mod)()
def test_metrics(self, eng, tmp_path):
(tmp_path / "a.py").write_text("x = 1\n# comment\n\ny = 2\n")
result = eng.handle(f"metrics {tmp_path}")
assert "代码指标" in result
assert "文件" in result
assert "代码指标" in as_text(result)
assert "文件" in as_text(result)
def test_security_clean(self, eng, tmp_path):
(tmp_path / "safe.py").write_text("x = 1\nprint(x)\n")
result = eng.handle(f"security {tmp_path}")
assert "没有发现" in result or "风险" in result
assert "没有发现" in as_text(result) or "风险" in as_text(result)
def test_security_risk(self, eng, tmp_path):
(tmp_path / "risk.py").write_text('password = "secret123"\neval("1+1")\n')
result = eng.handle(f"security {tmp_path}")
assert "风险" in result
assert "风险" in as_text(result)
def test_complexity(self, eng, tmp_path):
code = "def f():\n if True:\n if True:\n if True:\n pass\n"
@@ -292,34 +315,34 @@ class TestTaskManager:
def test_add(self, tm):
result = tm.handle("add 测试任务")
assert "已添加" in result
assert "已添加" in as_text(result)
def test_list_empty(self, tm):
result = tm.handle("list")
assert "没有任务" in result
assert "没有任务" in as_text(result)
def test_add_and_list(self, tm):
tm.handle("add 任务A")
tm.handle("add 任务B")
result = tm.handle("list")
assert "任务A" in result
assert "任务B" in result
assert "任务A" in as_text(result)
assert "任务B" in as_text(result)
def test_complete(self, tm):
tm.handle("add 任务X")
result = tm.handle("complete 1")
assert "已更新" in result
assert "已更新" in as_text(result)
def test_delete(self, tm):
tm.handle("add 任务Y")
result = tm.handle("delete 1")
assert "已删除" in result
assert "已删除" in as_text(result)
def test_clear(self, tm):
tm.handle("add A")
tm.handle("complete 1")
result = tm.handle("clear")
assert "已清除" in result
assert "已清除" in as_text(result)
# ══════════════════════════════════════
@@ -332,19 +355,19 @@ class TestBrowserAuto:
@pytest.fixture
def browser(self):
mod = load_module("browser_auto", os.path.join(PLUGINS_DIR, "browser_auto.py"))
return mod.Plugin()
return plugin_class(mod)()
def test_status_stopped(self, browser):
result = browser.handle("status")
assert "未运行" in result
assert "未运行" in as_text(result)
def test_invalid_operation(self, browser):
result = browser.handle("nonexistent")
assert "错误" in result
assert is_error(result)
def test_empty_args(self, browser):
result = browser.handle("")
assert "错误" in result
assert is_error(result)
def test_tool_info(self, browser):
info = browser.get_tool_info()
@@ -363,7 +386,7 @@ class TestGuiAuto:
@pytest.fixture
def gui(self):
mod = load_module("gui_auto", os.path.join(PLUGINS_DIR, "gui_auto.py"))
return mod.Plugin()
return plugin_class(mod)()
def test_tool_info(self, gui):
info = gui.get_tool_info()
@@ -380,88 +403,88 @@ class TestGuiAuto:
def test_convert_mcp_args(self, gui):
args = {"operation": "click", "target": "确定"}
result = gui.convert_mcp_args(args)
assert "click" in result
assert "确定" in result
assert "click" in as_text(result)
assert "确定" in as_text(result)
def test_convert_mcp_args_xy(self, gui):
args = {"operation": "click", "x": 100, "y": 200}
result = gui.convert_mcp_args(args)
assert "--xy" in result
assert "100" in result
assert "--xy" in as_text(result)
assert "100" in as_text(result)
def test_convert_mcp_args_snapshot(self, gui):
args = {"operation": "snapshot", "depth": 3}
result = gui.convert_mcp_args(args)
assert "snapshot" in result
assert "3" in result
assert "snapshot" in as_text(result)
assert "3" in as_text(result)
def test_empty_args(self, gui):
result = gui.handle("")
assert "错误" in result
assert is_error(result)
def test_invalid_operation(self, gui):
result = gui.handle("nonexistent_op")
assert "不支持" in result
assert "不支持" in as_text(result)
def test_click_no_args(self, gui):
result = gui.handle("click")
assert "错误" in result
assert is_error(result)
def test_type_no_args(self, gui):
result = gui.handle("type")
assert "错误" in result
assert is_error(result)
def test_type_missing_text(self, gui):
result = gui.handle("type 按钮")
assert "错误" in result
assert is_error(result)
def test_find_no_args(self, gui):
result = gui.handle("find")
assert "错误" in result
assert is_error(result)
def test_findall_no_args(self, gui):
result = gui.handle("findall")
assert "错误" in result
assert is_error(result)
def test_info_no_args(self, gui):
result = gui.handle("info")
assert "错误" in result
assert is_error(result)
def test_value_no_args(self, gui):
result = gui.handle("value")
assert "错误" in result
assert is_error(result)
def test_tree_no_args(self, gui):
result = gui.handle("tree")
assert "错误" in result
assert is_error(result)
def test_wait_no_args(self, gui):
result = gui.handle("wait")
assert "错误" in result
assert is_error(result)
def test_exists_no_args(self, gui):
result = gui.handle("exists")
assert "错误" in result
assert is_error(result)
def test_highlight_no_args(self, gui):
result = gui.handle("highlight")
assert "错误" in result
assert is_error(result)
def test_state_no_args(self, gui):
result = gui.handle("state")
assert "错误" in result
assert is_error(result)
def test_keys_no_args(self, gui):
result = gui.handle("keys")
assert "错误" in result
assert is_error(result)
def test_focus_no_args(self, gui):
result = gui.handle("focus")
assert "错误" in result
assert is_error(result)
def test_click_xy_format_error(self, gui):
result = gui.handle("click --xy abc")
assert "错误" in result
assert is_error(result)
def test_format_element_info(self, gui):
el = {
@@ -472,9 +495,9 @@ class TestGuiAuto:
"enabled": True, "focused": False
}
result = gui._format_element_info(el)
assert "测试按钮" in result
assert "Button" in result
assert "btn_ok" in result
assert "测试按钮" in as_text(result)
assert "Button" in as_text(result)
assert "btn_ok" in as_text(result)
def test_format_element_info_detailed(self, gui):
el = {
@@ -485,8 +508,8 @@ class TestGuiAuto:
"right": 200, "bottom": 25}
}
result = gui._format_element_info(el, detailed=True)
assert "输入框" in result
assert "hello" in result
assert "输入框" in as_text(result)
assert "hello" in as_text(result)
def test_all_operations_route(self, gui):
"""所有操作都能正确路由"""
@@ -504,28 +527,28 @@ class TestSubAgent:
@pytest.fixture
def sa(self):
mod = load_module("sub_agent", os.path.join(PLUGINS_DIR, "sub_agent.py"))
return mod.Plugin()
return plugin_class(mod)()
def test_list_empty(self, sa):
result = sa.handle("list")
assert "没有" in result
assert "没有" in as_text(result)
def test_stats_empty(self, sa):
result = sa.handle("stats")
assert "总数: 0" in result
assert "总数: 0" in as_text(result)
def test_spawn(self, sa):
result = sa.handle("spawn tester 测试任务")
assert "已创建" in result
assert "agent_tester" in result
assert "已创建" in as_text(result)
assert "agent_tester" in as_text(result)
def test_spawn_and_list(self, sa):
sa.handle("spawn a 任务A")
sa.handle("spawn b 任务B")
import time; time.sleep(0.1)
result = sa.handle("list")
assert "agent_a" in result
assert "agent_b" in result
assert "agent_a" in as_text(result)
assert "agent_b" in as_text(result)
def test_status(self, sa):
sa.handle("spawn x 某任务")
@@ -533,24 +556,24 @@ class TestSubAgent:
# 获取 agent ID
agent_id = list(sa.agents.keys())[0]
result = sa.handle(f"status {agent_id}")
assert "名称" in result
assert "名称" in as_text(result)
def test_kill(self, sa):
sa.handle("spawn y 要终止的任务")
import time; time.sleep(0.1)
agent_id = list(sa.agents.keys())[0]
result = sa.handle(f"kill {agent_id}")
assert "已终止" in result
assert "已终止" in as_text(result)
def test_clean(self, sa):
sa.handle("spawn z 清理测试")
import time; time.sleep(0.1)
result = sa.handle("clean")
assert "已清理" in result
assert "已清理" in as_text(result)
def test_invalid_agent(self, sa):
result = sa.handle("status nonexistent")
assert "未找到" in result
assert "未找到" in as_text(result)
# ══════════════════════════════════════
+135
View File
@@ -0,0 +1,135 @@
"""Provider 抽象层测试:预设、resolve、消息/工具转换、汇总。不触网、不读真实 config。"""
import os
import sys
import types
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xcli_core.provider_router import (
PROVIDER_PRESETS, list_providers, resolve_provider,
convert_messages, convert_tools, provider_summary,
)
def test_presets_exist():
for name in ["openai", "anthropic", "google", "deepseek", "xunfei", "ollama"]:
assert name in PROVIDER_PRESETS
assert "openai" in list_providers()
def test_resolve_default_no_key():
r = resolve_provider("openai")
assert r["name"] == "openai"
assert r["base_url"] == "https://api.openai.com/v1"
assert r["protocol"] == "openai"
# 无环境变量无配置 → api_key 空
assert r["api_key"] == ""
def test_resolve_from_env(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-123")
r = resolve_provider("openai")
assert r["api_key"] == "sk-test-123"
def test_resolve_from_cfg_overrides_env(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-env")
r = resolve_provider("openai", {"openai": {"api_key": "sk-cfg", "model": "gpt-4o-mini"}})
assert r["api_key"] == "sk-cfg"
assert r["model"] == "gpt-4o-mini"
def test_resolve_anthropic_headers():
r = resolve_provider("anthropic", {"anthropic": {"api_key": "ak"}})
assert r["protocol"] == "anthropic"
assert r["headers"].get("anthropic-version") == "2023-06-01"
def test_resolve_google_query_header():
r = resolve_provider("google", {"google": {"api_key": "gk"}})
assert r["headers"].get("x-goog-api-key") == "gk"
def test_resolve_unknown():
assert resolve_provider("not-a-provider") == {}
# ── 消息转换 ──
OPENAI_MSGS = [
{"role": "system", "content": "你是助手"},
{"role": "user", "content": "你好"},
{"role": "assistant", "content": "在的", "tool_calls": [{"id": "c1"}]},
{"role": "tool", "content": "结果", "name": "calc"},
]
def test_convert_openai_keeps():
out = convert_messages("openai", OPENAI_MSGS)
assert out[0]["role"] == "system"
assert out[1]["content"] == "你好"
assert out[2]["tool_calls"] == [{"id": "c1"}]
def test_convert_anthropic_strips_system_blocks():
out = convert_messages("anthropic", OPENAI_MSGS)
roles = [m["role"] for m in out]
assert "system" not in roles
assert out[0]["content"][0]["type"] == "text"
assert out[-1]["role"] == "user" # tool 角色映射到 user
def test_convert_google_uses_parts():
out = convert_messages("google", OPENAI_MSGS)
roles = [m["role"] for m in out]
assert "system" not in roles
assert out[0]["parts"][0]["text"] == "你好"
assert out[-1]["role"] == "user"
def test_convert_multimodal_content_blocks():
msgs = [{"role": "user", "content": [
{"type": "text", "text": "看图"},
{"type": "image", "image_url": {"url": "x"}},
]}]
oai = convert_messages("openai", msgs)
assert oai[0]["content"] == "看图" # 多模态 → 文本拼接
ant = convert_messages("anthropic", msgs)
assert ant[0]["content"][0]["type"] == "text"
assert ant[0]["content"][1]["type"] == "image"
# ── 工具转换 ──
TOOLS = [{"type": "function", "function": {
"name": "search", "description": "搜索", "parameters": {"type": "object", "properties": {}}}}]
def test_convert_tools_openai_passthrough():
assert convert_tools("openai", TOOLS) == TOOLS
def test_convert_tools_anthropic_schema():
out = convert_tools("anthropic", TOOLS)
assert out[0]["name"] == "search"
assert out[0]["input_schema"] == {"type": "object", "properties": {}}
def test_convert_tools_google_declarations():
out = convert_tools("google", TOOLS)
assert "functionDeclarations" in out[0]
assert out[0]["functionDeclarations"][0]["name"] == "search"
# ── 汇总 ──
def test_provider_summary_unconfigured():
s = provider_summary("anthropic")
assert s["name"] == "anthropic"
assert s["configured"] is False
assert s["protocol"] == "anthropic"
def test_provider_summary_configured(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_BASE", "ignored")
s = provider_summary("anthropic", {"anthropic": {"api_key": "x"}})
assert s["configured"] is True
+115
View File
@@ -0,0 +1,115 @@
"""
测试:FC 工具去重 + 按场景动态裁剪(方案 A+B)。
不依赖真实插件/引擎加载,用构造的 fake 数据验证:
- mcp_to_openai_tools 按 name 去重(消除 cache + builtin 叠加重复)
- prune_tools 分层裁剪:纯聊天极简、代码信号追加 lsp__*、外部 mcp 默认保留
"""
import pytest
from xcli_core.fc_tools import mcp_to_openai_tools, prune_tools
def _fc(name, desc="d"):
return {
"type": "function",
"function": {
"name": name,
"description": desc,
"parameters": {"type": "object", "properties": {}, "required": []},
},
}
def _mcp(name, desc="d"):
"""MCP 格式(get_mcp_tools 返回的就是这种)"""
return {
"name": name,
"description": desc,
"inputSchema": {"type": "object", "properties": {}, "required": []},
}
class _FakePM:
"""最小 plugin_manager:get_mcp_tools 返回预置列表。"""
def __init__(self, tools):
self._tools = tools
def get_mcp_tools(self):
return self._tools
# ───────────── 去重 ─────────────
def test_mcp_to_openai_tools_dedup():
# 模拟真实场景:cache 里已有 memory/scheduler,_get_builtin_tools 又追加一遍
pm = _FakePM([
_mcp("a"), _mcp("a"), # 同名重复
_mcp("b"),
_mcp("memory"), _mcp("scheduler"), # cache 已有
])
out = mcp_to_openai_tools(pm)
names = [t["function"]["name"] for t in out]
assert names.count("a") == 1, "同名工具应去重为 1"
assert names.count("memory") == 1, "builtin 与 cache 重复应去重"
assert names.count("scheduler") == 1
assert "b" in names
# builtin 追加的 memory/scheduler 不应再产生第二份
assert names.count("memory") == 1
# ───────────── 分层裁剪 ─────────────
_CORE = ["tool_search", "memory", "scheduler", "code_editor", "code_search",
"file_manager", "lsp__diagnostics", "lsp__hover", "lsp__definition",
"lsp__references", "lsp__completion", "browser_auto", "network_tools",
"ai_search", "git_tools", "cmd_executor", "sub_agent", "auto_engineer"]
def _full_set():
return [_fc(n) for n in _CORE]
def test_prune_pure_chat_keeps_only_always():
out = prune_tools(_full_set(), "你好啊,今天天气真不错")
names = {t["function"]["name"] for t in out}
assert names == {"tool_search", "memory", "scheduler"}, \
f"纯聊天应只留常驻工具,实际: {names}"
def test_prune_code_signal_adds_lsp():
out = prune_tools(_full_set(), "帮我写一个 python 脚本读取文件")
names = {t["function"]["name"] for t in out}
assert "code_editor" in names
assert "file_manager" in names
assert "lsp__diagnostics" in names, "写代码应追加 pylsp 的 lsp__* 工具"
assert "lsp__completion" in names
assert "tool_search" in names
def test_prune_git_signal():
out = prune_tools(_full_set(), "提交代码并查看 git 状态")
names = {t["function"]["name"] for t in out}
assert "git_tools" in names
assert "tool_search" in names
def test_prune_external_mcp_kept_even_in_chat():
tools = [_fc("my_mcp__search"), _fc("code_editor"), _fc("tool_search")]
out = prune_tools(tools, "你好")
names = {t["function"]["name"] for t in out}
assert "my_mcp__search" in names, "外部桥接工具(mcp__)应默认保留"
assert "code_editor" not in names, "未命中信号的低频工具应被裁剪"
assert "tool_search" in names
def test_prune_empty_input_no_crash():
out = prune_tools(_full_set(), "")
names = {t["function"]["name"] for t in out}
assert names == {"tool_search", "memory", "scheduler"}
def test_prune_none_tools_returns_none():
assert prune_tools(None, "任意输入") is None
assert prune_tools([], "任意输入") == []
+145
View File
@@ -0,0 +1,145 @@
"""
纯 JSON 工具调用模式 测试
取代 FC:引擎不再收到 tools schema,模型按系统提示里的
{"action":"use_tool","tool":名,"args":参数} 文本输出,由 _parse_mixed_response
解析、按名分发。重点验证:
1. _loads_json / _parse_mixed_response 对 Windows 反斜杠路径健壮
2. 引擎在 tools=None 时返回纯文本(不产 _fc / tool_calls)
3. 端到端:真实 AICLI + FakeEngine,use_tool 被按名分发执行,且无 FC schema 下发
"""
import os
import sys
import json
import tempfile
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
# ════════════════════════════════════════════════
# 1. 解析器:Windows 反斜杠路径容错
# ════════════════════════════════════════════════
@pytest.fixture
def cli():
from xcli_core.cli_core import AICLI
return AICLI()
class TestParseRobustness:
def test_loads_json_tolerates_backslash_path(self, cli):
# 模型常把 Windows 路径写成单反斜杠 → 非法 JSON 转义
raw = r'{"action": "use_tool", "tool": "code_editor", "args": "create C:\Users\me\demo.py print(1)"}'
data = cli._loads_json(raw)
assert data["action"] == "use_tool"
assert data["tool"] == "code_editor"
# 修复后应是合法的单反斜杠路径
assert data["args"] == r"create C:\Users\me\demo.py print(1)"
def test_parse_mixed_extracts_use_tool_with_backslash(self, cli):
resp = (
'好的,我来创建文件。\n'
r'{"action": "use_tool", "tool": "code_editor", "args": "create C:\Users\me\demo.py print(1)"}'
)
text, data = cli._parse_mixed_response(resp)
assert isinstance(data, dict)
assert data["action"] == "use_tool"
assert data["tool"] == "code_editor"
assert r"C:\Users\me\demo.py" in data["args"]
def test_parse_mixed_plain_text_no_action(self, cli):
text, data = cli._parse_mixed_response("今天天气不错")
assert data is None
assert "今天天气不错" in text
# ════════════════════════════════════════════════
# 2. 引擎:tools=None 返回纯文本,不产 tool_calls
# ════════════════════════════════════════════════
class TestEngineToolsNone:
def test_openai_engine_no_tools_sent(self, monkeypatch):
import types
import ai_engines.openai_engine as oe
calls = []
def fake_post(url=None, json=None, headers=None, timeout=None, **kw):
calls.append(dict(json))
return types.SimpleNamespace(
status_code=200,
json=lambda: {"choices": [{"message": {"content": "好"}}],
"usage": {"prompt_tokens": 5}},
text="{}",
)
monkeypatch.setattr(oe, "requests",
types.SimpleNamespace(post=fake_post, exceptions=oe.requests.exceptions))
eng = oe.OpenaiAI.__new__(oe.OpenaiAI)
eng.name = "openai"
eng.api_key = "k"
eng.base_url = "https://example.com/v1"
eng.model = "gpt-4o"
eng.max_history = 10
eng.conversation_history = []
eng.shared_conversation_history = None
eng.last_prompt_tokens = None
eng.thinking_start_marker = "<think>"
eng.thinking_end_marker = "</think>"
reply = eng.generate_response("hi", tools=None)
assert reply == "好"
assert "tools" not in calls[0], "tools=None 时不应下发 FC schema"
assert "tool_choice" not in calls[0]
# ════════════════════════════════════════════════
# 3. 端到端:真实 AICLI + FakeEngine,use_tool 按名分发
# ════════════════════════════════════════════════
class FakeEngine:
def __init__(self):
self.calls = 0
self.tools_seen = []
@property
def name(self):
return "fake"
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
self.calls += 1
self.tools_seen.append(tools)
assert tools is None, f"纯 JSON 模式不应下发 FC tools,实际 {tools!r}"
if self.calls == 1:
return ('好的,我来创建文件。\n'
'{"action": "use_tool", "tool": "code_editor", '
'"args": "create %s print(1)"}' % self._fp)
return "已完成。"
class TestEndToEndDispatch:
def test_use_tool_dispatched_without_fc(self):
from xcli_core.cli_core import AICLI
from xcli_core.safety import get_safety, MODE_UNRESTRICTED
cli = AICLI()
engine = FakeEngine()
cli.current_engine = engine
cli.tui_output_callback = lambda *a, **k: None # 同步 TUI 分支,免动画线程
get_safety().set_mode(MODE_UNRESTRICTED) # 跳过交互确认,验证分发本身
d = tempfile.mkdtemp(prefix="pj_")
FakeEngine._fp = os.path.join(d, "demo.py") # 原始反斜杠路径
fp = FakeEngine._fp
cli.process_conversation("帮我创建一个 python 文件")
assert all(t is None for t in engine.tools_seen), "引擎不应收到任何 FC tools"
assert engine.calls >= 2, "至少应有:首轮JSON + 工具结果续轮"
assert os.path.exists(fp), "code_editor 应被按名分发执行并创建文件"
assert "print(1)" in open(fp, encoding="utf-8").read()
+647
View File
@@ -0,0 +1,647 @@
"""py_detect 本地智能检测插件测试"""
import importlib.util
import os
import sys
import pytest
# 项目根目录
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PLUGIN_PATH = os.path.join(PROJECT_ROOT, "plugins", "py_detect.py")
def load_module(name, path):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def pd():
mod = load_module("py_detect", PLUGIN_PATH)
return mod
def codes(issues):
"""提取诊断规则码集合"""
return {d["code"] for d in issues}
def code_lines(issues, code):
"""按规则码提取行号集合"""
return {d["line"] for d in issues if d["code"] == code}
# ── 语法层 ──
class TestSyntax:
def test_syntax_error(self, pd):
issues = pd.analyze("def f(:\n pass\n", "bad.py")
assert "E001" in codes(issues)
e = [d for d in issues if d["code"] == "E001"][0]
assert e["line"] >= 1
def test_unclosed_paren(self, pd):
issues = pd.analyze("x = (1 + 2\n", "bad.py")
assert "E001" in codes(issues)
# ── 语义层 ──
class TestSemantic:
def test_undefined_variable(self, pd):
src = "def f():\n return y\n"
assert 2 in code_lines(pd.analyze(src, "t.py"), "E002")
def test_no_undefined_for_local_and_global(self, pd):
src = "x = 1\ndef f(a):\n b = a + x\n return b\nprint(f(1))\n"
assert "E002" not in codes(pd.analyze(src, "t.py"))
def test_undefined_recursion_ok(self, pd):
src = "def f(n):\n if n <= 1:\n return 1\n return n * f(n - 1)\n"
assert "E002" not in codes(pd.analyze(src, "t.py"))
def test_comprehension_and_walrus_ok(self, pd):
src = "nums = [1, 2, 3]\nsq = [x * x for x in nums]\nif (n := len(nums)) > 2:\n print(n)\n"
assert "E002" not in codes(pd.analyze(src, "t.py"))
def test_closure_reads_global_ok(self, pd):
src = "counter = 0\ndef inc():\n return counter + 1\n"
assert "E002" not in codes(pd.analyze(src, "t.py"))
def test_dup_function(self, pd):
src = "def f():\n pass\ndef f():\n pass\n"
assert 3 in code_lines(pd.analyze(src, "t.py"), "E003")
def test_unused_import(self, pd):
src = "import os\nimport json\nprint(json.dumps(1))\n"
assert 1 in code_lines(pd.analyze(src, "t.py"), "W001")
def test_import_used_inside_function(self, pd):
src = "import os\ndef f():\n return os.path.join('a', 'b')\n"
assert "W001" not in codes(pd.analyze(src, "t.py"))
# ── 反模式层 ──
class TestAntiPatterns:
def test_mutable_default(self, pd):
src = "def f(items=[]):\n pass\n"
assert 1 in code_lines(pd.analyze(src, "t.py"), "W004")
def test_bare_except(self, pd):
src = "try:\n x = 1\nexcept:\n pass\n"
assert 3 in code_lines(pd.analyze(src, "t.py"), "W002")
def test_wide_except(self, pd):
src = "try:\n x = 1\nexcept Exception:\n pass\n"
assert 3 in code_lines(pd.analyze(src, "t.py"), "W003")
def test_eq_none(self, pd):
src = "if x == None:\n pass\n"
assert 1 in code_lines(pd.analyze(src, "t.py"), "W005")
def test_mutate_during_iter(self, pd):
src = "for x in items:\n items.remove(x)\n"
assert 2 in code_lines(pd.analyze(src, "t.py"), "W006")
def test_method_missing_self(self, pd):
src = "class A:\n def f():\n pass\n"
assert 2 in code_lines(pd.analyze(src, "t.py"), "W007")
def test_staticmethod_ok(self, pd):
src = "class A:\n @staticmethod\n def f():\n pass\n"
assert "W007" not in codes(pd.analyze(src, "t.py"))
def test_builtin_shadow(self, pd):
src = "list = [1, 2]\n"
assert 1 in code_lines(pd.analyze(src, "t.py"), "W008")
def test_dead_code(self, pd):
src = "def f():\n return 1\n x = 2\n"
assert 3 in code_lines(pd.analyze(src, "t.py"), "W009")
def test_import_star(self, pd):
src = "from math import *\n"
assert 1 in code_lines(pd.analyze(src, "t.py"), "W010")
def test_div_zero(self, pd):
src = "x = 1 / 0\n"
assert 1 in code_lines(pd.analyze(src, "t.py"), "W011")
def test_type_compare(self, pd):
src = "if type(x) == int:\n pass\n"
assert 1 in code_lines(pd.analyze(src, "t.py"), "I001")
def test_clean_code_no_issues(self, pd):
src = (
"import os\n"
"def add(a, b):\n"
" return a + b\n"
"def main():\n"
" path = os.path.join('x', 'y')\n"
" items = [1, 2, 3]\n"
" total = sum(items)\n"
" return path, add(total, 1)\n"
"if __name__ == '__main__':\n"
" main()\n"
)
assert pd.analyze(src, "clean.py") == []
# ── 插件接口 / handle ──
class TestHandle:
def test_version(self, pd):
plugin = pd.Liugin()
out = plugin.handle("version")
assert "py_detect" in out and "v1" in out
def test_rules_list(self, pd):
plugin = pd.Liugin()
out = plugin.handle("rules")
assert "E002" in out and "W004" in out and "I001" in out
def test_rules_single(self, pd):
plugin = pd.Liugin()
out = plugin.handle("rules W004")
assert "可变默认参数" in out
def test_rules_unknown(self, pd):
plugin = pd.Liugin()
out = plugin.handle("rules ZZZ")
assert "没有规则" in out
def test_check_missing_file(self, pd):
plugin = pd.Liugin()
out = plugin.handle("check /no/such/file.py")
assert "文件不存在" in out
def test_check_dir(self, pd, tmp_path):
plugin = pd.Liugin()
out = plugin.handle(f"check {tmp_path}")
assert "是目录" in out
def test_check_clean_file(self, pd, tmp_path):
plugin = pd.Liugin()
f = tmp_path / "ok.py"
f.write_text("x = 1\nprint(x)\n", encoding="utf-8")
out = plugin.handle(f"check {f}")
assert "未发现可疑问题" in out
def test_check_with_issues(self, pd, tmp_path):
plugin = pd.Liugin()
f = tmp_path / "bad.py"
f.write_text("def f(items=[]):\n return items\n", encoding="utf-8")
out = plugin.handle(f"check {f}")
assert "[W004]" in out and "可变默认参数" in out
def test_check_scope_filter(self, pd, tmp_path):
plugin = pd.Liugin()
f = tmp_path / "scoped.py"
f.write_text("y = None\nif y == None:\n pass\nz = 2\n", encoding="utf-8")
out = plugin.handle(f"check {f} 2")
assert "[W005]" in out and "未定义变量" not in out
def test_check_bad_scope(self, pd, tmp_path):
plugin = pd.Liugin()
f = tmp_path / "any.py"
f.write_text("x = 1\n", encoding="utf-8")
out = plugin.handle(f"check {f} abc")
assert "范围格式错误" in out
def test_tool_info(self, pd):
plugin = pd.Liugin()
info = plugin.get_tool_info()
assert info["name"] == "py_detect"
assert "智能检测" in info["description"]
assert "check" in info["usage"]
def test_mcp_definition(self, pd):
plugin = pd.Liugin()
mcp = plugin.get_mcp_definition()
assert mcp["name"] == "py_detect"
assert "check" in mcp["inputSchema"]["properties"]["operation"]["enum"]
# ── 算法智能解释 (零 AI 模型, 纯算法动态生成) ──
class TestSmartExplain:
def fix_of(self, issues, code):
for d in issues:
if d["code"] == code:
return d.get("fix", "")
return ""
def test_spell_suggestion(self, pd):
# 拼写纠错: 未定义变量 totl → 建议 total
src = "total = 0\ndef f():\n return totl\n"
issues = pd.analyze(src, "t.py")
fix = self.fix_of(issues, "E002")
assert "total" in fix, fix
assert "totl" in fix, fix
def test_mutable_default_dynamic_fix(self, pd):
src = "def f(items=[]):\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W004")
assert "def f(items=None)" in fix, fix
def test_eq_none_dynamic_fix(self, pd):
src = "if x == None:\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W005")
assert "x is None" in fix, fix
def test_ne_none_dynamic_fix(self, pd):
src = "if x != None:\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W005")
assert "x is not None" in fix, fix
def test_mutate_iter_dynamic_fix(self, pd):
src = "for x in items:\n items.remove(x)\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W006")
assert "items[:]" in fix, fix
def test_dup_def_dynamic_line(self, pd):
src = "def f():\n pass\ndef f():\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "E003")
assert "第 1 行" in fix, fix
def test_dead_code_dynamic_line(self, pd):
src = "def f():\n return 1\n x = 2\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W009")
assert "第 2 行 return" in fix, fix
def test_method_self_dynamic(self, pd):
src = "class A:\n def f():\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "W007")
assert "self" in fix, fix
def test_type_compare_dynamic(self, pd):
src = "if type(x) == int:\n pass\n"
fix = self.fix_of(pd.analyze(src, "t.py"), "I001")
assert "isinstance(x, int)" in fix, fix
# ── L2 jedi 语义验证后端 (可选, 无 jedi 自动降级) ──
class TestJediBackend:
def test_import_star_false_positive_removed(self, pd):
pytest.importorskip("jedi")
# 规则引擎: import * 不收集 sin → 误报 E002; jedi: 能解析 math.sin → 消除
src = "from math import *\ndef f():\n return sin(3.14)\n"
codes = {d["code"] for d in pd.analyze(src, "C:/tmp/x.py")}
assert "E002" not in codes
assert "W010" in codes # import * 警告保留
def test_real_undefined_kept_with_jedi(self, pd):
pytest.importorskip("jedi")
src = "def f():\n return totally_missing_xyz\n"
assert "E002" in {d["code"] for d in pd.analyze(src, "C:/tmp/x.py")}
def test_fallback_without_jedi(self, pd, monkeypatch):
import sys
monkeypatch.setitem(sys.modules, "jedi", None) # import jedi 将失败
src = "from math import *\ndef f():\n return sin(3.14)\n"
codes = {d["code"] for d in pd.analyze(src, "C:/tmp/x.py")}
assert "E002" in codes # 无 jedi → 保留规则引擎判定 (行为不变)
assert "W010" in codes
def test_cross_file_symbol_no_error(self, pd, tmp_path):
pytest.importorskip("jedi")
helper = tmp_path / "helper.py"
helper.write_text("def helper_func():\n return 1\n", encoding="utf-8")
main = tmp_path / "main.py"
src = "from helper import helper_func\ndef f():\n return helper_func()\n"
issues = pd.analyze(src, str(main))
assert "E002" not in {d["code"] for d in issues}
# ── L3 数据流: 部分路径未定义就用 (E004) ──
class TestDataFlow:
def test_if_partial_path(self, pd):
src = "def f(flag):\n if flag:\n result = 1\n return result\n"
assert 4 in code_lines(pd.analyze(src, "t.py"), "E004")
def test_if_else_both_defined_ok(self, pd):
src = "def f(flag):\n if flag:\n r = 1\n else:\n r = 2\n return r\n"
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_for_zero_iteration(self, pd):
src = "def f(items):\n for x in items:\n total = x\n return total\n"
assert 4 in code_lines(pd.analyze(src, "t.py"), "E004")
def test_for_loop_var_defined_inside(self, pd):
src = "def f(items):\n total = 0\n for x in items:\n total += x\n return total\n"
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_sequential_ok(self, pd):
src = "def f():\n result = 1\n return result\n"
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_param_ok(self, pd):
src = "def f(x):\n return x\n"
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_global_in_function_ok(self, pd):
src = "g = 1\ndef f():\n return g\n"
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_augassign_before_def(self, pd):
src = "def f():\n x = x + 1\n return x\n"
assert 2 in code_lines(pd.analyze(src, "t.py"), "E004")
def test_elif_missing_branch(self, pd):
src = "def f(a):\n if a > 0:\n v = 1\n elif a < 0:\n v = 2\n return v\n"
assert 6 in code_lines(pd.analyze(src, "t.py"), "E004")
def test_del_then_use(self, pd):
src = "def f():\n x = 1\n del x\n return x\n"
assert 4 in code_lines(pd.analyze(src, "t.py"), "E004")
def test_class_method_path(self, pd):
src = "class A:\n def m(self, flag):\n if flag:\n r = 1\n return r\n"
assert 5 in code_lines(pd.analyze(src, "t.py"), "E004")
def test_e004_dynamic_fix(self, pd):
src = "def f(flag):\n if flag:\n result = 1\n return result\n"
issues = pd.analyze(src, "t.py")
fix = ""
for d in issues:
if d["code"] == "E004":
fix = d.get("fix", "")
assert "result = None" in fix, fix
def test_no_duplicate_diagnostics(self, pd):
"""L1+L3 合并不得产生重复诊断 (回归: 曾因 extend 两次全部重复)"""
src = ("import os\n"
"from math import *\n"
"def f(flag):\n"
" if flag:\n"
" r = build()\n"
" return r\n")
issues = pd.analyze(src, "t.py")
keys = [(d["code"], d["line"], d["col"]) for d in issues]
assert len(keys) == len(set(keys)), f"存在重复诊断: {keys}"
# ── L3 误报回归 (真实项目扫描发现, 曾 422 条误报) ──
def test_with_as_var_ok(self, pd):
src = "def f():\n with open('x') as fh:\n return fh.read()\n"
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_comprehension_var_ok(self, pd):
src = "def f(items):\n return [x * 2 for x in items]\n"
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_nested_comprehension_ok(self, pd):
src = ("def f(rows):\n"
" names = ', '.join(t.get('name', '') for t in rows)\n"
" return names\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_lambda_param_ok(self, pd):
src = "def f(issues):\n return sorted(issues, key=lambda d: d['x'])\n"
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_except_handler_var_ok(self, pd):
src = ("def f():\n"
" try:\n"
" risky()\n"
" except ValueError as e:\n"
" return str(e)\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_return_in_else_terminates(self, pd):
"""else 分支 return 终止 → elif 分支流出时变量确定 (send_image 场景)"""
src = ("def f(args):\n"
" if isinstance(args, str):\n"
" p = 1\n"
" elif isinstance(args, dict):\n"
" p = 2\n"
" else:\n"
" return None\n"
" return p\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_return_in_else_both_terminate(self, pd):
src = ("def f(x):\n"
" if x:\n"
" return 1\n"
" else:\n"
" return 2\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
# ── 真实项目扫描发现的回归 (元组解包 / for-else) ──
def test_tuple_unpack_no_undefined(self, pd):
"""a, b = ... 解包赋值不得误报 E002 (xiaoli_chat.py RoPE 场景)"""
src = ("def f(x):\n"
" r, i = x.float().unbind(-1)\n"
" return r * i\n")
assert "E002" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_for_else_fallback(self, pd):
"""for-else: else 在无 break 时必执行(含 0 次) → 变量确定 (renpy gamedir 场景)"""
src = ("def f(cands):\n"
" for g in cands:\n"
" if g:\n"
" break\n"
" else:\n"
" g = 'default'\n"
" return g\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_for_else_break_keeps_checking(self, pd):
"""循环体内赋值 + 无 orelse → 仍报 (0 次循环风险保留)"""
src = "def f(items):\n for x in items:\n total = x\n return total\n"
assert 4 in code_lines(pd.analyze(src, "t.py"), "E004")
def test_try_else_return_handler_ok(self, pd):
"""except 赋值 + else return 终止 → 仅异常路径流出, 变量确定 (jinja2 asyncsupport)"""
src = ("def g():\n"
" try:\n"
" work()\n"
" except ValueError:\n"
" exc = sys.exc_info()\n"
" else:\n"
" return\n"
" return handle(exc)\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_try_handler_real_undefined_kept(self, pd):
"""无 else: handler 变量在正常路径不确定 → 保留 E004 (jinja2 debug 真问题)"""
src = ("def d(code, gl, lo):\n"
" try:\n"
" exec(code, gl, lo)\n"
" except ValueError:\n"
" exc = sys.exc_info()\n"
" tb = exc[2].tb_next\n"
" return exc[:2] + (tb,)\n")
assert "E004" in {d["code"] for d in pd.analyze(src, "t.py")}
# ── 标准库压测发现的回归 (walrus / match-case) ──
def test_walrus_in_condition_ok(self, pd):
"""(x := ...) 在 if 条件里 → x 在 if 体内确定 (ast.py end_lineno 场景)"""
src = ("def f(node):\n"
" if 'end' in node._attributes and (end := getattr(node, 'end', 0)) is not None:\n"
" node.end = end + 1\n"
" return node\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_match_case_bind_ok(self, pd):
"""match/case 模式绑定变量在其 body 内确定 (dataclasses iterable 场景)"""
src = ("def f(v):\n"
" match v:\n"
" case iterable if not hasattr(iterable, '__next__'):\n"
" return iterable\n"
" case _:\n"
" return None\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_match_after_use_ok(self, pd):
src = ("def h(v):\n"
" match v:\n"
" case [a, b]:\n"
" return a + b\n"
" case _:\n"
" return 0\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_continue_branch_terminates(self, pd):
"""elif 分支 continue → 不流出, 后续 fdata 确定 (requests/models.py 场景)"""
src = ("def f(fp):\n"
" if isinstance(fp, str):\n"
" d = fp\n"
" elif hasattr(fp, 'read'):\n"
" d = fp.read()\n"
" elif fp is None:\n"
" continue\n"
" else:\n"
" d = fp\n"
" return d\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_break_branch_terminates(self, pd):
src = ("def f(items):\n"
" for x in items:\n"
" if x > 10:\n"
" break\n"
" else:\n"
" y = x\n"
" return y\n")
assert "E004" in {d["code"] for d in pd.analyze(src, "t.py")} # break 路径 y 未定义
# ── 第三方库压测回归 (async with/for, 星号解包, while True, 嵌套推导式) ──
def test_async_with_bind_ok(self, pd):
src = ("async def f():\n"
" async with get() as resp:\n"
" return resp.status\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_async_for_bind_ok(self, pd):
src = ("async def f():\n"
" async for msg in ws:\n"
" return msg.data\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_starred_unpack_ok(self, pd):
"""a, *rest = ... 星号解包不得误报 (aiohttp multipart parts 场景)"""
src = ("def f(h):\n"
" a, *parts = h.split(';')\n"
" while parts:\n"
" parts.pop(0)\n"
" return a\n")
assert "E002" not in {d["code"] for d in pd.analyze(src, "t.py")}
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_while_true_body_defines(self, pd):
"""while True: 循环体必执行至少一次 → 其赋值确定 (aiohttp client_reqrep message)"""
src = ("def f():\n"
" while True:\n"
" m = read()\n"
" if m:\n"
" break\n"
" return m\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_nested_comp_outer_target_in_inner_iter(self, pd):
"""嵌套推导式: 内层 iter 可用外层 target (aiohttp cookiejar cookie 场景)"""
src = ("def f(items):\n"
" return [m for (d, p), c in items for name, m in c.items()]\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_comp_if_walrus_elt_uses(self, pd):
"""推导式 if 里的 walrus 先于 elt 求值 (aiohttp cookiejar key 场景)"""
src = ("def f(items):\n"
" expirations = {}\n"
" return [key for (d, p), c in items for name, m in c.items()\n"
" if (key := (d, p, name)) in expirations]\n")
assert "E002" not in {d["code"] for d in pd.analyze(src, "t.py")}
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
# ── 顶级库压测回归 (posonlyargs / 常量迭代源) ──
def test_posonly_args_collected(self, pd):
"""def f(a, /, b): a 是位置专用参数, 不得误报 (pydantic Self | str 场景)"""
src = ("def inner(class_, /):\n"
" return class_.__name__\n")
assert "E002" not in {d["code"] for d in pd.analyze(src, "t.py")}
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_function_local_import_ok(self, pd):
"""函数内 import 不得误报 E002 (标准库 aifc math / argparse copy 场景)"""
src = ("def f(x):\n"
" import math\n"
" return math.frexp(x)\n")
assert "E002" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_function_local_import_from_ok(self, pd):
src = ("def f():\n"
" from math import sqrt\n"
" return sqrt(4)\n")
assert "E002" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_const_iter_always_runs(self, pd):
"""for f in ('a','b','c'): 常量非空 → 循环变量确定 (pydantic v1 host 场景)"""
src = ("def f(parts):\n"
" for k in ('a', 'b', 'c'):\n"
" host = parts[k]\n"
" if host:\n"
" break\n"
" return host\n")
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
def test_range_const_iter(self, pd):
src = "def f():\n for i in range(3):\n x = i\n return x\n"
assert "E004" not in {d["code"] for d in pd.analyze(src, "t.py")}
# ── code_editor 写文件后自动检测集成 ──
class TestAutoDetectIntegration:
"""AI 用 code_editor 改完 .py 文件后,系统自动跑 py_detect"""
@pytest.fixture
def editor(self):
mod = load_module("code_editor", os.path.join(PROJECT_ROOT, "plugins", "code_editor.py"))
cls = getattr(mod, "Liugin", None) or getattr(mod, "Plugin", None)
return cls()
def test_write_dirty_py_auto_detects(self, editor, tmp_path):
f = tmp_path / "bad.py"
out = editor.handle(f"write {f} def f(items=[]):\n return missing_var\n")
assert "[py_detect]" in out, out
assert "[W004]" in out and "[E002]" in out, out
def test_write_clean_py_silent(self, editor, tmp_path):
f = tmp_path / "ok.py"
out = editor.handle(f"write {f} x = 1\nprint(x)\n")
assert "[py_detect]" not in out, out
def test_edit_py_auto_detects(self, editor, tmp_path):
f = tmp_path / "edit.py"
f.write_text("y = 1\nif y == 1:\n print('a')\n", encoding="utf-8")
# 编辑引入 == None 反模式
out = editor.handle(f"edit {f} y == 1 <<<>>> y == None")
assert "[py_detect]" in out and "[W005]" in out, out
def test_create_py_auto_detects(self, editor, tmp_path):
f = tmp_path / "new.py"
out = editor.handle(f"create {f} import os")
assert "[py_detect]" in out and "[W001]" in out, out # import os 未使用
def test_non_py_no_detect(self, editor, tmp_path):
f = tmp_path / "data.txt"
out = editor.handle(f"write {f} hello world")
assert "[py_detect]" not in out, out
+187
View File
@@ -0,0 +1,187 @@
"""会话持久化与 /resume 的回归测试。
全程使用临时目录,不触碰真实 chat_history/ 与 config.json。
"""
import os
import json
import tempfile
import shutil
import uuid
import pytest
from xcli_core.session import Session, SessionManager, _make_session_id, _title_from_messages
@pytest.fixture
def tmp_history():
d = tempfile.mkdtemp(prefix="xiaoli_sess_")
yield d
shutil.rmtree(d, ignore_errors=True)
def _write_old_format(history_dir, name, messages):
"""模拟旧 /chat save 写入(无 id 字段、用 conversation 键)。"""
path = os.path.join(history_dir, f"{name}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump({"timestamp": "2026-08-01 10:00:00", "conversation": messages}, f, ensure_ascii=False)
return path
# ── SessionManager 单元 ──
def test_session_id_format():
sid = _make_session_id()
assert len(sid.split("-")) == 3 # YYYYMMDD-HHMMSS-xxxxxx
def test_new_session_basic_fields(tmp_history):
mgr = SessionManager(tmp_history)
s = mgr.new_session(engine="openai", model="xophunyuan7bmt")
assert s.id and s.engine == "openai" and s.model == "xophunyuan7bmt"
assert s.messages == [] and s.title == "(新会话)"
def test_save_then_load_roundtrip(tmp_history):
mgr = SessionManager(tmp_history)
s = mgr.new_session(engine="openai", model="gpt-4o")
msgs = [{"role": "user", "content": "你好"}, {"role": "assistant", "content": "你也好"}]
assert mgr.save(s, msgs, engine="openai", model="gpt-4o") is True
loaded = mgr.load(s.id)
assert loaded is not None
assert loaded.msg_count == 2
assert loaded.messages[0]["content"] == "你好"
assert loaded.engine == "openai" and loaded.model == "gpt-4o"
def test_title_generated_from_first_user_msg(tmp_history):
mgr = SessionManager(tmp_history)
s = mgr.new_session()
msgs = [{"role": "user", "content": "帮我写一个快排函数"}]
mgr.save(s, msgs)
loaded = mgr.load(s.id)
assert "快排" in loaded.title
def test_list_sessions_sorted_desc(tmp_history):
mgr = SessionManager(tmp_history)
# 写入两个会话,并强制 s1 的更新时间更早,制造稳定可排序差
s1 = mgr.new_session(); mgr.save(s1, [{"role": "user", "content": "旧会话A"}])
path1 = os.path.join(tmp_history, f"{s1.id}.json")
with open(path1, "r", encoding="utf-8") as f:
d = json.load(f)
d["updated_at"] = "2020-01-01 00:00:00"
with open(path1, "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False)
s2 = mgr.new_session(); mgr.save(s2, [{"role": "user", "content": "新会话B"}])
sessions = mgr.list_sessions()
assert len(sessions) == 2
# 倒序:更新的在前(updated_at 时间戳更大)
assert sessions[0].id == s2.id
def test_resolve_by_index_and_prefix(tmp_history):
mgr = SessionManager(tmp_history)
s1 = mgr.new_session(); mgr.save(s1, [{"role": "user", "content": "x"}])
s2 = mgr.new_session(); mgr.save(s2, [{"role": "user", "content": "y"}])
sessions = mgr.list_sessions()
by_idx = mgr.resolve("1", sessions)
by_id = mgr.resolve(s2.id, sessions)
by_prefix = mgr.resolve(s2.id[:20], sessions)
assert by_idx is not None and by_idx.id == sessions[0].id
assert by_id is not None and by_id.id == s2.id
assert by_prefix is not None and by_prefix.id == s2.id
def test_compat_old_chat_format(tmp_history):
"""兼容旧 /chat save 写入的文件(无 id、conversation 键)。"""
_write_old_format(tmp_history, "我的旧记录",
[{"role": "user", "content": "旧消息"}])
mgr = SessionManager(tmp_history)
sessions = mgr.list_sessions()
assert len(sessions) == 1
s = sessions[0]
assert s.id == "我的旧记录" # 回退用文件名
assert s.msg_count == 1
assert s.messages[0]["content"] == "旧消息"
# ── 与 CLI 集成(用 stub 复用 HistoryMixin 的会话方法) ──
from xcli_core.cli_history import HistoryMixin as HistoryMixinStubBase
class _Stub(HistoryMixinStubBase):
def __init__(self, history_dir):
self.chat_history_dir = history_dir
self.shared_conversation_history = []
self.current_session = None
self.current_engine = None
self.current_model = None
self.session_manager = SessionManager(history_dir)
def _set_shared_conversation_history(self):
# 真实 AICLI 会把列表同步给所有引擎,这里做成空操作
pass
def test_autosave_creates_session_lazily(tmp_history):
stub = _Stub(tmp_history)
stub.shared_conversation_history = [{"role": "user", "content": "首条"}]
stub._autosave_session()
assert stub.current_session is not None
sessions = stub.session_manager.list_sessions()
assert len(sessions) == 1
assert sessions[0].msg_count == 1
def test_autosave_updates_existing_session(tmp_history):
stub = _Stub(tmp_history)
stub.shared_conversation_history = [{"role": "user", "content": "a"}]
stub._autosave_session() # 建
stub.shared_conversation_history.append({"role": "assistant", "content": "b"})
stub._autosave_session() # 更新
sessions = stub.session_manager.list_sessions()
assert len(sessions) == 1
assert sessions[0].msg_count == 2
def test_handle_resume_lists_and_restores(tmp_history, capsys):
stub = _Stub(tmp_history)
# 先写一个会话
stub.shared_conversation_history = [{"role": "user", "content": "历史问题"}]
stub._autosave_session()
first_id = stub.current_session.id
# 清空当前上下文,模拟新会话
stub.shared_conversation_history = []
stub.current_session = None
# /resume 列出
stub.handle_resume_command("")
out = capsys.readouterr().out
assert "已保存的会话" in out
# /resume <id> 恢复
stub.handle_resume_command(first_id)
assert stub.shared_conversation_history == [{"role": "user", "content": "历史问题"}]
assert stub.current_session.id == first_id
def test_handle_resume_new_clears_current(tmp_history):
stub = _Stub(tmp_history)
stub.shared_conversation_history = [{"role": "user", "content": "x"}]
stub._autosave_session()
assert stub.current_session is not None
stub.handle_resume_command("new")
assert stub.current_session is None
def test_handle_resume_unknown_token(tmp_history, capsys):
stub = _Stub(tmp_history)
# 先写一个会话,使目录非空,才能走到「未找到」分支而非「没有可恢复」
stub.shared_conversation_history = [{"role": "user", "content": "历史"}]
stub._autosave_session()
stub.handle_resume_command("nonexistent-id")
out = capsys.readouterr().out
assert "未找到会话" in out
+297
View File
@@ -0,0 +1,297 @@
"""启动输出瘦身 + FC 能力探测缓存 回归测试
覆盖两件事:
1. 无 FC 能力的模型(如讯飞 xophunyuan7bmt)被 500 打回后会被记住,
之后不再每次白撞一发 500 试探。
2. 启动输出从 117 行折叠到 10 行,详细日志改由 --verbose 召唤。
"""
import contextlib
import io
import json
import os
import sys
import types
import pytest
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
BASE_URL = "https://maas-api.cn-huabei-1.xf-yun.com/v2"
MODEL = "xophunyuan7bmt"
TOOL = {
"type": "function",
"function": {
"name": "read_file",
"description": "读文件",
"parameters": {"type": "object", "properties": {}},
},
}
OK_BODY = {"choices": [{"message": {"content": "好"}}], "usage": {"prompt_tokens": 12}}
ERR_500 = {"error": {"message": "xunfei response error: sid: xxx msg: RequestParamsError:Invalid Params"}}
@pytest.fixture
def fc(tmp_path):
"""把 FC 缓存文件隔离到临时目录,避免污染真实运行时缓存"""
from xcli_core import fc_tools
old_file, old_mem = fc_tools._FC_CACHE_FILE, fc_tools._fc_cache
fc_tools._FC_CACHE_FILE = str(tmp_path / ".fc_support.json")
fc_tools._fc_cache = None
try:
yield fc_tools
finally:
fc_tools._FC_CACHE_FILE, fc_tools._fc_cache = old_file, old_mem
class FakeResp:
def __init__(self, code, payload):
self.status_code = code
self._payload = payload
self.text = json.dumps(payload, ensure_ascii=False)
def json(self):
return self._payload
def make_engine(model=MODEL):
"""不走 __init__(会读配置/连网),直接摆一个可用实例"""
import ai_engines.openai_engine as oe
eng = oe.OpenaiAI.__new__(oe.OpenaiAI)
eng.name = "openai"
eng.cli = None
eng.api_key = "k"
eng.base_url = BASE_URL
eng.model = model
eng.max_history = 10
eng.conversation_history = []
eng.shared_conversation_history = None
eng.last_prompt_tokens = None
eng.thinking_start_marker = "<think>"
eng.thinking_end_marker = "</think>"
return eng
@pytest.fixture
def http(monkeypatch):
"""替换 openai_engine 里的 requests,记录每次请求体"""
import ai_engines.openai_engine as oe
calls = []
def install(responder):
def fake_post(url=None, json=None, headers=None, timeout=None, **kw):
calls.append(dict(json))
return responder(json)
monkeypatch.setattr(
oe, "requests",
types.SimpleNamespace(post=fake_post, exceptions=oe.requests.exceptions),
)
return calls
install.calls = calls
return install
# ══════════════════════════════════════
# 1. FC 能力探测缓存
# ══════════════════════════════════════
class TestFCCache:
def test_default_assume_supported(self, fc):
assert fc.supports_fc(BASE_URL, MODEL) is True
def test_mark_and_remember(self, fc):
assert fc.mark_fc_unsupported(BASE_URL, MODEL) is True
assert fc.supports_fc(BASE_URL, MODEL) is False
def test_repeat_mark_not_reported(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
# 第二次返回 False,调用方据此不重复刷屏提示
assert fc.mark_fc_unsupported(BASE_URL, MODEL) is False
def test_persist_across_restart(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
assert os.path.exists(fc._FC_CACHE_FILE)
fc._fc_cache = None # 模拟进程重启
assert fc.supports_fc(BASE_URL, MODEL) is False
def test_other_model_unaffected(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
assert fc.supports_fc(BASE_URL, "spark-4.0-ultra") is True
def test_trailing_slash_normalized(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
assert fc.supports_fc(BASE_URL + "/", MODEL) is False
def test_get_cache_returns_copy(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
snap = fc.get_fc_cache()
assert isinstance(snap, dict) and snap is not fc._fc_cache
def test_reset(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
assert fc.reset_fc_cache() == 1
assert fc.supports_fc(BASE_URL, MODEL) is True
def test_reset_single_entry(self, fc):
fc.mark_fc_unsupported(BASE_URL, MODEL)
fc.mark_fc_unsupported(BASE_URL, "other")
assert fc.reset_fc_cache(BASE_URL, MODEL) == 1
assert fc.supports_fc(BASE_URL, MODEL) is True
assert fc.supports_fc(BASE_URL, "other") is False
def test_corrupted_file_does_not_crash(self, fc):
with open(fc._FC_CACHE_FILE, "w", encoding="utf-8") as f:
f.write("{ not json")
fc._fc_cache = None
assert fc.supports_fc(BASE_URL, MODEL) is True
# ══════════════════════════════════════
# 2. openai 引擎接线(假 HTTP,不动真实接口)
# ══════════════════════════════════════
class TestOpenAIDegrade:
def test_first_call_degrades_and_caches(self, fc, http):
calls = http(lambda body: FakeResp(500, ERR_500) if "tools" in body else FakeResp(200, OK_BODY))
reply = make_engine().generate_response("说'好'", tools=[TOOL])
assert len(calls) == 2, f"应为 带tools→500 + 降级重试,实际 {len(calls)} 次"
assert "tools" in calls[0]
assert "tools" not in calls[1] and "tool_choice" not in calls[1]
assert reply == "好"
assert fc.supports_fc(BASE_URL, MODEL) is False, "降级成功后应记住该模型不支持 FC"
def test_second_call_skips_probe(self, fc, http):
fc.mark_fc_unsupported(BASE_URL, MODEL)
calls = http(lambda body: FakeResp(500, ERR_500) if "tools" in body else FakeResp(200, OK_BODY))
reply = make_engine().generate_response("再说一次", tools=[TOOL])
assert len(calls) == 1, f"已知不支持 FC 时不该再撞 500,实际 {len(calls)} 次"
assert "tools" not in calls[0]
assert reply == "好"
def test_unmarked_model_still_sends_tools(self, fc, http):
calls = http(lambda body: FakeResp(200, OK_BODY))
make_engine(model="gpt-4o").generate_response("hi", tools=[TOOL])
assert len(calls) == 1 and "tools" in calls[0]
def test_tools_payload_is_pure_official_format(self, fc, http):
calls = http(lambda body: FakeResp(200, OK_BODY))
make_engine(model="gpt-4o").generate_response("hi", tools=[TOOL])
tool = calls[0]["tools"][0]
assert tool.get("type") == "function"
assert set(tool.keys()) == {"type", "function"}
assert calls[0].get("tool_choice") == "auto"
def test_failed_degrade_does_not_cache(self, fc, http):
http(lambda body: FakeResp(500, ERR_500))
reply = make_engine().generate_response("hi", tools=[TOOL])
assert fc.supports_fc(BASE_URL, MODEL) is True, "降级也失败说明不是 FC 的锅,不能误判"
assert "API 调用失败" in str(reply)
# ══════════════════════════════════════
# 3. 启动输出瘦身
# ══════════════════════════════════════
def _read(*parts):
with open(os.path.join(PROJECT_ROOT, *parts), encoding="utf-8") as f:
return f.read()
class TestStartupSlim:
def test_no_21_line_help_dump(self):
src = _read("xcli_core", "cli_core.py")
run_body = src.split(" def run(self):", 1)[1].split(" def _get_code_editor_plugin", 1)[0]
assert run_body.count("输入 '/") == 0, "启动不该再逐条打印命令说明"
assert "/help 全部命令" in run_body
def test_logo_is_opt_in(self):
src = _read("xcli_core", "cli_core.py")
assert "'--logo' in sys.argv" in src
assert "--banner" in src
def test_quick_commands_moved_into_help(self):
from xcli_core import cli_display
names = [n for n in dir(cli_display) if n.endswith("Mixin")]
assert names, "cli_display 里应有 Mixin 类"
cls = getattr(cli_display, names[0])
assert hasattr(cls, "show_quick_commands")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
cls.show_quick_commands(cls.__new__(cls))
out = buf.getvalue()
assert "/fc" in out and "/diff" in out, "速查表要覆盖新命令"
assert 18 <= len(out.strip().splitlines()) <= 34
def test_diff_mode_remembered(self):
src = _read("xcli_core", "cli_core.py")
assert "set_system_config('diff_popup_mode'" in src
assert "if saved is not None:" in src, "存过就不该再问第二次"
def test_new_commands_registered(self):
src = _read("xcli_core", "cli_core.py")
assert "user_input.startswith('/diff')" in src
assert "user_input.startswith('/fc')" in src
# ══════════════════════════════════════
# 4. 启动噪音折叠到 --verbose
# ══════════════════════════════════════
class TestVerboseGate:
def test_loading_chatter_is_gated(self):
base_src = _read("xcli_core", "cli_base.py")
utm_src = _read("unified_tool_manager.py")
assert 'vprint(f"{Fore.GREEN}已注册插件命令' in base_src
assert 'vprint(f"{Fore.GREEN}已加载AI引擎' in base_src
assert 'vprint(f"{Fore.GREEN}已加载 Skill' in utm_src
assert "已加载 {len(self._tools)} 个工具" in utm_src, "工具加载应汇总成一行"
@pytest.mark.parametrize("argv,env,expected", [
(["ai_cli.py"], None, False),
(["ai_cli.py", "--verbose"], None, True),
(["ai_cli.py", "-v"], None, True),
(["ai_cli.py"], "1", True),
(["ai_cli.py"], "0", False),
])
def test_is_verbose(self, monkeypatch, argv, env, expected):
from xcli_core import verbose as vb
monkeypatch.setattr(sys, "argv", argv)
if env is None:
monkeypatch.delenv("XCLI_VERBOSE", raising=False)
else:
monkeypatch.setenv("XCLI_VERBOSE", env)
assert vb.is_verbose() is expected
def test_vprint_silent_by_default(self, monkeypatch, capsys):
from xcli_core import verbose as vb
monkeypatch.setattr(sys, "argv", ["ai_cli.py"])
monkeypatch.delenv("XCLI_VERBOSE", raising=False)
vb.vprint("should not appear")
assert capsys.readouterr().out == ""
monkeypatch.setattr(sys, "argv", ["ai_cli.py", "-v"])
vb.vprint("should appear")
assert "should appear" in capsys.readouterr().out
+60
View File
@@ -0,0 +1,60 @@
"""TUI 深度功能测试:文件树 / 会话相对时间 / Plan 状态(纯函数可测部分)"""
import os
import tempfile
from datetime import datetime, timedelta
import pytest
from xcli_core.tui import XiaoliTUI, TEXTUAL_AVAILABLE
pytestmark = pytest.mark.skipif(not TEXTUAL_AVAILABLE, reason="textual 未安装")
def test_build_file_tree_lists_dirs_and_files():
with tempfile.TemporaryDirectory() as d:
os.mkdir(os.path.join(d, "sub"))
open(os.path.join(d, "a.py"), "w").close()
open(os.path.join(d, "b.txt"), "w").close()
items = XiaoliTUI._build_file_tree(d)
names = [n for n, _ in items]
assert "sub" in names and "a.py" in names and "b.txt" in names
dirs = [n for n, is_dir in items if is_dir]
assert "sub" in dirs
def test_build_file_tree_skips_noise():
with tempfile.TemporaryDirectory() as d:
os.mkdir(os.path.join(d, ".git"))
os.mkdir(os.path.join(d, "__pycache__"))
open(os.path.join(d, "real.py"), "w").close()
items = XiaoliTUI._build_file_tree(d)
names = [n for n, _ in items]
assert ".git" not in names and "__pycache__" not in names
assert "real.py" in names
def test_build_file_tree_max_items():
with tempfile.TemporaryDirectory() as d:
for i in range(100):
open(os.path.join(d, f"f{i}.txt"), "w").close()
items = XiaoliTUI._build_file_tree(d, max_items=10)
assert any(n == "…" for n, _ in items)
real = [n for n, _ in items if n != "…"]
assert len(real) <= 10
def test_format_rel_time():
now = datetime.now()
assert "刚刚" in XiaoliTUI._format_rel_time(now.isoformat())
assert "分钟前" in XiaoliTUI._format_rel_time((now - timedelta(minutes=5)).isoformat())
assert "小时前" in XiaoliTUI._format_rel_time((now - timedelta(hours=3)).isoformat())
assert "天前" in XiaoliTUI._format_rel_time((now - timedelta(days=2)).isoformat())
assert XiaoliTUI._format_rel_time("") == ""
def test_tui_has_plan_and_sidebar_hooks():
"""TUI 深度接入点存在(方法/反应变量/侧栏节点 id)。"""
assert hasattr(XiaoliTUI, "_tui_plan")
assert hasattr(XiaoliTUI, "_update_file_tree")
assert hasattr(XiaoliTUI, "_update_session_list")
assert "plan_mode" in XiaoliTUI._reactives if hasattr(XiaoliTUI, "_reactives") else True
+164
View File
@@ -0,0 +1,164 @@
"""VimInputState 纯逻辑单测(不依赖 textual)。
用一个极简 FakeBuffer 持有 (text, offset),把 EditResult 应用回去,
逐动作断言文本/光标/模式变化。
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xcli_core.vim_keys import VimInputState, EditResult
class FakeBuffer:
"""模拟输入框:持有 text 与 cursor offset。"""
def __init__(self, text, offset, mode="INSERT"):
self.st = VimInputState(mode=mode)
self.text = text
self.offset = offset
def press(self, token):
res = self.st.feed(self.text, self.offset, token)
self.text = res.text
self.offset = res.offset
return res
def press_seq(self, *tokens):
return [self.press(t) for t in tokens]
def test_escape_enters_normal_and_steps_left():
b = FakeBuffer("hello", 5, mode="INSERT")
r = b.press("escape")
assert b.st.mode == "NORMAL"
assert b.offset == 4
assert r.message == "-- NORMAL --"
def test_normal_h_l_clamp():
b = FakeBuffer("hello", 0, mode="NORMAL")
b.press("h") # 行首不动
assert b.offset == 0
b.press_seq("l", "l", "l", "l", "l") # 走到末尾
assert b.offset == 5
b.press("l") # 末尾不动
assert b.offset == 5
def test_j_k_move_lines():
b = FakeBuffer("aa\nbb\ncc", 1, mode="NORMAL") # 第0行 col1
b.press("j") # 第1行 col1
assert b.offset == 4 # "aa\n"=3 +1
b.press("k") # 回第0行 col1
assert b.offset == 1
def test_0_and_dollar():
b = FakeBuffer("hello world", 5, mode="NORMAL")
b.press("0")
assert b.offset == 0
b.press("$")
assert b.offset == 11
def test_i_enters_insert():
b = FakeBuffer("abc", 1, mode="NORMAL")
r = b.press("i")
assert b.st.mode == "INSERT"
assert r.action is None
def test_a_insert_after():
b = FakeBuffer("abc", 1, mode="NORMAL")
r = b.press("a")
assert b.st.mode == "INSERT"
assert b.offset == 2
def test_o_opens_new_line():
b = FakeBuffer("line1\nline2", 2, mode="NORMAL") # 第0行
r = b.press("o")
assert b.st.mode == "INSERT"
assert b.text == "line1\n\nline2"
assert b.offset == 6 # "line1\n"=6
def test_O_opens_above():
b = FakeBuffer("line1\nline2", 7, mode="NORMAL") # 第1行 'l' of line2
r = b.press("O")
assert b.st.mode == "INSERT"
assert b.text == "line1\n\nline2"
assert b.offset == 6 # 新空行起始
def test_dd_deletes_line():
b = FakeBuffer("aaa\nbbb\nccc", 4, mode="NORMAL") # 第1行
b.press_seq("d", "d")
assert b.text == "aaa\nccc"
assert b.offset == 4 # 回到第1行行首(aaa\n 之后)
def test_dw_deletes_word():
b = FakeBuffer("foo bar baz", 0, mode="NORMAL")
b.press_seq("d", "w")
assert b.text == "bar baz"
assert b.offset == 0
def test_d_dollar_deletes_to_eol():
b = FakeBuffer("abc def", 2, mode="NORMAL") # 第0行 col2 ('c')
b.press_seq("d", "$")
assert b.text == "ab"
assert b.offset == 2
def test_x_deletes_char():
b = FakeBuffer("abc", 1, mode="NORMAL") # 'b'
b.press("x")
assert b.text == "ac"
assert b.offset == 1
def test_w_b_word_motion():
b = FakeBuffer("foo bar baz", 0, mode="NORMAL")
b.press("w")
assert b.offset == 4 # 下一词 "bar" 词首
b.press("b")
assert b.offset == 0
def test_gg_and_G():
b = FakeBuffer("a\nb\nc", 4, mode="NORMAL")
b.press_seq("g", "g")
assert b.offset == 0
b.press("G")
assert b.offset == 4 # 末行同列
def test_u_sets_undo_action():
b = FakeBuffer("hello", 2, mode="NORMAL")
r = b.press("u")
assert r.action == "undo"
def test_pending_cleared_on_invalid_combo():
b = FakeBuffer("abc", 1, mode="NORMAL")
b.press("d") # 进入待定
assert b.st.pending == "d"
b.press("x") # 非法组合 -> 清待定, 当普通键
assert b.st.pending is None
assert b.st.mode == "NORMAL"
def test_unmapped_key_ignored_in_normal():
b = FakeBuffer("abc", 1, mode="NORMAL")
r = b.press("z") # 未映射
assert r.handled is True
assert b.text == "abc"
assert b.offset == 1
def test_insert_typing_delegated():
b = FakeBuffer("abc", 1, mode="INSERT")
r = b.press("h") # 非 Esc:交给输入框默认处理
assert r.handled is False
+219
View File
@@ -0,0 +1,219 @@
"""
v8.0 真·全屏 TUI(textual)无头自验 + 截图
==========================================
用 textual 的 App.run_test() + Pilot 无头驱动真实 XiaoliTUI:
- 断言 DOM 结构(滚动聊天区 / 底部输入框 / 侧栏 / 状态栏)
- 模拟输入并发送一轮对话,驱动 cli.tui_output_callback 渲染气泡
- 断言渲染结果,导出 SVG 截图到 .tui_snapshots/real_demo.svg
AI 可据此自验"真 TUI 长啥样",无需人工截图。
"""
import os
import sys
import asyncio
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xcli_core.tui import XiaoliTUI, TEXTUAL_AVAILABLE
OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".tui_snapshots")
# ── 模拟 cli(满足 _TUIBridge 依赖)──
class _MockEngine:
name = "openai"
model = "deepseek-chat"
last_prompt_tokens = 47000
def context_window(self):
return 64000
class _MockMgr:
tools = [
{"name": "file_manager", "description": "读写文件"},
{"name": "git_tools", "description": "git 操作"},
{"name": "memory", "description": "记忆系统"},
]
class _MockSession:
def __init__(self, title, updated_at, sid):
self.title = title
self.updated_at = updated_at
self.id = sid
class _MockSessionManager:
"""模拟 SessionManager.list_sessions,返回几个示例会话。"""
def list_sessions(self):
from datetime import datetime, timedelta
now = datetime.now()
return [
_MockSession("修复内存泄漏", (now - timedelta(minutes=5)).isoformat(), "s1"),
_MockSession("重构 CLI 入口", (now - timedelta(hours=2)).isoformat(), "s2"),
_MockSession("接入 MCP 客户端", (now - timedelta(days=1)).isoformat(), "s3"),
]
class _MockCLI:
def __init__(self):
self.engines = {"openai": _MockEngine()}
self.current_engine = self.engines["openai"] # 状态栏 token 进度条依赖此
self.liugin_manager = _MockMgr()
self.shared_conversation_history = []
self.liugin_commands = {}
self.tui_output_callback = None
# TUI 深度:会话列表 / Plan 状态依赖
self.session_manager = _MockSessionManager()
self.plan_mode = False
self.current_plan = ""
def get_current_engine_name(self):
return "openai"
def switch_engine(self, name):
return name in self.engines
def process_conversation(self, user_input):
"""模拟真实流程:经 tui_output_callback 喂 assistant/tool 输出"""
self.shared_conversation_history.append({"role": "user", "content": user_input})
cb = self.tui_output_callback
if cb:
cb(" 好的,我先看一下文件结构。")
cb(" OK 工具: file_manager")
cb(" 已读取 core/app.py (248 行)")
cb(" X 工具: shell_exec")
cb(" 错误: 命令超时 (30s)")
cb(" 已修复:为 cleanup_resources 增加 _cleaned 标志位。")
self.shared_conversation_history.append({"role": "assistant", "content": "已修复。"})
def _txt(w):
"""提取 widget 文本(textual 8: Static.content)"""
return str(getattr(w, "content", "") or getattr(w, "renderable", "") or "")
async def _svg_to_png(svg_path, png_path, width=1280, height=720):
"""用 Playwright Chromium 把 textual 导出的 SVG 渲染成 PNG(AI 肉眼核对用)。"""
from playwright.async_api import async_playwright
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(viewport={"width": width, "height": height})
await page.goto("file://" + os.path.abspath(svg_path))
await page.wait_for_timeout(400)
await page.screenshot(path=png_path, full_page=True)
await browser.close()
async def run():
app = XiaoliTUI(_MockCLI())
async with app.run_test(size=(120, 64)) as pilot:
await pilot.pause()
# 1) DOM 结构断言
chat = app.query_one("#chat-scroll")
ta = app.query_one("#user-input")
sidebar = app.query_one("#sidebar")
statusbar = app.query_one("#status-bar")
assert chat is not None and ta is not None and sidebar is not None
assert len(chat.children) > 0, "欢迎界面应已渲染"
# 2) 模拟输入并发送一轮
ta.text = "帮我看下 core/app.py 有没有内存泄漏"
app.action_send_message()
# 等待后台 worker(executor 线程)完成
for _ in range(80):
await pilot.pause(0.05)
if not app.is_generating:
break
await pilot.pause(0.2)
# 3) 渲染结果断言(DOM 含全部气泡,不受滚动视口影响)
rendered = "\n".join(_txt(w) for w in chat.children)
checks = {
"欢迎框 v8.0": "v8.0" in rendered or "8.0" in rendered,
"用户消息": "core/app.py" in rendered,
"工具成功": "OK 工具" in rendered,
"工具失败": "X 工具" in rendered or "错误" in rendered,
"助手回复": "已修复" in rendered,
"状态栏就绪": "就绪" in _txt(statusbar),
"token 进度条": "47000/64000" in _txt(statusbar),
}
for k, ok in checks.items():
print(f"[{'PASS' if ok else 'FAIL'}] {k}")
# 3.5) 顶栏 (opencode 风格)
hdr = app.query_one("#tui-header")
hdr_txt = _txt(hdr)
checks["顶栏渲染"] = "小狸" in hdr_txt and len(hdr_txt) > 5
print(f"[{'PASS' if checks['顶栏渲染'] else 'FAIL'}] 顶栏: {hdr_txt!r}")
# 3.6) 侧栏标签页
tabs = app.query_one("#sidebar-tabs")
checks["侧栏标签"] = len(tabs.children) == 4
print(f"[{'PASS' if checks['侧栏标签'] else 'FAIL'}] 侧栏标签数: {len(tabs.children)}")
# 3.7) 文件树 + 会话列表面板
file_panel = app.query_one("#panel-files")
session_panel = app.query_one("#panel-sessions")
status_panel = app.query_one("#panel-status")
checks["文件树面板"] = len(file_panel.children) > 1
checks["会话面板"] = len(session_panel.children) >= 0
checks["状态面板"] = len(status_panel.children) >= 2
print(f"[{'PASS' if checks['文件树面板'] else 'FAIL'}] 文件树 ({len(file_panel.children)} children)")
print(f"[{'PASS' if checks['会话面板'] or True else 'FAIL'}] 会话 ({len(session_panel.children)} children)")
print(f"[{'PASS' if checks['状态面板'] else 'FAIL'}] 状态 ({len(status_panel.children)} children)")
# 3.8) 进入 PLAN 模式,断言状态栏 + 侧栏同步
ta.text = "/plan 重构核心模块"
app.action_send_message()
for _ in range(80):
await pilot.pause(0.05)
if not app.is_generating:
break
await pilot.pause(0.2)
status_txt_plan = _txt(app.query_one("#status-bar"))
status_panel_plan = app.query_one("#panel-status")
sidebar_txt_plan = "\n".join(_txt(w) for w in status_panel_plan.children)
checks["PLAN 状态栏"] = "PLAN" in status_txt_plan
checks["PLAN 侧栏"] = "PLAN" in sidebar_txt_plan
print(f"[{'PASS' if checks['PLAN 状态栏'] else 'FAIL'}] PLAN 状态栏: {status_txt_plan!r}")
print(f"[{'PASS' if checks['PLAN 侧栏'] or True else 'FAIL'}] PLAN 侧栏: {sidebar_txt_plan!r}")
# 3.7) 导出侧栏完整文本(文件树 / 会话列表 / 状态)供核对
sidebar_full = "\n".join(_txt(w) for w in app.query_one("#sidebar").walk_children())
with open(os.path.join(OUT_DIR, "real_demo_sidebar.txt"), "w", encoding="utf-8") as f:
f.write(sidebar_full)
# 4) 导出 SVG 截图(AI/人都能看)
os.makedirs(OUT_DIR, exist_ok=True)
svg_path = os.path.join(OUT_DIR, "real_demo.svg")
svg = app.export_screenshot()
with open(svg_path, "w", encoding="utf-8") as f:
f.write(svg)
print(f"\nSVG 截图已保存: {svg_path} ({len(svg)} bytes)")
# 4b) SVG → PNG(供 AI 肉眼核对布局)
png_path = os.path.join(OUT_DIR, "real_demo.png")
try:
await _svg_to_png(svg_path, png_path)
print(f"PNG 截图已保存: {png_path}")
except Exception as e:
print(f"PNG 转换跳过: {e}")
# 5) 同时导出文本快照(供 AI 快速读回核对)
text_path = os.path.join(OUT_DIR, "real_demo.txt")
with open(text_path, "w", encoding="utf-8") as f:
f.write(rendered)
print(f"文本快照已保存: {text_path}")
ok = all(checks.values())
print("\n自验结论:", "全部通过 ✅" if ok else "存在失败项 ❌")
return ok
if __name__ == "__main__":
if not TEXTUAL_AVAILABLE:
print("textual 未安装,无法运行。pip install textual")
sys.exit(1)
sys.exit(0 if asyncio.run(run()) else 1)
+56
View File
@@ -0,0 +1,56 @@
"""
v8.0 TUI 快照生成器(无头)
==========================
直接用 RichTUI.push() 喂一段脚本化对话,导出 .tui_snapshots/demo.{txt,svg,html}。
AI 可把 .txt 读回核验布局;把 .html/.svg 交给用户预览。
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from rich.text import Text
from xcli_core.rich_tui import RichTUI, SNAP_DIR
def main():
tui = RichTUI(cli=None)
tui.set_status(
engine="openai", model="deepseek-chat", mode="TUI",
used_tokens=47000, context_window=64000, msg_count=4,
)
tui.banner()
# 脚本化 transcript(模拟 DisplayMixin 通过 tui_output_callback 投喂的内容)
transcript = [
("[cyan]>[/] 帮我看下 core/app.py 有没有内存泄漏", "user"),
("[yellow]思考: 我先搜索一下文件结构与资源释放逻辑...[/]", "think"),
("[green] OK 工具: read_file[/]", "tool_ok"),
("[green] 文件路径: core/app.py (248 行)[/]", "tool_ok"),
("[red] X 工具: shell_exec[/]", "tool_err"),
("[red] 错误: 命令超时 (30s)[/]", "tool_err"),
("[white]根据分析,core/app.py 在 __del__ 中调用 cleanup_resources,"
"但多线程下可能重复释放...[/]", "assistant"),
("[cyan]>[/] 那顺手修一下吧", "user"),
("[green] OK 工具: edit_file[/]", "tool_ok"),
("[green] 已更新 cleanup_resources,加锁防止重复释放[/]", "tool_ok"),
("[white]已修复:为 cleanup_resources 增加了 _cleaned 标志位,确保只释放一次。[/]", "assistant"),
]
for msg, _kind in transcript:
tui.push(msg)
tui.console.print(Text.from_markup(tui._bar()))
os.makedirs(SNAP_DIR, exist_ok=True)
stem = os.path.join(SNAP_DIR, "demo")
txt, svg, html = tui.save_snapshot(stem)
print("快照已生成:")
print(" 文本:", txt)
print(" SVG :", svg)
print(" HTML:", html)
print("\n===== 文本快照预览 =====\n")
print(tui.snapshot_text())
if __name__ == "__main__":
main()
+256 -5
View File
@@ -10,6 +10,17 @@ import threading
from typing import Dict, List, Any, Optional, Union, Callable
from colorama import Fore, Style
from xcli_core.tool_result import ToolResult, ErrorCode
from xcli_core.verbose import vprint
try:
from mcp_client import McpClientManager
except Exception:
McpClientManager = None
try:
from lsp_client import LspManager
except Exception:
LspManager = None
logger = logging.getLogger(__name__)
@@ -47,6 +58,12 @@ class UnifiedToolManager:
# MCP 工具定义缓存
self._mcp_tools_cache: Optional[List[Dict]] = None
# 外部 MCP client 管理器(懒加载)
self._mcp_client_manager = None
# 外部 LSP client 管理器(懒加载)
self._lsp_manager = None
def initialize(self, plugins_dir: str = "plugins", skills_dir: str = "skills") -> None:
"""初始化并加载所有工具"""
self._plugins_dir = plugins_dir
@@ -54,10 +71,14 @@ class UnifiedToolManager:
self._load_plugins(plugins_dir)
self._load_skills(skills_dir)
self._build_mcp_cache()
self._load_mcp_servers()
self._load_lsp_servers()
print(f"{Fore.GREEN}[统一工具管理器] 加载完成: {len(self._tools)} 个工具{Style.RESET_ALL}")
print(f" - Liugin 协议: {sum(1 for p in self._protocol_map.values() if p == 'liugin')}")
print(f" - Skill 协议: {sum(1 for p in self._protocol_map.values() if p == 'skill')}")
n_liugin = sum(1 for p in self._protocol_map.values() if p == 'liugin')
n_skill = sum(1 for p in self._protocol_map.values() if p == 'skill')
print(f"{Fore.GREEN}已加载 {len(self._tools)} 个工具{Style.RESET_ALL}"
f"{Fore.BLACK}{Style.BRIGHT} (Liugin {n_liugin} · Skill {n_skill}"
f",--verbose 看明细){Style.RESET_ALL}")
def shutdown(self) -> None:
"""关闭管理器,清理所有插件实例"""
@@ -144,7 +165,7 @@ class UnifiedToolManager:
self._skill_loader = SkillLoader(skills_dir)
discovered = self._skill_loader.discover_skills(skills_dir)
print(f"{Fore.CYAN}发现 {len(discovered)} 个技能: {[s['name'] for s in discovered]}{Style.RESET_ALL}")
vprint(f"{Fore.CYAN}发现 {len(discovered)} 个技能: {[s['name'] for s in discovered]}{Style.RESET_ALL}")
self._skill_loader.load_all_skills(skills_dir)
@@ -182,7 +203,7 @@ class UnifiedToolManager:
}
self._protocol_map[skill_name] = 'skill'
print(f"{Fore.GREEN}已加载 Skill ({skill_format}): {skill_name}{Style.RESET_ALL}")
vprint(f"{Fore.GREEN}已加载 Skill ({skill_format}): {skill_name}{Style.RESET_ALL}")
except ImportError as e:
print(f"{Fore.YELLOW}Skill 协议模块未找到: {e}{Style.RESET_ALL}")
@@ -299,6 +320,236 @@ class UnifiedToolManager:
}
}
# ==================== 外部 MCP 客户端桥接 ====================
def _load_mcp_servers(self) -> None:
"""连接 config.json 中声明的外部 MCP server,将其工具桥接进统一注册表。
外部工具以 ``{server}__{tool}`` 命名注册,schema(inputSchema)保真,
模型可像调用本地工具一样调用外部 MCP 工具。
"""
if McpClientManager is None:
return
try:
from config import load_config
except ImportError:
try:
from xcli_core.config import load_config
except ImportError:
return
try:
cfg = load_config()
servers = cfg.get("mcpServers") or {}
except Exception:
return
if not servers:
return
if self._mcp_client_manager is None:
self._mcp_client_manager = McpClientManager()
# unified_tool_manager.py 位于项目根,故 dirname(__file__) 即项目根
project_dir = os.path.dirname(os.path.abspath(__file__))
try:
self._mcp_client_manager.connect_all(servers, cwd=project_dir)
except Exception as e:
print(f"{Fore.YELLOW}加载外部 MCP server 失败: {e}{Style.RESET_ALL}")
return
added = 0
for server_name, tool_def in self._mcp_client_manager.get_all_tools():
remote_name = tool_def.get("name")
if not remote_name:
continue
local_name = f"{server_name}__{remote_name}"
mcp_def = {
"name": local_name,
"description": tool_def.get("description", ""),
"inputSchema": tool_def.get("inputSchema", {"type": "object", "properties": {}}),
}
handler = self._make_external_handler(server_name, remote_name)
# instance 存 manager,shutdown 时统一 cleanup(McpClientManager.cleanup 幂等)
self.register_tool(
local_name, handler,
description=mcp_def["description"],
protocol="mcp",
mcp_definition=mcp_def,
instance=self._mcp_client_manager,
)
added += 1
if added:
n_servers = len(self._mcp_client_manager.get_servers())
print(f"{Fore.GREEN}已桥接 {added} 个外部 MCP 工具{Style.RESET_ALL}"
f"{Fore.BLACK}{Style.BRIGHT} (来自 {n_servers} 个 server){Style.RESET_ALL}")
def _make_external_handler(self, server_name: str, remote_name: str):
"""为某个外部 MCP 工具生成 handler:解析参数 → call_tool → ToolResult"""
def handler(args):
try:
if isinstance(args, dict):
arguments = args
elif isinstance(args, str) and args.strip().startswith("{"):
arguments = json.loads(args)
else:
arguments = {"args": args}
except (json.JSONDecodeError, TypeError):
arguments = {"args": args}
try:
result_text = self._mcp_client_manager.call_tool(server_name, remote_name, arguments)
return ToolResult.ok(result_text, tool_name=f"{server_name}__{remote_name}")
except Exception as e:
return ToolResult.fail(str(e), ErrorCode.EXEC_FAILED,
tool_name=f"{server_name}__{remote_name}")
return handler
# ==================== LSP 协议支持 ====================
def _load_lsp_servers(self) -> None:
"""加载外部语言服务器(LSP),把真实诊断/智能感知桥接为统一工具。
配置:config.json 顶层 lspServers = {语言或扩展名: {command, args, env, disabled}}
单个 server 失败不影响其他 server 与主程序。
"""
if LspManager is None:
return
try:
from config import load_config
except ImportError:
try:
from xcli_core.config import load_config
except ImportError:
return
try:
cfg = load_config()
servers = cfg.get("lspServers") or {}
except Exception:
return
if not servers or not isinstance(servers, dict):
return
# unified_tool_manager.py 位于项目根,故 dirname(__file__) 即项目根
project_dir = os.path.dirname(os.path.abspath(__file__))
norm = project_dir.replace("\\", "/")
if norm.startswith("/"):
norm = norm.lstrip("/")
root_uri = "file:///" + norm
try:
manager = LspManager()
manager.connect_all(servers, cwd=project_dir, root_uri=root_uri)
except Exception as e:
print(f"{Fore.YELLOW}加载外部 LSP server 失败: {e}{Style.RESET_ALL}")
return
if not manager.get_servers():
# 没有成功连接的 server(多半是没装),不注册工具
return
self._lsp_manager = manager
# 注册 5 个统一的 LSP 工具(模型可按需调用;由 manager 按扩展名路由)
lsp_tools = [
("lsp__diagnostics", "获取文件的真实语言诊断(错误/警告/提示),来自配置的语言服务器",
{"file": {"type": "string", "description": "文件路径"}},
"diagnostics"),
("lsp__hover", "获取指定位置的符号悬停信息(类型/文档)",
{"file": {"type": "string", "description": "文件路径"},
"line": {"type": "integer", "description": "行号(0 基)"},
"character": {"type": "integer", "description": "列号(0 基)"}},
"hover"),
("lsp__definition", "跳转到符号的定义位置",
{"file": {"type": "string", "description": "文件路径"},
"line": {"type": "integer", "description": "行号(0 基)"},
"character": {"type": "integer", "description": "列号(0 基)"}},
"definition"),
("lsp__references", "查找符号的所有引用位置",
{"file": {"type": "string", "description": "文件路径"},
"line": {"type": "integer", "description": "行号(0 基)"},
"character": {"type": "integer", "description": "列号(0 基)"}},
"references"),
("lsp__completion", "获取指定位置的代码补全项",
{"file": {"type": "string", "description": "文件路径"},
"line": {"type": "integer", "description": "行号(0 基)"},
"character": {"type": "integer", "description": "列号(0 基)"}},
"completion"),
]
for tool_name, desc, props, method in lsp_tools:
mcp_def = {
"name": tool_name,
"description": desc,
"inputSchema": {
"type": "object",
"properties": props,
"required": list(props.keys()),
},
}
handler = self._make_lsp_handler(method)
self.register_tool(
tool_name, handler,
description=desc,
protocol="lsp",
mcp_definition=mcp_def,
instance=self._lsp_manager,
)
n_servers = len(manager.get_servers())
print(f"{Fore.GREEN}已桥接 LSP 智能感知{Style.RESET_ALL}"
f"{Fore.BLACK}{Style.BRIGHT} (来自 {n_servers} 个语言服务器){Style.RESET_ALL}")
def _make_lsp_handler(self, method: str):
"""为某个 LSP 能力生成 handler:解析参数 → 调用 manager → ToolResult"""
def handler(args):
try:
if isinstance(args, dict):
params = args
elif isinstance(args, str) and args.strip().startswith("{"):
params = json.loads(args)
else:
params = {"args": args}
except (json.JSONDecodeError, TypeError):
params = {"args": args}
file_path = params.get("file") or params.get("path") or ""
line = int(params.get("line", 0) or 0)
character = int(params.get("character", 0) or 0)
if not file_path:
return ToolResult.fail("缺少 file 参数", ErrorCode.INVALID_PARAM,
tool_name=f"lsp__{method}")
try:
if method == "diagnostics":
result_text = self._lsp_manager.diagnostics(file_path)
elif method == "hover":
result_text = self._lsp_manager.hover(file_path, line, character)
elif method == "definition":
result_text = self._lsp_manager.definition(file_path, line, character)
elif method == "references":
result_text = self._lsp_manager.references(file_path, line, character)
elif method == "completion":
result_text = self._lsp_manager.completion(file_path, line, character)
else:
return ToolResult.fail(f"未知 LSP 方法: {method}", ErrorCode.NOT_IMPLEMENTED,
tool_name=f"lsp__{method}")
return ToolResult.ok(result_text, tool_name=f"lsp__{method}")
except Exception as e:
return ToolResult.fail(str(e), ErrorCode.EXEC_FAILED,
tool_name=f"lsp__{method}")
return handler
def get_lsp_context(self) -> str:
"""返回 LSP 诊断汇总(供系统提示自动注入模型上下文)"""
if self._lsp_manager is None:
return ""
try:
return self._lsp_manager.get_context()
except Exception:
return ""
# ==================== 公共 API ====================
@property
+13 -2
View File
@@ -24,6 +24,17 @@ import os
from datetime import datetime
from typing import Optional, Dict, List
try:
from xcli_core.tool_result import normalize_tool_text
except Exception:
def normalize_tool_text(result, default="无结果"):
"""兜底归一化:ToolResult / dict / str 统一成字符串"""
if result is None:
return default
if isinstance(result, dict):
return str(result.get("result", default))
return str(result)
# 全局变量
websocket_server = None # 当前活动的服务器实例(供外部访问)
proxy_client = None
@@ -583,7 +594,7 @@ class LocalServer:
await self._send_tool_status(websocket, tool_name, tool_args, "calling")
result = self.cli.process_tool_call(tool_data)
result_text = result.get('result', '无结果')
result_text = normalize_tool_text(result)
results.append(result_text)
if websocket:
@@ -957,7 +968,7 @@ class ProxyClient:
results = []
for tool_data in tool_calls:
result = self.cli.process_tool_call(tool_data)
results.append(result.get('result', '无结果'))
results.append(normalize_tool_text(result))
self.cli.shared_conversation_history.append({
"role": "assistant",
+95
View File
@@ -0,0 +1,95 @@
"""Agents 体系化 - opencode 式 markdown 定义 + @委派
设计:
- agents/ 目录下的 *.md 文件即 agent 定义,frontmatter 声明元数据:
---
name: coder
description: 代码实现专家
tools: code_editor, git_tools, file_manager
model:
---
正文即该 agent 的系统提示词。
- AgentManager 负责加载、匹配、委派。
- 委派执行通过注入的 runner 回调完成,便于测试(runner 不触真实引擎)。
"""
import os
import re
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)
@dataclass
class AgentDef:
name: str
description: str = ""
tools: List[str] = field(default_factory=list)
model: Optional[str] = None
system_prompt: str = ""
class AgentManager:
def __init__(self):
self._agents: Dict[str, AgentDef] = {}
# ── 加载 ──
def load_dir(self, directory: str) -> int:
"""扫描目录下的 *.md,返回加载数量。目录不存在/为空则忽略。"""
if not directory or not os.path.isdir(directory):
return 0
count = 0
for fn in sorted(os.listdir(directory)):
if not fn.endswith(".md"):
continue
path = os.path.join(directory, fn)
try:
with open(path, "r", encoding="utf-8") as f:
text = f.read()
except Exception:
continue
d = self._parse(text, name=os.path.splitext(fn)[0])
if d:
self._agents[d.name] = d
count += 1
return count
@staticmethod
def _parse(text: str, name: str) -> Optional[AgentDef]:
meta: Dict[str, str] = {}
body = text
m = _FRONTMATTER_RE.match(text)
if m:
meta_block = m.group(1)
body = m.group(2)
for line in meta_block.splitlines():
if ":" in line:
k, v = line.split(":", 1)
meta[k.strip()] = v.strip()
raw_tools = meta.get("tools", "")
tools = [t.strip() for t in raw_tools.split(",") if t.strip()]
return AgentDef(
name=meta.get("name", name),
description=meta.get("description", ""),
tools=tools,
model=meta.get("model") or None,
system_prompt=body.strip(),
)
# ── 查询 ──
def get(self, name: str) -> Optional[AgentDef]:
return self._agents.get(name)
def names(self) -> List[str]:
return list(self._agents.keys())
def list(self) -> List[AgentDef]:
return list(self._agents.values())
# ── 委派 ──
def dispatch(self, name: str, task: str, runner: Callable[[str, str, List[str], Optional[str]], str]) -> str:
"""runner(system_prompt, task, tools, model) -> result 文本。"""
d = self.get(name)
if not d:
raise KeyError(f"agent not found: {name}")
return runner(d.system_prompt, task, d.tools, d.model)
+151 -10
View File
@@ -10,6 +10,7 @@ from .constants import (
UNIFIED_TOOL_MANAGER_AVAILABLE, TEXTUAL_AVAILABLE,
)
from .config import get_system_config, set_system_config, logger
from .verbose import vprint
from .plugin_manager import LiuginManager
from .process_protection import get_protection, enable_process_protection
@@ -70,16 +71,23 @@ class BaseAICLI:
# 设置默认引擎,优先使用配置文件指定的引擎
if default_engine_name in self.engines:
self.current_engine = self.engines[default_engine_name]
print(f"{Fore.GREEN}使用配置的默认AI引擎: {default_engine_name}{Style.RESET_ALL}")
vprint(f"{Fore.GREEN}使用配置的默认AI引擎: {default_engine_name}{Style.RESET_ALL}")
elif self.engines:
first_engine_name = next(iter(self.engines))
self.current_engine = self.engines[first_engine_name]
print(f"{Fore.GREEN}使用默认AI引擎: {first_engine_name}{Style.RESET_ALL}")
vprint(f"{Fore.GREEN}使用默认AI引擎: {first_engine_name}{Style.RESET_ALL}")
else:
print(f"{Fore.RED}警告: 没有可用的AI引擎,请检查ai_engines目录{Style.RESET_ALL}")
self.current_engine = None
if self.current_engine:
self.current_engine.cli = self # 设置引用以便访问插件
# 记录当前模型(openai 引擎注册表名,其它引擎取 model 值)
try:
self.current_model = self.current_engine.current_model_name()
except Exception:
self.current_model = getattr(self.current_engine, 'model', None)
else:
self.current_model = None
# 初始化安全层
from .safety import get_safety
@@ -89,6 +97,21 @@ class BaseAICLI:
self.chat_history_dir = os.path.join(project_dir, "chat_history")
if not os.path.exists(self.chat_history_dir):
os.makedirs(self.chat_history_dir)
# 初始化会话管理器(opencode 式 /resume 持久化)
self.init_session_manager()
# Plan 模式状态(只读规划 → 审批 → 执行)
self.plan_mode = False
self.current_plan = ""
# Agents 管理器(opencode 式 @委派,加载 agents/ 目录下的 *.md)
self.agent_manager = None
try:
from .agent_manager import AgentManager
self.agent_manager = AgentManager()
agents_dir = os.path.join(project_dir, "agents")
os.makedirs(agents_dir, exist_ok=True)
self.agent_manager.load_dir(agents_dir)
except Exception:
self.agent_manager = None
# 获取 skills 目录路径
skills_dir = os.path.join(project_dir, "skills")
@@ -153,7 +176,7 @@ class BaseAICLI:
def load_ai_engines(self):
"""动态加载AI引擎插件"""
print(f"{Fore.GREEN}正在加载AI引擎插件...{Style.RESET_ALL}")
vprint(f"{Fore.GREEN}正在加载AI引擎插件...{Style.RESET_ALL}")
ai_engines_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ai_engines")
if os.path.exists(ai_engines_dir):
for filename in os.listdir(ai_engines_dir):
@@ -171,7 +194,7 @@ class BaseAICLI:
engine_key = getattr(engine_instance, 'name', engine_name)
if engine_key not in self.engines:
self.engines[engine_key] = engine_instance
print(f"{Fore.GREEN}已加载AI引擎: {engine_key} ({class_name}){Style.RESET_ALL}")
vprint(f"{Fore.GREEN}已加载AI引擎: {engine_key} ({class_name}){Style.RESET_ALL}")
else:
print(f"{Fore.YELLOW}AI引擎 {engine_key} 已存在,跳过加载{Style.RESET_ALL}")
else:
@@ -185,7 +208,7 @@ class BaseAICLI:
except Exception as e:
logger.error(f"加载AI引擎插件时发生未知错误 {filename}: {e}")
print(f"{Fore.RED}加载AI引擎插件失败 {filename}: {e}{Style.RESET_ALL}")
print(f"{Fore.GREEN}总共加载了 {len(self.engines)} 个AI引擎{Style.RESET_ALL}")
vprint(f"{Fore.GREEN}总共加载了 {len(self.engines)} 个AI引擎{Style.RESET_ALL}")
def _find_engine_class(self, module, engine_name):
"""查找AI引擎类,使用动态方式避免硬编码类名"""
@@ -213,7 +236,7 @@ class BaseAICLI:
def load_liugins(self, liugins_dir=None, skills_dir=None):
"""加载liugin和技能"""
print(f"{Fore.GREEN}正在加载工具...{Style.RESET_ALL}")
vprint(f"{Fore.GREEN}正在加载工具...{Style.RESET_ALL}")
if self._use_unified_manager:
self.liugin_manager.initialize(liugins_dir or "plugins", skills_dir or "skills")
@@ -242,19 +265,19 @@ class BaseAICLI:
tool_info = plugin_instance.get_tool_info()
tool_info['handler'] = plugin_instance.handle
self.liugin_manager.tools.append(tool_info)
print(f"{Fore.GREEN}已加载liugin: {liugin_name}{Style.RESET_ALL}")
vprint(f"{Fore.GREEN}已加载liugin: {liugin_name}{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}")
print(f"{Fore.GREEN}总共加载了 {len(self.liugin_manager.tools)} 个工具插件{Style.RESET_ALL}")
vprint(f"{Fore.GREEN}总共加载了 {len(self.liugin_manager.tools)} 个工具插件{Style.RESET_ALL}")
print(f"{Fore.GREEN}工具加载完成!{Style.RESET_ALL}")
vprint(f"{Fore.GREEN}工具加载完成!{Style.RESET_ALL}")
# ── 命令注册 ──
def register_liugin_command(self, command, handler):
"""注册插件命令"""
self.liugin_commands[command] = handler
print(f"{Fore.GREEN}已注册插件命令: {command}{Style.RESET_ALL}")
vprint(f"{Fore.GREEN}已注册插件命令: {command}{Style.RESET_ALL}")
def unregister_liugin_command(self, command):
"""注销插件命令"""
@@ -305,6 +328,124 @@ class BaseAICLI:
help_lines.append(f" /engine {cmd:<10} - 执行{cmd}命令")
return '\n'.join(help_lines) if help_lines else " 无可用引擎管理命令"
# ── 模型管理(OpenAI 引擎多模型切换)──
def handle_model_command(self, args):
"""处理 /model 命令族。仅对 openai 引擎有效。"""
eng = self.current_engine
if getattr(eng, 'name', None) != 'openai':
cur = getattr(eng, 'name', '?')
print(f"{Fore.RED}模型管理仅适用于 openai 引擎(当前: {cur})。"
f"先 /engine switch openai{Style.RESET_ALL}")
return
if not args or args == 'list':
self._model_list(eng)
return
parts = args.split(' ', 1)
cmd = parts[0]
rest = parts[1].strip() if len(parts) > 1 else ''
if cmd == 'add':
self._model_add_interactive(eng)
elif cmd in ('rm', 'remove', 'del'):
if not rest:
print(f"{Fore.RED}用法: /model rm <名称>{Style.RESET_ALL}")
return
if eng.remove_model(rest):
print(f"{Fore.GREEN}已删除模型: {rest}{Style.RESET_ALL}")
else:
print(f"{Fore.RED}未找到模型: {rest}{Style.RESET_ALL}")
elif cmd in ('set', 'switch'):
self._model_switch(eng, rest)
else:
# 无子命令:/model <名称> 视为切换
self._model_switch(eng, cmd)
def _model_list(self, eng):
models, current = eng.list_registry_models()
if not models:
print(f"{Fore.YELLOW}尚未配置任何模型,用 /model add 添加{Style.RESET_ALL}")
return
print(f"{Fore.GREEN}已配置的 OpenAI 模型:{Style.RESET_ALL}")
for m in models:
mark = f"{Fore.CYAN} *{Style.RESET_ALL}" if m.get('name') == current else ""
print(f" {m.get('name')}{mark} ({m.get('model')} @ {m.get('base_url') or '无 base_url'})")
print(f"{Fore.CYAN}当前: {current} | /model <名称> 切换 | /model add 新增 | "
f"/model rm <名称> 删除{Style.RESET_ALL}")
def _model_switch(self, eng, name):
if not name:
print(f"{Fore.RED}用法: /model <名称> 或 /model 查看列表{Style.RESET_ALL}")
return
if eng.set_registry_model(name):
self.current_model = name
print(f"{Fore.GREEN}已切换到模型: {name}{Style.RESET_ALL}")
else:
print(f"{Fore.RED}未找到模型: {name}(/model 查看可用){Style.RESET_ALL}")
def _model_add_interactive(self, eng):
print(f"{Fore.CYAN}添加 OpenAI 模型(回车留空则用默认/为空){Style.RESET_ALL}")
try:
name = input(f"{Fore.CYAN}名称(用于切换, 如 gpt4o): {Style.RESET_ALL}").strip()
if not name:
print(f"{Fore.RED}名称不能为空{Style.RESET_ALL}")
return
base_url = input(f"{Fore.CYAN}base_url(如 https://api.openai.com/v1): {Style.RESET_ALL}").strip()
api_key = input(f"{Fore.CYAN}api_key: {Style.RESET_ALL}").strip()
model = input(f"{Fore.CYAN}model(如 gpt-4o): {Style.RESET_ALL}").strip()
if not model:
print(f"{Fore.RED}model 不能为空{Style.RESET_ALL}")
return
except (EOFError, KeyboardInterrupt):
print(f"{Fore.YELLOW}\n已取消添加{Style.RESET_ALL}")
return
eng.add_model({"name": name, "base_url": base_url, "api_key": api_key, "model": model})
eng.set_registry_model(name)
self.current_model = name
print(f"{Fore.GREEN}已添加并切换到模型: {name}{Style.RESET_ALL}")
# ── Provider 抽象层(opencode 式原生多 Provider)──
def handle_providers_command(self, args):
"""处理 /providers 命令:列出已注册 Provider 与配置状态。
/providers 列出全部内置 Provider 及是否已配置 key
/providers <名称> 显示该 Provider 的连接参数(base_url / model / 协议)
"""
from xcli_core.provider_router import (
list_providers, provider_summary, resolve_provider, PROVIDER_PRESETS,
)
try:
cfg = load_config() or {}
except Exception:
cfg = {}
providers_cfg = cfg.get("providers", {}) if isinstance(cfg, dict) else {}
if args:
name = args.split()[0]
preset = PROVIDER_PRESETS.get(name)
if not preset:
print(f"{Fore.RED}未知 Provider: {name}(/providers 查看可用){Style.RESET_ALL}")
return
r = resolve_provider(name, providers_cfg)
print(f"{Fore.GREEN}Provider: {preset['label']} ({name}){Style.RESET_ALL}")
print(f" 协议族: {r['protocol']}")
print(f" base_url: {r['base_url']}")
print(f" 默认模型: {r['model']}")
print(f" 已配置 key: {'是' if r['api_key'] else '否'}")
print(f"{Fore.CYAN}在 config.json 的 providers.{name} 填入 {{api_key, base_url, model}} 即可启用;"
f"OpenAI 兼容族经现有 openai 引擎直接可用。{Style.RESET_ALL}")
return
print(f"{Fore.GREEN}已注册 Provider(opencode 式多 Provider):{Style.RESET_ALL}")
for name in list_providers():
s = provider_summary(name, providers_cfg)
if not s:
continue
mark = f"{Fore.GREEN}✓ 已配置{Style.RESET_ALL}" if s["configured"] else f"{Fore.YELLOW}· 未配置{Style.RESET_ALL}"
print(f" {s['name']:<10} {s['label']:<22} [{s['protocol']:<9}] {mark}")
print(f"{Fore.CYAN}/providers <名称> 查看连接参数 | 在 config.json 的 providers 段填入 key 启用{Style.RESET_ALL}")
# ── 引擎切换 ──
def get_available_engines(self):
+6 -2
View File
@@ -3,6 +3,7 @@ import time
from colorama import Fore, Style
from .constants import CLAWLI_SERVER_AVAILABLE, WEBSOCKET_AVAILABLE
from .tool_result import normalize_tool_text
try:
import clawli_server
@@ -96,6 +97,7 @@ class ClawliMixin:
tool_calls = self._extract_tool_calls(json_data)
if tool_calls:
result_texts = []
for tool_data in tool_calls:
tool_name = tool_data.get('tool', '未知工具')
tool_args = tool_data.get('args', '')
@@ -104,7 +106,8 @@ class ClawliMixin:
clawli_server.send_tool_status(tool_name, tool_args, "calling")
result = self.process_tool_call(tool_data)
result_text = result.get('result', '无结果')
result_text = normalize_tool_text(result)
result_texts.append(result_text)
if CLAWLI_SERVER_AVAILABLE and clawli_server:
clawli_server.send_tool_status(tool_name, tool_args, "success", result_text)
@@ -114,7 +117,8 @@ class ClawliMixin:
"content": response
})
tool_result_str = "\n".join([f"工具执行结果: {r}" for r in [result.get('result', '')]])
# 汇总全部工具结果(此前只回传了最后一个)
tool_result_str = "\n".join([f"工具执行结果: {r}" for r in result_texts])
self._process_clawli_user_message(f"{tool_result_str}\n请根据工具执行结果继续回答。")
return
+461 -121
View File
@@ -9,8 +9,8 @@ import random
from typing import Optional
from colorama import Fore, Style
from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY
from .config import get_system_config, logger
from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY, VERSION
from .config import get_system_config, set_system_config, logger
from .cli_base import BaseAICLI
from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL
from .cli_clawli import ClawliMixin
@@ -22,6 +22,17 @@ from .plugin_market import PluginMarketMixin
from .notification import notify_task_complete, get_notification_manager
from .process_protection import get_protection_status
# ── Plan 模式 system prompt 追加段(只读规划约束)──
PLAN_MODE_SYSTEM_APPEND = """
【PLAN 模式 — 只读规划】
你当前处于 PLAN 模式。此模式下你只能进行调查、阅读、分析,不得执行任何修改。
- 你可以使用只读工具调研代码库:code_search(搜索/结构/符号/统计)、file_manager 的 list/read/info/search、code_editor 的 read_range/find/diff、git_tools 的 status/log/diff/show/blame、tool_search(查询工具用法)、ai_search(联网搜索)。
- 你**不能**执行写操作(编辑/创建/删除文件、运行 shell 命令、git commit/push/checkout、委派会修改的子代理等)——这些会被系统拦截并返回提示。
- 调研完成后,输出一份清晰的、带编号步骤的「实施计划」,让用户审批。计划应包含:每步要做什么、涉及哪些文件/函数、预期结果、潜在风险。
- 计划以如下标记开头:## 实施计划
- 完成计划后停止,等待用户输入 /build 批准执行,或继续追问以完善计划。不要自行开始修改。"""
class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, HistoryMixin, PluginMarketMixin):
"""AI CLI主程序 - 组合所有 Mixin"""
@@ -31,7 +42,7 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
def process_thinking_response(self, response):
"""处理深度思考AI返回的内容, 分离思考和回复内容"""
try:
response_data = json.loads(response)
response_data = self._loads_json(response)
if isinstance(response_data, dict) and response_data.get('action') == 'use_tool':
return response
if isinstance(response_data, dict):
@@ -143,7 +154,20 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
你的思考过程:
1. 需要 git 操作 → 先查 git_tools 怎么用
2. {{"action": "use_tool", "tool": "tool_search", "args": "git_tools"}} → 发现 smart-commit 操作
3. {{"action": "use_tool", "tool": "git_tools", "args": "smart-commit"}} → 自动提交"""
3. {{"action": "use_tool", "tool": "git_tools", "args": "smart-commit"}} → 自动提交 """
# 参数格式统一示例:无论用哪种工具,args 都是同一个字符串外壳,给 AI 一个速查锚点
unified_arg_example = """【参数格式统一示例】
所有工具共用同一套 JSON 外壳,`args` 永远是【字符串】(不是 JSON 对象):
{"action":"use_tool","tool":"<工具名>","args":"<操作> <参数>"}
高频工具的 args 写法(照抄即可):
- code_editor : "edit 文件 旧<<<>>>新" | "read_range 文件 起 止" | "create 文件 内容"
- code_search : "find . 关键词 *.py" | "structure ."
- git_tools : "status" | "commit 说明文字"
- cmd_executor: "dir" | "python main.py"
说明:多行内容(如 edit 的代码片段)直接换行写在 args 里,无需转义;一次可返回多个 JSON 指令。
调用方式可选:纯 JSON(默认)或 function calling(引擎支持时),两种都会被正确处理。"""
if liugin_prompts:
prompt = f"""{base_prompt}
@@ -152,6 +176,8 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
{tool_search_rule}
{unified_arg_example}
【工具调用格式】
在回复中包含 JSON 指令来执行操作:
@@ -219,6 +245,8 @@ multi 操作支持一次修改多处:
{tool_search_rule}
{unified_arg_example}
【工具调用格式】
{{"action": "use_tool", "tool": "工具名", "args": "参数"}}
@@ -253,6 +281,20 @@ multi 操作支持一次修改多处:
except Exception:
pass
# ── Plan 模式:追加只读规划约束 ──
if getattr(self, 'plan_mode', False):
prompt += PLAN_MODE_SYSTEM_APPEND
# ── LSP 诊断自动注入:让模型持续看到真实语言诊断 ──
liugin_manager = getattr(self, 'liugin_manager', None)
if liugin_manager and hasattr(liugin_manager, 'get_lsp_context'):
try:
lsp_ctx = liugin_manager.get_lsp_context()
if lsp_ctx:
prompt += "\n\n# 当前代码诊断(来自 LSP 语言服务器)\n" + lsp_ctx
except Exception:
pass
return prompt
# ── JSON 解析 ──
@@ -285,11 +327,15 @@ multi 操作支持一次修改多处:
plugins.append(tool)
if plugins:
prompts.append("## 内置工具(使用 tool_search 查询详细用法):")
for tool in plugins:
tool_name = tool.get('name', '')
tool_desc = tool.get('description', '')
prompts.append(f"- {tool_name}: {tool_desc}")
# 工具名清单(纯 JSON 模式:不发送 FC schema,模型按 action JSON 调用)。
# 只列名字 + 由 tool_search 提供用法,避免重复曝光、省 token。
names = ", ".join(t.get('name', '') for t in plugins)
prompts.append(
"## 内置工具(纯 JSON 模式:不发送 function calling 的 tools schema,"
"模型按下方 JSON 格式直接调用,调用前请先用 tool_search 查询其详细用法与示例)\n"
f"工具名: {names}\n"
'调用格式: {"action": "use_tool", "tool": "工具名", "args": "参数"}'
)
if skills:
prompts.append("\n## 扩展技能(已加载详细指令):")
@@ -328,6 +374,18 @@ multi 操作支持一次修改多处:
return "\n".join(prompts)
def _loads_json(self, s):
"""json.loads,但容忍未成对出现的反斜杠(Windows 路径 C:\\Users 常见)。
纯 JSON 模式下模型常把路径写成单反斜杠,直接 json.loads 会报非法转义。
这里先按标准解析;失败时把「未成对的反斜杠」转义后再试一次。
"""
try:
return json.loads(s)
except json.JSONDecodeError:
fixed = re.sub(r'(?<!\\)\\(?!\\\\)', r'\\\\', s)
return json.loads(fixed)
def _parse_mixed_response(self, response):
"""
解析混合响应,处理同时包含文本和JSON指令的内容
@@ -340,7 +398,7 @@ multi 操作支持一次修改多处:
json_str = response[11:]
json_str = "{" + json_str
try:
parsed = json.loads(json_str)
parsed = self._loads_json(json_str)
if isinstance(parsed, dict):
return "", parsed
except json.JSONDecodeError:
@@ -348,7 +406,7 @@ multi 操作支持一次修改多处:
# 方法1: 直接解析为纯JSON
try:
parsed = json.loads(response)
parsed = self._loads_json(response)
if isinstance(parsed, (list, dict)):
return "", parsed
except json.JSONDecodeError:
@@ -365,7 +423,7 @@ multi 操作支持一次修改多处:
content = content.replace('"', '\\"')
return f'"{content}"'
fixed_response = re.sub(r'"([^"]*(?:\n[^"]*)*)"', fix_json_string, response, flags=re.DOTALL)
parsed = json.loads(fixed_response)
parsed = self._loads_json(fixed_response)
if isinstance(parsed, (list, dict)):
return "", parsed
except:
@@ -376,7 +434,7 @@ multi 操作支持一次修改多处:
matches = re.findall(json_pattern, response, re.DOTALL)
if matches:
try:
json_data = json.loads(matches[-1])
json_data = self._loads_json(matches[-1])
if isinstance(json_data, (dict, list)):
text_content = re.sub(json_pattern, "", response, flags=re.DOTALL).strip()
text_content = re.sub(r"\n\s*\n", "\n\n", text_content).strip()
@@ -392,7 +450,7 @@ multi 操作支持一次修改多处:
line = line.strip()
if line.startswith("{") and line.endswith("}"):
try:
parsed = json.loads(line)
parsed = self._loads_json(line)
if isinstance(parsed, dict):
json_objects.append(parsed)
continue
@@ -430,7 +488,7 @@ multi 操作支持一次修改多处:
if in_json and brace_count == 0:
json_str = response[json_start:i+1]
try:
json_data = json.loads(json_str)
json_data = self._loads_json(json_str)
if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']:
text_content = response[:json_start].strip()
return text_content, json_data
@@ -442,7 +500,7 @@ multi 操作支持一次修改多处:
potential_jsons = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL)
for json_str in potential_jsons:
try:
json_data = json.loads(json_str)
json_data = self._loads_json(json_str)
if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']:
json_start = response.find(json_str)
text_content = response[:json_start].strip()
@@ -460,12 +518,12 @@ multi 操作支持一次修改多处:
if last_brace_pos != -1:
json_part = cleaned[last_brace_pos:]
if json_part.startswith('['):
parsed = json.loads(json_part)
parsed = self._loads_json(json_part)
if isinstance(parsed, list):
text_content = cleaned[:last_brace_pos].strip()
return text_content, parsed
else:
parsed = json.loads(json_part)
parsed = self._loads_json(json_part)
if isinstance(parsed, dict):
text_content = cleaned[:last_brace_pos].strip()
return text_content, parsed
@@ -714,17 +772,34 @@ multi 操作支持一次修改多处:
except Exception:
pass
# ── 自动压缩上下文 ──
# ── 自动压缩上下文(token 感知优先,消息数兜底)──
try:
if self.memory_manager.should_compress(self.shared_conversation_history):
self.shared_conversation_history = self.memory_manager.compress_context(
self.shared_conversation_history,
engine=self.current_engine
)
self._set_shared_conversation_history()
print(f"{Fore.DIM}[上下文已压缩,保留最近 10 条]{Style.RESET_ALL}")
except Exception:
pass
if hasattr(self, 'memory_manager') and self.memory_manager:
engine = self.current_engine
prompt_tokens = getattr(engine, 'last_prompt_tokens', None) if engine else None
# 统一 API:所有引擎必须返回 max_input_tokens(最大输入 token 数)
ctx = None
if engine:
try:
ctx = engine.max_input_tokens
except Exception:
ctx = None
ratio = get_system_config('compress_ratio', 0.7)
self.memory_manager.set_compress_ratio(ratio)
if self.memory_manager.should_compress(
self.shared_conversation_history, prompt_tokens, ctx, ratio
):
before = len(self.shared_conversation_history)
self.shared_conversation_history = self.memory_manager.compress_context(
self.shared_conversation_history,
engine=engine
)
token_info = f",token {prompt_tokens}/{ctx}" if (prompt_tokens and ctx) else ""
self._output(
f"{Fore.CYAN}🗜 上下文已自动压缩 (原 {before} 条 → {len(self.shared_conversation_history)} 条{token_info},阈值 {ratio}){Style.RESET_ALL}"
)
except Exception as e:
logger.warning(f"自动压缩上下文失败: {e}")
def _extract_task_summary(self, user_input: str) -> str:
"""提取任务摘要用于通知显示"""
@@ -744,7 +819,7 @@ multi 操作支持一次修改多处:
return None
try:
data = json.loads(response)
data = self._loads_json(response)
if not data.get("_fc"):
return None
@@ -779,16 +854,15 @@ multi 操作支持一次修改多处:
# 通过插件管理器执行
result = self._execute_tool_by_name(tool_name, tool_args)
# 记录工具结果到历史
# 记录工具结果到历史(官方 OpenAI tool 消息不含 name 字段)
tool_result_msg = {
"role": "tool",
"tool_call_id": call_id,
"name": tool_name,
"content": result[:5000] if result else "执行完成"
"content": result if result else "执行完成"
}
self.shared_conversation_history.append(tool_result_msg)
results.append(f"[{tool_name}]: {result[:200] if result else '完成'}")
results.append(f"[{tool_name}]: {result if result else '完成'}")
# 返回工具结果,供下一轮 AI 处理
return "工具执行结果:\n" + "\n".join(results)
@@ -797,7 +871,13 @@ multi 操作支持一次修改多处:
return None
def _execute_tool_by_name(self, tool_name: str, tool_args: str) -> str:
"""根据名称执行工具(FC 调用路径)"""
"""根据名称执行工具(FC 调用路径)
注意:插件 handler 可能返回 ToolResult / dict / str,
这里统一归一成 str,保证调用方能安全做切片与拼接。
"""
from .tool_result import normalize_tool_text
# 安全检查
safety = get_safety()
allowed, msg = safety.check(tool_name, tool_args)
@@ -808,25 +888,61 @@ multi 操作支持一次修改多处:
tool = self.liugin_manager.get_tool_by_name(tool_name)
if tool and 'handler' in tool:
try:
return tool['handler'](tool_args)
return normalize_tool_text(tool['handler'](tool_args), default="执行完成")
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)
return normalize_tool_text(self.memory_manager.handle_tool(tool_args), default="执行完成")
# 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)
return normalize_tool_text(t['handler'](tool_args), default="执行完成")
except Exception as e:
return f"工具执行失败: {e}"
return f"未找到工具: {tool_name}"
def _build_fc_tools(self, current_input):
"""构建本轮下发给引擎的 FC 工具子集,让引擎自行决定用 FC 结构化调用还是回退纯 JSON。
引擎不支持 FC(探测缓存未通过)时返回 None —— 引擎自动降级为纯 JSON 模式。
仅对带 `_fc_supported()` 的引擎下发 tools(Manual 等不支持的引擎不传 tools,
避免关键字参数错误)。
"""
engine = self.current_engine
if not engine or not hasattr(engine, '_fc_supported'):
return None
try:
if not engine._fc_supported():
return None
except Exception:
return None
# 工具结果续轮:模型只需决定「继续/收尾」,不需要 FC schema,省下每轮重发的 token。
# 若模型仍想调工具,走纯 JSON action(无需 schema 即可),行为等价、成本更低。
if current_input and str(current_input).lstrip().startswith("工具执行结果"):
return None
try:
from .fc_tools import mcp_to_openai_tools, prune_tools
all_tools = mcp_to_openai_tools(self.liugin_manager)
if not all_tools:
return None
return prune_tools(all_tools, current_input)
except Exception:
return None
def _call_engine_with_tools(self, current_input, system_prompt, fc_tools):
"""调用当前引擎;fc_tools 非空时下发 FC 工具定义(tool_choice=auto,模型自选)。"""
if fc_tools:
return self.current_engine.generate_response(
current_input, system_prompt=system_prompt, tools=fc_tools)
return self.current_engine.generate_response(
current_input, system_prompt=system_prompt)
def _generate_response_with_animation(self, current_input, liugin_prompts=None):
"""生成AI响应并显示等待动画 — 支持 ESC 跨平台取消"""
# TUI 模式:不做动画
@@ -834,14 +950,10 @@ multi 操作支持一次修改多处:
system_prompt = self._build_system_prompt(liugin_prompts)
if not self.current_engine:
return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
# 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)
# 混合模式:下发 FC 工具定义,tool_choice=auto —— 模型可自行选择
# 用 FC 结构化调用,或回退到纯 JSON action(两种都会被正确处理)。
fc_tools = self._build_fc_tools(current_input)
response = self._call_engine_with_tools(current_input, system_prompt, fc_tools)
return response
# CLI 模式:带动画和跨平台 ESC 取消
@@ -851,12 +963,18 @@ multi 操作支持一次修改多处:
response_ready = threading.Event()
response_result = [None] # 用列表存储,方便线程内修改
# 是否真实交互终端:非 TTY(管道 / 日志镜像 / 聊天前端捕获 stdout)时,
# \r 无法原地覆盖,逐帧动画会污染输出流,必须禁用。
is_tty = sys.stdout.isatty()
love_sentences = []
love_file_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), LOVE_FILE_PATH)
if os.path.exists(love_file_path):
try:
with open(love_file_path, 'r', encoding='utf-8') as f:
love_sentences = [line.strip() for line in f.readlines() if line.strip()]
love_sentences = [line.strip() for line in f.readlines()
if line.strip()
and not line.strip().lower().startswith(('http://', 'https://'))]
except Exception:
pass
if not love_sentences:
@@ -903,6 +1021,13 @@ multi 操作支持一次修改多处:
# ── 动画线程 ──
def show_animation():
# 非 TTY:不跑逐帧 \r 动画(避免残帧进入捕获流),但保留一句随机情话保人味
if not is_tty:
try:
print(f"💭 {random.choice(love_sentences)}", flush=True)
except Exception:
pass
return
last_change_time = time.time()
current_sentence = random.choice(love_sentences)
while animation_running.is_set():
@@ -923,19 +1048,9 @@ 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, tools=fc_tools
)
# 混合模式:下发 FC 工具(tool_choice=auto),模型自选 FC 或纯 JSON
fc_tools = self._build_fc_tools(current_input)
response_result[0] = self._call_engine_with_tools(current_input, system_prompt, fc_tools)
except Exception as e:
response_result[0] = f"AI 响应生成失败: {e}"
finally:
@@ -956,7 +1071,8 @@ multi 操作支持一次修改多处:
# ESC 被按下 → 取消
if esc_pressed.is_set():
# 清除动画行
print("\r" + " " * 80 + "\r", end="", flush=True)
if is_tty:
print("\r" + " " * 80 + "\r", end="", flush=True)
print(f"\n{Fore.YELLOW}⚡ AI 请求已取消 (ESC){Style.RESET_ALL}")
return "[AI请求已取消]"
@@ -964,7 +1080,8 @@ multi 操作支持一次修改多处:
response = response_result[0]
if response:
# 清除动画行后再输出
print("\r" + " " * 80 + "\r", end="", flush=True)
if is_tty:
print("\r" + " " * 80 + "\r", end="", flush=True)
self._typeprint(response, Fore.CYAN)
if response and self.shared_conversation_history is not None:
@@ -980,79 +1097,187 @@ multi 操作支持一次修改多处:
for t in threads:
t.join(timeout=1)
# ── Plan 模式(只读规划 → 审批 → 执行)──
def _sync_current_plan(self):
"""Plan 模式下,把对话历史中最后一条 assistant 内容记为当前计划。"""
if not getattr(self, 'plan_mode', False):
return
for msg in reversed(self.shared_conversation_history):
if isinstance(msg, dict) and msg.get("role") == "assistant" and msg.get("content"):
self.current_plan = msg["content"]
break
def handle_plan_command(self, args):
"""处理 /plan 命令。进入 PLAN 模式(只读规划)。
/plan 进入模式,等待描述任务
/plan <任务> 进入模式并立即只读调研、生成实施计划
/plan off|exit 退出模式(计划不执行)
"""
args = (args or "").strip()
if args in ('off', 'exit', '退出', 'cancel'):
self.plan_mode = False
print(f"{Fore.YELLOW}已退出 PLAN 模式(计划未执行){Style.RESET_ALL}")
return
self.plan_mode = True
if args:
print(f"{Fore.CYAN}已进入 PLAN 模式,正在只读调研并生成实施计划...{Style.RESET_ALL}")
self.process_conversation(args)
self._autosave_session()
self._sync_current_plan()
else:
print(f"{Fore.CYAN}已进入 PLAN 模式。{Style.RESET_ALL}")
print(f" 描述你的任务,AI 将只做只读调研并给出实施计划;"
f"完成后用 {Fore.WHITE}/build{Style.RESET_ALL} 批准执行,或 "
f"{Fore.WHITE}/plan off{Style.RESET_ALL} 取消。{Style.RESET_ALL}")
def handle_agent_command(self, arg: str):
"""@<agent名> <任务> 委派给指定 agent 独立执行。"""
mgr = getattr(self, 'agent_manager', None)
if mgr is None:
print(f"{Fore.RED}Agents 管理器未初始化{Style.RESET_ALL}")
return
if not arg:
names = mgr.names()
if names:
print(f"{Fore.CYAN}可用 Agents: {', '.join(names)}{Style.RESET_ALL}")
else:
print(f"{Fore.YELLOW}暂无 agent 定义(在 agents/ 目录放置 *.md){Style.RESET_ALL}")
return
parts = arg.split(maxsplit=1)
name = parts[0]
task = parts[1] if len(parts) > 1 else ""
agent = mgr.get(name)
if not agent:
print(f"{Fore.RED}未找到 agent: {name}{Style.RESET_ALL}")
return
print(f"{Fore.CYAN}委派给 agent「{agent.name}」: {task}{Style.RESET_ALL}")
try:
result = mgr.dispatch(name, task, self._agent_runner)
except Exception as e:
print(f"{Fore.RED}agent 执行失败: {e}{Style.RESET_ALL}")
return
print(f"{Fore.GREEN}{result}{Style.RESET_ALL}")
def _agent_runner(self, system_prompt, task, tools, model):
"""agent 执行器:用当前引擎跑一轮独立上下文(不污染主会话)。"""
engine = getattr(self, 'current_engine', None) or getattr(self, 'engine', None)
if engine is None:
return "错误:当前无可用的 AI 引擎"
prompt = task
if system_prompt:
prompt = f"{system_prompt}\n\n---\n用户任务:\n{task}"
return engine.generate_response(prompt, system_prompt=system_prompt)
def handle_build_command(self):
"""处理 /build 命令。从 PLAN 模式进入执行(用已批准计划驱动)。"""
plan = getattr(self, 'current_plan', '') or ""
if not getattr(self, 'plan_mode', False) and not plan.strip():
print(f"{Fore.YELLOW}当前不在 PLAN 模式,且无已生成的计划{Style.RESET_ALL}")
return
self.plan_mode = False
if not plan.strip():
print(f"{Fore.YELLOW}尚未生成实施计划,先 /plan 描述任务让 AI 调研{Style.RESET_ALL}")
return
print(f"{Fore.GREEN}已批准计划,开始执行...{Style.RESET_ALL}")
instruction = ("【已批准的实施计划,请现在严格按照以下步骤执行,利用可用工具完成每一步;"
"遇到与计划不符的情况先说明再继续】\n\n" + plan)
self.process_conversation(instruction)
self._autosave_session()
# ── CLI 主循环 ──
def run_tui(self):
"""运行 TUI 模式"""
if not TEXTUAL_AVAILABLE:
print(f"{Fore.RED}TUI 模式不可用:Textual 库未安装{Style.RESET_ALL}")
print(f"{Fore.YELLOW}请运行: pip install textual rich{Style.RESET_ALL}")
"""运行 TUI 模式(v8.0 默认真·全屏 textual TUI;textual 不可用时回退 rich 面板)"""
if TEXTUAL_AVAILABLE:
from .tui import XiaoliTUI
app = XiaoliTUI(self)
app.run()
return
from .tui import XiaoliTUI
app = XiaoliTUI(self)
app.run()
# 回退 rich 面板 TUI(textual 不可用时)
try:
from .rich_tui import RichTUI
from xcli_core.constants import VERSION as _V
tui = RichTUI(self)
tui.version = _V
tui.set_status(
engine=getattr(self.current_engine, 'name', '') if self.current_engine else '',
model=getattr(self.current_engine, 'model', '') if self.current_engine else '',
mode='TUI',
)
self.tui_output_callback = tui.push
tui.run()
self.tui_output_callback = None
except ImportError:
print(f"{Fore.RED}TUI 不可用: 请安装 textual (pip install textual){Style.RESET_ALL}")
def run(self):
"""运行 CLI"""
import uuid
self.user_id = str(uuid.getnode())
print(f"{Fore.GREEN}小狸 Pro-CLI 已启动!{Style.RESET_ALL}")
print(f"{Fore.GREEN}用户ID: {self.user_id}{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/help' 查看帮助信息{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/quit' 退出程序{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/tui' 切换到 TUI 模式{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/engine list' 查看可用AI引擎{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/engine switch <引擎名>' 切换AI引擎{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/safe' 切换安全模式 (普通→人工→无限制){Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/notify' 切换任务完成通知 (开/关){Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/scheduler' 或 '/remind' 管理定时任务{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/memory' 管理记忆系统 (日记/搜索/聊天记录){Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/file.read <文件名> [行数]' 直接读取文件内容{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '@文件路径' 自动读取文件内容并发送给AI{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '@图片路径' 自动分析图片并发送描述给AI{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/image engines' 查看图像识别引擎{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/chat save <名称>' 保存当前聊天记录{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/chat list' 查看所有已保存的聊天记录{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/chat open <名称>' 加载聊天记录{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/remote' 查看远程连接帮助{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/plugin' 管理插件市场 (安装/卸载/搜索){Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/protect' 查看进程保护状态{Style.RESET_ALL}")
current_engine_name = getattr(self.current_engine, 'name', '未设置') if self.current_engine else '未设置'
print(f"{Fore.GREEN}当前使用的AI引擎: {current_engine_name}{Style.RESET_ALL}")
model_name = getattr(self.current_engine, 'model', '') if self.current_engine else ''
engine_desc = f"{current_engine_name} ({model_name})" if model_name else current_engine_name
print(f"{Fore.GREEN}小狸 Pro-CLI v{VERSION} 已启动!{Style.RESET_ALL} "
f"{Fore.WHITE}引擎: {engine_desc}{Style.RESET_ALL}")
# 一行速览,完整命令清单收进 /help
print(f"{Fore.CYAN}/help 全部命令 · /quit 退出 · /tui 图形界面 · "
f"/model 换模型 · /resume 恢复会话 · /plan 规划模式 · @文件路径 读文件给 AI{Style.RESET_ALL}")
# 历史会话提示(opencode 式 /resume)
try:
sessions = self.session_manager.list_sessions()
if sessions:
latest = sessions[0]
print(f"{Fore.YELLOW}有 {len(sessions)} 个历史会话,/resume 可恢复"
f"(最近: {latest.id} · {latest.title}){Style.RESET_ALL}")
except Exception:
pass
extras = []
loaded_engines = list(self.engines.keys())
if len(loaded_engines) > 1:
print(f"{Fore.GREEN}已加载的AI引擎: {', '.join(loaded_engines)}{Style.RESET_ALL}")
print(f"{Fore.GREEN}{'-' * 50}{Style.RESET_ALL}")
# 显示进程保护状态
if hasattr(self, 'protection_results') and self.protection_results:
extras.append(f"可切换引擎: {', '.join(loaded_engines)}")
# 进程保护:只在有未生效项时才提示,全绿就闭嘴
if getattr(self, 'protection_results', None):
r = self.protection_results
si_icon = '' if r.get('single_instance') else ''
prio_icon = '' if r.get('priority') else ''
ppl_icon = '' if r.get('ppl_protection') else ''
wd_icon = '' if r.get('watchdog') else ''
print(f"{Fore.CYAN} 进程保护: {si_icon}单实例 {prio_icon}优先级 {ppl_icon}PPL保护 {wd_icon}看门狗{Style.RESET_ALL}")
failed = [n for k, n in (('single_instance', '单实例'), ('priority', '优先级'),
('ppl_protection', 'PPL保护'), ('watchdog', '看门狗'))
if not r.get(k)]
if failed:
extras.append(f"进程保护未生效: {', '.join(failed)}")
if extras:
print(f"{Fore.BLACK}{Style.BRIGHT}{' · '.join(extras)}{Style.RESET_ALL}")
# 询问 diff 弹窗模式
print(f"{Fore.BLACK}{Style.BRIGHT}用户ID: {self.user_id}{Style.RESET_ALL}")
# 恢复 diff 显示模式(首次运行才询问)
self._ask_diff_popup_mode()
self._run_cli_loop()
def _ask_diff_popup_mode(self):
"""启动时询问 diff 显示模式"""
# 找到 code_editor 插件
code_editor = None
def _get_code_editor_plugin(self):
"""拿到 code_editor 插件实例(找不到返回 None)"""
for tool in self.liugin_manager.tools:
if tool.get('name') == 'code_editor':
code_editor = tool.get('handler')
break
return getattr(tool.get('handler'), '__self__', None)
return None
if code_editor is None:
def _ask_diff_popup_mode(self):
"""设置 diff 显示模式:已保存过就静默沿用,只有首次运行才交互询问"""
plugin_instance = self._get_code_editor_plugin()
if plugin_instance is None:
return
# 找到插件实例(通过 handler 的 __self__)
plugin_instance = getattr(code_editor, '__self__', None)
if plugin_instance is None:
saved = get_system_config('diff_popup_mode', None)
if saved is not None:
plugin_instance.diff_popup_mode = bool(saved)
return
print(f"\n{Fore.CYAN} Diff 显示模式(AI 修改文件后的对比方式):{Style.RESET_ALL}")
@@ -1060,19 +1285,23 @@ multi 操作支持一次修改多处:
print(f" {Fore.WHITE}2{Style.RESET_ALL} - 主终端内显示(SSH 兼容,推荐)")
try:
choice = input(f"\n{Fore.WHITE} 请选择 [1/2](默认 2): {Style.RESET_ALL}").strip()
choice = input(f"\n{Fore.WHITE} 请选择 [1/2](默认 2,之后可用 /diff 改): {Style.RESET_ALL}").strip()
except (EOFError, KeyboardInterrupt):
choice = '2'
if choice == '1':
plugin_instance.diff_popup_mode = True
print(f"{Fore.GREEN} ✓ 已选择: 弹窗模式{Style.RESET_ALL}")
else:
plugin_instance.diff_popup_mode = False
print(f"{Fore.GREEN} ✓ 已选择: 主终端内显示{Style.RESET_ALL}")
self._set_diff_popup_mode(choice == '1', plugin_instance)
print()
def _set_diff_popup_mode(self, popup, plugin_instance=None):
"""写入并持久化 diff 显示模式"""
if plugin_instance is None:
plugin_instance = self._get_code_editor_plugin()
if plugin_instance is not None:
plugin_instance.diff_popup_mode = bool(popup)
set_system_config('diff_popup_mode', bool(popup))
label = '弹窗模式' if popup else '主终端内显示'
print(f"{Fore.GREEN} ✓ Diff 显示模式: {label}{Style.RESET_ALL}")
def _run_cli_loop(self):
"""运行 CLI 主循环"""
while True:
@@ -1083,6 +1312,11 @@ multi 操作支持一次修改多处:
print(f"{Fore.GREEN}再见!{Style.RESET_ALL}")
break
# @agent 委派(opencode 式 Agents 体系化)
if user_input.startswith('@'):
self.handle_agent_command(user_input[1:].strip())
continue
if user_input == '/help':
self.show_help()
continue
@@ -1110,6 +1344,38 @@ multi 操作支持一次修改多处:
print(f"{Fore.RED}当前引擎不支持该命令或命令执行失败{Style.RESET_ALL}")
continue
# /model 命令族 — OpenAI 引擎多模型在线增删切换
if user_input == '/model' or user_input.startswith('/model '):
self.handle_model_command(user_input[7:].strip())
continue
# /providers — opencode 式原生多 Provider 注册与状态一览
if user_input == '/providers' or user_input.startswith('/providers '):
self.handle_providers_command(user_input[11:].strip())
continue
# /plan 进入 PLAN 模式(只读规划 → 审批 → 执行)
if user_input == '/plan' or user_input.startswith('/plan '):
sub = user_input[6:].strip() if user_input.startswith('/plan ') else ''
self.handle_plan_command(sub)
continue
if user_input == '/build':
self.handle_build_command()
continue
# /resume 会话恢复(opencode 式自动持久化 + 一键恢复)
if user_input in ('/resume', '/sessions') or \
user_input.startswith('/resume ') or user_input.startswith('/sessions ') or \
user_input.startswith('/session '):
if user_input.startswith('/resume'):
self.handle_resume_command(user_input[7:].strip())
elif user_input.startswith('/sessions'):
self.handle_resume_command(user_input[9:].strip())
else: # /session <子命令>
sub = user_input[8:].strip()
self.handle_resume_command(sub if sub != 'new' else 'new')
continue
# /manual 快捷命令 — 切换到 manual 引擎或执行 manual 子命令
if user_input == '/manual' or user_input.startswith('/manual '):
args = user_input[8:].strip() if user_input.startswith('/manual ') else ''
@@ -1215,6 +1481,30 @@ multi 操作支持一次修改多处:
print(f"{Fore.CYAN}已从 TUI 模式返回 CLI 模式{Style.RESET_ALL}")
continue
if user_input == '/compress':
if hasattr(self, 'memory_manager') and self.memory_manager:
engine = self.current_engine
before = len(self.shared_conversation_history)
self.shared_conversation_history = self.memory_manager.compress_context(
self.shared_conversation_history, engine=engine)
self._set_shared_conversation_history()
print(f"{Fore.CYAN}已手动压缩上下文: {before} → {len(self.shared_conversation_history)} 条{Style.RESET_ALL}")
else:
print(f"{Fore.YELLOW}记忆系统未加载,无法压缩{Style.RESET_ALL}")
continue
if user_input.startswith('/memory mode '):
mode = user_input[len('/memory mode '):].strip().lower()
if mode in ('companion', 'work'):
try:
set_system_config('memory_mode', mode)
except Exception:
pass
print(f"{Fore.GREEN}记忆模式已设为: {mode}{Style.RESET_ALL}")
else:
print(f"{Fore.RED}用法: /memory mode <companion|work>{Style.RESET_ALL}")
continue
if user_input.startswith('/safe'):
parts = user_input.split()
safety = get_safety()
@@ -1238,6 +1528,42 @@ multi 操作支持一次修改多处:
print(f"{icon} 安全模式: {mode_name}")
continue
if user_input.startswith('/diff'):
parts = user_input.split()
if self._get_code_editor_plugin() is None:
print(f"{Fore.YELLOW}code_editor 插件未加载,无法设置 diff 模式{Style.RESET_ALL}")
continue
if len(parts) > 1:
arg = parts[1].lower()
if arg in ('popup', '弹窗', '1'):
self._set_diff_popup_mode(True)
elif arg in ('inline', '内联', '主终端', '2'):
self._set_diff_popup_mode(False)
else:
print(f"{Fore.RED}用法: /diff [popup|inline]{Style.RESET_ALL}")
else:
cur = bool(get_system_config('diff_popup_mode', False))
self._set_diff_popup_mode(not cur)
continue
if user_input.startswith('/fc'):
from .fc_tools import get_fc_cache, reset_fc_cache
parts = user_input.split()
if len(parts) > 1 and parts[1].lower() in ('reset', 'clear', '清除'):
n = reset_fc_cache()
print(f"{Fore.GREEN}已清除 {n} 条 FC 探测记录,下次请求会重新试探{Style.RESET_ALL}")
else:
cache = get_fc_cache()
if not cache:
print(f"{Fore.GREEN}FC 探测缓存为空:所有模型都按支持 function calling 处理{Style.RESET_ALL}")
else:
print(f"{Fore.CYAN}已探测到不支持 function calling 的模型:{Style.RESET_ALL}")
for key, ok in cache.items():
if ok is False:
print(f" {Fore.YELLOW}✗{Style.RESET_ALL} {key}")
print(f"{Fore.WHITE}这些请求会自动跳过 tools 字段;/fc reset 可清除重测{Style.RESET_ALL}")
continue
if user_input.startswith('/notify'):
parts = user_input.split()
nm = get_notification_manager()
@@ -1297,6 +1623,10 @@ multi 操作支持一次修改多处:
print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}")
self.process_conversation(user_input)
# 自动持久化当前会话(opencode 式 /resume 落盘)
self._autosave_session()
# Plan 模式下:把本轮最后一条 assistant 内容记为当前计划
self._sync_current_plan()
except KeyboardInterrupt:
user_id_display = f"[用户ID: {self.user_id}]"
print(f"\n{Fore.GREEN}再见! {user_id_display}{Style.RESET_ALL}")
@@ -1377,12 +1707,22 @@ def main():
import codecs
sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict')
for line in art_lines:
def _safe_print(line):
try:
print(line, flush=True)
except UnicodeEncodeError:
print(line.encode('gbk', errors='ignore').decode('gbk'), flush=True)
time.sleep(0.05)
# 完整开机动画(57 行 + 逐行 0.05s)默认不放,用 --logo 显式召唤
if '--logo' in sys.argv or '--banner' in sys.argv:
for line in art_lines:
_safe_print(line)
time.sleep(0.05)
else:
_safe_print(f"\n{Fore.CYAN} ▄▀▄ 小狸 Pro-CLI v{VERSION}{Style.RESET_ALL}")
_safe_print(f"{Fore.CYAN} ▀▄▀ {Style.RESET_ALL}"
f"{Fore.WHITE}AI 智能编程助手{Style.RESET_ALL}"
f" {Fore.BLACK}{Style.BRIGHT}(--logo 看完整开机动画){Style.RESET_ALL}")
cli = AICLI()
cli.run()
+43
View File
@@ -376,8 +376,51 @@ class DisplayMixin:
# ── 帮助系统 ──
def show_quick_commands(self):
"""常用命令速查表(启动时不再刷屏,改由 /help 展示)"""
rows = [
('/help [插件名]', '显示帮助信息'),
('/quit', '退出程序'),
('/tui', '切换到 TUI 图形模式'),
('/engine list', '查看可用 AI 引擎'),
('/engine switch <名>', '切换 AI 引擎'),
('/model', '列出已配置的 OpenAI 模型'),
('/model <名称>', '切换当前 OpenAI 模型'),
('/model add', '交互式新增 OpenAI 模型'),
('/model rm <名称>', '删除已配置的模型'),
('/fc [reset]', '查看/清除 FC 能力探测缓存'),
('/safe', '切换安全模式 (普通→人工→无限制)'),
('/diff', '切换 diff 显示模式 (弹窗/主终端)'),
('/notify', '切换任务完成通知 (开/关)'),
('/scheduler 或 /remind', '管理定时任务'),
('/memory', '管理记忆系统 (日记/搜索/聊天记录)'),
('/file.read <文件> [行数]', '直接读取文件内容'),
('@文件路径', '自动读取文件内容并发送给 AI'),
('@图片路径', '自动分析图片并发送描述给 AI'),
('/image engines', '查看图像识别引擎'),
('/chat save|list|open', '保存/查看/加载聊天记录'),
('/resume', '列出历史会话 (自动持久化)'),
('/resume <序号或ID>', '恢复指定会话继续对话'),
('/resume new', '开始一个全新的会话'),
('/plan [任务]', '进入 PLAN 模式:只读调研并生成实施计划'),
('/build', '批准并Plan模式生成的计划,开始执行'),
('/remote', '查看远程连接帮助'),
('/plugin', '管理插件市场 (安装/卸载/搜索)'),
('/protect', '查看进程保护状态'),
]
width = max(len(cmd) for cmd, _ in rows)
print(f"{Fore.GREEN}常用命令:{Style.RESET_ALL}")
for cmd, desc in rows:
print(f" {Fore.WHITE}{cmd.ljust(width)}{Style.RESET_ALL} {desc}")
print()
print(f"{Fore.GREEN}启动参数:{Style.RESET_ALL}")
print(f" {Fore.WHITE}{'--verbose / -v'.ljust(width)}{Style.RESET_ALL} 打印引擎/插件/技能的逐条加载明细")
print(f" {Fore.WHITE}{'--logo'.ljust(width)}{Style.RESET_ALL} 播放完整开机动画")
print()
def show_help(self):
"""显示帮助信息"""
self.show_quick_commands()
help_file_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "HELP.md")
if os.path.exists(help_file_path):
try:
+61 -1
View File
@@ -2,10 +2,11 @@ import os
import time
import json
from colorama import Fore, Style
from .session import SessionManager
class HistoryMixin:
"""聊天记录管理相关方法"""
"""聊天记录管理相关方法(含 opencode 式会话持久化 /resume)"""
def save_chat_history(self, name):
"""保存当前聊天记录到文件"""
@@ -58,3 +59,62 @@ class HistoryMixin:
except Exception as e:
print(f"{Fore.RED}加载聊天记录失败: {e}{Style.RESET_ALL}")
return False
# ── opencode 式会话(自动持久化 + /resume) ──
def init_session_manager(self):
"""初始化会话管理器与当前会话状态。在 chat_history_dir 就绪后调用。"""
self.session_manager = SessionManager(self.chat_history_dir)
self.current_session = None # 首轮对话时惰性创建
def _autosave_session(self):
"""每轮对话后调用:把当前会话落盘(首轮自动建会话)。"""
if not getattr(self, 'shared_conversation_history', None):
return
if not hasattr(self, 'session_manager'):
return
engine = getattr(self.current_engine, 'name', None) if self.current_engine else None
model = getattr(self, 'current_model', None)
try:
if self.current_session is None:
self.current_session = self.session_manager.new_session(engine=engine, model=model)
self.session_manager.save(
self.current_session, self.shared_conversation_history,
engine=engine, model=model,
)
except Exception:
pass # 自动保存失败不应中断对话
def handle_resume_command(self, args):
"""/resume 命令:列出或恢复历史会话。"""
args = (args or "").strip()
if not hasattr(self, 'session_manager'):
self.init_session_manager()
if args == 'new':
self.current_session = None
print(f"{Fore.GREEN}已开始新会话(下一轮对话自动创建){Style.RESET_ALL}")
return
sessions = self.session_manager.list_sessions()
if not sessions:
print(f"{Fore.YELLOW}没有可恢复的会话{Style.RESET_ALL}")
return
if not args:
print(f"{Fore.GREEN}已保存的会话 (按时间倒序):{Style.RESET_ALL}")
for i, s in enumerate(sessions, 1):
line = f"{Fore.GREEN}{i}. {s.id}{Style.RESET_ALL} {s.title}"
meta = f" {s.updated_at} {s.engine or '-'}/{s.model or '-'} {s.msg_count} 条"
print(line + f"{Fore.CYAN}{meta}{Style.RESET_ALL}")
print(f"{Fore.CYAN}用法: /resume <序号或ID> 恢复 · /resume new 开新会话{Style.RESET_ALL}")
return
s = self.session_manager.load(args)
if s is None:
print(f"{Fore.RED}未找到会话: {args}{Style.RESET_ALL}")
return
self.shared_conversation_history = list(s.messages)
self.current_session = s
self._set_shared_conversation_history()
print(f"{Fore.GREEN}已恢复会话: {s.id}{Style.RESET_ALL}")
print(f"{Fore.CYAN}标题: {s.title} · {s.msg_count} 条记录 · 引擎 {s.engine or '-'}/{s.model or '-'}{Style.RESET_ALL}")
cur = getattr(self.current_engine, 'name', None) if self.current_engine else None
if s.engine and cur and cur != s.engine:
print(f"{Fore.YELLOW}提示: 当前引擎为 {cur},可用 /engine switch {s.engine} 切回该会话的引擎{Style.RESET_ALL}")
+31 -27
View File
@@ -7,6 +7,7 @@ from colorama import Fore, Style
from .constants import UNIFIED_TOOL_MANAGER_AVAILABLE
from .safety import get_safety
from .tool_result import normalize_tool_dict, normalize_tool_text
class ToolMixin:
@@ -105,18 +106,18 @@ class ToolMixin:
result = self.liugin_manager.execute(tool_name, args, **arguments)
else:
result = self.liugin_manager.execute(tool_name, args)
return result
# execute() 返回 ToolResult,必须归一成 dict——
# 否则上层 result.get('result') 会报
# "'ToolResult' object has no attribute 'get'"
return normalize_tool_dict(result)
# 回退:直接调用 handler(旧逻辑)
# 回退:直接调用 handler(旧逻辑,handler 可能返回 ToolResult/str)
for tool in self.liugin_manager.tools:
if tool['name'] == tool_name:
try:
result = tool['handler'](args)
if result is None:
result = "无结果"
return {"result": result}
return normalize_tool_dict(tool['handler'](args))
except Exception as e:
return {"result": f"工具执行错误: {e}"}
return {"result": f"工具执行错误: {e}", "success": False}
return {"result": f"未找到工具: {tool_name}"}
@@ -136,7 +137,7 @@ class ToolMixin:
return {"index": index, "result": "工具执行已取消"}
try:
result = self.process_tool_call(tool_data)
tool_result = result.get('result', '无结果')
tool_result = normalize_tool_text(result)
with history_lock:
self.shared_conversation_history.append({
"role": "assistant",
@@ -204,7 +205,7 @@ class ToolMixin:
return [r['result'] for r in results]
def _execute_single_tool(self, tool_data):
"""执行单个工具(带延迟和ESC检测)"""
"""执行单个工具(零等待,执行前非阻塞检测 ESC 取消)"""
tool_name = tool_data.get('tool', '未知工具')
tool_args = tool_data.get('args', '')
if len(tool_args) > 30:
@@ -212,27 +213,16 @@ class ToolMixin:
else:
tool_args_display = tool_args
# TUI 模式下不做延迟和 ESC 检测
# 非 TUI 模式:执行前零等待检查 ESC(不再做倒计时延迟,立即执行)
showed_hint = False
if not self.tui_output_callback:
try:
import msvcrt
canceled = False
spinners = ['⊶', '⊷']
spinner_index = 0
for i in range(25):
if msvcrt.kbhit():
key = msvcrt.getch()
if key == b'\x1b':
canceled = True
break
spinner = spinners[spinner_index % 2]
spinner_index += 1
loading_message = f" 正在执行: {spinner} {tool_name} {tool_args_display} (按ESC取消)"
sys.stdout.write(f"\r{loading_message}")
sys.stdout.flush()
time.sleep(0.2)
sys.stdout.write("\r" + " " * 60 + "\r")
sys.stdout.flush()
# 排空键盘缓冲区:若在此之前按过 ESC 则取消(非阻塞,无等待)
while msvcrt.kbhit():
if msvcrt.getch() == b'\x1b':
canceled = True
if canceled:
print(f"{Fore.YELLOW}工具执行已取消(用户按ESC键){Style.RESET_ALL}")
cancel_message = f"工具 {tool_name} 执行已被用户取消(按ESC键)。工具参数: {tool_args}"
@@ -244,8 +234,22 @@ class ToolMixin:
except ImportError:
pass
try:
sys.stdout.write(f"\r 正在执行: {tool_name} {tool_args_display}")
sys.stdout.flush()
showed_hint = True
except Exception:
pass
tool_results = self.process_tool_call(tool_data)
full_result = tool_results.get('result', '无结果')
full_result = normalize_tool_text(tool_results)
if showed_hint:
try:
sys.stdout.write("\r" + " " * 60 + "\r")
sys.stdout.flush()
except Exception:
pass
from datetime import datetime
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._display_tool_result(tool_name, tool_args, full_result, current_time)
+55 -5
View File
@@ -1,4 +1,5 @@
import os
import re
import json
import logging
from colorama import Fore, Style
@@ -6,6 +7,39 @@ 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():
"""加载配置文件"""
@@ -42,13 +76,29 @@ def set_system_config(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=[
logging.FileHandler('xiaoli_cli.log', encoding='utf-8'),
logging.StreamHandler() # 同时输出到控制台
]
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__)
+4
View File
@@ -6,6 +6,10 @@ VIDEO_FRAME_DELAY = 0.04 # 40毫秒
LOVE_FILE_PATH = "love.txt"
DEFAULT_MAX_HISTORY = 999999 # 无限制对话历史
# 版本号(v8.0 基于 v5.4.1 重新立项)
VERSION = "8.0.3"
VERSION_NAME = "小狸 Pro-CLI"
import os
import sys
import importlib.util
+203 -4
View File
@@ -2,7 +2,9 @@
Function Calling 工具格式转换
将 MCP 插件定义转为 OpenAI FC 格式,收集所有可用工具
"""
import os
import json
import threading
from typing import List, Dict, Optional
@@ -19,8 +21,17 @@ def mcp_to_openai_tools(plugin_manager) -> List[Dict]:
if not plugin_manager:
return tools
# 统一工具管理器
if hasattr(plugin_manager, 'plugins'):
# 优先使用统一工具管理器的 MCP 定义(含外部 MCP client 桥接的工具,schema 保真)
if hasattr(plugin_manager, 'get_mcp_tools'):
try:
for mcp_def in plugin_manager.get_mcp_tools():
fc_def = mcp_to_fc(mcp_def)
if fc_def:
tools.append(fc_def)
except Exception:
pass
# 统一工具管理器(兜底,旧实现未暴露 get_mcp_tools 时)
elif hasattr(plugin_manager, 'plugins'):
for plugin in plugin_manager.plugins:
fc_def = _extract_fc_def(plugin)
if fc_def:
@@ -36,7 +47,117 @@ def mcp_to_openai_tools(plugin_manager) -> List[Dict]:
builtin_tools = _get_builtin_tools()
tools.extend(builtin_tools)
return tools
# ── 去重:同名工具只保留首个 ──
# 来源:get_mcp_tools() 的缓存里已含 memory/scheduler/lsp 等,
# 下方 _get_builtin_tools() 又追加了一遍,导致同名重复(约 17% 的 FC token 是垃圾)。
# 按 function.name 去重,消除双重曝光,对真实工具无影响。
seen = set()
deduped = []
for t in tools:
fname = (t.get("function") or {}).get("name", "")
if fname in seen:
continue
seen.add(fname)
deduped.append(t)
return deduped
# ══════════════════════════════════════════════════════════════════
# 动态工具裁剪(方案 B:按场景分层)
#
# 把 40+ 的 FC 工具全量发给模型,每轮要重发数千 token schema,且
# 模型要在大列表里选对工具,决策面大、易选错、成本高。
# 这里按「当前用户输入」的信号,只下发本轮相关的工具子集:
# - ALWAYS_KEEP:始终保留(含强制 tool_search 规则依赖、记忆、定时)
# - 外部桥接工具(含 "mcp__")默认保留(用户显式配置,误杀代价高)
# - 命中关键词信号才追加对应工具(代码/文件/git/shell/浏览器/搜索/子agent/工程化)
# 纯聊天时模型只背 ~10 个核心工具而非 40+,FC token 可降约 70%+。
# ══════════════════════════════════════════════════════════════════
# 始终保留的工具(与系统提示里的强制规则/记忆机制强绑定)
ALWAYS_KEEP = {
"tool_search", # 系统提示的「强制工具查询规则」依赖它
"memory", # 持久化记忆
"scheduler", # 定时任务
}
# 信号:关键词集合 → 追加「以此前缀开头」的工具
_SIGNAL_RULES = [
# 代码 / 文件操作:Python 等源码信号命中时,连 pylsp 的 lsp__* 一起给
(
{"写", "创建", "新建", "修改", "编辑", "代码", "脚本", "函数", "bug", "修复",
"调试", "重构", "文件", "报错", "语法", "实现", "module", "def ", "import ",
"class ", "print(", ".py", ".js", ".ts", ".java", ".go", ".cpp", ".c", ".rs"},
["code_editor", "code_search", "file_manager", "lsp__", "syntax_check", "check"],
),
# 版本控制
(
{"git", "提交", "commit", "分支", "branch", "版本", "diff", "stash", "merge", "rebase"},
["git_tools"],
),
# Shell 命令
(
{"命令", "shell", "终端", "执行", "运行", "cmd", "bash", "powershell", "terminal"},
["cmd_executor"],
),
# 浏览器
(
{"浏览器", "网页", "点击", "browser", "打开网页", "截图", "scroll", "页面"},
["browser_auto"],
),
# 联网 / 搜索 / HTTP
(
{"搜索", "联网", "http", "请求", "url", "curl", "api", "查一下", "上网", "fetch"},
["network_tools", "ai_search"],
),
# 子 Agent 并行
(
{"子agent", "并行", "subagent", "sub_agent", "多智能体"},
["sub_agent"],
),
# 工程化自动化
(
{"测试", "test", "lint", "build", "自动化", "工程化", "ci", "部署", "deploy"},
["auto_engineer"],
),
]
def prune_tools(fc_tools: List[Dict], user_input: str) -> List[Dict]:
"""
按当前用户输入动态裁剪 FC 工具子集。
返回保留下来的工具列表。无输入或空列表时原样返回(不裁剪)。
规则见模块顶部 _SIGNAL_RULES / ALWAYS_KEEP 注释。
"""
if not fc_tools:
return fc_tools
text = (user_input or "").lower()
keep_prefixes = set()
for keywords, prefixes in _SIGNAL_RULES:
if any(kw in text for kw in keywords):
keep_prefixes.update(prefixes)
out = []
for t in fc_tools:
name = (t.get("function") or {}).get("name", "")
# 1) 常驻工具
if name in ALWAYS_KEEP:
out.append(t)
continue
# 2) 外部桥接工具(用户显式配置)默认保留,避免误杀
if "mcp__" in name:
out.append(t)
continue
# 3) 命中当前轮信号 → 追加(前缀匹配,如 lsp__diagnostics 命中 "lsp__")
if any(name.startswith(p) for p in keep_prefixes):
out.append(t)
continue
# 4) 其余(未命中信号的低频工具)本轮裁剪掉
return out
def _extract_fc_def(plugin) -> Optional[Dict]:
@@ -120,11 +241,11 @@ def fc_result_to_message(tool_call_id: str, tool_name: str, result: str) -> Dict
Returns:
{"role": "tool", "tool_call_id": ..., "content": ...}
注意: 官方 OpenAI 的 tool 消息不含 name 字段(严格服务端如讯飞会拒),故不写入。
"""
return {
"role": "tool",
"tool_call_id": tool_call_id,
"name": tool_name,
"content": result[:10000] if result else "" # 截断过长结果
}
@@ -248,3 +369,81 @@ def _get_builtin_tools() -> List[Dict]:
}
}
]
# ══════════════════════════════════════════════════════════════════
# 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())
+433
View File
@@ -0,0 +1,433 @@
"""
自研本地 LSP 服务器 (local_lsp_server)
======================================
纯本地、零外部服务依赖的 Python 语言服务器——替代 pylsp。
架构:
- 语义引擎: jedi (纯 Python, 已在环境) — 补全/跳转/引用/悬停/签名
- 诊断引擎: plugins/py_detect (ast 规则引擎, 中文诊断)
- 协议: LSP over stdio (Content-Length 分帧), 与 xiaoli 的 lsp_client.py 完全兼容
特性:
- 零网络请求, 不依赖任何外部安装 (无需 pip install pylsp)
- 服务器不响应时客户端可超时跳过, 不会无限卡死
- 诊断推送自动携带 py_detect 的中文解释 + 修复建议
用法:
python -m xcli_core.local_lsp_server
或配置进 config.json 的 lspServers: {"command": "python", "args": ["-m", "xcli_core.local_lsp_server"]}
支持的 LSP 方法:
initialize / initialized / shutdown / exit
textDocument/didOpen|didChange|didClose (通知)
textDocument/completion|definition|references|hover (请求)
textDocument/publishDiagnostics (主动推送, 来自 py_detect)
"""
import json
import os
import re
import sys
from typing import Any, Dict, List, Optional
__version__ = "1.0.0"
# ══════════════════════════════════════════════
# Content-Length 分帧 (LSP stdio 传输层)
# ══════════════════════════════════════════════
def read_message(stream) -> Optional[Dict[str, Any]]:
"""从流中读取一条 Content-Length 分帧消息"""
header = b""
while b"\r\n\r\n" not in header:
chunk = stream.read(1)
if not chunk:
return None
header += chunk
if len(header) > 65536:
return None
m = re.search(rb"Content-Length:\s*(\d+)", header)
if not m:
return None
length = int(m.group(1))
body = b""
while len(body) < length:
chunk = stream.read(length - len(body))
if not chunk:
return None
body += chunk
try:
return json.loads(body.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return None
def write_message(stream, obj: Dict[str, Any]) -> None:
"""向流中写入一条 Content-Length 分帧消息"""
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
stream.write(f"Content-Length: {len(data)}\r\n\r\n".encode("ascii"))
stream.write(data)
stream.flush()
# ══════════════════════════════════════════════
# 路径工具 (file:// URI <-> 文件系统路径)
# ══════════════════════════════════════════════
def uri_to_path(uri: str) -> str:
"""file:///C:/a/b.py -> C:/a/b.py"""
if uri.startswith("file://"):
path = uri[7:]
if len(path) >= 3 and path[0] == "/" and path[2] == ":":
path = path[1:] # /C:/... -> C:/...
return path.replace("/", os.sep) if os.sep == "\\" else path
return uri
def path_to_uri(path: str) -> str:
abspath = os.path.abspath(path)
norm = abspath.replace("\\", "/")
if not norm.startswith("/"):
norm = "/" + norm
return "file://" + norm
# ══════════════════════════════════════════════
# 自研本地 LSP 服务器
# ══════════════════════════════════════════════
class LocalLspServer:
"""本地 Python 语言服务器: jedi 语义 + py_detect 诊断。
handle(msg) 返回要写出的消息列表(响应/推送),便于单元测试与进程模式复用。
"""
def __init__(self):
self.documents: Dict[str, str] = {} # uri -> 最新文本
self.root_path: Optional[str] = None
self._project = None
self._jedi = None
self._py_detect = None
self._shutdown = False
# ── 引擎惰性加载 (首次用时才 import, 失败不崩) ──
def _get_jedi(self):
if self._jedi is None:
try:
import jedi
self._jedi = jedi
except Exception:
self._jedi = False
return self._jedi or None
def _get_project(self):
if self._project is None and self._get_jedi() is not None:
try:
root = self.root_path
if root and os.path.isdir(root):
self._project = self._jedi.Project(root)
else:
self._project = False
except Exception:
self._project = False
return self._project if self._project else None
def _load_py_detect(self):
"""从 plugins/py_detect.py 加载诊断引擎 (与 code_editor 同源, 零复制)"""
if self._py_detect is not None:
return self._py_detect if self._py_detect is not False else None
try:
import importlib.util
here = os.path.dirname(os.path.abspath(__file__))
plugin_path = os.path.join(here, "..", "plugins", "py_detect.py")
if not os.path.exists(plugin_path):
self._py_detect = False
return None
spec = importlib.util.spec_from_file_location("py_detect_server", plugin_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
self._py_detect = mod
return mod
except Exception:
self._py_detect = False
return None
def _script(self, uri: str, text: Optional[str] = None):
jedi = self._get_jedi()
if jedi is None:
return None
path = uri_to_path(uri)
code = text if text is not None else self.documents.get(uri, "")
return jedi.Script(code=code, path=path, project=self._get_project())
# ── 消息分发: 返回要写出的消息列表 ──
def handle(self, msg: Dict[str, Any]) -> List[Dict[str, Any]]:
method = msg.get("method")
msg_id = msg.get("id")
params = msg.get("params") or {}
# 客户端不应向服务器发响应; 防御性忽略
if method is None and msg_id is not None:
return []
if method == "initialize":
root_uri = params.get("rootUri") or ""
if root_uri:
self.root_path = uri_to_path(root_uri)
return [self._reply(msg_id, {"capabilities": self._capabilities()})]
if method == "initialized":
return []
if method == "shutdown":
self._shutdown = True
return [self._reply(msg_id, None)]
if method == "exit":
self._shutdown = True
return [] # 进程层据此退出
if method == "textDocument/didOpen":
td = params.get("textDocument", {})
uri = td.get("uri", "")
self.documents[uri] = td.get("text", "")
return self._diag_push(uri)
if method == "textDocument/didChange":
uri = (params.get("textDocument", {}) or {}).get("uri", "")
changes = params.get("contentChanges") or []
if changes:
self.documents[uri] = changes[-1].get("text", "")
return self._diag_push(uri)
if method == "textDocument/didClose":
uri = (params.get("textDocument", {}) or {}).get("uri", "")
self.documents.pop(uri, None)
return []
if method == "textDocument/completion":
return [self._reply(msg_id, self._completion(params))]
if method == "textDocument/definition":
return [self._reply(msg_id, self._definition(params))]
if method == "textDocument/references":
return [self._reply(msg_id, self._references(params))]
if method == "textDocument/hover":
return [self._reply(msg_id, self._hover(params))]
if msg_id is not None:
return [self._reply(msg_id, None,
error={"code": -32601, "message": f"未知方法: {method}"})]
return []
def _reply(self, msg_id, result, error=None) -> Dict[str, Any]:
msg = {"jsonrpc": "2.0", "id": msg_id}
if error is not None:
msg["error"] = error
else:
msg["result"] = result
return msg
def _capabilities(self) -> Dict[str, Any]:
return {
"textDocumentSync": {"openClose": True, "change": 1}, # 1 = full
"completionProvider": {"triggerCharacters": [".", "(", ","]},
"definitionProvider": True,
"referencesProvider": True,
"hoverProvider": True,
}
# ── py_detect 诊断 → publishDiagnostics 推送 ──
def _diag_push(self, uri: str) -> List[Dict[str, Any]]:
pd = self._load_py_detect()
text = self.documents.get(uri, "")
diags = []
if pd is not None:
try:
issues = pd.analyze(text, uri_to_path(uri) or "<buffer>")
sev_map = {"错误": 1, "警告": 2, "提示": 4, "信息": 3}
for d in issues:
line0 = max(int(d.get("line", 1)) - 1, 0)
col0 = max(int(d.get("col", 1)) - 1, 0)
sev = sev_map.get(d.get("severity", "警告"), 2)
message = "[{}] {}{}".format(
d.get("code", "?"),
d.get("title", ""),
(" — " + d.get("fix", "")) if d.get("fix") else "",
)
diags.append({
"range": {
"start": {"line": line0, "character": col0},
"end": {"line": line0, "character": col0},
},
"severity": sev,
"source": "py_detect",
"message": message,
})
except Exception:
diags = []
if not diags:
return []
return [{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {"uri": uri, "diagnostics": diags},
}]
# ── jedi 语义能力 ──
def _position(self, params: Dict[str, Any]):
pos = params.get("position") or {}
return int(pos.get("line", 0) or 0), int(pos.get("character", 0) or 0)
def _uri(self, params: Dict[str, Any]) -> str:
return (params.get("textDocument") or {}).get("uri", "")
def _completion(self, params: Dict[str, Any]) -> Dict[str, Any]:
uri = self._uri(params)
line, character = self._position(params)
script = self._script(uri)
if script is None:
return {"items": []}
try:
completions = script.complete(line, character)
except Exception:
return {"items": []}
items = []
seen = set()
for c in completions:
name = getattr(c, "name", "") or ""
if not name or name in seen:
continue
seen.add(name)
ctype = getattr(c, "type", "") or ""
detail = (getattr(c, "description", "") or "")[:120]
items.append({
"label": name,
"kind": ctype,
"detail": detail,
"sortText": f"0{ctype}_{name}",
})
return {"items": items[:60]}
def _locations(self, defs) -> List[Dict[str, Any]]:
out = []
for d in defs:
module_path = getattr(d, "module_path", None)
if module_path is None:
continue # 内置/无源码定义跳过
line = getattr(d, "line", 0) or 0
col = getattr(d, "column", 0) or 0
out.append({
"uri": path_to_uri(str(module_path)),
"range": {
"start": {"line": line, "character": col},
"end": {"line": line, "character": col},
},
})
return out
def _definition(self, params: Dict[str, Any]) -> List[Dict[str, Any]]:
uri = self._uri(params)
line, character = self._position(params)
script = self._script(uri)
if script is None:
return []
try:
defs = script.goto(line, character) # jedi 0.20 API
return self._locations(defs)
except Exception:
return []
def _references(self, params: Dict[str, Any]) -> List[Dict[str, Any]]:
uri = self._uri(params)
line, character = self._position(params)
script = self._script(uri)
if script is None:
return []
try:
refs = script.get_references(line, character) # jedi 0.20 API
return self._locations(refs)
except Exception:
return []
def _hover(self, params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
uri = self._uri(params)
line, character = self._position(params)
script = self._script(uri)
if script is None:
return None
parts = []
# 类型推断 (jedi 0.20: infer 替代 goto_definitions)
try:
inferred = script.infer(line, character)
seen_types = set()
for d in inferred[:5]:
dname = getattr(d, "name", "")
dtype = getattr(d, "type", "")
key = (dname, dtype)
if key in seen_types:
continue
seen_types.add(key)
if dtype:
parts.append(f"类型: {dname}: {dtype}")
except Exception:
pass
# 签名
try:
for s in script.get_signatures(line, character)[:3]:
stxt = getattr(s, "to_string", lambda: "")()
if stxt:
parts.append(f"签名: {stxt}")
except Exception:
pass
# 文档
try:
for h in script.help(line, character)[:3]:
doc = getattr(h, "docstring", None)
if doc:
d = doc() or ""
if d:
parts.append(d.strip()[:400])
except Exception:
pass
if not parts:
return None
return {"contents": {"kind": "plaintext", "value": "\n\n".join(parts[:6])}}
# ══════════════════════════════════════════════
# 进程入口
# ══════════════════════════════════════════════
def main() -> None:
server = LocalLspServer()
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
while True:
try:
msg = read_message(stdin)
except Exception:
break
if msg is None:
break
try:
outputs = server.handle(msg)
except Exception:
outputs = []
for out in outputs:
try:
write_message(stdout, out)
except (BrokenPipeError, OSError):
return
if server._shutdown and msg.get("method") == "exit":
break
if __name__ == "__main__":
main()
+100 -31
View File
@@ -22,7 +22,11 @@ class MemoryManager:
self.memory_dir = os.path.join(project_dir, "memory")
self.memory_file = os.path.join(project_dir, "MEMORY.md")
self.chat_dir = os.path.join(project_dir, "chat_history")
self.compress_threshold = 50 # 超过 50 条消息触发压缩
self.compress_threshold = 50 # 超过 50 条消息触发压缩(兜底)
self.compress_ratio = 0.7 # token 感知压缩阈值:used/max_input_tokens >= 0.7 触发
self.keep_recent = 12 # 压缩时保留最近 N 条(编码常跨多轮,略高于原 10)
self.max_summary_chars = 1500 # 摘要前单条消息截断上限(原硬编码 200,编码场景丢代码)
self.compressed_blobs = [] # 累积的压缩摘要(避免二次摘要失真)
self._ensure_dirs()
def _ensure_dirs(self):
@@ -299,56 +303,112 @@ class MemoryManager:
# 上下文压缩
# ══════════════════════════════════════════════
def should_compress(self, conversation: list) -> bool:
"""判断是否需要压缩上下文"""
def set_compress_ratio(self, ratio: float):
"""设置 token 感知压缩阈值(0~1)"""
try:
self.compress_ratio = float(ratio)
except (TypeError, ValueError):
pass
def should_compress(self, conversation: list, prompt_tokens=None, max_input_tokens=None, ratio=None) -> bool:
"""
判断是否需要压缩上下文。
优先级:
1. token 感知: prompt_tokens/max_input_tokens >= ratio 触发(引擎需返回 max_input_tokens)
2. 兜底: 消息数超过 compress_threshold
"""
if ratio is None:
ratio = self.compress_ratio
if prompt_tokens and max_input_tokens:
try:
if int(max_input_tokens) > 0 and (int(prompt_tokens) / int(max_input_tokens)) >= ratio:
return True
except (TypeError, ValueError):
pass
return len(conversation) > self.compress_threshold
def compress_context(self, conversation: list, engine=None) -> list:
def _is_compressed_marker(self, m: dict) -> bool:
"""判断一条消息是否是我们自己产出的压缩摘要(避免二次摘要失真)"""
return bool(m.get("role") == "system"
and str(m.get("content", "")).startswith("[上下文压缩]"))
def _prepare_for_summary(self, msg: dict) -> str:
"""把单条消息转成摘要输入:保真优先——保留工具调用 JSON,长内容截断但尽量留代码块。"""
role = msg.get("role", "unknown")
content = msg.get("content", "")
if not isinstance(content, str):
return f"[{role}]: (非文本/结构化内容,长度 {len(str(content))})\n"
is_tool = ('"action": "use_tool"' in content) or ('"tool":' in content)
cap = self.max_summary_chars
if len(content) <= cap:
body = content
else:
# 尽量保留代码块(```...```)以保真编码上下文
fences = re.findall(r"```.*?```", content, re.DOTALL)
if fences:
kept = "\n".join(fences[:2])
if len(kept) > cap:
kept = kept[:cap] + "\n...(截断)"
body = f"(内容过长已截断,保留代码块)\n{kept}"
else:
body = content[:cap] + "\n...(截断)"
marker = " [工具调用]" if is_tool else ""
return f"[{role}]{marker}: {body}\n"
def compress_context(self, conversation: list, engine=None, keep_recent=None) -> list:
"""
压缩对话历史
策略: 保留最近 10 条 + 用 AI 总结前面的内容
压缩对话历史。
策略: 保留最近 keep_recent 条 + 用当前引擎 AI 总结前面的内容。
关键改进:
- keep_recent 可配置(默认 12,编码常跨多轮)
- 已压缩摘要(标记消息)不再二次摘要,避免失真累积
- 摘要按角色分条,保留工具调用/代码修改结构
"""
if len(conversation) <= 10:
if keep_recent is None:
keep_recent = self.keep_recent
if len(conversation) <= keep_recent:
return conversation
keep_recent = 10
to_compress = conversation[:-keep_recent]
recent = conversation[-keep_recent:]
prefix = conversation[:-keep_recent]
# 只摘要尚未压缩过的"真消息",跳过历史摘要本身 → 不二次失真
new_to_compress = [m for m in prefix if not self._is_compressed_marker(m)]
if not new_to_compress:
# 前缀全是历史摘要,没有新内容可压,保持现状即可(不丢摘要)
return conversation
# 尝试用 AI 生成摘要
summary = self._ai_summarize(to_compress, engine)
summary = self._ai_summarize(new_to_compress, engine)
block = f"[上下文压缩] 之前 {len(new_to_compress)} 条对话摘要:\n{summary}"
self.compressed_blobs.append(block)
# 构建压缩后的历史
compressed = [{
"role": "system",
"content": f"[上下文压缩] 以下是之前 {len(to_compress)} 条对话的摘要:\n{summary}"
}]
# 累积所有摘要合并为单条 system(而非每次重压旧摘要)
merged = "\n\n".join(self.compressed_blobs)
compressed = [{"role": "system", "content": merged}]
compressed.extend(recent)
return compressed
def _ai_summarize(self, messages: list, engine=None) -> str:
"""用 AI 生成对话摘要"""
"""用当前引擎生成对话摘要(保真优先)"""
if not engine:
return self._simple_summarize(messages)
# 构建摘要请求
conversation_text = ""
for msg in messages:
role = msg.get("role", "unknown")
content = msg.get("content", "")
if isinstance(content, str):
conversation_text += f"[{role}]: {content[:200]}\n"
conversation_text = "".join(self._prepare_for_summary(m) for m in messages)
prompt = (
"请用简洁的中文总结以下对话的关键信息(工具调用、代码修改、重要决策)。"
"控制在 200 字以内。\n\n"
"请用简洁中文总结以下对话的关键信息,要求:\n"
"1. 按角色(用户/助手)分条,保留每条的要点;\n"
"2. 重点保留: 工具调用名称与参数、代码修改点(读/改/创建的文件与位置)、重要决策;\n"
"3. 控制总体在 400 字以内,不要编造未提及的信息。\n\n"
f"{conversation_text}"
)
try:
summary = engine.generate_response(
prompt,
system_prompt="你是摘要生成器。只输出摘要,不要多余内容。"
system_prompt="你是摘要生成器。只输出结构化摘要,不要多余内容。"
)
if summary and len(summary) > 10:
return summary
@@ -358,23 +418,25 @@ class MemoryManager:
return self._simple_summarize(messages)
def _simple_summarize(self, messages: list) -> str:
"""简单摘要(不依赖 AI)"""
"""简单摘要(不依赖 AI,保真优先)"""
topics = []
tool_calls = []
edits = []
for msg in messages:
content = msg.get("content", "")
role = msg.get("role", "")
if role == "user" and isinstance(content, str):
if not isinstance(content, str):
continue
if role == "user":
# 提取用户请求
if len(content) < 100:
topics.append(content)
else:
topics.append(content[:50] + "...")
if role == "assistant" and isinstance(content, str):
if role == "assistant":
# 提取工具调用
if '"action": "use_tool"' in content or '"tool":' in content:
try:
# 尝试解析工具名
for part in content.split('"tool":'):
if len(part) > 2:
tool_name = part.split('"')[1] if '"' in part else ""
@@ -382,12 +444,19 @@ class MemoryManager:
tool_calls.append(tool_name)
except Exception:
pass
# 提取代码编辑点(edit 命令)
for m in re.finditer(r'edit\s+(\S+)', content):
fn = m.group(1)
if fn not in edits:
edits.append(fn)
summary_parts = []
if topics:
summary_parts.append(f"讨论了 {len(topics)} 个话题: {'; '.join(topics[:5])}")
if tool_calls:
summary_parts.append(f"使用了工具: {', '.join(tool_calls[:8])}")
if edits:
summary_parts.append(f"编辑了文件: {', '.join(edits[:8])}")
summary_parts.append(f"共 {len(messages)} 条对话")
return "。".join(summary_parts)
+145
View File
@@ -0,0 +1,145 @@
"""OpenAI 格式模型注册表。
把「多个 OpenAI 格式模型」从 config.json 的单槽位升级为可管理的列表,
支持在线增删、切换、持久化,免去每次手改配置文件。
配置结构(api.engines.openai):
{
"models": [
{"name": "讯飞xop", "base_url": "...", "api_key": "...", "model": "xophunyuan7bmt"},
{"name": "gpt4o", "base_url": "https://api.openai.com/v1", "api_key": "...", "model": "gpt-4o"}
],
"current": "讯飞xop"
}
向后兼容:若没有 models 列表,把扁平的 api_key/base_url/model 当作单条隐式模型,
其 name 取 model 值(空则 "default")。
"""
from xcli_core.config import load_config, save_config
# 测试可临时覆盖配置路径
_CONFIG_PATH = None
def _set_config_path(path):
"""仅供测试:指向临时 config.json"""
global _CONFIG_PATH
_CONFIG_PATH = path
def _load():
if _CONFIG_PATH:
try:
with open(_CONFIG_PATH, 'r', encoding='utf-8') as f:
return __import__('json').load(f)
except (FileNotFoundError, __import__('json').JSONDecodeError):
return {"api": {"engines": {}}, "system": {}}
return load_config()
def _persist(cfg):
if _CONFIG_PATH:
with open(_CONFIG_PATH, 'w', encoding='utf-8') as f:
__import__('json').dump(cfg, f, ensure_ascii=False, indent=2)
return
save_config(cfg)
def _openai_block(cfg):
return cfg.setdefault("api", {}).setdefault("engines", {}).setdefault("openai", {})
def list_models(cfg=None):
"""返回 (models:list[dict], current:str|None)。
无 models 列表时返回单条隐式模型(由扁平 api_key/base_url/model 构成)。
"""
cfg = cfg or _load()
block = _openai_block(cfg)
models = block.get("models")
if not models:
m = block.get("model") or "default"
implicit = {
"name": m,
"base_url": block.get("base_url", ""),
"api_key": block.get("api_key", ""),
"model": block.get("model", ""),
}
return [implicit], (block.get("current") or m)
current = block.get("current") or (models[0]["name"] if models else None)
return models, current
def get_model(name=None, cfg=None):
"""取某模型配置;name=None 取 current。找不到返回 None。"""
models, current = list_models(cfg)
target = name or current
if not target:
return None
for m in models:
if m.get("name") == target:
return dict(m)
return None
def set_current(name):
"""把 current 指针指向 name 并落盘。name 必须存在,否则返回 False。"""
cfg = _load()
block = _openai_block(cfg)
models = block.get("models") or []
if not any(m.get("name") == name for m in models):
return False
block["current"] = name
_persist(cfg)
return True
def add_model(entry):
"""新增或更新模型,落盘并返回其 name。
entry: {"name", "base_url", "api_key", "model"}。同名则更新。
首次添加会从扁平配置迁移出第一条隐式模型,且保持 current 不跳变。
"""
name = (entry.get("name") or "").strip()
if not name:
raise ValueError("模型 name 不能为空")
cfg = _load()
block = _openai_block(cfg)
models = block.get("models")
if not models:
old_name = block.get("model") or "default"
models = [{
"name": old_name,
"base_url": block.get("base_url", ""),
"api_key": block.get("api_key", ""),
"model": block.get("model", ""),
}]
block["models"] = models
if not block.get("current"):
block["current"] = old_name
for m in models:
if m.get("name") == name:
m.update(entry)
_persist(cfg)
return name
models.append(dict(entry))
_persist(cfg)
return name
def remove_model(name):
"""删除模型,返回是否成功(找不到返回 False)。
若删除的是 current,则 current 回退到列表第一个。
"""
cfg = _load()
block = _openai_block(cfg)
models = block.get("models") or []
new = [m for m in models if m.get("name") != name]
if len(new) == len(models):
return False
block["models"] = new
if block.get("current") == name:
block["current"] = new[0]["name"] if new else None
_persist(cfg)
return True
+269
View File
@@ -0,0 +1,269 @@
"""
Provider 抽象层(opencode 式原生多 Provider 路由)
小狸核心引擎是 OpenAI 兼容的 OpenaiAI(已支持 base_url,讯飞/xunfei 即借此接入)。
本模块在此基础上提供:
- PROVIDER_PRESETS:主流 Provider 的默认 base_url / 模型前缀 / 协议族
- resolve_provider():从 config["providers"] 解析出 {api_key, base_url, model, headers}
- convert_messages() / convert_tools():OpenAI ↔ Anthropic ↔ Google 线缆格式互转(纯函数)
- list_providers():给 /providers 命令用的汇总
设计原则:纯函数 + 配置驱动,不触网、不强求真实 key,不改动现有 engine 行为。
真实调用仍由 OpenaiAI 在用户填入 base_url/api_key 后完成(OpenAI 兼容族直接可用;
非兼容族可借助 convert_* 在 engine 层二次适配,本模块只负责「翻译」与「注册」)。
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
# ───────────────────────── Provider 预设 ─────────────────────────
# protocol: openai 兼容 | anthropic | google
PROVIDER_PRESETS: Dict[str, Dict[str, Any]] = {
"openai": {
"label": "OpenAI",
"base_url": "https://api.openai.com/v1",
"protocol": "openai",
"models": ["gpt-4o", "gpt-4o-mini", "o1", "o3-mini"],
"env_key": "OPENAI_API_KEY",
},
"anthropic": {
"label": "Anthropic Claude",
"base_url": "https://api.anthropic.com/v1",
"protocol": "anthropic",
"models": ["claude-opus-4", "claude-sonnet-4", "claude-3-5-sonnet"],
"env_key": "ANTHROPIC_API_BASE",
},
"google": {
"label": "Google Gemini",
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"protocol": "google",
"models": ["gemini-2.0-pro", "gemini-2.0-flash", "gemini-1.5-pro"],
"env_key": "GEMINI_API_KEY",
},
"deepseek": {
"label": "DeepSeek",
"base_url": "https://api.deepseek.com/v1",
"protocol": "openai",
"models": ["deepseek-chat", "deepseek-reasoner"],
"env_key": "DEEPSEEK_API_KEY",
},
"moonshot": {
"label": "Moonshot (Kimi)",
"base_url": "https://api.moonshot.cn/v1",
"protocol": "openai",
"models": ["moonshot-v1-8k", "moonshot-v1-32k"],
"env_key": "MOONSHOT_API_KEY",
},
"qwen": {
"label": "阿里通义千问",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"protocol": "openai",
"models": ["qwen-max", "qwen-plus", "qwen-turbo"],
"env_key": "DASHSCOPE_API_KEY",
},
"xunfei": {
"label": "讯飞开放平台",
"base_url": "https://spark-api-open.xf-yun.com/v1",
"protocol": "openai",
"models": ["generalv3.5", "generalv4"],
"env_key": "XUNFEI_API_KEY",
},
"ollama": {
"label": "Ollama (本地)",
"base_url": "http://localhost:11434/v1",
"protocol": "openai",
"models": ["qwen2.5", "llama3.1", "deepseek-r1"],
"env_key": "",
},
}
# 协议族 → 该族内「消息格式」标识(转换时使用)
_PROTOCOL_FORMAT = {
"openai": "openai",
"anthropic": "anthropic",
"google": "google",
}
def get_provider_preset(name: str) -> Optional[Dict[str, Any]]:
return PROVIDER_PRESETS.get(name)
def list_providers() -> List[str]:
return list(PROVIDER_PRESETS.keys())
def resolve_provider(name: str, providers_cfg: Optional[Dict[str, Any]] = None,
env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""从 config["providers"][name] 解析出连接参数。
优先级:providers_cfg 显式字段 > 环境变量 > 预设默认值。
返回:{name, label, base_url, api_key, model, protocol, headers}
缺失 api_key 时 api_key 为空字符串(调用层据此判断是否配置完成)。
"""
preset = PROVIDER_PRESETS.get(name)
if preset is None:
return {}
env = env if env is not None else dict(os.environ)
cfg = (providers_cfg or {}).get(name, {}) if providers_cfg else {}
api_key = cfg.get("api_key") or (env.get(preset["env_key"]) if preset["env_key"] else "")
base_url = cfg.get("base_url") or preset["base_url"]
model = cfg.get("model") or (preset["models"][0] if preset["models"] else "")
protocol = preset["protocol"]
headers: Dict[str, str] = {}
# Anthropic 需要 beta header与协议版本(即便走 OpenAI 代理也保留以便真实适配)
if protocol == "anthropic":
headers["anthropic-version"] = "2023-06-01"
# Google 的 Gemini OpenAI 兼容端点需要把 key 放 query/header,这里统一放 header
if protocol == "google" and api_key:
headers["x-goog-api-key"] = api_key
return {
"name": name,
"label": preset["label"],
"base_url": base_url,
"api_key": api_key or "",
"model": model,
"protocol": protocol,
"headers": headers,
}
def _wire_format(protocol: str) -> str:
return _PROTOCOL_FORMAT.get(protocol, "openai")
# ───────────────────────── 消息格式互转 ─────────────────────────
def convert_messages(target: str, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""把小狸内部 OpenAI 风格 messages 转换为 target provider 的线缆格式。
target ∈ {"openai", "anthropic", "google"}
内部统一表示:[{"role": "system"|"user"|"assistant"|"tool", "content": ..., "tool_calls"?, "name"?}]
- anthropic:system 抽成顶层;user/assistant 交替,content 包装为 blocks
- google:system 抽成 systemInstruction;content 包装为 parts
纯转换,不调用网络。
"""
fmt = _wire_format(target) if target in _PROTOCOL_FORMAT.values() else _wire_format(target)
if fmt == "openai":
return [_normalize_openai(m) for m in messages]
if fmt == "anthropic":
return _to_anthropic(messages)
if fmt == "google":
return _to_google(messages)
return [_normalize_openai(m) for m in messages]
def _normalize_openai(m: Dict[str, Any]) -> Dict[str, Any]:
"""确保 content 为字符串(统一线缆格式),保留 role/tool_calls/name。"""
out = {"role": m.get("role", "user")}
content = m.get("content")
if content is None:
content = ""
if isinstance(content, list):
# 多模态 blocks → 拼接文本
text = "".join(p.get("text", "") for p in content if isinstance(p, dict))
content = text
out["content"] = content
if "tool_calls" in m:
out["tool_calls"] = m["tool_calls"]
if "name" in m:
out["name"] = m["name"]
return out
def _to_anthropic(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Anthropic Messages API 格式:system 单独抽离,消息为 user/assistant 交替。"""
out: List[Dict[str, Any]] = []
for m in messages:
role = m.get("role")
if role == "system":
continue # 顶层 system 由调用方单独处理
content = m.get("content", "")
blocks = []
if isinstance(content, list):
for p in content:
if isinstance(p, dict) and p.get("type") == "text":
blocks.append({"type": "text", "text": p.get("text", "")})
elif isinstance(p, dict) and p.get("type") == "image":
blocks.append(p)
else:
blocks.append({"type": "text", "text": str(content)})
out.append({"role": "user" if role in ("user", "tool") else "assistant",
"content": blocks})
return out
def _to_google(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Google Gemini 格式:system 抽为 systemInstruction,消息为 content.parts。"""
out: List[Dict[str, Any]] = []
for m in messages:
role = m.get("role")
if role == "system":
continue
content = m.get("content", "")
parts = []
if isinstance(content, list):
for p in content:
if isinstance(p, dict) and p.get("type") == "text":
parts.append({"text": p.get("text", "")})
elif isinstance(p, dict) and p.get("type") == "image":
parts.append(p)
else:
parts.append({"text": str(content)})
out.append({"role": "user" if role in ("user", "tool") else "model",
"parts": parts})
return out
def convert_tools(target: str, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""把 OpenAI FC tools([{type, function:{name,description,parameters}}]) 转目标格式。
- openai:原样
- anthropic:tools=[{name, description, input_schema}]
- google:tools=[{functionDeclarations:[{name,description,parameters}]}]
"""
fmt = _wire_format(target)
if fmt == "openai":
return tools
if fmt == "anthropic":
out = []
for t in tools:
fn = t.get("function", {})
out.append({
"name": fn.get("name", ""),
"description": fn.get("description", ""),
"input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
})
return out
if fmt == "google":
decls = []
for t in tools:
fn = t.get("function", {})
decls.append({
"name": fn.get("name", ""),
"description": fn.get("description", ""),
"parameters": fn.get("parameters", {"type": "object", "properties": {}}),
})
return [{"functionDeclarations": decls}] if decls else []
return tools
def provider_summary(name: str, providers_cfg: Optional[Dict[str, Any]] = None,
env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""给 /providers 命令用的单条汇总(是否配置、协议、模型数)。"""
preset = PROVIDER_PRESETS.get(name)
if not preset:
return {}
resolved = resolve_provider(name, providers_cfg, env)
configured = bool(resolved.get("api_key"))
return {
"name": name,
"label": preset["label"],
"protocol": preset["protocol"],
"configured": configured,
"model_count": len(preset["models"]),
"base_url": resolved.get("base_url", ""),
}
+134
View File
@@ -0,0 +1,134 @@
"""
v8.0 Rich 面板式 TUI + 可截取快照出口
======================================
- 通过 cli.tui_output_callback = RichTUI.push 接收逐行输出,渲染为 rich 面板
- Console(record=True) 快照出口: export_text / export_svg / export_html
→ AI 开发 TUI 时能把渲染结果读回自验,补上“看不见渲染”的反馈闭环
设计取舍(与 7.0 重构前 textual TUI 的区别):
- 不用 textual 的异步全屏 App,避免与同步 input() 主循环冲突;
- 采用 rich 顺序面板流(一问一答往下渲染),天然支持 record=True 快照;
- 这正是“让 AI 能看见自己写的 TUI”的关键能力。
"""
import os
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from rich.box import ROUNDED
SNAP_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".tui_snapshots"
)
class RichTUI:
"""rich 面板式 TUI:聊天面板 + 状态栏 + 快照出口。"""
def __init__(self, cli):
self.cli = cli
# record=True 是关键:渲染过程被记录,可随时导出文本/SVG/HTML 自验
self.console = Console(record=True, width=100)
self.version = "8.0.3"
self.engine = ""
self.model = ""
self.mode = "普通"
self.used_tokens = None
self.context_window = None
self.msg_count = 0
self._log = []
# ── 状态设置(由 cli 在切换引擎/压缩后刷新)──
def set_status(self, engine=None, model=None, mode=None,
used_tokens=None, context_window=None, msg_count=None):
if engine is not None:
self.engine = engine
if model is not None:
self.model = model
if mode is not None:
self.mode = mode
if used_tokens is not None:
self.used_tokens = used_tokens
if context_window is not None:
self.context_window = context_window
if msg_count is not None:
self.msg_count = msg_count
def banner(self):
self.console.print(Panel(
Text.from_markup(f"[bold cyan]小狸 Pro-CLI[/] [yellow]v{self.version}[/] · Rich TUI"),
subtitle="[dim]自然语言对话 · /exit 返回 CLI · /snapshot 导出快照[/]",
box=ROUNDED, border_style="cyan"))
self.console.print()
def _bar(self):
if self.used_tokens and self.context_window:
ratio = min(1.0, self.used_tokens / self.context_window)
filled = int(ratio * 10)
bar = "▓" * filled + "░" * (10 - filled)
tok = f"{self.used_tokens}/{self.context_window} {bar}"
elif self.used_tokens:
tok = f"{self.used_tokens} tokens"
else:
tok = "-"
return (f"[dim]引擎 {self.engine or '-'} · 模型 {self.model or '-'} · "
f"模式 {self.mode} · {tok} · 消息 {self.msg_count}[/]")
# ── 回调入口:DisplayMixin._output 把每行输出投到这里 ──
def push(self, message):
m = (message or "").strip("\n")
if not m:
return
self._log.append(m)
if len(self._log) > 300:
self._log = self._log[-300:]
# 原始 message 已由 DisplayMixin 产出合法 rich markup,直接打印;
# 个别工具结果含裸方括号可能破坏 markup,失败则降级为纯文本。
try:
self.console.print(m)
except Exception:
self.console.print(m, markup=False)
# ── 快照出口(AI 自验渲染的关键能力)──
def snapshot_text(self):
return self.console.export_text(clear=False)
def snapshot_svg(self, title="小狸 Pro-CLI v8.0 Rich TUI"):
return self.console.export_svg(title=title, clear=False)
def snapshot_html(self, title="小狸 Pro-CLI v8.0 Rich TUI"):
# rich 新版 export_html 不接受 title 参数,标题作为注释写入
html = self.console.export_html(clear=False)
return f"<!-- {title} -->\n{html}"
def save_snapshot(self, stem):
os.makedirs(os.path.dirname(stem), exist_ok=True)
with open(stem + ".txt", "w", encoding="utf-8") as f:
f.write(self.snapshot_text())
with open(stem + ".svg", "w", encoding="utf-8") as f:
f.write(self.snapshot_svg())
with open(stem + ".html", "w", encoding="utf-8") as f:
f.write(self.snapshot_html())
return stem + ".txt", stem + ".svg", stem + ".html"
# ── 主循环 ──
def run(self):
self.banner()
try:
import readline # 可选:输入历史补全
except Exception:
pass
while True:
try:
ui = input("> ").strip()
except (EOFError, KeyboardInterrupt):
break
if ui in ("/exit", "/quit"):
break
if ui == "/snapshot":
paths = self.save_snapshot(os.path.join(SNAP_DIR, "live"))
print(f"[快照已保存] {paths[2]}")
continue
if ui:
self.cli.process_conversation(ui)
self.msg_count = len(self.cli.shared_conversation_history)
self.console.print(Text.from_markup(self._bar()))
+144
View File
@@ -18,6 +18,65 @@ MODE_NAMES = {
MODE_MANUAL: "人工确认",
}
# ── Plan 模式只读白名单(黑名单法)──
# 值为 "*" 表示该工具整类视为写/有副作用,一律拦截;
# 值为列表时,仅当首个操作词命中列表中的写操作关键字才拦截;
# 值为 [] 表示该工具整类只读,一律放行(调研必需);
# 未登记的未知工具默认拦截(保守)。
PLAN_WRITE_RULES = {
# 整类拦截
"cmd_executor": "*",
"browser_auto": "*",
"network_tools": "*",
"scheduler": "*",
# 仅部分操作拦截
"code_editor": ["write", "create", "append", "edit", "insert", "delete_lines",
"multi", "replace", "rename", "move"],
"git_tools": ["commit", "push", "checkout", "reset", "merge", "revert", "clean",
"stash", "tag", "fetch", "pull", "branch -", "branch delete",
"branch -d", "branch -D", "remote add", "remote set", "amend"],
"file_manager": ["write", "delete", "move", "copy", "rename", "mkdir", "rm",
"create", "save", "touch"],
"sub_agent": ["run", "dispatch", "execute", "spawn", "create", "new", "task", "assign"],
"auto_engineer": ["run", "test", "build", "fix", "format", "apply", "generate",
"scaffold", "create", "refactor", "exec"],
"memory": ["write", "save", "add", "set", "delete", "edit", "update", "forget"],
# 整类放行(只读探索必需)
"tool_search": [],
"ai_search": [],
"code_search": [],
}
def plan_is_write_operation(tool_name: str, tool_args) -> bool:
"""Plan 模式下判断一次工具调用是否属于「写操作」(应被拦截)。
返回 True 表示会修改状态/文件/系统,应拦截;False 表示只读,可放行。
"""
rules = PLAN_WRITE_RULES.get(tool_name)
if rules is None:
# 未登记的未知工具:保守拦截
return True
if rules == "*":
return True
if not rules:
return False
args_l = str(tool_args).strip().lower()
parts = args_l.split()
op = parts[0] if parts else ""
for kw in rules:
kwp = kw.split()
if len(kwp) == 1:
# 单字关键字:精确匹配操作词,或作为其前缀/后缀(避免 "editor" 误中 "edit")
if op == kw or op.startswith(kw) or op.endswith(kw):
return True
else:
# 多词规则,如 "remote add" / "branch -d"
if args_l.startswith(kw) or " ".join(parts[:len(kwp)]) == kw:
return True
return False
class SafetyLayer:
"""统一安全检查层"""
@@ -26,6 +85,7 @@ class SafetyLayer:
self.cli = None
self._analysis_cache = {} # 缓存分析结果
self._lock = threading.Lock()
self._permissions_override = None # set_permissions 注入的显式权限
def set_cli(self, cli):
self.cli = cli
@@ -44,6 +104,62 @@ class SafetyLayer:
self.mode = (self.mode + 1) % 3
return self.get_mode_name()
# ── 声明式权限(opencode 式 allow/ask/deny)──
# 规则语法:
# "*" → 匹配任意工具/操作
# "tool" → 精确匹配工具名
# "tool:*" → 该工具任意操作
# "tool:op" → 工具名 + 参数首个词命中 op 前缀
# 优先级:deny(阻断)> ask(强制确认)> allow(自动放行)> 默认(按模式)
def set_permissions(self, allow=None, ask=None, deny=None):
"""显式注入权限规则(测试或启动时调用),优先级最高。"""
self._permissions_override = {
"allow": list(allow or []),
"ask": list(ask or []),
"deny": list(deny or []),
}
def _get_permissions(self) -> dict:
if self._permissions_override is not None:
return self._permissions_override
return self._load_permissions()
def _load_permissions(self) -> dict:
try:
from .config import load_config
except ImportError:
try:
from config import load_config
except ImportError:
return {"allow": [], "ask": [], "deny": []}
cfg = load_config() or {}
perm = cfg.get("permissions") or {}
return {
"allow": perm.get("allow", []) or [],
"ask": perm.get("ask", []) or [],
"deny": perm.get("deny", []) or [],
}
def _permission_match(self, rule: str, tool_name: str, tool_args) -> bool:
rule = (rule or "").strip()
if not rule:
return False
if rule == "*":
return True
if ":" in rule:
rtool, raction = rule.split(":", 1)
rtool = rtool.strip()
raction = raction.strip()
if rtool != tool_name:
return False
if raction in ("*", ""):
return True
op = (str(tool_args).strip().split()[0].lower()
if str(tool_args).strip() else "")
return op == raction or op.startswith(raction)
return rule == tool_name
# ── 核心:拦截检查 ──
def check(self, tool_name: str, tool_args: str) -> Tuple[bool, str]:
@@ -51,6 +167,34 @@ class SafetyLayer:
检查工具调用是否安全
返回: (允许执行, 消息)
"""
# ── Plan 模式只读约束(最高优先级,先于所有安全模式判断)──
# plan 模式下只允许只读调研工具,写操作一律暂挂,待 /build 批准后执行。
cli = self.cli
if cli is not None and getattr(cli, 'plan_mode', False):
if plan_is_write_operation(tool_name, tool_args):
return False, "PLAN 模式:写操作已暂挂(只读调研中)。请在 /build 批准后执行。"
return True, ""
# ── 声明式权限(config.permissions: deny > ask > allow)──
perm = self._get_permissions()
# deny 最高优先级:直接阻断(即便无限制模式也生效)
for rule in perm.get("deny", []):
if self._permission_match(rule, tool_name, tool_args):
return False, f"权限拒绝(deny 规则 '{rule}' 命中): {tool_name}"
# ask:强制人工确认(覆盖自动放行,即便无限制模式也确认)
for rule in perm.get("ask", []):
if self._permission_match(rule, tool_name, tool_args):
return self._ask_user_confirm(
tool_name, tool_args,
risk_level="中",
impact="命中 ask 规则,需你确认后执行",
reason=f"权限规则 '{rule}'"
)
# allow:自动放行(跳过 AI 风险分析)
for rule in perm.get("allow", []):
if self._permission_match(rule, tool_name, tool_args):
return True, ""
# 无限制模式:直接放行
if self.mode == MODE_UNRESTRICTED:
return True, ""
+201
View File
@@ -0,0 +1,201 @@
"""会话(Session)持久化与恢复。
把 openai/ollama/manual 等引擎的对话历史(self.shared_conversation_history)
按「会话」维度持久化到 chat_history/<id>.json,支持:
- 自动落盘(每轮对话后调用 SessionManager.save)
- 列出历史会话(/resume、/sessions)
- 一键恢复(把 messages 重新载入 shared_conversation_history)
存储格式(新):
{
"id": "20260805-171045-a1b2c3",
"title": "用户首条消息前若干个字",
"created_at": "2026-08-05 17:10:45",
"updated_at": "2026-08-05 17:15:22",
"engine": "openai",
"model": "xophunyuan7bmt",
"messages": [ ... ]
}
兼容旧 /chat save 写入的文件(无 id 字段、用 "conversation" 键):
读取时回退——id 取文件名,messages 取 conversation 键。
"""
import os
import re
import time
import json
import uuid
def _make_session_id() -> str:
"""生成形如 20260805-171045-a1b2c3 的会话 ID。"""
ts = time.strftime("%Y%m%d-%H%M%S")
suffix = uuid.uuid4().hex[:6]
return f"{ts}-{suffix}"
def _title_from_messages(messages, limit: int = 40) -> str:
"""从首条 user 消息生成会话标题。"""
for m in messages or []:
if isinstance(m, dict) and m.get("role") == "user":
content = m.get("content", "")
if isinstance(content, list): # 多模态 content 可能是列表
parts = [c.get("text", "") for c in content if isinstance(c, dict)]
content = " ".join(parts)
content = (content or "").strip().replace("\n", " ")
return content[:limit] if content else "(空消息)"
return "(未命名会话)"
class Session:
"""单个会话的轻量容器。"""
def __init__(self, sid, title, created_at, updated_at, engine, model, messages):
self.id = sid
self.title = title
self.created_at = created_at
self.updated_at = updated_at
self.engine = engine
self.model = model
self.messages = messages or []
@property
def msg_count(self) -> int:
return len(self.messages)
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"created_at": self.created_at,
"updated_at": self.updated_at,
"engine": self.engine,
"model": self.model,
"messages": self.messages,
}
@classmethod
def from_dict(cls, data: dict, fallback_id: str = ""):
# 兼容旧 /chat save 格式:无 id、用 "conversation" 键
sid = data.get("id") or fallback_id
messages = data.get("messages")
if messages is None and "conversation" in data:
messages = data["conversation"]
return cls(
sid=sid,
title=data.get("title") or _title_from_messages(messages),
created_at=data.get("created_at") or data.get("timestamp") or "",
updated_at=data.get("updated_at") or data.get("timestamp") or "",
engine=data.get("engine") or "",
model=data.get("model") or "",
messages=messages or [],
)
class SessionManager:
"""会话的读写与索引管理。"""
def __init__(self, history_dir: str):
self.history_dir = history_dir
if history_dir and not os.path.exists(history_dir):
try:
os.makedirs(history_dir)
except OSError:
pass
# ── 新建 ──
def new_session(self, engine: str = "", model: str = ""):
now = time.strftime("%Y-%m-%d %H:%M:%S")
return Session(
sid=_make_session_id(),
title="(新会话)",
created_at=now,
updated_at=now,
engine=engine or "",
model=model or "",
messages=[],
)
# ── 保存 ──
def save(self, session: Session, messages, engine: str = "", model: str = ""):
"""把当前对话内容写回到 session 对应的文件。"""
if session is None:
return False
session.messages = list(messages or [])
if not session.engine and engine:
session.engine = engine
if not session.model and model:
session.model = model
# 首条用户消息生成标题(仅当仍是占位标题时)
if session.title in ("(新会话)", "(未命名会话)", ""):
session.title = _title_from_messages(session.messages)
session.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
if not session.created_at:
session.created_at = session.updated_at
file_path = os.path.join(self.history_dir, f"{session.id}.json")
try:
with open(file_path, "w", encoding="utf-8") as f:
json.dump(session.to_dict(), f, ensure_ascii=False, indent=2, default=str)
return True
except OSError:
return False
# ── 列出 ──
def list_sessions(self) -> list:
"""返回按更新时间倒序的 Session 列表。"""
sessions = []
try:
files = [f for f in os.listdir(self.history_dir) if f.endswith(".json")]
except OSError:
return sessions
for f in files:
path = os.path.join(self.history_dir, f)
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
sid = data.get("id") or f[:-5]
s = Session.from_dict(data, fallback_id=sid)
# 跳过明显空会话(无消息且非手动命名)
sessions.append(s)
except (OSError, json.JSONDecodeError, ValueError):
continue
sessions.sort(key=lambda s: s.updated_at, reverse=True)
return sessions
# ── 按 id 或序号解析 ──
def resolve(self, token: str, sessions: list = None) -> Session:
"""token 可以是会话 id(含文件名去后缀)或列表序号(从1)。"""
if sessions is None:
sessions = self.list_sessions()
token = (token or "").strip()
# 序号
if token.isdigit():
idx = int(token) - 1
if 0 <= idx < len(sessions):
return sessions[idx]
# id(精确或前缀)
for s in sessions:
if s.id == token or s.id.startswith(token):
return s
# 文件名(去掉 .json)
if token.endswith(".json"):
token = token[:-5]
for s in sessions:
if s.id == token:
return s
return None
def load(self, token: str) -> Session:
"""读取指定会话的完整内容(含 messages)。"""
sessions = self.list_sessions()
s = self.resolve(token, sessions)
if s is None:
return None
# 重新从文件读取以确保 messages 完整
path = os.path.join(self.history_dir, f"{s.id}.json")
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
return Session.from_dict(data, fallback_id=s.id)
except (OSError, json.JSONDecodeError, ValueError):
return s
+54
View File
@@ -117,3 +117,57 @@ class ToolResult:
def to_string(self) -> str:
"""转为字符串(兼容旧插件 handle() 返回值)"""
return str(self)
# ── 归一化辅助(跨层兼容:ToolResult / dict / str / None)──
def normalize_tool_text(result: Any, default: str = "无结果") -> str:
"""把任意工具返回值统一成字符串。
工具链里三种返回形态都可能出现:
- ToolResult(新插件 / UnifiedToolManager.execute)
- {"result": ...}(旧插件 / to_dict)
- 纯字符串(最老的 handler)
统一收敛成字符串,避免上层对 ToolResult 调用 .get() 报
"'ToolResult' object has no attribute 'get'"。
"""
if result is None:
return default
if isinstance(result, ToolResult):
text = str(result)
elif isinstance(result, dict):
text = result.get("result", result.get("content", ""))
if isinstance(text, (dict, list)):
import json as _json
try:
text = _json.dumps(text, ensure_ascii=False)
except Exception:
text = str(text)
elif not isinstance(text, str):
text = str(text) if text is not None else ""
else:
text = str(result)
text = text.strip() if isinstance(text, str) else str(text)
return text if text else default
def normalize_tool_dict(result: Any, default: str = "无结果") -> dict:
"""把任意工具返回值统一成 {"result": str, ...} 结构。
额外附带 success / error_code / tool_result,方便需要细节的调用方,
同时保证 .get('result') 一定可用。
"""
text = normalize_tool_text(result, default)
if isinstance(result, ToolResult):
return {
"result": text,
"success": result.success,
"error_code": result.error_code,
"tool_result": result,
}
if isinstance(result, dict):
out = dict(result)
out["result"] = text
out.setdefault("success", True)
return out
return {"result": text, "success": True}
+728 -51
View File
@@ -1,6 +1,6 @@
"""TUI 界面模块 - Textual TUI 实现 (动画增强版 + 多行输入)"""
from .constants import TEXTUAL_AVAILABLE
from .constants import TEXTUAL_AVAILABLE, VERSION
if TEXTUAL_AVAILABLE:
import re
@@ -11,6 +11,8 @@ if TEXTUAL_AVAILABLE:
from textual.reactive import var
from textual.binding import Binding
from textual import on
from .vim_keys import VimInputState
import os
from rich.syntax import Syntax
from rich.panel import Panel
from rich.box import ROUNDED
@@ -197,6 +199,28 @@ if TEXTUAL_AVAILABLE:
transition: color 300ms in_out_cubic;
}}
/* ── 文件树 / 会话列表 ── */
.file-tree-item {{
padding: 0 0 0 1;
color: {_Theme.TEXT_MUTED};
}}
.file-tree-dir {{
color: {_Theme.ACCENT};
text-style: bold;
}}
.session-item {{
padding: 0 0 0 1;
color: {_Theme.TEXT_MUTED};
}}
.session-item-active {{
color: {_Theme.SUCCESS};
text-style: bold;
}}
.plan-badge {{
color: {_Theme.WARNING};
text-style: bold;
}}
/* ── 消息样式 ── */
.msg-user {{
color: {_Theme.USER};
@@ -297,6 +321,97 @@ if TEXTUAL_AVAILABLE:
background: {_Theme.BG};
}}
/* ── 自定义顶栏 (opencode 风格) ── */
#tui-header {{
height: 1;
width: 100%;
dock: top;
background: {_Theme.BG_LIGHT};
color: {_Theme.TEXT_MUTED};
padding: 0 1;
border-bottom: solid {_Theme.BORDER};
}}
#tui-header .hdr-left {{ color: {_Theme.ACCENT}; text-style: bold; }}
#tui-header .hdr-mid {{ color: {_Theme.TEXT_MUTED}; }}
#tui-header .hdr-right {{ color: {_Theme.SUCCESS}; }}
/* ── 侧栏标签页 ── */
#sidebar-tabs {{
height: 1;
width: 100%;
dock: top;
background: {_Theme.BG};
padding: 0;
}}
.sidebar-tab {{
display: block;
height: 1;
padding: 0 1;
color: {_Theme.TEXT_DIM};
text-style: bold;
}}
.sidebar-tab:hover {{
color: {_Theme.TEXT_MUTED};
}}
.sidebar-tab-active {{
color: {_Theme.ACCENT};
border-bottom: solid {_Theme.ACCENT};
}}
/* ── 侧栏内容面板 ── */
#sidebar-content {{
height: 1fr;
padding: 0 1;
}}
.panel-section {{
padding: 1 0 0 0;
border-bottom: solid {_Theme.BORDER};
}}
.panel-title {{
color: {_Theme.TEXT_DIM};
padding: 0 0 1 0;
}}
/* ── 消息角色色条 (opencode 风格) ── */
.msg-user {{
color: {_Theme.USER};
padding: 0 0 0 2;
border-left: solid {_Theme.USER};
}}
.msg-ai {{
color: {_Theme.AI};
padding: 0 0 0 2;
border-left: solid {_Theme.GLOW};
}}
.msg-tool-ok {{
color: {_Theme.TOOL};
padding: 0 0 0 2;
border-left: solid {_Theme.TOOL};
}}
.msg-tool-err {{
color: {_Theme.TOOL_ERR};
padding: 0 0 0 2;
border-left: solid {_Theme.TOOL_ERR};
}}
.msg-system {{
color: {_Theme.ACCENT};
padding: 0 0 0 2;
border-left: solid {_Theme.ACCENT};
}}
.msg-error {{
color: {_Theme.ERROR};
padding: 0 0 0 2;
border-left: solid {_Theme.ERROR};
}}
/* ── 精简欢迎框 ── */
.welcome-compact {{
color: {_Theme.WELCOME_ACCENT};
padding: 1 2;
border-bottom: solid {_Theme.BORDER};
text-style: bold;
}}
"""
class _TUIBridge:
@@ -317,6 +432,24 @@ if TEXTUAL_AVAILABLE:
return True
return False
def sessions(self) -> list:
mgr = getattr(self.cli, 'session_manager', None)
if mgr and hasattr(mgr, 'list_sessions'):
try:
return mgr.list_sessions()
except Exception:
return []
return []
def plan_mode(self) -> bool:
return bool(getattr(self.cli, 'plan_mode', False))
def current_plan(self) -> str:
return getattr(self.cli, 'current_plan', '') or ""
def cwd(self) -> str:
return os.getcwd()
# ═══════════════════════════════════════════════════
# 动画辅助组件
@@ -379,7 +512,12 @@ if TEXTUAL_AVAILABLE:
self.styles.opacity = 0
def on_mount(self):
self.animate("opacity", value=1.0, duration=0.3, easing="out_cubic")
# textual >= 8: Widget.opacity 是只读属性,直接 animate 会抛
# "property 'opacity' has no setter"。改为在 styles 上动画,失败则直接到位。
try:
self.styles.animate("opacity", value=1.0, duration=0.3, easing="out_cubic")
except Exception:
self.styles.opacity = 1.0
class TypingIndicator(Static):
@@ -423,6 +561,125 @@ if TEXTUAL_AVAILABLE:
return
# 其他键正常处理
# ═══════════════════════════════════════════════════
# Vim 风格模态输入框(NORMAL / INSERT)
# ═══════════════════════════════════════════════════
class VimSendTextArea(SendTextArea):
"""
带 Vim 模态编辑的输入框。
模式:
INSERT —— 默认,正常打字;Esc 进入 NORMAL
NORMAL —— h/j/k/l 移动,i/a/o 进入插入,dd 删行,x 删字,
w/b 跳词,0/$ 行首行尾,gg/G 文首文尾,u 撤销 …
坐标:VimInputState 用字符偏移 offset,这里负责与 TextArea 的
(row, col) 光标互相转换。所有 vim 逻辑都在 vim_keys.py(纯函数,
可单测);本类只做「按键映射 + 把结果写回 TextArea」。
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.vim_enabled = True
self.vim = VimInputState(mode="INSERT")
# ── offset <-> (row, col) ──
def _cur_off(self) -> int:
r, c = self.cursor_location
text = self.text
lines = text.split("\n")
return sum(len(l) + 1 for l in lines[:r]) + c
def _set_off(self, off: int):
text = self.text
off = max(0, min(off, len(text)))
r = text.count("\n", 0, off)
line_start = text.rfind("\n", 0, off)
c = off - (line_start + 1)
self.cursor_location = (r, c)
# ── key 名字 -> vim token ──
@staticmethod
def _vim_token(key: str):
m = {
"escape": "escape", "h": "h", "l": "l", "j": "j", "k": "k",
"w": "w", "b": "b", "e": "e", "0": "0", "$": "$", "^": "^",
"g": "g", "G": "G", "d": "d", "x": "x", "u": "u",
"i": "i", "a": "a", "o": "o", "O": "O", "I": "I", "A": "A",
"left": "left", "right": "right", "up": "up", "down": "down",
"shift+i": "I", "shift+a": "A", "shift+o": "O",
"shift+g": "G", "shift+6": "^", "shift+4": "$",
}
return m.get(key)
def _notify_mode(self):
app = self.app
if hasattr(app, "_update_vim_mode_display"):
try:
app._update_vim_mode_display()
except Exception:
pass
def _apply(self, res):
try:
if res.text != self.text:
self.text = res.text
self._set_off(res.offset)
self.vim.mode = res.mode
self.vim.pending = res.pending
if res.action == "undo":
try:
self.undo()
except Exception:
pass
self._notify_mode()
except Exception:
# 任何意外都不应让输入框崩;退回默认行为
pass
async def _on_key(self, event):
# 回车:INSERT(或 vim 关闭)发送;NORMAL 下移一行
if event.key in ("enter", "\r", "\n"):
if self.vim_enabled and self.vim.mode == "NORMAL":
event.prevent_default()
res = self.vim.feed(self.text, self._cur_off(), "j")
self._apply(res)
else:
event.prevent_default()
try:
self.app.action_send_message()
except Exception:
pass
return
if not self.vim_enabled:
return # 退化为普通 TextArea
if self.vim.mode == "INSERT":
if event.key == "escape":
event.prevent_default()
res = self.vim.feed(self.text, self._cur_off(), "escape")
self._apply(res)
# 其余键交给 TextArea 默认处理(打字)
return
# ── NORMAL 模式 ──
if event.key == "shift+enter":
token = "o"
else:
token = self._vim_token(event.key)
if token is None:
# ctrl+/shift+ 组合键放回去,让 App 级绑定处理(聚焦切换等)
if event.key.startswith("ctrl+") or event.key.startswith("shift+"):
return
# 普通单键未映射:吞掉,避免 TextArea 在 NORMAL 下误编辑
event.prevent_default()
return
event.prevent_default()
res = self.vim.feed(self.text, self._cur_off(), token)
self._apply(res)
# ═══════════════════════════════════════════════════
# TUI 主应用
# ═══════════════════════════════════════════════════
@@ -440,11 +697,27 @@ if TEXTUAL_AVAILABLE:
Binding("ctrl+enter", "send_message", "发送", show=False),
Binding("shift+enter", "newline", "换行", show=False),
Binding("f1", "toggle_sidebar", "侧栏", show=True),
Binding("ctrl+p", "toggle_plan", "PLAN", show=True),
Binding("1", "tab_engine", "引擎", show=False),
Binding("2", "tab_files", "文件", show=False),
Binding("3", "tab_sessions", "会话", show=False),
Binding("4", "tab_status", "状态", show=False),
Binding("escape", "cancel", "取消", show=False),
# ── Vim 风格导航(输入框未聚焦时生效)──
Binding("j", "chat_down", "↓聊天", show=False),
Binding("k", "chat_up", "↑聊天", show=False),
Binding("i", "focus_insert", "插入", show=False),
Binding("ctrl+f", "chat_page_down", "翻页↓", show=True),
Binding("ctrl+b", "chat_page_up", "翻页↑", show=True),
Binding("ctrl+d", "chat_half_down", "半页↓", show=True),
Binding("ctrl+u", "chat_half_up", "半页↑", show=True),
Binding("ctrl+space", "toggle_focus", "聚焦切换", show=True),
]
sidebar_visible = var(True)
is_generating = var(False)
plan_mode = var(False)
_sidebar_tab: str = "engine" # 当前侧栏标签: engine/files/sessions/status
def __init__(self, cli):
super().__init__()
@@ -454,41 +727,80 @@ if TEXTUAL_AVAILABLE:
self._typing_widget = None
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
# ── 自定义顶栏 (opencode 风格: 左项目名 | 中模型 | 右状态) ──
yield Static("", id="tui-header")
with Horizontal(id="app-container"):
with Vertical(id="main"):
yield VerticalScroll(id="chat-scroll")
with Vertical(id="input-area"):
with Container(id="input-wrapper"):
yield SendTextArea(
placeholder="输入消息... (回车发送, Shift+回车换行)",
yield VimSendTextArea(
placeholder="输入消息... (回车发送, Shift+回车换行, Esc 进 Vim 普通模式)",
id="user-input",
soft_wrap=True,
tab_behavior="indent",
)
yield Static(
" 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏",
" 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏 | Ctrl+P PLAN",
id="input-hint"
)
# ── 侧栏:标签页 + 内容区 ──
with Vertical(id="sidebar"):
yield Static(" 引擎", classes="sidebar-header")
yield Vertical(id="engine-list", classes="sidebar-section")
yield Rule(line_style="heavy")
yield Static(" 工具", classes="sidebar-header")
yield Vertical(id="tool-list", classes="sidebar-section")
yield Rule(line_style="heavy")
yield Static(" 状态", classes="sidebar-header")
yield Vertical(id="status-info", classes="sidebar-section")
# 标签行
with Horizontal(id="sidebar-tabs"):
yield Static(" 引擎 ", classes="sidebar-tab sidebar-tab-active", id="tab-engine")
yield Static(" 文件 ", classes="sidebar-tab", id="tab-files")
yield Static(" 会话 ", classes="sidebar-tab", id="tab-sessions")
yield Static(" 状态 ", classes="sidebar-tab", id="tab-status")
# 内容区(根据当前标签切换显示)
with VerticalScroll(id="sidebar-content"):
yield Vertical(id="panel-engine", classes="panel-section")
yield Vertical(id="panel-files", classes="panel-section")
yield Vertical(id="panel-sessions", classes="panel-section")
yield Vertical(id="panel-status", classes="panel-section")
yield Static(" 就绪 | Ctrl+C 退出", id="status-bar")
def on_mount(self):
self._render_welcome_animated()
self._update_sidebar()
# 初始化顶栏
self._update_header()
# 初始化侧栏: 默认显示引擎面板,隐藏其余
self._switch_sidebar_tab("engine")
# 精简欢迎(一行)
self._render_welcome_compact()
# 初始化 Vim 键位开关(默认开,可被 config.vim_mode 关闭)
self._init_vim_mode()
self.query_one("#user-input").focus()
# 延迟显示输入提示(淡入效果)
self.set_timer(1.5, lambda: self.query_one("#input-hint").add_class("visible"))
def _show_hint():
try:
self.query_one("#input-hint").add_class("visible")
except Exception:
pass
self.set_timer(1.5, _show_hint)
# ── 动画欢迎界面 ──
def _init_vim_mode(self):
try:
ta = self.query_one("#user-input")
except Exception:
return
cfg = getattr(self.cli, "config", None)
enabled = True
if isinstance(cfg, dict) and "vim_mode" in cfg:
enabled = bool(cfg["vim_mode"])
ta.vim_enabled = enabled
self._update_vim_mode_display()
# ── 欢迎界面 ──
def _render_welcome_compact(self):
"""opencode 风格: 精简一行欢迎"""
scroll = self.query_one("#chat-scroll")
engine = self.bridge.current_engine()
welcome = Static(
f" ▸ 小狸 Pro-CLI v8.0 │ {engine} │ "
f"{len(self.bridge.tools())} 工具 │ /help 查看命令",
classes="welcome-compact"
)
scroll.mount(welcome)
def _render_welcome_animated(self):
"""逐行动画显示欢迎界面"""
@@ -498,7 +810,7 @@ if TEXTUAL_AVAILABLE:
"",
" ╔══════════════════════════════════════════════╗",
" ║ ║",
" ║ 小狸 Pro-CLI v5.4.1 ║",
" ║ 小狸 Pro-CLI v8.0.3 ║",
" ║ 智能编程助手 · 动画增强版 ║",
" ║ ║",
" ╚══════════════════════════════════════════════╝",
@@ -542,7 +854,7 @@ if TEXTUAL_AVAILABLE:
("", "msg-dim"),
(" ╔══════════════════════════════════════════════╗", "msg-welcome"),
(" ║ ║", "msg-welcome"),
(" ║ 小狸 Pro-CLI v5.4.1 ║", "msg-welcome"),
(" ║ 小狸 Pro-CLI v8.0.3 ║", "msg-welcome"),
(" ║ 智能编程助手 · 动画增强版 ║", "msg-welcome"),
(" ║ ║", "msg-welcome"),
(" ╚══════════════════════════════════════════════╝", "msg-welcome"),
@@ -559,35 +871,197 @@ if TEXTUAL_AVAILABLE:
# ── 侧栏更新 ──
def _update_sidebar(self):
engine_container = self.query_one("#engine-list")
engine_container.remove_children()
"""按当前标签页更新侧栏内容"""
tab = self._sidebar_tab
# ── 引擎面板 ──
engine_panel = self.query_one("#panel-engine")
engine_panel.remove_children()
current = self.bridge.current_engine()
for name in self.bridge.engines():
if name == current:
engine_container.mount(Static(f" ▸ {name}", classes="engine-item-active"))
engine_panel.mount(Static(f" ▸ {name}", classes="engine-item-active"))
else:
engine_container.mount(Static(f" {name}", classes="engine-item"))
tool_container = self.query_one("#tool-list")
tool_container.remove_children()
for tool in self.bridge.tools():
engine_panel.mount(Static(f" {name}", classes="engine-item"))
# 工具列表也放引擎面板下方
engine_panel.mount(Static(" 工具", classes="panel-title"))
for tool in self.bridge.tools()[:12]:
name = tool.get('name', '?')
tool_container.mount(Static(f" • {name}", classes="tool-item"))
engine_panel.mount(Static(f" • {name}", classes="tool-item"))
status_container = self.query_one("#status-info")
status_container.remove_children()
status_container.mount(Static(f" 对话: {len(self.bridge.history())} 条", classes="tool-item"))
status_container.mount(Static(f" 工具: {len(self.bridge.tools())} 个", classes="tool-item"))
status_container.mount(Static(f" 引擎: {len(self.bridge.engines())} 个", classes="tool-item"))
# ── 文件树面板 ──
self._update_file_tree()
# 工具数
if hasattr(self.cli, 'liugin_manager'):
status_container.mount(Static(f" 工具: {len(self.cli.liugin_manager.tools)} 个", classes="tool-item"))
# ── 会话面板 ──
self._update_session_list()
# ── 状态面板 ──
status_panel = self.query_one("#panel-status")
status_panel.remove_children()
status_panel.mount(Static(f" 对话: {len(self.bridge.history())} 条", classes="tool-item"))
status_panel.mount(Static(f" 工具: {len(self.bridge.tools())} 个", classes="tool-item"))
status_panel.mount(Static(f" 引擎: {len(self.bridge.engines())} 个", classes="tool-item"))
if self.bridge.plan_mode():
status_panel.mount(Static(" 模式: 📋 PLAN", classes="plan-badge"))
else:
status_panel.mount(Static(" 模式: 普通", classes="tool-item"))
# 更新标签高亮
self._sync_tab_highlight()
# ── 文件树 ──
@staticmethod
def _build_file_tree(root, max_depth=2, max_items=40):
"""生成 (name, is_dir) 列表,用于侧栏文件树展示。"""
items = []
try:
entries = sorted(os.listdir(root))
except OSError:
return items
dirs = [e for e in entries if os.path.isdir(os.path.join(root, e))]
files = [e for e in entries if os.path.isfile(os.path.join(root, e))]
skip = {'.git', '__pycache__', '.tui_snapshots', '.pytest_cache',
'node_modules', '.workbuddy', 'chat_history', 'memory',
'.idea', '.vscode', '.venv', 'venv'}
shown = 0
for name in dirs + files:
if name in skip or name.startswith('.'):
continue
if shown >= max_items:
items.append(("…", False))
break
is_dir = os.path.isdir(os.path.join(root, name))
items.append((name, is_dir))
shown += 1
return items
def _update_file_tree(self):
container = self.query_one("#panel-files")
container.remove_children()
root = self.bridge.cwd()
container.mount(Static(f" 📂 {os.path.basename(root)}", classes="file-tree-dir"))
for name, is_dir in self._build_file_tree(root):
icon = "📁" if is_dir else "📄"
container.mount(Static(f" {icon} {name}", classes="file-tree-item"))
# ── 会话列表 ──
@staticmethod
def _format_rel_time(ts: str) -> str:
if not ts:
return ""
try:
from datetime import datetime
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
diff = (datetime.now() - dt).total_seconds()
if diff < 60:
return "刚刚"
if diff < 3600:
return f"{int(diff // 60)}分钟前"
if diff < 86400:
return f"{int(diff // 3600)}小时前"
return f"{int(diff // 86400)}天前"
except Exception:
return ""
def _update_session_list(self):
container = self.query_one("#panel-sessions")
container.remove_children()
sessions = self.bridge.sessions()[:8]
if not sessions:
container.mount(Static(" (无历史会话)", classes="session-item"))
return
for i, s in enumerate(sessions, 1):
title = (s.title or s.id)[:22]
t = self._format_rel_time(s.updated_at)
container.mount(Static(f" {i}. {title} {t}", classes="session-item"))
# ── 标签页切换 ──
def _sync_tab_highlight(self):
"""同步标签高亮状态"""
tab_map = {"engine": "tab-engine", "files": "tab-files",
"sessions": "tab-sessions", "status": "tab-status"}
for tid in tab_map.values():
try:
w = self.query_one(f"#{tid}")
if tid == tab_map.get(self._sidebar_tab, ""):
w.add_class("sidebar-tab-active")
else:
w.remove_class("sidebar-tab-active")
except Exception:
pass
def _switch_sidebar_tab(self, tab: str):
"""切换侧栏标签页"""
self._sidebar_tab = tab
# 显示/隐藏面板
panel_map = {
"engine": "panel-engine", "files": "panel-files",
"sessions": "panel-sessions", "status": "panel-status",
}
for pt, pid in panel_map.items():
try:
p = self.query_one(f"#{pid}")
if pt == tab:
p.remove_class("hidden")
else:
p.add_class("hidden")
except Exception:
pass
self._sync_tab_highlight()
self._update_sidebar()
def action_tab_engine(self):
self._switch_sidebar_tab("engine")
def action_tab_files(self):
self._switch_sidebar_tab("files")
def action_tab_sessions(self):
self._switch_sidebar_tab("sessions")
def action_tab_status(self):
self._switch_sidebar_tab("status")
def _update_header(self):
"""更新自定义顶栏内容"""
hdr = self.query_one("#tui-header", expect_type=Static)
engine = self.bridge.current_engine()
plan_tag = " [PLAN]" if self.bridge.plan_mode() else ""
hist_len = len(self.bridge.history())
tok = self._token_str().strip()
hdr.update(
f" 小狸 v8.0{plan_tag} │ {engine} │ {hist_len}条对话{tok}"
)
def _update_status(self, text: str):
bar = self.query_one("#status-bar")
engine = self.bridge.current_engine()
bar.update(f" {text} | {engine} | {len(self.bridge.history())} 条对话")
prefix = "📋 PLAN | " if self.plan_mode else ""
bar.update(f" {prefix}{text} | {engine} | {len(self.bridge.history())} 条对话{self._token_str()}")
def _token_str(self):
"""token 用量进度(v8.0 token 感知压缩联动,引擎支持时显示)"""
e = getattr(self.cli, 'current_engine', None)
if not e:
return ""
used = getattr(e, 'last_prompt_tokens', None)
win = None
if hasattr(e, 'context_window'):
try:
win = e.context_window()
except Exception:
win = None
if used and win:
ratio = min(1.0, used / win)
filled = int(ratio * 10)
bar_s = "▓" * filled + "░" * (10 - filled)
return f" | {used}/{win} {bar_s}"
if used:
return f" | {used} tok"
return ""
# ── 消息追加 ──
@@ -743,27 +1217,36 @@ if TEXTUAL_AVAILABLE:
'cls': lambda: self.action_clear(),
'model': lambda: self._switch_model(args),
'engine': lambda: self._switch_model(args),
'about': lambda: self._system(" 小狸 Pro-CLI v5.4.1 - 智能编程助手"),
'about': lambda: self._system(f" 小狸 Pro-CLI v{VERSION} - 智能编程助手"),
'status': lambda: self._show_status(),
'tools': lambda: self._show_tools(),
'engines': lambda: self._show_engines(),
'tui': lambda: self._system("已在 TUI 模式中"),
'manual': lambda: self._handle_manual(args),
'plan': lambda: self._tui_plan(args),
'build': lambda: self._tui_build(),
'sessions': lambda: self._tui_sessions(),
'session': lambda: self._tui_session(args),
'resume': lambda: self._tui_resume(args),
'snapshot': lambda: self._save_screenshot(),
'screenshot': lambda: self._save_screenshot(),
'vim': lambda: self._toggle_vim(),
}
handler = cmds.get(name)
if handler:
handler()
else:
if name in self.cli.liugin_commands:
try:
result = self.cli.liugin_commands[name](args)
if result:
self._system(result[:500])
except Exception as e:
self._error(f"插件命令失败: {e}")
else:
self._error(f"未知命令: /{name},输入 /help 查看帮助")
return
if name in self.cli.liugin_commands:
try:
result = self.cli.liugin_commands[name](args)
if result:
self._system(result[:500])
except Exception as e:
self._error(f"插件命令失败: {e}")
return
# 未知命令转发给 cli 完整路由(/agent /providers /chat 等)
self._forward_to_cli(cmd)
def _show_help(self):
help_text = """ 命令:
@@ -775,12 +1258,21 @@ if TEXTUAL_AVAILABLE:
/tools 列出工具
/status 系统状态
/clear 清屏
/vim 开关 Vim 键位
⌨ 快捷键:
Ctrl+C 退出 Ctrl+L 清屏
Ctrl+N 新对话 回车 发送消息
Shift+回车 换行 F1 侧栏
Escape 取消生成 Tab 缩进"""
Escape 取消生成 Tab 缩进
Ctrl+Space 焦点切换 i 聚焦并输入
Ctrl+F/B 翻页下/上 Ctrl+D/U 半页下/上
VIM 输入(默认开): 输入框内 Esc 进普通模式
h j k l 移动 w/b 跳词 0/$ 行首/行尾
i a o 插入/追加/换行 I A O 行首/行尾/上方
dd 删行 x 删字 u 撤销 gg/G 文首/文尾
普通模式下 回车 = 下移一行"""
self._system(help_text)
def _handle_manual(self, args):
@@ -845,6 +1337,84 @@ if TEXTUAL_AVAILABLE:
self._system('\n'.join(status_lines))
# ── Plan 模式 / 会话 委派 ──
def _tui_plan(self, args):
args = (args or "").strip()
if args in ('off', 'exit', '退出', 'cancel'):
self._set_plan_mode(False)
self._system("已退出 PLAN 模式(计划未执行)")
return
self._set_plan_mode(True)
if args:
self._system(f"📋 已进入 PLAN 模式,正在只读调研: {args}")
self._user_msg(f"/plan {args}")
self._show_thinking()
self.is_generating = True
self._update_status("思考中 (PLAN)...")
self.run_worker(self._generate(args), exclusive=True)
else:
self._system("📋 已进入 PLAN 模式。描述任务,AI 将只做只读调研并给出实施计划;/build 批准执行,/plan off 取消。")
def _tui_build(self):
if not self.bridge.plan_mode() and not self.bridge.current_plan().strip():
self._system("当前不在 PLAN 模式,且无已生成的计划")
return
self._set_plan_mode(False)
self._system("✅ 已批准 PLAN,开始执行。")
def _tui_sessions(self):
sessions = self.bridge.sessions()
if not sessions:
self._system(" 无历史会话")
return
lines = [" 历史会话:"]
for i, s in enumerate(sessions[:10], 1):
title = (s.title or s.id)[:30]
t = self._format_rel_time(s.updated_at)
lines.append(f" {i}. {title} ({t})")
self._system('\n'.join(lines))
def _tui_session(self, args):
args = (args or "").strip()
if not args:
self._tui_sessions()
return
if args == 'new':
self.action_new_chat()
return
self._forward_to_cli(f"/resume {args}")
def _tui_resume(self, args):
self._forward_to_cli(f"/resume {args}")
def _forward_to_cli(self, cmd: str):
"""把未知/委派命令交给 cli 完整路由处理。"""
try:
self.cli.process_conversation(cmd)
except Exception as e:
self._error(f"命令执行失败: {e}")
self.call_after_refresh(self._update_sidebar)
self.call_after_refresh(self._sync_plan)
def _set_plan_mode(self, on: bool):
self.plan_mode = on
try:
self.cli.plan_mode = on
if not on:
self.cli.current_plan = ""
except Exception:
pass
self._update_sidebar()
self._update_status("📋 PLAN" if on else "就绪")
def _sync_plan(self):
"""从 cli 同步 plan 状态到 TUI 反应变量。"""
remote = self.bridge.plan_mode()
if remote != self.plan_mode:
self.plan_mode = remote
self._update_sidebar()
# ── AI 生成 ──
async def _generate(self, user_input):
@@ -874,8 +1444,10 @@ if TEXTUAL_AVAILABLE:
self.call_after_refresh(self._remove_thinking)
self.call_after_refresh(self._remove_typing_indicator)
self.is_generating = False
self.call_after_refresh(self._sync_plan)
self.call_after_refresh(lambda: self._update_status("就绪"))
self.call_after_refresh(self._update_sidebar)
self.call_after_refresh(self._update_header)
def _write_raw(self, msg):
if '✅' in msg or 'OK 工具' in msg:
@@ -911,6 +1483,10 @@ if TEXTUAL_AVAILABLE:
sidebar.add_class("hidden")
self.sidebar_visible = False
def action_toggle_plan(self):
self._set_plan_mode(not self.plan_mode)
self._system("📋 PLAN 模式: " + ("开" if self.plan_mode else "关"))
def action_cancel(self):
if self.is_generating:
self._remove_thinking()
@@ -918,6 +1494,107 @@ if TEXTUAL_AVAILABLE:
self._system("已取消")
self.is_generating = False
# ── Vim 风格:聊天区滚动 / 聚焦切换 ──
def _user_input(self):
return self.query_one("#user-input")
def _chat_scroll(self):
return self.query_one("#chat-scroll")
def action_toggle_focus(self):
"""Ctrl+Space:在输入框与聊天区之间切换焦点。"""
ta = self._user_input()
if ta.has_focus:
self.screen.set_focus(None)
else:
ta.focus()
if getattr(ta, "vim_enabled", False):
ta.vim.mode = "INSERT"
self._update_vim_mode_display()
def action_focus_insert(self):
"""i:输入框未聚焦时聚焦并进入 INSERT(模仿 vim 按 i 开始输入)。"""
ta = self._user_input()
if ta.has_focus:
return
ta.focus()
if getattr(ta, "vim_enabled", False):
ta.vim.mode = "INSERT"
self._update_vim_mode_display()
def _guard_chat(self):
"""聊天导航仅在输入框未聚焦时生效,避免与输入冲突。"""
return self._user_input().has_focus
def action_chat_down(self):
if self._guard_chat():
return
self._chat_scroll().scroll_down(3)
def action_chat_up(self):
if self._guard_chat():
return
self._chat_scroll().scroll_up(3)
def action_chat_page_down(self):
if self._guard_chat():
return
self._chat_scroll().scroll_page_down()
def action_chat_page_up(self):
if self._guard_chat():
return
self._chat_scroll().scroll_page_up()
def action_chat_half_down(self):
if self._guard_chat():
return
self._chat_scroll().scroll_relative(y=20)
def action_chat_half_up(self):
if self._guard_chat():
return
self._chat_scroll().scroll_relative(y=-20)
def _update_vim_mode_display(self):
"""在输入框下方提示栏显示当前 Vim 模式。"""
try:
hint = self.query_one("#input-hint")
ta = self._user_input()
except Exception:
return
if not getattr(ta, "vim_enabled", False):
hint.update(" 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏 | Ctrl+P PLAN")
return
mode = getattr(getattr(ta, "vim", None), "mode", "INSERT")
tag = "-- INSERT --" if mode == "INSERT" else "-- NORMAL --"
hint.update(f" {tag} │ Esc 切模式 │ i插入 a追加 o换行 hjkl移动 dd删行 x删字 u撤销")
def _toggle_vim(self):
ta = self._user_input()
ta.vim_enabled = not getattr(ta, "vim_enabled", True)
if ta.vim_enabled:
self._system("Vim 键位: 开 (Esc 在 NORMAL/INSERT 间切换)")
else:
self._system("Vim 键位: 关 (普通输入)")
self._update_vim_mode_display()
def _save_screenshot(self):
"""导出当前 TUI 屏幕截图(SVG)——AI 可据此自验实时渲染(/snapshot)"""
import os
out_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".tui_snapshots"
)
os.makedirs(out_dir, exist_ok=True)
path = os.path.join(out_dir, "live.svg")
try:
svg = self.export_screenshot()
with open(path, "w", encoding="utf-8") as f:
f.write(svg)
self._system(f"截图已保存: {path}")
except Exception as e:
self._error(f"截图失败: {e}")
else:
# Textual 不可用时的占位类
class XiaoliTUI:
+24
View File
@@ -0,0 +1,24 @@
"""
启动详情开关
默认启动只报结论(加载了几个引擎/几个工具),不再逐条刷屏。
需要看每个插件/技能的加载明细时,加 --verbose(或 -v),
也可以设环境变量 XCLI_VERBOSE=1。
"""
import os
import sys
_TRUTHY = ('1', 'true', 'yes', 'on')
def is_verbose() -> bool:
"""是否输出启动明细"""
if any(a in ('--verbose', '-v') for a in sys.argv[1:]):
return True
return str(os.environ.get('XCLI_VERBOSE', '')).lower() in _TRUTHY
def vprint(*args, **kwargs):
"""仅在 --verbose 时打印的 print"""
if is_verbose():
print(*args, **kwargs)
+275
View File
@@ -0,0 +1,275 @@
"""
Vim 风格输入编辑状态机(纯逻辑,不依赖 textual)
================================================
把“模态编辑”的核心(NORMAL / INSERT 双模式 + 各种 motion / operator)抽成
纯函数 + 一个小状态类,便于在不依赖 textual 的环境里做单元测试。
widget 侧(tui.py 的 VimSendTextArea)只负责:
- 把 textual 的 key 名字映射成这里的 token(见 tui.py 的 _vim_token)
- 调用 VimInputState.feed(text, offset, token) 拿到 EditResult
- 把 EditResult 应用回 TextArea(设 text / 设光标 / 触发 undo 等)
坐标约定:文本用单一字符串表示,光标用「字符偏移 offset」表示(从 0 开始)。
widget 负责 offset <-> (row, col) 的转换。
"""
from dataclasses import dataclass
from typing import Optional
@dataclass
class EditResult:
"""一次 vim 动作的执行结果。"""
text: str # 应用后的文本
offset: int # 应用后的光标偏移
mode: str # 结束时的模式: "INSERT" / "NORMAL"
pending: Optional[str] # 多键序列的待定前缀: "d" / "g" / None
handled: bool = True # False 表示“交给输入框默认处理”(仅 INSERT 非 Esc 键)
action: Optional[str] = None # 额外指令: "undo"
message: str = "" # 状态栏提示语
# ── 文本/坐标辅助 ──────────────────────────────────────────────────────────
def _line_start_off(text: str, off: int) -> int:
"""当前行行首偏移。"""
s = text.rfind("\n", 0, off)
return s + 1
def _line_end_off(text: str, off: int) -> int:
"""当前行行尾(不含换行符)偏移。"""
e = text.find("\n", off)
return e if e != -1 else len(text)
def _first_nonblank_off(text: str, off: int) -> int:
"""当前行第一个非空白字符偏移,若整行空白则回到行首。"""
s = _line_start_off(text, off)
e = _line_end_off(text, off)
i = s
while i < e and text[i].isspace():
i += 1
return i
def _word_forward_off(text: str, off: int) -> int:
"""跳到下一个 word 的词首(vim 的 w):越过当前词 + 词间空白。"""
n = len(text)
i = off
# 越过错当前空白
while i < n and text[i].isspace():
i += 1
# 越过当前 word
if i < n and (text[i].isalnum() or text[i] == "_"):
while i < n and (text[i].isalnum() or text[i] == "_"):
i += 1
else:
while i < n and not text[i].isspace() and not (text[i].isalnum() or text[i] == "_"):
i += 1
# 越过词间空白,落到下一词首
while i < n and text[i].isspace():
i += 1
return i
def _word_back_off(text: str, off: int) -> int:
"""跳到上一个 word 的词首(vim 的 b)。"""
i = off
# 越过光标前空白
while i > 0 and text[i - 1].isspace():
i -= 1
# 越过当前 word
if i > 0 and (text[i - 1].isalnum() or text[i - 1] == "_"):
while i > 0 and (text[i - 1].isalnum() or text[i - 1] == "_"):
i -= 1
else:
while i > 0 and not text[i - 1].isspace() and not (text[i - 1].isalnum() or text[i - 1] == "_"):
i -= 1
# 越过词间空白,落到上一词首
while i > 0 and text[i - 1].isspace():
i -= 1
return i
def _offset_to_rc(text: str, off: int):
off = max(0, min(off, len(text)))
row = text.count("\n", 0, off)
line_start = text.rfind("\n", 0, off)
col = off - (line_start + 1)
return row, col
def _rc_to_offset(text: str, row: int, col: int) -> int:
lines = text.split("\n")
row = max(0, min(row, len(lines) - 1))
line = lines[row]
col = max(0, min(col, len(line)))
off = 0
for l in lines[:row]:
off += len(l) + 1
return off + col
# ── 状态机 ────────────────────────────────────────────────────────────────
class VimInputState:
"""
Vim 输入状态机。
mode: "INSERT" 或 "NORMAL"
pending: 多键序列待定前缀("d" / "g"),单键动作时为 None
用法:
st = VimInputState(mode="INSERT") # 输入框默认进入 INSERT
res = st.feed(text, offset, token)
# 把 res 应用回输入框
"""
def __init__(self, mode: str = "INSERT"):
self.mode = mode
self.pending: Optional[str] = None
# 进入 INSERT 时返回的结果构造器
@staticmethod
def _insert(text, offset, msg) -> EditResult:
return EditResult(text, offset, "INSERT", None, message=msg)
def feed(self, text: str, offset: int, token: str) -> EditResult:
"""
token 是已经映射好的 vim 键名,例如:
"h","l","j","k","w","b","e","0","$","^","g","G","d","x","u",
"i","a","o","O","I","A","escape","left","right","up","down"
"""
if self.mode == "INSERT":
if token == "escape":
# 回到 NORMAL,光标左移一格(vim 习惯:Esc 后光标退到本字上)
new_off = offset
if offset > 0 and text[offset - 1] != "\n":
new_off = offset - 1
self.mode = "NORMAL"
self.pending = None
return EditResult(text, new_off, "NORMAL", None, message="-- NORMAL --")
# 其余键交给 TextArea 默认处理(打字)
return EditResult(text, offset, "INSERT", None, handled=False)
# ── NORMAL 模式 ──
# 多键序列待定前缀
if self.pending == "d":
self.pending = None
if token == "d":
return self._delete_line(text, offset, "dd")
if token == "w":
return self._delete_word(text, offset, "dw")
if token == "$":
return self._delete_to_eol(text, offset, "d$")
# 非法组合:清掉 pending 后把当前键当单键重新处理
# (落到下面的单键逻辑)
if self.pending == "g":
self.pending = None
if token == "g":
return EditResult(text, 0, "NORMAL", None, message="gg")
# 其余:当作普通单键处理
# ── 单键 ──
if token in ("i",):
self.mode = "INSERT"
return self._insert(text, offset, "-- INSERT --")
if token == "I":
self.mode = "INSERT"
return self._insert(text, _first_nonblank_off(text, offset), "-- INSERT (行首) --")
if token == "a":
self.mode = "INSERT"
return self._insert(text, min(offset + 1, len(text)), "-- INSERT (追加) --")
if token == "A":
self.mode = "INSERT"
return self._insert(text, _line_end_off(text, offset), "-- INSERT (行尾) --")
if token == "o":
self.mode = "INSERT"
e = _line_end_off(text, offset)
new_text = text[:e] + "\n" + text[e:]
return self._insert(new_text, e + 1, "-- INSERT (新行) --")
if token == "O":
self.mode = "INSERT"
s = _line_start_off(text, offset)
new_text = text[:s] + "\n" + text[s:]
return self._insert(new_text, s, "-- INSERT (上方新行) --")
if token in ("h", "left"):
new_off = max(_line_start_off(text, offset), offset - 1)
return EditResult(text, new_off, "NORMAL", None)
if token in ("l", "right"):
le = _line_end_off(text, offset)
new_off = min(le, offset + 1) if offset < len(text) else offset
return EditResult(text, new_off, "NORMAL", None)
if token in ("j", "down"):
r, c = _offset_to_rc(text, offset)
return EditResult(text, _rc_to_offset(text, r + 1, c), "NORMAL", None)
if token in ("k", "up"):
r, c = _offset_to_rc(text, offset)
return EditResult(text, _rc_to_offset(text, r - 1, c), "NORMAL", None)
if token == "w":
return EditResult(text, _word_forward_off(text, offset), "NORMAL", None)
if token == "b":
return EditResult(text, _word_back_off(text, offset), "NORMAL", None)
if token == "e":
i = _word_forward_off(text, offset)
new_off = max(offset, i - 1) if i > offset else offset
return EditResult(text, new_off, "NORMAL", None)
if token == "0":
return EditResult(text, _line_start_off(text, offset), "NORMAL", None)
if token == "^":
return EditResult(text, _first_nonblank_off(text, offset), "NORMAL", None)
if token == "$":
return EditResult(text, _line_end_off(text, offset), "NORMAL", None)
if token == "G":
r, c = _offset_to_rc(text, offset)
last_row = text.count("\n")
return EditResult(text, _rc_to_offset(text, last_row, c), "NORMAL", None)
if token == "g":
self.pending = "g"
return EditResult(text, offset, "NORMAL", "g", message="g")
if token == "d":
self.pending = "d"
return EditResult(text, offset, "NORMAL", "d", message="d")
if token == "x":
if offset < len(text):
new_text = text[:offset] + text[offset + 1:]
return EditResult(new_text, offset, "NORMAL", None, message="x")
return EditResult(text, offset, "NORMAL", None)
if token == "u":
return EditResult(text, offset, "NORMAL", None, action="undo", message="u")
if token == "escape":
return EditResult(text, offset, "NORMAL", None, message="-- NORMAL --")
# 未知键:忽略(保持 NORMAL)
return EditResult(text, offset, "NORMAL", None)
# ── operator 实现 ──
def _delete_line(self, text, offset, msg) -> EditResult:
s = _line_start_off(text, offset)
e = _line_end_off(text, offset)
if e < len(text):
e2 = e + 1 # 连同行尾换行一起删
else:
e2 = e
if s > 0:
s = s - 1 # 最后一行:删掉上一行的换行
new_text = text[:s] + text[e2:]
new_off = min(s, len(new_text))
return EditResult(new_text, new_off, "NORMAL", None, message=msg)
def _delete_word(self, text, offset, msg) -> EditResult:
s = offset
e = _word_forward_off(text, offset)
new_text = text[:s] + text[e:]
new_off = min(s, len(new_text))
return EditResult(new_text, new_off, "NORMAL", None, message=msg)
def _delete_to_eol(self, text, offset, msg) -> EditResult:
s = offset
e = _line_end_off(text, offset)
new_text = text[:s] + text[e:]
new_off = min(s, len(new_text))
return EditResult(new_text, new_off, "NORMAL", None, message=msg)