From 1054932921af38659df8cc806b36b0a78a21515d Mon Sep 17 00:00:00 2001 From: shuiyu Date: Wed, 5 Aug 2026 21:23:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20pylsp=20=E4=BD=9C=E4=B8=BA=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E4=B9=8B=E4=B8=80=20+=20AI=20=E5=86=99/=E6=94=B9=20.p?= =?UTF-8?q?y=20=E8=87=AA=E5=8A=A8=E8=A7=A6=E5=8F=91=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E5=B9=B6=E6=B3=A8=E5=85=A5=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补装 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 钩子单测 --- config.json.example | 8 ++++ plugins/code_editor.py | 42 ++++++++++++++++---- plugins/file_manager.py | 40 ++++++++++++++++++- requirements.txt | 49 +++++++++--------------- tests/test_lsp_client.py | 83 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 183 insertions(+), 39 deletions(-) diff --git a/config.json.example b/config.json.example index a529ef4..0e12645 100644 --- a/config.json.example +++ b/config.json.example @@ -17,5 +17,13 @@ "system": { "default_engine": "ollama", "max_history": 50 + }, + "lspServers": { + "_说明": "语言服务器(LSP)。键为语言ID或扩展名,值为启动命令。配置后重启即生效,工具 lsp__diagnostics/hover/definition/references/completion 自动可用,且 AI 写/改 .py 文件后会自动触发诊断并注入模型上下文。", + "python": { + "command": "pylsp", + "args": [], + "disabled": false + } } } diff --git a/plugins/code_editor.py b/plugins/code_editor.py index af232e7..016b4fd 100644 --- a/plugins/code_editor.py +++ b/plugins/code_editor.py @@ -599,6 +599,34 @@ 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}" + # ══════════════════════════════════════ # Diff 弹窗系统 # ══════════════════════════════════════ @@ -975,7 +1003,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) return result def _op_multi_edit(self, path: str, rest: str) -> str: @@ -1013,7 +1041,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) return result def _op_insert(self, path: str, rest: str) -> str: @@ -1046,7 +1074,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) return result def _op_delete_lines(self, path: str, rest: str) -> str: @@ -1079,7 +1107,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) return result def _op_create(self, path: str, rest: str) -> str: @@ -1095,7 +1123,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) return result def _op_write(self, path: str, rest: str) -> str: @@ -1110,7 +1138,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) return result def _op_append(self, path: str, rest: str) -> str: @@ -1125,7 +1153,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) return result # ══════════════════════════════════════ diff --git a/plugins/file_manager.py b/plugins/file_manager.py index e45e1b1..5c009b4 100644 --- a/plugins/file_manager.py +++ b/plugins/file_manager.py @@ -38,6 +38,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", @@ -219,7 +247,11 @@ 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: @@ -237,7 +269,11 @@ 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: diff --git a/requirements.txt b/requirements.txt index 1900e47..fab81ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,32 +1,21 @@ -# 小狸 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 - -# 数据处理 -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 +# LSP 智能感知(pylsp 作为工具之一:lsp__diagnostics/hover/definition/references/completion) +# 写/改 .py 文件后自动触发诊断并注入模型上下文 +python-lsp-server +pyflakes +pycodestyle +rope +mccabe diff --git a/tests/test_lsp_client.py b/tests/test_lsp_client.py index b28c510..e1c09f1 100644 --- a/tests/test_lsp_client.py +++ b/tests/test_lsp_client.py @@ -290,3 +290,86 @@ def test_fc_tools_includes_lsp(tmp_path, monkeypatch, fake_server): 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)) == ""