Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
*.bak
|
||||
|
||||
# v8.0 TUI 快照产物(运行时生成,供 AI 自验/预览)
|
||||
.tui_snapshots/
|
||||
|
||||
# FC 能力探测缓存(运行时按 base_url+model 生成,机器相关)
|
||||
.fc_support.json
|
||||
|
||||
# pytest 缓存
|
||||
.pytest_cache/
|
||||
@@ -4,6 +4,49 @@
|
||||
|
||||
---
|
||||
|
||||
## v8.0.2 (2026-08-07) — 小修复版本
|
||||
|
||||
### 🐛 Bug 修复
|
||||
|
||||
- **加载动画在非 TTY 环境不再污染输出流**(提交 `c81914a`)
|
||||
- `_generate_response_with_animation` 的 `show_animation` 用 `print("\r...")` 逐帧(约 10 帧/秒)输出 `love.txt` 情话 + spinner,仅真终端能原地覆盖;被聊天前端 / WebSocket 桥 / 管道 / 日志镜像捕获时每帧变成一行垃圾混进对话显示
|
||||
- 加 `sys.stdout.isatty()` 守卫:非 TTY 只输出单行提示、不跑逐帧 `\r` 动画
|
||||
- TUI 模式(`tui_output_callback`)本就跳过动画,不受影响
|
||||
- **非 TTY 环境保留情话「人味」**(提交 `1c52482`)
|
||||
- 上一版修复导致聊天界面完全看不到情话,改为:非 TTY 在请求开始输出一句随机情话(`💭` 前缀,只打一次、零残帧)
|
||||
- `love.txt` 读取时过滤 `http/https` 行,避免把反战标语 / 网址当情话甩出
|
||||
- 真 TTY 逐帧情话 + spinner 动画、TUI 模式均不受影响
|
||||
- **修复文件管理器跨平台路径串台**(本次提交)
|
||||
- 模型在 Windows 主机上常给出 Linux/Mac 风格绝对路径(如 `/home/user/Desktop`、`/Users/<谁>/Desktop`、`/root/Desktop`),`os.path.abspath` 在 Windows 下会错误地拼成 `C:\home\user\Desktop` 并报「路径不存在」
|
||||
- 新增 `_resolve_cross_platform_path`:Windows 下把 `/home/<用户>/…`、`/Users/<用户>/…`、`/root/…` 统一映射到当前用户家目录对应位置(`~/Desktop` 等);Linux/Mac 下这些路径本身合法,原样保留
|
||||
- `file_manager` 全部 10 个操作(list/read/write/append/copy/move/delete/search/info/mkdir)的路径解析均改用该归一化函数
|
||||
|
||||
### 🛠 工程
|
||||
|
||||
- 版本号统一更新至 v8.0.2(含 README 中英 badge、`about.txt`、`xcli_core/constants.py` 中央 `VERSION`、TUI banner、LSP/MCP `clientInfo.version`)
|
||||
|
||||
---
|
||||
|
||||
## v8.0.1 (2026-08-07) — Release
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
- **Operit 市场插件转译层全面落地**
|
||||
- `plugins/operit_loader.py` 增强:`OkHttp.newClient()`、`OkHttpRequest.execute()` 改为异步、`OkHttpResponse` 新增 `json()` 与 `statusMessage()`(基于 `http.HTTPStatus`)
|
||||
- `plugins/operit_translate.py` 新增:Operit `.ts` 插件 → xiaoli-cli `.py` 脚手架生成器(自动提取 METADATA、生成 `exports` 胶水,原 TS 作参考注释保留)
|
||||
- 接入 5 个已全链路验证的 Operit 市场插件:
|
||||
- `market_time` — 时间查询 / 格式化
|
||||
- `market_duckduckgo` — 网页内容抓取与清洗
|
||||
- `market_crossref` — Crossref 学术文献检索
|
||||
- `market_network_test` — HTTP 连通性测试
|
||||
- `market_reader` — 本地代码递归搜索
|
||||
|
||||
### 🛠 工程
|
||||
|
||||
- 版本号统一更新至 v8.0.1(含 README 中英 badge、`about.txt`、`xcli_core/constants.py` 中央 `VERSION`、TUI banner、LSP/MCP `clientInfo.version`)
|
||||
|
||||
---
|
||||
|
||||
## v5.4.0 (2026-05-09) — Release
|
||||
|
||||
### ✨ 新增功能
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 shuiyu1123
|
||||
Copyright (c) 2026 shuiyu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-5.4.1-blue" alt="version">
|
||||
<img src="https://img.shields.io/badge/version-8.0.2-blue" alt="version">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-green" alt="python">
|
||||
<img src="https://img.shields.io/badge/license-MIT-orange" alt="license">
|
||||
<img src="https://img.shields.io/badge/tests-113%20passed-brightgreen" alt="tests">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-5.4.1-blue" alt="version">
|
||||
<img src="https://img.shields.io/badge/version-8.0.2-blue" alt="version">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-green" alt="python">
|
||||
<img src="https://img.shields.io/badge/license-MIT-orange" alt="license">
|
||||
<img src="https://img.shields.io/badge/tests-150+%20passed-brightgreen" alt="tests">
|
||||
@@ -22,6 +22,12 @@
|
||||
|
||||
小狸 Pro-CLI 是一款基于 Python 的智能编程助手,集成了代码编辑、版本控制、浏览器自动化、图像生成、记忆系统等功能,支持多种 AI 引擎,帮助开发者提升工作效率。
|
||||
|
||||
## v8.0 新特性
|
||||
|
||||
- **Rich 面板式 TUI(默认)** — `rich` 驱动的可视化对话面板;自带**快照出口**(导出 text/svg/html),AI 开发界面时可把渲染结果读回自验,补上"看不见渲染"的反馈闭环。输入 `/tui` 进入,`/snapshot` 随时导出当前界面。
|
||||
- **Token 感知压缩** — 自动读取 OpenAI 格式返回的 `usage.prompt_tokens`,当 `已用 token / 模型上下文窗口 ≥ 0.7`(可在 `config.json` 设 `system.compress_ratio` / `system.context_window` 覆盖)即触发上下文压缩,消息数阈值作为兜底,避免超窗截断。
|
||||
- **记忆模式** — 持久化 `MEMORY.md` + 每日日记 + 自动保存聊天记录 + 关键词检索;新增 `/memory mode <companion|work>` 切换与 `/compress` 手动压缩。
|
||||
|
||||
## 特性
|
||||
|
||||
- **双 AI 引擎** — Ollama 本地模型 + OpenAI 兼容格式(DeepSeek、Grok、硅基流动等)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
**产品标识**
|
||||
- **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI)
|
||||
- **内部版本号**:20260428
|
||||
- **发布版本**:发行版5.1.3
|
||||
- **内部版本号**:20260801
|
||||
- **发布版本**:发行版8.0.2
|
||||
|
||||
**技术规格**
|
||||
- **主要语言**:简体中文(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)
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
try:
|
||||
from xcli_core.tool_result import normalize_tool_text
|
||||
except Exception:
|
||||
def normalize_tool_text(result, default="无结果"):
|
||||
"""兜底归一化:ToolResult / dict / str 统一成字符串"""
|
||||
if result is None:
|
||||
return default
|
||||
if isinstance(result, dict):
|
||||
return str(result.get("result", default))
|
||||
return str(result)
|
||||
|
||||
# 通信用的临时目录
|
||||
COMM_DIR = os.path.join(tempfile.gettempdir(), "xiaoli_manual_engine")
|
||||
|
||||
@@ -33,6 +44,8 @@ class ManualAI:
|
||||
self.conversation_history = []
|
||||
self.max_history = 10
|
||||
self._subprocess = None
|
||||
# 最近一次请求的 prompt token 数(手动引擎无真实模型,恒为 None)
|
||||
self.last_prompt_tokens = None
|
||||
self._session_id = str(int(time.time()))
|
||||
self._started = False
|
||||
|
||||
@@ -229,6 +242,11 @@ while True:
|
||||
time.sleep(0.3)
|
||||
'''
|
||||
|
||||
@property
|
||||
def max_input_tokens(self):
|
||||
"""当前引擎支持的最大输入 token 数(统一 API)。手动引擎无真实模型,给名义上限。"""
|
||||
return 128000
|
||||
|
||||
def generate_response(self, user_input, tool_results=None, system_prompt=None):
|
||||
"""生成响应 — 转发给另一个终端的人工"""
|
||||
paths = self._get_paths()
|
||||
@@ -246,7 +264,7 @@ while True:
|
||||
# 把用户消息写入文件
|
||||
msg = user_input
|
||||
if tool_results:
|
||||
msg = f"[工具结果] {tool_results.get('result', '无结果')}\n\n{user_input}"
|
||||
msg = f"[工具结果] {normalize_tool_text(tool_results)}\n\n{user_input}"
|
||||
|
||||
to_human = paths["msg_to_human"]
|
||||
with open(to_human, 'w', encoding='utf-8') as f:
|
||||
|
||||
+149
-32
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
@@ -6,21 +7,57 @@ import threading
|
||||
import time
|
||||
from colorama import Fore, Style
|
||||
|
||||
# 延迟导入 ollama,避免程序启动时卡住
|
||||
# 如果导入失败,ollama_client 将为 None
|
||||
try:
|
||||
import ollama
|
||||
ollama_client = ollama
|
||||
except Exception as e:
|
||||
print(f"{Fore.YELLOW}警告: 无法导入 ollama 库: {e}{Style.RESET_ALL}")
|
||||
print(f"{Fore.YELLOW}请确保已安装 ollama 包 (pip install ollama){Style.RESET_ALL}")
|
||||
ollama_client = None
|
||||
# 真正的惰性导入:只有真正用到 ollama 引擎时才 import ollama,
|
||||
# 避免 xiaoli-cli 启动 / 加载所有引擎时因 ollama SDK 初始化
|
||||
# (其内部 platform.machine() -> WMI 查询)在某些环境下卡住。
|
||||
_ollama_client = None
|
||||
_ollama_import_tried = False
|
||||
|
||||
|
||||
def _ollama():
|
||||
"""惰性获取 ollama SDK 客户端;导入失败返回 None(仅尝试一次)。"""
|
||||
global _ollama_client, _ollama_import_tried
|
||||
if _ollama_import_tried:
|
||||
return _ollama_client
|
||||
_ollama_import_tried = True
|
||||
try:
|
||||
import ollama
|
||||
_ollama_client = ollama
|
||||
except Exception as e:
|
||||
_ollama_client = None
|
||||
print(f"{Fore.YELLOW}警告: 无法导入 ollama 库: {e}{Style.RESET_ALL}")
|
||||
print(f"{Fore.YELLOW}请确保已安装 ollama 包 (pip install ollama){Style.RESET_ALL}")
|
||||
return _ollama_client
|
||||
|
||||
# 添加项目根目录到sys.path
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(current_dir)
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
try:
|
||||
from xcli_core.verbose import vprint as _vprint
|
||||
except Exception:
|
||||
def _vprint(*args, **kwargs):
|
||||
pass
|
||||
|
||||
# <input> 特殊语法解析(配置模型时可交互式询问用户)
|
||||
try:
|
||||
from xcli_core.config import resolve_input_value
|
||||
except Exception:
|
||||
def resolve_input_value(value, field_name="", default_prompt=None):
|
||||
return value
|
||||
|
||||
try:
|
||||
from xcli_core.tool_result import normalize_tool_text
|
||||
except Exception:
|
||||
def normalize_tool_text(result, default="无结果"):
|
||||
"""兜底归一化:ToolResult / dict / str 统一成字符串"""
|
||||
if result is None:
|
||||
return default
|
||||
if isinstance(result, dict):
|
||||
return str(result.get("result", default))
|
||||
return str(result)
|
||||
|
||||
|
||||
class OllamaAI:
|
||||
"""基于Ollama的本地AI引擎"""
|
||||
@@ -62,6 +99,9 @@ Ollama AI引擎插件帮助信息
|
||||
self.conversation_history = []
|
||||
|
||||
# 直接从 config.json 读取配置
|
||||
# 模型最大 token 大小(单位 K,如 32 = 32K = 32768 token)
|
||||
# 支持 <input> 语法:配置里写 <input:提示语> 会在配置模型时询问用户
|
||||
max_token_k = "<input:请输入模型最大token大小(K)>"
|
||||
config_file = os.path.join(project_root, "config.json")
|
||||
try:
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
@@ -71,42 +111,86 @@ Ollama AI引擎插件帮助信息
|
||||
self.base_url = engine_config.get("base_url", "")
|
||||
self.model = engine_config.get("model", "")
|
||||
self.max_history = config.get("system", {}).get("max_history", 10)
|
||||
max_token_k = engine_config.get("max_token_k", max_token_k)
|
||||
except Exception as e:
|
||||
# 如果读取失败,使用空值
|
||||
self.api_key = ""
|
||||
self.base_url = ""
|
||||
self.model = ""
|
||||
self.max_history = 10
|
||||
|
||||
# 解析 <input> 语法(询问用户)并换算成 num_ctx(token 数)
|
||||
resolved_k = resolve_input_value(max_token_k, field_name="模型最大token大小(K)")
|
||||
self.num_ctx = self._parse_num_ctx(resolved_k)
|
||||
|
||||
# 共享对话历史引用(由CLI设置)
|
||||
self.shared_conversation_history = None
|
||||
|
||||
# 最近一次请求的 prompt token 数(供 token 感知压缩使用)
|
||||
self.last_prompt_tokens = None
|
||||
|
||||
# 直接启用思考标记,由主程序自动处理深度思考模型的响应
|
||||
self.thinking_start_marker = "<thinking>"
|
||||
self.thinking_end_marker = "</thinking>"
|
||||
|
||||
# 检测Ollama服务是否运行
|
||||
self.is_service_running = self.check_ollama_service()
|
||||
|
||||
if self.is_service_running:
|
||||
print(f"{Fore.GREEN}Ollama服务检测成功,引擎已启用{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}")
|
||||
# 惰性服务检测:不在构造时急切点服务(避免无 ollama / 沙箱环境下
|
||||
# import ollama 卡顿)。首次真正使用时(generate_response 等)才检测并缓存。
|
||||
self._service_checked = False
|
||||
self._service_running = False
|
||||
|
||||
@staticmethod
|
||||
def _parse_num_ctx(value):
|
||||
"""把用户输入的 K 值换算成 token 数:32 / 32K → 32768。非法/空 → 4096"""
|
||||
if value is None:
|
||||
return 4096
|
||||
s = str(value).strip().lower()
|
||||
digits = re.sub(r"[^\d.]", "", s)
|
||||
if not digits:
|
||||
return 4096
|
||||
try:
|
||||
k = float(digits)
|
||||
except ValueError:
|
||||
return 4096
|
||||
if k <= 0:
|
||||
return 4096
|
||||
return max(1024, int(k * 1024))
|
||||
|
||||
def check_ollama_service(self):
|
||||
"""检测Ollama服务是否运行"""
|
||||
# 检查 ollama 库是否可用
|
||||
if ollama_client is None:
|
||||
if _ollama() is None:
|
||||
return False
|
||||
try:
|
||||
# 尝试调用Ollama API获取版本信息
|
||||
response = ollama_client.list()
|
||||
response = _ollama().list()
|
||||
# 如果没有异常,说明服务运行正常
|
||||
return True
|
||||
except Exception as e:
|
||||
# 如果出现异常,说明服务未运行
|
||||
return False
|
||||
|
||||
|
||||
@property
|
||||
def max_input_tokens(self):
|
||||
"""当前引擎支持的最大输入 token 数(统一 API,供上下文压缩使用)。
|
||||
本地 Ollama 由配置 max_token_k 换算的 num_ctx 决定。"""
|
||||
return self.num_ctx
|
||||
|
||||
@property
|
||||
def is_service_running(self):
|
||||
"""惰性检测 Ollama 服务是否运行:首次访问时才 import ollama 并点服务。
|
||||
|
||||
这样 AICLI() 构造 / 加载所有引擎时不会因 import ollama 卡住
|
||||
(沙箱或 ollama 未安装的环境也能正常启动 CLI,仅真正选用本引擎时才校验)。
|
||||
"""
|
||||
if not self._service_checked:
|
||||
self._service_running = self.check_ollama_service()
|
||||
self._service_checked = True
|
||||
if self._service_running:
|
||||
_vprint(f"{Fore.GREEN}Ollama服务检测成功,引擎已启用{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}")
|
||||
return self._service_running
|
||||
|
||||
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
|
||||
"""
|
||||
生成AI响应 - 使用Ollama API
|
||||
@@ -131,27 +215,46 @@ Ollama AI引擎插件帮助信息
|
||||
|
||||
# 如果有工具结果,将其作为上下文返回给AI处理
|
||||
if tool_results:
|
||||
tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}"
|
||||
tool_result_text = f"工具执行结果: {normalize_tool_text(tool_results)}"
|
||||
messages = self._build_messages_with_history(system_prompt, tool_result_text)
|
||||
else:
|
||||
messages = self._build_messages_with_history(system_prompt, user_input)
|
||||
|
||||
# 构建调用参数
|
||||
# 构建调用参数(num_ctx: 模型上下文窗口大小,由配置的 max_token_k 决定)
|
||||
kwargs = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": False
|
||||
"stream": False,
|
||||
"options": {"num_ctx": self.num_ctx}
|
||||
}
|
||||
|
||||
# ── Function Calling: 注入工具定义 ──
|
||||
if tools:
|
||||
# ── Function Calling: 注入工具定义(仅引擎支持 FC 时下发)──
|
||||
fc_enabled = bool(tools) and self._fc_supported()
|
||||
if fc_enabled:
|
||||
kwargs["tools"] = tools
|
||||
|
||||
# 使用Ollama Python库调用API
|
||||
response = ollama_client.chat(**kwargs)
|
||||
# 使用Ollama Python库调用API(FC 不支持时降级为无工具重试一次)
|
||||
try:
|
||||
response = _ollama().chat(**kwargs)
|
||||
except Exception as _fc_err:
|
||||
if fc_enabled and "tool" in str(_fc_err).lower():
|
||||
self._mark_fc_unsupported()
|
||||
kwargs.pop("tools", None)
|
||||
response = _ollama().chat(**kwargs)
|
||||
else:
|
||||
raise
|
||||
|
||||
# ── Function Calling: 检查 tool_calls ──
|
||||
message = response.get("message", {})
|
||||
# 上报本轮 prompt token 数(Ollama 返回 prompt_eval_count),供 token 感知压缩
|
||||
try:
|
||||
if isinstance(response, dict):
|
||||
_pet = response.get("prompt_eval_count")
|
||||
else:
|
||||
_pet = getattr(response, "prompt_eval_count", None)
|
||||
self.last_prompt_tokens = int(_pet) if _pet else None
|
||||
except Exception:
|
||||
self.last_prompt_tokens = None
|
||||
tool_calls_raw = message.get("tool_calls")
|
||||
if tool_calls_raw:
|
||||
# Ollama 的 tool_calls 格式: [{"function": {"name": ..., "arguments": {...}}}]
|
||||
@@ -197,6 +300,22 @@ Ollama AI引擎插件帮助信息
|
||||
except Exception as e:
|
||||
return f"Ollama API调用失败: {str(e)}"
|
||||
|
||||
def _fc_supported(self):
|
||||
"""该 (base_url, model) 是否支持 FC。未探测过时默认 True(乐观,先试一次)"""
|
||||
try:
|
||||
from xcli_core.fc_tools import supports_fc
|
||||
return supports_fc(self.base_url, self.model)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _mark_fc_unsupported(self):
|
||||
"""标记该 (base_url, model) 不支持 FC 并持久化"""
|
||||
try:
|
||||
from xcli_core.fc_tools import mark_fc_unsupported
|
||||
mark_fc_unsupported(self.base_url, self.model)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _build_messages_with_history(self, system_prompt, user_input):
|
||||
"""构建包含历史记录的消息列表(支持 FC tool 消息)"""
|
||||
messages = []
|
||||
@@ -217,13 +336,11 @@ Ollama AI引擎插件帮助信息
|
||||
|
||||
role = msg["role"]
|
||||
|
||||
# tool 角色消息(FC 工具结果)
|
||||
# tool 角色消息(FC 工具结果)—— 官方格式不含 name 字段
|
||||
if role == "tool":
|
||||
tool_msg = {"role": "tool", "content": msg.get("content", "")}
|
||||
if "tool_call_id" in msg:
|
||||
tool_msg["tool_call_id"] = msg["tool_call_id"]
|
||||
if "name" in msg:
|
||||
tool_msg["name"] = msg["name"]
|
||||
messages.append(tool_msg)
|
||||
continue
|
||||
|
||||
@@ -285,7 +402,7 @@ Ollama AI引擎插件帮助信息
|
||||
})
|
||||
|
||||
# 使用Ollama Python库调用API
|
||||
response = ollama_client.chat(
|
||||
response = _ollama().chat(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
stream=False
|
||||
@@ -308,7 +425,7 @@ Ollama AI引擎插件帮助信息
|
||||
return ["错误:Ollama服务未运行"]
|
||||
|
||||
try:
|
||||
response = ollama_client.list()
|
||||
response = _ollama().list()
|
||||
models = [model["name"] for model in response["models"]]
|
||||
return models
|
||||
except Exception as e:
|
||||
@@ -388,7 +505,7 @@ Ollama AI引擎插件帮助信息
|
||||
print(f"{Fore.YELLOW}正在拉取模型 {model_name}...{Style.RESET_ALL}")
|
||||
|
||||
# 使用Ollama Python库拉取模型
|
||||
response = ollama_client.pull(model_name, stream=True)
|
||||
response = _ollama().pull(model_name, stream=True)
|
||||
|
||||
# 逐块处理响应
|
||||
for part in response:
|
||||
|
||||
+260
-15
@@ -13,6 +13,56 @@ import logging
|
||||
# 设置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from xcli_core.config import get_system_config
|
||||
except Exception:
|
||||
def get_system_config(key, default=None):
|
||||
return default
|
||||
|
||||
try:
|
||||
from xcli_core import model_registry
|
||||
except Exception:
|
||||
model_registry = None
|
||||
|
||||
try:
|
||||
from xcli_core.verbose import vprint
|
||||
except Exception:
|
||||
def vprint(*args, **kwargs):
|
||||
pass
|
||||
|
||||
try:
|
||||
from xcli_core.tool_result import normalize_tool_text
|
||||
except Exception:
|
||||
def normalize_tool_text(result, default="无结果"):
|
||||
"""兜底归一化:ToolResult / dict / str 统一成字符串"""
|
||||
if result is None:
|
||||
return default
|
||||
if isinstance(result, dict):
|
||||
return str(result.get("result", default))
|
||||
return str(result)
|
||||
|
||||
# ── 常见模型的上下文窗口(token 数)──
|
||||
# OpenAI /chat/completions 协议本身不返回模型窗口,需本地维护。
|
||||
MODEL_CONTEXT_WINDOWS = {
|
||||
# 讯飞星火(Spark,经 one-api/new-api 转发时模型名常为 spark-* / general*)
|
||||
"spark-4.0": 128000, "spark-max": 8192, "spark-pro": 8192, "spark-lite": 8192,
|
||||
"spark": 8192, "generalv3.5": 8192, "generalv3": 8192, "general": 4096,
|
||||
"gpt-4o": 128000, "gpt-4o-mini": 128000, "gpt-4": 8192, "gpt-4-turbo": 128000,
|
||||
"gpt-3.5-turbo": 16385,
|
||||
"deepseek-chat": 64000, "deepseek-reasoner": 64000,
|
||||
"qwen2.5": 32768, "qwen2": 32768, "qwen-max": 32768, "qwen-plus": 32768,
|
||||
"llama3": 8192, "llama3.1": 128000, "llama3.2": 128000, "llama2": 4096,
|
||||
"claude-3": 200000, "claude-3.5": 200000, "claude-3.7": 200000, "claude": 200000,
|
||||
"grok": 131072, "grok-2": 131072, "grok-3": 131072,
|
||||
"glm-4": 128000, "glm-4v": 128000,
|
||||
"mistral": 32768, "mixtral": 32768, "mistral-large": 128000,
|
||||
"gemini": 1000000, "gemini-1.5": 1000000, "gemini-2.0": 1000000, "gemini-2.5": 1000000,
|
||||
"yi": 200000, "yi-large": 200000,
|
||||
"kimi": 200000, "moonshot": 200000,
|
||||
"abab": 200000, "minimax": 200000,
|
||||
"baichuan": 192000, "chatglm": 32768,
|
||||
}
|
||||
|
||||
# 添加项目根目录到sys.path
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(current_dir)
|
||||
@@ -95,6 +145,12 @@ OpenAI 兼容格式引擎帮助信息
|
||||
self.conversation_history = []
|
||||
self.shared_conversation_history = None
|
||||
|
||||
# 应用注册表中的当前模型(覆盖扁平默认配置,向后兼容)
|
||||
self._apply_registry_model()
|
||||
|
||||
# 最近一次请求的 token 用量(供 token 感知压缩使用)
|
||||
self.last_prompt_tokens = None
|
||||
|
||||
# 思考标记(兼容深度思考模型)
|
||||
self.thinking_start_marker = "<think>"
|
||||
self.thinking_end_marker = "</think>"
|
||||
@@ -105,7 +161,64 @@ OpenAI 兼容格式引擎帮助信息
|
||||
if not self.base_url:
|
||||
print(f"{Fore.YELLOW}警告: openai 引擎的 base_url 未设置{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.GREEN}OpenAI 兼容引擎已启用 (base_url: {self.base_url}){Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}OpenAI 兼容引擎已启用 (base_url: {self.base_url}){Style.RESET_ALL}")
|
||||
|
||||
# ── 模型注册表(多模型在线切换)──
|
||||
|
||||
def _apply_registry_model(self):
|
||||
"""若注册表存在 current 模型,用它覆盖 __init__ 读到的扁平默认配置。"""
|
||||
if not model_registry:
|
||||
return
|
||||
try:
|
||||
m = model_registry.get_model()
|
||||
if m:
|
||||
self.api_key = m.get("api_key", self.api_key)
|
||||
self.base_url = (m.get("base_url") or self.base_url).rstrip('/')
|
||||
self.model = m.get("model", self.model)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def list_registry_models(self):
|
||||
"""返回 (models, current_name),供 CLI 列出已配置模型。"""
|
||||
if not model_registry:
|
||||
return [], None
|
||||
return model_registry.list_models()
|
||||
|
||||
def current_model_name(self):
|
||||
"""当前激活模型在注册表中的名称(兼容单槽位时返回 model 值)。"""
|
||||
if not model_registry:
|
||||
return self.model
|
||||
try:
|
||||
models, current = model_registry.list_models()
|
||||
return current or self.model
|
||||
except Exception:
|
||||
return self.model
|
||||
|
||||
def set_registry_model(self, name):
|
||||
"""切换到注册表中指定模型(含 base_url/api_key/model)并持久化 current。
|
||||
成功返回 True。"""
|
||||
if not model_registry:
|
||||
return False
|
||||
m = model_registry.get_model(name)
|
||||
if not m:
|
||||
return False
|
||||
self.api_key = m.get("api_key", "")
|
||||
self.base_url = (m.get("base_url") or "").rstrip('/')
|
||||
self.model = m.get("model", "")
|
||||
model_registry.set_current(name)
|
||||
return True
|
||||
|
||||
def add_model(self, entry):
|
||||
"""新增/更新模型并持久化。entry: {name, base_url, api_key, model}。"""
|
||||
if not model_registry:
|
||||
return None
|
||||
return model_registry.add_model(entry)
|
||||
|
||||
def remove_model(self, name):
|
||||
"""删除模型并持久化。成功返回 True。"""
|
||||
if not model_registry:
|
||||
return False
|
||||
return model_registry.remove_model(name)
|
||||
|
||||
def _get_headers(self):
|
||||
"""构建请求头"""
|
||||
@@ -125,6 +238,26 @@ OpenAI 兼容格式引擎帮助信息
|
||||
url = url.rstrip('/') + '/chat/completions'
|
||||
return url
|
||||
|
||||
# ── FC 能力探测缓存 ──
|
||||
|
||||
def _fc_supported(self):
|
||||
"""当前 (base_url, model) 是否支持 function calling(未探测过默认支持)"""
|
||||
try:
|
||||
from xcli_core.fc_tools import supports_fc
|
||||
return supports_fc(self.base_url, self.model)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def _mark_fc_unsupported(self):
|
||||
"""降级成功后记住该模型不支持 FC,后续请求不再带 tools"""
|
||||
try:
|
||||
from xcli_core.fc_tools import mark_fc_unsupported
|
||||
if mark_fc_unsupported(self.base_url, self.model):
|
||||
print(f"{Fore.CYAN}已记住 {self.model} 不支持 FC,后续请求将跳过工具字段"
|
||||
f"(/fc reset 可清除){Style.RESET_ALL}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
|
||||
"""
|
||||
生成 AI 响应
|
||||
@@ -141,7 +274,7 @@ OpenAI 兼容格式引擎帮助信息
|
||||
|
||||
# 处理工具结果
|
||||
if tool_results:
|
||||
tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}"
|
||||
tool_result_text = f"工具执行结果: {normalize_tool_text(tool_results)}"
|
||||
messages = self._build_messages_with_history(system_prompt, tool_result_text)
|
||||
else:
|
||||
messages = self._build_messages_with_history(system_prompt, user_input)
|
||||
@@ -153,10 +286,20 @@ OpenAI 兼容格式引擎帮助信息
|
||||
"stream": False
|
||||
}
|
||||
|
||||
# ── Function Calling: 注入工具定义 ──
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
body["tool_choice"] = "auto"
|
||||
# ── Function Calling: 注入工具定义(统一纯官方格式)──
|
||||
# 已探测过不支持 FC 的模型直接跳过 tools,省掉每次白撞一次 500
|
||||
if tools and self._fc_supported():
|
||||
pure_tools = []
|
||||
for t in tools:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
if t.get("type") == "function" and isinstance(t.get("function"), dict):
|
||||
pure_tools.append(t)
|
||||
elif isinstance(t.get("function"), dict):
|
||||
pure_tools.append({"type": "function", "function": t["function"]})
|
||||
if pure_tools:
|
||||
body["tools"] = pure_tools
|
||||
body["tool_choice"] = "auto"
|
||||
|
||||
# 发送请求
|
||||
api_url = self._get_api_url()
|
||||
@@ -169,6 +312,13 @@ OpenAI 兼容格式引擎帮助信息
|
||||
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
# ── 记录 token 用量(供 token 感知压缩)──
|
||||
usage = response_data.get("usage", {})
|
||||
if isinstance(usage, dict) and usage.get("prompt_tokens"):
|
||||
try:
|
||||
self.last_prompt_tokens = int(usage["prompt_tokens"])
|
||||
except (TypeError, ValueError):
|
||||
self.last_prompt_tokens = None
|
||||
if "choices" in response_data and len(response_data["choices"]) > 0:
|
||||
message = response_data["choices"][0].get("message", {})
|
||||
|
||||
@@ -202,19 +352,41 @@ OpenAI 兼容格式引擎帮助信息
|
||||
return f"API 端点不存在,请检查 base_url 是否正确: {api_url}"
|
||||
elif response.status_code == 400:
|
||||
# 工具定义可能不兼容,降级为无工具重试
|
||||
if tools and "tools" in str(response.text).lower():
|
||||
if body.get("tools") and "tools" in str(response.text).lower():
|
||||
print(f"{Fore.YELLOW}FC 不支持,降级为普通模式{Style.RESET_ALL}")
|
||||
body.pop("tools", None)
|
||||
body.pop("tool_choice", None)
|
||||
retry = requests.post(url=api_url, json=body, headers=self._get_headers(), timeout=None)
|
||||
if retry.status_code == 200:
|
||||
self._mark_fc_unsupported()
|
||||
rd = retry.json()
|
||||
if "choices" in rd and rd["choices"]:
|
||||
ai_response = rd["choices"][0].get("message", {}).get("content", "")
|
||||
return ai_response.encode('utf-8', 'replace').decode('utf-8') if ai_response else ""
|
||||
return f"API 调用失败: {response.status_code} - {response.text}"
|
||||
return self._friendly_error(response.status_code, response)
|
||||
else:
|
||||
return f"API 调用失败: {response.status_code} - {response.text}"
|
||||
# 500 等错误:部分服务(如讯飞星火经 one-api 转发)不支持 FC 工具,
|
||||
# 会直接报 500 Invalid Params —— 尝试去掉 tools 降级重试一次
|
||||
lower = str(response.text).lower()
|
||||
if body.get("tools") and ("xunfei" in lower or "invalid params" in lower or "requestparamserror" in lower):
|
||||
print(f"{Fore.YELLOW}该接口可能不支持 FC 工具,降级为普通模式重试...{Style.RESET_ALL}")
|
||||
body.pop("tools", None)
|
||||
body.pop("tool_choice", None)
|
||||
retry = requests.post(url=api_url, json=body, headers=self._get_headers(), timeout=None)
|
||||
if retry.status_code == 200:
|
||||
self._mark_fc_unsupported()
|
||||
rd = retry.json()
|
||||
retry_usage = rd.get("usage", {})
|
||||
if isinstance(retry_usage, dict) and retry_usage.get("prompt_tokens"):
|
||||
try:
|
||||
self.last_prompt_tokens = int(retry_usage["prompt_tokens"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "choices" in rd and rd["choices"]:
|
||||
ai_response = rd["choices"][0].get("message", {}).get("content", "")
|
||||
ai_response = self._process_thinking_content(ai_response)
|
||||
return ai_response.encode('utf-8', 'replace').decode('utf-8') if ai_response else ""
|
||||
return self._friendly_error(response.status_code, response)
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return f"无法连接到 API 服务: {self.base_url},请检查服务是否运行"
|
||||
@@ -241,22 +413,20 @@ OpenAI 兼容格式引擎帮助信息
|
||||
|
||||
role = msg["role"]
|
||||
|
||||
# tool 角色消息(FC 工具结果)
|
||||
# tool 角色消息(FC 工具结果)—— 官方 OpenAI 格式不含 name 字段
|
||||
if role == "tool":
|
||||
tool_msg = {"role": "tool", "content": msg.get("content", "")}
|
||||
if "tool_call_id" in msg:
|
||||
tool_msg["tool_call_id"] = msg["tool_call_id"]
|
||||
if "name" in msg:
|
||||
tool_msg["name"] = msg["name"]
|
||||
messages.append(tool_msg)
|
||||
continue
|
||||
|
||||
# assistant 消息可能包含 tool_calls
|
||||
# assistant 消息可能包含 tool_calls —— 重建成纯官方结构
|
||||
if role == "assistant" and "tool_calls" in msg:
|
||||
assistant_msg = {
|
||||
"role": "assistant",
|
||||
"content": msg.get("content"),
|
||||
"tool_calls": msg["tool_calls"]
|
||||
"content": msg.get("content") or "",
|
||||
"tool_calls": self._pure_tool_calls(msg["tool_calls"])
|
||||
}
|
||||
messages.append(assistant_msg)
|
||||
continue
|
||||
@@ -337,11 +507,86 @@ OpenAI 兼容格式引擎帮助信息
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _friendly_error(status_code, response):
|
||||
"""把错误响应里的可读信息提取出来,附带常见原因提示"""
|
||||
try:
|
||||
data = response.json()
|
||||
err = data.get("error", {})
|
||||
msg = err.get("message", "") if isinstance(err, dict) else ""
|
||||
except Exception:
|
||||
msg = ""
|
||||
if not msg:
|
||||
return f"API 调用失败: {status_code} - {response.text}"
|
||||
|
||||
hint = ""
|
||||
low = str(msg).lower()
|
||||
if "invalid params" in low or "requestparamserror" in low or "xunfei" in low:
|
||||
hint = ("\n提示: 讯飞星火接口报'参数错误',常见原因:"
|
||||
"\n 1) model 名与 one-api/new-api 渠道里的模型映射不一致(如 spark-4.0-ultra / spark-max / generalv3.5)"
|
||||
"\n 2) 该模型/渠道不支持 function calling(引擎已尝试去掉 tools 重试)"
|
||||
"\n 3) base_url 需为 OpenAI 兼容转发地址(如 one-api 的 https://域名/v1)")
|
||||
return f"API 调用失败: {status_code} - {msg}{hint}"
|
||||
|
||||
@staticmethod
|
||||
def _pure_tool_calls(tool_calls):
|
||||
"""把历史里的 tool_calls 重建成官方 OpenAI 格式:
|
||||
[{"id": ..., "type": "function", "function": {"name": ..., "arguments": "json串"}}]
|
||||
剔除内部字段(action/tool/args 等),arguments 统一为字符串。
|
||||
"""
|
||||
pure = []
|
||||
for tc in (tool_calls or []):
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
func = tc.get("function")
|
||||
if isinstance(func, dict):
|
||||
name = func.get("name", tc.get("name", ""))
|
||||
args = func.get("arguments", "{}")
|
||||
else:
|
||||
name = tc.get("name", "")
|
||||
args = tc.get("arguments", "{}")
|
||||
if isinstance(args, str):
|
||||
args_str = args
|
||||
else:
|
||||
try:
|
||||
args_str = json.dumps(args, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
args_str = "{}"
|
||||
pure.append({
|
||||
"id": tc.get("id", f"call_{name}"),
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": args_str}
|
||||
})
|
||||
return pure
|
||||
|
||||
def set_model(self, model_name):
|
||||
"""设置模型"""
|
||||
self.model = model_name
|
||||
print(f"{Fore.GREEN}已切换模型为: {model_name}{Style.RESET_ALL}")
|
||||
|
||||
def context_window(self):
|
||||
"""
|
||||
返回当前模型的上下文窗口(token 数)。
|
||||
优先级: config.system.context_window > 模型名查表 > 默认 128000。
|
||||
注: OpenAI /chat/completions 协议本身不返回模型窗口,需本地维护。
|
||||
"""
|
||||
cfg = get_system_config("context_window", None)
|
||||
if cfg:
|
||||
try:
|
||||
return int(cfg)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
model = (self.model or "").lower()
|
||||
for key, win in MODEL_CONTEXT_WINDOWS.items():
|
||||
if key and key in model:
|
||||
return win
|
||||
return 128000
|
||||
|
||||
@property
|
||||
def max_input_tokens(self):
|
||||
"""当前引擎支持的最大输入 token 数(统一 API,供上下文压缩使用)。"""
|
||||
return self.context_window()
|
||||
|
||||
def list_models(self):
|
||||
"""列出可用模型(调用 API 的 /models 端点)"""
|
||||
if not self.base_url:
|
||||
|
||||
+10
-1
@@ -4,7 +4,8 @@
|
||||
"ollama": {
|
||||
"api_key": "",
|
||||
"base_url": "http://localhost:11434",
|
||||
"model": "gemma4:31b"
|
||||
"model": "gemma4:31b",
|
||||
"max_token_k": "<input:请输入模型最大token大小(K)>"
|
||||
},
|
||||
"openai": {
|
||||
"api_key": "your-api-key-here",
|
||||
@@ -16,5 +17,13 @@
|
||||
"system": {
|
||||
"default_engine": "ollama",
|
||||
"max_history": 50
|
||||
},
|
||||
"lspServers": {
|
||||
"_说明": "语言服务器(LSP)。键为语言ID或扩展名,值为启动命令。配置后重启即生效,工具 lsp__diagnostics/hover/definition/references/completion 自动可用,且 AI 写/改 .py 文件后会自动触发诊断并注入模型上下文。python 默认用自研本地服务器(jedi 语义 + py_detect 中文诊断,纯本地零安装);换回 pylsp 则 command 改 \"pylsp\"、args 清空。",
|
||||
"python": {
|
||||
"command": "python",
|
||||
"args": ["-m", "xcli_core.local_lsp_server"],
|
||||
"disabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-3
@@ -16,6 +16,13 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
import platform
|
||||
|
||||
# <input> 特殊语法:配置模型时可用它交互式询问用户并获取输入
|
||||
try:
|
||||
from xcli_core.config import resolve_input_value
|
||||
except Exception:
|
||||
def resolve_input_value(value, field_name="", default_prompt=None):
|
||||
return value
|
||||
|
||||
# ── 编码设置 ──
|
||||
if sys.stdout.encoding != 'utf-8':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
@@ -120,6 +127,7 @@ class Launcher:
|
||||
'colorama', 'openai', 'requests', 'ollama', 'pygame',
|
||||
'PIL', 'Pillow', 'cv2', 'opencv-python', 'numpy',
|
||||
'ascii_magic', 'tkinter', 'matplotlib', 'pandas',
|
||||
'jedi', # py_detect L2 语义验证后端 / 自研本地 LSP(纯 Python,预装即启用)
|
||||
}
|
||||
|
||||
# ── 清屏 ──
|
||||
@@ -471,6 +479,12 @@ class Launcher:
|
||||
entry['base_url'] = 'https://api.deepseek.com/v1'
|
||||
entry['model'] = 'deepseek-chat'
|
||||
config['api']['engines'][name] = entry
|
||||
if name == 'ollama':
|
||||
# 模型最大 token 大小(单位 K,如 32 = 32K = 32768 token)
|
||||
# 默认用 <input> 语法:配置模型时交互式询问用户
|
||||
config['api']['engines'][name].setdefault(
|
||||
'max_token_k', '<input:请输入模型最大token大小(K)>'
|
||||
)
|
||||
|
||||
self.config_file.write_text(json.dumps(config, ensure_ascii=False, indent=2), 'utf-8')
|
||||
return config
|
||||
@@ -498,7 +512,7 @@ class Launcher:
|
||||
info(f'当前密钥: {masked}')
|
||||
if prompt_yn(f'配置 {name} 的 API 密钥?', 'n'):
|
||||
key = self.secret_input(f'{name} API 密钥')
|
||||
cfg['api_key'] = key
|
||||
cfg['api_key'] = resolve_input_value(key, field_name='API 密钥')
|
||||
|
||||
# URL
|
||||
current_url = cfg.get('base_url', '')
|
||||
@@ -506,7 +520,7 @@ class Launcher:
|
||||
info(f'当前 URL: {current_url}')
|
||||
if prompt_yn(f'配置 {name} 的 Base URL?', 'n'):
|
||||
url = prompt('Base URL', current_url)
|
||||
cfg['base_url'] = url
|
||||
cfg['base_url'] = resolve_input_value(url, field_name='Base URL')
|
||||
|
||||
# Model
|
||||
current_model = cfg.get('model', '')
|
||||
@@ -514,7 +528,26 @@ class Launcher:
|
||||
info(f'当前模型: {current_model}')
|
||||
if prompt_yn(f'配置 {name} 的模型?', 'n'):
|
||||
model = prompt('模型名称', current_model)
|
||||
cfg['model'] = model
|
||||
cfg['model'] = resolve_input_value(model, field_name='模型')
|
||||
|
||||
# 最大 token 大小(仅 ollama;单位 K,如 32 = 32K = 32768 token;支持 <input> 语法)
|
||||
if name == 'ollama':
|
||||
current_ctx = cfg.get('max_token_k', '')
|
||||
is_syntax = isinstance(current_ctx, str) and '<input' in current_ctx
|
||||
if is_syntax:
|
||||
info('当前最大 token: (启动时询问)')
|
||||
elif current_ctx:
|
||||
shown = str(current_ctx)
|
||||
info(f'当前最大 token: {shown}K' if shown.replace('.', '', 1).isdigit() else f'当前最大 token: {shown}')
|
||||
else:
|
||||
info('当前最大 token: 未设置')
|
||||
if prompt_yn(f'配置 {name} 的最大 token 大小(K)?', 'n'):
|
||||
default_ctx = '' if is_syntax else str(current_ctx)
|
||||
val = prompt('最大 token (K)', default_ctx)
|
||||
if val.strip():
|
||||
cfg['max_token_k'] = resolve_input_value(val, field_name='最大 token 大小(K)')
|
||||
elif not current_ctx:
|
||||
cfg['max_token_k'] = '<input:请输入模型最大token大小(K)>'
|
||||
|
||||
config.setdefault('api', {}).setdefault('engines', {})[name] = cfg
|
||||
|
||||
|
||||
+617
@@ -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.2"},
|
||||
})
|
||||
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.2"},
|
||||
})
|
||||
# 通知 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:
|
||||
return f"\n{result}"
|
||||
|
||||
def _auto_lsp_check(self, filepath: str) -> str:
|
||||
"""写/改文件后自动让已连接的语言服务器(pylsp 等)重新诊断该文件。
|
||||
|
||||
诊断结果会被缓存进 LSP 管理器的 _workspace_diag,由 get_lsp_context()
|
||||
在下一轮静默注入模型上下文;同时仅在确有问题时给一行可见提示。
|
||||
无对应 server / 文件干净时静默跳过,绝不影响主流程。
|
||||
"""
|
||||
manager = None
|
||||
try:
|
||||
cli = self.cli
|
||||
utm = getattr(cli, 'liugin_manager', None)
|
||||
if utm is not None:
|
||||
manager = getattr(utm, '_lsp_manager', None)
|
||||
except Exception:
|
||||
manager = None
|
||||
if manager is None:
|
||||
return ""
|
||||
try:
|
||||
diag_text = manager.diagnostics(filepath, wait=1.5)
|
||||
except Exception:
|
||||
return ""
|
||||
if not diag_text or "没有为" in diag_text:
|
||||
return "" # 该扩展名未配置 LSP server,跳过
|
||||
if "无诊断" in diag_text:
|
||||
return "" # 干净,无需呈现(缓存也为空)
|
||||
first = diag_text.splitlines()[0] if diag_text else ""
|
||||
return f"\n LSP 诊断: {first}"
|
||||
|
||||
def _load_py_detect(self):
|
||||
"""延迟加载 py_detect 核心(plugins/py_detect.py),失败返回 None 并缓存"""
|
||||
cached = getattr(self, "_py_detect_mod", "unset")
|
||||
if cached != "unset":
|
||||
return cached
|
||||
try:
|
||||
import importlib.util
|
||||
plugin_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "py_detect.py")
|
||||
if not os.path.exists(plugin_path):
|
||||
self._py_detect_mod = None
|
||||
return None
|
||||
spec = importlib.util.spec_from_file_location("py_detect_core", plugin_path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
self._py_detect_mod = mod
|
||||
return mod
|
||||
except Exception:
|
||||
self._py_detect_mod = None
|
||||
return None
|
||||
|
||||
def _auto_py_detect(self, filepath: str) -> str:
|
||||
"""写/改 .py 文件后自动智能检测(py_detect,纯本地零依赖)。
|
||||
|
||||
仅在 .py 文件且检测到问题时输出(截断为前 6 条,避免刷屏),
|
||||
干净或非 py 文件静默返回空串——与 _auto_lsp_check 同风格。
|
||||
"""
|
||||
if not filepath.lower().endswith(".py"):
|
||||
return ""
|
||||
mod = self._load_py_detect()
|
||||
if mod is None:
|
||||
return ""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||
source = f.read()
|
||||
issues = mod.analyze(source, filepath)
|
||||
if not issues:
|
||||
return ""
|
||||
order = {"错误": 0, "警告": 1, "提示": 2}
|
||||
issues = sorted(issues, key=lambda d: (order.get(d["severity"], 9), d["line"], d["col"]))
|
||||
n = len(issues)
|
||||
n_e = sum(1 for d in issues if d["severity"] == "错误")
|
||||
n_w = sum(1 for d in issues if d["severity"] == "警告")
|
||||
n_i = sum(1 for d in issues if d["severity"] == "提示")
|
||||
lines = [f"\n [py_detect] 检测到 {n} 个问题 (错误 {n_e} · 警告 {n_w} · 提示 {n_i}):"]
|
||||
for d in issues[:6]:
|
||||
lines.append(f" [{d['code']}] {d['severity']} @{d['line']}:{d['col']} {d['title']}"
|
||||
+ (f" ({d['extra']})" if d.get("extra") else ""))
|
||||
if n > 6:
|
||||
lines.append(f" … 还有 {n - 6} 条,py_detect check {filepath} 查看全部")
|
||||
return "\n".join(lines)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Diff 弹窗系统
|
||||
# ══════════════════════════════════════
|
||||
@@ -975,7 +1057,7 @@ else:
|
||||
|
||||
self._spawn_diff_popup(fp, before_content, new_content)
|
||||
result = f" 已编辑: {fp}\n\n{self._make_diff(old_text, new_text)}"
|
||||
result += self._auto_syntax_check(fp)
|
||||
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
|
||||
return result
|
||||
|
||||
def _op_multi_edit(self, path: str, rest: str) -> str:
|
||||
@@ -1013,7 +1095,7 @@ else:
|
||||
f.write(content)
|
||||
self._spawn_diff_popup(fp, before_content, content)
|
||||
result = f" 批量编辑: {fp}\n" + "\n".join(results)
|
||||
result += self._auto_syntax_check(fp)
|
||||
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
|
||||
return result
|
||||
|
||||
def _op_insert(self, path: str, rest: str) -> str:
|
||||
@@ -1046,7 +1128,7 @@ else:
|
||||
after_content = ''.join(lines)
|
||||
self._spawn_diff_popup(fp, before_content, after_content)
|
||||
result = f" 已在第 {line_num} 行插入 {len(insert_lines)} 行"
|
||||
result += self._auto_syntax_check(fp)
|
||||
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
|
||||
return result
|
||||
|
||||
def _op_delete_lines(self, path: str, rest: str) -> str:
|
||||
@@ -1079,7 +1161,7 @@ else:
|
||||
after_content = ''.join(lines)
|
||||
self._spawn_diff_popup(fp, before_content, after_content)
|
||||
result = f" 已删除第 {start}-{end} 行 ({len(deleted)} 行)"
|
||||
result += self._auto_syntax_check(fp)
|
||||
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
|
||||
return result
|
||||
|
||||
def _op_create(self, path: str, rest: str) -> str:
|
||||
@@ -1095,7 +1177,7 @@ else:
|
||||
f.write(rest or "")
|
||||
self._spawn_diff_popup(fp, "", rest or "")
|
||||
result = f" 已创建: {fp} ({len(rest or '')} 字符)"
|
||||
result += self._auto_syntax_check(fp)
|
||||
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
|
||||
return result
|
||||
|
||||
def _op_write(self, path: str, rest: str) -> str:
|
||||
@@ -1110,7 +1192,7 @@ else:
|
||||
f.write(rest)
|
||||
self._spawn_diff_popup(fp, before_content, rest)
|
||||
result = f" 已写入: {fp} ({len(rest)} 字符)"
|
||||
result += self._auto_syntax_check(fp)
|
||||
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
|
||||
return result
|
||||
|
||||
def _op_append(self, path: str, rest: str) -> str:
|
||||
@@ -1125,7 +1207,7 @@ else:
|
||||
after_content = self._read_file_safe(fp)
|
||||
self._spawn_diff_popup(fp, before_content, after_content)
|
||||
result = f" 已追加: {fp} ({len(rest)} 字符)"
|
||||
result += self._auto_syntax_check(fp)
|
||||
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp) + self._auto_py_detect(fp)
|
||||
return result
|
||||
|
||||
# ══════════════════════════════════════
|
||||
|
||||
+73
-12
@@ -3,6 +3,7 @@
|
||||
聚焦文件系统操作,代码编辑交给 code_editor
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
@@ -10,6 +11,30 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
# ── 跨平台路径归一化 ──────────────────────────────────────────────
|
||||
# 模型常给出 Linux/Mac 风格绝对路径(如 /home/<user>/Desktop、
|
||||
# /Users/<user>/Desktop、/root/Desktop)。在 Windows 下 os.path.abspath 会
|
||||
# 错误地拼成 C:\home\user\Desktop。这里把这类路径映射到当前用户家目录
|
||||
# 对应位置;Linux/Mac 下这些路径本身合法,原样保留。
|
||||
_HOME_OR_USER_RE = re.compile(r'^/(?:home|Users)/[^/]+(/.*)?$')
|
||||
_ROOT_RE = re.compile(r'^/root(/.*)?$')
|
||||
|
||||
|
||||
def _resolve_cross_platform_path(path: str) -> str:
|
||||
"""把模型给出的路径归一化到当前平台后再转绝对路径。"""
|
||||
if os.name == 'nt' and path:
|
||||
p = path.replace('\\', '/')
|
||||
m = _HOME_OR_USER_RE.match(p)
|
||||
if m:
|
||||
rest = (m.group(1) or '').lstrip('/')
|
||||
return os.path.join(os.path.expanduser('~'), rest)
|
||||
m = _ROOT_RE.match(p)
|
||||
if m:
|
||||
rest = (m.group(1) or '').lstrip('/')
|
||||
return os.path.join(os.path.expanduser('~'), rest)
|
||||
return os.path.abspath(path)
|
||||
|
||||
# 添加项目根目录到 path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from xcli_core.tool_result import ToolResult, ErrorCode
|
||||
@@ -38,6 +63,34 @@ class Liugin:
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def _auto_lsp_check(self, filepath: str) -> str:
|
||||
"""写/改文件后自动让已连接的语言服务器(pylsp 等)重新诊断该文件。
|
||||
|
||||
诊断结果缓存进 LSP 管理器的 _workspace_diag,由 get_lsp_context()
|
||||
在下一轮静默注入模型上下文;仅在有问题时返回一行提示。
|
||||
无对应 server / 文件干净时静默跳过。
|
||||
"""
|
||||
manager = None
|
||||
try:
|
||||
cli = self.cli
|
||||
utm = getattr(cli, 'liugin_manager', None)
|
||||
if utm is not None:
|
||||
manager = getattr(utm, '_lsp_manager', None)
|
||||
except Exception:
|
||||
manager = None
|
||||
if manager is None:
|
||||
return ""
|
||||
try:
|
||||
diag_text = manager.diagnostics(filepath, wait=1.5)
|
||||
except Exception:
|
||||
return ""
|
||||
if not diag_text or "没有为" in diag_text:
|
||||
return ""
|
||||
if "无诊断" in diag_text:
|
||||
return ""
|
||||
first = diag_text.splitlines()[0] if diag_text else ""
|
||||
return f"\n LSP 诊断: {first}"
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "file_manager",
|
||||
@@ -130,7 +183,7 @@ class Liugin:
|
||||
path = args[i]
|
||||
i += 1
|
||||
|
||||
path = os.path.abspath(path)
|
||||
path = _resolve_cross_platform_path(path)
|
||||
if not os.path.isdir(path):
|
||||
return ToolResult.not_found(f"目录: {path}")
|
||||
|
||||
@@ -165,7 +218,7 @@ class Liugin:
|
||||
def _op_read(self, args: List[str]):
|
||||
if not args:
|
||||
return ToolResult.missing("文件路径")
|
||||
file_path = os.path.abspath(args[0])
|
||||
file_path = _resolve_cross_platform_path(args[0])
|
||||
max_lines = None
|
||||
i = 1
|
||||
while i < len(args):
|
||||
@@ -207,7 +260,7 @@ class Liugin:
|
||||
def _op_write(self, args: List[str]):
|
||||
if len(args) < 2:
|
||||
return ToolResult.fail("格式: write <文件> -c <内容>", ErrorCode.MISSING_ARGS)
|
||||
fp = os.path.abspath(args[0])
|
||||
fp = _resolve_cross_platform_path(args[0])
|
||||
content = None
|
||||
i = 1
|
||||
while i < len(args):
|
||||
@@ -219,12 +272,16 @@ class Liugin:
|
||||
os.makedirs(os.path.dirname(fp) or ".", exist_ok=True)
|
||||
with open(fp, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return ToolResult.ok(f"已写入: {fp} ({len(content)} 字符)", tool_name="file_manager")
|
||||
msg = f"已写入: {fp} ({len(content)} 字符)"
|
||||
lsp_hint = self._auto_lsp_check(fp)
|
||||
if lsp_hint:
|
||||
msg += lsp_hint
|
||||
return ToolResult.ok(msg, tool_name="file_manager")
|
||||
|
||||
def _op_append(self, args: List[str]):
|
||||
if len(args) < 2:
|
||||
return ToolResult.fail("格式: append <文件> -c <内容>", ErrorCode.MISSING_ARGS)
|
||||
fp = os.path.abspath(args[0])
|
||||
fp = _resolve_cross_platform_path(args[0])
|
||||
content = None
|
||||
i = 1
|
||||
while i < len(args):
|
||||
@@ -237,12 +294,16 @@ class Liugin:
|
||||
return ToolResult.not_found(f"文件: {fp}")
|
||||
with open(fp, 'a', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return ToolResult.ok(f"已追加: {fp}", tool_name="file_manager")
|
||||
msg = f"已追加: {fp}"
|
||||
lsp_hint = self._auto_lsp_check(fp)
|
||||
if lsp_hint:
|
||||
msg += lsp_hint
|
||||
return ToolResult.ok(msg, tool_name="file_manager")
|
||||
|
||||
def _op_copy(self, args: List[str]):
|
||||
if len(args) < 2:
|
||||
return ToolResult.missing("源和目标路径")
|
||||
src, dst = os.path.abspath(args[0]), os.path.abspath(args[1])
|
||||
src, dst = _resolve_cross_platform_path(args[0]), _resolve_cross_platform_path(args[1])
|
||||
if not os.path.exists(src):
|
||||
return ToolResult.not_found(f"源: {src}")
|
||||
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
|
||||
@@ -255,7 +316,7 @@ class Liugin:
|
||||
def _op_move(self, args: List[str]):
|
||||
if len(args) < 2:
|
||||
return ToolResult.missing("源和目标路径")
|
||||
src, dst = os.path.abspath(args[0]), os.path.abspath(args[1])
|
||||
src, dst = _resolve_cross_platform_path(args[0]), _resolve_cross_platform_path(args[1])
|
||||
if not os.path.exists(src):
|
||||
return ToolResult.not_found(f"源: {src}")
|
||||
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
|
||||
@@ -265,7 +326,7 @@ class Liugin:
|
||||
def _op_delete(self, args: List[str]):
|
||||
if not args:
|
||||
return ToolResult.missing("路径")
|
||||
path = os.path.abspath(args[0])
|
||||
path = _resolve_cross_platform_path(args[0])
|
||||
recursive = "-r" in args
|
||||
if not os.path.exists(path):
|
||||
return ToolResult.not_found(f"路径: {path}")
|
||||
@@ -285,7 +346,7 @@ class Liugin:
|
||||
def _op_search(self, args: List[str]):
|
||||
if not args:
|
||||
return ToolResult.missing("搜索目录")
|
||||
directory = os.path.abspath(args[0])
|
||||
directory = _resolve_cross_platform_path(args[0])
|
||||
pattern, content_search = None, None
|
||||
i = 1
|
||||
while i < len(args):
|
||||
@@ -327,7 +388,7 @@ class Liugin:
|
||||
def _op_info(self, args: List[str]):
|
||||
if not args:
|
||||
return ToolResult.missing("路径")
|
||||
path = os.path.abspath(args[0])
|
||||
path = _resolve_cross_platform_path(args[0])
|
||||
if not os.path.exists(path):
|
||||
return ToolResult.not_found(f"路径: {path}")
|
||||
|
||||
@@ -347,7 +408,7 @@ class Liugin:
|
||||
def _op_mkdir(self, args: List[str]):
|
||||
if not args:
|
||||
return ToolResult.missing("目录路径")
|
||||
path = os.path.abspath(args[0])
|
||||
path = _resolve_cross_platform_path(args[0])
|
||||
if os.path.exists(path):
|
||||
return ToolResult.fail(f"已存在: {path}", ErrorCode.INVALID_ARGS)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,176 @@
|
||||
# METADATA
|
||||
# {
|
||||
# "name": "beeimg_image_uploader_v2",
|
||||
#
|
||||
# "display_name": {
|
||||
# "zh": "Beeimg 图片上传器 V2",
|
||||
# "en": "Beeimg Image Uploader V2"
|
||||
# },
|
||||
# "description": {
|
||||
# "zh": "BeeIMG(https://beeimg.com/)工具将本地图片上传到图床并返回图片url,配合图片生成工具实现图生图(图生图务必开启)。",
|
||||
# "en": "Upload a local image to BeeIMG (https://beeimg.com/) and return a public image URL. Useful for image-to-image workflows (make sure img2img is enabled)."
|
||||
# },
|
||||
# "env": [
|
||||
# "BEEIMG_API_KEY"
|
||||
# ],
|
||||
# "category": "Media",
|
||||
# "tools": [
|
||||
# {
|
||||
# "name": "upload_image",
|
||||
# "description": {
|
||||
# "zh": "使用 multipart 上传将本地图片上传到 BeeIMG 图床并返回图片 url。",
|
||||
# "en": "Upload a local image to BeeIMG via multipart upload and return the image URL."
|
||||
# },
|
||||
# "parameters": [
|
||||
# { "name": "file_path", "description": { "zh": "要上传的图片文件绝对路径 (建议使用 /sdcard/ 开头的完整路径)。", "en": "Absolute path of the image file to upload (recommended: full path starting with /sdcard/)." }, "type": "string", "required": true },
|
||||
# { "name": "album_id", "description": { "zh": "相册ID (可选)。", "en": "Album ID (optional)." }, "type": "string", "required": false },
|
||||
# { "name": "privacy", "description": { "zh": "隐私设置,'public' 或 'private' (可选)。", "en": "Privacy setting: 'public' or 'private' (optional)." }, "type": "string", "required": false }
|
||||
# ]
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
|
||||
import json
|
||||
import traceback
|
||||
|
||||
API_ENDPOINT = "https://beeimg.com/api/upload/file/json/"
|
||||
|
||||
|
||||
def isRecord(value):
|
||||
return isinstance(value, dict)
|
||||
|
||||
|
||||
def getErrorMessage(error):
|
||||
if isinstance(error, Exception):
|
||||
return str(error)
|
||||
return str(error)
|
||||
|
||||
|
||||
def getErrorStack(error):
|
||||
if isinstance(error, Exception):
|
||||
return traceback.format_exc()
|
||||
return None
|
||||
|
||||
|
||||
def getApiKey():
|
||||
return getEnv("BEEIMG_API_KEY") or ""
|
||||
|
||||
|
||||
def guessMimeTypeFromPath(filePath):
|
||||
lower = filePath.lower()
|
||||
if lower.endswith(".png"):
|
||||
return "image/png"
|
||||
if lower.endswith(".jpg") or lower.endswith(".jpeg"):
|
||||
return "image/jpeg"
|
||||
if lower.endswith(".webp"):
|
||||
return "image/webp"
|
||||
if lower.endswith(".gif"):
|
||||
return "image/gif"
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def safeJsonParseLoose(text):
|
||||
trimmed = (text or "").strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
try:
|
||||
return json.loads(trimmed)
|
||||
except Exception as e:
|
||||
start = trimmed.find("{")
|
||||
end = trimmed.rfind("}")
|
||||
if start != -1 and end != -1 and end > start:
|
||||
return json.loads(trimmed[start:end + 1])
|
||||
raise e
|
||||
|
||||
|
||||
async def upload_image(params):
|
||||
file_path = params.get("file_path") if params else None
|
||||
album_id = params.get("album_id") if params else None
|
||||
privacy = params.get("privacy") if params else None
|
||||
|
||||
if not file_path or len(str(file_path).strip()) == 0:
|
||||
raise Exception("参数 file_path 不能为空。")
|
||||
|
||||
# 1. 检查文件是否存在
|
||||
fileExists = await Tools.Files.exists(file_path)
|
||||
if not fileExists["exists"]:
|
||||
raise Exception(f"文件未找到: {file_path} (请尝试使用 /sdcard/ 开头的绝对路径)")
|
||||
|
||||
# 2. 准备参数
|
||||
apiKey = getApiKey()
|
||||
if not apiKey:
|
||||
raise Exception("BEEIMG_API_KEY 未配置,请在环境变量中设置 BeeIMG 的 API Key。")
|
||||
|
||||
form_data = {
|
||||
"apikey": apiKey
|
||||
}
|
||||
if album_id:
|
||||
form_data["albumid"] = str(album_id)
|
||||
if privacy:
|
||||
form_data["privacy"] = str(privacy)
|
||||
|
||||
# 3. 发起 multipart 上传
|
||||
console.log("正在执行上传...")
|
||||
|
||||
resp = await Tools.Net.uploadFile({
|
||||
"url": API_ENDPOINT,
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
# 尽量模拟浏览器 UA,降低被风控概率
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36"
|
||||
},
|
||||
"form_data": form_data,
|
||||
"files": [
|
||||
{
|
||||
"field_name": "file",
|
||||
"file_path": file_path,
|
||||
"content_type": guessMimeTypeFromPath(file_path)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
if resp["statusCode"] < 200 or resp["statusCode"] >= 300:
|
||||
raise Exception(f"上传请求失败 (HTTP {resp['statusCode']})。可能是网络问题或 IP 被屏蔽。\n响应: {resp['content']}")
|
||||
|
||||
responseText = (resp.get("content") or "").strip()
|
||||
if not responseText:
|
||||
raise Exception("上传失败:服务器返回了空内容。请检查网络连接或尝试关闭 VPN。")
|
||||
|
||||
try:
|
||||
jsonResponse = safeJsonParseLoose(responseText)
|
||||
except Exception as e:
|
||||
raise Exception(f"API 响应解析失败。服务器返回内容不是 JSON:\n{responseText[:200]}...")
|
||||
|
||||
files = jsonResponse["files"] if isRecord(jsonResponse) and isRecord(jsonResponse.get("files")) else None
|
||||
ok = bool(files) and (files.get("status") == "Success" or files.get("code") == "200" or files.get("code") == 200)
|
||||
url = files.get("url") if files else None
|
||||
|
||||
if ok and (isinstance(url, str) or isinstance(url, (int, float))) and len(str(url).strip()) > 0:
|
||||
return {
|
||||
"url": str(url),
|
||||
"thumbnail_url": str(files["thumbnail_url"]) if files and files.get("thumbnail_url") else None,
|
||||
"page_url": str(files["view_url"]) if files and files.get("view_url") else None,
|
||||
"details": f"上传成功! URL: {str(url)}"
|
||||
}
|
||||
else:
|
||||
if files:
|
||||
errMsg = files.get("status") or files.get("message") or json.dumps(files, ensure_ascii=False)
|
||||
else:
|
||||
errMsg = "未知错误"
|
||||
raise Exception(f"BeeIMG API 报错: {errMsg}")
|
||||
|
||||
|
||||
async def wrap(func, params):
|
||||
try:
|
||||
result = await func(params)
|
||||
complete({"success": True, "message": "图片上传成功", "data": result})
|
||||
except Exception as error:
|
||||
console.error(f"Error: {getErrorMessage(error)}")
|
||||
complete({"success": False, "message": getErrorMessage(error), "error_stack": getErrorStack(error)})
|
||||
|
||||
|
||||
async def upload_image_wrapper(params):
|
||||
await wrap(upload_image, params)
|
||||
|
||||
|
||||
exports.upload_image = upload_image_wrapper
|
||||
@@ -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_AUDIO_PLAYER = True
|
||||
ENABLE_AUTO_ENGINEER = True
|
||||
ENABLE_BOOKMARK_MANAGER = True
|
||||
ENABLE_BROWSER_AUTO = True
|
||||
ENABLE_CMD_EXECUTOR = True
|
||||
ENABLE_FILE_MANAGER = True
|
||||
ENABLE_CODE_EDITOR = True
|
||||
ENABLE_CODE_SEARCH = True
|
||||
ENABLE_GIT_TOOLS = True
|
||||
ENABLE_COLOR_PICKER = True
|
||||
ENABLE_FILE_MANAGER = True
|
||||
ENABLE_FRONTEND_TESTER = True
|
||||
ENABLE_GIT_TOOLS = True
|
||||
ENABLE_GUI_AUTO = True
|
||||
ENABLE_ID_GENERATOR = True
|
||||
ENABLE_IMAGE_GENERATOR = True
|
||||
ENABLE_JSON_TOOLKIT = True
|
||||
ENABLE_MARKDOWN_TOOL = True
|
||||
ENABLE_MEMORY_PLUGIN = True
|
||||
ENABLE_NETWORK_TOOLS = True
|
||||
ENABLE_OPERIT_LOADER = True
|
||||
ENABLE_PY_DETECT = True
|
||||
ENABLE_REGEX_TESTER = True
|
||||
ENABLE_SCHEDULER = True
|
||||
ENABLE_SEND_IMAGE = True
|
||||
ENABLE_SNIPPET_MANAGER = True
|
||||
ENABLE_SPEECH_RECOGNITION = True
|
||||
ENABLE_SUB_AGENT = True
|
||||
ENABLE_TASK_MANAGER = True
|
||||
ENABLE_TOOL_SEARCH = True
|
||||
ENABLE_AUTO_ENGINEER = True
|
||||
ENABLE_GIT_WORKFLOW = True
|
||||
ENABLE_PROJECT_ANALYZER = True
|
||||
|
||||
ENABLE_DGLAB_WS = False
|
||||
ENABLE_WORKFLOW = True
|
||||
+22
-27
@@ -1,30 +1,25 @@
|
||||
# 小狸 Pro-CLI 依赖库
|
||||
# 修复后的包名(使用 pip 安装名)
|
||||
# 小狸 Pro-CLI 依赖
|
||||
# 生成于 2026-08-05 21:10
|
||||
|
||||
# 核心依赖
|
||||
colorama>=0.4.6
|
||||
openai>=1.0.0
|
||||
requests>=2.28.0
|
||||
ollama>=0.1.0
|
||||
PIL
|
||||
ascii_magic
|
||||
colorama
|
||||
cv2
|
||||
numpy
|
||||
ollama
|
||||
openai
|
||||
pygame
|
||||
requests
|
||||
tkinter
|
||||
|
||||
# 图像处理
|
||||
Pillow>=9.0.0
|
||||
opencv-python>=4.5.0
|
||||
numpy>=1.21.0
|
||||
ascii-magic>=2.0.0
|
||||
# LSP 智能感知(pylsp 作为工具之一:lsp__diagnostics/hover/definition/references/completion)
|
||||
# 写/改 .py 文件后自动触发诊断并注入模型上下文
|
||||
python-lsp-server
|
||||
pyflakes
|
||||
pycodestyle
|
||||
rope
|
||||
mccabe
|
||||
|
||||
# 数据处理
|
||||
pandas>=1.3.0
|
||||
scipy>=1.7.0
|
||||
|
||||
# 音频
|
||||
pygame>=2.0.0
|
||||
|
||||
# TUI 模式
|
||||
textual>=0.40.0
|
||||
|
||||
# WebSocket 远程
|
||||
websockets>=11.0
|
||||
|
||||
# 类型提示
|
||||
typing-extensions>=4.0.0
|
||||
# 自研本地 LSP 服务器 / py_detect L2 语义验证后端
|
||||
# 纯 Python 语义引擎,装依赖即自动预装(L2 自动启用;无 jedi 自动降级为纯标准库模式)
|
||||
jedi
|
||||
@@ -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
|
||||
|
||||
|
||||
def plugin_class(mod):
|
||||
"""取插件入口类(项目里叫 Liugin,历史上叫 Plugin,两种都认)"""
|
||||
cls = getattr(mod, "Liugin", None) or getattr(mod, "Plugin", None)
|
||||
assert cls is not None, f"{mod.__name__}: 缺少 Plugin/Liugin 类"
|
||||
return cls
|
||||
|
||||
|
||||
def as_text(result):
|
||||
"""插件返回值统一转文本(可能是 str,也可能是 ToolResult)"""
|
||||
return result if isinstance(result, str) else str(result)
|
||||
|
||||
|
||||
ERROR_MARKERS = ("❌", "错误", "失败", "缺少", "不存在", "无效", "请提供", "用法")
|
||||
|
||||
|
||||
def is_error(result):
|
||||
"""判断插件返回是否表示失败(不依赖具体文案措辞)"""
|
||||
success = getattr(result, "success", None)
|
||||
if success is not None:
|
||||
return not success
|
||||
return any(m in as_text(result) for m in ERROR_MARKERS)
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 插件加载测试
|
||||
# ══════════════════════════════════════
|
||||
@@ -89,7 +112,7 @@ class TestCodeEditor:
|
||||
@pytest.fixture
|
||||
def editor(self):
|
||||
mod = load_module("code_editor", os.path.join(PLUGINS_DIR, "code_editor.py"))
|
||||
return mod.Plugin()
|
||||
return plugin_class(mod)()
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_file(self, tmp_path):
|
||||
@@ -99,47 +122,47 @@ class TestCodeEditor:
|
||||
|
||||
def test_read_range(self, editor, tmp_file):
|
||||
result = editor.handle(f"read_range {tmp_file} 1 2")
|
||||
assert "hello" in result
|
||||
assert "1 |" in result
|
||||
assert "hello" in as_text(result)
|
||||
assert "1 |" in as_text(result)
|
||||
|
||||
def test_edit(self, editor, tmp_file):
|
||||
result = editor.handle(f"edit {tmp_file} print('hello') <<<>>> print('hi')")
|
||||
assert "已编辑" in result
|
||||
assert "已编辑" in as_text(result)
|
||||
content = open(tmp_file).read()
|
||||
assert "print('hi')" in content
|
||||
|
||||
def test_edit_not_found(self, editor, tmp_file):
|
||||
result = editor.handle(f"edit {tmp_file} nonexistent_code <<<>>> new_code")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_create(self, editor, tmp_path):
|
||||
new_file = str(tmp_path / "new.py")
|
||||
result = editor.handle(f"create {new_file} import os")
|
||||
assert "已创建" in result
|
||||
assert "已创建" in as_text(result)
|
||||
assert os.path.exists(new_file)
|
||||
|
||||
def test_diff_text(self, editor):
|
||||
result = editor.handle("diff_text old <<<>>> new")
|
||||
assert "变更预览" in result
|
||||
assert "变更预览" in as_text(result)
|
||||
|
||||
def test_ast_info(self, editor, tmp_file):
|
||||
result = editor.handle(f"ast_info {tmp_file}")
|
||||
assert "def hello" in result
|
||||
assert "def world" in result
|
||||
assert "def hello" in as_text(result)
|
||||
assert "def world" in as_text(result)
|
||||
|
||||
def test_stats(self, editor, tmp_path):
|
||||
result = editor.handle(f"stats {tmp_path}")
|
||||
assert "代码统计" in result
|
||||
assert "文件" in result
|
||||
assert "代码统计" in as_text(result)
|
||||
assert "文件" in as_text(result)
|
||||
|
||||
def test_structure(self, editor, tmp_path):
|
||||
result = editor.handle(f"structure {tmp_path} 1")
|
||||
assert "" in result
|
||||
assert "" in as_text(result)
|
||||
|
||||
def test_find(self, editor, tmp_path):
|
||||
(tmp_path / "a.py").write_text("x = 1\ny = 2\n")
|
||||
result = editor.handle(f"find {tmp_path} x *.py")
|
||||
assert "x = 1" in result
|
||||
assert "x = 1" in as_text(result)
|
||||
|
||||
|
||||
class TestGitTools:
|
||||
@@ -148,11 +171,11 @@ class TestGitTools:
|
||||
@pytest.fixture
|
||||
def git(self):
|
||||
mod = load_module("git_tools", os.path.join(PLUGINS_DIR, "git_tools.py"))
|
||||
return mod.Plugin()
|
||||
return plugin_class(mod)()
|
||||
|
||||
def test_status(self, git):
|
||||
result = git.handle("status")
|
||||
assert "Git" in result or "干净" in result or "分支" in result
|
||||
assert "Git" in as_text(result) or "干净" in as_text(result) or "分支" in as_text(result)
|
||||
|
||||
def test_log(self, git):
|
||||
result = git.handle("log --oneline 3")
|
||||
@@ -161,7 +184,7 @@ class TestGitTools:
|
||||
|
||||
def test_branch(self, git):
|
||||
result = git.handle("branch")
|
||||
assert "master" in result or "main" in result
|
||||
assert "master" in as_text(result) or "main" in as_text(result)
|
||||
|
||||
|
||||
class TestCmdExecutor:
|
||||
@@ -170,27 +193,27 @@ class TestCmdExecutor:
|
||||
@pytest.fixture
|
||||
def cmd(self):
|
||||
mod = load_module("cmd_executor", os.path.join(PLUGINS_DIR, "cmd_executor.py"))
|
||||
return mod.Plugin()
|
||||
return plugin_class(mod)()
|
||||
|
||||
def test_echo(self, cmd):
|
||||
result = cmd.handle("run echo test_ok")
|
||||
assert "test_ok" in result
|
||||
assert "test_ok" in as_text(result)
|
||||
|
||||
def test_python_version(self, cmd):
|
||||
result = cmd.handle("run python3 --version")
|
||||
assert "Python" in result
|
||||
assert "Python" in as_text(result)
|
||||
|
||||
def test_blocked_command(self, cmd):
|
||||
result = cmd.handle("run rm -rf /")
|
||||
assert "拦截" in result
|
||||
assert "拦截" in as_text(result)
|
||||
|
||||
def test_timeout(self, cmd):
|
||||
result = cmd.handle("run echo fast --timeout 5")
|
||||
assert "fast" in result
|
||||
assert "fast" in as_text(result)
|
||||
|
||||
def test_empty_command(self, cmd):
|
||||
result = cmd.handle("run")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
|
||||
class TestFileManager:
|
||||
@@ -199,53 +222,53 @@ class TestFileManager:
|
||||
@pytest.fixture
|
||||
def fm(self):
|
||||
mod = load_module("file_manager", os.path.join(PLUGINS_DIR, "file_manager.py"))
|
||||
return mod.Plugin()
|
||||
return plugin_class(mod)()
|
||||
|
||||
def test_list(self, fm, tmp_path):
|
||||
(tmp_path / "a.txt").write_text("hi")
|
||||
result = fm.handle(f"list {tmp_path}")
|
||||
assert "a.txt" in result
|
||||
assert "a.txt" in as_text(result)
|
||||
|
||||
def test_read(self, fm, tmp_path):
|
||||
f = tmp_path / "test.txt"
|
||||
f.write_text("hello world")
|
||||
result = fm.handle(f"read {f}")
|
||||
assert "hello world" in result
|
||||
assert "hello world" in as_text(result)
|
||||
|
||||
def test_write_and_read(self, fm, tmp_path):
|
||||
f = tmp_path / "new.txt"
|
||||
result = fm.handle(f"write {f} -c test_content")
|
||||
assert "已写入" in result
|
||||
assert "已写入" in as_text(result)
|
||||
result = fm.handle(f"read {f}")
|
||||
assert "test_content" in result
|
||||
assert "test_content" in as_text(result)
|
||||
|
||||
def test_copy(self, fm, tmp_path):
|
||||
src = tmp_path / "src.txt"
|
||||
src.write_text("data")
|
||||
dst = tmp_path / "dst.txt"
|
||||
result = fm.handle(f"copy {src} {dst}")
|
||||
assert "已复制" in result
|
||||
assert "已复制" in as_text(result)
|
||||
assert dst.exists()
|
||||
|
||||
def test_delete(self, fm, tmp_path):
|
||||
f = tmp_path / "del.txt"
|
||||
f.write_text("bye")
|
||||
result = fm.handle(f"delete {f}")
|
||||
assert "已删除" in result
|
||||
assert "已删除" in as_text(result)
|
||||
assert not f.exists()
|
||||
|
||||
def test_mkdir(self, fm, tmp_path):
|
||||
d = tmp_path / "newdir"
|
||||
result = fm.handle(f"mkdir {d}")
|
||||
assert "已创建" in result
|
||||
assert "已创建" in as_text(result)
|
||||
assert d.is_dir()
|
||||
|
||||
def test_info(self, fm, tmp_path):
|
||||
f = tmp_path / "info.txt"
|
||||
f.write_text("x")
|
||||
result = fm.handle(f"info {f}")
|
||||
assert "路径" in result
|
||||
assert "大小" in result
|
||||
assert "路径" in as_text(result)
|
||||
assert "大小" in as_text(result)
|
||||
|
||||
|
||||
class TestAutoEngineer:
|
||||
@@ -254,23 +277,23 @@ class TestAutoEngineer:
|
||||
@pytest.fixture
|
||||
def eng(self):
|
||||
mod = load_module("auto_engineer", os.path.join(PLUGINS_DIR, "auto_engineer.py"))
|
||||
return mod.Plugin()
|
||||
return plugin_class(mod)()
|
||||
|
||||
def test_metrics(self, eng, tmp_path):
|
||||
(tmp_path / "a.py").write_text("x = 1\n# comment\n\ny = 2\n")
|
||||
result = eng.handle(f"metrics {tmp_path}")
|
||||
assert "代码指标" in result
|
||||
assert "文件" in result
|
||||
assert "代码指标" in as_text(result)
|
||||
assert "文件" in as_text(result)
|
||||
|
||||
def test_security_clean(self, eng, tmp_path):
|
||||
(tmp_path / "safe.py").write_text("x = 1\nprint(x)\n")
|
||||
result = eng.handle(f"security {tmp_path}")
|
||||
assert "没有发现" in result or "风险" in result
|
||||
assert "没有发现" in as_text(result) or "风险" in as_text(result)
|
||||
|
||||
def test_security_risk(self, eng, tmp_path):
|
||||
(tmp_path / "risk.py").write_text('password = "secret123"\neval("1+1")\n')
|
||||
result = eng.handle(f"security {tmp_path}")
|
||||
assert "风险" in result
|
||||
assert "风险" in as_text(result)
|
||||
|
||||
def test_complexity(self, eng, tmp_path):
|
||||
code = "def f():\n if True:\n if True:\n if True:\n pass\n"
|
||||
@@ -292,34 +315,34 @@ class TestTaskManager:
|
||||
|
||||
def test_add(self, tm):
|
||||
result = tm.handle("add 测试任务")
|
||||
assert "已添加" in result
|
||||
assert "已添加" in as_text(result)
|
||||
|
||||
def test_list_empty(self, tm):
|
||||
result = tm.handle("list")
|
||||
assert "没有任务" in result
|
||||
assert "没有任务" in as_text(result)
|
||||
|
||||
def test_add_and_list(self, tm):
|
||||
tm.handle("add 任务A")
|
||||
tm.handle("add 任务B")
|
||||
result = tm.handle("list")
|
||||
assert "任务A" in result
|
||||
assert "任务B" in result
|
||||
assert "任务A" in as_text(result)
|
||||
assert "任务B" in as_text(result)
|
||||
|
||||
def test_complete(self, tm):
|
||||
tm.handle("add 任务X")
|
||||
result = tm.handle("complete 1")
|
||||
assert "已更新" in result
|
||||
assert "已更新" in as_text(result)
|
||||
|
||||
def test_delete(self, tm):
|
||||
tm.handle("add 任务Y")
|
||||
result = tm.handle("delete 1")
|
||||
assert "已删除" in result
|
||||
assert "已删除" in as_text(result)
|
||||
|
||||
def test_clear(self, tm):
|
||||
tm.handle("add A")
|
||||
tm.handle("complete 1")
|
||||
result = tm.handle("clear")
|
||||
assert "已清除" in result
|
||||
assert "已清除" in as_text(result)
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
@@ -332,19 +355,19 @@ class TestBrowserAuto:
|
||||
@pytest.fixture
|
||||
def browser(self):
|
||||
mod = load_module("browser_auto", os.path.join(PLUGINS_DIR, "browser_auto.py"))
|
||||
return mod.Plugin()
|
||||
return plugin_class(mod)()
|
||||
|
||||
def test_status_stopped(self, browser):
|
||||
result = browser.handle("status")
|
||||
assert "未运行" in result
|
||||
assert "未运行" in as_text(result)
|
||||
|
||||
def test_invalid_operation(self, browser):
|
||||
result = browser.handle("nonexistent")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_empty_args(self, browser):
|
||||
result = browser.handle("")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_tool_info(self, browser):
|
||||
info = browser.get_tool_info()
|
||||
@@ -363,7 +386,7 @@ class TestGuiAuto:
|
||||
@pytest.fixture
|
||||
def gui(self):
|
||||
mod = load_module("gui_auto", os.path.join(PLUGINS_DIR, "gui_auto.py"))
|
||||
return mod.Plugin()
|
||||
return plugin_class(mod)()
|
||||
|
||||
def test_tool_info(self, gui):
|
||||
info = gui.get_tool_info()
|
||||
@@ -380,88 +403,88 @@ class TestGuiAuto:
|
||||
def test_convert_mcp_args(self, gui):
|
||||
args = {"operation": "click", "target": "确定"}
|
||||
result = gui.convert_mcp_args(args)
|
||||
assert "click" in result
|
||||
assert "确定" in result
|
||||
assert "click" in as_text(result)
|
||||
assert "确定" in as_text(result)
|
||||
|
||||
def test_convert_mcp_args_xy(self, gui):
|
||||
args = {"operation": "click", "x": 100, "y": 200}
|
||||
result = gui.convert_mcp_args(args)
|
||||
assert "--xy" in result
|
||||
assert "100" in result
|
||||
assert "--xy" in as_text(result)
|
||||
assert "100" in as_text(result)
|
||||
|
||||
def test_convert_mcp_args_snapshot(self, gui):
|
||||
args = {"operation": "snapshot", "depth": 3}
|
||||
result = gui.convert_mcp_args(args)
|
||||
assert "snapshot" in result
|
||||
assert "3" in result
|
||||
assert "snapshot" in as_text(result)
|
||||
assert "3" in as_text(result)
|
||||
|
||||
def test_empty_args(self, gui):
|
||||
result = gui.handle("")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_invalid_operation(self, gui):
|
||||
result = gui.handle("nonexistent_op")
|
||||
assert "不支持" in result
|
||||
assert "不支持" in as_text(result)
|
||||
|
||||
def test_click_no_args(self, gui):
|
||||
result = gui.handle("click")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_type_no_args(self, gui):
|
||||
result = gui.handle("type")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_type_missing_text(self, gui):
|
||||
result = gui.handle("type 按钮")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_find_no_args(self, gui):
|
||||
result = gui.handle("find")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_findall_no_args(self, gui):
|
||||
result = gui.handle("findall")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_info_no_args(self, gui):
|
||||
result = gui.handle("info")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_value_no_args(self, gui):
|
||||
result = gui.handle("value")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_tree_no_args(self, gui):
|
||||
result = gui.handle("tree")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_wait_no_args(self, gui):
|
||||
result = gui.handle("wait")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_exists_no_args(self, gui):
|
||||
result = gui.handle("exists")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_highlight_no_args(self, gui):
|
||||
result = gui.handle("highlight")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_state_no_args(self, gui):
|
||||
result = gui.handle("state")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_keys_no_args(self, gui):
|
||||
result = gui.handle("keys")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_focus_no_args(self, gui):
|
||||
result = gui.handle("focus")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_click_xy_format_error(self, gui):
|
||||
result = gui.handle("click --xy abc")
|
||||
assert "错误" in result
|
||||
assert is_error(result)
|
||||
|
||||
def test_format_element_info(self, gui):
|
||||
el = {
|
||||
@@ -472,9 +495,9 @@ class TestGuiAuto:
|
||||
"enabled": True, "focused": False
|
||||
}
|
||||
result = gui._format_element_info(el)
|
||||
assert "测试按钮" in result
|
||||
assert "Button" in result
|
||||
assert "btn_ok" in result
|
||||
assert "测试按钮" in as_text(result)
|
||||
assert "Button" in as_text(result)
|
||||
assert "btn_ok" in as_text(result)
|
||||
|
||||
def test_format_element_info_detailed(self, gui):
|
||||
el = {
|
||||
@@ -485,8 +508,8 @@ class TestGuiAuto:
|
||||
"right": 200, "bottom": 25}
|
||||
}
|
||||
result = gui._format_element_info(el, detailed=True)
|
||||
assert "输入框" in result
|
||||
assert "hello" in result
|
||||
assert "输入框" in as_text(result)
|
||||
assert "hello" in as_text(result)
|
||||
|
||||
def test_all_operations_route(self, gui):
|
||||
"""所有操作都能正确路由"""
|
||||
@@ -504,28 +527,28 @@ class TestSubAgent:
|
||||
@pytest.fixture
|
||||
def sa(self):
|
||||
mod = load_module("sub_agent", os.path.join(PLUGINS_DIR, "sub_agent.py"))
|
||||
return mod.Plugin()
|
||||
return plugin_class(mod)()
|
||||
|
||||
def test_list_empty(self, sa):
|
||||
result = sa.handle("list")
|
||||
assert "没有" in result
|
||||
assert "没有" in as_text(result)
|
||||
|
||||
def test_stats_empty(self, sa):
|
||||
result = sa.handle("stats")
|
||||
assert "总数: 0" in result
|
||||
assert "总数: 0" in as_text(result)
|
||||
|
||||
def test_spawn(self, sa):
|
||||
result = sa.handle("spawn tester 测试任务")
|
||||
assert "已创建" in result
|
||||
assert "agent_tester" in result
|
||||
assert "已创建" in as_text(result)
|
||||
assert "agent_tester" in as_text(result)
|
||||
|
||||
def test_spawn_and_list(self, sa):
|
||||
sa.handle("spawn a 任务A")
|
||||
sa.handle("spawn b 任务B")
|
||||
import time; time.sleep(0.1)
|
||||
result = sa.handle("list")
|
||||
assert "agent_a" in result
|
||||
assert "agent_b" in result
|
||||
assert "agent_a" in as_text(result)
|
||||
assert "agent_b" in as_text(result)
|
||||
|
||||
def test_status(self, sa):
|
||||
sa.handle("spawn x 某任务")
|
||||
@@ -533,24 +556,24 @@ class TestSubAgent:
|
||||
# 获取 agent ID
|
||||
agent_id = list(sa.agents.keys())[0]
|
||||
result = sa.handle(f"status {agent_id}")
|
||||
assert "名称" in result
|
||||
assert "名称" in as_text(result)
|
||||
|
||||
def test_kill(self, sa):
|
||||
sa.handle("spawn y 要终止的任务")
|
||||
import time; time.sleep(0.1)
|
||||
agent_id = list(sa.agents.keys())[0]
|
||||
result = sa.handle(f"kill {agent_id}")
|
||||
assert "已终止" in result
|
||||
assert "已终止" in as_text(result)
|
||||
|
||||
def test_clean(self, sa):
|
||||
sa.handle("spawn z 清理测试")
|
||||
import time; time.sleep(0.1)
|
||||
result = sa.handle("clean")
|
||||
assert "已清理" in result
|
||||
assert "已清理" in as_text(result)
|
||||
|
||||
def test_invalid_agent(self, sa):
|
||||
result = sa.handle("status nonexistent")
|
||||
assert "未找到" in result
|
||||
assert "未找到" in as_text(result)
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
|
||||
@@ -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,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 colorama import Fore, Style
|
||||
from xcli_core.tool_result import ToolResult, ErrorCode
|
||||
from xcli_core.verbose import vprint
|
||||
|
||||
try:
|
||||
from mcp_client import McpClientManager
|
||||
except Exception:
|
||||
McpClientManager = None
|
||||
|
||||
try:
|
||||
from lsp_client import LspManager
|
||||
except Exception:
|
||||
LspManager = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,6 +58,12 @@ class UnifiedToolManager:
|
||||
# MCP 工具定义缓存
|
||||
self._mcp_tools_cache: Optional[List[Dict]] = None
|
||||
|
||||
# 外部 MCP client 管理器(懒加载)
|
||||
self._mcp_client_manager = None
|
||||
|
||||
# 外部 LSP client 管理器(懒加载)
|
||||
self._lsp_manager = None
|
||||
|
||||
def initialize(self, plugins_dir: str = "plugins", skills_dir: str = "skills") -> None:
|
||||
"""初始化并加载所有工具"""
|
||||
self._plugins_dir = plugins_dir
|
||||
@@ -54,10 +71,14 @@ class UnifiedToolManager:
|
||||
self._load_plugins(plugins_dir)
|
||||
self._load_skills(skills_dir)
|
||||
self._build_mcp_cache()
|
||||
self._load_mcp_servers()
|
||||
self._load_lsp_servers()
|
||||
|
||||
print(f"{Fore.GREEN}[统一工具管理器] 加载完成: {len(self._tools)} 个工具{Style.RESET_ALL}")
|
||||
print(f" - Liugin 协议: {sum(1 for p in self._protocol_map.values() if p == 'liugin')}")
|
||||
print(f" - Skill 协议: {sum(1 for p in self._protocol_map.values() if p == 'skill')}")
|
||||
n_liugin = sum(1 for p in self._protocol_map.values() if p == 'liugin')
|
||||
n_skill = sum(1 for p in self._protocol_map.values() if p == 'skill')
|
||||
print(f"{Fore.GREEN}已加载 {len(self._tools)} 个工具{Style.RESET_ALL}"
|
||||
f"{Fore.BLACK}{Style.BRIGHT} (Liugin {n_liugin} · Skill {n_skill}"
|
||||
f",--verbose 看明细){Style.RESET_ALL}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""关闭管理器,清理所有插件实例"""
|
||||
@@ -144,7 +165,7 @@ class UnifiedToolManager:
|
||||
self._skill_loader = SkillLoader(skills_dir)
|
||||
|
||||
discovered = self._skill_loader.discover_skills(skills_dir)
|
||||
print(f"{Fore.CYAN}发现 {len(discovered)} 个技能: {[s['name'] for s in discovered]}{Style.RESET_ALL}")
|
||||
vprint(f"{Fore.CYAN}发现 {len(discovered)} 个技能: {[s['name'] for s in discovered]}{Style.RESET_ALL}")
|
||||
|
||||
self._skill_loader.load_all_skills(skills_dir)
|
||||
|
||||
@@ -182,7 +203,7 @@ class UnifiedToolManager:
|
||||
}
|
||||
self._protocol_map[skill_name] = 'skill'
|
||||
|
||||
print(f"{Fore.GREEN}已加载 Skill ({skill_format}): {skill_name}{Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}已加载 Skill ({skill_format}): {skill_name}{Style.RESET_ALL}")
|
||||
|
||||
except ImportError as e:
|
||||
print(f"{Fore.YELLOW}Skill 协议模块未找到: {e}{Style.RESET_ALL}")
|
||||
@@ -299,6 +320,236 @@ class UnifiedToolManager:
|
||||
}
|
||||
}
|
||||
|
||||
# ==================== 外部 MCP 客户端桥接 ====================
|
||||
|
||||
def _load_mcp_servers(self) -> None:
|
||||
"""连接 config.json 中声明的外部 MCP server,将其工具桥接进统一注册表。
|
||||
|
||||
外部工具以 ``{server}__{tool}`` 命名注册,schema(inputSchema)保真,
|
||||
模型可像调用本地工具一样调用外部 MCP 工具。
|
||||
"""
|
||||
if McpClientManager is None:
|
||||
return
|
||||
try:
|
||||
from config import load_config
|
||||
except ImportError:
|
||||
try:
|
||||
from xcli_core.config import load_config
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
servers = cfg.get("mcpServers") or {}
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not servers:
|
||||
return
|
||||
|
||||
if self._mcp_client_manager is None:
|
||||
self._mcp_client_manager = McpClientManager()
|
||||
|
||||
# unified_tool_manager.py 位于项目根,故 dirname(__file__) 即项目根
|
||||
project_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
try:
|
||||
self._mcp_client_manager.connect_all(servers, cwd=project_dir)
|
||||
except Exception as e:
|
||||
print(f"{Fore.YELLOW}加载外部 MCP server 失败: {e}{Style.RESET_ALL}")
|
||||
return
|
||||
|
||||
added = 0
|
||||
for server_name, tool_def in self._mcp_client_manager.get_all_tools():
|
||||
remote_name = tool_def.get("name")
|
||||
if not remote_name:
|
||||
continue
|
||||
local_name = f"{server_name}__{remote_name}"
|
||||
mcp_def = {
|
||||
"name": local_name,
|
||||
"description": tool_def.get("description", ""),
|
||||
"inputSchema": tool_def.get("inputSchema", {"type": "object", "properties": {}}),
|
||||
}
|
||||
handler = self._make_external_handler(server_name, remote_name)
|
||||
# instance 存 manager,shutdown 时统一 cleanup(McpClientManager.cleanup 幂等)
|
||||
self.register_tool(
|
||||
local_name, handler,
|
||||
description=mcp_def["description"],
|
||||
protocol="mcp",
|
||||
mcp_definition=mcp_def,
|
||||
instance=self._mcp_client_manager,
|
||||
)
|
||||
added += 1
|
||||
|
||||
if added:
|
||||
n_servers = len(self._mcp_client_manager.get_servers())
|
||||
print(f"{Fore.GREEN}已桥接 {added} 个外部 MCP 工具{Style.RESET_ALL}"
|
||||
f"{Fore.BLACK}{Style.BRIGHT} (来自 {n_servers} 个 server){Style.RESET_ALL}")
|
||||
|
||||
def _make_external_handler(self, server_name: str, remote_name: str):
|
||||
"""为某个外部 MCP 工具生成 handler:解析参数 → call_tool → ToolResult"""
|
||||
def handler(args):
|
||||
try:
|
||||
if isinstance(args, dict):
|
||||
arguments = args
|
||||
elif isinstance(args, str) and args.strip().startswith("{"):
|
||||
arguments = json.loads(args)
|
||||
else:
|
||||
arguments = {"args": args}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
arguments = {"args": args}
|
||||
|
||||
try:
|
||||
result_text = self._mcp_client_manager.call_tool(server_name, remote_name, arguments)
|
||||
return ToolResult.ok(result_text, tool_name=f"{server_name}__{remote_name}")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(str(e), ErrorCode.EXEC_FAILED,
|
||||
tool_name=f"{server_name}__{remote_name}")
|
||||
return handler
|
||||
|
||||
# ==================== LSP 协议支持 ====================
|
||||
def _load_lsp_servers(self) -> None:
|
||||
"""加载外部语言服务器(LSP),把真实诊断/智能感知桥接为统一工具。
|
||||
|
||||
配置:config.json 顶层 lspServers = {语言或扩展名: {command, args, env, disabled}}
|
||||
单个 server 失败不影响其他 server 与主程序。
|
||||
"""
|
||||
if LspManager is None:
|
||||
return
|
||||
try:
|
||||
from config import load_config
|
||||
except ImportError:
|
||||
try:
|
||||
from xcli_core.config import load_config
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
servers = cfg.get("lspServers") or {}
|
||||
except Exception:
|
||||
return
|
||||
if not servers or not isinstance(servers, dict):
|
||||
return
|
||||
|
||||
# unified_tool_manager.py 位于项目根,故 dirname(__file__) 即项目根
|
||||
project_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
norm = project_dir.replace("\\", "/")
|
||||
if norm.startswith("/"):
|
||||
norm = norm.lstrip("/")
|
||||
root_uri = "file:///" + norm
|
||||
|
||||
try:
|
||||
manager = LspManager()
|
||||
manager.connect_all(servers, cwd=project_dir, root_uri=root_uri)
|
||||
except Exception as e:
|
||||
print(f"{Fore.YELLOW}加载外部 LSP server 失败: {e}{Style.RESET_ALL}")
|
||||
return
|
||||
|
||||
if not manager.get_servers():
|
||||
# 没有成功连接的 server(多半是没装),不注册工具
|
||||
return
|
||||
|
||||
self._lsp_manager = manager
|
||||
|
||||
# 注册 5 个统一的 LSP 工具(模型可按需调用;由 manager 按扩展名路由)
|
||||
lsp_tools = [
|
||||
("lsp__diagnostics", "获取文件的真实语言诊断(错误/警告/提示),来自配置的语言服务器",
|
||||
{"file": {"type": "string", "description": "文件路径"}},
|
||||
"diagnostics"),
|
||||
("lsp__hover", "获取指定位置的符号悬停信息(类型/文档)",
|
||||
{"file": {"type": "string", "description": "文件路径"},
|
||||
"line": {"type": "integer", "description": "行号(0 基)"},
|
||||
"character": {"type": "integer", "description": "列号(0 基)"}},
|
||||
"hover"),
|
||||
("lsp__definition", "跳转到符号的定义位置",
|
||||
{"file": {"type": "string", "description": "文件路径"},
|
||||
"line": {"type": "integer", "description": "行号(0 基)"},
|
||||
"character": {"type": "integer", "description": "列号(0 基)"}},
|
||||
"definition"),
|
||||
("lsp__references", "查找符号的所有引用位置",
|
||||
{"file": {"type": "string", "description": "文件路径"},
|
||||
"line": {"type": "integer", "description": "行号(0 基)"},
|
||||
"character": {"type": "integer", "description": "列号(0 基)"}},
|
||||
"references"),
|
||||
("lsp__completion", "获取指定位置的代码补全项",
|
||||
{"file": {"type": "string", "description": "文件路径"},
|
||||
"line": {"type": "integer", "description": "行号(0 基)"},
|
||||
"character": {"type": "integer", "description": "列号(0 基)"}},
|
||||
"completion"),
|
||||
]
|
||||
for tool_name, desc, props, method in lsp_tools:
|
||||
mcp_def = {
|
||||
"name": tool_name,
|
||||
"description": desc,
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": list(props.keys()),
|
||||
},
|
||||
}
|
||||
handler = self._make_lsp_handler(method)
|
||||
self.register_tool(
|
||||
tool_name, handler,
|
||||
description=desc,
|
||||
protocol="lsp",
|
||||
mcp_definition=mcp_def,
|
||||
instance=self._lsp_manager,
|
||||
)
|
||||
|
||||
n_servers = len(manager.get_servers())
|
||||
print(f"{Fore.GREEN}已桥接 LSP 智能感知{Style.RESET_ALL}"
|
||||
f"{Fore.BLACK}{Style.BRIGHT} (来自 {n_servers} 个语言服务器){Style.RESET_ALL}")
|
||||
|
||||
def _make_lsp_handler(self, method: str):
|
||||
"""为某个 LSP 能力生成 handler:解析参数 → 调用 manager → ToolResult"""
|
||||
def handler(args):
|
||||
try:
|
||||
if isinstance(args, dict):
|
||||
params = args
|
||||
elif isinstance(args, str) and args.strip().startswith("{"):
|
||||
params = json.loads(args)
|
||||
else:
|
||||
params = {"args": args}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
params = {"args": args}
|
||||
|
||||
file_path = params.get("file") or params.get("path") or ""
|
||||
line = int(params.get("line", 0) or 0)
|
||||
character = int(params.get("character", 0) or 0)
|
||||
if not file_path:
|
||||
return ToolResult.fail("缺少 file 参数", ErrorCode.INVALID_PARAM,
|
||||
tool_name=f"lsp__{method}")
|
||||
|
||||
try:
|
||||
if method == "diagnostics":
|
||||
result_text = self._lsp_manager.diagnostics(file_path)
|
||||
elif method == "hover":
|
||||
result_text = self._lsp_manager.hover(file_path, line, character)
|
||||
elif method == "definition":
|
||||
result_text = self._lsp_manager.definition(file_path, line, character)
|
||||
elif method == "references":
|
||||
result_text = self._lsp_manager.references(file_path, line, character)
|
||||
elif method == "completion":
|
||||
result_text = self._lsp_manager.completion(file_path, line, character)
|
||||
else:
|
||||
return ToolResult.fail(f"未知 LSP 方法: {method}", ErrorCode.NOT_IMPLEMENTED,
|
||||
tool_name=f"lsp__{method}")
|
||||
return ToolResult.ok(result_text, tool_name=f"lsp__{method}")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(str(e), ErrorCode.EXEC_FAILED,
|
||||
tool_name=f"lsp__{method}")
|
||||
return handler
|
||||
|
||||
def get_lsp_context(self) -> str:
|
||||
"""返回 LSP 诊断汇总(供系统提示自动注入模型上下文)"""
|
||||
if self._lsp_manager is None:
|
||||
return ""
|
||||
try:
|
||||
return self._lsp_manager.get_context()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# ==================== 公共 API ====================
|
||||
|
||||
@property
|
||||
|
||||
+13
-2
@@ -24,6 +24,17 @@ import os
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
try:
|
||||
from xcli_core.tool_result import normalize_tool_text
|
||||
except Exception:
|
||||
def normalize_tool_text(result, default="无结果"):
|
||||
"""兜底归一化:ToolResult / dict / str 统一成字符串"""
|
||||
if result is None:
|
||||
return default
|
||||
if isinstance(result, dict):
|
||||
return str(result.get("result", default))
|
||||
return str(result)
|
||||
|
||||
# 全局变量
|
||||
websocket_server = None # 当前活动的服务器实例(供外部访问)
|
||||
proxy_client = None
|
||||
@@ -583,7 +594,7 @@ class LocalServer:
|
||||
await self._send_tool_status(websocket, tool_name, tool_args, "calling")
|
||||
|
||||
result = self.cli.process_tool_call(tool_data)
|
||||
result_text = result.get('result', '无结果')
|
||||
result_text = normalize_tool_text(result)
|
||||
results.append(result_text)
|
||||
|
||||
if websocket:
|
||||
@@ -957,7 +968,7 @@ class ProxyClient:
|
||||
results = []
|
||||
for tool_data in tool_calls:
|
||||
result = self.cli.process_tool_call(tool_data)
|
||||
results.append(result.get('result', '无结果'))
|
||||
results.append(normalize_tool_text(result))
|
||||
|
||||
self.cli.shared_conversation_history.append({
|
||||
"role": "assistant",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
from .config import get_system_config, set_system_config, logger
|
||||
from .verbose import vprint
|
||||
from .plugin_manager import LiuginManager
|
||||
from .process_protection import get_protection, enable_process_protection
|
||||
|
||||
@@ -70,16 +71,23 @@ class BaseAICLI:
|
||||
# 设置默认引擎,优先使用配置文件指定的引擎
|
||||
if default_engine_name in self.engines:
|
||||
self.current_engine = self.engines[default_engine_name]
|
||||
print(f"{Fore.GREEN}使用配置的默认AI引擎: {default_engine_name}{Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}使用配置的默认AI引擎: {default_engine_name}{Style.RESET_ALL}")
|
||||
elif self.engines:
|
||||
first_engine_name = next(iter(self.engines))
|
||||
self.current_engine = self.engines[first_engine_name]
|
||||
print(f"{Fore.GREEN}使用默认AI引擎: {first_engine_name}{Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}使用默认AI引擎: {first_engine_name}{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.RED}警告: 没有可用的AI引擎,请检查ai_engines目录{Style.RESET_ALL}")
|
||||
self.current_engine = None
|
||||
if self.current_engine:
|
||||
self.current_engine.cli = self # 设置引用以便访问插件
|
||||
# 记录当前模型(openai 引擎注册表名,其它引擎取 model 值)
|
||||
try:
|
||||
self.current_model = self.current_engine.current_model_name()
|
||||
except Exception:
|
||||
self.current_model = getattr(self.current_engine, 'model', None)
|
||||
else:
|
||||
self.current_model = None
|
||||
|
||||
# 初始化安全层
|
||||
from .safety import get_safety
|
||||
@@ -89,6 +97,21 @@ class BaseAICLI:
|
||||
self.chat_history_dir = os.path.join(project_dir, "chat_history")
|
||||
if not os.path.exists(self.chat_history_dir):
|
||||
os.makedirs(self.chat_history_dir)
|
||||
# 初始化会话管理器(opencode 式 /resume 持久化)
|
||||
self.init_session_manager()
|
||||
# Plan 模式状态(只读规划 → 审批 → 执行)
|
||||
self.plan_mode = False
|
||||
self.current_plan = ""
|
||||
# Agents 管理器(opencode 式 @委派,加载 agents/ 目录下的 *.md)
|
||||
self.agent_manager = None
|
||||
try:
|
||||
from .agent_manager import AgentManager
|
||||
self.agent_manager = AgentManager()
|
||||
agents_dir = os.path.join(project_dir, "agents")
|
||||
os.makedirs(agents_dir, exist_ok=True)
|
||||
self.agent_manager.load_dir(agents_dir)
|
||||
except Exception:
|
||||
self.agent_manager = None
|
||||
|
||||
# 获取 skills 目录路径
|
||||
skills_dir = os.path.join(project_dir, "skills")
|
||||
@@ -153,7 +176,7 @@ class BaseAICLI:
|
||||
|
||||
def load_ai_engines(self):
|
||||
"""动态加载AI引擎插件"""
|
||||
print(f"{Fore.GREEN}正在加载AI引擎插件...{Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}正在加载AI引擎插件...{Style.RESET_ALL}")
|
||||
ai_engines_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ai_engines")
|
||||
if os.path.exists(ai_engines_dir):
|
||||
for filename in os.listdir(ai_engines_dir):
|
||||
@@ -171,7 +194,7 @@ class BaseAICLI:
|
||||
engine_key = getattr(engine_instance, 'name', engine_name)
|
||||
if engine_key not in self.engines:
|
||||
self.engines[engine_key] = engine_instance
|
||||
print(f"{Fore.GREEN}已加载AI引擎: {engine_key} ({class_name}){Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}已加载AI引擎: {engine_key} ({class_name}){Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.YELLOW}AI引擎 {engine_key} 已存在,跳过加载{Style.RESET_ALL}")
|
||||
else:
|
||||
@@ -185,7 +208,7 @@ class BaseAICLI:
|
||||
except Exception as e:
|
||||
logger.error(f"加载AI引擎插件时发生未知错误 {filename}: {e}")
|
||||
print(f"{Fore.RED}加载AI引擎插件失败 {filename}: {e}{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}总共加载了 {len(self.engines)} 个AI引擎{Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}总共加载了 {len(self.engines)} 个AI引擎{Style.RESET_ALL}")
|
||||
|
||||
def _find_engine_class(self, module, engine_name):
|
||||
"""查找AI引擎类,使用动态方式避免硬编码类名"""
|
||||
@@ -213,7 +236,7 @@ class BaseAICLI:
|
||||
|
||||
def load_liugins(self, liugins_dir=None, skills_dir=None):
|
||||
"""加载liugin和技能"""
|
||||
print(f"{Fore.GREEN}正在加载工具...{Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}正在加载工具...{Style.RESET_ALL}")
|
||||
|
||||
if self._use_unified_manager:
|
||||
self.liugin_manager.initialize(liugins_dir or "plugins", skills_dir or "skills")
|
||||
@@ -242,19 +265,19 @@ class BaseAICLI:
|
||||
tool_info = plugin_instance.get_tool_info()
|
||||
tool_info['handler'] = plugin_instance.handle
|
||||
self.liugin_manager.tools.append(tool_info)
|
||||
print(f"{Fore.GREEN}已加载liugin: {liugin_name}{Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}已加载liugin: {liugin_name}{Style.RESET_ALL}")
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}总共加载了 {len(self.liugin_manager.tools)} 个工具插件{Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}总共加载了 {len(self.liugin_manager.tools)} 个工具插件{Style.RESET_ALL}")
|
||||
|
||||
print(f"{Fore.GREEN}工具加载完成!{Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}工具加载完成!{Style.RESET_ALL}")
|
||||
|
||||
# ── 命令注册 ──
|
||||
|
||||
def register_liugin_command(self, command, handler):
|
||||
"""注册插件命令"""
|
||||
self.liugin_commands[command] = handler
|
||||
print(f"{Fore.GREEN}已注册插件命令: {command}{Style.RESET_ALL}")
|
||||
vprint(f"{Fore.GREEN}已注册插件命令: {command}{Style.RESET_ALL}")
|
||||
|
||||
def unregister_liugin_command(self, command):
|
||||
"""注销插件命令"""
|
||||
@@ -305,6 +328,124 @@ class BaseAICLI:
|
||||
help_lines.append(f" /engine {cmd:<10} - 执行{cmd}命令")
|
||||
return '\n'.join(help_lines) if help_lines else " 无可用引擎管理命令"
|
||||
|
||||
# ── 模型管理(OpenAI 引擎多模型切换)──
|
||||
|
||||
def handle_model_command(self, args):
|
||||
"""处理 /model 命令族。仅对 openai 引擎有效。"""
|
||||
eng = self.current_engine
|
||||
if getattr(eng, 'name', None) != 'openai':
|
||||
cur = getattr(eng, 'name', '?')
|
||||
print(f"{Fore.RED}模型管理仅适用于 openai 引擎(当前: {cur})。"
|
||||
f"先 /engine switch openai{Style.RESET_ALL}")
|
||||
return
|
||||
|
||||
if not args or args == 'list':
|
||||
self._model_list(eng)
|
||||
return
|
||||
|
||||
parts = args.split(' ', 1)
|
||||
cmd = parts[0]
|
||||
rest = parts[1].strip() if len(parts) > 1 else ''
|
||||
if cmd == 'add':
|
||||
self._model_add_interactive(eng)
|
||||
elif cmd in ('rm', 'remove', 'del'):
|
||||
if not rest:
|
||||
print(f"{Fore.RED}用法: /model rm <名称>{Style.RESET_ALL}")
|
||||
return
|
||||
if eng.remove_model(rest):
|
||||
print(f"{Fore.GREEN}已删除模型: {rest}{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.RED}未找到模型: {rest}{Style.RESET_ALL}")
|
||||
elif cmd in ('set', 'switch'):
|
||||
self._model_switch(eng, rest)
|
||||
else:
|
||||
# 无子命令:/model <名称> 视为切换
|
||||
self._model_switch(eng, cmd)
|
||||
|
||||
def _model_list(self, eng):
|
||||
models, current = eng.list_registry_models()
|
||||
if not models:
|
||||
print(f"{Fore.YELLOW}尚未配置任何模型,用 /model add 添加{Style.RESET_ALL}")
|
||||
return
|
||||
print(f"{Fore.GREEN}已配置的 OpenAI 模型:{Style.RESET_ALL}")
|
||||
for m in models:
|
||||
mark = f"{Fore.CYAN} *{Style.RESET_ALL}" if m.get('name') == current else ""
|
||||
print(f" {m.get('name')}{mark} ({m.get('model')} @ {m.get('base_url') or '无 base_url'})")
|
||||
print(f"{Fore.CYAN}当前: {current} | /model <名称> 切换 | /model add 新增 | "
|
||||
f"/model rm <名称> 删除{Style.RESET_ALL}")
|
||||
|
||||
def _model_switch(self, eng, name):
|
||||
if not name:
|
||||
print(f"{Fore.RED}用法: /model <名称> 或 /model 查看列表{Style.RESET_ALL}")
|
||||
return
|
||||
if eng.set_registry_model(name):
|
||||
self.current_model = name
|
||||
print(f"{Fore.GREEN}已切换到模型: {name}{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.RED}未找到模型: {name}(/model 查看可用){Style.RESET_ALL}")
|
||||
|
||||
def _model_add_interactive(self, eng):
|
||||
print(f"{Fore.CYAN}添加 OpenAI 模型(回车留空则用默认/为空){Style.RESET_ALL}")
|
||||
try:
|
||||
name = input(f"{Fore.CYAN}名称(用于切换, 如 gpt4o): {Style.RESET_ALL}").strip()
|
||||
if not name:
|
||||
print(f"{Fore.RED}名称不能为空{Style.RESET_ALL}")
|
||||
return
|
||||
base_url = input(f"{Fore.CYAN}base_url(如 https://api.openai.com/v1): {Style.RESET_ALL}").strip()
|
||||
api_key = input(f"{Fore.CYAN}api_key: {Style.RESET_ALL}").strip()
|
||||
model = input(f"{Fore.CYAN}model(如 gpt-4o): {Style.RESET_ALL}").strip()
|
||||
if not model:
|
||||
print(f"{Fore.RED}model 不能为空{Style.RESET_ALL}")
|
||||
return
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print(f"{Fore.YELLOW}\n已取消添加{Style.RESET_ALL}")
|
||||
return
|
||||
eng.add_model({"name": name, "base_url": base_url, "api_key": api_key, "model": model})
|
||||
eng.set_registry_model(name)
|
||||
self.current_model = name
|
||||
print(f"{Fore.GREEN}已添加并切换到模型: {name}{Style.RESET_ALL}")
|
||||
|
||||
# ── Provider 抽象层(opencode 式原生多 Provider)──
|
||||
def handle_providers_command(self, args):
|
||||
"""处理 /providers 命令:列出已注册 Provider 与配置状态。
|
||||
|
||||
/providers 列出全部内置 Provider 及是否已配置 key
|
||||
/providers <名称> 显示该 Provider 的连接参数(base_url / model / 协议)
|
||||
"""
|
||||
from xcli_core.provider_router import (
|
||||
list_providers, provider_summary, resolve_provider, PROVIDER_PRESETS,
|
||||
)
|
||||
try:
|
||||
cfg = load_config() or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
providers_cfg = cfg.get("providers", {}) if isinstance(cfg, dict) else {}
|
||||
|
||||
if args:
|
||||
name = args.split()[0]
|
||||
preset = PROVIDER_PRESETS.get(name)
|
||||
if not preset:
|
||||
print(f"{Fore.RED}未知 Provider: {name}(/providers 查看可用){Style.RESET_ALL}")
|
||||
return
|
||||
r = resolve_provider(name, providers_cfg)
|
||||
print(f"{Fore.GREEN}Provider: {preset['label']} ({name}){Style.RESET_ALL}")
|
||||
print(f" 协议族: {r['protocol']}")
|
||||
print(f" base_url: {r['base_url']}")
|
||||
print(f" 默认模型: {r['model']}")
|
||||
print(f" 已配置 key: {'是' if r['api_key'] else '否'}")
|
||||
print(f"{Fore.CYAN}在 config.json 的 providers.{name} 填入 {{api_key, base_url, model}} 即可启用;"
|
||||
f"OpenAI 兼容族经现有 openai 引擎直接可用。{Style.RESET_ALL}")
|
||||
return
|
||||
|
||||
print(f"{Fore.GREEN}已注册 Provider(opencode 式多 Provider):{Style.RESET_ALL}")
|
||||
for name in list_providers():
|
||||
s = provider_summary(name, providers_cfg)
|
||||
if not s:
|
||||
continue
|
||||
mark = f"{Fore.GREEN}✓ 已配置{Style.RESET_ALL}" if s["configured"] else f"{Fore.YELLOW}· 未配置{Style.RESET_ALL}"
|
||||
print(f" {s['name']:<10} {s['label']:<22} [{s['protocol']:<9}] {mark}")
|
||||
print(f"{Fore.CYAN}/providers <名称> 查看连接参数 | 在 config.json 的 providers 段填入 key 启用{Style.RESET_ALL}")
|
||||
|
||||
# ── 引擎切换 ──
|
||||
|
||||
def get_available_engines(self):
|
||||
|
||||
@@ -3,6 +3,7 @@ import time
|
||||
from colorama import Fore, Style
|
||||
|
||||
from .constants import CLAWLI_SERVER_AVAILABLE, WEBSOCKET_AVAILABLE
|
||||
from .tool_result import normalize_tool_text
|
||||
|
||||
try:
|
||||
import clawli_server
|
||||
@@ -96,6 +97,7 @@ class ClawliMixin:
|
||||
tool_calls = self._extract_tool_calls(json_data)
|
||||
|
||||
if tool_calls:
|
||||
result_texts = []
|
||||
for tool_data in tool_calls:
|
||||
tool_name = tool_data.get('tool', '未知工具')
|
||||
tool_args = tool_data.get('args', '')
|
||||
@@ -104,7 +106,8 @@ class ClawliMixin:
|
||||
clawli_server.send_tool_status(tool_name, tool_args, "calling")
|
||||
|
||||
result = self.process_tool_call(tool_data)
|
||||
result_text = result.get('result', '无结果')
|
||||
result_text = normalize_tool_text(result)
|
||||
result_texts.append(result_text)
|
||||
|
||||
if CLAWLI_SERVER_AVAILABLE and clawli_server:
|
||||
clawli_server.send_tool_status(tool_name, tool_args, "success", result_text)
|
||||
@@ -114,7 +117,8 @@ class ClawliMixin:
|
||||
"content": response
|
||||
})
|
||||
|
||||
tool_result_str = "\n".join([f"工具执行结果: {r}" for r in [result.get('result', '')]])
|
||||
# 汇总全部工具结果(此前只回传了最后一个)
|
||||
tool_result_str = "\n".join([f"工具执行结果: {r}" for r in result_texts])
|
||||
self._process_clawli_user_message(f"{tool_result_str}\n请根据工具执行结果继续回答。")
|
||||
return
|
||||
|
||||
|
||||
+461
-121
@@ -9,8 +9,8 @@ import random
|
||||
from typing import Optional
|
||||
from colorama import Fore, Style
|
||||
|
||||
from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY
|
||||
from .config import get_system_config, logger
|
||||
from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY, VERSION
|
||||
from .config import get_system_config, set_system_config, logger
|
||||
from .cli_base import BaseAICLI
|
||||
from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL
|
||||
from .cli_clawli import ClawliMixin
|
||||
@@ -22,6 +22,17 @@ from .plugin_market import PluginMarketMixin
|
||||
from .notification import notify_task_complete, get_notification_manager
|
||||
from .process_protection import get_protection_status
|
||||
|
||||
# ── Plan 模式 system prompt 追加段(只读规划约束)──
|
||||
PLAN_MODE_SYSTEM_APPEND = """
|
||||
|
||||
【PLAN 模式 — 只读规划】
|
||||
你当前处于 PLAN 模式。此模式下你只能进行调查、阅读、分析,不得执行任何修改。
|
||||
- 你可以使用只读工具调研代码库:code_search(搜索/结构/符号/统计)、file_manager 的 list/read/info/search、code_editor 的 read_range/find/diff、git_tools 的 status/log/diff/show/blame、tool_search(查询工具用法)、ai_search(联网搜索)。
|
||||
- 你**不能**执行写操作(编辑/创建/删除文件、运行 shell 命令、git commit/push/checkout、委派会修改的子代理等)——这些会被系统拦截并返回提示。
|
||||
- 调研完成后,输出一份清晰的、带编号步骤的「实施计划」,让用户审批。计划应包含:每步要做什么、涉及哪些文件/函数、预期结果、潜在风险。
|
||||
- 计划以如下标记开头:## 实施计划
|
||||
- 完成计划后停止,等待用户输入 /build 批准执行,或继续追问以完善计划。不要自行开始修改。"""
|
||||
|
||||
|
||||
class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, HistoryMixin, PluginMarketMixin):
|
||||
"""AI CLI主程序 - 组合所有 Mixin"""
|
||||
@@ -31,7 +42,7 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
|
||||
def process_thinking_response(self, response):
|
||||
"""处理深度思考AI返回的内容, 分离思考和回复内容"""
|
||||
try:
|
||||
response_data = json.loads(response)
|
||||
response_data = self._loads_json(response)
|
||||
if isinstance(response_data, dict) and response_data.get('action') == 'use_tool':
|
||||
return response
|
||||
if isinstance(response_data, dict):
|
||||
@@ -143,7 +154,20 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
|
||||
你的思考过程:
|
||||
1. 需要 git 操作 → 先查 git_tools 怎么用
|
||||
2. {{"action": "use_tool", "tool": "tool_search", "args": "git_tools"}} → 发现 smart-commit 操作
|
||||
3. {{"action": "use_tool", "tool": "git_tools", "args": "smart-commit"}} → 自动提交"""
|
||||
3. {{"action": "use_tool", "tool": "git_tools", "args": "smart-commit"}} → 自动提交 """
|
||||
|
||||
# 参数格式统一示例:无论用哪种工具,args 都是同一个字符串外壳,给 AI 一个速查锚点
|
||||
unified_arg_example = """【参数格式统一示例】
|
||||
所有工具共用同一套 JSON 外壳,`args` 永远是【字符串】(不是 JSON 对象):
|
||||
{"action":"use_tool","tool":"<工具名>","args":"<操作> <参数>"}
|
||||
|
||||
高频工具的 args 写法(照抄即可):
|
||||
- code_editor : "edit 文件 旧<<<>>>新" | "read_range 文件 起 止" | "create 文件 内容"
|
||||
- code_search : "find . 关键词 *.py" | "structure ."
|
||||
- git_tools : "status" | "commit 说明文字"
|
||||
- cmd_executor: "dir" | "python main.py"
|
||||
说明:多行内容(如 edit 的代码片段)直接换行写在 args 里,无需转义;一次可返回多个 JSON 指令。
|
||||
调用方式可选:纯 JSON(默认)或 function calling(引擎支持时),两种都会被正确处理。"""
|
||||
|
||||
if liugin_prompts:
|
||||
prompt = f"""{base_prompt}
|
||||
@@ -152,6 +176,8 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
|
||||
|
||||
{tool_search_rule}
|
||||
|
||||
{unified_arg_example}
|
||||
|
||||
【工具调用格式】
|
||||
在回复中包含 JSON 指令来执行操作:
|
||||
|
||||
@@ -219,6 +245,8 @@ multi 操作支持一次修改多处:
|
||||
|
||||
{tool_search_rule}
|
||||
|
||||
{unified_arg_example}
|
||||
|
||||
【工具调用格式】
|
||||
{{"action": "use_tool", "tool": "工具名", "args": "参数"}}
|
||||
|
||||
@@ -253,6 +281,20 @@ multi 操作支持一次修改多处:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Plan 模式:追加只读规划约束 ──
|
||||
if getattr(self, 'plan_mode', False):
|
||||
prompt += PLAN_MODE_SYSTEM_APPEND
|
||||
|
||||
# ── LSP 诊断自动注入:让模型持续看到真实语言诊断 ──
|
||||
liugin_manager = getattr(self, 'liugin_manager', None)
|
||||
if liugin_manager and hasattr(liugin_manager, 'get_lsp_context'):
|
||||
try:
|
||||
lsp_ctx = liugin_manager.get_lsp_context()
|
||||
if lsp_ctx:
|
||||
prompt += "\n\n# 当前代码诊断(来自 LSP 语言服务器)\n" + lsp_ctx
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return prompt
|
||||
|
||||
# ── JSON 解析 ──
|
||||
@@ -285,11 +327,15 @@ multi 操作支持一次修改多处:
|
||||
plugins.append(tool)
|
||||
|
||||
if plugins:
|
||||
prompts.append("## 内置工具(使用 tool_search 查询详细用法):")
|
||||
for tool in plugins:
|
||||
tool_name = tool.get('name', '')
|
||||
tool_desc = tool.get('description', '')
|
||||
prompts.append(f"- {tool_name}: {tool_desc}")
|
||||
# 工具名清单(纯 JSON 模式:不发送 FC schema,模型按 action JSON 调用)。
|
||||
# 只列名字 + 由 tool_search 提供用法,避免重复曝光、省 token。
|
||||
names = ", ".join(t.get('name', '') for t in plugins)
|
||||
prompts.append(
|
||||
"## 内置工具(纯 JSON 模式:不发送 function calling 的 tools schema,"
|
||||
"模型按下方 JSON 格式直接调用,调用前请先用 tool_search 查询其详细用法与示例)\n"
|
||||
f"工具名: {names}\n"
|
||||
'调用格式: {"action": "use_tool", "tool": "工具名", "args": "参数"}'
|
||||
)
|
||||
|
||||
if skills:
|
||||
prompts.append("\n## 扩展技能(已加载详细指令):")
|
||||
@@ -328,6 +374,18 @@ multi 操作支持一次修改多处:
|
||||
|
||||
return "\n".join(prompts)
|
||||
|
||||
def _loads_json(self, s):
|
||||
"""json.loads,但容忍未成对出现的反斜杠(Windows 路径 C:\\Users 常见)。
|
||||
|
||||
纯 JSON 模式下模型常把路径写成单反斜杠,直接 json.loads 会报非法转义。
|
||||
这里先按标准解析;失败时把「未成对的反斜杠」转义后再试一次。
|
||||
"""
|
||||
try:
|
||||
return json.loads(s)
|
||||
except json.JSONDecodeError:
|
||||
fixed = re.sub(r'(?<!\\)\\(?!\\\\)', r'\\\\', s)
|
||||
return json.loads(fixed)
|
||||
|
||||
def _parse_mixed_response(self, response):
|
||||
"""
|
||||
解析混合响应,处理同时包含文本和JSON指令的内容
|
||||
@@ -340,7 +398,7 @@ multi 操作支持一次修改多处:
|
||||
json_str = response[11:]
|
||||
json_str = "{" + json_str
|
||||
try:
|
||||
parsed = json.loads(json_str)
|
||||
parsed = self._loads_json(json_str)
|
||||
if isinstance(parsed, dict):
|
||||
return "", parsed
|
||||
except json.JSONDecodeError:
|
||||
@@ -348,7 +406,7 @@ multi 操作支持一次修改多处:
|
||||
|
||||
# 方法1: 直接解析为纯JSON
|
||||
try:
|
||||
parsed = json.loads(response)
|
||||
parsed = self._loads_json(response)
|
||||
if isinstance(parsed, (list, dict)):
|
||||
return "", parsed
|
||||
except json.JSONDecodeError:
|
||||
@@ -365,7 +423,7 @@ multi 操作支持一次修改多处:
|
||||
content = content.replace('"', '\\"')
|
||||
return f'"{content}"'
|
||||
fixed_response = re.sub(r'"([^"]*(?:\n[^"]*)*)"', fix_json_string, response, flags=re.DOTALL)
|
||||
parsed = json.loads(fixed_response)
|
||||
parsed = self._loads_json(fixed_response)
|
||||
if isinstance(parsed, (list, dict)):
|
||||
return "", parsed
|
||||
except:
|
||||
@@ -376,7 +434,7 @@ multi 操作支持一次修改多处:
|
||||
matches = re.findall(json_pattern, response, re.DOTALL)
|
||||
if matches:
|
||||
try:
|
||||
json_data = json.loads(matches[-1])
|
||||
json_data = self._loads_json(matches[-1])
|
||||
if isinstance(json_data, (dict, list)):
|
||||
text_content = re.sub(json_pattern, "", response, flags=re.DOTALL).strip()
|
||||
text_content = re.sub(r"\n\s*\n", "\n\n", text_content).strip()
|
||||
@@ -392,7 +450,7 @@ multi 操作支持一次修改多处:
|
||||
line = line.strip()
|
||||
if line.startswith("{") and line.endswith("}"):
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
parsed = self._loads_json(line)
|
||||
if isinstance(parsed, dict):
|
||||
json_objects.append(parsed)
|
||||
continue
|
||||
@@ -430,7 +488,7 @@ multi 操作支持一次修改多处:
|
||||
if in_json and brace_count == 0:
|
||||
json_str = response[json_start:i+1]
|
||||
try:
|
||||
json_data = json.loads(json_str)
|
||||
json_data = self._loads_json(json_str)
|
||||
if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']:
|
||||
text_content = response[:json_start].strip()
|
||||
return text_content, json_data
|
||||
@@ -442,7 +500,7 @@ multi 操作支持一次修改多处:
|
||||
potential_jsons = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL)
|
||||
for json_str in potential_jsons:
|
||||
try:
|
||||
json_data = json.loads(json_str)
|
||||
json_data = self._loads_json(json_str)
|
||||
if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']:
|
||||
json_start = response.find(json_str)
|
||||
text_content = response[:json_start].strip()
|
||||
@@ -460,12 +518,12 @@ multi 操作支持一次修改多处:
|
||||
if last_brace_pos != -1:
|
||||
json_part = cleaned[last_brace_pos:]
|
||||
if json_part.startswith('['):
|
||||
parsed = json.loads(json_part)
|
||||
parsed = self._loads_json(json_part)
|
||||
if isinstance(parsed, list):
|
||||
text_content = cleaned[:last_brace_pos].strip()
|
||||
return text_content, parsed
|
||||
else:
|
||||
parsed = json.loads(json_part)
|
||||
parsed = self._loads_json(json_part)
|
||||
if isinstance(parsed, dict):
|
||||
text_content = cleaned[:last_brace_pos].strip()
|
||||
return text_content, parsed
|
||||
@@ -714,17 +772,34 @@ multi 操作支持一次修改多处:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 自动压缩上下文 ──
|
||||
# ── 自动压缩上下文(token 感知优先,消息数兜底)──
|
||||
try:
|
||||
if self.memory_manager.should_compress(self.shared_conversation_history):
|
||||
self.shared_conversation_history = self.memory_manager.compress_context(
|
||||
self.shared_conversation_history,
|
||||
engine=self.current_engine
|
||||
)
|
||||
self._set_shared_conversation_history()
|
||||
print(f"{Fore.DIM}[上下文已压缩,保留最近 10 条]{Style.RESET_ALL}")
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(self, 'memory_manager') and self.memory_manager:
|
||||
engine = self.current_engine
|
||||
prompt_tokens = getattr(engine, 'last_prompt_tokens', None) if engine else None
|
||||
# 统一 API:所有引擎必须返回 max_input_tokens(最大输入 token 数)
|
||||
ctx = None
|
||||
if engine:
|
||||
try:
|
||||
ctx = engine.max_input_tokens
|
||||
except Exception:
|
||||
ctx = None
|
||||
ratio = get_system_config('compress_ratio', 0.7)
|
||||
self.memory_manager.set_compress_ratio(ratio)
|
||||
if self.memory_manager.should_compress(
|
||||
self.shared_conversation_history, prompt_tokens, ctx, ratio
|
||||
):
|
||||
before = len(self.shared_conversation_history)
|
||||
self.shared_conversation_history = self.memory_manager.compress_context(
|
||||
self.shared_conversation_history,
|
||||
engine=engine
|
||||
)
|
||||
token_info = f",token {prompt_tokens}/{ctx}" if (prompt_tokens and ctx) else ""
|
||||
self._output(
|
||||
f"{Fore.CYAN}🗜 上下文已自动压缩 (原 {before} 条 → {len(self.shared_conversation_history)} 条{token_info},阈值 {ratio}){Style.RESET_ALL}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"自动压缩上下文失败: {e}")
|
||||
|
||||
def _extract_task_summary(self, user_input: str) -> str:
|
||||
"""提取任务摘要用于通知显示"""
|
||||
@@ -744,7 +819,7 @@ multi 操作支持一次修改多处:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(response)
|
||||
data = self._loads_json(response)
|
||||
if not data.get("_fc"):
|
||||
return None
|
||||
|
||||
@@ -779,16 +854,15 @@ multi 操作支持一次修改多处:
|
||||
# 通过插件管理器执行
|
||||
result = self._execute_tool_by_name(tool_name, tool_args)
|
||||
|
||||
# 记录工具结果到历史
|
||||
# 记录工具结果到历史(官方 OpenAI tool 消息不含 name 字段)
|
||||
tool_result_msg = {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"content": result[:5000] if result else "执行完成"
|
||||
"content": result if result else "执行完成"
|
||||
}
|
||||
self.shared_conversation_history.append(tool_result_msg)
|
||||
|
||||
results.append(f"[{tool_name}]: {result[:200] if result else '完成'}")
|
||||
results.append(f"[{tool_name}]: {result if result else '完成'}")
|
||||
|
||||
# 返回工具结果,供下一轮 AI 处理
|
||||
return "工具执行结果:\n" + "\n".join(results)
|
||||
@@ -797,7 +871,13 @@ multi 操作支持一次修改多处:
|
||||
return None
|
||||
|
||||
def _execute_tool_by_name(self, tool_name: str, tool_args: str) -> str:
|
||||
"""根据名称执行工具(FC 调用路径)"""
|
||||
"""根据名称执行工具(FC 调用路径)
|
||||
|
||||
注意:插件 handler 可能返回 ToolResult / dict / str,
|
||||
这里统一归一成 str,保证调用方能安全做切片与拼接。
|
||||
"""
|
||||
from .tool_result import normalize_tool_text
|
||||
|
||||
# 安全检查
|
||||
safety = get_safety()
|
||||
allowed, msg = safety.check(tool_name, tool_args)
|
||||
@@ -808,25 +888,61 @@ multi 操作支持一次修改多处:
|
||||
tool = self.liugin_manager.get_tool_by_name(tool_name)
|
||||
if tool and 'handler' in tool:
|
||||
try:
|
||||
return tool['handler'](tool_args)
|
||||
return normalize_tool_text(tool['handler'](tool_args), default="执行完成")
|
||||
except Exception as e:
|
||||
return f"工具执行失败: {e}"
|
||||
|
||||
# memory 内置工具
|
||||
if tool_name == 'memory' and hasattr(self, 'memory_manager'):
|
||||
return self.memory_manager.handle_tool(tool_args)
|
||||
return normalize_tool_text(self.memory_manager.handle_tool(tool_args), default="执行完成")
|
||||
|
||||
# scheduler 内置工具
|
||||
if tool_name == 'scheduler':
|
||||
for t in self.liugin_manager.tools:
|
||||
if t.get('name') == 'scheduler' and 'handler' in t:
|
||||
try:
|
||||
return t['handler'](tool_args)
|
||||
return normalize_tool_text(t['handler'](tool_args), default="执行完成")
|
||||
except Exception as e:
|
||||
return f"工具执行失败: {e}"
|
||||
|
||||
return f"未找到工具: {tool_name}"
|
||||
|
||||
def _build_fc_tools(self, current_input):
|
||||
"""构建本轮下发给引擎的 FC 工具子集,让引擎自行决定用 FC 结构化调用还是回退纯 JSON。
|
||||
|
||||
引擎不支持 FC(探测缓存未通过)时返回 None —— 引擎自动降级为纯 JSON 模式。
|
||||
仅对带 `_fc_supported()` 的引擎下发 tools(Manual 等不支持的引擎不传 tools,
|
||||
避免关键字参数错误)。
|
||||
"""
|
||||
engine = self.current_engine
|
||||
if not engine or not hasattr(engine, '_fc_supported'):
|
||||
return None
|
||||
try:
|
||||
if not engine._fc_supported():
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
# 工具结果续轮:模型只需决定「继续/收尾」,不需要 FC schema,省下每轮重发的 token。
|
||||
# 若模型仍想调工具,走纯 JSON action(无需 schema 即可),行为等价、成本更低。
|
||||
if current_input and str(current_input).lstrip().startswith("工具执行结果"):
|
||||
return None
|
||||
try:
|
||||
from .fc_tools import mcp_to_openai_tools, prune_tools
|
||||
all_tools = mcp_to_openai_tools(self.liugin_manager)
|
||||
if not all_tools:
|
||||
return None
|
||||
return prune_tools(all_tools, current_input)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _call_engine_with_tools(self, current_input, system_prompt, fc_tools):
|
||||
"""调用当前引擎;fc_tools 非空时下发 FC 工具定义(tool_choice=auto,模型自选)。"""
|
||||
if fc_tools:
|
||||
return self.current_engine.generate_response(
|
||||
current_input, system_prompt=system_prompt, tools=fc_tools)
|
||||
return self.current_engine.generate_response(
|
||||
current_input, system_prompt=system_prompt)
|
||||
|
||||
def _generate_response_with_animation(self, current_input, liugin_prompts=None):
|
||||
"""生成AI响应并显示等待动画 — 支持 ESC 跨平台取消"""
|
||||
# TUI 模式:不做动画
|
||||
@@ -834,14 +950,10 @@ multi 操作支持一次修改多处:
|
||||
system_prompt = self._build_system_prompt(liugin_prompts)
|
||||
if not self.current_engine:
|
||||
return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
|
||||
# TUI 模式也支持 FC
|
||||
fc_tools = None
|
||||
try:
|
||||
from .fc_tools import mcp_to_openai_tools
|
||||
fc_tools = mcp_to_openai_tools(self.liugin_manager)
|
||||
except Exception:
|
||||
pass
|
||||
response = self.current_engine.generate_response(current_input, system_prompt=system_prompt, tools=fc_tools)
|
||||
# 混合模式:下发 FC 工具定义,tool_choice=auto —— 模型可自行选择
|
||||
# 用 FC 结构化调用,或回退到纯 JSON action(两种都会被正确处理)。
|
||||
fc_tools = self._build_fc_tools(current_input)
|
||||
response = self._call_engine_with_tools(current_input, system_prompt, fc_tools)
|
||||
return response
|
||||
|
||||
# CLI 模式:带动画和跨平台 ESC 取消
|
||||
@@ -851,12 +963,18 @@ multi 操作支持一次修改多处:
|
||||
response_ready = threading.Event()
|
||||
response_result = [None] # 用列表存储,方便线程内修改
|
||||
|
||||
# 是否真实交互终端:非 TTY(管道 / 日志镜像 / 聊天前端捕获 stdout)时,
|
||||
# \r 无法原地覆盖,逐帧动画会污染输出流,必须禁用。
|
||||
is_tty = sys.stdout.isatty()
|
||||
|
||||
love_sentences = []
|
||||
love_file_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), LOVE_FILE_PATH)
|
||||
if os.path.exists(love_file_path):
|
||||
try:
|
||||
with open(love_file_path, 'r', encoding='utf-8') as f:
|
||||
love_sentences = [line.strip() for line in f.readlines() if line.strip()]
|
||||
love_sentences = [line.strip() for line in f.readlines()
|
||||
if line.strip()
|
||||
and not line.strip().lower().startswith(('http://', 'https://'))]
|
||||
except Exception:
|
||||
pass
|
||||
if not love_sentences:
|
||||
@@ -903,6 +1021,13 @@ multi 操作支持一次修改多处:
|
||||
|
||||
# ── 动画线程 ──
|
||||
def show_animation():
|
||||
# 非 TTY:不跑逐帧 \r 动画(避免残帧进入捕获流),但保留一句随机情话保人味
|
||||
if not is_tty:
|
||||
try:
|
||||
print(f"💭 {random.choice(love_sentences)}", flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
last_change_time = time.time()
|
||||
current_sentence = random.choice(love_sentences)
|
||||
while animation_running.is_set():
|
||||
@@ -923,19 +1048,9 @@ multi 操作支持一次修改多处:
|
||||
if not self.current_engine:
|
||||
response_result[0] = "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
|
||||
else:
|
||||
# ── 收集 FC 工具定义 ──
|
||||
fc_tools = None
|
||||
try:
|
||||
from .fc_tools import mcp_to_openai_tools
|
||||
fc_tools = mcp_to_openai_tools(self.liugin_manager)
|
||||
if fc_tools:
|
||||
fc_tools = fc_tools # 已经是列表
|
||||
except Exception:
|
||||
fc_tools = None
|
||||
|
||||
response_result[0] = self.current_engine.generate_response(
|
||||
current_input, system_prompt=system_prompt, tools=fc_tools
|
||||
)
|
||||
# 混合模式:下发 FC 工具(tool_choice=auto),模型自选 FC 或纯 JSON
|
||||
fc_tools = self._build_fc_tools(current_input)
|
||||
response_result[0] = self._call_engine_with_tools(current_input, system_prompt, fc_tools)
|
||||
except Exception as e:
|
||||
response_result[0] = f"AI 响应生成失败: {e}"
|
||||
finally:
|
||||
@@ -956,7 +1071,8 @@ multi 操作支持一次修改多处:
|
||||
# ESC 被按下 → 取消
|
||||
if esc_pressed.is_set():
|
||||
# 清除动画行
|
||||
print("\r" + " " * 80 + "\r", end="", flush=True)
|
||||
if is_tty:
|
||||
print("\r" + " " * 80 + "\r", end="", flush=True)
|
||||
print(f"\n{Fore.YELLOW}⚡ AI 请求已取消 (ESC){Style.RESET_ALL}")
|
||||
return "[AI请求已取消]"
|
||||
|
||||
@@ -964,7 +1080,8 @@ multi 操作支持一次修改多处:
|
||||
response = response_result[0]
|
||||
if response:
|
||||
# 清除动画行后再输出
|
||||
print("\r" + " " * 80 + "\r", end="", flush=True)
|
||||
if is_tty:
|
||||
print("\r" + " " * 80 + "\r", end="", flush=True)
|
||||
self._typeprint(response, Fore.CYAN)
|
||||
|
||||
if response and self.shared_conversation_history is not None:
|
||||
@@ -980,79 +1097,187 @@ multi 操作支持一次修改多处:
|
||||
for t in threads:
|
||||
t.join(timeout=1)
|
||||
|
||||
# ── Plan 模式(只读规划 → 审批 → 执行)──
|
||||
|
||||
def _sync_current_plan(self):
|
||||
"""Plan 模式下,把对话历史中最后一条 assistant 内容记为当前计划。"""
|
||||
if not getattr(self, 'plan_mode', False):
|
||||
return
|
||||
for msg in reversed(self.shared_conversation_history):
|
||||
if isinstance(msg, dict) and msg.get("role") == "assistant" and msg.get("content"):
|
||||
self.current_plan = msg["content"]
|
||||
break
|
||||
|
||||
def handle_plan_command(self, args):
|
||||
"""处理 /plan 命令。进入 PLAN 模式(只读规划)。
|
||||
|
||||
/plan 进入模式,等待描述任务
|
||||
/plan <任务> 进入模式并立即只读调研、生成实施计划
|
||||
/plan off|exit 退出模式(计划不执行)
|
||||
"""
|
||||
args = (args or "").strip()
|
||||
if args in ('off', 'exit', '退出', 'cancel'):
|
||||
self.plan_mode = False
|
||||
print(f"{Fore.YELLOW}已退出 PLAN 模式(计划未执行){Style.RESET_ALL}")
|
||||
return
|
||||
|
||||
self.plan_mode = True
|
||||
if args:
|
||||
print(f"{Fore.CYAN}已进入 PLAN 模式,正在只读调研并生成实施计划...{Style.RESET_ALL}")
|
||||
self.process_conversation(args)
|
||||
self._autosave_session()
|
||||
self._sync_current_plan()
|
||||
else:
|
||||
print(f"{Fore.CYAN}已进入 PLAN 模式。{Style.RESET_ALL}")
|
||||
print(f" 描述你的任务,AI 将只做只读调研并给出实施计划;"
|
||||
f"完成后用 {Fore.WHITE}/build{Style.RESET_ALL} 批准执行,或 "
|
||||
f"{Fore.WHITE}/plan off{Style.RESET_ALL} 取消。{Style.RESET_ALL}")
|
||||
|
||||
def handle_agent_command(self, arg: str):
|
||||
"""@<agent名> <任务> 委派给指定 agent 独立执行。"""
|
||||
mgr = getattr(self, 'agent_manager', None)
|
||||
if mgr is None:
|
||||
print(f"{Fore.RED}Agents 管理器未初始化{Style.RESET_ALL}")
|
||||
return
|
||||
if not arg:
|
||||
names = mgr.names()
|
||||
if names:
|
||||
print(f"{Fore.CYAN}可用 Agents: {', '.join(names)}{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.YELLOW}暂无 agent 定义(在 agents/ 目录放置 *.md){Style.RESET_ALL}")
|
||||
return
|
||||
parts = arg.split(maxsplit=1)
|
||||
name = parts[0]
|
||||
task = parts[1] if len(parts) > 1 else ""
|
||||
agent = mgr.get(name)
|
||||
if not agent:
|
||||
print(f"{Fore.RED}未找到 agent: {name}{Style.RESET_ALL}")
|
||||
return
|
||||
print(f"{Fore.CYAN}委派给 agent「{agent.name}」: {task}{Style.RESET_ALL}")
|
||||
try:
|
||||
result = mgr.dispatch(name, task, self._agent_runner)
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}agent 执行失败: {e}{Style.RESET_ALL}")
|
||||
return
|
||||
print(f"{Fore.GREEN}{result}{Style.RESET_ALL}")
|
||||
|
||||
def _agent_runner(self, system_prompt, task, tools, model):
|
||||
"""agent 执行器:用当前引擎跑一轮独立上下文(不污染主会话)。"""
|
||||
engine = getattr(self, 'current_engine', None) or getattr(self, 'engine', None)
|
||||
if engine is None:
|
||||
return "错误:当前无可用的 AI 引擎"
|
||||
prompt = task
|
||||
if system_prompt:
|
||||
prompt = f"{system_prompt}\n\n---\n用户任务:\n{task}"
|
||||
return engine.generate_response(prompt, system_prompt=system_prompt)
|
||||
|
||||
def handle_build_command(self):
|
||||
"""处理 /build 命令。从 PLAN 模式进入执行(用已批准计划驱动)。"""
|
||||
plan = getattr(self, 'current_plan', '') or ""
|
||||
if not getattr(self, 'plan_mode', False) and not plan.strip():
|
||||
print(f"{Fore.YELLOW}当前不在 PLAN 模式,且无已生成的计划{Style.RESET_ALL}")
|
||||
return
|
||||
|
||||
self.plan_mode = False
|
||||
if not plan.strip():
|
||||
print(f"{Fore.YELLOW}尚未生成实施计划,先 /plan 描述任务让 AI 调研{Style.RESET_ALL}")
|
||||
return
|
||||
|
||||
print(f"{Fore.GREEN}已批准计划,开始执行...{Style.RESET_ALL}")
|
||||
instruction = ("【已批准的实施计划,请现在严格按照以下步骤执行,利用可用工具完成每一步;"
|
||||
"遇到与计划不符的情况先说明再继续】\n\n" + plan)
|
||||
self.process_conversation(instruction)
|
||||
self._autosave_session()
|
||||
|
||||
# ── CLI 主循环 ──
|
||||
|
||||
def run_tui(self):
|
||||
"""运行 TUI 模式"""
|
||||
if not TEXTUAL_AVAILABLE:
|
||||
print(f"{Fore.RED}TUI 模式不可用:Textual 库未安装{Style.RESET_ALL}")
|
||||
print(f"{Fore.YELLOW}请运行: pip install textual rich{Style.RESET_ALL}")
|
||||
"""运行 TUI 模式(v8.0 默认真·全屏 textual TUI;textual 不可用时回退 rich 面板)"""
|
||||
if TEXTUAL_AVAILABLE:
|
||||
from .tui import XiaoliTUI
|
||||
app = XiaoliTUI(self)
|
||||
app.run()
|
||||
return
|
||||
from .tui import XiaoliTUI
|
||||
app = XiaoliTUI(self)
|
||||
app.run()
|
||||
# 回退 rich 面板 TUI(textual 不可用时)
|
||||
try:
|
||||
from .rich_tui import RichTUI
|
||||
from xcli_core.constants import VERSION as _V
|
||||
tui = RichTUI(self)
|
||||
tui.version = _V
|
||||
tui.set_status(
|
||||
engine=getattr(self.current_engine, 'name', '') if self.current_engine else '',
|
||||
model=getattr(self.current_engine, 'model', '') if self.current_engine else '',
|
||||
mode='TUI',
|
||||
)
|
||||
self.tui_output_callback = tui.push
|
||||
tui.run()
|
||||
self.tui_output_callback = None
|
||||
except ImportError:
|
||||
print(f"{Fore.RED}TUI 不可用: 请安装 textual (pip install textual){Style.RESET_ALL}")
|
||||
|
||||
def run(self):
|
||||
"""运行 CLI"""
|
||||
import uuid
|
||||
self.user_id = str(uuid.getnode())
|
||||
print(f"{Fore.GREEN}小狸 Pro-CLI 已启动!{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}用户ID: {self.user_id}{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/help' 查看帮助信息{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/quit' 退出程序{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/tui' 切换到 TUI 模式{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/engine list' 查看可用AI引擎{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/engine switch <引擎名>' 切换AI引擎{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/safe' 切换安全模式 (普通→人工→无限制){Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/notify' 切换任务完成通知 (开/关){Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/scheduler' 或 '/remind' 管理定时任务{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/memory' 管理记忆系统 (日记/搜索/聊天记录){Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/file.read <文件名> [行数]' 直接读取文件内容{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '@文件路径' 自动读取文件内容并发送给AI{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '@图片路径' 自动分析图片并发送描述给AI{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/image engines' 查看图像识别引擎{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/chat save <名称>' 保存当前聊天记录{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/chat list' 查看所有已保存的聊天记录{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/chat open <名称>' 加载聊天记录{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/remote' 查看远程连接帮助{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/plugin' 管理插件市场 (安装/卸载/搜索){Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}输入 '/protect' 查看进程保护状态{Style.RESET_ALL}")
|
||||
current_engine_name = getattr(self.current_engine, 'name', '未设置') if self.current_engine else '未设置'
|
||||
print(f"{Fore.GREEN}当前使用的AI引擎: {current_engine_name}{Style.RESET_ALL}")
|
||||
model_name = getattr(self.current_engine, 'model', '') if self.current_engine else ''
|
||||
engine_desc = f"{current_engine_name} ({model_name})" if model_name else current_engine_name
|
||||
|
||||
print(f"{Fore.GREEN}小狸 Pro-CLI v{VERSION} 已启动!{Style.RESET_ALL} "
|
||||
f"{Fore.WHITE}引擎: {engine_desc}{Style.RESET_ALL}")
|
||||
|
||||
# 一行速览,完整命令清单收进 /help
|
||||
print(f"{Fore.CYAN}/help 全部命令 · /quit 退出 · /tui 图形界面 · "
|
||||
f"/model 换模型 · /resume 恢复会话 · /plan 规划模式 · @文件路径 读文件给 AI{Style.RESET_ALL}")
|
||||
|
||||
# 历史会话提示(opencode 式 /resume)
|
||||
try:
|
||||
sessions = self.session_manager.list_sessions()
|
||||
if sessions:
|
||||
latest = sessions[0]
|
||||
print(f"{Fore.YELLOW}有 {len(sessions)} 个历史会话,/resume 可恢复"
|
||||
f"(最近: {latest.id} · {latest.title}){Style.RESET_ALL}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
extras = []
|
||||
loaded_engines = list(self.engines.keys())
|
||||
if len(loaded_engines) > 1:
|
||||
print(f"{Fore.GREEN}已加载的AI引擎: {', '.join(loaded_engines)}{Style.RESET_ALL}")
|
||||
print(f"{Fore.GREEN}{'-' * 50}{Style.RESET_ALL}")
|
||||
|
||||
# 显示进程保护状态
|
||||
if hasattr(self, 'protection_results') and self.protection_results:
|
||||
extras.append(f"可切换引擎: {', '.join(loaded_engines)}")
|
||||
# 进程保护:只在有未生效项时才提示,全绿就闭嘴
|
||||
if getattr(self, 'protection_results', None):
|
||||
r = self.protection_results
|
||||
si_icon = '' if r.get('single_instance') else ''
|
||||
prio_icon = '' if r.get('priority') else ''
|
||||
ppl_icon = '' if r.get('ppl_protection') else ''
|
||||
wd_icon = '' if r.get('watchdog') else ''
|
||||
print(f"{Fore.CYAN} 进程保护: {si_icon}单实例 {prio_icon}优先级 {ppl_icon}PPL保护 {wd_icon}看门狗{Style.RESET_ALL}")
|
||||
failed = [n for k, n in (('single_instance', '单实例'), ('priority', '优先级'),
|
||||
('ppl_protection', 'PPL保护'), ('watchdog', '看门狗'))
|
||||
if not r.get(k)]
|
||||
if failed:
|
||||
extras.append(f"进程保护未生效: {', '.join(failed)}")
|
||||
if extras:
|
||||
print(f"{Fore.BLACK}{Style.BRIGHT}{' · '.join(extras)}{Style.RESET_ALL}")
|
||||
|
||||
# 询问 diff 弹窗模式
|
||||
print(f"{Fore.BLACK}{Style.BRIGHT}用户ID: {self.user_id}{Style.RESET_ALL}")
|
||||
|
||||
# 恢复 diff 显示模式(首次运行才询问)
|
||||
self._ask_diff_popup_mode()
|
||||
|
||||
self._run_cli_loop()
|
||||
|
||||
def _ask_diff_popup_mode(self):
|
||||
"""启动时询问 diff 显示模式"""
|
||||
# 找到 code_editor 插件
|
||||
code_editor = None
|
||||
def _get_code_editor_plugin(self):
|
||||
"""拿到 code_editor 插件实例(找不到返回 None)"""
|
||||
for tool in self.liugin_manager.tools:
|
||||
if tool.get('name') == 'code_editor':
|
||||
code_editor = tool.get('handler')
|
||||
break
|
||||
return getattr(tool.get('handler'), '__self__', None)
|
||||
return None
|
||||
|
||||
if code_editor is None:
|
||||
def _ask_diff_popup_mode(self):
|
||||
"""设置 diff 显示模式:已保存过就静默沿用,只有首次运行才交互询问"""
|
||||
plugin_instance = self._get_code_editor_plugin()
|
||||
if plugin_instance is None:
|
||||
return
|
||||
|
||||
# 找到插件实例(通过 handler 的 __self__)
|
||||
plugin_instance = getattr(code_editor, '__self__', None)
|
||||
if plugin_instance is None:
|
||||
saved = get_system_config('diff_popup_mode', None)
|
||||
if saved is not None:
|
||||
plugin_instance.diff_popup_mode = bool(saved)
|
||||
return
|
||||
|
||||
print(f"\n{Fore.CYAN} Diff 显示模式(AI 修改文件后的对比方式):{Style.RESET_ALL}")
|
||||
@@ -1060,19 +1285,23 @@ multi 操作支持一次修改多处:
|
||||
print(f" {Fore.WHITE}2{Style.RESET_ALL} - 主终端内显示(SSH 兼容,推荐)")
|
||||
|
||||
try:
|
||||
choice = input(f"\n{Fore.WHITE} 请选择 [1/2](默认 2): {Style.RESET_ALL}").strip()
|
||||
choice = input(f"\n{Fore.WHITE} 请选择 [1/2](默认 2,之后可用 /diff 改): {Style.RESET_ALL}").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
choice = '2'
|
||||
|
||||
if choice == '1':
|
||||
plugin_instance.diff_popup_mode = True
|
||||
print(f"{Fore.GREEN} ✓ 已选择: 弹窗模式{Style.RESET_ALL}")
|
||||
else:
|
||||
plugin_instance.diff_popup_mode = False
|
||||
print(f"{Fore.GREEN} ✓ 已选择: 主终端内显示{Style.RESET_ALL}")
|
||||
|
||||
self._set_diff_popup_mode(choice == '1', plugin_instance)
|
||||
print()
|
||||
|
||||
def _set_diff_popup_mode(self, popup, plugin_instance=None):
|
||||
"""写入并持久化 diff 显示模式"""
|
||||
if plugin_instance is None:
|
||||
plugin_instance = self._get_code_editor_plugin()
|
||||
if plugin_instance is not None:
|
||||
plugin_instance.diff_popup_mode = bool(popup)
|
||||
set_system_config('diff_popup_mode', bool(popup))
|
||||
label = '弹窗模式' if popup else '主终端内显示'
|
||||
print(f"{Fore.GREEN} ✓ Diff 显示模式: {label}{Style.RESET_ALL}")
|
||||
|
||||
def _run_cli_loop(self):
|
||||
"""运行 CLI 主循环"""
|
||||
while True:
|
||||
@@ -1083,6 +1312,11 @@ multi 操作支持一次修改多处:
|
||||
print(f"{Fore.GREEN}再见!{Style.RESET_ALL}")
|
||||
break
|
||||
|
||||
# @agent 委派(opencode 式 Agents 体系化)
|
||||
if user_input.startswith('@'):
|
||||
self.handle_agent_command(user_input[1:].strip())
|
||||
continue
|
||||
|
||||
if user_input == '/help':
|
||||
self.show_help()
|
||||
continue
|
||||
@@ -1110,6 +1344,38 @@ multi 操作支持一次修改多处:
|
||||
print(f"{Fore.RED}当前引擎不支持该命令或命令执行失败{Style.RESET_ALL}")
|
||||
continue
|
||||
|
||||
# /model 命令族 — OpenAI 引擎多模型在线增删切换
|
||||
if user_input == '/model' or user_input.startswith('/model '):
|
||||
self.handle_model_command(user_input[7:].strip())
|
||||
continue
|
||||
|
||||
# /providers — opencode 式原生多 Provider 注册与状态一览
|
||||
if user_input == '/providers' or user_input.startswith('/providers '):
|
||||
self.handle_providers_command(user_input[11:].strip())
|
||||
continue
|
||||
|
||||
# /plan 进入 PLAN 模式(只读规划 → 审批 → 执行)
|
||||
if user_input == '/plan' or user_input.startswith('/plan '):
|
||||
sub = user_input[6:].strip() if user_input.startswith('/plan ') else ''
|
||||
self.handle_plan_command(sub)
|
||||
continue
|
||||
if user_input == '/build':
|
||||
self.handle_build_command()
|
||||
continue
|
||||
|
||||
# /resume 会话恢复(opencode 式自动持久化 + 一键恢复)
|
||||
if user_input in ('/resume', '/sessions') or \
|
||||
user_input.startswith('/resume ') or user_input.startswith('/sessions ') or \
|
||||
user_input.startswith('/session '):
|
||||
if user_input.startswith('/resume'):
|
||||
self.handle_resume_command(user_input[7:].strip())
|
||||
elif user_input.startswith('/sessions'):
|
||||
self.handle_resume_command(user_input[9:].strip())
|
||||
else: # /session <子命令>
|
||||
sub = user_input[8:].strip()
|
||||
self.handle_resume_command(sub if sub != 'new' else 'new')
|
||||
continue
|
||||
|
||||
# /manual 快捷命令 — 切换到 manual 引擎或执行 manual 子命令
|
||||
if user_input == '/manual' or user_input.startswith('/manual '):
|
||||
args = user_input[8:].strip() if user_input.startswith('/manual ') else ''
|
||||
@@ -1215,6 +1481,30 @@ multi 操作支持一次修改多处:
|
||||
print(f"{Fore.CYAN}已从 TUI 模式返回 CLI 模式{Style.RESET_ALL}")
|
||||
continue
|
||||
|
||||
if user_input == '/compress':
|
||||
if hasattr(self, 'memory_manager') and self.memory_manager:
|
||||
engine = self.current_engine
|
||||
before = len(self.shared_conversation_history)
|
||||
self.shared_conversation_history = self.memory_manager.compress_context(
|
||||
self.shared_conversation_history, engine=engine)
|
||||
self._set_shared_conversation_history()
|
||||
print(f"{Fore.CYAN}已手动压缩上下文: {before} → {len(self.shared_conversation_history)} 条{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.YELLOW}记忆系统未加载,无法压缩{Style.RESET_ALL}")
|
||||
continue
|
||||
|
||||
if user_input.startswith('/memory mode '):
|
||||
mode = user_input[len('/memory mode '):].strip().lower()
|
||||
if mode in ('companion', 'work'):
|
||||
try:
|
||||
set_system_config('memory_mode', mode)
|
||||
except Exception:
|
||||
pass
|
||||
print(f"{Fore.GREEN}记忆模式已设为: {mode}{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.RED}用法: /memory mode <companion|work>{Style.RESET_ALL}")
|
||||
continue
|
||||
|
||||
if user_input.startswith('/safe'):
|
||||
parts = user_input.split()
|
||||
safety = get_safety()
|
||||
@@ -1238,6 +1528,42 @@ multi 操作支持一次修改多处:
|
||||
print(f"{icon} 安全模式: {mode_name}")
|
||||
continue
|
||||
|
||||
if user_input.startswith('/diff'):
|
||||
parts = user_input.split()
|
||||
if self._get_code_editor_plugin() is None:
|
||||
print(f"{Fore.YELLOW}code_editor 插件未加载,无法设置 diff 模式{Style.RESET_ALL}")
|
||||
continue
|
||||
if len(parts) > 1:
|
||||
arg = parts[1].lower()
|
||||
if arg in ('popup', '弹窗', '1'):
|
||||
self._set_diff_popup_mode(True)
|
||||
elif arg in ('inline', '内联', '主终端', '2'):
|
||||
self._set_diff_popup_mode(False)
|
||||
else:
|
||||
print(f"{Fore.RED}用法: /diff [popup|inline]{Style.RESET_ALL}")
|
||||
else:
|
||||
cur = bool(get_system_config('diff_popup_mode', False))
|
||||
self._set_diff_popup_mode(not cur)
|
||||
continue
|
||||
|
||||
if user_input.startswith('/fc'):
|
||||
from .fc_tools import get_fc_cache, reset_fc_cache
|
||||
parts = user_input.split()
|
||||
if len(parts) > 1 and parts[1].lower() in ('reset', 'clear', '清除'):
|
||||
n = reset_fc_cache()
|
||||
print(f"{Fore.GREEN}已清除 {n} 条 FC 探测记录,下次请求会重新试探{Style.RESET_ALL}")
|
||||
else:
|
||||
cache = get_fc_cache()
|
||||
if not cache:
|
||||
print(f"{Fore.GREEN}FC 探测缓存为空:所有模型都按支持 function calling 处理{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.CYAN}已探测到不支持 function calling 的模型:{Style.RESET_ALL}")
|
||||
for key, ok in cache.items():
|
||||
if ok is False:
|
||||
print(f" {Fore.YELLOW}✗{Style.RESET_ALL} {key}")
|
||||
print(f"{Fore.WHITE}这些请求会自动跳过 tools 字段;/fc reset 可清除重测{Style.RESET_ALL}")
|
||||
continue
|
||||
|
||||
if user_input.startswith('/notify'):
|
||||
parts = user_input.split()
|
||||
nm = get_notification_manager()
|
||||
@@ -1297,6 +1623,10 @@ multi 操作支持一次修改多处:
|
||||
print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}")
|
||||
|
||||
self.process_conversation(user_input)
|
||||
# 自动持久化当前会话(opencode 式 /resume 落盘)
|
||||
self._autosave_session()
|
||||
# Plan 模式下:把本轮最后一条 assistant 内容记为当前计划
|
||||
self._sync_current_plan()
|
||||
except KeyboardInterrupt:
|
||||
user_id_display = f"[用户ID: {self.user_id}]"
|
||||
print(f"\n{Fore.GREEN}再见! {user_id_display}{Style.RESET_ALL}")
|
||||
@@ -1377,12 +1707,22 @@ def main():
|
||||
import codecs
|
||||
sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict')
|
||||
|
||||
for line in art_lines:
|
||||
def _safe_print(line):
|
||||
try:
|
||||
print(line, flush=True)
|
||||
except UnicodeEncodeError:
|
||||
print(line.encode('gbk', errors='ignore').decode('gbk'), flush=True)
|
||||
time.sleep(0.05)
|
||||
|
||||
# 完整开机动画(57 行 + 逐行 0.05s)默认不放,用 --logo 显式召唤
|
||||
if '--logo' in sys.argv or '--banner' in sys.argv:
|
||||
for line in art_lines:
|
||||
_safe_print(line)
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
_safe_print(f"\n{Fore.CYAN} ▄▀▄ 小狸 Pro-CLI v{VERSION}{Style.RESET_ALL}")
|
||||
_safe_print(f"{Fore.CYAN} ▀▄▀ {Style.RESET_ALL}"
|
||||
f"{Fore.WHITE}AI 智能编程助手{Style.RESET_ALL}"
|
||||
f" {Fore.BLACK}{Style.BRIGHT}(--logo 看完整开机动画){Style.RESET_ALL}")
|
||||
|
||||
cli = AICLI()
|
||||
cli.run()
|
||||
@@ -376,8 +376,51 @@ class DisplayMixin:
|
||||
|
||||
# ── 帮助系统 ──
|
||||
|
||||
def show_quick_commands(self):
|
||||
"""常用命令速查表(启动时不再刷屏,改由 /help 展示)"""
|
||||
rows = [
|
||||
('/help [插件名]', '显示帮助信息'),
|
||||
('/quit', '退出程序'),
|
||||
('/tui', '切换到 TUI 图形模式'),
|
||||
('/engine list', '查看可用 AI 引擎'),
|
||||
('/engine switch <名>', '切换 AI 引擎'),
|
||||
('/model', '列出已配置的 OpenAI 模型'),
|
||||
('/model <名称>', '切换当前 OpenAI 模型'),
|
||||
('/model add', '交互式新增 OpenAI 模型'),
|
||||
('/model rm <名称>', '删除已配置的模型'),
|
||||
('/fc [reset]', '查看/清除 FC 能力探测缓存'),
|
||||
('/safe', '切换安全模式 (普通→人工→无限制)'),
|
||||
('/diff', '切换 diff 显示模式 (弹窗/主终端)'),
|
||||
('/notify', '切换任务完成通知 (开/关)'),
|
||||
('/scheduler 或 /remind', '管理定时任务'),
|
||||
('/memory', '管理记忆系统 (日记/搜索/聊天记录)'),
|
||||
('/file.read <文件> [行数]', '直接读取文件内容'),
|
||||
('@文件路径', '自动读取文件内容并发送给 AI'),
|
||||
('@图片路径', '自动分析图片并发送描述给 AI'),
|
||||
('/image engines', '查看图像识别引擎'),
|
||||
('/chat save|list|open', '保存/查看/加载聊天记录'),
|
||||
('/resume', '列出历史会话 (自动持久化)'),
|
||||
('/resume <序号或ID>', '恢复指定会话继续对话'),
|
||||
('/resume new', '开始一个全新的会话'),
|
||||
('/plan [任务]', '进入 PLAN 模式:只读调研并生成实施计划'),
|
||||
('/build', '批准并Plan模式生成的计划,开始执行'),
|
||||
('/remote', '查看远程连接帮助'),
|
||||
('/plugin', '管理插件市场 (安装/卸载/搜索)'),
|
||||
('/protect', '查看进程保护状态'),
|
||||
]
|
||||
width = max(len(cmd) for cmd, _ in rows)
|
||||
print(f"{Fore.GREEN}常用命令:{Style.RESET_ALL}")
|
||||
for cmd, desc in rows:
|
||||
print(f" {Fore.WHITE}{cmd.ljust(width)}{Style.RESET_ALL} {desc}")
|
||||
print()
|
||||
print(f"{Fore.GREEN}启动参数:{Style.RESET_ALL}")
|
||||
print(f" {Fore.WHITE}{'--verbose / -v'.ljust(width)}{Style.RESET_ALL} 打印引擎/插件/技能的逐条加载明细")
|
||||
print(f" {Fore.WHITE}{'--logo'.ljust(width)}{Style.RESET_ALL} 播放完整开机动画")
|
||||
print()
|
||||
|
||||
def show_help(self):
|
||||
"""显示帮助信息"""
|
||||
self.show_quick_commands()
|
||||
help_file_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "HELP.md")
|
||||
if os.path.exists(help_file_path):
|
||||
try:
|
||||
|
||||
@@ -2,10 +2,11 @@ import os
|
||||
import time
|
||||
import json
|
||||
from colorama import Fore, Style
|
||||
from .session import SessionManager
|
||||
|
||||
|
||||
class HistoryMixin:
|
||||
"""聊天记录管理相关方法"""
|
||||
"""聊天记录管理相关方法(含 opencode 式会话持久化 /resume)"""
|
||||
|
||||
def save_chat_history(self, name):
|
||||
"""保存当前聊天记录到文件"""
|
||||
@@ -58,3 +59,62 @@ class HistoryMixin:
|
||||
except Exception as e:
|
||||
print(f"{Fore.RED}加载聊天记录失败: {e}{Style.RESET_ALL}")
|
||||
return False
|
||||
|
||||
# ── opencode 式会话(自动持久化 + /resume) ──
|
||||
|
||||
def init_session_manager(self):
|
||||
"""初始化会话管理器与当前会话状态。在 chat_history_dir 就绪后调用。"""
|
||||
self.session_manager = SessionManager(self.chat_history_dir)
|
||||
self.current_session = None # 首轮对话时惰性创建
|
||||
|
||||
def _autosave_session(self):
|
||||
"""每轮对话后调用:把当前会话落盘(首轮自动建会话)。"""
|
||||
if not getattr(self, 'shared_conversation_history', None):
|
||||
return
|
||||
if not hasattr(self, 'session_manager'):
|
||||
return
|
||||
engine = getattr(self.current_engine, 'name', None) if self.current_engine else None
|
||||
model = getattr(self, 'current_model', None)
|
||||
try:
|
||||
if self.current_session is None:
|
||||
self.current_session = self.session_manager.new_session(engine=engine, model=model)
|
||||
self.session_manager.save(
|
||||
self.current_session, self.shared_conversation_history,
|
||||
engine=engine, model=model,
|
||||
)
|
||||
except Exception:
|
||||
pass # 自动保存失败不应中断对话
|
||||
|
||||
def handle_resume_command(self, args):
|
||||
"""/resume 命令:列出或恢复历史会话。"""
|
||||
args = (args or "").strip()
|
||||
if not hasattr(self, 'session_manager'):
|
||||
self.init_session_manager()
|
||||
if args == 'new':
|
||||
self.current_session = None
|
||||
print(f"{Fore.GREEN}已开始新会话(下一轮对话自动创建){Style.RESET_ALL}")
|
||||
return
|
||||
sessions = self.session_manager.list_sessions()
|
||||
if not sessions:
|
||||
print(f"{Fore.YELLOW}没有可恢复的会话{Style.RESET_ALL}")
|
||||
return
|
||||
if not args:
|
||||
print(f"{Fore.GREEN}已保存的会话 (按时间倒序):{Style.RESET_ALL}")
|
||||
for i, s in enumerate(sessions, 1):
|
||||
line = f"{Fore.GREEN}{i}. {s.id}{Style.RESET_ALL} {s.title}"
|
||||
meta = f" {s.updated_at} {s.engine or '-'}/{s.model or '-'} {s.msg_count} 条"
|
||||
print(line + f"{Fore.CYAN}{meta}{Style.RESET_ALL}")
|
||||
print(f"{Fore.CYAN}用法: /resume <序号或ID> 恢复 · /resume new 开新会话{Style.RESET_ALL}")
|
||||
return
|
||||
s = self.session_manager.load(args)
|
||||
if s is None:
|
||||
print(f"{Fore.RED}未找到会话: {args}{Style.RESET_ALL}")
|
||||
return
|
||||
self.shared_conversation_history = list(s.messages)
|
||||
self.current_session = s
|
||||
self._set_shared_conversation_history()
|
||||
print(f"{Fore.GREEN}已恢复会话: {s.id}{Style.RESET_ALL}")
|
||||
print(f"{Fore.CYAN}标题: {s.title} · {s.msg_count} 条记录 · 引擎 {s.engine or '-'}/{s.model or '-'}{Style.RESET_ALL}")
|
||||
cur = getattr(self.current_engine, 'name', None) if self.current_engine else None
|
||||
if s.engine and cur and cur != s.engine:
|
||||
print(f"{Fore.YELLOW}提示: 当前引擎为 {cur},可用 /engine switch {s.engine} 切回该会话的引擎{Style.RESET_ALL}")
|
||||
+31
-27
@@ -7,6 +7,7 @@ from colorama import Fore, Style
|
||||
|
||||
from .constants import UNIFIED_TOOL_MANAGER_AVAILABLE
|
||||
from .safety import get_safety
|
||||
from .tool_result import normalize_tool_dict, normalize_tool_text
|
||||
|
||||
|
||||
class ToolMixin:
|
||||
@@ -105,18 +106,18 @@ class ToolMixin:
|
||||
result = self.liugin_manager.execute(tool_name, args, **arguments)
|
||||
else:
|
||||
result = self.liugin_manager.execute(tool_name, args)
|
||||
return result
|
||||
# execute() 返回 ToolResult,必须归一成 dict——
|
||||
# 否则上层 result.get('result') 会报
|
||||
# "'ToolResult' object has no attribute 'get'"
|
||||
return normalize_tool_dict(result)
|
||||
|
||||
# 回退:直接调用 handler(旧逻辑)
|
||||
# 回退:直接调用 handler(旧逻辑,handler 可能返回 ToolResult/str)
|
||||
for tool in self.liugin_manager.tools:
|
||||
if tool['name'] == tool_name:
|
||||
try:
|
||||
result = tool['handler'](args)
|
||||
if result is None:
|
||||
result = "无结果"
|
||||
return {"result": result}
|
||||
return normalize_tool_dict(tool['handler'](args))
|
||||
except Exception as e:
|
||||
return {"result": f"工具执行错误: {e}"}
|
||||
return {"result": f"工具执行错误: {e}", "success": False}
|
||||
|
||||
return {"result": f"未找到工具: {tool_name}"}
|
||||
|
||||
@@ -136,7 +137,7 @@ class ToolMixin:
|
||||
return {"index": index, "result": "工具执行已取消"}
|
||||
try:
|
||||
result = self.process_tool_call(tool_data)
|
||||
tool_result = result.get('result', '无结果')
|
||||
tool_result = normalize_tool_text(result)
|
||||
with history_lock:
|
||||
self.shared_conversation_history.append({
|
||||
"role": "assistant",
|
||||
@@ -204,7 +205,7 @@ class ToolMixin:
|
||||
return [r['result'] for r in results]
|
||||
|
||||
def _execute_single_tool(self, tool_data):
|
||||
"""执行单个工具(带延迟和ESC检测)"""
|
||||
"""执行单个工具(零等待,执行前非阻塞检测 ESC 取消)"""
|
||||
tool_name = tool_data.get('tool', '未知工具')
|
||||
tool_args = tool_data.get('args', '')
|
||||
if len(tool_args) > 30:
|
||||
@@ -212,27 +213,16 @@ class ToolMixin:
|
||||
else:
|
||||
tool_args_display = tool_args
|
||||
|
||||
# TUI 模式下不做延迟和 ESC 检测
|
||||
# 非 TUI 模式:执行前零等待检查 ESC(不再做倒计时延迟,立即执行)
|
||||
showed_hint = False
|
||||
if not self.tui_output_callback:
|
||||
try:
|
||||
import msvcrt
|
||||
canceled = False
|
||||
spinners = ['⊶', '⊷']
|
||||
spinner_index = 0
|
||||
for i in range(25):
|
||||
if msvcrt.kbhit():
|
||||
key = msvcrt.getch()
|
||||
if key == b'\x1b':
|
||||
canceled = True
|
||||
break
|
||||
spinner = spinners[spinner_index % 2]
|
||||
spinner_index += 1
|
||||
loading_message = f" 正在执行: {spinner} {tool_name} {tool_args_display} (按ESC取消)"
|
||||
sys.stdout.write(f"\r{loading_message}")
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.2)
|
||||
sys.stdout.write("\r" + " " * 60 + "\r")
|
||||
sys.stdout.flush()
|
||||
# 排空键盘缓冲区:若在此之前按过 ESC 则取消(非阻塞,无等待)
|
||||
while msvcrt.kbhit():
|
||||
if msvcrt.getch() == b'\x1b':
|
||||
canceled = True
|
||||
if canceled:
|
||||
print(f"{Fore.YELLOW}工具执行已取消(用户按ESC键){Style.RESET_ALL}")
|
||||
cancel_message = f"工具 {tool_name} 执行已被用户取消(按ESC键)。工具参数: {tool_args}"
|
||||
@@ -244,8 +234,22 @@ class ToolMixin:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
sys.stdout.write(f"\r 正在执行: {tool_name} {tool_args_display}")
|
||||
sys.stdout.flush()
|
||||
showed_hint = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tool_results = self.process_tool_call(tool_data)
|
||||
full_result = tool_results.get('result', '无结果')
|
||||
full_result = normalize_tool_text(tool_results)
|
||||
|
||||
if showed_hint:
|
||||
try:
|
||||
sys.stdout.write("\r" + " " * 60 + "\r")
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
from datetime import datetime
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self._display_tool_result(tool_name, tool_args, full_result, current_time)
|
||||
|
||||
+55
-5
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from colorama import Fore, Style
|
||||
@@ -6,6 +7,39 @@ from colorama import Fore, Style
|
||||
# 配置文件路径
|
||||
CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json")
|
||||
|
||||
# <input> 特殊语法:配置模型时可用它交互式询问用户并获取输入
|
||||
# <input> → 用默认提示询问
|
||||
# <input:提示语> → 用自定义提示语询问
|
||||
_INPUT_PATTERN = re.compile(r"<input(?::([^>]*))?>")
|
||||
|
||||
|
||||
def resolve_input_value(value, field_name="", default_prompt=None):
|
||||
"""解析配置值中的 <input> 特殊语法,交互式询问用户并返回其输入。
|
||||
|
||||
用法(写在 config.json 的字段值里):
|
||||
"model": "<input>" → 启动时提示"请输入model: "
|
||||
"model": "<input:请输入模型名>" → 启动时提示自定义文案
|
||||
也可以夹在字符串中间: "prefix<input>suffix"
|
||||
|
||||
不含 <input> 的值原样返回;用户直接回车/中断时返回空串,由调用方兜底。
|
||||
"""
|
||||
if not isinstance(value, str) or "<input" not in value:
|
||||
return value
|
||||
|
||||
def _ask(match):
|
||||
label = (match.group(1) or "").strip()
|
||||
prompt = label or default_prompt or (f"请输入{field_name}" if field_name else "请输入")
|
||||
prompt = prompt.rstrip()
|
||||
if prompt and not prompt.endswith((":", ":", "?", "?", "!", "!", " ")):
|
||||
prompt += ": "
|
||||
try:
|
||||
answer = input(f"{Fore.CYAN}{prompt}{Style.RESET_ALL}").strip()
|
||||
return answer
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return ""
|
||||
|
||||
return _INPUT_PATTERN.sub(_ask, value)
|
||||
|
||||
|
||||
def load_config():
|
||||
"""加载配置文件"""
|
||||
@@ -42,13 +76,29 @@ def set_system_config(key, value):
|
||||
save_config(config)
|
||||
|
||||
|
||||
# 配置日志
|
||||
# 配置日志:文件留全量 INFO,控制台默认只报 WARNING 以上(--verbose 时放开到 INFO)
|
||||
try:
|
||||
from .verbose import is_verbose as _is_verbose
|
||||
except ImportError: # 被当作顶层模块单独导入时
|
||||
try:
|
||||
from verbose import is_verbose as _is_verbose
|
||||
except ImportError:
|
||||
def _is_verbose():
|
||||
return False
|
||||
|
||||
_file_handler = logging.FileHandler('xiaoli_cli.log', encoding='utf-8')
|
||||
_file_handler.setLevel(logging.INFO)
|
||||
_console_handler = logging.StreamHandler()
|
||||
_console_handler.setLevel(logging.INFO if _is_verbose() else logging.WARNING)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('xiaoli_cli.log', encoding='utf-8'),
|
||||
logging.StreamHandler() # 同时输出到控制台
|
||||
]
|
||||
handlers=[_file_handler, _console_handler]
|
||||
)
|
||||
|
||||
# 第三方库的心跳日志(httpx 每发一次请求就打一行)不上屏也不进日志文件
|
||||
for _noisy in ('httpx', 'httpcore', 'urllib3', 'requests', 'openai', 'PIL', 'markdown_it'):
|
||||
logging.getLogger(_noisy).setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -6,6 +6,10 @@ VIDEO_FRAME_DELAY = 0.04 # 40毫秒
|
||||
LOVE_FILE_PATH = "love.txt"
|
||||
DEFAULT_MAX_HISTORY = 999999 # 无限制对话历史
|
||||
|
||||
# 版本号(v8.0 基于 v5.4.1 重新立项)
|
||||
VERSION = "8.0.2"
|
||||
VERSION_NAME = "小狸 Pro-CLI"
|
||||
|
||||
import os
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
+203
-4
@@ -2,7 +2,9 @@
|
||||
Function Calling 工具格式转换
|
||||
将 MCP 插件定义转为 OpenAI FC 格式,收集所有可用工具
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
@@ -19,8 +21,17 @@ def mcp_to_openai_tools(plugin_manager) -> List[Dict]:
|
||||
if not plugin_manager:
|
||||
return tools
|
||||
|
||||
# 统一工具管理器
|
||||
if hasattr(plugin_manager, 'plugins'):
|
||||
# 优先使用统一工具管理器的 MCP 定义(含外部 MCP client 桥接的工具,schema 保真)
|
||||
if hasattr(plugin_manager, 'get_mcp_tools'):
|
||||
try:
|
||||
for mcp_def in plugin_manager.get_mcp_tools():
|
||||
fc_def = mcp_to_fc(mcp_def)
|
||||
if fc_def:
|
||||
tools.append(fc_def)
|
||||
except Exception:
|
||||
pass
|
||||
# 统一工具管理器(兜底,旧实现未暴露 get_mcp_tools 时)
|
||||
elif hasattr(plugin_manager, 'plugins'):
|
||||
for plugin in plugin_manager.plugins:
|
||||
fc_def = _extract_fc_def(plugin)
|
||||
if fc_def:
|
||||
@@ -36,7 +47,117 @@ def mcp_to_openai_tools(plugin_manager) -> List[Dict]:
|
||||
builtin_tools = _get_builtin_tools()
|
||||
tools.extend(builtin_tools)
|
||||
|
||||
return tools
|
||||
# ── 去重:同名工具只保留首个 ──
|
||||
# 来源:get_mcp_tools() 的缓存里已含 memory/scheduler/lsp 等,
|
||||
# 下方 _get_builtin_tools() 又追加了一遍,导致同名重复(约 17% 的 FC token 是垃圾)。
|
||||
# 按 function.name 去重,消除双重曝光,对真实工具无影响。
|
||||
seen = set()
|
||||
deduped = []
|
||||
for t in tools:
|
||||
fname = (t.get("function") or {}).get("name", "")
|
||||
if fname in seen:
|
||||
continue
|
||||
seen.add(fname)
|
||||
deduped.append(t)
|
||||
return deduped
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# 动态工具裁剪(方案 B:按场景分层)
|
||||
#
|
||||
# 把 40+ 的 FC 工具全量发给模型,每轮要重发数千 token schema,且
|
||||
# 模型要在大列表里选对工具,决策面大、易选错、成本高。
|
||||
# 这里按「当前用户输入」的信号,只下发本轮相关的工具子集:
|
||||
# - ALWAYS_KEEP:始终保留(含强制 tool_search 规则依赖、记忆、定时)
|
||||
# - 外部桥接工具(含 "mcp__")默认保留(用户显式配置,误杀代价高)
|
||||
# - 命中关键词信号才追加对应工具(代码/文件/git/shell/浏览器/搜索/子agent/工程化)
|
||||
# 纯聊天时模型只背 ~10 个核心工具而非 40+,FC token 可降约 70%+。
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
# 始终保留的工具(与系统提示里的强制规则/记忆机制强绑定)
|
||||
ALWAYS_KEEP = {
|
||||
"tool_search", # 系统提示的「强制工具查询规则」依赖它
|
||||
"memory", # 持久化记忆
|
||||
"scheduler", # 定时任务
|
||||
}
|
||||
|
||||
# 信号:关键词集合 → 追加「以此前缀开头」的工具
|
||||
_SIGNAL_RULES = [
|
||||
# 代码 / 文件操作:Python 等源码信号命中时,连 pylsp 的 lsp__* 一起给
|
||||
(
|
||||
{"写", "创建", "新建", "修改", "编辑", "代码", "脚本", "函数", "bug", "修复",
|
||||
"调试", "重构", "文件", "报错", "语法", "实现", "module", "def ", "import ",
|
||||
"class ", "print(", ".py", ".js", ".ts", ".java", ".go", ".cpp", ".c", ".rs"},
|
||||
["code_editor", "code_search", "file_manager", "lsp__", "syntax_check", "check"],
|
||||
),
|
||||
# 版本控制
|
||||
(
|
||||
{"git", "提交", "commit", "分支", "branch", "版本", "diff", "stash", "merge", "rebase"},
|
||||
["git_tools"],
|
||||
),
|
||||
# Shell 命令
|
||||
(
|
||||
{"命令", "shell", "终端", "执行", "运行", "cmd", "bash", "powershell", "terminal"},
|
||||
["cmd_executor"],
|
||||
),
|
||||
# 浏览器
|
||||
(
|
||||
{"浏览器", "网页", "点击", "browser", "打开网页", "截图", "scroll", "页面"},
|
||||
["browser_auto"],
|
||||
),
|
||||
# 联网 / 搜索 / HTTP
|
||||
(
|
||||
{"搜索", "联网", "http", "请求", "url", "curl", "api", "查一下", "上网", "fetch"},
|
||||
["network_tools", "ai_search"],
|
||||
),
|
||||
# 子 Agent 并行
|
||||
(
|
||||
{"子agent", "并行", "subagent", "sub_agent", "多智能体"},
|
||||
["sub_agent"],
|
||||
),
|
||||
# 工程化自动化
|
||||
(
|
||||
{"测试", "test", "lint", "build", "自动化", "工程化", "ci", "部署", "deploy"},
|
||||
["auto_engineer"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def prune_tools(fc_tools: List[Dict], user_input: str) -> List[Dict]:
|
||||
"""
|
||||
按当前用户输入动态裁剪 FC 工具子集。
|
||||
|
||||
返回保留下来的工具列表。无输入或空列表时原样返回(不裁剪)。
|
||||
规则见模块顶部 _SIGNAL_RULES / ALWAYS_KEEP 注释。
|
||||
"""
|
||||
if not fc_tools:
|
||||
return fc_tools
|
||||
|
||||
text = (user_input or "").lower()
|
||||
|
||||
keep_prefixes = set()
|
||||
for keywords, prefixes in _SIGNAL_RULES:
|
||||
if any(kw in text for kw in keywords):
|
||||
keep_prefixes.update(prefixes)
|
||||
|
||||
out = []
|
||||
for t in fc_tools:
|
||||
name = (t.get("function") or {}).get("name", "")
|
||||
# 1) 常驻工具
|
||||
if name in ALWAYS_KEEP:
|
||||
out.append(t)
|
||||
continue
|
||||
# 2) 外部桥接工具(用户显式配置)默认保留,避免误杀
|
||||
if "mcp__" in name:
|
||||
out.append(t)
|
||||
continue
|
||||
# 3) 命中当前轮信号 → 追加(前缀匹配,如 lsp__diagnostics 命中 "lsp__")
|
||||
if any(name.startswith(p) for p in keep_prefixes):
|
||||
out.append(t)
|
||||
continue
|
||||
# 4) 其余(未命中信号的低频工具)本轮裁剪掉
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _extract_fc_def(plugin) -> Optional[Dict]:
|
||||
@@ -120,11 +241,11 @@ def fc_result_to_message(tool_call_id: str, tool_name: str, result: str) -> Dict
|
||||
|
||||
Returns:
|
||||
{"role": "tool", "tool_call_id": ..., "content": ...}
|
||||
注意: 官方 OpenAI 的 tool 消息不含 name 字段(严格服务端如讯飞会拒),故不写入。
|
||||
"""
|
||||
return {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"name": tool_name,
|
||||
"content": result[:10000] if result else "" # 截断过长结果
|
||||
}
|
||||
|
||||
@@ -248,3 +369,81 @@ def _get_builtin_tools() -> List[Dict]:
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# FC 能力探测缓存
|
||||
#
|
||||
# 部分渠道/模型(如讯飞星火经 one-api 转发的 xophunyuan* 系列)
|
||||
# 不支持 function calling,请求体里只要带上非空 tools 字段就会直接
|
||||
# 返回 500 RequestParamsError:Invalid Params。
|
||||
# 引擎捕获到这种错误后会去掉 tools 降级重试;本缓存把"该模型不支持 FC"
|
||||
# 这件事记下来,后续请求直接跳过 tools,避免每次冷启动都白撞一次 500。
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
_FC_CACHE_FILE = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".fc_support.json"
|
||||
)
|
||||
_fc_cache = None
|
||||
_fc_lock = threading.Lock()
|
||||
|
||||
|
||||
def _fc_key(base_url, model) -> str:
|
||||
"""缓存键:base_url + model(同一渠道下不同模型能力可能不同)"""
|
||||
return f"{(base_url or '').rstrip('/')}::{model or ''}"
|
||||
|
||||
|
||||
def _load_fc_cache() -> dict:
|
||||
global _fc_cache
|
||||
if _fc_cache is not None:
|
||||
return _fc_cache
|
||||
try:
|
||||
with open(_FC_CACHE_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
_fc_cache = data if isinstance(data, dict) else {}
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError, ValueError):
|
||||
_fc_cache = {}
|
||||
return _fc_cache
|
||||
|
||||
|
||||
def _save_fc_cache(cache: dict):
|
||||
try:
|
||||
with open(_FC_CACHE_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(cache, f, ensure_ascii=False, indent=2)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def supports_fc(base_url, model) -> bool:
|
||||
"""该 (base_url, model) 是否支持 FC。未探测过时默认 True(乐观,先试一次)"""
|
||||
return _load_fc_cache().get(_fc_key(base_url, model)) is not False
|
||||
|
||||
|
||||
def mark_fc_unsupported(base_url, model) -> bool:
|
||||
"""标记该 (base_url, model) 不支持 FC 并持久化。首次标记返回 True"""
|
||||
with _fc_lock:
|
||||
cache = _load_fc_cache()
|
||||
key = _fc_key(base_url, model)
|
||||
if cache.get(key) is False:
|
||||
return False
|
||||
cache[key] = False
|
||||
_save_fc_cache(cache)
|
||||
return True
|
||||
|
||||
|
||||
def reset_fc_cache(base_url=None, model=None) -> int:
|
||||
"""清除 FC 探测缓存。不传参数则全清,返回清除的条目数"""
|
||||
with _fc_lock:
|
||||
cache = _load_fc_cache()
|
||||
if base_url is None and model is None:
|
||||
count = len(cache)
|
||||
cache.clear()
|
||||
else:
|
||||
count = 1 if cache.pop(_fc_key(base_url, model), None) is not None else 0
|
||||
_save_fc_cache(cache)
|
||||
return count
|
||||
|
||||
|
||||
def get_fc_cache() -> dict:
|
||||
"""返回 FC 探测缓存副本(供 /fc 命令展示)"""
|
||||
return dict(_load_fc_cache())
|
||||
@@ -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_file = os.path.join(project_dir, "MEMORY.md")
|
||||
self.chat_dir = os.path.join(project_dir, "chat_history")
|
||||
self.compress_threshold = 50 # 超过 50 条消息触发压缩
|
||||
self.compress_threshold = 50 # 超过 50 条消息触发压缩(兜底)
|
||||
self.compress_ratio = 0.7 # token 感知压缩阈值:used/max_input_tokens >= 0.7 触发
|
||||
self.keep_recent = 12 # 压缩时保留最近 N 条(编码常跨多轮,略高于原 10)
|
||||
self.max_summary_chars = 1500 # 摘要前单条消息截断上限(原硬编码 200,编码场景丢代码)
|
||||
self.compressed_blobs = [] # 累积的压缩摘要(避免二次摘要失真)
|
||||
self._ensure_dirs()
|
||||
|
||||
def _ensure_dirs(self):
|
||||
@@ -299,56 +303,112 @@ class MemoryManager:
|
||||
# 上下文压缩
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
def should_compress(self, conversation: list) -> bool:
|
||||
"""判断是否需要压缩上下文"""
|
||||
def set_compress_ratio(self, ratio: float):
|
||||
"""设置 token 感知压缩阈值(0~1)"""
|
||||
try:
|
||||
self.compress_ratio = float(ratio)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
def should_compress(self, conversation: list, prompt_tokens=None, max_input_tokens=None, ratio=None) -> bool:
|
||||
"""
|
||||
判断是否需要压缩上下文。
|
||||
优先级:
|
||||
1. token 感知: prompt_tokens/max_input_tokens >= ratio 触发(引擎需返回 max_input_tokens)
|
||||
2. 兜底: 消息数超过 compress_threshold
|
||||
"""
|
||||
if ratio is None:
|
||||
ratio = self.compress_ratio
|
||||
if prompt_tokens and max_input_tokens:
|
||||
try:
|
||||
if int(max_input_tokens) > 0 and (int(prompt_tokens) / int(max_input_tokens)) >= ratio:
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return len(conversation) > self.compress_threshold
|
||||
|
||||
def compress_context(self, conversation: list, engine=None) -> list:
|
||||
def _is_compressed_marker(self, m: dict) -> bool:
|
||||
"""判断一条消息是否是我们自己产出的压缩摘要(避免二次摘要失真)"""
|
||||
return bool(m.get("role") == "system"
|
||||
and str(m.get("content", "")).startswith("[上下文压缩]"))
|
||||
|
||||
def _prepare_for_summary(self, msg: dict) -> str:
|
||||
"""把单条消息转成摘要输入:保真优先——保留工具调用 JSON,长内容截断但尽量留代码块。"""
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
if not isinstance(content, str):
|
||||
return f"[{role}]: (非文本/结构化内容,长度 {len(str(content))})\n"
|
||||
|
||||
is_tool = ('"action": "use_tool"' in content) or ('"tool":' in content)
|
||||
cap = self.max_summary_chars
|
||||
|
||||
if len(content) <= cap:
|
||||
body = content
|
||||
else:
|
||||
# 尽量保留代码块(```...```)以保真编码上下文
|
||||
fences = re.findall(r"```.*?```", content, re.DOTALL)
|
||||
if fences:
|
||||
kept = "\n".join(fences[:2])
|
||||
if len(kept) > cap:
|
||||
kept = kept[:cap] + "\n...(截断)"
|
||||
body = f"(内容过长已截断,保留代码块)\n{kept}"
|
||||
else:
|
||||
body = content[:cap] + "\n...(截断)"
|
||||
|
||||
marker = " [工具调用]" if is_tool else ""
|
||||
return f"[{role}]{marker}: {body}\n"
|
||||
|
||||
def compress_context(self, conversation: list, engine=None, keep_recent=None) -> list:
|
||||
"""
|
||||
压缩对话历史
|
||||
策略: 保留最近 10 条 + 用 AI 总结前面的内容
|
||||
压缩对话历史。
|
||||
策略: 保留最近 keep_recent 条 + 用当前引擎 AI 总结前面的内容。
|
||||
关键改进:
|
||||
- keep_recent 可配置(默认 12,编码常跨多轮)
|
||||
- 已压缩摘要(标记消息)不再二次摘要,避免失真累积
|
||||
- 摘要按角色分条,保留工具调用/代码修改结构
|
||||
"""
|
||||
if len(conversation) <= 10:
|
||||
if keep_recent is None:
|
||||
keep_recent = self.keep_recent
|
||||
if len(conversation) <= keep_recent:
|
||||
return conversation
|
||||
|
||||
keep_recent = 10
|
||||
to_compress = conversation[:-keep_recent]
|
||||
recent = conversation[-keep_recent:]
|
||||
prefix = conversation[:-keep_recent]
|
||||
# 只摘要尚未压缩过的"真消息",跳过历史摘要本身 → 不二次失真
|
||||
new_to_compress = [m for m in prefix if not self._is_compressed_marker(m)]
|
||||
if not new_to_compress:
|
||||
# 前缀全是历史摘要,没有新内容可压,保持现状即可(不丢摘要)
|
||||
return conversation
|
||||
|
||||
# 尝试用 AI 生成摘要
|
||||
summary = self._ai_summarize(to_compress, engine)
|
||||
summary = self._ai_summarize(new_to_compress, engine)
|
||||
block = f"[上下文压缩] 之前 {len(new_to_compress)} 条对话摘要:\n{summary}"
|
||||
self.compressed_blobs.append(block)
|
||||
|
||||
# 构建压缩后的历史
|
||||
compressed = [{
|
||||
"role": "system",
|
||||
"content": f"[上下文压缩] 以下是之前 {len(to_compress)} 条对话的摘要:\n{summary}"
|
||||
}]
|
||||
# 累积所有摘要合并为单条 system(而非每次重压旧摘要)
|
||||
merged = "\n\n".join(self.compressed_blobs)
|
||||
compressed = [{"role": "system", "content": merged}]
|
||||
compressed.extend(recent)
|
||||
return compressed
|
||||
|
||||
def _ai_summarize(self, messages: list, engine=None) -> str:
|
||||
"""用 AI 生成对话摘要"""
|
||||
"""用当前引擎生成对话摘要(保真优先)"""
|
||||
if not engine:
|
||||
return self._simple_summarize(messages)
|
||||
|
||||
# 构建摘要请求
|
||||
conversation_text = ""
|
||||
for msg in messages:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
conversation_text += f"[{role}]: {content[:200]}\n"
|
||||
conversation_text = "".join(self._prepare_for_summary(m) for m in messages)
|
||||
|
||||
prompt = (
|
||||
"请用简洁的中文总结以下对话的关键信息(工具调用、代码修改、重要决策)。"
|
||||
"控制在 200 字以内。\n\n"
|
||||
"请用简洁中文总结以下对话的关键信息,要求:\n"
|
||||
"1. 按角色(用户/助手)分条,保留每条的要点;\n"
|
||||
"2. 重点保留: 工具调用名称与参数、代码修改点(读/改/创建的文件与位置)、重要决策;\n"
|
||||
"3. 控制总体在 400 字以内,不要编造未提及的信息。\n\n"
|
||||
f"{conversation_text}"
|
||||
)
|
||||
|
||||
try:
|
||||
summary = engine.generate_response(
|
||||
prompt,
|
||||
system_prompt="你是摘要生成器。只输出摘要,不要多余内容。"
|
||||
system_prompt="你是摘要生成器。只输出结构化摘要,不要多余内容。"
|
||||
)
|
||||
if summary and len(summary) > 10:
|
||||
return summary
|
||||
@@ -358,23 +418,25 @@ class MemoryManager:
|
||||
return self._simple_summarize(messages)
|
||||
|
||||
def _simple_summarize(self, messages: list) -> str:
|
||||
"""简单摘要(不依赖 AI)"""
|
||||
"""简单摘要(不依赖 AI,保真优先)"""
|
||||
topics = []
|
||||
tool_calls = []
|
||||
edits = []
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
role = msg.get("role", "")
|
||||
if role == "user" and isinstance(content, str):
|
||||
if not isinstance(content, str):
|
||||
continue
|
||||
if role == "user":
|
||||
# 提取用户请求
|
||||
if len(content) < 100:
|
||||
topics.append(content)
|
||||
else:
|
||||
topics.append(content[:50] + "...")
|
||||
if role == "assistant" and isinstance(content, str):
|
||||
if role == "assistant":
|
||||
# 提取工具调用
|
||||
if '"action": "use_tool"' in content or '"tool":' in content:
|
||||
try:
|
||||
# 尝试解析工具名
|
||||
for part in content.split('"tool":'):
|
||||
if len(part) > 2:
|
||||
tool_name = part.split('"')[1] if '"' in part else ""
|
||||
@@ -382,12 +444,19 @@ class MemoryManager:
|
||||
tool_calls.append(tool_name)
|
||||
except Exception:
|
||||
pass
|
||||
# 提取代码编辑点(edit 命令)
|
||||
for m in re.finditer(r'edit\s+(\S+)', content):
|
||||
fn = m.group(1)
|
||||
if fn not in edits:
|
||||
edits.append(fn)
|
||||
|
||||
summary_parts = []
|
||||
if topics:
|
||||
summary_parts.append(f"讨论了 {len(topics)} 个话题: {'; '.join(topics[:5])}")
|
||||
if tool_calls:
|
||||
summary_parts.append(f"使用了工具: {', '.join(tool_calls[:8])}")
|
||||
if edits:
|
||||
summary_parts.append(f"编辑了文件: {', '.join(edits[:8])}")
|
||||
summary_parts.append(f"共 {len(messages)} 条对话")
|
||||
|
||||
return "。".join(summary_parts)
|
||||
|
||||
@@ -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.2"
|
||||
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: "人工确认",
|
||||
}
|
||||
|
||||
# ── Plan 模式只读白名单(黑名单法)──
|
||||
# 值为 "*" 表示该工具整类视为写/有副作用,一律拦截;
|
||||
# 值为列表时,仅当首个操作词命中列表中的写操作关键字才拦截;
|
||||
# 值为 [] 表示该工具整类只读,一律放行(调研必需);
|
||||
# 未登记的未知工具默认拦截(保守)。
|
||||
PLAN_WRITE_RULES = {
|
||||
# 整类拦截
|
||||
"cmd_executor": "*",
|
||||
"browser_auto": "*",
|
||||
"network_tools": "*",
|
||||
"scheduler": "*",
|
||||
# 仅部分操作拦截
|
||||
"code_editor": ["write", "create", "append", "edit", "insert", "delete_lines",
|
||||
"multi", "replace", "rename", "move"],
|
||||
"git_tools": ["commit", "push", "checkout", "reset", "merge", "revert", "clean",
|
||||
"stash", "tag", "fetch", "pull", "branch -", "branch delete",
|
||||
"branch -d", "branch -D", "remote add", "remote set", "amend"],
|
||||
"file_manager": ["write", "delete", "move", "copy", "rename", "mkdir", "rm",
|
||||
"create", "save", "touch"],
|
||||
"sub_agent": ["run", "dispatch", "execute", "spawn", "create", "new", "task", "assign"],
|
||||
"auto_engineer": ["run", "test", "build", "fix", "format", "apply", "generate",
|
||||
"scaffold", "create", "refactor", "exec"],
|
||||
"memory": ["write", "save", "add", "set", "delete", "edit", "update", "forget"],
|
||||
# 整类放行(只读探索必需)
|
||||
"tool_search": [],
|
||||
"ai_search": [],
|
||||
"code_search": [],
|
||||
}
|
||||
|
||||
|
||||
def plan_is_write_operation(tool_name: str, tool_args) -> bool:
|
||||
"""Plan 模式下判断一次工具调用是否属于「写操作」(应被拦截)。
|
||||
|
||||
返回 True 表示会修改状态/文件/系统,应拦截;False 表示只读,可放行。
|
||||
"""
|
||||
rules = PLAN_WRITE_RULES.get(tool_name)
|
||||
if rules is None:
|
||||
# 未登记的未知工具:保守拦截
|
||||
return True
|
||||
if rules == "*":
|
||||
return True
|
||||
if not rules:
|
||||
return False
|
||||
args_l = str(tool_args).strip().lower()
|
||||
parts = args_l.split()
|
||||
op = parts[0] if parts else ""
|
||||
for kw in rules:
|
||||
kwp = kw.split()
|
||||
if len(kwp) == 1:
|
||||
# 单字关键字:精确匹配操作词,或作为其前缀/后缀(避免 "editor" 误中 "edit")
|
||||
if op == kw or op.startswith(kw) or op.endswith(kw):
|
||||
return True
|
||||
else:
|
||||
# 多词规则,如 "remote add" / "branch -d"
|
||||
if args_l.startswith(kw) or " ".join(parts[:len(kwp)]) == kw:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class SafetyLayer:
|
||||
"""统一安全检查层"""
|
||||
|
||||
@@ -26,6 +85,7 @@ class SafetyLayer:
|
||||
self.cli = None
|
||||
self._analysis_cache = {} # 缓存分析结果
|
||||
self._lock = threading.Lock()
|
||||
self._permissions_override = None # set_permissions 注入的显式权限
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
@@ -44,6 +104,62 @@ class SafetyLayer:
|
||||
self.mode = (self.mode + 1) % 3
|
||||
return self.get_mode_name()
|
||||
|
||||
# ── 声明式权限(opencode 式 allow/ask/deny)──
|
||||
# 规则语法:
|
||||
# "*" → 匹配任意工具/操作
|
||||
# "tool" → 精确匹配工具名
|
||||
# "tool:*" → 该工具任意操作
|
||||
# "tool:op" → 工具名 + 参数首个词命中 op 前缀
|
||||
# 优先级:deny(阻断)> ask(强制确认)> allow(自动放行)> 默认(按模式)
|
||||
|
||||
def set_permissions(self, allow=None, ask=None, deny=None):
|
||||
"""显式注入权限规则(测试或启动时调用),优先级最高。"""
|
||||
self._permissions_override = {
|
||||
"allow": list(allow or []),
|
||||
"ask": list(ask or []),
|
||||
"deny": list(deny or []),
|
||||
}
|
||||
|
||||
def _get_permissions(self) -> dict:
|
||||
if self._permissions_override is not None:
|
||||
return self._permissions_override
|
||||
return self._load_permissions()
|
||||
|
||||
def _load_permissions(self) -> dict:
|
||||
try:
|
||||
from .config import load_config
|
||||
except ImportError:
|
||||
try:
|
||||
from config import load_config
|
||||
except ImportError:
|
||||
return {"allow": [], "ask": [], "deny": []}
|
||||
cfg = load_config() or {}
|
||||
perm = cfg.get("permissions") or {}
|
||||
return {
|
||||
"allow": perm.get("allow", []) or [],
|
||||
"ask": perm.get("ask", []) or [],
|
||||
"deny": perm.get("deny", []) or [],
|
||||
}
|
||||
|
||||
def _permission_match(self, rule: str, tool_name: str, tool_args) -> bool:
|
||||
rule = (rule or "").strip()
|
||||
if not rule:
|
||||
return False
|
||||
if rule == "*":
|
||||
return True
|
||||
if ":" in rule:
|
||||
rtool, raction = rule.split(":", 1)
|
||||
rtool = rtool.strip()
|
||||
raction = raction.strip()
|
||||
if rtool != tool_name:
|
||||
return False
|
||||
if raction in ("*", ""):
|
||||
return True
|
||||
op = (str(tool_args).strip().split()[0].lower()
|
||||
if str(tool_args).strip() else "")
|
||||
return op == raction or op.startswith(raction)
|
||||
return rule == tool_name
|
||||
|
||||
# ── 核心:拦截检查 ──
|
||||
|
||||
def check(self, tool_name: str, tool_args: str) -> Tuple[bool, str]:
|
||||
@@ -51,6 +167,34 @@ class SafetyLayer:
|
||||
检查工具调用是否安全
|
||||
返回: (允许执行, 消息)
|
||||
"""
|
||||
# ── Plan 模式只读约束(最高优先级,先于所有安全模式判断)──
|
||||
# plan 模式下只允许只读调研工具,写操作一律暂挂,待 /build 批准后执行。
|
||||
cli = self.cli
|
||||
if cli is not None and getattr(cli, 'plan_mode', False):
|
||||
if plan_is_write_operation(tool_name, tool_args):
|
||||
return False, "PLAN 模式:写操作已暂挂(只读调研中)。请在 /build 批准后执行。"
|
||||
return True, ""
|
||||
|
||||
# ── 声明式权限(config.permissions: deny > ask > allow)──
|
||||
perm = self._get_permissions()
|
||||
# deny 最高优先级:直接阻断(即便无限制模式也生效)
|
||||
for rule in perm.get("deny", []):
|
||||
if self._permission_match(rule, tool_name, tool_args):
|
||||
return False, f"权限拒绝(deny 规则 '{rule}' 命中): {tool_name}"
|
||||
# ask:强制人工确认(覆盖自动放行,即便无限制模式也确认)
|
||||
for rule in perm.get("ask", []):
|
||||
if self._permission_match(rule, tool_name, tool_args):
|
||||
return self._ask_user_confirm(
|
||||
tool_name, tool_args,
|
||||
risk_level="中",
|
||||
impact="命中 ask 规则,需你确认后执行",
|
||||
reason=f"权限规则 '{rule}'"
|
||||
)
|
||||
# allow:自动放行(跳过 AI 风险分析)
|
||||
for rule in perm.get("allow", []):
|
||||
if self._permission_match(rule, tool_name, tool_args):
|
||||
return True, ""
|
||||
|
||||
# 无限制模式:直接放行
|
||||
if self.mode == MODE_UNRESTRICTED:
|
||||
return True, ""
|
||||
|
||||
@@ -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:
|
||||
"""转为字符串(兼容旧插件 handle() 返回值)"""
|
||||
return str(self)
|
||||
|
||||
|
||||
# ── 归一化辅助(跨层兼容:ToolResult / dict / str / None)──
|
||||
|
||||
def normalize_tool_text(result: Any, default: str = "无结果") -> str:
|
||||
"""把任意工具返回值统一成字符串。
|
||||
|
||||
工具链里三种返回形态都可能出现:
|
||||
- ToolResult(新插件 / UnifiedToolManager.execute)
|
||||
- {"result": ...}(旧插件 / to_dict)
|
||||
- 纯字符串(最老的 handler)
|
||||
统一收敛成字符串,避免上层对 ToolResult 调用 .get() 报
|
||||
"'ToolResult' object has no attribute 'get'"。
|
||||
"""
|
||||
if result is None:
|
||||
return default
|
||||
if isinstance(result, ToolResult):
|
||||
text = str(result)
|
||||
elif isinstance(result, dict):
|
||||
text = result.get("result", result.get("content", ""))
|
||||
if isinstance(text, (dict, list)):
|
||||
import json as _json
|
||||
try:
|
||||
text = _json.dumps(text, ensure_ascii=False)
|
||||
except Exception:
|
||||
text = str(text)
|
||||
elif not isinstance(text, str):
|
||||
text = str(text) if text is not None else ""
|
||||
else:
|
||||
text = str(result)
|
||||
text = text.strip() if isinstance(text, str) else str(text)
|
||||
return text if text else default
|
||||
|
||||
|
||||
def normalize_tool_dict(result: Any, default: str = "无结果") -> dict:
|
||||
"""把任意工具返回值统一成 {"result": str, ...} 结构。
|
||||
|
||||
额外附带 success / error_code / tool_result,方便需要细节的调用方,
|
||||
同时保证 .get('result') 一定可用。
|
||||
"""
|
||||
text = normalize_tool_text(result, default)
|
||||
if isinstance(result, ToolResult):
|
||||
return {
|
||||
"result": text,
|
||||
"success": result.success,
|
||||
"error_code": result.error_code,
|
||||
"tool_result": result,
|
||||
}
|
||||
if isinstance(result, dict):
|
||||
out = dict(result)
|
||||
out["result"] = text
|
||||
out.setdefault("success", True)
|
||||
return out
|
||||
return {"result": text, "success": True}
|
||||
+487
-48
@@ -1,6 +1,6 @@
|
||||
"""TUI 界面模块 - Textual TUI 实现 (动画增强版 + 多行输入)"""
|
||||
|
||||
from .constants import TEXTUAL_AVAILABLE
|
||||
from .constants import TEXTUAL_AVAILABLE, VERSION
|
||||
|
||||
if TEXTUAL_AVAILABLE:
|
||||
import re
|
||||
@@ -11,6 +11,7 @@ if TEXTUAL_AVAILABLE:
|
||||
from textual.reactive import var
|
||||
from textual.binding import Binding
|
||||
from textual import on
|
||||
import os
|
||||
from rich.syntax import Syntax
|
||||
from rich.panel import Panel
|
||||
from rich.box import ROUNDED
|
||||
@@ -197,6 +198,28 @@ if TEXTUAL_AVAILABLE:
|
||||
transition: color 300ms in_out_cubic;
|
||||
}}
|
||||
|
||||
/* ── 文件树 / 会话列表 ── */
|
||||
.file-tree-item {{
|
||||
padding: 0 0 0 1;
|
||||
color: {_Theme.TEXT_MUTED};
|
||||
}}
|
||||
.file-tree-dir {{
|
||||
color: {_Theme.ACCENT};
|
||||
text-style: bold;
|
||||
}}
|
||||
.session-item {{
|
||||
padding: 0 0 0 1;
|
||||
color: {_Theme.TEXT_MUTED};
|
||||
}}
|
||||
.session-item-active {{
|
||||
color: {_Theme.SUCCESS};
|
||||
text-style: bold;
|
||||
}}
|
||||
.plan-badge {{
|
||||
color: {_Theme.WARNING};
|
||||
text-style: bold;
|
||||
}}
|
||||
|
||||
/* ── 消息样式 ── */
|
||||
.msg-user {{
|
||||
color: {_Theme.USER};
|
||||
@@ -297,6 +320,97 @@ if TEXTUAL_AVAILABLE:
|
||||
background: {_Theme.BG};
|
||||
}}
|
||||
|
||||
/* ── 自定义顶栏 (opencode 风格) ── */
|
||||
#tui-header {{
|
||||
height: 1;
|
||||
width: 100%;
|
||||
dock: top;
|
||||
background: {_Theme.BG_LIGHT};
|
||||
color: {_Theme.TEXT_MUTED};
|
||||
padding: 0 1;
|
||||
border-bottom: solid {_Theme.BORDER};
|
||||
}}
|
||||
#tui-header .hdr-left {{ color: {_Theme.ACCENT}; text-style: bold; }}
|
||||
#tui-header .hdr-mid {{ color: {_Theme.TEXT_MUTED}; }}
|
||||
#tui-header .hdr-right {{ color: {_Theme.SUCCESS}; }}
|
||||
|
||||
/* ── 侧栏标签页 ── */
|
||||
#sidebar-tabs {{
|
||||
height: 1;
|
||||
width: 100%;
|
||||
dock: top;
|
||||
background: {_Theme.BG};
|
||||
padding: 0;
|
||||
}}
|
||||
.sidebar-tab {{
|
||||
display: block;
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
color: {_Theme.TEXT_DIM};
|
||||
text-style: bold;
|
||||
}}
|
||||
.sidebar-tab:hover {{
|
||||
color: {_Theme.TEXT_MUTED};
|
||||
}}
|
||||
.sidebar-tab-active {{
|
||||
color: {_Theme.ACCENT};
|
||||
border-bottom: solid {_Theme.ACCENT};
|
||||
}}
|
||||
|
||||
/* ── 侧栏内容面板 ── */
|
||||
#sidebar-content {{
|
||||
height: 1fr;
|
||||
padding: 0 1;
|
||||
}}
|
||||
.panel-section {{
|
||||
padding: 1 0 0 0;
|
||||
border-bottom: solid {_Theme.BORDER};
|
||||
}}
|
||||
.panel-title {{
|
||||
color: {_Theme.TEXT_DIM};
|
||||
padding: 0 0 1 0;
|
||||
}}
|
||||
|
||||
/* ── 消息角色色条 (opencode 风格) ── */
|
||||
.msg-user {{
|
||||
color: {_Theme.USER};
|
||||
padding: 0 0 0 2;
|
||||
border-left: solid {_Theme.USER};
|
||||
}}
|
||||
.msg-ai {{
|
||||
color: {_Theme.AI};
|
||||
padding: 0 0 0 2;
|
||||
border-left: solid {_Theme.GLOW};
|
||||
}}
|
||||
.msg-tool-ok {{
|
||||
color: {_Theme.TOOL};
|
||||
padding: 0 0 0 2;
|
||||
border-left: solid {_Theme.TOOL};
|
||||
}}
|
||||
.msg-tool-err {{
|
||||
color: {_Theme.TOOL_ERR};
|
||||
padding: 0 0 0 2;
|
||||
border-left: solid {_Theme.TOOL_ERR};
|
||||
}}
|
||||
.msg-system {{
|
||||
color: {_Theme.ACCENT};
|
||||
padding: 0 0 0 2;
|
||||
border-left: solid {_Theme.ACCENT};
|
||||
}}
|
||||
.msg-error {{
|
||||
color: {_Theme.ERROR};
|
||||
padding: 0 0 0 2;
|
||||
border-left: solid {_Theme.ERROR};
|
||||
}}
|
||||
|
||||
/* ── 精简欢迎框 ── */
|
||||
.welcome-compact {{
|
||||
color: {_Theme.WELCOME_ACCENT};
|
||||
padding: 1 2;
|
||||
border-bottom: solid {_Theme.BORDER};
|
||||
text-style: bold;
|
||||
}}
|
||||
|
||||
"""
|
||||
|
||||
class _TUIBridge:
|
||||
@@ -317,6 +431,24 @@ if TEXTUAL_AVAILABLE:
|
||||
return True
|
||||
return False
|
||||
|
||||
def sessions(self) -> list:
|
||||
mgr = getattr(self.cli, 'session_manager', None)
|
||||
if mgr and hasattr(mgr, 'list_sessions'):
|
||||
try:
|
||||
return mgr.list_sessions()
|
||||
except Exception:
|
||||
return []
|
||||
return []
|
||||
|
||||
def plan_mode(self) -> bool:
|
||||
return bool(getattr(self.cli, 'plan_mode', False))
|
||||
|
||||
def current_plan(self) -> str:
|
||||
return getattr(self.cli, 'current_plan', '') or ""
|
||||
|
||||
def cwd(self) -> str:
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# 动画辅助组件
|
||||
@@ -379,7 +511,12 @@ if TEXTUAL_AVAILABLE:
|
||||
self.styles.opacity = 0
|
||||
|
||||
def on_mount(self):
|
||||
self.animate("opacity", value=1.0, duration=0.3, easing="out_cubic")
|
||||
# textual >= 8: Widget.opacity 是只读属性,直接 animate 会抛
|
||||
# "property 'opacity' has no setter"。改为在 styles 上动画,失败则直接到位。
|
||||
try:
|
||||
self.styles.animate("opacity", value=1.0, duration=0.3, easing="out_cubic")
|
||||
except Exception:
|
||||
self.styles.opacity = 1.0
|
||||
|
||||
|
||||
class TypingIndicator(Static):
|
||||
@@ -440,11 +577,18 @@ if TEXTUAL_AVAILABLE:
|
||||
Binding("ctrl+enter", "send_message", "发送", show=False),
|
||||
Binding("shift+enter", "newline", "换行", show=False),
|
||||
Binding("f1", "toggle_sidebar", "侧栏", show=True),
|
||||
Binding("ctrl+p", "toggle_plan", "PLAN", show=True),
|
||||
Binding("1", "tab_engine", "引擎", show=False),
|
||||
Binding("2", "tab_files", "文件", show=False),
|
||||
Binding("3", "tab_sessions", "会话", show=False),
|
||||
Binding("4", "tab_status", "状态", show=False),
|
||||
Binding("escape", "cancel", "取消", show=False),
|
||||
]
|
||||
|
||||
sidebar_visible = var(True)
|
||||
is_generating = var(False)
|
||||
plan_mode = var(False)
|
||||
_sidebar_tab: str = "engine" # 当前侧栏标签: engine/files/sessions/status
|
||||
|
||||
def __init__(self, cli):
|
||||
super().__init__()
|
||||
@@ -454,7 +598,8 @@ if TEXTUAL_AVAILABLE:
|
||||
self._typing_widget = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(show_clock=True)
|
||||
# ── 自定义顶栏 (opencode 风格: 左项目名 | 中模型 | 右状态) ──
|
||||
yield Static("", id="tui-header")
|
||||
with Horizontal(id="app-container"):
|
||||
with Vertical(id="main"):
|
||||
yield VerticalScroll(id="chat-scroll")
|
||||
@@ -467,28 +612,52 @@ if TEXTUAL_AVAILABLE:
|
||||
tab_behavior="indent",
|
||||
)
|
||||
yield Static(
|
||||
" 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏",
|
||||
" 回车发送 | Shift+回车换行 | Ctrl+L 清屏 | F1 侧栏 | Ctrl+P PLAN",
|
||||
id="input-hint"
|
||||
)
|
||||
# ── 侧栏:标签页 + 内容区 ──
|
||||
with Vertical(id="sidebar"):
|
||||
yield Static(" 引擎", classes="sidebar-header")
|
||||
yield Vertical(id="engine-list", classes="sidebar-section")
|
||||
yield Rule(line_style="heavy")
|
||||
yield Static(" 工具", classes="sidebar-header")
|
||||
yield Vertical(id="tool-list", classes="sidebar-section")
|
||||
yield Rule(line_style="heavy")
|
||||
yield Static(" 状态", classes="sidebar-header")
|
||||
yield Vertical(id="status-info", classes="sidebar-section")
|
||||
# 标签行
|
||||
with Horizontal(id="sidebar-tabs"):
|
||||
yield Static(" 引擎 ", classes="sidebar-tab sidebar-tab-active", id="tab-engine")
|
||||
yield Static(" 文件 ", classes="sidebar-tab", id="tab-files")
|
||||
yield Static(" 会话 ", classes="sidebar-tab", id="tab-sessions")
|
||||
yield Static(" 状态 ", classes="sidebar-tab", id="tab-status")
|
||||
# 内容区(根据当前标签切换显示)
|
||||
with VerticalScroll(id="sidebar-content"):
|
||||
yield Vertical(id="panel-engine", classes="panel-section")
|
||||
yield Vertical(id="panel-files", classes="panel-section")
|
||||
yield Vertical(id="panel-sessions", classes="panel-section")
|
||||
yield Vertical(id="panel-status", classes="panel-section")
|
||||
yield Static(" 就绪 | Ctrl+C 退出", id="status-bar")
|
||||
|
||||
def on_mount(self):
|
||||
self._render_welcome_animated()
|
||||
self._update_sidebar()
|
||||
# 初始化顶栏
|
||||
self._update_header()
|
||||
# 初始化侧栏: 默认显示引擎面板,隐藏其余
|
||||
self._switch_sidebar_tab("engine")
|
||||
# 精简欢迎(一行)
|
||||
self._render_welcome_compact()
|
||||
self.query_one("#user-input").focus()
|
||||
# 延迟显示输入提示(淡入效果)
|
||||
self.set_timer(1.5, lambda: self.query_one("#input-hint").add_class("visible"))
|
||||
def _show_hint():
|
||||
try:
|
||||
self.query_one("#input-hint").add_class("visible")
|
||||
except Exception:
|
||||
pass
|
||||
self.set_timer(1.5, _show_hint)
|
||||
|
||||
# ── 动画欢迎界面 ──
|
||||
# ── 欢迎界面 ──
|
||||
|
||||
def _render_welcome_compact(self):
|
||||
"""opencode 风格: 精简一行欢迎"""
|
||||
scroll = self.query_one("#chat-scroll")
|
||||
engine = self.bridge.current_engine()
|
||||
welcome = Static(
|
||||
f" ▸ 小狸 Pro-CLI v8.0 │ {engine} │ "
|
||||
f"{len(self.bridge.tools())} 工具 │ /help 查看命令",
|
||||
classes="welcome-compact"
|
||||
)
|
||||
scroll.mount(welcome)
|
||||
|
||||
def _render_welcome_animated(self):
|
||||
"""逐行动画显示欢迎界面"""
|
||||
@@ -498,7 +667,7 @@ if TEXTUAL_AVAILABLE:
|
||||
"",
|
||||
" ╔══════════════════════════════════════════════╗",
|
||||
" ║ ║",
|
||||
" ║ 小狸 Pro-CLI v5.4.1 ║",
|
||||
" ║ 小狸 Pro-CLI v8.0.2 ║",
|
||||
" ║ 智能编程助手 · 动画增强版 ║",
|
||||
" ║ ║",
|
||||
" ╚══════════════════════════════════════════════╝",
|
||||
@@ -542,7 +711,7 @@ if TEXTUAL_AVAILABLE:
|
||||
("", "msg-dim"),
|
||||
(" ╔══════════════════════════════════════════════╗", "msg-welcome"),
|
||||
(" ║ ║", "msg-welcome"),
|
||||
(" ║ 小狸 Pro-CLI v5.4.1 ║", "msg-welcome"),
|
||||
(" ║ 小狸 Pro-CLI v8.0.2 ║", "msg-welcome"),
|
||||
(" ║ 智能编程助手 · 动画增强版 ║", "msg-welcome"),
|
||||
(" ║ ║", "msg-welcome"),
|
||||
(" ╚══════════════════════════════════════════════╝", "msg-welcome"),
|
||||
@@ -559,35 +728,197 @@ if TEXTUAL_AVAILABLE:
|
||||
# ── 侧栏更新 ──
|
||||
|
||||
def _update_sidebar(self):
|
||||
engine_container = self.query_one("#engine-list")
|
||||
engine_container.remove_children()
|
||||
"""按当前标签页更新侧栏内容"""
|
||||
tab = self._sidebar_tab
|
||||
|
||||
# ── 引擎面板 ──
|
||||
engine_panel = self.query_one("#panel-engine")
|
||||
engine_panel.remove_children()
|
||||
current = self.bridge.current_engine()
|
||||
for name in self.bridge.engines():
|
||||
if name == current:
|
||||
engine_container.mount(Static(f" ▸ {name}", classes="engine-item-active"))
|
||||
engine_panel.mount(Static(f" ▸ {name}", classes="engine-item-active"))
|
||||
else:
|
||||
engine_container.mount(Static(f" {name}", classes="engine-item"))
|
||||
|
||||
tool_container = self.query_one("#tool-list")
|
||||
tool_container.remove_children()
|
||||
for tool in self.bridge.tools():
|
||||
engine_panel.mount(Static(f" {name}", classes="engine-item"))
|
||||
# 工具列表也放引擎面板下方
|
||||
engine_panel.mount(Static(" 工具", classes="panel-title"))
|
||||
for tool in self.bridge.tools()[:12]:
|
||||
name = tool.get('name', '?')
|
||||
tool_container.mount(Static(f" • {name}", classes="tool-item"))
|
||||
engine_panel.mount(Static(f" • {name}", classes="tool-item"))
|
||||
|
||||
status_container = self.query_one("#status-info")
|
||||
status_container.remove_children()
|
||||
status_container.mount(Static(f" 对话: {len(self.bridge.history())} 条", classes="tool-item"))
|
||||
status_container.mount(Static(f" 工具: {len(self.bridge.tools())} 个", classes="tool-item"))
|
||||
status_container.mount(Static(f" 引擎: {len(self.bridge.engines())} 个", classes="tool-item"))
|
||||
# ── 文件树面板 ──
|
||||
self._update_file_tree()
|
||||
|
||||
# 工具数
|
||||
if hasattr(self.cli, 'liugin_manager'):
|
||||
status_container.mount(Static(f" 工具: {len(self.cli.liugin_manager.tools)} 个", classes="tool-item"))
|
||||
# ── 会话面板 ──
|
||||
self._update_session_list()
|
||||
|
||||
# ── 状态面板 ──
|
||||
status_panel = self.query_one("#panel-status")
|
||||
status_panel.remove_children()
|
||||
status_panel.mount(Static(f" 对话: {len(self.bridge.history())} 条", classes="tool-item"))
|
||||
status_panel.mount(Static(f" 工具: {len(self.bridge.tools())} 个", classes="tool-item"))
|
||||
status_panel.mount(Static(f" 引擎: {len(self.bridge.engines())} 个", classes="tool-item"))
|
||||
if self.bridge.plan_mode():
|
||||
status_panel.mount(Static(" 模式: 📋 PLAN", classes="plan-badge"))
|
||||
else:
|
||||
status_panel.mount(Static(" 模式: 普通", classes="tool-item"))
|
||||
|
||||
# 更新标签高亮
|
||||
self._sync_tab_highlight()
|
||||
|
||||
# ── 文件树 ──
|
||||
|
||||
@staticmethod
|
||||
def _build_file_tree(root, max_depth=2, max_items=40):
|
||||
"""生成 (name, is_dir) 列表,用于侧栏文件树展示。"""
|
||||
items = []
|
||||
try:
|
||||
entries = sorted(os.listdir(root))
|
||||
except OSError:
|
||||
return items
|
||||
dirs = [e for e in entries if os.path.isdir(os.path.join(root, e))]
|
||||
files = [e for e in entries if os.path.isfile(os.path.join(root, e))]
|
||||
skip = {'.git', '__pycache__', '.tui_snapshots', '.pytest_cache',
|
||||
'node_modules', '.workbuddy', 'chat_history', 'memory',
|
||||
'.idea', '.vscode', '.venv', 'venv'}
|
||||
shown = 0
|
||||
for name in dirs + files:
|
||||
if name in skip or name.startswith('.'):
|
||||
continue
|
||||
if shown >= max_items:
|
||||
items.append(("…", False))
|
||||
break
|
||||
is_dir = os.path.isdir(os.path.join(root, name))
|
||||
items.append((name, is_dir))
|
||||
shown += 1
|
||||
return items
|
||||
|
||||
def _update_file_tree(self):
|
||||
container = self.query_one("#panel-files")
|
||||
container.remove_children()
|
||||
root = self.bridge.cwd()
|
||||
container.mount(Static(f" 📂 {os.path.basename(root)}", classes="file-tree-dir"))
|
||||
for name, is_dir in self._build_file_tree(root):
|
||||
icon = "📁" if is_dir else "📄"
|
||||
container.mount(Static(f" {icon} {name}", classes="file-tree-item"))
|
||||
|
||||
# ── 会话列表 ──
|
||||
|
||||
@staticmethod
|
||||
def _format_rel_time(ts: str) -> str:
|
||||
if not ts:
|
||||
return ""
|
||||
try:
|
||||
from datetime import datetime
|
||||
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
||||
diff = (datetime.now() - dt).total_seconds()
|
||||
if diff < 60:
|
||||
return "刚刚"
|
||||
if diff < 3600:
|
||||
return f"{int(diff // 60)}分钟前"
|
||||
if diff < 86400:
|
||||
return f"{int(diff // 3600)}小时前"
|
||||
return f"{int(diff // 86400)}天前"
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _update_session_list(self):
|
||||
container = self.query_one("#panel-sessions")
|
||||
container.remove_children()
|
||||
sessions = self.bridge.sessions()[:8]
|
||||
if not sessions:
|
||||
container.mount(Static(" (无历史会话)", classes="session-item"))
|
||||
return
|
||||
for i, s in enumerate(sessions, 1):
|
||||
title = (s.title or s.id)[:22]
|
||||
t = self._format_rel_time(s.updated_at)
|
||||
container.mount(Static(f" {i}. {title} {t}", classes="session-item"))
|
||||
|
||||
# ── 标签页切换 ──
|
||||
|
||||
def _sync_tab_highlight(self):
|
||||
"""同步标签高亮状态"""
|
||||
tab_map = {"engine": "tab-engine", "files": "tab-files",
|
||||
"sessions": "tab-sessions", "status": "tab-status"}
|
||||
for tid in tab_map.values():
|
||||
try:
|
||||
w = self.query_one(f"#{tid}")
|
||||
if tid == tab_map.get(self._sidebar_tab, ""):
|
||||
w.add_class("sidebar-tab-active")
|
||||
else:
|
||||
w.remove_class("sidebar-tab-active")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _switch_sidebar_tab(self, tab: str):
|
||||
"""切换侧栏标签页"""
|
||||
self._sidebar_tab = tab
|
||||
# 显示/隐藏面板
|
||||
panel_map = {
|
||||
"engine": "panel-engine", "files": "panel-files",
|
||||
"sessions": "panel-sessions", "status": "panel-status",
|
||||
}
|
||||
for pt, pid in panel_map.items():
|
||||
try:
|
||||
p = self.query_one(f"#{pid}")
|
||||
if pt == tab:
|
||||
p.remove_class("hidden")
|
||||
else:
|
||||
p.add_class("hidden")
|
||||
except Exception:
|
||||
pass
|
||||
self._sync_tab_highlight()
|
||||
self._update_sidebar()
|
||||
|
||||
def action_tab_engine(self):
|
||||
self._switch_sidebar_tab("engine")
|
||||
|
||||
def action_tab_files(self):
|
||||
self._switch_sidebar_tab("files")
|
||||
|
||||
def action_tab_sessions(self):
|
||||
self._switch_sidebar_tab("sessions")
|
||||
|
||||
def action_tab_status(self):
|
||||
self._switch_sidebar_tab("status")
|
||||
|
||||
def _update_header(self):
|
||||
"""更新自定义顶栏内容"""
|
||||
hdr = self.query_one("#tui-header", expect_type=Static)
|
||||
engine = self.bridge.current_engine()
|
||||
plan_tag = " [PLAN]" if self.bridge.plan_mode() else ""
|
||||
hist_len = len(self.bridge.history())
|
||||
tok = self._token_str().strip()
|
||||
hdr.update(
|
||||
f" 小狸 v8.0{plan_tag} │ {engine} │ {hist_len}条对话{tok}"
|
||||
)
|
||||
|
||||
def _update_status(self, text: str):
|
||||
bar = self.query_one("#status-bar")
|
||||
engine = self.bridge.current_engine()
|
||||
bar.update(f" {text} | {engine} | {len(self.bridge.history())} 条对话")
|
||||
prefix = "📋 PLAN | " if self.plan_mode else ""
|
||||
bar.update(f" {prefix}{text} | {engine} | {len(self.bridge.history())} 条对话{self._token_str()}")
|
||||
|
||||
def _token_str(self):
|
||||
"""token 用量进度(v8.0 token 感知压缩联动,引擎支持时显示)"""
|
||||
e = getattr(self.cli, 'current_engine', None)
|
||||
if not e:
|
||||
return ""
|
||||
used = getattr(e, 'last_prompt_tokens', None)
|
||||
win = None
|
||||
if hasattr(e, 'context_window'):
|
||||
try:
|
||||
win = e.context_window()
|
||||
except Exception:
|
||||
win = None
|
||||
if used and win:
|
||||
ratio = min(1.0, used / win)
|
||||
filled = int(ratio * 10)
|
||||
bar_s = "▓" * filled + "░" * (10 - filled)
|
||||
return f" | {used}/{win} {bar_s}"
|
||||
if used:
|
||||
return f" | {used} tok"
|
||||
return ""
|
||||
|
||||
# ── 消息追加 ──
|
||||
|
||||
@@ -743,27 +1074,35 @@ if TEXTUAL_AVAILABLE:
|
||||
'cls': lambda: self.action_clear(),
|
||||
'model': lambda: self._switch_model(args),
|
||||
'engine': lambda: self._switch_model(args),
|
||||
'about': lambda: self._system(" 小狸 Pro-CLI v5.4.1 - 智能编程助手"),
|
||||
'about': lambda: self._system(f" 小狸 Pro-CLI v{VERSION} - 智能编程助手"),
|
||||
'status': lambda: self._show_status(),
|
||||
'tools': lambda: self._show_tools(),
|
||||
'engines': lambda: self._show_engines(),
|
||||
'tui': lambda: self._system("已在 TUI 模式中"),
|
||||
'manual': lambda: self._handle_manual(args),
|
||||
'plan': lambda: self._tui_plan(args),
|
||||
'build': lambda: self._tui_build(),
|
||||
'sessions': lambda: self._tui_sessions(),
|
||||
'session': lambda: self._tui_session(args),
|
||||
'resume': lambda: self._tui_resume(args),
|
||||
'snapshot': lambda: self._save_screenshot(),
|
||||
'screenshot': lambda: self._save_screenshot(),
|
||||
}
|
||||
|
||||
handler = cmds.get(name)
|
||||
if handler:
|
||||
handler()
|
||||
else:
|
||||
if name in self.cli.liugin_commands:
|
||||
try:
|
||||
result = self.cli.liugin_commands[name](args)
|
||||
if result:
|
||||
self._system(result[:500])
|
||||
except Exception as e:
|
||||
self._error(f"插件命令失败: {e}")
|
||||
else:
|
||||
self._error(f"未知命令: /{name},输入 /help 查看帮助")
|
||||
return
|
||||
if name in self.cli.liugin_commands:
|
||||
try:
|
||||
result = self.cli.liugin_commands[name](args)
|
||||
if result:
|
||||
self._system(result[:500])
|
||||
except Exception as e:
|
||||
self._error(f"插件命令失败: {e}")
|
||||
return
|
||||
# 未知命令转发给 cli 完整路由(/agent /providers /chat 等)
|
||||
self._forward_to_cli(cmd)
|
||||
|
||||
def _show_help(self):
|
||||
help_text = """ 命令:
|
||||
@@ -845,6 +1184,84 @@ if TEXTUAL_AVAILABLE:
|
||||
|
||||
self._system('\n'.join(status_lines))
|
||||
|
||||
# ── Plan 模式 / 会话 委派 ──
|
||||
|
||||
def _tui_plan(self, args):
|
||||
args = (args or "").strip()
|
||||
if args in ('off', 'exit', '退出', 'cancel'):
|
||||
self._set_plan_mode(False)
|
||||
self._system("已退出 PLAN 模式(计划未执行)")
|
||||
return
|
||||
self._set_plan_mode(True)
|
||||
if args:
|
||||
self._system(f"📋 已进入 PLAN 模式,正在只读调研: {args}")
|
||||
self._user_msg(f"/plan {args}")
|
||||
self._show_thinking()
|
||||
self.is_generating = True
|
||||
self._update_status("思考中 (PLAN)...")
|
||||
self.run_worker(self._generate(args), exclusive=True)
|
||||
else:
|
||||
self._system("📋 已进入 PLAN 模式。描述任务,AI 将只做只读调研并给出实施计划;/build 批准执行,/plan off 取消。")
|
||||
|
||||
def _tui_build(self):
|
||||
if not self.bridge.plan_mode() and not self.bridge.current_plan().strip():
|
||||
self._system("当前不在 PLAN 模式,且无已生成的计划")
|
||||
return
|
||||
self._set_plan_mode(False)
|
||||
self._system("✅ 已批准 PLAN,开始执行。")
|
||||
|
||||
def _tui_sessions(self):
|
||||
sessions = self.bridge.sessions()
|
||||
if not sessions:
|
||||
self._system(" 无历史会话")
|
||||
return
|
||||
lines = [" 历史会话:"]
|
||||
for i, s in enumerate(sessions[:10], 1):
|
||||
title = (s.title or s.id)[:30]
|
||||
t = self._format_rel_time(s.updated_at)
|
||||
lines.append(f" {i}. {title} ({t})")
|
||||
self._system('\n'.join(lines))
|
||||
|
||||
def _tui_session(self, args):
|
||||
args = (args or "").strip()
|
||||
if not args:
|
||||
self._tui_sessions()
|
||||
return
|
||||
if args == 'new':
|
||||
self.action_new_chat()
|
||||
return
|
||||
self._forward_to_cli(f"/resume {args}")
|
||||
|
||||
def _tui_resume(self, args):
|
||||
self._forward_to_cli(f"/resume {args}")
|
||||
|
||||
def _forward_to_cli(self, cmd: str):
|
||||
"""把未知/委派命令交给 cli 完整路由处理。"""
|
||||
try:
|
||||
self.cli.process_conversation(cmd)
|
||||
except Exception as e:
|
||||
self._error(f"命令执行失败: {e}")
|
||||
self.call_after_refresh(self._update_sidebar)
|
||||
self.call_after_refresh(self._sync_plan)
|
||||
|
||||
def _set_plan_mode(self, on: bool):
|
||||
self.plan_mode = on
|
||||
try:
|
||||
self.cli.plan_mode = on
|
||||
if not on:
|
||||
self.cli.current_plan = ""
|
||||
except Exception:
|
||||
pass
|
||||
self._update_sidebar()
|
||||
self._update_status("📋 PLAN" if on else "就绪")
|
||||
|
||||
def _sync_plan(self):
|
||||
"""从 cli 同步 plan 状态到 TUI 反应变量。"""
|
||||
remote = self.bridge.plan_mode()
|
||||
if remote != self.plan_mode:
|
||||
self.plan_mode = remote
|
||||
self._update_sidebar()
|
||||
|
||||
# ── AI 生成 ──
|
||||
|
||||
async def _generate(self, user_input):
|
||||
@@ -874,8 +1291,10 @@ if TEXTUAL_AVAILABLE:
|
||||
self.call_after_refresh(self._remove_thinking)
|
||||
self.call_after_refresh(self._remove_typing_indicator)
|
||||
self.is_generating = False
|
||||
self.call_after_refresh(self._sync_plan)
|
||||
self.call_after_refresh(lambda: self._update_status("就绪"))
|
||||
self.call_after_refresh(self._update_sidebar)
|
||||
self.call_after_refresh(self._update_header)
|
||||
|
||||
def _write_raw(self, msg):
|
||||
if '✅' in msg or 'OK 工具' in msg:
|
||||
@@ -911,6 +1330,10 @@ if TEXTUAL_AVAILABLE:
|
||||
sidebar.add_class("hidden")
|
||||
self.sidebar_visible = False
|
||||
|
||||
def action_toggle_plan(self):
|
||||
self._set_plan_mode(not self.plan_mode)
|
||||
self._system("📋 PLAN 模式: " + ("开" if self.plan_mode else "关"))
|
||||
|
||||
def action_cancel(self):
|
||||
if self.is_generating:
|
||||
self._remove_thinking()
|
||||
@@ -918,6 +1341,22 @@ if TEXTUAL_AVAILABLE:
|
||||
self._system("已取消")
|
||||
self.is_generating = False
|
||||
|
||||
def _save_screenshot(self):
|
||||
"""导出当前 TUI 屏幕截图(SVG)——AI 可据此自验实时渲染(/snapshot)"""
|
||||
import os
|
||||
out_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".tui_snapshots"
|
||||
)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
path = os.path.join(out_dir, "live.svg")
|
||||
try:
|
||||
svg = self.export_screenshot()
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(svg)
|
||||
self._system(f"截图已保存: {path}")
|
||||
except Exception as e:
|
||||
self._error(f"截图失败: {e}")
|
||||
|
||||
else:
|
||||
# Textual 不可用时的占位类
|
||||
class XiaoliTUI:
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user