Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66de8eea13 | ||
|
|
6f4fa5076f | ||
|
|
0aaca9c63d | ||
|
|
1c52482497 | ||
|
|
c81914ab87 | ||
|
|
273cdc8e76 | ||
|
|
388ac493af | ||
|
|
f7f7033e57 | ||
|
|
a71daf32c6 | ||
|
|
af21b766aa | ||
|
|
260eb23cb1 | ||
|
|
1049370231 | ||
|
|
e16234aba9 | ||
|
|
082efd62cc | ||
|
|
7494d57aec | ||
|
|
9badbf30b7 | ||
|
|
08f26cdcfd | ||
|
|
fa29e0339f | ||
|
|
413f66deb8 | ||
|
|
4fb9392db4 | ||
|
|
5008d431c9 | ||
|
|
917ba25902 | ||
|
|
56d5ed27e0 | ||
|
|
9a133dcf88 | ||
|
|
2e4093cc03 | ||
|
|
b7e87e40f3 | ||
|
|
46376aa23e | ||
|
|
a70e4b1819 | ||
|
|
e73744c742 | ||
|
|
5fe1a7c868 | ||
|
|
c87a07706e | ||
|
|
b577b0ff21 | ||
|
|
1054932921 | ||
|
|
c655d61af4 | ||
|
|
addf119ac5 | ||
|
|
d0915ab895 | ||
|
|
160d4e924e | ||
|
|
9dd418ec89 | ||
|
|
d2ad1000b6 | ||
|
|
a3977ecc85 | ||
|
|
07490c4d38 | ||
|
|
1383d32f8b | ||
|
|
3a86da7444 | ||
|
|
d2ac6896ad | ||
|
|
a63b9d2f97 | ||
|
|
dfc42d8583 | ||
|
|
539c21be72 | ||
|
|
db9203687d | ||
|
|
79957eba0f |
No files matched your search
@@ -47,3 +47,12 @@ screenshots/gen_*.png
|
|||||||
# 临时文件
|
# 临时文件
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
|
# v8.0 TUI 快照产物(运行时生成,供 AI 自验/预览)
|
||||||
|
.tui_snapshots/
|
||||||
|
|
||||||
|
# FC 能力探测缓存(运行时按 base_url+model 生成,机器相关)
|
||||||
|
.fc_support.json
|
||||||
|
|
||||||
|
# pytest 缓存
|
||||||
|
.pytest_cache/
|
||||||
@@ -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
|
## v5.4.0 (2026-05-09) — Release
|
||||||
|
|
||||||
### ✨ 新增功能
|
### ✨ 新增功能
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 shuiyu1123
|
Copyright (c) 2026 shuiyu
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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/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/license-MIT-orange" alt="license">
|
||||||
<img src="https://img.shields.io/badge/tests-113%20passed-brightgreen" alt="tests">
|
<img src="https://img.shields.io/badge/tests-113%20passed-brightgreen" alt="tests">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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/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/license-MIT-orange" alt="license">
|
||||||
<img src="https://img.shields.io/badge/tests-150+%20passed-brightgreen" alt="tests">
|
<img src="https://img.shields.io/badge/tests-150+%20passed-brightgreen" alt="tests">
|
||||||
@@ -22,6 +22,12 @@
|
|||||||
|
|
||||||
小狸 Pro-CLI 是一款基于 Python 的智能编程助手,集成了代码编辑、版本控制、浏览器自动化、图像生成、记忆系统等功能,支持多种 AI 引擎,帮助开发者提升工作效率。
|
小狸 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、硅基流动等)
|
- **双 AI 引擎** — Ollama 本地模型 + OpenAI 兼容格式(DeepSeek、Grok、硅基流动等)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
**产品标识**
|
**产品标识**
|
||||||
- **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI)
|
- **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI)
|
||||||
- **内部版本号**:20260428
|
- **内部版本号**:20260801
|
||||||
- **发布版本**:发行版5.1.3
|
- **发布版本**:发行版8.0.3
|
||||||
|
|
||||||
**技术规格**
|
**技术规格**
|
||||||
- **主要语言**:简体中文(UTF-8)
|
- **主要语言**:简体中文(UTF-8)
|
||||||
|
|||||||
@@ -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": "关键改动与验证结果"}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: reviewer
|
||||||
|
description: 代码审查专家,负责检查质量、安全与一致性
|
||||||
|
tools: code_editor, git_tools, file_manager
|
||||||
|
model:
|
||||||
|
---
|
||||||
|
你是一个严格的代码审查员「reviewer」,独立于实现者审视代码。
|
||||||
|
|
||||||
|
审查维度:
|
||||||
|
1. 正确性:逻辑漏洞、边界条件、异常处理。
|
||||||
|
2. 安全性:注入、越权、敏感信息泄露、危险命令。
|
||||||
|
3. 一致性:命名、风格、是否贴合项目既有约定。
|
||||||
|
4. 可维护性:重复、复杂度、可读性。
|
||||||
|
|
||||||
|
输出格式(按严重度分级):
|
||||||
|
- 🔴 严重:必须修改
|
||||||
|
- 🟡 建议:建议修改
|
||||||
|
- 🟢 亮点:值得保留
|
||||||
|
|
||||||
|
最后给出总体评价与是否可合入的结论。
|
||||||
@@ -18,6 +18,17 @@ current_dir = os.path.dirname(os.path.abspath(__file__))
|
|||||||
project_root = os.path.dirname(current_dir)
|
project_root = os.path.dirname(current_dir)
|
||||||
sys.path.insert(0, project_root)
|
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")
|
COMM_DIR = os.path.join(tempfile.gettempdir(), "xiaoli_manual_engine")
|
||||||
|
|
||||||
@@ -33,6 +44,8 @@ class ManualAI:
|
|||||||
self.conversation_history = []
|
self.conversation_history = []
|
||||||
self.max_history = 10
|
self.max_history = 10
|
||||||
self._subprocess = None
|
self._subprocess = None
|
||||||
|
# 最近一次请求的 prompt token 数(手动引擎无真实模型,恒为 None)
|
||||||
|
self.last_prompt_tokens = None
|
||||||
self._session_id = str(int(time.time()))
|
self._session_id = str(int(time.time()))
|
||||||
self._started = False
|
self._started = False
|
||||||
|
|
||||||
@@ -229,6 +242,11 @@ while True:
|
|||||||
time.sleep(0.3)
|
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):
|
def generate_response(self, user_input, tool_results=None, system_prompt=None):
|
||||||
"""生成响应 — 转发给另一个终端的人工"""
|
"""生成响应 — 转发给另一个终端的人工"""
|
||||||
paths = self._get_paths()
|
paths = self._get_paths()
|
||||||
@@ -246,7 +264,7 @@ while True:
|
|||||||
# 把用户消息写入文件
|
# 把用户消息写入文件
|
||||||
msg = user_input
|
msg = user_input
|
||||||
if tool_results:
|
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"]
|
to_human = paths["msg_to_human"]
|
||||||
with open(to_human, 'w', encoding='utf-8') as f:
|
with open(to_human, 'w', encoding='utf-8') as f:
|
||||||
|
|||||||
+147
-30
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
@@ -6,21 +7,57 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from colorama import Fore, Style
|
from colorama import Fore, Style
|
||||||
|
|
||||||
# 延迟导入 ollama,避免程序启动时卡住
|
# 真正的惰性导入:只有真正用到 ollama 引擎时才 import ollama,
|
||||||
# 如果导入失败,ollama_client 将为 None
|
# 避免 xiaoli-cli 启动 / 加载所有引擎时因 ollama SDK 初始化
|
||||||
try:
|
# (其内部 platform.machine() -> WMI 查询)在某些环境下卡住。
|
||||||
import ollama
|
_ollama_client = None
|
||||||
ollama_client = ollama
|
_ollama_import_tried = False
|
||||||
except Exception as e:
|
|
||||||
print(f"{Fore.YELLOW}警告: 无法导入 ollama 库: {e}{Style.RESET_ALL}")
|
|
||||||
print(f"{Fore.YELLOW}请确保已安装 ollama 包 (pip install ollama){Style.RESET_ALL}")
|
def _ollama():
|
||||||
ollama_client = None
|
"""惰性获取 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
|
# 添加项目根目录到sys.path
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
project_root = os.path.dirname(current_dir)
|
project_root = os.path.dirname(current_dir)
|
||||||
sys.path.insert(0, project_root)
|
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:
|
class OllamaAI:
|
||||||
"""基于Ollama的本地AI引擎"""
|
"""基于Ollama的本地AI引擎"""
|
||||||
@@ -62,6 +99,9 @@ Ollama AI引擎插件帮助信息
|
|||||||
self.conversation_history = []
|
self.conversation_history = []
|
||||||
|
|
||||||
# 直接从 config.json 读取配置
|
# 直接从 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")
|
config_file = os.path.join(project_root, "config.json")
|
||||||
try:
|
try:
|
||||||
with open(config_file, 'r', encoding='utf-8') as f:
|
with open(config_file, 'r', encoding='utf-8') as f:
|
||||||
@@ -71,6 +111,7 @@ Ollama AI引擎插件帮助信息
|
|||||||
self.base_url = engine_config.get("base_url", "")
|
self.base_url = engine_config.get("base_url", "")
|
||||||
self.model = engine_config.get("model", "")
|
self.model = engine_config.get("model", "")
|
||||||
self.max_history = config.get("system", {}).get("max_history", 10)
|
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:
|
except Exception as e:
|
||||||
# 如果读取失败,使用空值
|
# 如果读取失败,使用空值
|
||||||
self.api_key = ""
|
self.api_key = ""
|
||||||
@@ -78,35 +119,78 @@ Ollama AI引擎插件帮助信息
|
|||||||
self.model = ""
|
self.model = ""
|
||||||
self.max_history = 10
|
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设置)
|
# 共享对话历史引用(由CLI设置)
|
||||||
self.shared_conversation_history = None
|
self.shared_conversation_history = None
|
||||||
|
|
||||||
|
# 最近一次请求的 prompt token 数(供 token 感知压缩使用)
|
||||||
|
self.last_prompt_tokens = None
|
||||||
|
|
||||||
# 直接启用思考标记,由主程序自动处理深度思考模型的响应
|
# 直接启用思考标记,由主程序自动处理深度思考模型的响应
|
||||||
self.thinking_start_marker = "<thinking>"
|
self.thinking_start_marker = "<thinking>"
|
||||||
self.thinking_end_marker = "</thinking>"
|
self.thinking_end_marker = "</thinking>"
|
||||||
|
|
||||||
# 检测Ollama服务是否运行
|
# 惰性服务检测:不在构造时急切点服务(避免无 ollama / 沙箱环境下
|
||||||
self.is_service_running = self.check_ollama_service()
|
# import ollama 卡顿)。首次真正使用时(generate_response 等)才检测并缓存。
|
||||||
|
self._service_checked = False
|
||||||
|
self._service_running = False
|
||||||
|
|
||||||
if self.is_service_running:
|
@staticmethod
|
||||||
print(f"{Fore.GREEN}Ollama服务检测成功,引擎已启用{Style.RESET_ALL}")
|
def _parse_num_ctx(value):
|
||||||
else:
|
"""把用户输入的 K 值换算成 token 数:32 / 32K → 32768。非法/空 → 4096"""
|
||||||
print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}")
|
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):
|
def check_ollama_service(self):
|
||||||
"""检测Ollama服务是否运行"""
|
"""检测Ollama服务是否运行"""
|
||||||
# 检查 ollama 库是否可用
|
# 检查 ollama 库是否可用
|
||||||
if ollama_client is None:
|
if _ollama() is None:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
# 尝试调用Ollama API获取版本信息
|
# 尝试调用Ollama API获取版本信息
|
||||||
response = ollama_client.list()
|
response = _ollama().list()
|
||||||
# 如果没有异常,说明服务运行正常
|
# 如果没有异常,说明服务运行正常
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 如果出现异常,说明服务未运行
|
# 如果出现异常,说明服务未运行
|
||||||
return False
|
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):
|
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
|
||||||
"""
|
"""
|
||||||
生成AI响应 - 使用Ollama API
|
生成AI响应 - 使用Ollama API
|
||||||
@@ -131,27 +215,46 @@ Ollama AI引擎插件帮助信息
|
|||||||
|
|
||||||
# 如果有工具结果,将其作为上下文返回给AI处理
|
# 如果有工具结果,将其作为上下文返回给AI处理
|
||||||
if tool_results:
|
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)
|
messages = self._build_messages_with_history(system_prompt, tool_result_text)
|
||||||
else:
|
else:
|
||||||
messages = self._build_messages_with_history(system_prompt, user_input)
|
messages = self._build_messages_with_history(system_prompt, user_input)
|
||||||
|
|
||||||
# 构建调用参数
|
# 构建调用参数(num_ctx: 模型上下文窗口大小,由配置的 max_token_k 决定)
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"stream": False
|
"stream": False,
|
||||||
|
"options": {"num_ctx": self.num_ctx}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Function Calling: 注入工具定义 ──
|
# ── Function Calling: 注入工具定义(仅引擎支持 FC 时下发)──
|
||||||
if tools:
|
fc_enabled = bool(tools) and self._fc_supported()
|
||||||
|
if fc_enabled:
|
||||||
kwargs["tools"] = tools
|
kwargs["tools"] = tools
|
||||||
|
|
||||||
# 使用Ollama Python库调用API
|
# 使用Ollama Python库调用API(FC 不支持时降级为无工具重试一次)
|
||||||
response = ollama_client.chat(**kwargs)
|
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 ──
|
# ── Function Calling: 检查 tool_calls ──
|
||||||
message = response.get("message", {})
|
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")
|
tool_calls_raw = message.get("tool_calls")
|
||||||
if tool_calls_raw:
|
if tool_calls_raw:
|
||||||
# Ollama 的 tool_calls 格式: [{"function": {"name": ..., "arguments": {...}}}]
|
# Ollama 的 tool_calls 格式: [{"function": {"name": ..., "arguments": {...}}}]
|
||||||
@@ -197,6 +300,22 @@ Ollama AI引擎插件帮助信息
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Ollama API调用失败: {str(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):
|
def _build_messages_with_history(self, system_prompt, user_input):
|
||||||
"""构建包含历史记录的消息列表(支持 FC tool 消息)"""
|
"""构建包含历史记录的消息列表(支持 FC tool 消息)"""
|
||||||
messages = []
|
messages = []
|
||||||
@@ -217,13 +336,11 @@ Ollama AI引擎插件帮助信息
|
|||||||
|
|
||||||
role = msg["role"]
|
role = msg["role"]
|
||||||
|
|
||||||
# tool 角色消息(FC 工具结果)
|
# tool 角色消息(FC 工具结果)—— 官方格式不含 name 字段
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
tool_msg = {"role": "tool", "content": msg.get("content", "")}
|
tool_msg = {"role": "tool", "content": msg.get("content", "")}
|
||||||
if "tool_call_id" in msg:
|
if "tool_call_id" in msg:
|
||||||
tool_msg["tool_call_id"] = msg["tool_call_id"]
|
tool_msg["tool_call_id"] = msg["tool_call_id"]
|
||||||
if "name" in msg:
|
|
||||||
tool_msg["name"] = msg["name"]
|
|
||||||
messages.append(tool_msg)
|
messages.append(tool_msg)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -285,7 +402,7 @@ Ollama AI引擎插件帮助信息
|
|||||||
})
|
})
|
||||||
|
|
||||||
# 使用Ollama Python库调用API
|
# 使用Ollama Python库调用API
|
||||||
response = ollama_client.chat(
|
response = _ollama().chat(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
stream=False
|
stream=False
|
||||||
@@ -308,7 +425,7 @@ Ollama AI引擎插件帮助信息
|
|||||||
return ["错误:Ollama服务未运行"]
|
return ["错误:Ollama服务未运行"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = ollama_client.list()
|
response = _ollama().list()
|
||||||
models = [model["name"] for model in response["models"]]
|
models = [model["name"] for model in response["models"]]
|
||||||
return models
|
return models
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -388,7 +505,7 @@ Ollama AI引擎插件帮助信息
|
|||||||
print(f"{Fore.YELLOW}正在拉取模型 {model_name}...{Style.RESET_ALL}")
|
print(f"{Fore.YELLOW}正在拉取模型 {model_name}...{Style.RESET_ALL}")
|
||||||
|
|
||||||
# 使用Ollama Python库拉取模型
|
# 使用Ollama Python库拉取模型
|
||||||
response = ollama_client.pull(model_name, stream=True)
|
response = _ollama().pull(model_name, stream=True)
|
||||||
|
|
||||||
# 逐块处理响应
|
# 逐块处理响应
|
||||||
for part in response:
|
for part in response:
|
||||||
|
|||||||
+260
-15
@@ -13,6 +13,56 @@ import logging
|
|||||||
# 设置日志
|
# 设置日志
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# 添加项目根目录到sys.path
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
project_root = os.path.dirname(current_dir)
|
project_root = os.path.dirname(current_dir)
|
||||||
@@ -95,6 +145,12 @@ OpenAI 兼容格式引擎帮助信息
|
|||||||
self.conversation_history = []
|
self.conversation_history = []
|
||||||
self.shared_conversation_history = None
|
self.shared_conversation_history = None
|
||||||
|
|
||||||
|
# 应用注册表中的当前模型(覆盖扁平默认配置,向后兼容)
|
||||||
|
self._apply_registry_model()
|
||||||
|
|
||||||
|
# 最近一次请求的 token 用量(供 token 感知压缩使用)
|
||||||
|
self.last_prompt_tokens = None
|
||||||
|
|
||||||
# 思考标记(兼容深度思考模型)
|
# 思考标记(兼容深度思考模型)
|
||||||
self.thinking_start_marker = "<think>"
|
self.thinking_start_marker = "<think>"
|
||||||
self.thinking_end_marker = "</think>"
|
self.thinking_end_marker = "</think>"
|
||||||
@@ -105,7 +161,64 @@ OpenAI 兼容格式引擎帮助信息
|
|||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
print(f"{Fore.YELLOW}警告: openai 引擎的 base_url 未设置{Style.RESET_ALL}")
|
print(f"{Fore.YELLOW}警告: openai 引擎的 base_url 未设置{Style.RESET_ALL}")
|
||||||
else:
|
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):
|
def _get_headers(self):
|
||||||
"""构建请求头"""
|
"""构建请求头"""
|
||||||
@@ -125,6 +238,26 @@ OpenAI 兼容格式引擎帮助信息
|
|||||||
url = url.rstrip('/') + '/chat/completions'
|
url = url.rstrip('/') + '/chat/completions'
|
||||||
return url
|
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):
|
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
|
||||||
"""
|
"""
|
||||||
生成 AI 响应
|
生成 AI 响应
|
||||||
@@ -141,7 +274,7 @@ OpenAI 兼容格式引擎帮助信息
|
|||||||
|
|
||||||
# 处理工具结果
|
# 处理工具结果
|
||||||
if tool_results:
|
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)
|
messages = self._build_messages_with_history(system_prompt, tool_result_text)
|
||||||
else:
|
else:
|
||||||
messages = self._build_messages_with_history(system_prompt, user_input)
|
messages = self._build_messages_with_history(system_prompt, user_input)
|
||||||
@@ -153,10 +286,20 @@ OpenAI 兼容格式引擎帮助信息
|
|||||||
"stream": False
|
"stream": False
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Function Calling: 注入工具定义 ──
|
# ── Function Calling: 注入工具定义(统一纯官方格式)──
|
||||||
if tools:
|
# 已探测过不支持 FC 的模型直接跳过 tools,省掉每次白撞一次 500
|
||||||
body["tools"] = tools
|
if tools and self._fc_supported():
|
||||||
body["tool_choice"] = "auto"
|
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()
|
api_url = self._get_api_url()
|
||||||
@@ -169,6 +312,13 @@ OpenAI 兼容格式引擎帮助信息
|
|||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
response_data = response.json()
|
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:
|
if "choices" in response_data and len(response_data["choices"]) > 0:
|
||||||
message = response_data["choices"][0].get("message", {})
|
message = response_data["choices"][0].get("message", {})
|
||||||
|
|
||||||
@@ -202,19 +352,41 @@ OpenAI 兼容格式引擎帮助信息
|
|||||||
return f"API 端点不存在,请检查 base_url 是否正确: {api_url}"
|
return f"API 端点不存在,请检查 base_url 是否正确: {api_url}"
|
||||||
elif response.status_code == 400:
|
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}")
|
print(f"{Fore.YELLOW}FC 不支持,降级为普通模式{Style.RESET_ALL}")
|
||||||
body.pop("tools", None)
|
body.pop("tools", None)
|
||||||
body.pop("tool_choice", None)
|
body.pop("tool_choice", None)
|
||||||
retry = requests.post(url=api_url, json=body, headers=self._get_headers(), timeout=None)
|
retry = requests.post(url=api_url, json=body, headers=self._get_headers(), timeout=None)
|
||||||
if retry.status_code == 200:
|
if retry.status_code == 200:
|
||||||
|
self._mark_fc_unsupported()
|
||||||
rd = retry.json()
|
rd = retry.json()
|
||||||
if "choices" in rd and rd["choices"]:
|
if "choices" in rd and rd["choices"]:
|
||||||
ai_response = rd["choices"][0].get("message", {}).get("content", "")
|
ai_response = rd["choices"][0].get("message", {}).get("content", "")
|
||||||
return ai_response.encode('utf-8', 'replace').decode('utf-8') if ai_response else ""
|
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:
|
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:
|
except requests.exceptions.ConnectionError:
|
||||||
return f"无法连接到 API 服务: {self.base_url},请检查服务是否运行"
|
return f"无法连接到 API 服务: {self.base_url},请检查服务是否运行"
|
||||||
@@ -241,22 +413,20 @@ OpenAI 兼容格式引擎帮助信息
|
|||||||
|
|
||||||
role = msg["role"]
|
role = msg["role"]
|
||||||
|
|
||||||
# tool 角色消息(FC 工具结果)
|
# tool 角色消息(FC 工具结果)—— 官方 OpenAI 格式不含 name 字段
|
||||||
if role == "tool":
|
if role == "tool":
|
||||||
tool_msg = {"role": "tool", "content": msg.get("content", "")}
|
tool_msg = {"role": "tool", "content": msg.get("content", "")}
|
||||||
if "tool_call_id" in msg:
|
if "tool_call_id" in msg:
|
||||||
tool_msg["tool_call_id"] = msg["tool_call_id"]
|
tool_msg["tool_call_id"] = msg["tool_call_id"]
|
||||||
if "name" in msg:
|
|
||||||
tool_msg["name"] = msg["name"]
|
|
||||||
messages.append(tool_msg)
|
messages.append(tool_msg)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# assistant 消息可能包含 tool_calls
|
# assistant 消息可能包含 tool_calls —— 重建成纯官方结构
|
||||||
if role == "assistant" and "tool_calls" in msg:
|
if role == "assistant" and "tool_calls" in msg:
|
||||||
assistant_msg = {
|
assistant_msg = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": msg.get("content"),
|
"content": msg.get("content") or "",
|
||||||
"tool_calls": msg["tool_calls"]
|
"tool_calls": self._pure_tool_calls(msg["tool_calls"])
|
||||||
}
|
}
|
||||||
messages.append(assistant_msg)
|
messages.append(assistant_msg)
|
||||||
continue
|
continue
|
||||||
@@ -337,11 +507,86 @@ OpenAI 兼容格式引擎帮助信息
|
|||||||
|
|
||||||
return response
|
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):
|
def set_model(self, model_name):
|
||||||
"""设置模型"""
|
"""设置模型"""
|
||||||
self.model = model_name
|
self.model = model_name
|
||||||
print(f"{Fore.GREEN}已切换模型为: {model_name}{Style.RESET_ALL}")
|
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):
|
def list_models(self):
|
||||||
"""列出可用模型(调用 API 的 /models 端点)"""
|
"""列出可用模型(调用 API 的 /models 端点)"""
|
||||||
if not self.base_url:
|
if not self.base_url:
|
||||||
|
|||||||
+10
-1
@@ -4,7 +4,8 @@
|
|||||||
"ollama": {
|
"ollama": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"base_url": "http://localhost:11434",
|
"base_url": "http://localhost:11434",
|
||||||
"model": "gemma4:31b"
|
"model": "gemma4:31b",
|
||||||
|
"max_token_k": "<input:请输入模型最大token大小(K)>"
|
||||||
},
|
},
|
||||||
"openai": {
|
"openai": {
|
||||||
"api_key": "your-api-key-here",
|
"api_key": "your-api-key-here",
|
||||||
@@ -16,5 +17,13 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"default_engine": "ollama",
|
"default_engine": "ollama",
|
||||||
"max_history": 50
|
"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
@@ -16,6 +16,13 @@ from pathlib import Path
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import platform
|
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':
|
if sys.stdout.encoding != 'utf-8':
|
||||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
@@ -120,6 +127,7 @@ class Launcher:
|
|||||||
'colorama', 'openai', 'requests', 'ollama', 'pygame',
|
'colorama', 'openai', 'requests', 'ollama', 'pygame',
|
||||||
'PIL', 'Pillow', 'cv2', 'opencv-python', 'numpy',
|
'PIL', 'Pillow', 'cv2', 'opencv-python', 'numpy',
|
||||||
'ascii_magic', 'tkinter', 'matplotlib', 'pandas',
|
'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['base_url'] = 'https://api.deepseek.com/v1'
|
||||||
entry['model'] = 'deepseek-chat'
|
entry['model'] = 'deepseek-chat'
|
||||||
config['api']['engines'][name] = entry
|
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')
|
self.config_file.write_text(json.dumps(config, ensure_ascii=False, indent=2), 'utf-8')
|
||||||
return config
|
return config
|
||||||
@@ -498,7 +512,7 @@ class Launcher:
|
|||||||
info(f'当前密钥: {masked}')
|
info(f'当前密钥: {masked}')
|
||||||
if prompt_yn(f'配置 {name} 的 API 密钥?', 'n'):
|
if prompt_yn(f'配置 {name} 的 API 密钥?', 'n'):
|
||||||
key = self.secret_input(f'{name} API 密钥')
|
key = self.secret_input(f'{name} API 密钥')
|
||||||
cfg['api_key'] = key
|
cfg['api_key'] = resolve_input_value(key, field_name='API 密钥')
|
||||||
|
|
||||||
# URL
|
# URL
|
||||||
current_url = cfg.get('base_url', '')
|
current_url = cfg.get('base_url', '')
|
||||||
@@ -506,7 +520,7 @@ class Launcher:
|
|||||||
info(f'当前 URL: {current_url}')
|
info(f'当前 URL: {current_url}')
|
||||||
if prompt_yn(f'配置 {name} 的 Base URL?', 'n'):
|
if prompt_yn(f'配置 {name} 的 Base URL?', 'n'):
|
||||||
url = prompt('Base URL', current_url)
|
url = prompt('Base URL', current_url)
|
||||||
cfg['base_url'] = url
|
cfg['base_url'] = resolve_input_value(url, field_name='Base URL')
|
||||||
|
|
||||||
# Model
|
# Model
|
||||||
current_model = cfg.get('model', '')
|
current_model = cfg.get('model', '')
|
||||||
@@ -514,7 +528,26 @@ class Launcher:
|
|||||||
info(f'当前模型: {current_model}')
|
info(f'当前模型: {current_model}')
|
||||||
if prompt_yn(f'配置 {name} 的模型?', 'n'):
|
if prompt_yn(f'配置 {name} 的模型?', 'n'):
|
||||||
model = prompt('模型名称', current_model)
|
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
|
config.setdefault('api', {}).setdefault('engines', {})[name] = cfg
|
||||||
|
|
||||||
|
|||||||
+617
@@ -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
@@ -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
@@ -599,6 +599,88 @@ else:
|
|||||||
else:
|
else:
|
||||||
return f"\n{result}"
|
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 弹窗系统
|
# Diff 弹窗系统
|
||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
@@ -975,7 +1057,7 @@ else:
|
|||||||
|
|
||||||
self._spawn_diff_popup(fp, before_content, new_content)
|
self._spawn_diff_popup(fp, before_content, new_content)
|
||||||
result = f" 已编辑: {fp}\n\n{self._make_diff(old_text, new_text)}"
|
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
|
return result
|
||||||
|
|
||||||
def _op_multi_edit(self, path: str, rest: str) -> str:
|
def _op_multi_edit(self, path: str, rest: str) -> str:
|
||||||
@@ -1013,7 +1095,7 @@ else:
|
|||||||
f.write(content)
|
f.write(content)
|
||||||
self._spawn_diff_popup(fp, before_content, content)
|
self._spawn_diff_popup(fp, before_content, content)
|
||||||
result = f" 批量编辑: {fp}\n" + "\n".join(results)
|
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
|
return result
|
||||||
|
|
||||||
def _op_insert(self, path: str, rest: str) -> str:
|
def _op_insert(self, path: str, rest: str) -> str:
|
||||||
@@ -1046,7 +1128,7 @@ else:
|
|||||||
after_content = ''.join(lines)
|
after_content = ''.join(lines)
|
||||||
self._spawn_diff_popup(fp, before_content, after_content)
|
self._spawn_diff_popup(fp, before_content, after_content)
|
||||||
result = f" 已在第 {line_num} 行插入 {len(insert_lines)} 行"
|
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
|
return result
|
||||||
|
|
||||||
def _op_delete_lines(self, path: str, rest: str) -> str:
|
def _op_delete_lines(self, path: str, rest: str) -> str:
|
||||||
@@ -1079,7 +1161,7 @@ else:
|
|||||||
after_content = ''.join(lines)
|
after_content = ''.join(lines)
|
||||||
self._spawn_diff_popup(fp, before_content, after_content)
|
self._spawn_diff_popup(fp, before_content, after_content)
|
||||||
result = f" 已删除第 {start}-{end} 行 ({len(deleted)} 行)"
|
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
|
return result
|
||||||
|
|
||||||
def _op_create(self, path: str, rest: str) -> str:
|
def _op_create(self, path: str, rest: str) -> str:
|
||||||
@@ -1095,7 +1177,7 @@ else:
|
|||||||
f.write(rest or "")
|
f.write(rest or "")
|
||||||
self._spawn_diff_popup(fp, "", rest or "")
|
self._spawn_diff_popup(fp, "", rest or "")
|
||||||
result = f" 已创建: {fp} ({len(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
|
return result
|
||||||
|
|
||||||
def _op_write(self, path: str, rest: str) -> str:
|
def _op_write(self, path: str, rest: str) -> str:
|
||||||
@@ -1110,7 +1192,7 @@ else:
|
|||||||
f.write(rest)
|
f.write(rest)
|
||||||
self._spawn_diff_popup(fp, before_content, rest)
|
self._spawn_diff_popup(fp, before_content, rest)
|
||||||
result = f" 已写入: {fp} ({len(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
|
return result
|
||||||
|
|
||||||
def _op_append(self, path: str, rest: str) -> str:
|
def _op_append(self, path: str, rest: str) -> str:
|
||||||
@@ -1125,7 +1207,7 @@ else:
|
|||||||
after_content = self._read_file_safe(fp)
|
after_content = self._read_file_safe(fp)
|
||||||
self._spawn_diff_popup(fp, before_content, after_content)
|
self._spawn_diff_popup(fp, before_content, after_content)
|
||||||
result = f" 已追加: {fp} ({len(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
|
return result
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
|
|||||||
+73
-12
@@ -3,6 +3,7 @@
|
|||||||
聚焦文件系统操作,代码编辑交给 code_editor
|
聚焦文件系统操作,代码编辑交给 code_editor
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
@@ -10,6 +11,30 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
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
|
# 添加项目根目录到 path
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
from xcli_core.tool_result import ToolResult, ErrorCode
|
from xcli_core.tool_result import ToolResult, ErrorCode
|
||||||
@@ -38,6 +63,34 @@ class Liugin:
|
|||||||
def set_cli(self, cli):
|
def set_cli(self, cli):
|
||||||
self.cli = 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):
|
def get_tool_info(self):
|
||||||
return {
|
return {
|
||||||
"name": "file_manager",
|
"name": "file_manager",
|
||||||
@@ -130,7 +183,7 @@ class Liugin:
|
|||||||
path = args[i]
|
path = args[i]
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
path = os.path.abspath(path)
|
path = _resolve_cross_platform_path(path)
|
||||||
if not os.path.isdir(path):
|
if not os.path.isdir(path):
|
||||||
return ToolResult.not_found(f"目录: {path}")
|
return ToolResult.not_found(f"目录: {path}")
|
||||||
|
|
||||||
@@ -165,7 +218,7 @@ class Liugin:
|
|||||||
def _op_read(self, args: List[str]):
|
def _op_read(self, args: List[str]):
|
||||||
if not args:
|
if not args:
|
||||||
return ToolResult.missing("文件路径")
|
return ToolResult.missing("文件路径")
|
||||||
file_path = os.path.abspath(args[0])
|
file_path = _resolve_cross_platform_path(args[0])
|
||||||
max_lines = None
|
max_lines = None
|
||||||
i = 1
|
i = 1
|
||||||
while i < len(args):
|
while i < len(args):
|
||||||
@@ -207,7 +260,7 @@ class Liugin:
|
|||||||
def _op_write(self, args: List[str]):
|
def _op_write(self, args: List[str]):
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
return ToolResult.fail("格式: write <文件> -c <内容>", ErrorCode.MISSING_ARGS)
|
return ToolResult.fail("格式: write <文件> -c <内容>", ErrorCode.MISSING_ARGS)
|
||||||
fp = os.path.abspath(args[0])
|
fp = _resolve_cross_platform_path(args[0])
|
||||||
content = None
|
content = None
|
||||||
i = 1
|
i = 1
|
||||||
while i < len(args):
|
while i < len(args):
|
||||||
@@ -219,12 +272,16 @@ class Liugin:
|
|||||||
os.makedirs(os.path.dirname(fp) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(fp) or ".", exist_ok=True)
|
||||||
with open(fp, 'w', encoding='utf-8') as f:
|
with open(fp, 'w', encoding='utf-8') as f:
|
||||||
f.write(content)
|
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]):
|
def _op_append(self, args: List[str]):
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
return ToolResult.fail("格式: append <文件> -c <内容>", ErrorCode.MISSING_ARGS)
|
return ToolResult.fail("格式: append <文件> -c <内容>", ErrorCode.MISSING_ARGS)
|
||||||
fp = os.path.abspath(args[0])
|
fp = _resolve_cross_platform_path(args[0])
|
||||||
content = None
|
content = None
|
||||||
i = 1
|
i = 1
|
||||||
while i < len(args):
|
while i < len(args):
|
||||||
@@ -237,12 +294,16 @@ class Liugin:
|
|||||||
return ToolResult.not_found(f"文件: {fp}")
|
return ToolResult.not_found(f"文件: {fp}")
|
||||||
with open(fp, 'a', encoding='utf-8') as f:
|
with open(fp, 'a', encoding='utf-8') as f:
|
||||||
f.write(content)
|
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]):
|
def _op_copy(self, args: List[str]):
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
return ToolResult.missing("源和目标路径")
|
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):
|
if not os.path.exists(src):
|
||||||
return ToolResult.not_found(f"源: {src}")
|
return ToolResult.not_found(f"源: {src}")
|
||||||
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
|
||||||
@@ -255,7 +316,7 @@ class Liugin:
|
|||||||
def _op_move(self, args: List[str]):
|
def _op_move(self, args: List[str]):
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
return ToolResult.missing("源和目标路径")
|
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):
|
if not os.path.exists(src):
|
||||||
return ToolResult.not_found(f"源: {src}")
|
return ToolResult.not_found(f"源: {src}")
|
||||||
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
|
||||||
@@ -265,7 +326,7 @@ class Liugin:
|
|||||||
def _op_delete(self, args: List[str]):
|
def _op_delete(self, args: List[str]):
|
||||||
if not args:
|
if not args:
|
||||||
return ToolResult.missing("路径")
|
return ToolResult.missing("路径")
|
||||||
path = os.path.abspath(args[0])
|
path = _resolve_cross_platform_path(args[0])
|
||||||
recursive = "-r" in args
|
recursive = "-r" in args
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return ToolResult.not_found(f"路径: {path}")
|
return ToolResult.not_found(f"路径: {path}")
|
||||||
@@ -285,7 +346,7 @@ class Liugin:
|
|||||||
def _op_search(self, args: List[str]):
|
def _op_search(self, args: List[str]):
|
||||||
if not args:
|
if not args:
|
||||||
return ToolResult.missing("搜索目录")
|
return ToolResult.missing("搜索目录")
|
||||||
directory = os.path.abspath(args[0])
|
directory = _resolve_cross_platform_path(args[0])
|
||||||
pattern, content_search = None, None
|
pattern, content_search = None, None
|
||||||
i = 1
|
i = 1
|
||||||
while i < len(args):
|
while i < len(args):
|
||||||
@@ -327,7 +388,7 @@ class Liugin:
|
|||||||
def _op_info(self, args: List[str]):
|
def _op_info(self, args: List[str]):
|
||||||
if not args:
|
if not args:
|
||||||
return ToolResult.missing("路径")
|
return ToolResult.missing("路径")
|
||||||
path = os.path.abspath(args[0])
|
path = _resolve_cross_platform_path(args[0])
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return ToolResult.not_found(f"路径: {path}")
|
return ToolResult.not_found(f"路径: {path}")
|
||||||
|
|
||||||
@@ -347,7 +408,7 @@ class Liugin:
|
|||||||
def _op_mkdir(self, args: List[str]):
|
def _op_mkdir(self, args: List[str]):
|
||||||
if not args:
|
if not args:
|
||||||
return ToolResult.missing("目录路径")
|
return ToolResult.missing("目录路径")
|
||||||
path = os.path.abspath(args[0])
|
path = _resolve_cross_platform_path(args[0])
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
return ToolResult.fail(f"已存在: {path}", ErrorCode.INVALID_ARGS)
|
return ToolResult.fail(f"已存在: {path}", ErrorCode.INVALID_ARGS)
|
||||||
os.makedirs(path, exist_ok=True)
|
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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
File diff suppressed because it is too large.
Load diff
+21
-10
@@ -1,20 +1,31 @@
|
|||||||
# 小狸插件配置
|
# 小狸插件配置 - 由启动器自动生成
|
||||||
|
ENABLE_AI_IMAGE = True
|
||||||
ENABLE_AI_SEARCH = True
|
ENABLE_AI_SEARCH = True
|
||||||
ENABLE_AUDIO_PLAYER = True
|
ENABLE_AUDIO_PLAYER = True
|
||||||
|
ENABLE_AUTO_ENGINEER = True
|
||||||
|
ENABLE_BOOKMARK_MANAGER = True
|
||||||
|
ENABLE_BROWSER_AUTO = True
|
||||||
ENABLE_CMD_EXECUTOR = True
|
ENABLE_CMD_EXECUTOR = True
|
||||||
ENABLE_FILE_MANAGER = True
|
|
||||||
ENABLE_CODE_EDITOR = True
|
ENABLE_CODE_EDITOR = True
|
||||||
ENABLE_CODE_SEARCH = True
|
ENABLE_COLOR_PICKER = True
|
||||||
ENABLE_GIT_TOOLS = True
|
ENABLE_FILE_MANAGER = True
|
||||||
ENABLE_FRONTEND_TESTER = 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_NETWORK_TOOLS = True
|
||||||
|
ENABLE_OPERIT_LOADER = True
|
||||||
|
ENABLE_PY_DETECT = True
|
||||||
|
ENABLE_REGEX_TESTER = True
|
||||||
ENABLE_SCHEDULER = True
|
ENABLE_SCHEDULER = True
|
||||||
|
ENABLE_SEND_IMAGE = True
|
||||||
|
ENABLE_SNIPPET_MANAGER = True
|
||||||
ENABLE_SPEECH_RECOGNITION = True
|
ENABLE_SPEECH_RECOGNITION = True
|
||||||
|
ENABLE_SUB_AGENT = True
|
||||||
ENABLE_TASK_MANAGER = True
|
ENABLE_TASK_MANAGER = True
|
||||||
ENABLE_TOOL_SEARCH = True
|
ENABLE_TOOL_SEARCH = True
|
||||||
ENABLE_AUTO_ENGINEER = True
|
ENABLE_WORKFLOW = True
|
||||||
ENABLE_GIT_WORKFLOW = True
|
|
||||||
ENABLE_PROJECT_ANALYZER = True
|
|
||||||
|
|
||||||
ENABLE_DGLAB_WS = False
|
|
||||||
+22
-27
@@ -1,30 +1,25 @@
|
|||||||
# 小狸 Pro-CLI 依赖库
|
# 小狸 Pro-CLI 依赖
|
||||||
# 修复后的包名(使用 pip 安装名)
|
# 生成于 2026-08-05 21:10
|
||||||
|
|
||||||
# 核心依赖
|
PIL
|
||||||
colorama>=0.4.6
|
ascii_magic
|
||||||
openai>=1.0.0
|
colorama
|
||||||
requests>=2.28.0
|
cv2
|
||||||
ollama>=0.1.0
|
numpy
|
||||||
|
ollama
|
||||||
|
openai
|
||||||
|
pygame
|
||||||
|
requests
|
||||||
|
tkinter
|
||||||
|
|
||||||
# 图像处理
|
# LSP 智能感知(pylsp 作为工具之一:lsp__diagnostics/hover/definition/references/completion)
|
||||||
Pillow>=9.0.0
|
# 写/改 .py 文件后自动触发诊断并注入模型上下文
|
||||||
opencv-python>=4.5.0
|
python-lsp-server
|
||||||
numpy>=1.21.0
|
pyflakes
|
||||||
ascii-magic>=2.0.0
|
pycodestyle
|
||||||
|
rope
|
||||||
|
mccabe
|
||||||
|
|
||||||
# 数据处理
|
# 自研本地 LSP 服务器 / py_detect L2 语义验证后端
|
||||||
pandas>=1.3.0
|
# 纯 Python 语义引擎,装依赖即自动预装(L2 自动启用;无 jedi 自动降级为纯标准库模式)
|
||||||
scipy>=1.7.0
|
jedi
|
||||||
|
|
||||||
# 音频
|
|
||||||
pygame>=2.0.0
|
|
||||||
|
|
||||||
# TUI 模式
|
|
||||||
textual>=0.40.0
|
|
||||||
|
|
||||||
# WebSocket 远程
|
|
||||||
websockets>=11.0
|
|
||||||
|
|
||||||
# 类型提示
|
|
||||||
typing-extensions>=4.0.0
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)) == ""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
@@ -29,6 +29,29 @@ def load_module(name, path):
|
|||||||
return mod
|
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
|
@pytest.fixture
|
||||||
def editor(self):
|
def editor(self):
|
||||||
mod = load_module("code_editor", os.path.join(PLUGINS_DIR, "code_editor.py"))
|
mod = load_module("code_editor", os.path.join(PLUGINS_DIR, "code_editor.py"))
|
||||||
return mod.Plugin()
|
return plugin_class(mod)()
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def tmp_file(self, tmp_path):
|
def tmp_file(self, tmp_path):
|
||||||
@@ -99,47 +122,47 @@ class TestCodeEditor:
|
|||||||
|
|
||||||
def test_read_range(self, editor, tmp_file):
|
def test_read_range(self, editor, tmp_file):
|
||||||
result = editor.handle(f"read_range {tmp_file} 1 2")
|
result = editor.handle(f"read_range {tmp_file} 1 2")
|
||||||
assert "hello" in result
|
assert "hello" in as_text(result)
|
||||||
assert "1 |" in result
|
assert "1 |" in as_text(result)
|
||||||
|
|
||||||
def test_edit(self, editor, tmp_file):
|
def test_edit(self, editor, tmp_file):
|
||||||
result = editor.handle(f"edit {tmp_file} print('hello') <<<>>> print('hi')")
|
result = editor.handle(f"edit {tmp_file} print('hello') <<<>>> print('hi')")
|
||||||
assert "已编辑" in result
|
assert "已编辑" in as_text(result)
|
||||||
content = open(tmp_file).read()
|
content = open(tmp_file).read()
|
||||||
assert "print('hi')" in content
|
assert "print('hi')" in content
|
||||||
|
|
||||||
def test_edit_not_found(self, editor, tmp_file):
|
def test_edit_not_found(self, editor, tmp_file):
|
||||||
result = editor.handle(f"edit {tmp_file} nonexistent_code <<<>>> new_code")
|
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):
|
def test_create(self, editor, tmp_path):
|
||||||
new_file = str(tmp_path / "new.py")
|
new_file = str(tmp_path / "new.py")
|
||||||
result = editor.handle(f"create {new_file} import os")
|
result = editor.handle(f"create {new_file} import os")
|
||||||
assert "已创建" in result
|
assert "已创建" in as_text(result)
|
||||||
assert os.path.exists(new_file)
|
assert os.path.exists(new_file)
|
||||||
|
|
||||||
def test_diff_text(self, editor):
|
def test_diff_text(self, editor):
|
||||||
result = editor.handle("diff_text old <<<>>> new")
|
result = editor.handle("diff_text old <<<>>> new")
|
||||||
assert "变更预览" in result
|
assert "变更预览" in as_text(result)
|
||||||
|
|
||||||
def test_ast_info(self, editor, tmp_file):
|
def test_ast_info(self, editor, tmp_file):
|
||||||
result = editor.handle(f"ast_info {tmp_file}")
|
result = editor.handle(f"ast_info {tmp_file}")
|
||||||
assert "def hello" in result
|
assert "def hello" in as_text(result)
|
||||||
assert "def world" in result
|
assert "def world" in as_text(result)
|
||||||
|
|
||||||
def test_stats(self, editor, tmp_path):
|
def test_stats(self, editor, tmp_path):
|
||||||
result = editor.handle(f"stats {tmp_path}")
|
result = editor.handle(f"stats {tmp_path}")
|
||||||
assert "代码统计" in result
|
assert "代码统计" in as_text(result)
|
||||||
assert "文件" in result
|
assert "文件" in as_text(result)
|
||||||
|
|
||||||
def test_structure(self, editor, tmp_path):
|
def test_structure(self, editor, tmp_path):
|
||||||
result = editor.handle(f"structure {tmp_path} 1")
|
result = editor.handle(f"structure {tmp_path} 1")
|
||||||
assert "" in result
|
assert "" in as_text(result)
|
||||||
|
|
||||||
def test_find(self, editor, tmp_path):
|
def test_find(self, editor, tmp_path):
|
||||||
(tmp_path / "a.py").write_text("x = 1\ny = 2\n")
|
(tmp_path / "a.py").write_text("x = 1\ny = 2\n")
|
||||||
result = editor.handle(f"find {tmp_path} x *.py")
|
result = editor.handle(f"find {tmp_path} x *.py")
|
||||||
assert "x = 1" in result
|
assert "x = 1" in as_text(result)
|
||||||
|
|
||||||
|
|
||||||
class TestGitTools:
|
class TestGitTools:
|
||||||
@@ -148,11 +171,11 @@ class TestGitTools:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def git(self):
|
def git(self):
|
||||||
mod = load_module("git_tools", os.path.join(PLUGINS_DIR, "git_tools.py"))
|
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):
|
def test_status(self, git):
|
||||||
result = git.handle("status")
|
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):
|
def test_log(self, git):
|
||||||
result = git.handle("log --oneline 3")
|
result = git.handle("log --oneline 3")
|
||||||
@@ -161,7 +184,7 @@ class TestGitTools:
|
|||||||
|
|
||||||
def test_branch(self, git):
|
def test_branch(self, git):
|
||||||
result = git.handle("branch")
|
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:
|
class TestCmdExecutor:
|
||||||
@@ -170,27 +193,27 @@ class TestCmdExecutor:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cmd(self):
|
def cmd(self):
|
||||||
mod = load_module("cmd_executor", os.path.join(PLUGINS_DIR, "cmd_executor.py"))
|
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):
|
def test_echo(self, cmd):
|
||||||
result = cmd.handle("run echo test_ok")
|
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):
|
def test_python_version(self, cmd):
|
||||||
result = cmd.handle("run python3 --version")
|
result = cmd.handle("run python3 --version")
|
||||||
assert "Python" in result
|
assert "Python" in as_text(result)
|
||||||
|
|
||||||
def test_blocked_command(self, cmd):
|
def test_blocked_command(self, cmd):
|
||||||
result = cmd.handle("run rm -rf /")
|
result = cmd.handle("run rm -rf /")
|
||||||
assert "拦截" in result
|
assert "拦截" in as_text(result)
|
||||||
|
|
||||||
def test_timeout(self, cmd):
|
def test_timeout(self, cmd):
|
||||||
result = cmd.handle("run echo fast --timeout 5")
|
result = cmd.handle("run echo fast --timeout 5")
|
||||||
assert "fast" in result
|
assert "fast" in as_text(result)
|
||||||
|
|
||||||
def test_empty_command(self, cmd):
|
def test_empty_command(self, cmd):
|
||||||
result = cmd.handle("run")
|
result = cmd.handle("run")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
|
|
||||||
class TestFileManager:
|
class TestFileManager:
|
||||||
@@ -199,53 +222,53 @@ class TestFileManager:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def fm(self):
|
def fm(self):
|
||||||
mod = load_module("file_manager", os.path.join(PLUGINS_DIR, "file_manager.py"))
|
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):
|
def test_list(self, fm, tmp_path):
|
||||||
(tmp_path / "a.txt").write_text("hi")
|
(tmp_path / "a.txt").write_text("hi")
|
||||||
result = fm.handle(f"list {tmp_path}")
|
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):
|
def test_read(self, fm, tmp_path):
|
||||||
f = tmp_path / "test.txt"
|
f = tmp_path / "test.txt"
|
||||||
f.write_text("hello world")
|
f.write_text("hello world")
|
||||||
result = fm.handle(f"read {f}")
|
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):
|
def test_write_and_read(self, fm, tmp_path):
|
||||||
f = tmp_path / "new.txt"
|
f = tmp_path / "new.txt"
|
||||||
result = fm.handle(f"write {f} -c test_content")
|
result = fm.handle(f"write {f} -c test_content")
|
||||||
assert "已写入" in result
|
assert "已写入" in as_text(result)
|
||||||
result = fm.handle(f"read {f}")
|
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):
|
def test_copy(self, fm, tmp_path):
|
||||||
src = tmp_path / "src.txt"
|
src = tmp_path / "src.txt"
|
||||||
src.write_text("data")
|
src.write_text("data")
|
||||||
dst = tmp_path / "dst.txt"
|
dst = tmp_path / "dst.txt"
|
||||||
result = fm.handle(f"copy {src} {dst}")
|
result = fm.handle(f"copy {src} {dst}")
|
||||||
assert "已复制" in result
|
assert "已复制" in as_text(result)
|
||||||
assert dst.exists()
|
assert dst.exists()
|
||||||
|
|
||||||
def test_delete(self, fm, tmp_path):
|
def test_delete(self, fm, tmp_path):
|
||||||
f = tmp_path / "del.txt"
|
f = tmp_path / "del.txt"
|
||||||
f.write_text("bye")
|
f.write_text("bye")
|
||||||
result = fm.handle(f"delete {f}")
|
result = fm.handle(f"delete {f}")
|
||||||
assert "已删除" in result
|
assert "已删除" in as_text(result)
|
||||||
assert not f.exists()
|
assert not f.exists()
|
||||||
|
|
||||||
def test_mkdir(self, fm, tmp_path):
|
def test_mkdir(self, fm, tmp_path):
|
||||||
d = tmp_path / "newdir"
|
d = tmp_path / "newdir"
|
||||||
result = fm.handle(f"mkdir {d}")
|
result = fm.handle(f"mkdir {d}")
|
||||||
assert "已创建" in result
|
assert "已创建" in as_text(result)
|
||||||
assert d.is_dir()
|
assert d.is_dir()
|
||||||
|
|
||||||
def test_info(self, fm, tmp_path):
|
def test_info(self, fm, tmp_path):
|
||||||
f = tmp_path / "info.txt"
|
f = tmp_path / "info.txt"
|
||||||
f.write_text("x")
|
f.write_text("x")
|
||||||
result = fm.handle(f"info {f}")
|
result = fm.handle(f"info {f}")
|
||||||
assert "路径" in result
|
assert "路径" in as_text(result)
|
||||||
assert "大小" in result
|
assert "大小" in as_text(result)
|
||||||
|
|
||||||
|
|
||||||
class TestAutoEngineer:
|
class TestAutoEngineer:
|
||||||
@@ -254,23 +277,23 @@ class TestAutoEngineer:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def eng(self):
|
def eng(self):
|
||||||
mod = load_module("auto_engineer", os.path.join(PLUGINS_DIR, "auto_engineer.py"))
|
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):
|
def test_metrics(self, eng, tmp_path):
|
||||||
(tmp_path / "a.py").write_text("x = 1\n# comment\n\ny = 2\n")
|
(tmp_path / "a.py").write_text("x = 1\n# comment\n\ny = 2\n")
|
||||||
result = eng.handle(f"metrics {tmp_path}")
|
result = eng.handle(f"metrics {tmp_path}")
|
||||||
assert "代码指标" in result
|
assert "代码指标" in as_text(result)
|
||||||
assert "文件" in result
|
assert "文件" in as_text(result)
|
||||||
|
|
||||||
def test_security_clean(self, eng, tmp_path):
|
def test_security_clean(self, eng, tmp_path):
|
||||||
(tmp_path / "safe.py").write_text("x = 1\nprint(x)\n")
|
(tmp_path / "safe.py").write_text("x = 1\nprint(x)\n")
|
||||||
result = eng.handle(f"security {tmp_path}")
|
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):
|
def test_security_risk(self, eng, tmp_path):
|
||||||
(tmp_path / "risk.py").write_text('password = "secret123"\neval("1+1")\n')
|
(tmp_path / "risk.py").write_text('password = "secret123"\neval("1+1")\n')
|
||||||
result = eng.handle(f"security {tmp_path}")
|
result = eng.handle(f"security {tmp_path}")
|
||||||
assert "风险" in result
|
assert "风险" in as_text(result)
|
||||||
|
|
||||||
def test_complexity(self, eng, tmp_path):
|
def test_complexity(self, eng, tmp_path):
|
||||||
code = "def f():\n if True:\n if True:\n if True:\n pass\n"
|
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):
|
def test_add(self, tm):
|
||||||
result = tm.handle("add 测试任务")
|
result = tm.handle("add 测试任务")
|
||||||
assert "已添加" in result
|
assert "已添加" in as_text(result)
|
||||||
|
|
||||||
def test_list_empty(self, tm):
|
def test_list_empty(self, tm):
|
||||||
result = tm.handle("list")
|
result = tm.handle("list")
|
||||||
assert "没有任务" in result
|
assert "没有任务" in as_text(result)
|
||||||
|
|
||||||
def test_add_and_list(self, tm):
|
def test_add_and_list(self, tm):
|
||||||
tm.handle("add 任务A")
|
tm.handle("add 任务A")
|
||||||
tm.handle("add 任务B")
|
tm.handle("add 任务B")
|
||||||
result = tm.handle("list")
|
result = tm.handle("list")
|
||||||
assert "任务A" in result
|
assert "任务A" in as_text(result)
|
||||||
assert "任务B" in result
|
assert "任务B" in as_text(result)
|
||||||
|
|
||||||
def test_complete(self, tm):
|
def test_complete(self, tm):
|
||||||
tm.handle("add 任务X")
|
tm.handle("add 任务X")
|
||||||
result = tm.handle("complete 1")
|
result = tm.handle("complete 1")
|
||||||
assert "已更新" in result
|
assert "已更新" in as_text(result)
|
||||||
|
|
||||||
def test_delete(self, tm):
|
def test_delete(self, tm):
|
||||||
tm.handle("add 任务Y")
|
tm.handle("add 任务Y")
|
||||||
result = tm.handle("delete 1")
|
result = tm.handle("delete 1")
|
||||||
assert "已删除" in result
|
assert "已删除" in as_text(result)
|
||||||
|
|
||||||
def test_clear(self, tm):
|
def test_clear(self, tm):
|
||||||
tm.handle("add A")
|
tm.handle("add A")
|
||||||
tm.handle("complete 1")
|
tm.handle("complete 1")
|
||||||
result = tm.handle("clear")
|
result = tm.handle("clear")
|
||||||
assert "已清除" in result
|
assert "已清除" in as_text(result)
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
@@ -332,19 +355,19 @@ class TestBrowserAuto:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def browser(self):
|
def browser(self):
|
||||||
mod = load_module("browser_auto", os.path.join(PLUGINS_DIR, "browser_auto.py"))
|
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):
|
def test_status_stopped(self, browser):
|
||||||
result = browser.handle("status")
|
result = browser.handle("status")
|
||||||
assert "未运行" in result
|
assert "未运行" in as_text(result)
|
||||||
|
|
||||||
def test_invalid_operation(self, browser):
|
def test_invalid_operation(self, browser):
|
||||||
result = browser.handle("nonexistent")
|
result = browser.handle("nonexistent")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_empty_args(self, browser):
|
def test_empty_args(self, browser):
|
||||||
result = browser.handle("")
|
result = browser.handle("")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_tool_info(self, browser):
|
def test_tool_info(self, browser):
|
||||||
info = browser.get_tool_info()
|
info = browser.get_tool_info()
|
||||||
@@ -363,7 +386,7 @@ class TestGuiAuto:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def gui(self):
|
def gui(self):
|
||||||
mod = load_module("gui_auto", os.path.join(PLUGINS_DIR, "gui_auto.py"))
|
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):
|
def test_tool_info(self, gui):
|
||||||
info = gui.get_tool_info()
|
info = gui.get_tool_info()
|
||||||
@@ -380,88 +403,88 @@ class TestGuiAuto:
|
|||||||
def test_convert_mcp_args(self, gui):
|
def test_convert_mcp_args(self, gui):
|
||||||
args = {"operation": "click", "target": "确定"}
|
args = {"operation": "click", "target": "确定"}
|
||||||
result = gui.convert_mcp_args(args)
|
result = gui.convert_mcp_args(args)
|
||||||
assert "click" in result
|
assert "click" in as_text(result)
|
||||||
assert "确定" in result
|
assert "确定" in as_text(result)
|
||||||
|
|
||||||
def test_convert_mcp_args_xy(self, gui):
|
def test_convert_mcp_args_xy(self, gui):
|
||||||
args = {"operation": "click", "x": 100, "y": 200}
|
args = {"operation": "click", "x": 100, "y": 200}
|
||||||
result = gui.convert_mcp_args(args)
|
result = gui.convert_mcp_args(args)
|
||||||
assert "--xy" in result
|
assert "--xy" in as_text(result)
|
||||||
assert "100" in result
|
assert "100" in as_text(result)
|
||||||
|
|
||||||
def test_convert_mcp_args_snapshot(self, gui):
|
def test_convert_mcp_args_snapshot(self, gui):
|
||||||
args = {"operation": "snapshot", "depth": 3}
|
args = {"operation": "snapshot", "depth": 3}
|
||||||
result = gui.convert_mcp_args(args)
|
result = gui.convert_mcp_args(args)
|
||||||
assert "snapshot" in result
|
assert "snapshot" in as_text(result)
|
||||||
assert "3" in result
|
assert "3" in as_text(result)
|
||||||
|
|
||||||
def test_empty_args(self, gui):
|
def test_empty_args(self, gui):
|
||||||
result = gui.handle("")
|
result = gui.handle("")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_invalid_operation(self, gui):
|
def test_invalid_operation(self, gui):
|
||||||
result = gui.handle("nonexistent_op")
|
result = gui.handle("nonexistent_op")
|
||||||
assert "不支持" in result
|
assert "不支持" in as_text(result)
|
||||||
|
|
||||||
def test_click_no_args(self, gui):
|
def test_click_no_args(self, gui):
|
||||||
result = gui.handle("click")
|
result = gui.handle("click")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_type_no_args(self, gui):
|
def test_type_no_args(self, gui):
|
||||||
result = gui.handle("type")
|
result = gui.handle("type")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_type_missing_text(self, gui):
|
def test_type_missing_text(self, gui):
|
||||||
result = gui.handle("type 按钮")
|
result = gui.handle("type 按钮")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_find_no_args(self, gui):
|
def test_find_no_args(self, gui):
|
||||||
result = gui.handle("find")
|
result = gui.handle("find")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_findall_no_args(self, gui):
|
def test_findall_no_args(self, gui):
|
||||||
result = gui.handle("findall")
|
result = gui.handle("findall")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_info_no_args(self, gui):
|
def test_info_no_args(self, gui):
|
||||||
result = gui.handle("info")
|
result = gui.handle("info")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_value_no_args(self, gui):
|
def test_value_no_args(self, gui):
|
||||||
result = gui.handle("value")
|
result = gui.handle("value")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_tree_no_args(self, gui):
|
def test_tree_no_args(self, gui):
|
||||||
result = gui.handle("tree")
|
result = gui.handle("tree")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_wait_no_args(self, gui):
|
def test_wait_no_args(self, gui):
|
||||||
result = gui.handle("wait")
|
result = gui.handle("wait")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_exists_no_args(self, gui):
|
def test_exists_no_args(self, gui):
|
||||||
result = gui.handle("exists")
|
result = gui.handle("exists")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_highlight_no_args(self, gui):
|
def test_highlight_no_args(self, gui):
|
||||||
result = gui.handle("highlight")
|
result = gui.handle("highlight")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_state_no_args(self, gui):
|
def test_state_no_args(self, gui):
|
||||||
result = gui.handle("state")
|
result = gui.handle("state")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_keys_no_args(self, gui):
|
def test_keys_no_args(self, gui):
|
||||||
result = gui.handle("keys")
|
result = gui.handle("keys")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_focus_no_args(self, gui):
|
def test_focus_no_args(self, gui):
|
||||||
result = gui.handle("focus")
|
result = gui.handle("focus")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_click_xy_format_error(self, gui):
|
def test_click_xy_format_error(self, gui):
|
||||||
result = gui.handle("click --xy abc")
|
result = gui.handle("click --xy abc")
|
||||||
assert "错误" in result
|
assert is_error(result)
|
||||||
|
|
||||||
def test_format_element_info(self, gui):
|
def test_format_element_info(self, gui):
|
||||||
el = {
|
el = {
|
||||||
@@ -472,9 +495,9 @@ class TestGuiAuto:
|
|||||||
"enabled": True, "focused": False
|
"enabled": True, "focused": False
|
||||||
}
|
}
|
||||||
result = gui._format_element_info(el)
|
result = gui._format_element_info(el)
|
||||||
assert "测试按钮" in result
|
assert "测试按钮" in as_text(result)
|
||||||
assert "Button" in result
|
assert "Button" in as_text(result)
|
||||||
assert "btn_ok" in result
|
assert "btn_ok" in as_text(result)
|
||||||
|
|
||||||
def test_format_element_info_detailed(self, gui):
|
def test_format_element_info_detailed(self, gui):
|
||||||
el = {
|
el = {
|
||||||
@@ -485,8 +508,8 @@ class TestGuiAuto:
|
|||||||
"right": 200, "bottom": 25}
|
"right": 200, "bottom": 25}
|
||||||
}
|
}
|
||||||
result = gui._format_element_info(el, detailed=True)
|
result = gui._format_element_info(el, detailed=True)
|
||||||
assert "输入框" in result
|
assert "输入框" in as_text(result)
|
||||||
assert "hello" in result
|
assert "hello" in as_text(result)
|
||||||
|
|
||||||
def test_all_operations_route(self, gui):
|
def test_all_operations_route(self, gui):
|
||||||
"""所有操作都能正确路由"""
|
"""所有操作都能正确路由"""
|
||||||
@@ -504,28 +527,28 @@ class TestSubAgent:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def sa(self):
|
def sa(self):
|
||||||
mod = load_module("sub_agent", os.path.join(PLUGINS_DIR, "sub_agent.py"))
|
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):
|
def test_list_empty(self, sa):
|
||||||
result = sa.handle("list")
|
result = sa.handle("list")
|
||||||
assert "没有" in result
|
assert "没有" in as_text(result)
|
||||||
|
|
||||||
def test_stats_empty(self, sa):
|
def test_stats_empty(self, sa):
|
||||||
result = sa.handle("stats")
|
result = sa.handle("stats")
|
||||||
assert "总数: 0" in result
|
assert "总数: 0" in as_text(result)
|
||||||
|
|
||||||
def test_spawn(self, sa):
|
def test_spawn(self, sa):
|
||||||
result = sa.handle("spawn tester 测试任务")
|
result = sa.handle("spawn tester 测试任务")
|
||||||
assert "已创建" in result
|
assert "已创建" in as_text(result)
|
||||||
assert "agent_tester" in result
|
assert "agent_tester" in as_text(result)
|
||||||
|
|
||||||
def test_spawn_and_list(self, sa):
|
def test_spawn_and_list(self, sa):
|
||||||
sa.handle("spawn a 任务A")
|
sa.handle("spawn a 任务A")
|
||||||
sa.handle("spawn b 任务B")
|
sa.handle("spawn b 任务B")
|
||||||
import time; time.sleep(0.1)
|
import time; time.sleep(0.1)
|
||||||
result = sa.handle("list")
|
result = sa.handle("list")
|
||||||
assert "agent_a" in result
|
assert "agent_a" in as_text(result)
|
||||||
assert "agent_b" in result
|
assert "agent_b" in as_text(result)
|
||||||
|
|
||||||
def test_status(self, sa):
|
def test_status(self, sa):
|
||||||
sa.handle("spawn x 某任务")
|
sa.handle("spawn x 某任务")
|
||||||
@@ -533,24 +556,24 @@ class TestSubAgent:
|
|||||||
# 获取 agent ID
|
# 获取 agent ID
|
||||||
agent_id = list(sa.agents.keys())[0]
|
agent_id = list(sa.agents.keys())[0]
|
||||||
result = sa.handle(f"status {agent_id}")
|
result = sa.handle(f"status {agent_id}")
|
||||||
assert "名称" in result
|
assert "名称" in as_text(result)
|
||||||
|
|
||||||
def test_kill(self, sa):
|
def test_kill(self, sa):
|
||||||
sa.handle("spawn y 要终止的任务")
|
sa.handle("spawn y 要终止的任务")
|
||||||
import time; time.sleep(0.1)
|
import time; time.sleep(0.1)
|
||||||
agent_id = list(sa.agents.keys())[0]
|
agent_id = list(sa.agents.keys())[0]
|
||||||
result = sa.handle(f"kill {agent_id}")
|
result = sa.handle(f"kill {agent_id}")
|
||||||
assert "已终止" in result
|
assert "已终止" in as_text(result)
|
||||||
|
|
||||||
def test_clean(self, sa):
|
def test_clean(self, sa):
|
||||||
sa.handle("spawn z 清理测试")
|
sa.handle("spawn z 清理测试")
|
||||||
import time; time.sleep(0.1)
|
import time; time.sleep(0.1)
|
||||||
result = sa.handle("clean")
|
result = sa.handle("clean")
|
||||||
assert "已清理" in result
|
assert "已清理" in as_text(result)
|
||||||
|
|
||||||
def test_invalid_agent(self, sa):
|
def test_invalid_agent(self, sa):
|
||||||
result = sa.handle("status nonexistent")
|
result = sa.handle("status nonexistent")
|
||||||
assert "未找到" in result
|
assert "未找到" in as_text(result)
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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([], "任意输入") == []
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -10,6 +10,17 @@ import threading
|
|||||||
from typing import Dict, List, Any, Optional, Union, Callable
|
from typing import Dict, List, Any, Optional, Union, Callable
|
||||||
from colorama import Fore, Style
|
from colorama import Fore, Style
|
||||||
from xcli_core.tool_result import ToolResult, ErrorCode
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -47,6 +58,12 @@ class UnifiedToolManager:
|
|||||||
# MCP 工具定义缓存
|
# MCP 工具定义缓存
|
||||||
self._mcp_tools_cache: Optional[List[Dict]] = None
|
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:
|
def initialize(self, plugins_dir: str = "plugins", skills_dir: str = "skills") -> None:
|
||||||
"""初始化并加载所有工具"""
|
"""初始化并加载所有工具"""
|
||||||
self._plugins_dir = plugins_dir
|
self._plugins_dir = plugins_dir
|
||||||
@@ -54,10 +71,14 @@ class UnifiedToolManager:
|
|||||||
self._load_plugins(plugins_dir)
|
self._load_plugins(plugins_dir)
|
||||||
self._load_skills(skills_dir)
|
self._load_skills(skills_dir)
|
||||||
self._build_mcp_cache()
|
self._build_mcp_cache()
|
||||||
|
self._load_mcp_servers()
|
||||||
|
self._load_lsp_servers()
|
||||||
|
|
||||||
print(f"{Fore.GREEN}[统一工具管理器] 加载完成: {len(self._tools)} 个工具{Style.RESET_ALL}")
|
n_liugin = sum(1 for p in self._protocol_map.values() if p == 'liugin')
|
||||||
print(f" - 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" - 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:
|
def shutdown(self) -> None:
|
||||||
"""关闭管理器,清理所有插件实例"""
|
"""关闭管理器,清理所有插件实例"""
|
||||||
@@ -144,7 +165,7 @@ class UnifiedToolManager:
|
|||||||
self._skill_loader = SkillLoader(skills_dir)
|
self._skill_loader = SkillLoader(skills_dir)
|
||||||
|
|
||||||
discovered = self._skill_loader.discover_skills(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)
|
self._skill_loader.load_all_skills(skills_dir)
|
||||||
|
|
||||||
@@ -182,7 +203,7 @@ class UnifiedToolManager:
|
|||||||
}
|
}
|
||||||
self._protocol_map[skill_name] = 'skill'
|
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:
|
except ImportError as e:
|
||||||
print(f"{Fore.YELLOW}Skill 协议模块未找到: {e}{Style.RESET_ALL}")
|
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 ====================
|
# ==================== 公共 API ====================
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
+13
-2
@@ -24,6 +24,17 @@ import os
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Dict, List
|
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 # 当前活动的服务器实例(供外部访问)
|
websocket_server = None # 当前活动的服务器实例(供外部访问)
|
||||||
proxy_client = None
|
proxy_client = None
|
||||||
@@ -583,7 +594,7 @@ class LocalServer:
|
|||||||
await self._send_tool_status(websocket, tool_name, tool_args, "calling")
|
await self._send_tool_status(websocket, tool_name, tool_args, "calling")
|
||||||
|
|
||||||
result = self.cli.process_tool_call(tool_data)
|
result = self.cli.process_tool_call(tool_data)
|
||||||
result_text = result.get('result', '无结果')
|
result_text = normalize_tool_text(result)
|
||||||
results.append(result_text)
|
results.append(result_text)
|
||||||
|
|
||||||
if websocket:
|
if websocket:
|
||||||
@@ -957,7 +968,7 @@ class ProxyClient:
|
|||||||
results = []
|
results = []
|
||||||
for tool_data in tool_calls:
|
for tool_data in tool_calls:
|
||||||
result = self.cli.process_tool_call(tool_data)
|
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({
|
self.cli.shared_conversation_history.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
|
|||||||
@@ -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
@@ -10,6 +10,7 @@ from .constants import (
|
|||||||
UNIFIED_TOOL_MANAGER_AVAILABLE, TEXTUAL_AVAILABLE,
|
UNIFIED_TOOL_MANAGER_AVAILABLE, TEXTUAL_AVAILABLE,
|
||||||
)
|
)
|
||||||
from .config import get_system_config, set_system_config, logger
|
from .config import get_system_config, set_system_config, logger
|
||||||
|
from .verbose import vprint
|
||||||
from .plugin_manager import LiuginManager
|
from .plugin_manager import LiuginManager
|
||||||
from .process_protection import get_protection, enable_process_protection
|
from .process_protection import get_protection, enable_process_protection
|
||||||
|
|
||||||
@@ -70,16 +71,23 @@ class BaseAICLI:
|
|||||||
# 设置默认引擎,优先使用配置文件指定的引擎
|
# 设置默认引擎,优先使用配置文件指定的引擎
|
||||||
if default_engine_name in self.engines:
|
if default_engine_name in self.engines:
|
||||||
self.current_engine = self.engines[default_engine_name]
|
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:
|
elif self.engines:
|
||||||
first_engine_name = next(iter(self.engines))
|
first_engine_name = next(iter(self.engines))
|
||||||
self.current_engine = self.engines[first_engine_name]
|
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:
|
else:
|
||||||
print(f"{Fore.RED}警告: 没有可用的AI引擎,请检查ai_engines目录{Style.RESET_ALL}")
|
print(f"{Fore.RED}警告: 没有可用的AI引擎,请检查ai_engines目录{Style.RESET_ALL}")
|
||||||
self.current_engine = None
|
self.current_engine = None
|
||||||
if self.current_engine:
|
if self.current_engine:
|
||||||
self.current_engine.cli = self # 设置引用以便访问插件
|
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
|
from .safety import get_safety
|
||||||
@@ -89,6 +97,21 @@ class BaseAICLI:
|
|||||||
self.chat_history_dir = os.path.join(project_dir, "chat_history")
|
self.chat_history_dir = os.path.join(project_dir, "chat_history")
|
||||||
if not os.path.exists(self.chat_history_dir):
|
if not os.path.exists(self.chat_history_dir):
|
||||||
os.makedirs(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 目录路径
|
||||||
skills_dir = os.path.join(project_dir, "skills")
|
skills_dir = os.path.join(project_dir, "skills")
|
||||||
@@ -153,7 +176,7 @@ class BaseAICLI:
|
|||||||
|
|
||||||
def load_ai_engines(self):
|
def load_ai_engines(self):
|
||||||
"""动态加载AI引擎插件"""
|
"""动态加载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")
|
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):
|
if os.path.exists(ai_engines_dir):
|
||||||
for filename in os.listdir(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)
|
engine_key = getattr(engine_instance, 'name', engine_name)
|
||||||
if engine_key not in self.engines:
|
if engine_key not in self.engines:
|
||||||
self.engines[engine_key] = engine_instance
|
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:
|
else:
|
||||||
print(f"{Fore.YELLOW}AI引擎 {engine_key} 已存在,跳过加载{Style.RESET_ALL}")
|
print(f"{Fore.YELLOW}AI引擎 {engine_key} 已存在,跳过加载{Style.RESET_ALL}")
|
||||||
else:
|
else:
|
||||||
@@ -185,7 +208,7 @@ class BaseAICLI:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"加载AI引擎插件时发生未知错误 {filename}: {e}")
|
logger.error(f"加载AI引擎插件时发生未知错误 {filename}: {e}")
|
||||||
print(f"{Fore.RED}加载AI引擎插件失败 {filename}: {e}{Style.RESET_ALL}")
|
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):
|
def _find_engine_class(self, module, engine_name):
|
||||||
"""查找AI引擎类,使用动态方式避免硬编码类名"""
|
"""查找AI引擎类,使用动态方式避免硬编码类名"""
|
||||||
@@ -213,7 +236,7 @@ class BaseAICLI:
|
|||||||
|
|
||||||
def load_liugins(self, liugins_dir=None, skills_dir=None):
|
def load_liugins(self, liugins_dir=None, skills_dir=None):
|
||||||
"""加载liugin和技能"""
|
"""加载liugin和技能"""
|
||||||
print(f"{Fore.GREEN}正在加载工具...{Style.RESET_ALL}")
|
vprint(f"{Fore.GREEN}正在加载工具...{Style.RESET_ALL}")
|
||||||
|
|
||||||
if self._use_unified_manager:
|
if self._use_unified_manager:
|
||||||
self.liugin_manager.initialize(liugins_dir or "plugins", skills_dir or "skills")
|
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 = plugin_instance.get_tool_info()
|
||||||
tool_info['handler'] = plugin_instance.handle
|
tool_info['handler'] = plugin_instance.handle
|
||||||
self.liugin_manager.tools.append(tool_info)
|
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:
|
except Exception as e:
|
||||||
print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}")
|
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):
|
def register_liugin_command(self, command, handler):
|
||||||
"""注册插件命令"""
|
"""注册插件命令"""
|
||||||
self.liugin_commands[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):
|
def unregister_liugin_command(self, command):
|
||||||
"""注销插件命令"""
|
"""注销插件命令"""
|
||||||
@@ -305,6 +328,124 @@ class BaseAICLI:
|
|||||||
help_lines.append(f" /engine {cmd:<10} - 执行{cmd}命令")
|
help_lines.append(f" /engine {cmd:<10} - 执行{cmd}命令")
|
||||||
return '\n'.join(help_lines) if help_lines else " 无可用引擎管理命令"
|
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):
|
def get_available_engines(self):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import time
|
|||||||
from colorama import Fore, Style
|
from colorama import Fore, Style
|
||||||
|
|
||||||
from .constants import CLAWLI_SERVER_AVAILABLE, WEBSOCKET_AVAILABLE
|
from .constants import CLAWLI_SERVER_AVAILABLE, WEBSOCKET_AVAILABLE
|
||||||
|
from .tool_result import normalize_tool_text
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import clawli_server
|
import clawli_server
|
||||||
@@ -96,6 +97,7 @@ class ClawliMixin:
|
|||||||
tool_calls = self._extract_tool_calls(json_data)
|
tool_calls = self._extract_tool_calls(json_data)
|
||||||
|
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
|
result_texts = []
|
||||||
for tool_data in tool_calls:
|
for tool_data in tool_calls:
|
||||||
tool_name = tool_data.get('tool', '未知工具')
|
tool_name = tool_data.get('tool', '未知工具')
|
||||||
tool_args = tool_data.get('args', '')
|
tool_args = tool_data.get('args', '')
|
||||||
@@ -104,7 +106,8 @@ class ClawliMixin:
|
|||||||
clawli_server.send_tool_status(tool_name, tool_args, "calling")
|
clawli_server.send_tool_status(tool_name, tool_args, "calling")
|
||||||
|
|
||||||
result = self.process_tool_call(tool_data)
|
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:
|
if CLAWLI_SERVER_AVAILABLE and clawli_server:
|
||||||
clawli_server.send_tool_status(tool_name, tool_args, "success", result_text)
|
clawli_server.send_tool_status(tool_name, tool_args, "success", result_text)
|
||||||
@@ -114,7 +117,8 @@ class ClawliMixin:
|
|||||||
"content": response
|
"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请根据工具执行结果继续回答。")
|
self._process_clawli_user_message(f"{tool_result_str}\n请根据工具执行结果继续回答。")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+461
-121
@@ -9,8 +9,8 @@ import random
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from colorama import Fore, Style
|
from colorama import Fore, Style
|
||||||
|
|
||||||
from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY
|
from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY, VERSION
|
||||||
from .config import get_system_config, logger
|
from .config import get_system_config, set_system_config, logger
|
||||||
from .cli_base import BaseAICLI
|
from .cli_base import BaseAICLI
|
||||||
from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL
|
from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL
|
||||||
from .cli_clawli import ClawliMixin
|
from .cli_clawli import ClawliMixin
|
||||||
@@ -22,6 +22,17 @@ from .plugin_market import PluginMarketMixin
|
|||||||
from .notification import notify_task_complete, get_notification_manager
|
from .notification import notify_task_complete, get_notification_manager
|
||||||
from .process_protection import get_protection_status
|
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):
|
class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, HistoryMixin, PluginMarketMixin):
|
||||||
"""AI CLI主程序 - 组合所有 Mixin"""
|
"""AI CLI主程序 - 组合所有 Mixin"""
|
||||||
@@ -31,7 +42,7 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
|
|||||||
def process_thinking_response(self, response):
|
def process_thinking_response(self, response):
|
||||||
"""处理深度思考AI返回的内容, 分离思考和回复内容"""
|
"""处理深度思考AI返回的内容, 分离思考和回复内容"""
|
||||||
try:
|
try:
|
||||||
response_data = json.loads(response)
|
response_data = self._loads_json(response)
|
||||||
if isinstance(response_data, dict) and response_data.get('action') == 'use_tool':
|
if isinstance(response_data, dict) and response_data.get('action') == 'use_tool':
|
||||||
return response
|
return response
|
||||||
if isinstance(response_data, dict):
|
if isinstance(response_data, dict):
|
||||||
@@ -143,7 +154,20 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
|
|||||||
你的思考过程:
|
你的思考过程:
|
||||||
1. 需要 git 操作 → 先查 git_tools 怎么用
|
1. 需要 git 操作 → 先查 git_tools 怎么用
|
||||||
2. {{"action": "use_tool", "tool": "tool_search", "args": "git_tools"}} → 发现 smart-commit 操作
|
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:
|
if liugin_prompts:
|
||||||
prompt = f"""{base_prompt}
|
prompt = f"""{base_prompt}
|
||||||
@@ -152,6 +176,8 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
|
|||||||
|
|
||||||
{tool_search_rule}
|
{tool_search_rule}
|
||||||
|
|
||||||
|
{unified_arg_example}
|
||||||
|
|
||||||
【工具调用格式】
|
【工具调用格式】
|
||||||
在回复中包含 JSON 指令来执行操作:
|
在回复中包含 JSON 指令来执行操作:
|
||||||
|
|
||||||
@@ -219,6 +245,8 @@ multi 操作支持一次修改多处:
|
|||||||
|
|
||||||
{tool_search_rule}
|
{tool_search_rule}
|
||||||
|
|
||||||
|
{unified_arg_example}
|
||||||
|
|
||||||
【工具调用格式】
|
【工具调用格式】
|
||||||
{{"action": "use_tool", "tool": "工具名", "args": "参数"}}
|
{{"action": "use_tool", "tool": "工具名", "args": "参数"}}
|
||||||
|
|
||||||
@@ -253,6 +281,20 @@ multi 操作支持一次修改多处:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
return prompt
|
||||||
|
|
||||||
# ── JSON 解析 ──
|
# ── JSON 解析 ──
|
||||||
@@ -285,11 +327,15 @@ multi 操作支持一次修改多处:
|
|||||||
plugins.append(tool)
|
plugins.append(tool)
|
||||||
|
|
||||||
if plugins:
|
if plugins:
|
||||||
prompts.append("## 内置工具(使用 tool_search 查询详细用法):")
|
# 工具名清单(纯 JSON 模式:不发送 FC schema,模型按 action JSON 调用)。
|
||||||
for tool in plugins:
|
# 只列名字 + 由 tool_search 提供用法,避免重复曝光、省 token。
|
||||||
tool_name = tool.get('name', '')
|
names = ", ".join(t.get('name', '') for t in plugins)
|
||||||
tool_desc = tool.get('description', '')
|
prompts.append(
|
||||||
prompts.append(f"- {tool_name}: {tool_desc}")
|
"## 内置工具(纯 JSON 模式:不发送 function calling 的 tools schema,"
|
||||||
|
"模型按下方 JSON 格式直接调用,调用前请先用 tool_search 查询其详细用法与示例)\n"
|
||||||
|
f"工具名: {names}\n"
|
||||||
|
'调用格式: {"action": "use_tool", "tool": "工具名", "args": "参数"}'
|
||||||
|
)
|
||||||
|
|
||||||
if skills:
|
if skills:
|
||||||
prompts.append("\n## 扩展技能(已加载详细指令):")
|
prompts.append("\n## 扩展技能(已加载详细指令):")
|
||||||
@@ -328,6 +374,18 @@ multi 操作支持一次修改多处:
|
|||||||
|
|
||||||
return "\n".join(prompts)
|
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):
|
def _parse_mixed_response(self, response):
|
||||||
"""
|
"""
|
||||||
解析混合响应,处理同时包含文本和JSON指令的内容
|
解析混合响应,处理同时包含文本和JSON指令的内容
|
||||||
@@ -340,7 +398,7 @@ multi 操作支持一次修改多处:
|
|||||||
json_str = response[11:]
|
json_str = response[11:]
|
||||||
json_str = "{" + json_str
|
json_str = "{" + json_str
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(json_str)
|
parsed = self._loads_json(json_str)
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
return "", parsed
|
return "", parsed
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
@@ -348,7 +406,7 @@ multi 操作支持一次修改多处:
|
|||||||
|
|
||||||
# 方法1: 直接解析为纯JSON
|
# 方法1: 直接解析为纯JSON
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(response)
|
parsed = self._loads_json(response)
|
||||||
if isinstance(parsed, (list, dict)):
|
if isinstance(parsed, (list, dict)):
|
||||||
return "", parsed
|
return "", parsed
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
@@ -365,7 +423,7 @@ multi 操作支持一次修改多处:
|
|||||||
content = content.replace('"', '\\"')
|
content = content.replace('"', '\\"')
|
||||||
return f'"{content}"'
|
return f'"{content}"'
|
||||||
fixed_response = re.sub(r'"([^"]*(?:\n[^"]*)*)"', fix_json_string, response, flags=re.DOTALL)
|
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)):
|
if isinstance(parsed, (list, dict)):
|
||||||
return "", parsed
|
return "", parsed
|
||||||
except:
|
except:
|
||||||
@@ -376,7 +434,7 @@ multi 操作支持一次修改多处:
|
|||||||
matches = re.findall(json_pattern, response, re.DOTALL)
|
matches = re.findall(json_pattern, response, re.DOTALL)
|
||||||
if matches:
|
if matches:
|
||||||
try:
|
try:
|
||||||
json_data = json.loads(matches[-1])
|
json_data = self._loads_json(matches[-1])
|
||||||
if isinstance(json_data, (dict, list)):
|
if isinstance(json_data, (dict, list)):
|
||||||
text_content = re.sub(json_pattern, "", response, flags=re.DOTALL).strip()
|
text_content = re.sub(json_pattern, "", response, flags=re.DOTALL).strip()
|
||||||
text_content = re.sub(r"\n\s*\n", "\n\n", text_content).strip()
|
text_content = re.sub(r"\n\s*\n", "\n\n", text_content).strip()
|
||||||
@@ -392,7 +450,7 @@ multi 操作支持一次修改多处:
|
|||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line.startswith("{") and line.endswith("}"):
|
if line.startswith("{") and line.endswith("}"):
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(line)
|
parsed = self._loads_json(line)
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
json_objects.append(parsed)
|
json_objects.append(parsed)
|
||||||
continue
|
continue
|
||||||
@@ -430,7 +488,7 @@ multi 操作支持一次修改多处:
|
|||||||
if in_json and brace_count == 0:
|
if in_json and brace_count == 0:
|
||||||
json_str = response[json_start:i+1]
|
json_str = response[json_start:i+1]
|
||||||
try:
|
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']:
|
if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']:
|
||||||
text_content = response[:json_start].strip()
|
text_content = response[:json_start].strip()
|
||||||
return text_content, json_data
|
return text_content, json_data
|
||||||
@@ -442,7 +500,7 @@ multi 操作支持一次修改多处:
|
|||||||
potential_jsons = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL)
|
potential_jsons = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL)
|
||||||
for json_str in potential_jsons:
|
for json_str in potential_jsons:
|
||||||
try:
|
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']:
|
if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']:
|
||||||
json_start = response.find(json_str)
|
json_start = response.find(json_str)
|
||||||
text_content = response[:json_start].strip()
|
text_content = response[:json_start].strip()
|
||||||
@@ -460,12 +518,12 @@ multi 操作支持一次修改多处:
|
|||||||
if last_brace_pos != -1:
|
if last_brace_pos != -1:
|
||||||
json_part = cleaned[last_brace_pos:]
|
json_part = cleaned[last_brace_pos:]
|
||||||
if json_part.startswith('['):
|
if json_part.startswith('['):
|
||||||
parsed = json.loads(json_part)
|
parsed = self._loads_json(json_part)
|
||||||
if isinstance(parsed, list):
|
if isinstance(parsed, list):
|
||||||
text_content = cleaned[:last_brace_pos].strip()
|
text_content = cleaned[:last_brace_pos].strip()
|
||||||
return text_content, parsed
|
return text_content, parsed
|
||||||
else:
|
else:
|
||||||
parsed = json.loads(json_part)
|
parsed = self._loads_json(json_part)
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
text_content = cleaned[:last_brace_pos].strip()
|
text_content = cleaned[:last_brace_pos].strip()
|
||||||
return text_content, parsed
|
return text_content, parsed
|
||||||
@@ -714,17 +772,34 @@ multi 操作支持一次修改多处:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ── 自动压缩上下文 ──
|
# ── 自动压缩上下文(token 感知优先,消息数兜底)──
|
||||||
try:
|
try:
|
||||||
if self.memory_manager.should_compress(self.shared_conversation_history):
|
if hasattr(self, 'memory_manager') and self.memory_manager:
|
||||||
self.shared_conversation_history = self.memory_manager.compress_context(
|
engine = self.current_engine
|
||||||
self.shared_conversation_history,
|
prompt_tokens = getattr(engine, 'last_prompt_tokens', None) if engine else None
|
||||||
engine=self.current_engine
|
# 统一 API:所有引擎必须返回 max_input_tokens(最大输入 token 数)
|
||||||
)
|
ctx = None
|
||||||
self._set_shared_conversation_history()
|
if engine:
|
||||||
print(f"{Fore.DIM}[上下文已压缩,保留最近 10 条]{Style.RESET_ALL}")
|
try:
|
||||||
except Exception:
|
ctx = engine.max_input_tokens
|
||||||
pass
|
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:
|
def _extract_task_summary(self, user_input: str) -> str:
|
||||||
"""提取任务摘要用于通知显示"""
|
"""提取任务摘要用于通知显示"""
|
||||||
@@ -744,7 +819,7 @@ multi 操作支持一次修改多处:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(response)
|
data = self._loads_json(response)
|
||||||
if not data.get("_fc"):
|
if not data.get("_fc"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -779,16 +854,15 @@ multi 操作支持一次修改多处:
|
|||||||
# 通过插件管理器执行
|
# 通过插件管理器执行
|
||||||
result = self._execute_tool_by_name(tool_name, tool_args)
|
result = self._execute_tool_by_name(tool_name, tool_args)
|
||||||
|
|
||||||
# 记录工具结果到历史
|
# 记录工具结果到历史(官方 OpenAI tool 消息不含 name 字段)
|
||||||
tool_result_msg = {
|
tool_result_msg = {
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": call_id,
|
"tool_call_id": call_id,
|
||||||
"name": tool_name,
|
"content": result if result else "执行完成"
|
||||||
"content": result[:5000] if result else "执行完成"
|
|
||||||
}
|
}
|
||||||
self.shared_conversation_history.append(tool_result_msg)
|
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 处理
|
# 返回工具结果,供下一轮 AI 处理
|
||||||
return "工具执行结果:\n" + "\n".join(results)
|
return "工具执行结果:\n" + "\n".join(results)
|
||||||
@@ -797,7 +871,13 @@ multi 操作支持一次修改多处:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _execute_tool_by_name(self, tool_name: str, tool_args: str) -> str:
|
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()
|
safety = get_safety()
|
||||||
allowed, msg = safety.check(tool_name, tool_args)
|
allowed, msg = safety.check(tool_name, tool_args)
|
||||||
@@ -808,25 +888,61 @@ multi 操作支持一次修改多处:
|
|||||||
tool = self.liugin_manager.get_tool_by_name(tool_name)
|
tool = self.liugin_manager.get_tool_by_name(tool_name)
|
||||||
if tool and 'handler' in tool:
|
if tool and 'handler' in tool:
|
||||||
try:
|
try:
|
||||||
return tool['handler'](tool_args)
|
return normalize_tool_text(tool['handler'](tool_args), default="执行完成")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"工具执行失败: {e}"
|
return f"工具执行失败: {e}"
|
||||||
|
|
||||||
# memory 内置工具
|
# memory 内置工具
|
||||||
if tool_name == 'memory' and hasattr(self, 'memory_manager'):
|
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 内置工具
|
# scheduler 内置工具
|
||||||
if tool_name == 'scheduler':
|
if tool_name == 'scheduler':
|
||||||
for t in self.liugin_manager.tools:
|
for t in self.liugin_manager.tools:
|
||||||
if t.get('name') == 'scheduler' and 'handler' in t:
|
if t.get('name') == 'scheduler' and 'handler' in t:
|
||||||
try:
|
try:
|
||||||
return t['handler'](tool_args)
|
return normalize_tool_text(t['handler'](tool_args), default="执行完成")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"工具执行失败: {e}"
|
return f"工具执行失败: {e}"
|
||||||
|
|
||||||
return f"未找到工具: {tool_name}"
|
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):
|
def _generate_response_with_animation(self, current_input, liugin_prompts=None):
|
||||||
"""生成AI响应并显示等待动画 — 支持 ESC 跨平台取消"""
|
"""生成AI响应并显示等待动画 — 支持 ESC 跨平台取消"""
|
||||||
# TUI 模式:不做动画
|
# TUI 模式:不做动画
|
||||||
@@ -834,14 +950,10 @@ multi 操作支持一次修改多处:
|
|||||||
system_prompt = self._build_system_prompt(liugin_prompts)
|
system_prompt = self._build_system_prompt(liugin_prompts)
|
||||||
if not self.current_engine:
|
if not self.current_engine:
|
||||||
return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
|
return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
|
||||||
# TUI 模式也支持 FC
|
# 混合模式:下发 FC 工具定义,tool_choice=auto —— 模型可自行选择
|
||||||
fc_tools = None
|
# 用 FC 结构化调用,或回退到纯 JSON action(两种都会被正确处理)。
|
||||||
try:
|
fc_tools = self._build_fc_tools(current_input)
|
||||||
from .fc_tools import mcp_to_openai_tools
|
response = self._call_engine_with_tools(current_input, system_prompt, fc_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)
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# CLI 模式:带动画和跨平台 ESC 取消
|
# CLI 模式:带动画和跨平台 ESC 取消
|
||||||
@@ -851,12 +963,18 @@ multi 操作支持一次修改多处:
|
|||||||
response_ready = threading.Event()
|
response_ready = threading.Event()
|
||||||
response_result = [None] # 用列表存储,方便线程内修改
|
response_result = [None] # 用列表存储,方便线程内修改
|
||||||
|
|
||||||
|
# 是否真实交互终端:非 TTY(管道 / 日志镜像 / 聊天前端捕获 stdout)时,
|
||||||
|
# \r 无法原地覆盖,逐帧动画会污染输出流,必须禁用。
|
||||||
|
is_tty = sys.stdout.isatty()
|
||||||
|
|
||||||
love_sentences = []
|
love_sentences = []
|
||||||
love_file_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), LOVE_FILE_PATH)
|
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):
|
if os.path.exists(love_file_path):
|
||||||
try:
|
try:
|
||||||
with open(love_file_path, 'r', encoding='utf-8') as f:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not love_sentences:
|
if not love_sentences:
|
||||||
@@ -903,6 +1021,13 @@ multi 操作支持一次修改多处:
|
|||||||
|
|
||||||
# ── 动画线程 ──
|
# ── 动画线程 ──
|
||||||
def show_animation():
|
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()
|
last_change_time = time.time()
|
||||||
current_sentence = random.choice(love_sentences)
|
current_sentence = random.choice(love_sentences)
|
||||||
while animation_running.is_set():
|
while animation_running.is_set():
|
||||||
@@ -923,19 +1048,9 @@ multi 操作支持一次修改多处:
|
|||||||
if not self.current_engine:
|
if not self.current_engine:
|
||||||
response_result[0] = "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
|
response_result[0] = "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
|
||||||
else:
|
else:
|
||||||
# ── 收集 FC 工具定义 ──
|
# 混合模式:下发 FC 工具(tool_choice=auto),模型自选 FC 或纯 JSON
|
||||||
fc_tools = None
|
fc_tools = self._build_fc_tools(current_input)
|
||||||
try:
|
response_result[0] = self._call_engine_with_tools(current_input, system_prompt, fc_tools)
|
||||||
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
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
response_result[0] = f"AI 响应生成失败: {e}"
|
response_result[0] = f"AI 响应生成失败: {e}"
|
||||||
finally:
|
finally:
|
||||||
@@ -956,7 +1071,8 @@ multi 操作支持一次修改多处:
|
|||||||
# ESC 被按下 → 取消
|
# ESC 被按下 → 取消
|
||||||
if esc_pressed.is_set():
|
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}")
|
print(f"\n{Fore.YELLOW}⚡ AI 请求已取消 (ESC){Style.RESET_ALL}")
|
||||||
return "[AI请求已取消]"
|
return "[AI请求已取消]"
|
||||||
|
|
||||||
@@ -964,7 +1080,8 @@ multi 操作支持一次修改多处:
|
|||||||
response = response_result[0]
|
response = response_result[0]
|
||||||
if response:
|
if response:
|
||||||
# 清除动画行后再输出
|
# 清除动画行后再输出
|
||||||
print("\r" + " " * 80 + "\r", end="", flush=True)
|
if is_tty:
|
||||||
|
print("\r" + " " * 80 + "\r", end="", flush=True)
|
||||||
self._typeprint(response, Fore.CYAN)
|
self._typeprint(response, Fore.CYAN)
|
||||||
|
|
||||||
if response and self.shared_conversation_history is not None:
|
if response and self.shared_conversation_history is not None:
|
||||||
@@ -980,79 +1097,187 @@ multi 操作支持一次修改多处:
|
|||||||
for t in threads:
|
for t in threads:
|
||||||
t.join(timeout=1)
|
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 主循环 ──
|
# ── CLI 主循环 ──
|
||||||
|
|
||||||
def run_tui(self):
|
def run_tui(self):
|
||||||
"""运行 TUI 模式"""
|
"""运行 TUI 模式(v8.0 默认真·全屏 textual TUI;textual 不可用时回退 rich 面板)"""
|
||||||
if not TEXTUAL_AVAILABLE:
|
if TEXTUAL_AVAILABLE:
|
||||||
print(f"{Fore.RED}TUI 模式不可用:Textual 库未安装{Style.RESET_ALL}")
|
from .tui import XiaoliTUI
|
||||||
print(f"{Fore.YELLOW}请运行: pip install textual rich{Style.RESET_ALL}")
|
app = XiaoliTUI(self)
|
||||||
|
app.run()
|
||||||
return
|
return
|
||||||
from .tui import XiaoliTUI
|
# 回退 rich 面板 TUI(textual 不可用时)
|
||||||
app = XiaoliTUI(self)
|
try:
|
||||||
app.run()
|
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):
|
def run(self):
|
||||||
"""运行 CLI"""
|
"""运行 CLI"""
|
||||||
import uuid
|
import uuid
|
||||||
self.user_id = str(uuid.getnode())
|
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 '未设置'
|
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())
|
loaded_engines = list(self.engines.keys())
|
||||||
if len(loaded_engines) > 1:
|
if len(loaded_engines) > 1:
|
||||||
print(f"{Fore.GREEN}已加载的AI引擎: {', '.join(loaded_engines)}{Style.RESET_ALL}")
|
extras.append(f"可切换引擎: {', '.join(loaded_engines)}")
|
||||||
print(f"{Fore.GREEN}{'-' * 50}{Style.RESET_ALL}")
|
# 进程保护:只在有未生效项时才提示,全绿就闭嘴
|
||||||
|
if getattr(self, 'protection_results', None):
|
||||||
# 显示进程保护状态
|
|
||||||
if hasattr(self, 'protection_results') and self.protection_results:
|
|
||||||
r = self.protection_results
|
r = self.protection_results
|
||||||
si_icon = '' if r.get('single_instance') else ''
|
failed = [n for k, n in (('single_instance', '单实例'), ('priority', '优先级'),
|
||||||
prio_icon = '' if r.get('priority') else ''
|
('ppl_protection', 'PPL保护'), ('watchdog', '看门狗'))
|
||||||
ppl_icon = '' if r.get('ppl_protection') else ''
|
if not r.get(k)]
|
||||||
wd_icon = '' if r.get('watchdog') else ''
|
if failed:
|
||||||
print(f"{Fore.CYAN} 进程保护: {si_icon}单实例 {prio_icon}优先级 {ppl_icon}PPL保护 {wd_icon}看门狗{Style.RESET_ALL}")
|
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._ask_diff_popup_mode()
|
||||||
|
|
||||||
self._run_cli_loop()
|
self._run_cli_loop()
|
||||||
|
|
||||||
def _ask_diff_popup_mode(self):
|
def _get_code_editor_plugin(self):
|
||||||
"""启动时询问 diff 显示模式"""
|
"""拿到 code_editor 插件实例(找不到返回 None)"""
|
||||||
# 找到 code_editor 插件
|
|
||||||
code_editor = None
|
|
||||||
for tool in self.liugin_manager.tools:
|
for tool in self.liugin_manager.tools:
|
||||||
if tool.get('name') == 'code_editor':
|
if tool.get('name') == 'code_editor':
|
||||||
code_editor = tool.get('handler')
|
return getattr(tool.get('handler'), '__self__', None)
|
||||||
break
|
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
|
return
|
||||||
|
|
||||||
# 找到插件实例(通过 handler 的 __self__)
|
saved = get_system_config('diff_popup_mode', None)
|
||||||
plugin_instance = getattr(code_editor, '__self__', None)
|
if saved is not None:
|
||||||
if plugin_instance is None:
|
plugin_instance.diff_popup_mode = bool(saved)
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"\n{Fore.CYAN} Diff 显示模式(AI 修改文件后的对比方式):{Style.RESET_ALL}")
|
print(f"\n{Fore.CYAN} Diff 显示模式(AI 修改文件后的对比方式):{Style.RESET_ALL}")
|
||||||
@@ -1060,19 +1285,23 @@ multi 操作支持一次修改多处:
|
|||||||
print(f" {Fore.WHITE}2{Style.RESET_ALL} - 主终端内显示(SSH 兼容,推荐)")
|
print(f" {Fore.WHITE}2{Style.RESET_ALL} - 主终端内显示(SSH 兼容,推荐)")
|
||||||
|
|
||||||
try:
|
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):
|
except (EOFError, KeyboardInterrupt):
|
||||||
choice = '2'
|
choice = '2'
|
||||||
|
|
||||||
if choice == '1':
|
self._set_diff_popup_mode(choice == '1', plugin_instance)
|
||||||
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}")
|
|
||||||
|
|
||||||
print()
|
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):
|
def _run_cli_loop(self):
|
||||||
"""运行 CLI 主循环"""
|
"""运行 CLI 主循环"""
|
||||||
while True:
|
while True:
|
||||||
@@ -1083,6 +1312,11 @@ multi 操作支持一次修改多处:
|
|||||||
print(f"{Fore.GREEN}再见!{Style.RESET_ALL}")
|
print(f"{Fore.GREEN}再见!{Style.RESET_ALL}")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# @agent 委派(opencode 式 Agents 体系化)
|
||||||
|
if user_input.startswith('@'):
|
||||||
|
self.handle_agent_command(user_input[1:].strip())
|
||||||
|
continue
|
||||||
|
|
||||||
if user_input == '/help':
|
if user_input == '/help':
|
||||||
self.show_help()
|
self.show_help()
|
||||||
continue
|
continue
|
||||||
@@ -1110,6 +1344,38 @@ multi 操作支持一次修改多处:
|
|||||||
print(f"{Fore.RED}当前引擎不支持该命令或命令执行失败{Style.RESET_ALL}")
|
print(f"{Fore.RED}当前引擎不支持该命令或命令执行失败{Style.RESET_ALL}")
|
||||||
continue
|
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 子命令
|
# /manual 快捷命令 — 切换到 manual 引擎或执行 manual 子命令
|
||||||
if user_input == '/manual' or user_input.startswith('/manual '):
|
if user_input == '/manual' or user_input.startswith('/manual '):
|
||||||
args = user_input[8:].strip() if user_input.startswith('/manual ') else ''
|
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}")
|
print(f"{Fore.CYAN}已从 TUI 模式返回 CLI 模式{Style.RESET_ALL}")
|
||||||
continue
|
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'):
|
if user_input.startswith('/safe'):
|
||||||
parts = user_input.split()
|
parts = user_input.split()
|
||||||
safety = get_safety()
|
safety = get_safety()
|
||||||
@@ -1238,6 +1528,42 @@ multi 操作支持一次修改多处:
|
|||||||
print(f"{icon} 安全模式: {mode_name}")
|
print(f"{icon} 安全模式: {mode_name}")
|
||||||
continue
|
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'):
|
if user_input.startswith('/notify'):
|
||||||
parts = user_input.split()
|
parts = user_input.split()
|
||||||
nm = get_notification_manager()
|
nm = get_notification_manager()
|
||||||
@@ -1297,6 +1623,10 @@ multi 操作支持一次修改多处:
|
|||||||
print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}")
|
print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}")
|
||||||
|
|
||||||
self.process_conversation(user_input)
|
self.process_conversation(user_input)
|
||||||
|
# 自动持久化当前会话(opencode 式 /resume 落盘)
|
||||||
|
self._autosave_session()
|
||||||
|
# Plan 模式下:把本轮最后一条 assistant 内容记为当前计划
|
||||||
|
self._sync_current_plan()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
user_id_display = f"[用户ID: {self.user_id}]"
|
user_id_display = f"[用户ID: {self.user_id}]"
|
||||||
print(f"\n{Fore.GREEN}再见! {user_id_display}{Style.RESET_ALL}")
|
print(f"\n{Fore.GREEN}再见! {user_id_display}{Style.RESET_ALL}")
|
||||||
@@ -1377,12 +1707,22 @@ def main():
|
|||||||
import codecs
|
import codecs
|
||||||
sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict')
|
sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict')
|
||||||
|
|
||||||
for line in art_lines:
|
def _safe_print(line):
|
||||||
try:
|
try:
|
||||||
print(line, flush=True)
|
print(line, flush=True)
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
print(line.encode('gbk', errors='ignore').decode('gbk'), flush=True)
|
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 = AICLI()
|
||||||
cli.run()
|
cli.run()
|
||||||
@@ -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):
|
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")
|
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):
|
if os.path.exists(help_file_path):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import os
|
|||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
from colorama import Fore, Style
|
from colorama import Fore, Style
|
||||||
|
from .session import SessionManager
|
||||||
|
|
||||||
|
|
||||||
class HistoryMixin:
|
class HistoryMixin:
|
||||||
"""聊天记录管理相关方法"""
|
"""聊天记录管理相关方法(含 opencode 式会话持久化 /resume)"""
|
||||||
|
|
||||||
def save_chat_history(self, name):
|
def save_chat_history(self, name):
|
||||||
"""保存当前聊天记录到文件"""
|
"""保存当前聊天记录到文件"""
|
||||||
@@ -58,3 +59,62 @@ class HistoryMixin:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"{Fore.RED}加载聊天记录失败: {e}{Style.RESET_ALL}")
|
print(f"{Fore.RED}加载聊天记录失败: {e}{Style.RESET_ALL}")
|
||||||
return False
|
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
@@ -7,6 +7,7 @@ from colorama import Fore, Style
|
|||||||
|
|
||||||
from .constants import UNIFIED_TOOL_MANAGER_AVAILABLE
|
from .constants import UNIFIED_TOOL_MANAGER_AVAILABLE
|
||||||
from .safety import get_safety
|
from .safety import get_safety
|
||||||
|
from .tool_result import normalize_tool_dict, normalize_tool_text
|
||||||
|
|
||||||
|
|
||||||
class ToolMixin:
|
class ToolMixin:
|
||||||
@@ -105,18 +106,18 @@ class ToolMixin:
|
|||||||
result = self.liugin_manager.execute(tool_name, args, **arguments)
|
result = self.liugin_manager.execute(tool_name, args, **arguments)
|
||||||
else:
|
else:
|
||||||
result = self.liugin_manager.execute(tool_name, args)
|
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:
|
for tool in self.liugin_manager.tools:
|
||||||
if tool['name'] == tool_name:
|
if tool['name'] == tool_name:
|
||||||
try:
|
try:
|
||||||
result = tool['handler'](args)
|
return normalize_tool_dict(tool['handler'](args))
|
||||||
if result is None:
|
|
||||||
result = "无结果"
|
|
||||||
return {"result": result}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"result": f"工具执行错误: {e}"}
|
return {"result": f"工具执行错误: {e}", "success": False}
|
||||||
|
|
||||||
return {"result": f"未找到工具: {tool_name}"}
|
return {"result": f"未找到工具: {tool_name}"}
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ class ToolMixin:
|
|||||||
return {"index": index, "result": "工具执行已取消"}
|
return {"index": index, "result": "工具执行已取消"}
|
||||||
try:
|
try:
|
||||||
result = self.process_tool_call(tool_data)
|
result = self.process_tool_call(tool_data)
|
||||||
tool_result = result.get('result', '无结果')
|
tool_result = normalize_tool_text(result)
|
||||||
with history_lock:
|
with history_lock:
|
||||||
self.shared_conversation_history.append({
|
self.shared_conversation_history.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
@@ -204,7 +205,7 @@ class ToolMixin:
|
|||||||
return [r['result'] for r in results]
|
return [r['result'] for r in results]
|
||||||
|
|
||||||
def _execute_single_tool(self, tool_data):
|
def _execute_single_tool(self, tool_data):
|
||||||
"""执行单个工具(带延迟和ESC检测)"""
|
"""执行单个工具(零等待,执行前非阻塞检测 ESC 取消)"""
|
||||||
tool_name = tool_data.get('tool', '未知工具')
|
tool_name = tool_data.get('tool', '未知工具')
|
||||||
tool_args = tool_data.get('args', '')
|
tool_args = tool_data.get('args', '')
|
||||||
if len(tool_args) > 30:
|
if len(tool_args) > 30:
|
||||||
@@ -212,27 +213,16 @@ class ToolMixin:
|
|||||||
else:
|
else:
|
||||||
tool_args_display = tool_args
|
tool_args_display = tool_args
|
||||||
|
|
||||||
# TUI 模式下不做延迟和 ESC 检测
|
# 非 TUI 模式:执行前零等待检查 ESC(不再做倒计时延迟,立即执行)
|
||||||
|
showed_hint = False
|
||||||
if not self.tui_output_callback:
|
if not self.tui_output_callback:
|
||||||
try:
|
try:
|
||||||
import msvcrt
|
import msvcrt
|
||||||
canceled = False
|
canceled = False
|
||||||
spinners = ['⊶', '⊷']
|
# 排空键盘缓冲区:若在此之前按过 ESC 则取消(非阻塞,无等待)
|
||||||
spinner_index = 0
|
while msvcrt.kbhit():
|
||||||
for i in range(25):
|
if msvcrt.getch() == b'\x1b':
|
||||||
if msvcrt.kbhit():
|
canceled = True
|
||||||
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()
|
|
||||||
if canceled:
|
if canceled:
|
||||||
print(f"{Fore.YELLOW}工具执行已取消(用户按ESC键){Style.RESET_ALL}")
|
print(f"{Fore.YELLOW}工具执行已取消(用户按ESC键){Style.RESET_ALL}")
|
||||||
cancel_message = f"工具 {tool_name} 执行已被用户取消(按ESC键)。工具参数: {tool_args}"
|
cancel_message = f"工具 {tool_name} 执行已被用户取消(按ESC键)。工具参数: {tool_args}"
|
||||||
@@ -244,8 +234,22 @@ class ToolMixin:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
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)
|
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
|
from datetime import datetime
|
||||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
self._display_tool_result(tool_name, tool_args, full_result, current_time)
|
self._display_tool_result(tool_name, tool_args, full_result, current_time)
|
||||||
|
|||||||
+55
-5
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from colorama import Fore, Style
|
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")
|
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():
|
def load_config():
|
||||||
"""加载配置文件"""
|
"""加载配置文件"""
|
||||||
@@ -42,13 +76,29 @@ def set_system_config(key, value):
|
|||||||
save_config(config)
|
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(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
handlers=[
|
handlers=[_file_handler, _console_handler]
|
||||||
logging.FileHandler('xiaoli_cli.log', encoding='utf-8'),
|
|
||||||
logging.StreamHandler() # 同时输出到控制台
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 第三方库的心跳日志(httpx 每发一次请求就打一行)不上屏也不进日志文件
|
||||||
|
for _noisy in ('httpx', 'httpcore', 'urllib3', 'requests', 'openai', 'PIL', 'markdown_it'):
|
||||||
|
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -6,6 +6,10 @@ VIDEO_FRAME_DELAY = 0.04 # 40毫秒
|
|||||||
LOVE_FILE_PATH = "love.txt"
|
LOVE_FILE_PATH = "love.txt"
|
||||||
DEFAULT_MAX_HISTORY = 999999 # 无限制对话历史
|
DEFAULT_MAX_HISTORY = 999999 # 无限制对话历史
|
||||||
|
|
||||||
|
# 版本号(v8.0 基于 v5.4.1 重新立项)
|
||||||
|
VERSION = "8.0.3"
|
||||||
|
VERSION_NAME = "小狸 Pro-CLI"
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
|||||||
+203
-4
@@ -2,7 +2,9 @@
|
|||||||
Function Calling 工具格式转换
|
Function Calling 工具格式转换
|
||||||
将 MCP 插件定义转为 OpenAI FC 格式,收集所有可用工具
|
将 MCP 插件定义转为 OpenAI FC 格式,收集所有可用工具
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
@@ -19,8 +21,17 @@ def mcp_to_openai_tools(plugin_manager) -> List[Dict]:
|
|||||||
if not plugin_manager:
|
if not plugin_manager:
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
# 统一工具管理器
|
# 优先使用统一工具管理器的 MCP 定义(含外部 MCP client 桥接的工具,schema 保真)
|
||||||
if hasattr(plugin_manager, 'plugins'):
|
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:
|
for plugin in plugin_manager.plugins:
|
||||||
fc_def = _extract_fc_def(plugin)
|
fc_def = _extract_fc_def(plugin)
|
||||||
if fc_def:
|
if fc_def:
|
||||||
@@ -36,7 +47,117 @@ def mcp_to_openai_tools(plugin_manager) -> List[Dict]:
|
|||||||
builtin_tools = _get_builtin_tools()
|
builtin_tools = _get_builtin_tools()
|
||||||
tools.extend(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]:
|
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:
|
Returns:
|
||||||
{"role": "tool", "tool_call_id": ..., "content": ...}
|
{"role": "tool", "tool_call_id": ..., "content": ...}
|
||||||
|
注意: 官方 OpenAI 的 tool 消息不含 name 字段(严格服务端如讯飞会拒),故不写入。
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tool_call_id,
|
"tool_call_id": tool_call_id,
|
||||||
"name": tool_name,
|
|
||||||
"content": result[:10000] if result else "" # 截断过长结果
|
"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())
|
||||||
@@ -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
@@ -22,7 +22,11 @@ class MemoryManager:
|
|||||||
self.memory_dir = os.path.join(project_dir, "memory")
|
self.memory_dir = os.path.join(project_dir, "memory")
|
||||||
self.memory_file = os.path.join(project_dir, "MEMORY.md")
|
self.memory_file = os.path.join(project_dir, "MEMORY.md")
|
||||||
self.chat_dir = os.path.join(project_dir, "chat_history")
|
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()
|
self._ensure_dirs()
|
||||||
|
|
||||||
def _ensure_dirs(self):
|
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
|
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
|
return conversation
|
||||||
|
|
||||||
keep_recent = 10
|
|
||||||
to_compress = conversation[:-keep_recent]
|
|
||||||
recent = 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(new_to_compress, engine)
|
||||||
summary = self._ai_summarize(to_compress, engine)
|
block = f"[上下文压缩] 之前 {len(new_to_compress)} 条对话摘要:\n{summary}"
|
||||||
|
self.compressed_blobs.append(block)
|
||||||
|
|
||||||
# 构建压缩后的历史
|
# 累积所有摘要合并为单条 system(而非每次重压旧摘要)
|
||||||
compressed = [{
|
merged = "\n\n".join(self.compressed_blobs)
|
||||||
"role": "system",
|
compressed = [{"role": "system", "content": merged}]
|
||||||
"content": f"[上下文压缩] 以下是之前 {len(to_compress)} 条对话的摘要:\n{summary}"
|
|
||||||
}]
|
|
||||||
compressed.extend(recent)
|
compressed.extend(recent)
|
||||||
return compressed
|
return compressed
|
||||||
|
|
||||||
def _ai_summarize(self, messages: list, engine=None) -> str:
|
def _ai_summarize(self, messages: list, engine=None) -> str:
|
||||||
"""用 AI 生成对话摘要"""
|
"""用当前引擎生成对话摘要(保真优先)"""
|
||||||
if not engine:
|
if not engine:
|
||||||
return self._simple_summarize(messages)
|
return self._simple_summarize(messages)
|
||||||
|
|
||||||
# 构建摘要请求
|
conversation_text = "".join(self._prepare_for_summary(m) for m in 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"
|
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
"请用简洁的中文总结以下对话的关键信息(工具调用、代码修改、重要决策)。"
|
"请用简洁中文总结以下对话的关键信息,要求:\n"
|
||||||
"控制在 200 字以内。\n\n"
|
"1. 按角色(用户/助手)分条,保留每条的要点;\n"
|
||||||
|
"2. 重点保留: 工具调用名称与参数、代码修改点(读/改/创建的文件与位置)、重要决策;\n"
|
||||||
|
"3. 控制总体在 400 字以内,不要编造未提及的信息。\n\n"
|
||||||
f"{conversation_text}"
|
f"{conversation_text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
summary = engine.generate_response(
|
summary = engine.generate_response(
|
||||||
prompt,
|
prompt,
|
||||||
system_prompt="你是摘要生成器。只输出摘要,不要多余内容。"
|
system_prompt="你是摘要生成器。只输出结构化摘要,不要多余内容。"
|
||||||
)
|
)
|
||||||
if summary and len(summary) > 10:
|
if summary and len(summary) > 10:
|
||||||
return summary
|
return summary
|
||||||
@@ -358,23 +418,25 @@ class MemoryManager:
|
|||||||
return self._simple_summarize(messages)
|
return self._simple_summarize(messages)
|
||||||
|
|
||||||
def _simple_summarize(self, messages: list) -> str:
|
def _simple_summarize(self, messages: list) -> str:
|
||||||
"""简单摘要(不依赖 AI)"""
|
"""简单摘要(不依赖 AI,保真优先)"""
|
||||||
topics = []
|
topics = []
|
||||||
tool_calls = []
|
tool_calls = []
|
||||||
|
edits = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
content = msg.get("content", "")
|
content = msg.get("content", "")
|
||||||
role = msg.get("role", "")
|
role = msg.get("role", "")
|
||||||
if role == "user" and isinstance(content, str):
|
if not isinstance(content, str):
|
||||||
|
continue
|
||||||
|
if role == "user":
|
||||||
# 提取用户请求
|
# 提取用户请求
|
||||||
if len(content) < 100:
|
if len(content) < 100:
|
||||||
topics.append(content)
|
topics.append(content)
|
||||||
else:
|
else:
|
||||||
topics.append(content[:50] + "...")
|
topics.append(content[:50] + "...")
|
||||||
if role == "assistant" and isinstance(content, str):
|
if role == "assistant":
|
||||||
# 提取工具调用
|
# 提取工具调用
|
||||||
if '"action": "use_tool"' in content or '"tool":' in content:
|
if '"action": "use_tool"' in content or '"tool":' in content:
|
||||||
try:
|
try:
|
||||||
# 尝试解析工具名
|
|
||||||
for part in content.split('"tool":'):
|
for part in content.split('"tool":'):
|
||||||
if len(part) > 2:
|
if len(part) > 2:
|
||||||
tool_name = part.split('"')[1] if '"' in part else ""
|
tool_name = part.split('"')[1] if '"' in part else ""
|
||||||
@@ -382,12 +444,19 @@ class MemoryManager:
|
|||||||
tool_calls.append(tool_name)
|
tool_calls.append(tool_name)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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 = []
|
summary_parts = []
|
||||||
if topics:
|
if topics:
|
||||||
summary_parts.append(f"讨论了 {len(topics)} 个话题: {'; '.join(topics[:5])}")
|
summary_parts.append(f"讨论了 {len(topics)} 个话题: {'; '.join(topics[:5])}")
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
summary_parts.append(f"使用了工具: {', '.join(tool_calls[:8])}")
|
summary_parts.append(f"使用了工具: {', '.join(tool_calls[:8])}")
|
||||||
|
if edits:
|
||||||
|
summary_parts.append(f"编辑了文件: {', '.join(edits[:8])}")
|
||||||
summary_parts.append(f"共 {len(messages)} 条对话")
|
summary_parts.append(f"共 {len(messages)} 条对话")
|
||||||
|
|
||||||
return "。".join(summary_parts)
|
return "。".join(summary_parts)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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", ""),
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
@@ -18,6 +18,65 @@ MODE_NAMES = {
|
|||||||
MODE_MANUAL: "人工确认",
|
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:
|
class SafetyLayer:
|
||||||
"""统一安全检查层"""
|
"""统一安全检查层"""
|
||||||
|
|
||||||
@@ -26,6 +85,7 @@ class SafetyLayer:
|
|||||||
self.cli = None
|
self.cli = None
|
||||||
self._analysis_cache = {} # 缓存分析结果
|
self._analysis_cache = {} # 缓存分析结果
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._permissions_override = None # set_permissions 注入的显式权限
|
||||||
|
|
||||||
def set_cli(self, cli):
|
def set_cli(self, cli):
|
||||||
self.cli = cli
|
self.cli = cli
|
||||||
@@ -44,6 +104,62 @@ class SafetyLayer:
|
|||||||
self.mode = (self.mode + 1) % 3
|
self.mode = (self.mode + 1) % 3
|
||||||
return self.get_mode_name()
|
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]:
|
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:
|
if self.mode == MODE_UNRESTRICTED:
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -117,3 +117,57 @@ class ToolResult:
|
|||||||
def to_string(self) -> str:
|
def to_string(self) -> str:
|
||||||
"""转为字符串(兼容旧插件 handle() 返回值)"""
|
"""转为字符串(兼容旧插件 handle() 返回值)"""
|
||||||
return str(self)
|
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
@@ -1,6 +1,6 @@
|
|||||||
"""TUI 界面模块 - Textual TUI 实现 (动画增强版 + 多行输入)"""
|
"""TUI 界面模块 - Textual TUI 实现 (动画增强版 + 多行输入)"""
|
||||||
|
|
||||||
from .constants import TEXTUAL_AVAILABLE
|
from .constants import TEXTUAL_AVAILABLE, VERSION
|
||||||
|
|
||||||
if TEXTUAL_AVAILABLE:
|
if TEXTUAL_AVAILABLE:
|
||||||
import re
|
import re
|
||||||
@@ -11,6 +11,8 @@ if TEXTUAL_AVAILABLE:
|
|||||||
from textual.reactive import var
|
from textual.reactive import var
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual import on
|
from textual import on
|
||||||
|
from .vim_keys import VimInputState
|
||||||
|
import os
|
||||||
from rich.syntax import Syntax
|
from rich.syntax import Syntax
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.box import ROUNDED
|
from rich.box import ROUNDED
|
||||||
@@ -197,6 +199,28 @@ if TEXTUAL_AVAILABLE:
|
|||||||
transition: color 300ms in_out_cubic;
|
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 {{
|
.msg-user {{
|
||||||
color: {_Theme.USER};
|
color: {_Theme.USER};
|
||||||
@@ -297,6 +321,97 @@ if TEXTUAL_AVAILABLE:
|
|||||||
background: {_Theme.BG};
|
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:
|
class _TUIBridge:
|
||||||
@@ -317,6 +432,24 @@ if TEXTUAL_AVAILABLE:
|
|||||||
return True
|
return True
|
||||||
return False
|
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
|
self.styles.opacity = 0
|
||||||
|
|
||||||
def on_mount(self):
|
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):
|
class TypingIndicator(Static):
|
||||||
@@ -423,6 +561,125 @@ if TEXTUAL_AVAILABLE:
|
|||||||
return
|
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 主应用
|
# TUI 主应用
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
@@ -440,11 +697,27 @@ if TEXTUAL_AVAILABLE:
|
|||||||
Binding("ctrl+enter", "send_message", "发送", show=False),
|
Binding("ctrl+enter", "send_message", "发送", show=False),
|
||||||
Binding("shift+enter", "newline", "换行", show=False),
|
Binding("shift+enter", "newline", "换行", show=False),
|
||||||
Binding("f1", "toggle_sidebar", "侧栏", show=True),
|
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),
|
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)
|
sidebar_visible = var(True)
|
||||||
is_generating = var(False)
|
is_generating = var(False)
|
||||||
|
plan_mode = var(False)
|
||||||
|
_sidebar_tab: str = "engine" # 当前侧栏标签: engine/files/sessions/status
|
||||||
|
|
||||||
def __init__(self, cli):
|
def __init__(self, cli):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -454,41 +727,80 @@ if TEXTUAL_AVAILABLE:
|
|||||||
self._typing_widget = None
|
self._typing_widget = None
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header(show_clock=True)
|
# ── 自定义顶栏 (opencode 风格: 左项目名 | 中模型 | 右状态) ──
|
||||||
|
yield Static("", id="tui-header")
|
||||||
with Horizontal(id="app-container"):
|
with Horizontal(id="app-container"):
|
||||||
with Vertical(id="main"):
|
with Vertical(id="main"):
|
||||||
yield VerticalScroll(id="chat-scroll")
|
yield VerticalScroll(id="chat-scroll")
|
||||||
with Vertical(id="input-area"):
|
with Vertical(id="input-area"):
|
||||||
with Container(id="input-wrapper"):
|
with Container(id="input-wrapper"):
|
||||||
yield SendTextArea(
|
yield VimSendTextArea(
|
||||||
placeholder="输入消息... (回车发送, Shift+回车换行)",
|
placeholder="输入消息... (回车发送, Shift+回车换行, Esc 进 Vim 普通模式)",
|
||||||
id="user-input",
|
id="user-input",
|
||||||
soft_wrap=True,
|
soft_wrap=True,
|
||||||
tab_behavior="indent",
|
tab_behavior="indent",
|
||||||
)
|
)
|
||||||
yield Static(
|
yield Static(
|
||||||
" 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏",
|
" 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏 | Ctrl+P PLAN",
|
||||||
id="input-hint"
|
id="input-hint"
|
||||||
)
|
)
|
||||||
|
# ── 侧栏:标签页 + 内容区 ──
|
||||||
with Vertical(id="sidebar"):
|
with Vertical(id="sidebar"):
|
||||||
yield Static(" 引擎", classes="sidebar-header")
|
# 标签行
|
||||||
yield Vertical(id="engine-list", classes="sidebar-section")
|
with Horizontal(id="sidebar-tabs"):
|
||||||
yield Rule(line_style="heavy")
|
yield Static(" 引擎 ", classes="sidebar-tab sidebar-tab-active", id="tab-engine")
|
||||||
yield Static(" 工具", classes="sidebar-header")
|
yield Static(" 文件 ", classes="sidebar-tab", id="tab-files")
|
||||||
yield Vertical(id="tool-list", classes="sidebar-section")
|
yield Static(" 会话 ", classes="sidebar-tab", id="tab-sessions")
|
||||||
yield Rule(line_style="heavy")
|
yield Static(" 状态 ", classes="sidebar-tab", id="tab-status")
|
||||||
yield Static(" 状态", classes="sidebar-header")
|
# 内容区(根据当前标签切换显示)
|
||||||
yield Vertical(id="status-info", classes="sidebar-section")
|
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")
|
yield Static(" 就绪 | Ctrl+C 退出", id="status-bar")
|
||||||
|
|
||||||
def on_mount(self):
|
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.query_one("#user-input").focus()
|
||||||
# 延迟显示输入提示(淡入效果)
|
def _show_hint():
|
||||||
self.set_timer(1.5, lambda: self.query_one("#input-hint").add_class("visible"))
|
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):
|
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-dim"),
|
||||||
(" ╔══════════════════════════════════════════════╗", "msg-welcome"),
|
(" ╔══════════════════════════════════════════════╗", "msg-welcome"),
|
||||||
(" ║ ║", "msg-welcome"),
|
(" ║ ║", "msg-welcome"),
|
||||||
(" ║ 小狸 Pro-CLI v5.4.1 ║", "msg-welcome"),
|
(" ║ 小狸 Pro-CLI v8.0.3 ║", "msg-welcome"),
|
||||||
(" ║ 智能编程助手 · 动画增强版 ║", "msg-welcome"),
|
(" ║ 智能编程助手 · 动画增强版 ║", "msg-welcome"),
|
||||||
(" ║ ║", "msg-welcome"),
|
(" ║ ║", "msg-welcome"),
|
||||||
(" ╚══════════════════════════════════════════════╝", "msg-welcome"),
|
(" ╚══════════════════════════════════════════════╝", "msg-welcome"),
|
||||||
@@ -559,35 +871,197 @@ if TEXTUAL_AVAILABLE:
|
|||||||
# ── 侧栏更新 ──
|
# ── 侧栏更新 ──
|
||||||
|
|
||||||
def _update_sidebar(self):
|
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()
|
current = self.bridge.current_engine()
|
||||||
for name in self.bridge.engines():
|
for name in self.bridge.engines():
|
||||||
if name == current:
|
if name == current:
|
||||||
engine_container.mount(Static(f" ▸ {name}", classes="engine-item-active"))
|
engine_panel.mount(Static(f" ▸ {name}", classes="engine-item-active"))
|
||||||
else:
|
else:
|
||||||
engine_container.mount(Static(f" {name}", classes="engine-item"))
|
engine_panel.mount(Static(f" {name}", classes="engine-item"))
|
||||||
|
# 工具列表也放引擎面板下方
|
||||||
tool_container = self.query_one("#tool-list")
|
engine_panel.mount(Static(" 工具", classes="panel-title"))
|
||||||
tool_container.remove_children()
|
for tool in self.bridge.tools()[:12]:
|
||||||
for tool in self.bridge.tools():
|
|
||||||
name = tool.get('name', '?')
|
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()
|
self._update_file_tree()
|
||||||
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"))
|
|
||||||
|
|
||||||
# 工具数
|
# ── 会话面板 ──
|
||||||
if hasattr(self.cli, 'liugin_manager'):
|
self._update_session_list()
|
||||||
status_container.mount(Static(f" 工具: {len(self.cli.liugin_manager.tools)} 个", classes="tool-item"))
|
|
||||||
|
# ── 状态面板 ──
|
||||||
|
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):
|
def _update_status(self, text: str):
|
||||||
bar = self.query_one("#status-bar")
|
bar = self.query_one("#status-bar")
|
||||||
engine = self.bridge.current_engine()
|
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(),
|
'cls': lambda: self.action_clear(),
|
||||||
'model': lambda: self._switch_model(args),
|
'model': lambda: self._switch_model(args),
|
||||||
'engine': 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(),
|
'status': lambda: self._show_status(),
|
||||||
'tools': lambda: self._show_tools(),
|
'tools': lambda: self._show_tools(),
|
||||||
'engines': lambda: self._show_engines(),
|
'engines': lambda: self._show_engines(),
|
||||||
'tui': lambda: self._system("已在 TUI 模式中"),
|
'tui': lambda: self._system("已在 TUI 模式中"),
|
||||||
'manual': lambda: self._handle_manual(args),
|
'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)
|
handler = cmds.get(name)
|
||||||
if handler:
|
if handler:
|
||||||
handler()
|
handler()
|
||||||
else:
|
return
|
||||||
if name in self.cli.liugin_commands:
|
if name in self.cli.liugin_commands:
|
||||||
try:
|
try:
|
||||||
result = self.cli.liugin_commands[name](args)
|
result = self.cli.liugin_commands[name](args)
|
||||||
if result:
|
if result:
|
||||||
self._system(result[:500])
|
self._system(result[:500])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._error(f"插件命令失败: {e}")
|
self._error(f"插件命令失败: {e}")
|
||||||
else:
|
return
|
||||||
self._error(f"未知命令: /{name},输入 /help 查看帮助")
|
# 未知命令转发给 cli 完整路由(/agent /providers /chat 等)
|
||||||
|
self._forward_to_cli(cmd)
|
||||||
|
|
||||||
def _show_help(self):
|
def _show_help(self):
|
||||||
help_text = """ 命令:
|
help_text = """ 命令:
|
||||||
@@ -775,12 +1258,21 @@ if TEXTUAL_AVAILABLE:
|
|||||||
/tools 列出工具
|
/tools 列出工具
|
||||||
/status 系统状态
|
/status 系统状态
|
||||||
/clear 清屏
|
/clear 清屏
|
||||||
|
/vim 开关 Vim 键位
|
||||||
|
|
||||||
⌨ 快捷键:
|
⌨ 快捷键:
|
||||||
Ctrl+C 退出 Ctrl+L 清屏
|
Ctrl+C 退出 Ctrl+L 清屏
|
||||||
Ctrl+N 新对话 回车 发送消息
|
Ctrl+N 新对话 回车 发送消息
|
||||||
Shift+回车 换行 F1 侧栏
|
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)
|
self._system(help_text)
|
||||||
|
|
||||||
def _handle_manual(self, args):
|
def _handle_manual(self, args):
|
||||||
@@ -845,6 +1337,84 @@ if TEXTUAL_AVAILABLE:
|
|||||||
|
|
||||||
self._system('\n'.join(status_lines))
|
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 生成 ──
|
# ── AI 生成 ──
|
||||||
|
|
||||||
async def _generate(self, user_input):
|
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_thinking)
|
||||||
self.call_after_refresh(self._remove_typing_indicator)
|
self.call_after_refresh(self._remove_typing_indicator)
|
||||||
self.is_generating = False
|
self.is_generating = False
|
||||||
|
self.call_after_refresh(self._sync_plan)
|
||||||
self.call_after_refresh(lambda: self._update_status("就绪"))
|
self.call_after_refresh(lambda: self._update_status("就绪"))
|
||||||
self.call_after_refresh(self._update_sidebar)
|
self.call_after_refresh(self._update_sidebar)
|
||||||
|
self.call_after_refresh(self._update_header)
|
||||||
|
|
||||||
def _write_raw(self, msg):
|
def _write_raw(self, msg):
|
||||||
if '✅' in msg or 'OK 工具' in msg:
|
if '✅' in msg or 'OK 工具' in msg:
|
||||||
@@ -911,6 +1483,10 @@ if TEXTUAL_AVAILABLE:
|
|||||||
sidebar.add_class("hidden")
|
sidebar.add_class("hidden")
|
||||||
self.sidebar_visible = False
|
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):
|
def action_cancel(self):
|
||||||
if self.is_generating:
|
if self.is_generating:
|
||||||
self._remove_thinking()
|
self._remove_thinking()
|
||||||
@@ -918,6 +1494,107 @@ if TEXTUAL_AVAILABLE:
|
|||||||
self._system("已取消")
|
self._system("已取消")
|
||||||
self.is_generating = False
|
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:
|
else:
|
||||||
# Textual 不可用时的占位类
|
# Textual 不可用时的占位类
|
||||||
class XiaoliTUI:
|
class XiaoliTUI:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user