feat: pylsp 作为工具之一 + AI 写/改 .py 自动触发诊断并注入上下文
- 补装 pylsp 及 lint 后端(pyflakes/pycodestyle/rope/mccabe),真实诊断可用 - code_editor / file_manager 写/改操作后自动调用 LSP 诊断(.py),结果经 get_lsp_context() 静默注入下一轮模型上下文,仅在有问题时的可见提示 - config.json 中 rust 设为 disabled(本机未装,免 ERROR 噪声) - requirements.txt / config.json.example 补充 pylsp 配置说明 - 新增 4 个 _auto_lsp_check 钩子单测
This commit is contained in:
1 parent
c655d61af4
commit
1054932921
5 files changed
+183
-39
No files matched your search
@@ -17,5 +17,13 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"default_engine": "ollama",
|
"default_engine": "ollama",
|
||||||
"max_history": 50
|
"max_history": 50
|
||||||
|
},
|
||||||
|
"lspServers": {
|
||||||
|
"_说明": "语言服务器(LSP)。键为语言ID或扩展名,值为启动命令。配置后重启即生效,工具 lsp__diagnostics/hover/definition/references/completion 自动可用,且 AI 写/改 .py 文件后会自动触发诊断并注入模型上下文。",
|
||||||
|
"python": {
|
||||||
|
"command": "pylsp",
|
||||||
|
"args": [],
|
||||||
|
"disabled": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+35
-7
@@ -599,6 +599,34 @@ else:
|
|||||||
else:
|
else:
|
||||||
return f"\n{result}"
|
return f"\n{result}"
|
||||||
|
|
||||||
|
def _auto_lsp_check(self, filepath: str) -> str:
|
||||||
|
"""写/改文件后自动让已连接的语言服务器(pylsp 等)重新诊断该文件。
|
||||||
|
|
||||||
|
诊断结果会被缓存进 LSP 管理器的 _workspace_diag,由 get_lsp_context()
|
||||||
|
在下一轮静默注入模型上下文;同时仅在确有问题时给一行可见提示。
|
||||||
|
无对应 server / 文件干净时静默跳过,绝不影响主流程。
|
||||||
|
"""
|
||||||
|
manager = None
|
||||||
|
try:
|
||||||
|
cli = self.cli
|
||||||
|
utm = getattr(cli, 'liugin_manager', None)
|
||||||
|
if utm is not None:
|
||||||
|
manager = getattr(utm, '_lsp_manager', None)
|
||||||
|
except Exception:
|
||||||
|
manager = None
|
||||||
|
if manager is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
diag_text = manager.diagnostics(filepath, wait=1.5)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if not diag_text or "没有为" in diag_text:
|
||||||
|
return "" # 该扩展名未配置 LSP server,跳过
|
||||||
|
if "无诊断" in diag_text:
|
||||||
|
return "" # 干净,无需呈现(缓存也为空)
|
||||||
|
first = diag_text.splitlines()[0] if diag_text else ""
|
||||||
|
return f"\n LSP 诊断: {first}"
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
# Diff 弹窗系统
|
# Diff 弹窗系统
|
||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
@@ -975,7 +1003,7 @@ else:
|
|||||||
|
|
||||||
self._spawn_diff_popup(fp, before_content, new_content)
|
self._spawn_diff_popup(fp, before_content, new_content)
|
||||||
result = f" 已编辑: {fp}\n\n{self._make_diff(old_text, new_text)}"
|
result = f" 已编辑: {fp}\n\n{self._make_diff(old_text, new_text)}"
|
||||||
result += self._auto_syntax_check(fp)
|
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _op_multi_edit(self, path: str, rest: str) -> str:
|
def _op_multi_edit(self, path: str, rest: str) -> str:
|
||||||
@@ -1013,7 +1041,7 @@ else:
|
|||||||
f.write(content)
|
f.write(content)
|
||||||
self._spawn_diff_popup(fp, before_content, content)
|
self._spawn_diff_popup(fp, before_content, content)
|
||||||
result = f" 批量编辑: {fp}\n" + "\n".join(results)
|
result = f" 批量编辑: {fp}\n" + "\n".join(results)
|
||||||
result += self._auto_syntax_check(fp)
|
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _op_insert(self, path: str, rest: str) -> str:
|
def _op_insert(self, path: str, rest: str) -> str:
|
||||||
@@ -1046,7 +1074,7 @@ else:
|
|||||||
after_content = ''.join(lines)
|
after_content = ''.join(lines)
|
||||||
self._spawn_diff_popup(fp, before_content, after_content)
|
self._spawn_diff_popup(fp, before_content, after_content)
|
||||||
result = f" 已在第 {line_num} 行插入 {len(insert_lines)} 行"
|
result = f" 已在第 {line_num} 行插入 {len(insert_lines)} 行"
|
||||||
result += self._auto_syntax_check(fp)
|
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _op_delete_lines(self, path: str, rest: str) -> str:
|
def _op_delete_lines(self, path: str, rest: str) -> str:
|
||||||
@@ -1079,7 +1107,7 @@ else:
|
|||||||
after_content = ''.join(lines)
|
after_content = ''.join(lines)
|
||||||
self._spawn_diff_popup(fp, before_content, after_content)
|
self._spawn_diff_popup(fp, before_content, after_content)
|
||||||
result = f" 已删除第 {start}-{end} 行 ({len(deleted)} 行)"
|
result = f" 已删除第 {start}-{end} 行 ({len(deleted)} 行)"
|
||||||
result += self._auto_syntax_check(fp)
|
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _op_create(self, path: str, rest: str) -> str:
|
def _op_create(self, path: str, rest: str) -> str:
|
||||||
@@ -1095,7 +1123,7 @@ else:
|
|||||||
f.write(rest or "")
|
f.write(rest or "")
|
||||||
self._spawn_diff_popup(fp, "", rest or "")
|
self._spawn_diff_popup(fp, "", rest or "")
|
||||||
result = f" 已创建: {fp} ({len(rest or '')} 字符)"
|
result = f" 已创建: {fp} ({len(rest or '')} 字符)"
|
||||||
result += self._auto_syntax_check(fp)
|
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _op_write(self, path: str, rest: str) -> str:
|
def _op_write(self, path: str, rest: str) -> str:
|
||||||
@@ -1110,7 +1138,7 @@ else:
|
|||||||
f.write(rest)
|
f.write(rest)
|
||||||
self._spawn_diff_popup(fp, before_content, rest)
|
self._spawn_diff_popup(fp, before_content, rest)
|
||||||
result = f" 已写入: {fp} ({len(rest)} 字符)"
|
result = f" 已写入: {fp} ({len(rest)} 字符)"
|
||||||
result += self._auto_syntax_check(fp)
|
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _op_append(self, path: str, rest: str) -> str:
|
def _op_append(self, path: str, rest: str) -> str:
|
||||||
@@ -1125,7 +1153,7 @@ else:
|
|||||||
after_content = self._read_file_safe(fp)
|
after_content = self._read_file_safe(fp)
|
||||||
self._spawn_diff_popup(fp, before_content, after_content)
|
self._spawn_diff_popup(fp, before_content, after_content)
|
||||||
result = f" 已追加: {fp} ({len(rest)} 字符)"
|
result = f" 已追加: {fp} ({len(rest)} 字符)"
|
||||||
result += self._auto_syntax_check(fp)
|
result += self._auto_syntax_check(fp) + self._auto_lsp_check(fp)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ══════════════════════════════════════
|
# ══════════════════════════════════════
|
||||||
|
|||||||
+38
-2
@@ -38,6 +38,34 @@ class Liugin:
|
|||||||
def set_cli(self, cli):
|
def set_cli(self, cli):
|
||||||
self.cli = cli
|
self.cli = cli
|
||||||
|
|
||||||
|
def _auto_lsp_check(self, filepath: str) -> str:
|
||||||
|
"""写/改文件后自动让已连接的语言服务器(pylsp 等)重新诊断该文件。
|
||||||
|
|
||||||
|
诊断结果缓存进 LSP 管理器的 _workspace_diag,由 get_lsp_context()
|
||||||
|
在下一轮静默注入模型上下文;仅在有问题时返回一行提示。
|
||||||
|
无对应 server / 文件干净时静默跳过。
|
||||||
|
"""
|
||||||
|
manager = None
|
||||||
|
try:
|
||||||
|
cli = self.cli
|
||||||
|
utm = getattr(cli, 'liugin_manager', None)
|
||||||
|
if utm is not None:
|
||||||
|
manager = getattr(utm, '_lsp_manager', None)
|
||||||
|
except Exception:
|
||||||
|
manager = None
|
||||||
|
if manager is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
diag_text = manager.diagnostics(filepath, wait=1.5)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
if not diag_text or "没有为" in diag_text:
|
||||||
|
return ""
|
||||||
|
if "无诊断" in diag_text:
|
||||||
|
return ""
|
||||||
|
first = diag_text.splitlines()[0] if diag_text else ""
|
||||||
|
return f"\n LSP 诊断: {first}"
|
||||||
|
|
||||||
def get_tool_info(self):
|
def get_tool_info(self):
|
||||||
return {
|
return {
|
||||||
"name": "file_manager",
|
"name": "file_manager",
|
||||||
@@ -219,7 +247,11 @@ class Liugin:
|
|||||||
os.makedirs(os.path.dirname(fp) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(fp) or ".", exist_ok=True)
|
||||||
with open(fp, 'w', encoding='utf-8') as f:
|
with open(fp, 'w', encoding='utf-8') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
return ToolResult.ok(f"已写入: {fp} ({len(content)} 字符)", tool_name="file_manager")
|
msg = f"已写入: {fp} ({len(content)} 字符)"
|
||||||
|
lsp_hint = self._auto_lsp_check(fp)
|
||||||
|
if lsp_hint:
|
||||||
|
msg += lsp_hint
|
||||||
|
return ToolResult.ok(msg, tool_name="file_manager")
|
||||||
|
|
||||||
def _op_append(self, args: List[str]):
|
def _op_append(self, args: List[str]):
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
@@ -237,7 +269,11 @@ class Liugin:
|
|||||||
return ToolResult.not_found(f"文件: {fp}")
|
return ToolResult.not_found(f"文件: {fp}")
|
||||||
with open(fp, 'a', encoding='utf-8') as f:
|
with open(fp, 'a', encoding='utf-8') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
return ToolResult.ok(f"已追加: {fp}", tool_name="file_manager")
|
msg = f"已追加: {fp}"
|
||||||
|
lsp_hint = self._auto_lsp_check(fp)
|
||||||
|
if lsp_hint:
|
||||||
|
msg += lsp_hint
|
||||||
|
return ToolResult.ok(msg, tool_name="file_manager")
|
||||||
|
|
||||||
def _op_copy(self, args: List[str]):
|
def _op_copy(self, args: List[str]):
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
|
|||||||
+19
-30
@@ -1,32 +1,21 @@
|
|||||||
# 小狸 Pro-CLI 依赖库
|
# 小狸 Pro-CLI 依赖
|
||||||
# 修复后的包名(使用 pip 安装名)
|
# 生成于 2026-08-05 21:10
|
||||||
|
|
||||||
# 核心依赖
|
PIL
|
||||||
colorama>=0.4.6
|
ascii_magic
|
||||||
openai>=1.0.0
|
colorama
|
||||||
requests>=2.28.0
|
cv2
|
||||||
ollama>=0.1.0
|
numpy
|
||||||
|
ollama
|
||||||
|
openai
|
||||||
|
pygame
|
||||||
|
requests
|
||||||
|
tkinter
|
||||||
|
|
||||||
# 图像处理
|
# LSP 智能感知(pylsp 作为工具之一:lsp__diagnostics/hover/definition/references/completion)
|
||||||
Pillow>=9.0.0
|
# 写/改 .py 文件后自动触发诊断并注入模型上下文
|
||||||
opencv-python>=4.5.0
|
python-lsp-server
|
||||||
numpy>=1.21.0
|
pyflakes
|
||||||
ascii-magic>=2.0.0
|
pycodestyle
|
||||||
|
rope
|
||||||
# 数据处理
|
mccabe
|
||||||
pandas>=1.3.0
|
|
||||||
scipy>=1.7.0
|
|
||||||
|
|
||||||
# 音频
|
|
||||||
pygame>=2.0.0
|
|
||||||
|
|
||||||
# TUI 模式
|
|
||||||
textual>=0.40.0
|
|
||||||
# v8.0 Rich 面板式 TUI(支持快照自验,默认 TUI 渲染器)
|
|
||||||
rich>=13.0.0
|
|
||||||
|
|
||||||
# WebSocket 远程
|
|
||||||
websockets>=11.0
|
|
||||||
|
|
||||||
# 类型提示
|
|
||||||
typing-extensions>=4.0.0
|
|
||||||
@@ -290,3 +290,86 @@ def test_fc_tools_includes_lsp(tmp_path, monkeypatch, fake_server):
|
|||||||
assert "file" in diag["function"]["parameters"]["properties"]
|
assert "file" in diag["function"]["parameters"]["properties"]
|
||||||
finally:
|
finally:
|
||||||
mgr.shutdown()
|
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)) == ""
|
||||||
Reference in new issue
Block a user