pi-web-ui 0.95.0 → 0.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -1
- package/bin/pi-web-ui.mjs +159 -31
- package/dist/server/agent-service.js +880 -240
- package/dist/server/approval-rules.js +56 -10
- package/dist/server/attachment-store.js +21 -4
- package/dist/server/attachments.js +30 -3
- package/dist/server/bg-servers.js +45 -7
- package/dist/server/claim-files-tool.js +4 -8
- package/dist/server/claim-store.js +4 -2
- package/dist/server/client-state.js +63 -14
- package/dist/server/compact-context-tool.js +6 -5
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +72 -5
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +19 -6
- package/dist/server/edit-soft-tool.js +14 -11
- package/dist/server/eval-tool.js +67 -14
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +30 -11
- package/dist/server/goal-service.js +141 -19
- package/dist/server/hashline-engine.js +59 -21
- package/dist/server/host-guard.js +94 -0
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +306 -78
- package/dist/server/lsp-tool.js +752 -60
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +94 -34
- package/dist/server/office-parse.js +96 -30
- package/dist/server/patch-tool.js +9 -15
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +11 -1
- package/dist/server/plugin-catalog-sync.js +36 -19
- package/dist/server/plugin-catalog.js +11 -4
- package/dist/server/plugin-facilities.js +92 -21
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-project.js +70 -2
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +205 -44
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +4 -0
- package/dist/server/prompt-composer.js +9 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +69 -30
- package/dist/server/resolve-global-sdk.js +30 -16
- package/dist/server/schedule-agent-tool.js +12 -15
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +108 -21
- package/dist/server/settings-service.js +4 -2
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +48 -72
- package/dist/server/terminals.js +181 -69
- package/dist/server/tool-manager.js +24 -7
- package/dist/server/tool-overrides.js +59 -0
- package/dist/server/update-check.js +28 -1
- package/dist/server/uploads.js +17 -2
- package/dist/server/wait-subscription-scan.js +18 -21
- package/dist/server/ws-client-id.js +29 -0
- package/dist/server/ws-pending-queue.js +60 -0
- package/extensions/webui.ts +60 -2
- package/package.json +4 -2
- package/plugins/catalog.json +20 -0
- package/themes/aetheris.css +457 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-DDChcYOh.js} +1 -1
- package/web/dist/assets/index-Do9RgJC3.js +366 -0
- package/web/dist/assets/index-DryOsILO.css +1 -0
- package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-BXnCQL9p.js +0 -364
- package/web/dist/assets/index-RpIGFyi_.css +0 -1
package/dist/server/lsp-tool.js
CHANGED
|
@@ -12,40 +12,86 @@
|
|
|
12
12
|
* 4. diagnostics:获取文件或项目的实时编译与类型检查错误。
|
|
13
13
|
* - 闲置自动回收:15 分钟无请求自动休眠退出,释放系统内存。
|
|
14
14
|
*/
|
|
15
|
-
import { spawn
|
|
16
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
-
import {
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { delimiter, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
18
19
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
19
20
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
20
21
|
import { Type } from "typebox";
|
|
21
22
|
export const LSP_TOOL_NAME = "lsp";
|
|
23
|
+
export const LSP_SYMBOL_KINDS = {
|
|
24
|
+
1: "File",
|
|
25
|
+
2: "Module",
|
|
26
|
+
3: "Namespace",
|
|
27
|
+
4: "Package",
|
|
28
|
+
5: "Class",
|
|
29
|
+
6: "Method",
|
|
30
|
+
7: "Property",
|
|
31
|
+
8: "Field",
|
|
32
|
+
9: "Constructor",
|
|
33
|
+
10: "Enum",
|
|
34
|
+
11: "Interface",
|
|
35
|
+
12: "Function",
|
|
36
|
+
13: "Variable",
|
|
37
|
+
14: "Constant",
|
|
38
|
+
15: "String",
|
|
39
|
+
16: "Number",
|
|
40
|
+
17: "Boolean",
|
|
41
|
+
18: "Array",
|
|
42
|
+
19: "Object",
|
|
43
|
+
20: "Key",
|
|
44
|
+
21: "Null",
|
|
45
|
+
22: "EnumMember",
|
|
46
|
+
23: "Struct",
|
|
47
|
+
24: "Event",
|
|
48
|
+
25: "Operator",
|
|
49
|
+
26: "TypeParameter",
|
|
50
|
+
};
|
|
22
51
|
const LANGUAGE_SERVER_CATALOG = {
|
|
23
52
|
ts: {
|
|
24
53
|
languageId: "typescript",
|
|
25
54
|
commands: [
|
|
55
|
+
{
|
|
56
|
+
bin: "vtsls",
|
|
57
|
+
args: ["--stdio"],
|
|
58
|
+
installHint: "npm install -g @vtsls/language-server",
|
|
59
|
+
npmPackage: "@vtsls/language-server typescript",
|
|
60
|
+
},
|
|
26
61
|
{
|
|
27
62
|
bin: "typescript-language-server",
|
|
28
63
|
args: ["--stdio"],
|
|
29
64
|
installHint: "npm install -g typescript-language-server typescript",
|
|
65
|
+
npmPackage: "typescript-language-server typescript",
|
|
30
66
|
},
|
|
31
|
-
{ bin: "vtsls", args: ["--stdio"], installHint: "npm install -g @vtsls/language-server" },
|
|
32
67
|
],
|
|
33
68
|
},
|
|
34
69
|
js: {
|
|
35
70
|
languageId: "javascript",
|
|
36
71
|
commands: [
|
|
72
|
+
{
|
|
73
|
+
bin: "vtsls",
|
|
74
|
+
args: ["--stdio"],
|
|
75
|
+
installHint: "npm install -g @vtsls/language-server",
|
|
76
|
+
npmPackage: "@vtsls/language-server typescript",
|
|
77
|
+
},
|
|
37
78
|
{
|
|
38
79
|
bin: "typescript-language-server",
|
|
39
80
|
args: ["--stdio"],
|
|
40
81
|
installHint: "npm install -g typescript-language-server typescript",
|
|
82
|
+
npmPackage: "typescript-language-server typescript",
|
|
41
83
|
},
|
|
42
|
-
{ bin: "vtsls", args: ["--stdio"], installHint: "npm install -g @vtsls/language-server" },
|
|
43
84
|
],
|
|
44
85
|
},
|
|
45
86
|
py: {
|
|
46
87
|
languageId: "python",
|
|
47
88
|
commands: [
|
|
48
|
-
{
|
|
89
|
+
{
|
|
90
|
+
bin: "pyright-langserver",
|
|
91
|
+
args: ["--stdio"],
|
|
92
|
+
installHint: "npm install -g pyright",
|
|
93
|
+
npmPackage: "pyright",
|
|
94
|
+
},
|
|
49
95
|
{ bin: "pyright", args: ["--stdio"], installHint: "pip install pyright" },
|
|
50
96
|
{ bin: "pylsp", args: [], installHint: "pip install python-lsp-server" },
|
|
51
97
|
],
|
|
@@ -85,42 +131,158 @@ function getLanguageForPath(filePath) {
|
|
|
85
131
|
return { langKey: "cpp", config: LANGUAGE_SERVER_CATALOG.cpp };
|
|
86
132
|
return null;
|
|
87
133
|
}
|
|
88
|
-
/**
|
|
89
|
-
|
|
134
|
+
/** 二进制探测结果的内存缓存(cwd + cmd 为 key;用户态安装成功后按包失效) */
|
|
135
|
+
const resolveBinaryCache = new Map();
|
|
136
|
+
export function clearResolveBinaryCache() {
|
|
137
|
+
resolveBinaryCache.clear();
|
|
138
|
+
}
|
|
139
|
+
/** 探测二进制是否可在当前系统执行(优先项目本地、Pi 生态共享目录、用户态托管目录、系统 PATH) */
|
|
140
|
+
export function resolveBinary(cmd, cwd) {
|
|
141
|
+
const cacheKey = `${cwd}::${cmd}`;
|
|
142
|
+
const cached = resolveBinaryCache.get(cacheKey);
|
|
143
|
+
if (cached !== undefined)
|
|
144
|
+
return cached;
|
|
145
|
+
const found = resolveBinaryUncached(cmd, cwd);
|
|
146
|
+
resolveBinaryCache.set(cacheKey, found);
|
|
147
|
+
return found;
|
|
148
|
+
}
|
|
149
|
+
function resolveBinaryUncached(cmd, cwd) {
|
|
90
150
|
const isWin = process.platform === "win32";
|
|
91
|
-
const
|
|
151
|
+
const exts = isWin ? [".cmd", ".exe", ".bat", ""] : [""];
|
|
92
152
|
if (isAbsolute(cmd)) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
else {
|
|
96
|
-
// 1. 本地 node_modules/.bin
|
|
97
|
-
const localBin = resolve(cwd, "node_modules", ".bin", cmd);
|
|
98
|
-
candidates.push(localBin);
|
|
153
|
+
if (existsSync(cmd))
|
|
154
|
+
return cmd;
|
|
99
155
|
if (isWin) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
candidates.push(cmd);
|
|
105
|
-
if (isWin) {
|
|
106
|
-
candidates.push(`${cmd}.cmd`);
|
|
107
|
-
candidates.push(`${cmd}.exe`);
|
|
156
|
+
for (const ext of exts) {
|
|
157
|
+
if (ext && existsSync(cmd + ext))
|
|
158
|
+
return cmd + ext;
|
|
159
|
+
}
|
|
108
160
|
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
// 1. 本地 workspace node_modules/.bin 优先
|
|
164
|
+
const localBinDir = resolve(cwd, "node_modules", ".bin");
|
|
165
|
+
for (const ext of exts) {
|
|
166
|
+
const target = join(localBinDir, cmd + ext);
|
|
167
|
+
if (existsSync(target))
|
|
168
|
+
return target;
|
|
109
169
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
170
|
+
// 2. Pi 生态工具与用户态目录(pi-lens / pi-web 用户态托管,零权限直接复用)
|
|
171
|
+
const home = homedir();
|
|
172
|
+
const sharedDirs = [
|
|
173
|
+
join(home, ".pi-lens", "tools", "node_modules", ".bin"),
|
|
174
|
+
join(home, ".pi-web", "lsp-servers", "node_modules", ".bin"),
|
|
175
|
+
join(home, ".pi", "agent", "tools", "node_modules", ".bin"),
|
|
176
|
+
];
|
|
177
|
+
for (const sharedDir of sharedDirs) {
|
|
178
|
+
for (const ext of exts) {
|
|
179
|
+
const target = join(sharedDir, cmd + ext);
|
|
180
|
+
if (existsSync(target))
|
|
181
|
+
return target;
|
|
182
|
+
}
|
|
113
183
|
}
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
184
|
+
// 3. 遍历系统 PATH 目录(纯文件系统检查,零子进程开销、杜绝 Windows cmd.exe 引号卡死)
|
|
185
|
+
const pathDirs = (process.env.PATH || "").split(delimiter);
|
|
186
|
+
for (const dir of pathDirs) {
|
|
187
|
+
if (!dir)
|
|
188
|
+
continue;
|
|
189
|
+
for (const ext of exts) {
|
|
190
|
+
const target = join(dir, cmd + ext);
|
|
191
|
+
if (existsSync(target))
|
|
192
|
+
return target;
|
|
193
|
+
}
|
|
120
194
|
}
|
|
121
|
-
catch { }
|
|
122
195
|
return null;
|
|
123
196
|
}
|
|
197
|
+
const installTasks = new Map();
|
|
198
|
+
/** 安装失败的负缓存(pkg -> 失败时间戳):失败后 5 分钟内不再重复执行 120s 的 npm install */
|
|
199
|
+
const installFailedAt = new Map();
|
|
200
|
+
const INSTALL_FAIL_NEGATIVE_CACHE_MS = 5 * 60 * 1000;
|
|
201
|
+
/**
|
|
202
|
+
* 在用户专属目录(~/.pi-web/lsp-servers)执行无侵入、免 sudo/root 的语言服务包按需安装。
|
|
203
|
+
* 注意:必须经用户显式授权(lsp 工具的 allowInstall 参数)后才能调用,不得静默触发。
|
|
204
|
+
*/
|
|
205
|
+
export async function autoInstallLanguageServer(pkg) {
|
|
206
|
+
const existing = installTasks.get(pkg);
|
|
207
|
+
if (existing)
|
|
208
|
+
return existing;
|
|
209
|
+
const failedAt = installFailedAt.get(pkg);
|
|
210
|
+
if (failedAt !== undefined && Date.now() - failedAt < INSTALL_FAIL_NEGATIVE_CACHE_MS) {
|
|
211
|
+
console.warn(`[LSP] Skip auto-install for ${pkg}: failed recently, retry later with allowInstall`);
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
const task = (async () => {
|
|
215
|
+
try {
|
|
216
|
+
const userLspDir = join(homedir(), ".pi-web", "lsp-servers");
|
|
217
|
+
console.warn(`[LSP] Installing language server [${pkg}] into ${userLspDir} (user-space, no sudo)…`);
|
|
218
|
+
mkdirSync(userLspDir, { recursive: true });
|
|
219
|
+
const pkgJson = join(userLspDir, "package.json");
|
|
220
|
+
if (!existsSync(pkgJson)) {
|
|
221
|
+
writeFileSync(pkgJson, JSON.stringify({ name: "pi-web-lsp-servers", private: true }) + "\n");
|
|
222
|
+
}
|
|
223
|
+
const pkgs = pkg.split(/\s+/).filter(Boolean);
|
|
224
|
+
const isWin = process.platform === "win32";
|
|
225
|
+
const npmCmd = isWin ? "npm.cmd" : "npm";
|
|
226
|
+
let stderr = "";
|
|
227
|
+
await new Promise((resolve, reject) => {
|
|
228
|
+
const proc = spawn(npmCmd, ["install", "--no-audit", "--no-fund", "--save-dev", ...pkgs], {
|
|
229
|
+
cwd: userLspDir,
|
|
230
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
231
|
+
shell: isWin,
|
|
232
|
+
});
|
|
233
|
+
const timer = setTimeout(() => {
|
|
234
|
+
if (isWin) {
|
|
235
|
+
// shell:true 时 proc.pid 只是 cmd.exe 的 PID——proc.kill() 只杀得到
|
|
236
|
+
// cmd.exe,npm/node 整棵子进程树会残留。taskkill /T 连树强杀。
|
|
237
|
+
if (typeof proc.pid === "number") {
|
|
238
|
+
const killer = spawn("taskkill", ["/PID", String(proc.pid), "/T", "/F"], { stdio: "ignore" });
|
|
239
|
+
killer.on("error", () => { });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
// POSIX:npm 由非 shell 直接 spawn,kill 其主进程即可让安装流程终止
|
|
244
|
+
//(取 SIGKILL 而非 detached 进程组方案:不改变子进程组语义,安装
|
|
245
|
+
// 超时本身是罕见路径,残余 node 子进程随 npm 主进程退出被回收)。
|
|
246
|
+
try {
|
|
247
|
+
proc.kill("SIGKILL");
|
|
248
|
+
}
|
|
249
|
+
catch { }
|
|
250
|
+
}
|
|
251
|
+
reject(new Error("npm install timed out"));
|
|
252
|
+
}, 120_000);
|
|
253
|
+
proc.stderr?.on("data", (chunk) => {
|
|
254
|
+
stderr += chunk.toString("utf8");
|
|
255
|
+
});
|
|
256
|
+
proc.on("error", (err) => {
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
reject(err);
|
|
259
|
+
});
|
|
260
|
+
proc.on("exit", (code) => {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
if (code === 0)
|
|
263
|
+
resolve();
|
|
264
|
+
else
|
|
265
|
+
reject(new Error(`npm install exited with code ${code}${stderr ? `: ${stderr.slice(-500)}` : ""}`));
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
console.warn(`[LSP] Language server [${pkg}] installed, resolving binaries…`);
|
|
269
|
+
// 新二进制落盘,探测缓存失效
|
|
270
|
+
clearResolveBinaryCache();
|
|
271
|
+
installFailedAt.delete(pkg);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
console.warn(`[LSP] Auto-install failed for ${pkg}: ${err.message}`);
|
|
276
|
+
installFailedAt.set(pkg, Date.now());
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
installTasks.delete(pkg);
|
|
281
|
+
}
|
|
282
|
+
})();
|
|
283
|
+
installTasks.set(pkg, task);
|
|
284
|
+
return task;
|
|
285
|
+
}
|
|
124
286
|
// ----------------------------------------------------------------------------
|
|
125
287
|
// JSON-RPC 2.0 传输层与单项目 LSP 客户端
|
|
126
288
|
// ----------------------------------------------------------------------------
|
|
@@ -130,7 +292,7 @@ export class LspClient {
|
|
|
130
292
|
binPath;
|
|
131
293
|
binArgs;
|
|
132
294
|
languageId;
|
|
133
|
-
|
|
295
|
+
onEvict;
|
|
134
296
|
proc = null;
|
|
135
297
|
nextId = 1;
|
|
136
298
|
pendingRequests = new Map();
|
|
@@ -141,28 +303,46 @@ export class LspClient {
|
|
|
141
303
|
idleTimer = null;
|
|
142
304
|
initialized = false;
|
|
143
305
|
isShuttingDown = false;
|
|
144
|
-
constructor(projectCwd, langKey, binPath, binArgs, languageId,
|
|
306
|
+
constructor(projectCwd, langKey, binPath, binArgs, languageId,
|
|
307
|
+
/** 客户端不可用(空闲回收 / 进程退出 / spawn 失败)时从池移除的回调 */
|
|
308
|
+
onEvict) {
|
|
145
309
|
this.projectCwd = projectCwd;
|
|
146
310
|
this.langKey = langKey;
|
|
147
311
|
this.binPath = binPath;
|
|
148
312
|
this.binArgs = binArgs;
|
|
149
313
|
this.languageId = languageId;
|
|
150
|
-
this.
|
|
314
|
+
this.onEvict = onEvict;
|
|
151
315
|
}
|
|
152
316
|
async start() {
|
|
153
317
|
this.touch();
|
|
154
318
|
this.proc = spawn(this.binPath, this.binArgs, {
|
|
155
319
|
cwd: this.projectCwd,
|
|
156
320
|
stdio: ["pipe", "pipe", "pipe"],
|
|
157
|
-
shell: process.platform === "win32" && this.binPath
|
|
321
|
+
shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(this.binPath),
|
|
158
322
|
});
|
|
159
323
|
this.proc.stdout?.on("data", (chunk) => this.handleData(chunk));
|
|
160
324
|
this.proc.stderr?.on("data", (d) => {
|
|
161
325
|
// 可选记录 debug 日志,不干扰输出
|
|
162
326
|
});
|
|
327
|
+
// spawn 失败(ENOENT/EACCES 等)只触发 'error',进程从未启动时不会触发
|
|
328
|
+
// 'exit'——不监听会让 initialize 等请求挂到超时,客户端还留在池里被误判存活。
|
|
329
|
+
this.proc.on("error", (err) => {
|
|
330
|
+
this.proc = null;
|
|
331
|
+
this.rejectAllPending(new Error(`Language server failed to start: ${err.message}`));
|
|
332
|
+
this.onEvict();
|
|
333
|
+
});
|
|
334
|
+
// stdin 写错误(对端退出后的 EPIPE 等)在 stream 上异步 emit,不监听会以
|
|
335
|
+
// uncaughtException 崩掉整个服务进程。这里统一兜底:标记死亡 + 清理挂起请求。
|
|
336
|
+
this.proc.stdin?.on("error", (err) => {
|
|
337
|
+
this.proc = null;
|
|
338
|
+
this.rejectAllPending(new Error(`Language server stdin error: ${err.message}`));
|
|
339
|
+
this.onEvict();
|
|
340
|
+
});
|
|
163
341
|
this.proc.on("exit", (code) => {
|
|
164
342
|
this.proc = null;
|
|
165
343
|
this.rejectAllPending(new Error(`Language server exited with code ${code}`));
|
|
344
|
+
// 进程退出即从池移除,防止后续请求命中死客户端
|
|
345
|
+
this.onEvict();
|
|
166
346
|
});
|
|
167
347
|
try {
|
|
168
348
|
// 发送 initialize 握手
|
|
@@ -207,7 +387,7 @@ export class LspClient {
|
|
|
207
387
|
// 15 分钟无调用自动回收
|
|
208
388
|
this.idleTimer = setTimeout(() => {
|
|
209
389
|
this.shutdown().catch(() => { });
|
|
210
|
-
this.
|
|
390
|
+
this.onEvict();
|
|
211
391
|
}, 15 * 60 * 1000);
|
|
212
392
|
this.idleTimer.unref();
|
|
213
393
|
}
|
|
@@ -282,7 +462,16 @@ export class LspClient {
|
|
|
282
462
|
rej(new Error(`LSP request ${method} timed out after ${timeoutMs}ms`));
|
|
283
463
|
}, timeoutMs);
|
|
284
464
|
this.pendingRequests.set(id, { resolve: res, reject: rej, timer });
|
|
285
|
-
|
|
465
|
+
try {
|
|
466
|
+
this.proc.stdin.write(wire);
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
// 同步写失败(stream 已销毁等):撤销挂起请求;异步 EPIPE 由 start() 里的
|
|
470
|
+
// stdin 'error' 监听兜底,这里只需保证 promise 被 reject 而非向上抛。
|
|
471
|
+
clearTimeout(timer);
|
|
472
|
+
this.pendingRequests.delete(id);
|
|
473
|
+
rej(new Error(`Language server stdin write failed: ${err.message}`));
|
|
474
|
+
}
|
|
286
475
|
});
|
|
287
476
|
}
|
|
288
477
|
notify(method, params) {
|
|
@@ -291,7 +480,12 @@ export class LspClient {
|
|
|
291
480
|
return;
|
|
292
481
|
const payload = JSON.stringify({ jsonrpc: "2.0", method, params });
|
|
293
482
|
const wire = `Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n${payload}`;
|
|
294
|
-
|
|
483
|
+
try {
|
|
484
|
+
this.proc.stdin.write(wire);
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// 通知写失败只影响状态同步,不值得打断调用链;异步 EPIPE 由 stdin 'error' 监听兜底
|
|
488
|
+
}
|
|
295
489
|
}
|
|
296
490
|
async syncDocument(absPath) {
|
|
297
491
|
const uri = pathToFileURL(absPath).toString();
|
|
@@ -374,7 +568,7 @@ class LspServerPool {
|
|
|
374
568
|
const client = this.pool.get(poolKey);
|
|
375
569
|
return Boolean(client && client.isAlive());
|
|
376
570
|
}
|
|
377
|
-
async getClient(projectCwd, filePath) {
|
|
571
|
+
async getClient(projectCwd, filePath, opts) {
|
|
378
572
|
if (this.shuttingDown) {
|
|
379
573
|
return { error: "LSP server pool is shutting down" };
|
|
380
574
|
}
|
|
@@ -385,8 +579,13 @@ class LspServerPool {
|
|
|
385
579
|
const poolKey = `${projectCwd}::${langInfo.langKey}`;
|
|
386
580
|
const client = this.pool.get(poolKey);
|
|
387
581
|
if (client) {
|
|
388
|
-
|
|
389
|
-
|
|
582
|
+
// 池命中必须复检存活:进程退出事件与请求之间有竞态窗口(exit 回调排队、
|
|
583
|
+
// spawn error 未触发 exit),死客户端留在池里会让所有请求挂到超时。
|
|
584
|
+
if (client.isAlive()) {
|
|
585
|
+
client.touch();
|
|
586
|
+
return { client };
|
|
587
|
+
}
|
|
588
|
+
this.pool.delete(poolKey);
|
|
390
589
|
}
|
|
391
590
|
const pending = this.inFlight.get(poolKey);
|
|
392
591
|
if (pending) {
|
|
@@ -397,6 +596,7 @@ class LspServerPool {
|
|
|
397
596
|
let resolvedBin = null;
|
|
398
597
|
let resolvedArgs = [];
|
|
399
598
|
let hint = "";
|
|
599
|
+
let autoPkg = undefined;
|
|
400
600
|
for (const cmd of langInfo.config.commands) {
|
|
401
601
|
const found = resolveBinary(cmd.bin, projectCwd);
|
|
402
602
|
if (found) {
|
|
@@ -405,10 +605,29 @@ class LspServerPool {
|
|
|
405
605
|
break;
|
|
406
606
|
}
|
|
407
607
|
hint = cmd.installHint;
|
|
608
|
+
if (!autoPkg && cmd.npmPackage)
|
|
609
|
+
autoPkg = cmd.npmPackage;
|
|
610
|
+
}
|
|
611
|
+
// 用户态按需安装必须经用户显式授权(allowInstall),避免在工具调用链里无提示联网 npm install
|
|
612
|
+
if (!resolvedBin && autoPkg && opts?.allowInstall === true) {
|
|
613
|
+
const ok = await autoInstallLanguageServer(autoPkg);
|
|
614
|
+
if (ok) {
|
|
615
|
+
for (const cmd of langInfo.config.commands) {
|
|
616
|
+
const found = resolveBinary(cmd.bin, projectCwd);
|
|
617
|
+
if (found) {
|
|
618
|
+
resolvedBin = found;
|
|
619
|
+
resolvedArgs = cmd.args;
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
408
624
|
}
|
|
409
625
|
if (!resolvedBin) {
|
|
626
|
+
const installGateHint = autoPkg
|
|
627
|
+
? `\nOr retry this tool call with { "allowInstall": true } to install \`${autoPkg}\` into ~/.pi-web/lsp-servers (user-space, no sudo) automatically.`
|
|
628
|
+
: "";
|
|
410
629
|
return {
|
|
411
|
-
error: `Language server for ${langInfo.config.languageId} not found.\nPlease install it: \`${hint}
|
|
630
|
+
error: `Language server for ${langInfo.config.languageId} not found.\nPlease install it: \`${hint}\`${installGateHint}`,
|
|
412
631
|
};
|
|
413
632
|
}
|
|
414
633
|
const newClient = new LspClient(projectCwd, langInfo.langKey, resolvedBin, resolvedArgs, langInfo.config.languageId, () => {
|
|
@@ -416,6 +635,8 @@ class LspServerPool {
|
|
|
416
635
|
this.pool.delete(poolKey);
|
|
417
636
|
}
|
|
418
637
|
});
|
|
638
|
+
// 启动失败时客户端尚在 start() 内部、池里没有它,onEvict 是空操作;
|
|
639
|
+
// 成功后再退出/回收才会真正从池移除。
|
|
419
640
|
try {
|
|
420
641
|
await newClient.start();
|
|
421
642
|
if (this.shuttingDown) {
|
|
@@ -473,26 +694,140 @@ export async function getLiveLspDiagnostics(absPath, cwd) {
|
|
|
473
694
|
});
|
|
474
695
|
return `⚠️ Post-edit Diagnostics (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}`;
|
|
475
696
|
}
|
|
697
|
+
/**
|
|
698
|
+
* 递归格式化 DocumentSymbol 列表为缩进的符号大纲树(做条数上限保护)
|
|
699
|
+
*/
|
|
700
|
+
function formatDocumentSymbols(symbols, indent = "", lines = []) {
|
|
701
|
+
for (const sym of symbols) {
|
|
702
|
+
if (lines.length >= 300) {
|
|
703
|
+
lines.push(`${indent}• ... [Truncated: outline exceeds 300 symbols]`);
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
const kind = LSP_SYMBOL_KINDS[sym.kind] || `Kind(${sym.kind})`;
|
|
707
|
+
const range = sym.range || sym.location?.range;
|
|
708
|
+
const startLine = range ? range.start.line + 1 : "?";
|
|
709
|
+
const endLine = range ? range.end.line + 1 : "?";
|
|
710
|
+
const lineSpan = startLine === endLine ? `line ${startLine}` : `lines ${startLine}-${endLine}`;
|
|
711
|
+
const detail = sym.detail ? ` (${sym.detail})` : "";
|
|
712
|
+
lines.push(`${indent}• [${kind}] ${sym.name}${detail} (${lineSpan})`);
|
|
713
|
+
if (Array.isArray(sym.children) && sym.children.length > 0) {
|
|
714
|
+
formatDocumentSymbols(sym.children, indent + " ", lines);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return lines;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* 递归单趟查找符号(两遍扫描:先严格精确匹配,未命中再执行大小写忽略回退,避免遮蔽后续精确符号;支持 containerName 点分路径)
|
|
721
|
+
*/
|
|
722
|
+
function findSymbolPass(symbols, target, mode, parentName = "") {
|
|
723
|
+
const targetLower = target.toLowerCase();
|
|
724
|
+
for (const sym of symbols) {
|
|
725
|
+
const qualifiedName = sym.containerName
|
|
726
|
+
? `${sym.containerName}.${sym.name}`
|
|
727
|
+
: parentName
|
|
728
|
+
? `${parentName}.${sym.name}`
|
|
729
|
+
: sym.name;
|
|
730
|
+
if (mode === "exact") {
|
|
731
|
+
if (sym.name === target || qualifiedName === target) {
|
|
732
|
+
return { symbol: sym, fullName: qualifiedName };
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
if (sym.name.toLowerCase() === targetLower || qualifiedName.toLowerCase() === targetLower) {
|
|
737
|
+
return { symbol: sym, fullName: qualifiedName };
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (Array.isArray(sym.children) && sym.children.length > 0) {
|
|
741
|
+
const found = findSymbolPass(sym.children, target, mode, qualifiedName);
|
|
742
|
+
if (found)
|
|
743
|
+
return found;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
function findSymbol(symbols, target) {
|
|
749
|
+
return findSymbolPass(symbols, target, "exact") ?? findSymbolPass(symbols, target, "ci");
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* 收集文件内可用的顶层符号全名清单(最多收集 50 条,供找不到符号时提供备选提示)
|
|
753
|
+
*/
|
|
754
|
+
function collectSymbolNames(symbols, prefix = "", names = []) {
|
|
755
|
+
for (const sym of symbols) {
|
|
756
|
+
if (names.length >= 50)
|
|
757
|
+
break;
|
|
758
|
+
const current = sym.containerName
|
|
759
|
+
? `${sym.containerName}.${sym.name}`
|
|
760
|
+
: prefix
|
|
761
|
+
? `${prefix}.${sym.name}`
|
|
762
|
+
: sym.name;
|
|
763
|
+
const kind = LSP_SYMBOL_KINDS[sym.kind] || "Symbol";
|
|
764
|
+
names.push(`${current} [${kind}]`);
|
|
765
|
+
if (Array.isArray(sym.children) && sym.children.length > 0) {
|
|
766
|
+
collectSymbolNames(sym.children, current, names);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return names;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* 当未传 path 且执行工作区级操作(如 workspaceSymbol)时,寻找工作区默认主文件以定位语言服务
|
|
773
|
+
*/
|
|
774
|
+
function findDefaultSourceFileForLsp(cwd) {
|
|
775
|
+
const candidates = [
|
|
776
|
+
"src/index.ts",
|
|
777
|
+
"src/main.ts",
|
|
778
|
+
"src/app.ts",
|
|
779
|
+
"index.ts",
|
|
780
|
+
"main.ts",
|
|
781
|
+
"app.ts",
|
|
782
|
+
"server.ts",
|
|
783
|
+
"main.py",
|
|
784
|
+
"app.py",
|
|
785
|
+
"main.go",
|
|
786
|
+
"src/main.rs",
|
|
787
|
+
];
|
|
788
|
+
for (const c of candidates) {
|
|
789
|
+
if (existsSync(join(cwd, c)))
|
|
790
|
+
return c;
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
476
794
|
export function makeLspTool(options) {
|
|
477
795
|
const cwd = options.cwd;
|
|
478
796
|
return defineTool({
|
|
479
797
|
name: LSP_TOOL_NAME,
|
|
480
798
|
label: "LSP code intelligence",
|
|
481
|
-
description: `
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
- \`
|
|
485
|
-
- \`
|
|
486
|
-
- \`
|
|
487
|
-
- \`
|
|
488
|
-
|
|
799
|
+
description: `IDE-grade semantic analysis (LSP) across the workspace. Actions:
|
|
800
|
+
- \`definition\`: definition of the symbol at \`line\`/\`character\` in \`path\` (file, line, snippet).
|
|
801
|
+
- \`references\`: all workspace usages of that symbol.
|
|
802
|
+
- \`hover\`: type signature and docs for that symbol.
|
|
803
|
+
- \`diagnostics\`: compiler/type errors and warnings for \`path\` (whole file).
|
|
804
|
+
- \`documentSymbol\`: hierarchical symbol outline with line spans for \`path\`.
|
|
805
|
+
- \`read_symbol\`: read the body of \`symbol\` in \`path\` (e.g. "parseConfig").
|
|
806
|
+
- \`workspaceSymbol\`: search symbols across the workspace by \`query\`.
|
|
807
|
+
- \`cascade\`: impact check for \`path\` — report diagnostics of files referencing it.
|
|
808
|
+
Lines are 1-indexed.`,
|
|
489
809
|
parameters: Type.Object({
|
|
490
|
-
action: Type.Union([
|
|
810
|
+
action: Type.Union([
|
|
811
|
+
Type.Literal("definition"),
|
|
812
|
+
Type.Literal("references"),
|
|
813
|
+
Type.Literal("hover"),
|
|
814
|
+
Type.Literal("diagnostics"),
|
|
815
|
+
Type.Literal("documentSymbol"),
|
|
816
|
+
Type.Literal("read_symbol"),
|
|
817
|
+
Type.Literal("workspaceSymbol"),
|
|
818
|
+
Type.Literal("cascade"),
|
|
819
|
+
], {
|
|
491
820
|
description: "The LSP operation to perform.",
|
|
492
821
|
}),
|
|
493
|
-
path: Type.String({
|
|
494
|
-
description: "Workspace-relative or absolute path to the target source file.",
|
|
495
|
-
}),
|
|
822
|
+
path: Type.Optional(Type.String({
|
|
823
|
+
description: "Workspace-relative or absolute path to the target source file (required for all actions except workspaceSymbol).",
|
|
824
|
+
})),
|
|
825
|
+
symbol: Type.Optional(Type.String({
|
|
826
|
+
description: "Symbol name to read for 'read_symbol' action (e.g. 'functionName' or 'ClassName.methodName').",
|
|
827
|
+
})),
|
|
828
|
+
query: Type.Optional(Type.String({
|
|
829
|
+
description: "Search query for 'workspaceSymbol' action.",
|
|
830
|
+
})),
|
|
496
831
|
line: Type.Optional(Type.Number({
|
|
497
832
|
description: "1-indexed line number in the source file.",
|
|
498
833
|
})),
|
|
@@ -502,10 +837,33 @@ Note: Line numbers are 1-indexed.`,
|
|
|
502
837
|
timeout: Type.Optional(Type.Number({
|
|
503
838
|
description: "Timeout in seconds (defaults to 15).",
|
|
504
839
|
})),
|
|
840
|
+
allowInstall: Type.Optional(Type.Boolean({
|
|
841
|
+
description: "Install the missing language server into ~/.pi-web/lsp-servers (user-space, no sudo). Default false: the tool returns an installHint instead.",
|
|
842
|
+
})),
|
|
505
843
|
}),
|
|
506
844
|
async execute(_callId, params, _signal, _onUpdate, _ctx) {
|
|
507
845
|
const action = params.action;
|
|
508
|
-
|
|
846
|
+
let targetPath = params.path;
|
|
847
|
+
if (!targetPath && action === "workspaceSymbol") {
|
|
848
|
+
targetPath = findDefaultSourceFileForLsp(cwd) ?? undefined;
|
|
849
|
+
if (!targetPath) {
|
|
850
|
+
return {
|
|
851
|
+
content: [
|
|
852
|
+
{
|
|
853
|
+
type: "text",
|
|
854
|
+
text: `Error: Could not automatically detect a primary project source file to route language server. Please provide 'path' (pointing to any source file in the project, e.g. path='src/index.ts') to select the language server.`,
|
|
855
|
+
},
|
|
856
|
+
],
|
|
857
|
+
details: { ok: false, error: "Missing path: cannot route language server" },
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
if (!targetPath) {
|
|
862
|
+
return {
|
|
863
|
+
content: [{ type: "text", text: `Error: 'path' parameter is required for action '${action}'.` }],
|
|
864
|
+
details: { ok: false, error: "Missing path parameter" },
|
|
865
|
+
};
|
|
866
|
+
}
|
|
509
867
|
const absPath = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
|
|
510
868
|
const line = typeof params.line === "number" ? Math.max(1, params.line) : 1;
|
|
511
869
|
const character = typeof params.character === "number" ? Math.max(1, params.character) : 1;
|
|
@@ -527,7 +885,29 @@ Note: Line numbers are 1-indexed.`,
|
|
|
527
885
|
details: { ok: false, error: "File not found" },
|
|
528
886
|
};
|
|
529
887
|
}
|
|
530
|
-
|
|
888
|
+
if (action === "read_symbol" && !params.symbol?.trim()) {
|
|
889
|
+
return {
|
|
890
|
+
content: [
|
|
891
|
+
{
|
|
892
|
+
type: "text",
|
|
893
|
+
text: `Error: 'symbol' parameter is required for 'read_symbol' action (e.g. symbol="parseConfig" or "ClassName.methodName").`,
|
|
894
|
+
},
|
|
895
|
+
],
|
|
896
|
+
details: { ok: false, error: "Missing symbol parameter" },
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
if (action === "workspaceSymbol" && !(params.query ?? "").trim()) {
|
|
900
|
+
return {
|
|
901
|
+
content: [
|
|
902
|
+
{
|
|
903
|
+
type: "text",
|
|
904
|
+
text: `Error: 'query' parameter cannot be empty for 'workspaceSymbol' action. Please provide a search term (e.g. query='User' or 'Router').`,
|
|
905
|
+
},
|
|
906
|
+
],
|
|
907
|
+
details: { ok: false, error: "Empty query parameter" },
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
const clientRes = await globalLspPool.getClient(cwd, absPath, { allowInstall: params.allowInstall });
|
|
531
911
|
if ("error" in clientRes) {
|
|
532
912
|
return {
|
|
533
913
|
content: [{ type: "text", text: `LSP Error: ${clientRes.error}` }],
|
|
@@ -551,7 +931,10 @@ Note: Line numbers are 1-indexed.`,
|
|
|
551
931
|
details: { ok: true, locations: [] },
|
|
552
932
|
};
|
|
553
933
|
}
|
|
554
|
-
|
|
934
|
+
// 与 references(:25) 同口径:每个位置都要整读一次目标文件拼 snippet,
|
|
935
|
+
// 无上限的 definition 列表会放大成几十次同步 IO 拖死请求。
|
|
936
|
+
const MAX_DEFS = 25;
|
|
937
|
+
const formatted = locs.slice(0, MAX_DEFS).map((loc) => {
|
|
555
938
|
const targetUri = loc.targetUri || loc.uri || "";
|
|
556
939
|
let defPath = targetUri;
|
|
557
940
|
try {
|
|
@@ -576,8 +959,14 @@ Note: Line numbers are 1-indexed.`,
|
|
|
576
959
|
}
|
|
577
960
|
return `• ${defRel}:${defLine}:${defCol}\n\`\`\`\n${snippet}\n\`\`\``;
|
|
578
961
|
});
|
|
962
|
+
const defTail = locs.length > MAX_DEFS ? `\n\n... and ${locs.length - MAX_DEFS} more definitions` : "";
|
|
579
963
|
return {
|
|
580
|
-
content: [
|
|
964
|
+
content: [
|
|
965
|
+
{
|
|
966
|
+
type: "text",
|
|
967
|
+
text: `Definitions (${locs.length}):\n\n${formatted.join("\n\n")}${defTail}`,
|
|
968
|
+
},
|
|
969
|
+
],
|
|
581
970
|
details: { ok: true, locations: locs },
|
|
582
971
|
};
|
|
583
972
|
}
|
|
@@ -663,6 +1052,309 @@ Note: Line numbers are 1-indexed.`,
|
|
|
663
1052
|
details: { ok: true, diagnostics: diags },
|
|
664
1053
|
};
|
|
665
1054
|
}
|
|
1055
|
+
if (action === "documentSymbol") {
|
|
1056
|
+
const result = await client.request("textDocument/documentSymbol", { textDocument: { uri } }, timeoutMs);
|
|
1057
|
+
const symbols = Array.isArray(result) ? result : [];
|
|
1058
|
+
if (symbols.length === 0) {
|
|
1059
|
+
return {
|
|
1060
|
+
content: [{ type: "text", text: `No symbols found in ${targetPath}` }],
|
|
1061
|
+
details: { ok: true, symbols: [] },
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
const lines = formatDocumentSymbols(symbols);
|
|
1065
|
+
// details 随会话持久且整体 ≤64KB(超限整条丢弃):symbols 与文本大纲同口径截断
|
|
1066
|
+
const detailsSymbols = symbols.length > 300 ? symbols.slice(0, 300) : symbols;
|
|
1067
|
+
return {
|
|
1068
|
+
content: [
|
|
1069
|
+
{
|
|
1070
|
+
type: "text",
|
|
1071
|
+
text: `Symbols in ${targetPath} (${symbols.length} top-level):\n${lines.join("\n")}`,
|
|
1072
|
+
},
|
|
1073
|
+
],
|
|
1074
|
+
details: { ok: true, count: symbols.length, symbols: detailsSymbols },
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
if (action === "read_symbol") {
|
|
1078
|
+
const targetSymbol = params.symbol?.trim();
|
|
1079
|
+
if (!targetSymbol) {
|
|
1080
|
+
return {
|
|
1081
|
+
content: [
|
|
1082
|
+
{
|
|
1083
|
+
type: "text",
|
|
1084
|
+
text: `Error: 'symbol' parameter is required for 'read_symbol' action (e.g. symbol="parseConfig" or "ClassName.methodName").`,
|
|
1085
|
+
},
|
|
1086
|
+
],
|
|
1087
|
+
details: { ok: false, error: "Missing symbol parameter" },
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
const result = await client.request("textDocument/documentSymbol", { textDocument: { uri } }, timeoutMs);
|
|
1091
|
+
const symbols = Array.isArray(result) ? result : [];
|
|
1092
|
+
const match = findSymbol(symbols, targetSymbol);
|
|
1093
|
+
if (!match) {
|
|
1094
|
+
const available = collectSymbolNames(symbols);
|
|
1095
|
+
const listSnippet = available.length > 0
|
|
1096
|
+
? `\nAvailable symbols in ${targetPath}:\n${available
|
|
1097
|
+
.slice(0, 30)
|
|
1098
|
+
.map((s) => `• ${s}`)
|
|
1099
|
+
.join("\n")}${available.length > 30 ? `\n... and ${available.length - 30} more` : ""}`
|
|
1100
|
+
: "";
|
|
1101
|
+
return {
|
|
1102
|
+
content: [
|
|
1103
|
+
{
|
|
1104
|
+
type: "text",
|
|
1105
|
+
text: `Symbol '${targetSymbol}' not found in ${targetPath}.${listSnippet}`,
|
|
1106
|
+
},
|
|
1107
|
+
],
|
|
1108
|
+
details: { ok: false, error: "Symbol not found", availableSymbols: available },
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
const range = match.symbol.range || match.symbol.location?.range;
|
|
1112
|
+
if (!range) {
|
|
1113
|
+
return {
|
|
1114
|
+
content: [
|
|
1115
|
+
{
|
|
1116
|
+
type: "text",
|
|
1117
|
+
text: `Symbol '${targetSymbol}' found, but no range information was provided by language server.`,
|
|
1118
|
+
},
|
|
1119
|
+
],
|
|
1120
|
+
details: { ok: false, error: "Missing range" },
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
let startLine = range.start.line; // 0-indexed
|
|
1124
|
+
let endLine = range.end.line; // 0-indexed
|
|
1125
|
+
// 针对行尾排他边界(end.character === 0 且跨行时)避免多读末尾空行
|
|
1126
|
+
if (range.end.character === 0 && endLine > startLine) {
|
|
1127
|
+
endLine -= 1;
|
|
1128
|
+
}
|
|
1129
|
+
const fileLines = readFileSync(absPath, "utf8").split(/\r?\n/);
|
|
1130
|
+
const totalSymbolLines = Math.max(0, endLine - startLine + 1);
|
|
1131
|
+
const MAX_SYMBOL_READ_LINES = 400;
|
|
1132
|
+
const isTruncated = totalSymbolLines > MAX_SYMBOL_READ_LINES;
|
|
1133
|
+
const sliceEndLine = isTruncated ? startLine + MAX_SYMBOL_READ_LINES - 1 : endLine;
|
|
1134
|
+
const symbolLines = fileLines.slice(startLine, sliceEndLine + 1);
|
|
1135
|
+
let formattedSnippet = symbolLines.map((l, idx) => `${startLine + idx + 1}: ${l}`).join("\n");
|
|
1136
|
+
if (isTruncated) {
|
|
1137
|
+
formattedSnippet += `\n// ... [Truncated: symbol body has ${totalSymbolLines} lines, showing first ${MAX_SYMBOL_READ_LINES} lines. Use 'documentSymbol' to inspect nested methods/members and read them individually]`;
|
|
1138
|
+
}
|
|
1139
|
+
const kind = LSP_SYMBOL_KINDS[match.symbol.kind] || `Kind(${match.symbol.kind})`;
|
|
1140
|
+
return {
|
|
1141
|
+
content: [
|
|
1142
|
+
{
|
|
1143
|
+
type: "text",
|
|
1144
|
+
text: `// Symbol: ${match.fullName} [${kind}]\n// File: ${rel}:${startLine + 1}-${endLine + 1}\n\`\`\`\n${formattedSnippet}\n\`\`\``,
|
|
1145
|
+
},
|
|
1146
|
+
],
|
|
1147
|
+
details: {
|
|
1148
|
+
ok: true,
|
|
1149
|
+
symbol: match.symbol,
|
|
1150
|
+
fullName: match.fullName,
|
|
1151
|
+
startLine: startLine + 1,
|
|
1152
|
+
endLine: endLine + 1,
|
|
1153
|
+
code: symbolLines.join("\n"),
|
|
1154
|
+
totalLines: totalSymbolLines,
|
|
1155
|
+
truncated: isTruncated,
|
|
1156
|
+
},
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
if (action === "workspaceSymbol") {
|
|
1160
|
+
const query = (params.query ?? "").trim();
|
|
1161
|
+
if (!query) {
|
|
1162
|
+
return {
|
|
1163
|
+
content: [
|
|
1164
|
+
{
|
|
1165
|
+
type: "text",
|
|
1166
|
+
text: `Error: 'query' parameter cannot be empty for 'workspaceSymbol' action. Please provide a search term (e.g. query='User' or 'Router').`,
|
|
1167
|
+
},
|
|
1168
|
+
],
|
|
1169
|
+
details: { ok: false, error: "Empty query parameter" },
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
const result = await client.request("workspace/symbol", { query }, timeoutMs);
|
|
1173
|
+
const locs = Array.isArray(result) ? result : [];
|
|
1174
|
+
if (locs.length === 0) {
|
|
1175
|
+
return {
|
|
1176
|
+
content: [{ type: "text", text: `No symbols found across workspace matching '${query}'` }],
|
|
1177
|
+
details: { ok: true, symbols: [] },
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
const MAX_WORKSPACE_SYMBOLS = 100;
|
|
1181
|
+
const isTruncated = locs.length > MAX_WORKSPACE_SYMBOLS;
|
|
1182
|
+
const cappedLocs = isTruncated ? locs.slice(0, MAX_WORKSPACE_SYMBOLS) : locs;
|
|
1183
|
+
const formatted = locs.slice(0, 30).map((sym) => {
|
|
1184
|
+
const targetUri = sym.location?.uri || sym.uri || "";
|
|
1185
|
+
let filePath = targetUri;
|
|
1186
|
+
try {
|
|
1187
|
+
if (targetUri.startsWith("file:"))
|
|
1188
|
+
filePath = fileURLToPath(targetUri);
|
|
1189
|
+
}
|
|
1190
|
+
catch { }
|
|
1191
|
+
const fileRel = filePath.startsWith(cwd) ? filePath.slice(cwd.length).replace(/^[/\\]/, "") : filePath;
|
|
1192
|
+
const range = sym.location?.range || sym.range;
|
|
1193
|
+
const lineNum = range ? range.start.line + 1 : 1;
|
|
1194
|
+
const kind = LSP_SYMBOL_KINDS[sym.kind] || `Kind(${sym.kind})`;
|
|
1195
|
+
const container = sym.containerName ? ` in ${sym.containerName}` : "";
|
|
1196
|
+
return `• [${kind}] ${sym.name}${container} (${fileRel}:${lineNum})`;
|
|
1197
|
+
});
|
|
1198
|
+
const tail = locs.length > 30 ? `\n... and ${locs.length - 30} more symbols` : "";
|
|
1199
|
+
return {
|
|
1200
|
+
content: [
|
|
1201
|
+
{
|
|
1202
|
+
type: "text",
|
|
1203
|
+
text: `Found ${locs.length} symbol${locs.length > 1 ? "s" : ""} matching '${query}' (via ${rel}):\n${formatted.join("\n")}${tail}`,
|
|
1204
|
+
},
|
|
1205
|
+
],
|
|
1206
|
+
details: { ok: true, count: locs.length, symbols: cappedLocs, truncated: isTruncated },
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
if (action === "cascade") {
|
|
1210
|
+
// 影响级联(Impact Cascade):找出引用本文件(或本文件某个符号)的工作区文件,
|
|
1211
|
+
// 聚合它们的实时诊断——让"改了签名/导出,下游编译炸了"在编辑当轮就暴露,
|
|
1212
|
+
// 而不是等到构建或提交时才发现。
|
|
1213
|
+
const MAX_SEEDS = 20;
|
|
1214
|
+
const MAX_DEPENDENTS = 25;
|
|
1215
|
+
const DIAGS_BUDGET_MS = 1500;
|
|
1216
|
+
const normalizePath = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
|
|
1217
|
+
// 1. 收集种子位置:给了 line/character 就只查那个符号;否则查全部顶层符号。
|
|
1218
|
+
const seeds = [];
|
|
1219
|
+
if (typeof params.line === "number") {
|
|
1220
|
+
seeds.push({ line: line - 1, character: character - 1 });
|
|
1221
|
+
}
|
|
1222
|
+
else {
|
|
1223
|
+
const symResult = await client.request("textDocument/documentSymbol", { textDocument: { uri } }, timeoutMs);
|
|
1224
|
+
const topSymbols = Array.isArray(symResult) ? symResult : [];
|
|
1225
|
+
for (const sym of topSymbols.slice(0, MAX_SEEDS)) {
|
|
1226
|
+
const pos = sym.selectionRange?.start ?? sym.range?.start ?? sym.location?.range?.start;
|
|
1227
|
+
if (pos && typeof pos.line === "number") {
|
|
1228
|
+
seeds.push({ line: pos.line, character: pos.character ?? 0 });
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
if (seeds.length === 0) {
|
|
1233
|
+
return {
|
|
1234
|
+
content: [{ type: "text", text: `No symbols to trace in ${targetPath} — nothing to cascade.` }],
|
|
1235
|
+
details: { ok: true, impacted: [], clean: [], notReported: [], referencedFiles: [] },
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
// 2. 对每个种子查 references(不含声明处),汇总工作区内的引用方文件。
|
|
1239
|
+
const selfNorm = normalizePath(absPath);
|
|
1240
|
+
const depPaths = new Set();
|
|
1241
|
+
const seedResults = await Promise.allSettled(seeds.map((pos) => client.request("textDocument/references", { textDocument: { uri }, position: pos, context: { includeDeclaration: false } }, timeoutMs)));
|
|
1242
|
+
for (const r of seedResults) {
|
|
1243
|
+
if (r.status !== "fulfilled" || !Array.isArray(r.value))
|
|
1244
|
+
continue;
|
|
1245
|
+
for (const loc of r.value) {
|
|
1246
|
+
const refUri = loc?.uri ?? "";
|
|
1247
|
+
if (!refUri.startsWith("file:"))
|
|
1248
|
+
continue;
|
|
1249
|
+
let refPath;
|
|
1250
|
+
try {
|
|
1251
|
+
refPath = fileURLToPath(refUri);
|
|
1252
|
+
}
|
|
1253
|
+
catch {
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
if (normalizePath(refPath) === selfNorm)
|
|
1257
|
+
continue; // 排除自身
|
|
1258
|
+
const relRef = relative(cwd, refPath);
|
|
1259
|
+
if (relRef === ".." || relRef.startsWith(".." + sep) || relRef.startsWith("../") || isAbsolute(relRef)) {
|
|
1260
|
+
continue; // 只看工作区内
|
|
1261
|
+
}
|
|
1262
|
+
if (relRef.split(sep).includes("node_modules"))
|
|
1263
|
+
continue;
|
|
1264
|
+
depPaths.add(refPath);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
const dependents = [...depPaths].sort().slice(0, MAX_DEPENDENTS);
|
|
1268
|
+
if (dependents.length === 0) {
|
|
1269
|
+
return {
|
|
1270
|
+
content: [
|
|
1271
|
+
{
|
|
1272
|
+
type: "text",
|
|
1273
|
+
text: `No referencing files found for ${targetPath} — no impact cascade needed.`,
|
|
1274
|
+
},
|
|
1275
|
+
],
|
|
1276
|
+
details: { ok: true, impacted: [], clean: [], notReported: [], referencedFiles: [] },
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
// 3. 逐个 didOpen/didChange 触发服务器分析,轮询等待 publishDiagnostics 回流。
|
|
1280
|
+
const depUris = [];
|
|
1281
|
+
for (const dep of dependents) {
|
|
1282
|
+
try {
|
|
1283
|
+
depUris.push(await client.syncDocument(dep));
|
|
1284
|
+
}
|
|
1285
|
+
catch {
|
|
1286
|
+
depUris.push("");
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
const pending = new Set(depUris.filter(Boolean));
|
|
1290
|
+
const deadline = Date.now() + DIAGS_BUDGET_MS;
|
|
1291
|
+
while (pending.size > 0 && Date.now() < deadline) {
|
|
1292
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
1293
|
+
const known = client.getAllDiagnostics();
|
|
1294
|
+
for (const u of [...pending]) {
|
|
1295
|
+
if (known.has(u))
|
|
1296
|
+
pending.delete(u);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
// 4. 聚合输出:有错误的排前面,其次警告,clean 与未上报的折叠列出。
|
|
1300
|
+
const impacted = [];
|
|
1301
|
+
const clean = [];
|
|
1302
|
+
const notReported = [];
|
|
1303
|
+
const knownFinal = client.getAllDiagnostics();
|
|
1304
|
+
for (let i = 0; i < dependents.length; i++) {
|
|
1305
|
+
const dep = dependents[i];
|
|
1306
|
+
const depUri = depUris[i];
|
|
1307
|
+
const depRel = relative(cwd, dep).replace(/\\/g, "/");
|
|
1308
|
+
if (!depUri) {
|
|
1309
|
+
notReported.push(depRel);
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
const diags = client.getDiagnostics(depUri);
|
|
1313
|
+
const errors = diags.filter((d) => d.severity === 1).length;
|
|
1314
|
+
const warnings = diags.filter((d) => d.severity === 2).length;
|
|
1315
|
+
if (errors + warnings > 0) {
|
|
1316
|
+
impacted.push({ path: depRel, errors, warnings, diagnostics: diags.slice(0, 10) });
|
|
1317
|
+
}
|
|
1318
|
+
else if (diags.length === 0 && !knownFinal.has(depUri)) {
|
|
1319
|
+
notReported.push(depRel); // 预算内服务器未上报(可能仍在分析)
|
|
1320
|
+
}
|
|
1321
|
+
else {
|
|
1322
|
+
clean.push(depRel);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
impacted.sort((a, b) => b.errors - a.errors || b.warnings - a.warnings);
|
|
1326
|
+
const out = [];
|
|
1327
|
+
out.push(`Impact cascade for ${targetPath}: ${dependents.length} referencing file(s), ${impacted.length} with findings.`);
|
|
1328
|
+
for (const item of impacted) {
|
|
1329
|
+
out.push(`• ${item.path} — ${item.errors} error(s), ${item.warnings} warning(s)`);
|
|
1330
|
+
for (const d of item.diagnostics.slice(0, 5)) {
|
|
1331
|
+
const sev = d.severity === 1 ? "ERROR" : d.severity === 2 ? "WARN" : "INFO";
|
|
1332
|
+
const code = d.code ? ` [${d.code}]` : "";
|
|
1333
|
+
const msg = String(d.message).split("\n")[0];
|
|
1334
|
+
out.push(` [${sev}] line ${d.range.start.line + 1}:${d.range.start.character + 1}${code} - ${msg}`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
if (clean.length > 0) {
|
|
1338
|
+
const shown = clean
|
|
1339
|
+
.slice(0, 10)
|
|
1340
|
+
.map((p) => `• ${p}`)
|
|
1341
|
+
.join("\n");
|
|
1342
|
+
out.push(`Clean (${clean.length}):\n${shown}${clean.length > 10 ? `\n... and ${clean.length - 10} more` : ""}`);
|
|
1343
|
+
}
|
|
1344
|
+
if (notReported.length > 0) {
|
|
1345
|
+
out.push(`Diagnostics not reported in time (${notReported.length}, server may still be analyzing): ${notReported.slice(0, 5).join(", ")}${notReported.length > 5 ? ", ..." : ""}`);
|
|
1346
|
+
}
|
|
1347
|
+
return {
|
|
1348
|
+
content: [{ type: "text", text: out.join("\n") }],
|
|
1349
|
+
details: {
|
|
1350
|
+
ok: true,
|
|
1351
|
+
impacted,
|
|
1352
|
+
clean,
|
|
1353
|
+
notReported,
|
|
1354
|
+
referencedFiles: dependents.map((p) => relative(cwd, p).replace(/\\/g, "/")),
|
|
1355
|
+
},
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
666
1358
|
return {
|
|
667
1359
|
content: [{ type: "text", text: `Unsupported action: ${action}` }],
|
|
668
1360
|
details: { ok: false, error: "Unsupported action" },
|