@bolloon/bolloon-agent 0.3.16 → 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.
@@ -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
@@ -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
- const CYAN = '\x1b[36m';
34
- const GREEN = '\x1b[32m';
35
- const YELLOW = '\x1b[33m';
36
- const BLUE = '\x1b[34m';
37
- const MAGENTA = '\x1b[35m';
38
- const WHITE = '\x1b[37m';
39
- 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;
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
- 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}`);
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
+ }