@bolloon/bolloon-agent 0.3.15 → 0.3.17

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.
Files changed (40) hide show
  1. package/dist/agents/agent-identity.js +138 -0
  2. package/dist/agents/error-classifier.js +118 -0
  3. package/dist/agents/parse-tool-call.js +196 -1
  4. package/dist/agents/pi-sdk-tools.js +13 -0
  5. package/dist/agents/pi-sdk.js +195 -262
  6. package/dist/cli/loading-tui.js +43 -36
  7. package/dist/cli-entry.js +15 -8
  8. package/dist/electron/config.js +9 -14
  9. package/dist/electron/dialogs.js +16 -53
  10. package/dist/electron/first-run.js +24 -65
  11. package/dist/electron/ipc.js +10 -14
  12. package/dist/electron/logger.js +7 -44
  13. package/dist/electron/main.js +42 -45
  14. package/dist/electron/menu.js +13 -18
  15. package/dist/electron/paths.js +12 -54
  16. package/dist/electron/server.js +18 -57
  17. package/dist/electron/tray.js +15 -53
  18. package/dist/electron/window.js +22 -61
  19. package/dist/electron-preload.js +16 -19
  20. package/dist/electron.js +1 -4
  21. package/dist/external-engines/discovery.js +11 -0
  22. package/dist/index.js +172 -15
  23. package/dist/lsp/lsp-manager.js +281 -0
  24. package/dist/lsp/lsp-tools.js +222 -0
  25. package/dist/network/auto-peer-discovery.js +77 -0
  26. package/dist/network/did-agent-resolver.js +206 -0
  27. package/dist/network/p2p-direct.js +1 -1
  28. package/dist/network/p2p-outbox.js +2 -2
  29. package/dist/utils/auto-update.js +12 -51
  30. package/dist/web/client.js +4323 -4812
  31. package/dist/web/components/p2p/P2PModal.js +188 -0
  32. package/dist/web/components/p2p/index.js +276 -234
  33. package/dist/web/components/p2p/p2p-modal.js +664 -0
  34. package/dist/web/components/p2p/p2p-tools.js +248 -0
  35. package/dist/web/index.html +7 -0
  36. package/dist/web/server.js +164 -41
  37. package/dist/web/style.css +58 -3
  38. package/dist/web/ui/message-renderer.js +535 -396
  39. package/dist/web/ui/step-timeline.js +371 -272
  40. package/package.json +3 -3
