feat: LSP 集成(连接外部语言服务器,真实诊断/智能感知喂给模型)
- 新增 lsp_client.py:LspClient(subprocess stdio,Content-Length 分帧 JSON-RPC, reader 线程,initialize/initialized/didOpen/didChange/hover/definition/references/ completion + publishDiagnostics 通知捕获)+ LspManager(按扩展名→语言→server 路由、 connect_all 读 lspServers、get_context 返回已分析文件诊断、cleanup 幂等) - unified_tool_manager.py:initialize 末尾加载 LSP server,注册 5 个 lsp__* 工具 (diagnostics/hover/definition/references/completion,带真实 inputSchema、protocol='lsp'、 instance 存 manager 供 shutdown 清理) - cli_core.py:_build_system_prompt 末尾注入 LSP 诊断上下文(模型持续看到真实诊断) - config.json:顶层加 lspServers 段(python/rust 示例,含 _说明;该文件被 gitignore 不入库) - 新增 tests/test_lsp_client.py(9 项:fake server 全链路+路由+桥接+fc_tools 保真+缺失/失败优雅降级) - 修 LSP 线缆坑:didOpen/didChange 是通知(无响应),初版误当请求等响应导致超时 Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
This commit is contained in:
1 parent
d2ad1000b6
commit
9dd418ec89
4 files changed
+1061
No files matched your search
+607
@@ -0,0 +1,607 @@
|
||||
"""
|
||||
小狸 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 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
|
||||
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"},
|
||||
})
|
||||
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
|
||||
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
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()
|
||||
@@ -17,6 +17,11 @@ try:
|
||||
except Exception:
|
||||
McpClientManager = None
|
||||
|
||||
try:
|
||||
from lsp_client import LspManager
|
||||
except Exception:
|
||||
LspManager = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -56,6 +61,9 @@ class UnifiedToolManager:
|
||||
# 外部 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
|
||||
@@ -64,6 +72,7 @@ class UnifiedToolManager:
|
||||
self._load_skills(skills_dir)
|
||||
self._build_mcp_cache()
|
||||
self._load_mcp_servers()
|
||||
self._load_lsp_servers()
|
||||
|
||||
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')
|
||||
@@ -398,6 +407,149 @@ class UnifiedToolManager:
|
||||
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
|
||||
|
||||
@@ -268,6 +268,16 @@ multi 操作支持一次修改多处:
|
||||
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 解析 ──
|
||||
|
||||
Reference in new issue
Block a user