@bolloon/bolloon-agent 0.3.16 → 0.3.18
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/dist/agents/error-classifier.js +118 -0
- package/dist/agents/parse-tool-call.js +196 -1
- package/dist/agents/pi-sdk-tools.js +13 -0
- package/dist/agents/pi-sdk.js +195 -262
- package/dist/cli/loading-tui.js +43 -36
- package/dist/cli-entry.js +15 -8
- package/dist/electron/config.js +9 -14
- package/dist/electron/dialogs.js +16 -53
- package/dist/electron/first-run.js +24 -65
- package/dist/electron/ipc.js +10 -14
- package/dist/electron/logger.js +7 -44
- package/dist/electron/main.js +42 -45
- package/dist/electron/menu.js +13 -18
- package/dist/electron/paths.js +12 -54
- package/dist/electron/server.js +18 -57
- package/dist/electron/tray.js +15 -53
- package/dist/electron/window.js +22 -61
- package/dist/electron-preload.js +16 -19
- package/dist/electron.js +1 -4
- package/dist/external-engines/discovery.js +11 -0
- package/dist/index.js +151 -12
- package/dist/lsp/lsp-manager.js +281 -0
- package/dist/lsp/lsp-tools.js +222 -0
- package/dist/utils/auto-update.js +12 -51
- package/dist/web/client.js +16 -0
- package/dist/web/index.html +7 -0
- package/dist/web/server.js +48 -7
- package/dist/web/style.css +58 -3
- package/package.json +3 -3
package/dist/electron-preload.js
CHANGED
|
@@ -1,32 +1,29 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
1
|
/**
|
|
4
2
|
* Electron Preload 脚本
|
|
5
3
|
* 在渲染进程和主进程之间建立安全的通信桥梁
|
|
6
4
|
* contextIsolation: true, nodeIntegration: false — 只能通过这里暴露的 API 触达主进程
|
|
7
5
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
import { contextBridge, ipcRenderer } from 'electron';
|
|
7
|
+
contextBridge.exposeInMainWorld('electronAPI', {
|
|
10
8
|
// === 原有 (保留) ===
|
|
11
|
-
getVersion: () =>
|
|
12
|
-
getUserDataPath: () =>
|
|
13
|
-
openExternal: (url) =>
|
|
9
|
+
getVersion: () => ipcRenderer.invoke('get-version'),
|
|
10
|
+
getUserDataPath: () => ipcRenderer.invoke('get-user-data-path'),
|
|
11
|
+
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
|
14
12
|
// === 新增: 数据目录 ===
|
|
15
|
-
getDataPath: () =>
|
|
13
|
+
getDataPath: () => ipcRenderer.invoke('get-data-path'),
|
|
16
14
|
// === 新增: 文件 dialog ===
|
|
17
|
-
openFile: (opts) =>
|
|
18
|
-
saveFile: (opts) =>
|
|
19
|
-
openDirectory: (opts) =>
|
|
15
|
+
openFile: (opts) => ipcRenderer.invoke('dialog:open-file', opts),
|
|
16
|
+
saveFile: (opts) => ipcRenderer.invoke('dialog:save-file', opts),
|
|
17
|
+
openDirectory: (opts) => ipcRenderer.invoke('dialog:open-directory', opts),
|
|
20
18
|
// === 新增: 文件系统 (限大小, 主进程守卫) ===
|
|
21
|
-
readTextFile: (opts) =>
|
|
22
|
-
writeTextFile: (opts) =>
|
|
23
|
-
pathExists: (opts) =>
|
|
19
|
+
readTextFile: (opts) => ipcRenderer.invoke('fs:read-text-file', opts),
|
|
20
|
+
writeTextFile: (opts) => ipcRenderer.invoke('fs:write-text-file', opts),
|
|
21
|
+
pathExists: (opts) => ipcRenderer.invoke('fs:path-exists', opts),
|
|
24
22
|
// === 新增: 首启引导 ===
|
|
25
|
-
getFirstRunSeen: () =>
|
|
26
|
-
markFirstRunSeen: () =>
|
|
27
|
-
getDataPathSync: () =>
|
|
28
|
-
getLogsPathSync: () =>
|
|
23
|
+
getFirstRunSeen: () => ipcRenderer.invoke('first-run:seen'),
|
|
24
|
+
markFirstRunSeen: () => ipcRenderer.invoke('first-run:mark-seen'),
|
|
25
|
+
getDataPathSync: () => ipcRenderer.invoke('first-run:data-dir'),
|
|
26
|
+
getLogsPathSync: () => ipcRenderer.invoke('first-run:logs-dir'),
|
|
29
27
|
// === 元数据 ===
|
|
30
28
|
platform: process.platform,
|
|
31
29
|
});
|
|
32
|
-
//# sourceMappingURL=electron-preload.js.map
|
package/dist/electron.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
1
|
/**
|
|
4
2
|
* Electron 入口 shim — 真正逻辑在 src/electron/main.ts
|
|
5
3
|
* (保留 src/electron.ts 平铺入口, 不动 package.json 的 dist/electron.js 解析)
|
|
6
4
|
*/
|
|
7
|
-
|
|
8
|
-
//# sourceMappingURL=electron.js.map
|
|
5
|
+
import './electron/main';
|
|
@@ -124,6 +124,17 @@ const KNOWN_ENGINES = [
|
|
|
124
124
|
models: OPENCODE_MODELS,
|
|
125
125
|
delegateArgs: (p) => ['prompt', p],
|
|
126
126
|
},
|
|
127
|
+
{
|
|
128
|
+
id: 'opencli',
|
|
129
|
+
displayName: 'OpenCLI',
|
|
130
|
+
binaries: ['opencli', 'open-cli'],
|
|
131
|
+
configFiles: ['.opencli/config.json', '.config/opencli/config.json'],
|
|
132
|
+
envKeys: ['OPENCLI_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'],
|
|
133
|
+
providerHint: 'openai',
|
|
134
|
+
models: OPENCODE_MODELS,
|
|
135
|
+
modelFlag: '-m',
|
|
136
|
+
delegateArgs: (p) => ['exec', p],
|
|
137
|
+
},
|
|
127
138
|
];
|
|
128
139
|
// ====================== 默认 deps (真实 IO) ======================
|
|
129
140
|
function realWhichImpl(name) {
|
package/dist/index.js
CHANGED
|
@@ -30,13 +30,22 @@ const _BOLLOON_VERSION = (() => {
|
|
|
30
30
|
const RESET = '\x1b[0m';
|
|
31
31
|
const BOLD = '\x1b[1m';
|
|
32
32
|
const DIM = '\x1b[2m';
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
33
|
+
// Bolloon Web UI 配色 truecolor ANSI — 与 loading-tui.ts 一致
|
|
34
|
+
function fg(r, g, b) { return `\x1b[38;2;${r};${g};${b}m`; }
|
|
35
|
+
const C_ACCENT = fg(0xc4, 0xd6, 0x40); // #c4d640
|
|
36
|
+
const C_TEXT = fg(0xd8, 0xd8, 0xc8); // #d8d8c8
|
|
37
|
+
const C_DIM = fg(0x90, 0x90, 0x88); // #909088
|
|
38
|
+
const C_OK = fg(0x22, 0xc5, 0x5e); // #22c55e
|
|
39
|
+
const C_ERROR = fg(0xef, 0x44, 0x44); // #ef4444
|
|
40
|
+
const C_WARN = fg(0xf5, 0x9e, 0x0b); // #f59e0b
|
|
41
|
+
// 向下兼容 — 旧名映射到新色
|
|
42
|
+
const CYAN = C_ACCENT;
|
|
43
|
+
const GREEN = C_OK;
|
|
44
|
+
const YELLOW = C_WARN;
|
|
45
|
+
const MAGENTA = C_ERROR;
|
|
46
|
+
const WHITE = C_TEXT;
|
|
47
|
+
const GRAY = C_DIM;
|
|
48
|
+
const BLUE = C_DIM;
|
|
40
49
|
const BG_WHITE = '\x1b[47m';
|
|
41
50
|
const BG_BLUE = '\x1b[44m';
|
|
42
51
|
const BLACK = '\x1b[30m';
|
|
@@ -346,27 +355,68 @@ const MOVE_UP_1 = '\x1b[1A';
|
|
|
346
355
|
let isRunning = false;
|
|
347
356
|
let currentInput = '';
|
|
348
357
|
let promptVisible = false;
|
|
358
|
+
let queueMode = false;
|
|
359
|
+
const pendingQueue = [];
|
|
360
|
+
// 底部状态栏数据
|
|
361
|
+
let cliStartTime = 0;
|
|
362
|
+
let cliModelName = '…';
|
|
363
|
+
let cliAgentName = '…';
|
|
364
|
+
let cliContextPct = 0; // 0-100
|
|
349
365
|
function getTermHeight() {
|
|
350
366
|
return process.stdout.rows || 24;
|
|
351
367
|
}
|
|
352
|
-
function moveCursorToBottom() {
|
|
368
|
+
function moveCursorToBottom(lines = 1) {
|
|
353
369
|
const height = getTermHeight();
|
|
354
|
-
process.stdout.write(`\x1b[${height};1H`);
|
|
370
|
+
process.stdout.write(`\x1b[${height - lines + 1};1H`);
|
|
371
|
+
}
|
|
372
|
+
function fmtDuration(ms) {
|
|
373
|
+
const s = Math.floor(ms / 1000);
|
|
374
|
+
const m = Math.floor(s / 60);
|
|
375
|
+
const h = Math.floor(m / 60);
|
|
376
|
+
if (h > 0)
|
|
377
|
+
return `${h}h${m % 60}m`;
|
|
378
|
+
if (m > 0)
|
|
379
|
+
return `${m}m${s % 60}s`;
|
|
380
|
+
return `${s}s`;
|
|
381
|
+
}
|
|
382
|
+
function statusBarLine() {
|
|
383
|
+
const dur = cliStartTime ? fmtDuration(Date.now() - cliStartTime) : '0s';
|
|
384
|
+
const barLen = 12;
|
|
385
|
+
const filled = Math.round((cliContextPct / 100) * barLen);
|
|
386
|
+
const bar = C_OK + '█'.repeat(filled) + C_DIM + '░'.repeat(barLen - filled) + RESET;
|
|
387
|
+
return `${C_DIM}${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ ${C_ACCENT}${dur}${RESET} ${C_DIM}│${RESET} ${bar} ${C_DIM}${cliContextPct}%${RESET}`;
|
|
355
388
|
}
|
|
356
389
|
function showBottomPrompt() {
|
|
357
390
|
if (!isRunning)
|
|
358
391
|
return;
|
|
359
392
|
promptVisible = true;
|
|
360
393
|
process.stdout.write(SAVE_CURSOR);
|
|
361
|
-
moveCursorToBottom();
|
|
362
|
-
|
|
394
|
+
moveCursorToBottom(2);
|
|
395
|
+
// 第 1 行: 状态栏 (从屏幕底上数第 2 行)
|
|
396
|
+
process.stdout.write(CLEAR_LINE + statusBarLine() + '\n');
|
|
397
|
+
// 第 2 行: 输入框
|
|
398
|
+
process.stdout.write(CLEAR_LINE);
|
|
399
|
+
const hint = currentInput.length === 0 && !queueMode
|
|
400
|
+
? ` ${C_DIM}!cmd${RESET} ${C_DIM}/queue${RESET} ${C_DIM}/help${RESET}`
|
|
401
|
+
: queueMode ? ` ${C_WARN}[队列${pendingQueue.length}]${RESET}` : '';
|
|
402
|
+
const prefix = queueMode ? `${C_WARN}▸${RESET}` : `${C_ACCENT}❯${RESET}`;
|
|
403
|
+
const tw = process.stdout.columns || 80;
|
|
404
|
+
process.stdout.write(`${C_DIM}${'─'.repeat(tw)}${RESET}\n`);
|
|
405
|
+
process.stdout.write(`${prefix} ${currentInput}${hint}${HIDE_CURSOR_SEQ}`);
|
|
406
|
+
process.stdout.write(`\n${C_DIM}${'─'.repeat(tw)}${RESET}`);
|
|
363
407
|
process.stdout.write(RESTORE_CURSOR);
|
|
364
408
|
}
|
|
365
409
|
function clearPromptLine() {
|
|
366
410
|
if (!promptVisible)
|
|
367
411
|
return;
|
|
368
412
|
process.stdout.write(SAVE_CURSOR);
|
|
369
|
-
moveCursorToBottom();
|
|
413
|
+
moveCursorToBottom(4);
|
|
414
|
+
process.stdout.write(CLEAR_LINE);
|
|
415
|
+
process.stdout.write('\n');
|
|
416
|
+
process.stdout.write(CLEAR_LINE);
|
|
417
|
+
process.stdout.write('\n');
|
|
418
|
+
process.stdout.write(CLEAR_LINE);
|
|
419
|
+
process.stdout.write('\n');
|
|
370
420
|
process.stdout.write(CLEAR_LINE);
|
|
371
421
|
process.stdout.write(RESTORE_CURSOR);
|
|
372
422
|
promptVisible = false;
|
|
@@ -389,6 +439,21 @@ function startCLI(comm) {
|
|
|
389
439
|
peerCount = comm.getConnections().length;
|
|
390
440
|
}
|
|
391
441
|
catch { /* */ }
|
|
442
|
+
// 读取 LLM 模型名 (支持多 provider)
|
|
443
|
+
const providerNames = [
|
|
444
|
+
['OPENAI_API_KEY', 'OpenAI'],
|
|
445
|
+
['ANTHROPIC_API_KEY', 'Anthropic'],
|
|
446
|
+
['DEEPSEEK_API_KEY', 'DeepSeek'],
|
|
447
|
+
['GOOGLE_API_KEY', 'Google'],
|
|
448
|
+
['GROQ_API_KEY', 'Groq'],
|
|
449
|
+
['MINIMAX_API_KEY', 'MiniMax'],
|
|
450
|
+
['XAI_API_KEY', 'xAI'],
|
|
451
|
+
['TOGETHER_API_KEY', 'Together'],
|
|
452
|
+
];
|
|
453
|
+
const foundProvider = providerNames.find(([k]) => process.env[k]);
|
|
454
|
+
cliModelName = foundProvider ? foundProvider[1] : '未配置';
|
|
455
|
+
cliAgentName = agentIdentity?.name || 'bolloon';
|
|
456
|
+
cliStartTime = Date.now();
|
|
392
457
|
const llmName = process.env.MINIMAX_API_KEY ? 'MiniMax'
|
|
393
458
|
: process.env.OPENAI_API_KEY ? 'OpenAI'
|
|
394
459
|
: process.env.ANTHROPIC_API_KEY ? 'Anthropic'
|
|
@@ -470,6 +535,63 @@ function startCLI(comm) {
|
|
|
470
535
|
}
|
|
471
536
|
async function processInput(input, comm) {
|
|
472
537
|
const trimmed = input.trim();
|
|
538
|
+
// !command — 直接执行终端命令
|
|
539
|
+
if (trimmed.startsWith('!')) {
|
|
540
|
+
const cmd = trimmed.slice(1).trim();
|
|
541
|
+
if (!cmd) {
|
|
542
|
+
process.stdout.write(`${C_DIM}!<命令> 执行终端命令, 如 !ls -la${RESET}\n`);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
process.stdout.write(`${C_DIM}── $ ${cmd}${RESET}\n`);
|
|
546
|
+
try {
|
|
547
|
+
const { execSync } = await import('child_process');
|
|
548
|
+
const out = execSync(cmd, { timeout: 30000, encoding: 'utf-8', cwd: process.cwd() });
|
|
549
|
+
process.stdout.write(`${C_DIM}${out || '(无输出)'}${RESET}`);
|
|
550
|
+
}
|
|
551
|
+
catch (e) {
|
|
552
|
+
process.stdout.write(`${C_ERROR}${e.stderr || e.message}${RESET}\n`);
|
|
553
|
+
}
|
|
554
|
+
process.stdout.write(`${C_DIM}──${RESET}\n`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
// /queue — 切换队列模式
|
|
558
|
+
if (trimmed.toLowerCase() === '/queue') {
|
|
559
|
+
queueMode = !queueMode;
|
|
560
|
+
process.stdout.write(`${C_WARN}队列 ${queueMode ? '开启' : '关闭'}${RESET} (${pendingQueue.length} 条)\n`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// /dequeue — 出队一条
|
|
564
|
+
if (trimmed.toLowerCase() === '/dequeue' || trimmed.toLowerCase() === '/dq') {
|
|
565
|
+
const next = pendingQueue.shift();
|
|
566
|
+
if (next)
|
|
567
|
+
process.stdout.write(`${C_WARN}出队:${RESET} ${next}\n`);
|
|
568
|
+
else
|
|
569
|
+
process.stdout.write(`${C_DIM}队列为空${RESET}\n`);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
// 队列模式: 入队
|
|
573
|
+
if (queueMode) {
|
|
574
|
+
pendingQueue.push(trimmed);
|
|
575
|
+
process.stdout.write(`${C_WARN}[${pendingQueue.length}]${RESET} 已入队\n`);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// 队列非空: 也入队末尾 (排队执行)
|
|
579
|
+
if (pendingQueue.length > 0) {
|
|
580
|
+
pendingQueue.push(trimmed);
|
|
581
|
+
process.stdout.write(`${C_WARN}[队列 ${pendingQueue.length}]${RESET} 已入队, 执行完当前后自动运行\n`);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (trimmed.toLowerCase() === '/help' || trimmed === 'help') {
|
|
585
|
+
process.stdout.write(`${C_DIM}命令:${RESET}\n`);
|
|
586
|
+
process.stdout.write(` ${C_ACCENT}!<cmd>${RESET} 执行终端命令 ${C_DIM}如 !ls -la${RESET}\n`);
|
|
587
|
+
process.stdout.write(` ${C_ACCENT}/queue${RESET} 切换队列模式 ${C_DIM}输入排队, 当前结束后自动执行${RESET}\n`);
|
|
588
|
+
process.stdout.write(` ${C_ACCENT}/dequeue${RESET} 出队一条\n`);
|
|
589
|
+
process.stdout.write(` ${C_ACCENT}peers${RESET} 查看 P2P 节点\n`);
|
|
590
|
+
process.stdout.write(` ${C_ACCENT}iroh${RESET} 查看 iroh 状态\n`);
|
|
591
|
+
process.stdout.write(` ${C_ACCENT}add_friend${RESET} 添加好友\n`);
|
|
592
|
+
process.stdout.write(` ${C_ACCENT}exit${RESET} 退出\n`);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
473
595
|
if (trimmed === '退出' || trimmed === 'exit' || trimmed === 'quit') {
|
|
474
596
|
clearPromptLine();
|
|
475
597
|
process.stdout.write(`${CYAN}👋 再见!${RESET}\n`);
|
|
@@ -533,6 +655,8 @@ async function processInput(input, comm) {
|
|
|
533
655
|
return;
|
|
534
656
|
}
|
|
535
657
|
try {
|
|
658
|
+
// 双横线分割
|
|
659
|
+
process.stdout.write(`${C_DIM}${'─'.repeat(8)} · ${'─'.repeat(8)}${RESET}\n`);
|
|
536
660
|
// 已发送消息框
|
|
537
661
|
process.stdout.write(renderUserMessage(trimmed) + '\n');
|
|
538
662
|
const a = await getAgent();
|
|
@@ -570,6 +694,21 @@ async function processInput(input, comm) {
|
|
|
570
694
|
clearThinking();
|
|
571
695
|
// 智能体回复框 (圆角)
|
|
572
696
|
process.stdout.write(renderAgentMessage(response) + '\n');
|
|
697
|
+
// 更新底部状态栏: 上下文进度
|
|
698
|
+
try {
|
|
699
|
+
// 用 messageHistory 长度推断上下文占用
|
|
700
|
+
const msgLen = JSON.stringify(a.messageHistory ?? []).length;
|
|
701
|
+
cliContextPct = Math.min(100, Math.round((msgLen / 240_000) * 100));
|
|
702
|
+
}
|
|
703
|
+
catch { /* 降级容忍 */ }
|
|
704
|
+
// 双横线分隔 + 自动消费队列
|
|
705
|
+
process.stdout.write(`${C_DIM}${'─'.repeat(8)} · ${'─'.repeat(8)}${RESET}\n`);
|
|
706
|
+
if (pendingQueue.length > 0) {
|
|
707
|
+
const next = pendingQueue.shift();
|
|
708
|
+
process.stdout.write(`${C_WARN}⏩ 自动执行队列 [${pendingQueue.length + 1}/${pendingQueue.length + 1}]${RESET}\n`);
|
|
709
|
+
await processInput(next, comm);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
573
712
|
}
|
|
574
713
|
catch (e) {
|
|
575
714
|
if (!e.message?.includes('ERR_USE_AFTER_CLOSE') && !e.message?.includes('write after end')) {
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lsp/lsp-manager.ts — LSP 服务器发现 + 生命周期管理
|
|
3
|
+
*
|
|
4
|
+
* 检测本机已安装的语言服务器, 管理启动/关闭,
|
|
5
|
+
* 通过 JSON-RPC over stdio 与 LSP 服务器通信.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { createInterface } from 'readline';
|
|
10
|
+
// ==================== 已知 LSP 服务器规格 ====================
|
|
11
|
+
const KNOWN_LSP_SERVERS = [
|
|
12
|
+
{
|
|
13
|
+
language: 'typescript',
|
|
14
|
+
displayName: 'TypeScript Language Server',
|
|
15
|
+
binary: 'typescript-language-server',
|
|
16
|
+
args: ['--stdio'],
|
|
17
|
+
fileExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
language: 'rust',
|
|
21
|
+
displayName: 'rust-analyzer',
|
|
22
|
+
binary: 'rust-analyzer',
|
|
23
|
+
args: [],
|
|
24
|
+
fileExtensions: ['.rs'],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
language: 'python',
|
|
28
|
+
displayName: 'Pyright',
|
|
29
|
+
binary: 'pyright-langserver',
|
|
30
|
+
args: ['--stdio'],
|
|
31
|
+
fileExtensions: ['.py'],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
language: 'css',
|
|
35
|
+
displayName: 'CSS Language Server',
|
|
36
|
+
binary: 'vscode-css-language-server',
|
|
37
|
+
args: ['--stdio'],
|
|
38
|
+
fileExtensions: ['.css', '.scss', '.less'],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
language: 'json',
|
|
42
|
+
displayName: 'JSON Language Server',
|
|
43
|
+
binary: 'vscode-json-language-server',
|
|
44
|
+
args: ['--stdio'],
|
|
45
|
+
fileExtensions: ['.json', '.jsonc'],
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
// ==================== 服务器实例缓存 ====================
|
|
49
|
+
const instances = new Map();
|
|
50
|
+
// ==================== 发现 ====================
|
|
51
|
+
/** 检测已安装的 LSP 服务器 (哪些二进制在 PATH 上) */
|
|
52
|
+
export async function detectInstalledLspServers() {
|
|
53
|
+
const results = [];
|
|
54
|
+
for (const spec of KNOWN_LSP_SERVERS) {
|
|
55
|
+
try {
|
|
56
|
+
const exists = await checkBinary(spec.binary);
|
|
57
|
+
if (exists)
|
|
58
|
+
results.push(spec);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// 静默跳过
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
66
|
+
function checkBinary(name) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const p = spawn('sh', ['-c', `command -v ${JSON.stringify(name)}`], {
|
|
69
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
70
|
+
});
|
|
71
|
+
let out = '';
|
|
72
|
+
p.stdout?.on('data', (d) => (out += d.toString()));
|
|
73
|
+
p.on('close', () => resolve(out.trim().length > 0));
|
|
74
|
+
p.on('error', () => resolve(false));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// ==================== 生命周期 ====================
|
|
78
|
+
/** 启动一个 LSP 服务器 (如果尚未启动则启动) */
|
|
79
|
+
export async function startLspServer(language) {
|
|
80
|
+
// 已有实例, 返回缓存
|
|
81
|
+
const existing = instances.get(language);
|
|
82
|
+
if (existing && existing.process.exitCode === null)
|
|
83
|
+
return existing;
|
|
84
|
+
// 查找规格
|
|
85
|
+
const spec = KNOWN_LSP_SERVERS.find(s => s.language === language)
|
|
86
|
+
|| (await detectInstalledLspServers()).find(s => s.language === language);
|
|
87
|
+
if (!spec)
|
|
88
|
+
return null;
|
|
89
|
+
// spawn 进程
|
|
90
|
+
const proc = spawn(spec.binary, spec.args, {
|
|
91
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
92
|
+
env: { ...process.env },
|
|
93
|
+
});
|
|
94
|
+
const instance = { spec, process: proc, nextId: 1 };
|
|
95
|
+
instances.set(language, instance);
|
|
96
|
+
// 发送 initialize 请求
|
|
97
|
+
await sendRequest(instance, 'initialize', {
|
|
98
|
+
processId: process.pid,
|
|
99
|
+
capabilities: {},
|
|
100
|
+
rootUri: null,
|
|
101
|
+
});
|
|
102
|
+
// 发送 initialized 通知
|
|
103
|
+
sendNotification(instance, 'initialized', {});
|
|
104
|
+
return instance;
|
|
105
|
+
}
|
|
106
|
+
/** 关闭 LSP 服务器 */
|
|
107
|
+
export async function stopLspServer(language) {
|
|
108
|
+
const inst = instances.get(language);
|
|
109
|
+
if (!inst)
|
|
110
|
+
return;
|
|
111
|
+
try {
|
|
112
|
+
sendNotification(inst, 'shutdown', {});
|
|
113
|
+
sendNotification(inst, 'exit', {});
|
|
114
|
+
}
|
|
115
|
+
catch { /* ignore */ }
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
try {
|
|
118
|
+
inst.process.kill('SIGKILL');
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore */ }
|
|
121
|
+
}, 2000).unref();
|
|
122
|
+
instances.delete(language);
|
|
123
|
+
}
|
|
124
|
+
/** 关闭所有 LSP 服务器 */
|
|
125
|
+
export function stopAllLspServers() {
|
|
126
|
+
for (const lang of instances.keys()) {
|
|
127
|
+
stopLspServer(lang);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ==================== JSON-RPC 通信 ====================
|
|
131
|
+
function sendRequest(inst, method, params) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const id = inst.nextId++;
|
|
134
|
+
const req = { jsonrpc: '2.0', id, method, params };
|
|
135
|
+
const body = JSON.stringify(req);
|
|
136
|
+
const header = `Content-Length: ${Buffer.byteLength(body, 'utf-8')}\r\n\r\n`;
|
|
137
|
+
const rl = createInterface({ input: inst.process.stdout });
|
|
138
|
+
const onLine = (line) => {
|
|
139
|
+
try {
|
|
140
|
+
const resp = JSON.parse(line);
|
|
141
|
+
if (resp.id === id) {
|
|
142
|
+
rl.close();
|
|
143
|
+
if (resp.error)
|
|
144
|
+
reject(new Error(resp.error.message));
|
|
145
|
+
else
|
|
146
|
+
resolve(resp.result);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch { /* skip non-JSON lines (content-length headers) */ }
|
|
150
|
+
};
|
|
151
|
+
// LSP 响应是 header + body, 先读 Content-Length 头再读 body
|
|
152
|
+
let buffer = '';
|
|
153
|
+
let contentLength = -1;
|
|
154
|
+
const onData = (chunk) => {
|
|
155
|
+
buffer += chunk.toString();
|
|
156
|
+
while (true) {
|
|
157
|
+
if (contentLength < 0) {
|
|
158
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
159
|
+
if (headerEnd === -1)
|
|
160
|
+
break;
|
|
161
|
+
const headerPart = buffer.substring(0, headerEnd);
|
|
162
|
+
const lenMatch = headerPart.match(/Content-Length:\s*(\d+)/i);
|
|
163
|
+
if (lenMatch)
|
|
164
|
+
contentLength = parseInt(lenMatch[1], 10);
|
|
165
|
+
buffer = buffer.substring(headerEnd + 4);
|
|
166
|
+
}
|
|
167
|
+
if (contentLength > 0 && buffer.length >= contentLength) {
|
|
168
|
+
const bodyStr = buffer.substring(0, contentLength);
|
|
169
|
+
buffer = buffer.substring(contentLength);
|
|
170
|
+
contentLength = -1;
|
|
171
|
+
try {
|
|
172
|
+
const resp = JSON.parse(bodyStr);
|
|
173
|
+
if (resp.id === id) {
|
|
174
|
+
inst.process.stdout.removeListener('data', onData);
|
|
175
|
+
if (resp.error)
|
|
176
|
+
reject(new Error(resp.error.message));
|
|
177
|
+
else
|
|
178
|
+
resolve(resp.result);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch { /* skip malformed */ }
|
|
183
|
+
}
|
|
184
|
+
else
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
inst.process.stdout.on('data', onData);
|
|
189
|
+
inst.process.stdin.write(header + body);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
function sendNotification(inst, method, params) {
|
|
193
|
+
const req = { jsonrpc: '2.0', method, params };
|
|
194
|
+
const body = JSON.stringify(req);
|
|
195
|
+
const header = `Content-Length: ${Buffer.byteLength(body, 'utf-8')}\r\n\r\n`;
|
|
196
|
+
inst.process.stdin.write(header + body);
|
|
197
|
+
}
|
|
198
|
+
/** 打开文档 (textDocument/didOpen) */
|
|
199
|
+
export function lspDidOpen(inst, filePath, content) {
|
|
200
|
+
const uri = pathToUri(filePath);
|
|
201
|
+
sendNotification(inst, 'textDocument/didOpen', {
|
|
202
|
+
textDocument: { uri, languageId: inst.spec.language, version: 1, text: content },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** 悬停 (textDocument/hover) */
|
|
206
|
+
export async function lspHover(inst, filePath, line, character) {
|
|
207
|
+
const uri = pathToUri(filePath);
|
|
208
|
+
const result = await sendRequest(inst, 'textDocument/hover', {
|
|
209
|
+
textDocument: { uri },
|
|
210
|
+
position: { line, character },
|
|
211
|
+
});
|
|
212
|
+
if (!result || !result.contents)
|
|
213
|
+
return null;
|
|
214
|
+
const contents = Array.isArray(result.contents)
|
|
215
|
+
? result.contents.map((c) => typeof c === 'string' ? c : c.value || '').join('\n---\n')
|
|
216
|
+
: typeof result.contents === 'object' ? (result.contents.value || JSON.stringify(result.contents))
|
|
217
|
+
: String(result.contents);
|
|
218
|
+
return { contents, range: result.range };
|
|
219
|
+
}
|
|
220
|
+
/** 补全 (textDocument/completion) */
|
|
221
|
+
export async function lspCompletion(inst, filePath, line, character) {
|
|
222
|
+
const uri = pathToUri(filePath);
|
|
223
|
+
const result = await sendRequest(inst, 'textDocument/completion', {
|
|
224
|
+
textDocument: { uri },
|
|
225
|
+
position: { line, character },
|
|
226
|
+
});
|
|
227
|
+
const items = Array.isArray(result)
|
|
228
|
+
? result
|
|
229
|
+
: (result?.items || []);
|
|
230
|
+
return {
|
|
231
|
+
items: items.map((item) => ({
|
|
232
|
+
label: typeof item === 'string' ? item : (item.label || item.insertText || ''),
|
|
233
|
+
kind: item.kind,
|
|
234
|
+
detail: item.detail,
|
|
235
|
+
})),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/** 诊断 (textDocument/diagnostic — 需要拉模式) */
|
|
239
|
+
export async function lspDiagnostics(inst, filePath) {
|
|
240
|
+
const uri = pathToUri(filePath);
|
|
241
|
+
// 先触发分析: didChange 或 didOpen
|
|
242
|
+
try {
|
|
243
|
+
const result = await sendRequest(inst, 'textDocument/diagnostic', {
|
|
244
|
+
textDocument: { uri },
|
|
245
|
+
});
|
|
246
|
+
return { diagnostics: result?.diagnostics || result?.items || [] };
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return { diagnostics: [] };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** 转到定义 (textDocument/definition) */
|
|
253
|
+
export async function lspGoToDefinition(inst, filePath, line, character) {
|
|
254
|
+
const uri = pathToUri(filePath);
|
|
255
|
+
try {
|
|
256
|
+
const result = await sendRequest(inst, 'textDocument/definition', {
|
|
257
|
+
textDocument: { uri },
|
|
258
|
+
position: { line, character },
|
|
259
|
+
});
|
|
260
|
+
if (!result)
|
|
261
|
+
return null;
|
|
262
|
+
// 可能返回 Location 或 Location[]
|
|
263
|
+
const loc = Array.isArray(result) ? result[0] : result;
|
|
264
|
+
if (!loc || !loc.uri)
|
|
265
|
+
return null;
|
|
266
|
+
return { uri: loc.uri, range: loc.range };
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// ==================== 工具 ====================
|
|
273
|
+
function pathToUri(filePath) {
|
|
274
|
+
const abs = path.resolve(filePath);
|
|
275
|
+
return `file://${abs.startsWith('/') ? '' : '/'}${abs}`;
|
|
276
|
+
}
|
|
277
|
+
/** 根据文件扩展名找到合适的 LSP 服务器 */
|
|
278
|
+
export function findLspForFile(filePath, specs) {
|
|
279
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
280
|
+
return specs.find(s => s.fileExtensions.includes(ext));
|
|
281
|
+
}
|