@@ -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
- const electron_1 = require("electron");
9
- electron_1.contextBridge.exposeInMainWorld('electronAPI', {
6
+ import { contextBridge, ipcRenderer } from 'electron';
7
+ contextBridge.exposeInMainWorld('electronAPI', {
10
8
  // === 原有 (保留) ===
11
- getVersion: () => electron_1.ipcRenderer.invoke('get-version'),
12
- getUserDataPath: () => electron_1.ipcRenderer.invoke('get-user-data-path'),
13
- openExternal: (url) => electron_1.ipcRenderer.invoke('open-external', 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: () => electron_1.ipcRenderer.invoke('get-data-path'),
13
+ getDataPath: () => ipcRenderer.invoke('get-data-path'),
16
14
  // === 新增: 文件 dialog ===
17
- openFile: (opts) => electron_1.ipcRenderer.invoke('dialog:open-file', opts),
18
- saveFile: (opts) => electron_1.ipcRenderer.invoke('dialog:save-file', opts),
19
- openDirectory: (opts) => electron_1.ipcRenderer.invoke('dialog:open-directory', 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) => electron_1.ipcRenderer.invoke('fs:read-text-file', opts),
22
- writeTextFile: (opts) => electron_1.ipcRenderer.invoke('fs:write-text-file', opts),
23
- pathExists: (opts) => electron_1.ipcRenderer.invoke('fs:path-exists', 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: () => electron_1.ipcRenderer.invoke('first-run:seen'),
26
- markFirstRunSeen: () => electron_1.ipcRenderer.invoke('first-run:mark-seen'),
27
- getDataPathSync: () => electron_1.ipcRenderer.invoke('first-run:data-dir'),
28
- getLogsPathSync: () => electron_1.ipcRenderer.invoke('first-run:logs-dir'),
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
- require("./electron/main");
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
@@ -4,8 +4,10 @@ import { HybridMessenger } from './network/hybrid-messenger.js';
4
4
  import * as ed25519 from '@noble/ed25519';
5
5
  import { sha512 } from '@noble/hashes/sha2.js';
6
6
  import * as fs from 'fs/promises';
7
+ import { existsSync, mkdirSync } from 'fs';
7
8
  import * as path from 'path';
8
9
  import { spawn } from 'child_process';
10
+ import * as os from 'os';
9
11
  import { documentReader } from './documents/reader.js';
10
12
  import { initMinimax } from './constraints/index.js';
11
13
  import { createAgentSession } from './agents/pi-sdk.js';
@@ -28,13 +30,22 @@ const _BOLLOON_VERSION = (() => {
28
30
  const RESET = '\x1b[0m';
29
31
  const BOLD = '\x1b[1m';
30
32
  const DIM = '\x1b[2m';
31
- const CYAN = '\x1b[36m';
32
- const GREEN = '\x1b[32m';
33
- const YELLOW = '\x1b[33m';
34
- const BLUE = '\x1b[34m';
35
- const MAGENTA = '\x1b[35m';
36
- const WHITE = '\x1b[37m';
37
- const GRAY = '\x1b[90m';
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;
38
49
  const BG_WHITE = '\x1b[47m';
39
50
  const BG_BLUE = '\x1b[44m';
40
51
  const BLACK = '\x1b[30m';
@@ -128,14 +139,30 @@ function getUserName() {
128
139
  }
129
140
  async function bootstrapIdentity() {
130
141
  s.step(1, 5, '生成 DIAP 身份', 'loading');
131
- const kp = KeyManager.generate();
142
+ const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir?.() || '.';
143
+ const identityPath = path.join(homeDir, '.bolloon', 'identity.json');
144
+ let kp;
145
+ let reused = false;
146
+ try {
147
+ if (existsSync(identityPath)) {
148
+ kp = await KeyManager.fromFile(identityPath);
149
+ reused = true;
150
+ }
151
+ else
152
+ throw 0;
153
+ }
154
+ catch {
155
+ kp = KeyManager.generate();
156
+ mkdirSync(path.dirname(identityPath), { recursive: true });
157
+ await KeyManager.saveToFile(kp, identityPath);
158
+ }
132
159
  const did = kp.did;
133
160
  const username = getUserName();
134
161
  const suffix = did.split(':').pop()?.substring(0, 4);
135
162
  const name = `blln-${username}-${suffix}`;
136
- console.log(` ${GRAY}DID:${RESET} ${did}`);
163
+ console.log(` ${reused ? GRAY + '复用 ' : ''}${GRAY}DID:${RESET} ${did}`);
137
164
  console.log(` ${GRAY}名称:${RESET} ${name}`);
138
- s.step(1, 5, '生成 DIAP 身份', 'ok');
165
+ s.step(1, 5, reused ? '复用 DIAP 身份' : '生成 DIAP 身份', 'ok');
139
166
  return { keypair: kp, did, name };
140
167
  }
141
168
  function publishDID(name, kp) {
@@ -328,27 +355,68 @@ const MOVE_UP_1 = '\x1b[1A';
328
355
  let isRunning = false;
329
356
  let currentInput = '';
330
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
331
365
  function getTermHeight() {
332
366
  return process.stdout.rows || 24;
333
367
  }
334
- function moveCursorToBottom() {
368
+ function moveCursorToBottom(lines = 1) {
335
369
  const height = getTermHeight();
336
- 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}`;
337
388
  }
338
389
  function showBottomPrompt() {
339
390
  if (!isRunning)
340
391
  return;
341
392
  promptVisible = true;
342
393
  process.stdout.write(SAVE_CURSOR);
343
- moveCursorToBottom();
344
- process.stdout.write(`${CYAN}❯ ${RESET}${currentInput}${HIDE_CURSOR_SEQ}`);
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}`);
345
407
  process.stdout.write(RESTORE_CURSOR);
346
408
  }
347
409
  function clearPromptLine() {
348
410
  if (!promptVisible)
349
411
  return;
350
412
  process.stdout.write(SAVE_CURSOR);
351
- 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');
352
420
  process.stdout.write(CLEAR_LINE);
353
421
  process.stdout.write(RESTORE_CURSOR);
354
422
  promptVisible = false;
@@ -371,6 +439,21 @@ function startCLI(comm) {
371
439
  peerCount = comm.getConnections().length;
372
440
  }
373
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();
374
457
  const llmName = process.env.MINIMAX_API_KEY ? 'MiniMax'
375
458
  : process.env.OPENAI_API_KEY ? 'OpenAI'
376
459
  : process.env.ANTHROPIC_API_KEY ? 'Anthropic'
@@ -452,6 +535,63 @@ function startCLI(comm) {
452
535
  }
453
536
  async function processInput(input, comm) {
454
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
+ }
455
595
  if (trimmed === '退出' || trimmed === 'exit' || trimmed === 'quit') {
456
596
  clearPromptLine();
457
597
  process.stdout.write(`${CYAN}👋 再见!${RESET}\n`);
@@ -515,6 +655,8 @@ async function processInput(input, comm) {
515
655
  return;
516
656
  }
517
657
  try {
658
+ // 双横线分割
659
+ process.stdout.write(`${C_DIM}${'─'.repeat(8)} · ${'─'.repeat(8)}${RESET}\n`);
518
660
  // 已发送消息框
519
661
  process.stdout.write(renderUserMessage(trimmed) + '\n');
520
662
  const a = await getAgent();
@@ -552,6 +694,21 @@ async function processInput(input, comm) {
552
694
  clearThinking();
553
695
  // 智能体回复框 (圆角)
554
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
+ }
555
712
  }
556
713
  catch (e) {
557
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
+ }