@code4bug/jarvis-agent 1.0.2 → 1.0.4

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 (39) hide show
  1. package/LICENSE +1 -1
  2. package/dist/components/MessageItem.js +9 -1
  3. package/dist/components/MultilineInput.d.ts +7 -1
  4. package/dist/components/MultilineInput.js +148 -4
  5. package/dist/config/loader.d.ts +2 -0
  6. package/dist/core/QueryEngine.d.ts +3 -3
  7. package/dist/core/QueryEngine.js +13 -11
  8. package/dist/core/WorkerBridge.d.ts +9 -0
  9. package/dist/core/WorkerBridge.js +109 -0
  10. package/dist/core/query.d.ts +8 -1
  11. package/dist/core/query.js +276 -54
  12. package/dist/core/queryWorker.d.ts +44 -0
  13. package/dist/core/queryWorker.js +66 -0
  14. package/dist/core/safeguard.js +1 -1
  15. package/dist/hooks/useDoubleCtrlCExit.d.ts +5 -0
  16. package/dist/hooks/useDoubleCtrlCExit.js +34 -0
  17. package/dist/hooks/useInputHistory.js +35 -3
  18. package/dist/hooks/useSlashMenu.d.ts +36 -0
  19. package/dist/hooks/useSlashMenu.js +216 -0
  20. package/dist/hooks/useStreamThrottle.d.ts +20 -0
  21. package/dist/hooks/useStreamThrottle.js +120 -0
  22. package/dist/hooks/useTerminalWidth.d.ts +2 -0
  23. package/dist/hooks/useTerminalWidth.js +13 -0
  24. package/dist/hooks/useTokenDisplay.d.ts +13 -0
  25. package/dist/hooks/useTokenDisplay.js +45 -0
  26. package/dist/screens/repl.js +153 -625
  27. package/dist/screens/slashCommands.d.ts +7 -0
  28. package/dist/screens/slashCommands.js +134 -0
  29. package/dist/services/api/llm.d.ts +2 -0
  30. package/dist/services/api/llm.js +65 -11
  31. package/dist/skills/index.js +1 -1
  32. package/dist/tools/index.d.ts +2 -1
  33. package/dist/tools/index.js +3 -2
  34. package/dist/tools/runCommand.js +37 -6
  35. package/dist/tools/semanticSearch.d.ts +9 -0
  36. package/dist/tools/semanticSearch.js +159 -0
  37. package/dist/tools/writeFile.js +124 -24
  38. package/dist/types/index.d.ts +10 -1
  39. package/package.json +1 -1
@@ -0,0 +1,7 @@
1
+ import { Message } from '../types/index.js';
2
+ /**
3
+ * 斜杠命令执行器
4
+ *
5
+ * 纯函数,返回要追加的系统消息。不涉及 React 状态。
6
+ */
7
+ export declare function executeSlashCommand(cmdName: string): Message | null;
@@ -0,0 +1,134 @@
1
+ import { executeInit } from '../commands/init.js';
2
+ import { APP_VERSION } from '../config/constants.js';
3
+ import { allTools } from '../tools/index.js';
4
+ import { listSkills } from '../skills/index.js';
5
+ import { getExternalSkillsDir } from '../skills/loader.js';
6
+ import { listPermanentAuthorizations, DANGER_RULES, } from '../core/safeguard.js';
7
+ /**
8
+ * 斜杠命令执行器
9
+ *
10
+ * 纯函数,返回要追加的系统消息。不涉及 React 状态。
11
+ */
12
+ export function executeSlashCommand(cmdName) {
13
+ switch (cmdName) {
14
+ case 'init': {
15
+ const result = executeInit();
16
+ return {
17
+ id: `init-${Date.now()}`,
18
+ type: 'system',
19
+ status: 'success',
20
+ content: result.displayText,
21
+ timestamp: Date.now(),
22
+ };
23
+ }
24
+ case 'help': {
25
+ const helpText = [
26
+ '可用命令:',
27
+ ' /init 初始化项目信息,生成 JARVIS.md',
28
+ ' /new 开启新会话,重新初始化上下文',
29
+ ' /resume 恢复历史会话(支持二级菜单选择)',
30
+ ' /resume <ID> 直接恢复指定会话',
31
+ ' /help 显示此帮助信息',
32
+ ' /session_clear 清理所有非当前会话的历史记录',
33
+ ' /skills 查看当前所有 tools 和 skills',
34
+ ' /permissions 查看所有持久化授权列表',
35
+ ' /create_skill <描述> 根据需求创建新 skill',
36
+ ' /agent <名称> 切换智能体(需重启生效)',
37
+ ' /read <路径> 读取文件内容',
38
+ ' /write <路径> 写入文件',
39
+ ' /bash <命令> 执行 Bash 命令',
40
+ ' /ls <路径> 列出目录',
41
+ ' /search <词> 搜索文件内容',
42
+ ' /version 显示当前版本号',
43
+ '',
44
+ '快捷键:',
45
+ ' Ctrl+L 清屏重置',
46
+ ' Ctrl+O 切换详情显示',
47
+ ' ESC 中断推理 / 双击清空输入',
48
+ ' Ctrl+C ×2 退出',
49
+ ].join('\n');
50
+ return {
51
+ id: `help-${Date.now()}`,
52
+ type: 'system',
53
+ status: 'success',
54
+ content: helpText,
55
+ timestamp: Date.now(),
56
+ };
57
+ }
58
+ case 'permissions': {
59
+ const perms = listPermanentAuthorizations();
60
+ const lines = ['持久化授权列表 (~/.jarvis/.permissions.json)', ''];
61
+ if (perms.rules.length > 0) {
62
+ lines.push('按规则授权:');
63
+ for (const r of perms.rules) {
64
+ const rule = DANGER_RULES.find((d) => d.name === r);
65
+ lines.push(` [v] ${r}${rule ? ` — ${rule.reason}` : ''}`);
66
+ }
67
+ lines.push('');
68
+ }
69
+ if (perms.commands.length > 0) {
70
+ lines.push('按命令授权:');
71
+ for (const c of perms.commands) {
72
+ lines.push(` [v] [${c.ruleName}] ${c.command} (${c.grantedAt})`);
73
+ }
74
+ lines.push('');
75
+ }
76
+ if (perms.rules.length === 0 && perms.commands.length === 0) {
77
+ lines.push('(空) 暂无持久化授权记录');
78
+ }
79
+ return {
80
+ id: `perms-${Date.now()}`,
81
+ type: 'system',
82
+ status: 'success',
83
+ content: lines.join('\n'),
84
+ timestamp: Date.now(),
85
+ };
86
+ }
87
+ case 'skills': {
88
+ const skills = listSkills();
89
+ const parts = [];
90
+ parts.push('### Built-in Tools\n');
91
+ allTools.forEach((t, i) => {
92
+ parts.push(`${i + 1}. \`${t.name}\` - ${t.description.slice(0, 60)}`);
93
+ });
94
+ parts.push('');
95
+ parts.push(`### External Skills\n`);
96
+ parts.push(`> ${getExternalSkillsDir()}\n`);
97
+ if (skills.length === 0) {
98
+ parts.push('_(empty)_');
99
+ }
100
+ else {
101
+ skills.forEach((s, i) => {
102
+ const hint = s.meta.argumentHint ? ` \`${s.meta.argumentHint}\`` : '';
103
+ const flags = [];
104
+ if (s.meta.disableModelInvocation)
105
+ flags.push('manual-only');
106
+ if (s.meta.userInvocable === false)
107
+ flags.push('hidden');
108
+ const flagStr = flags.length > 0 ? ` _(${flags.join(', ')})_` : '';
109
+ parts.push(`${i + 1}. \`${s.meta.name}\`${hint} - ${s.meta.description}${flagStr}`);
110
+ });
111
+ }
112
+ parts.push('');
113
+ parts.push(`**Total:** ${allTools.length} tools + ${skills.length} skills = ${allTools.length + skills.length}`);
114
+ return {
115
+ id: `skills-${Date.now()}`,
116
+ type: 'system',
117
+ status: 'success',
118
+ content: parts.join('\n'),
119
+ timestamp: Date.now(),
120
+ };
121
+ }
122
+ case 'version': {
123
+ return {
124
+ id: `version-${Date.now()}`,
125
+ type: 'system',
126
+ status: 'success',
127
+ content: `当前版本: ${APP_VERSION}`,
128
+ timestamp: Date.now(),
129
+ };
130
+ }
131
+ default:
132
+ return null;
133
+ }
134
+ }
@@ -14,6 +14,8 @@ export interface LLMConfig {
14
14
  maxTokens: number;
15
15
  baseUrl?: string;
16
16
  temperature?: number;
17
+ /** 额外请求体参数,直接合并到 API 请求 body */
18
+ extraBody?: Record<string, unknown>;
17
19
  }
18
20
  /** 从配置文件构建 LLMConfig,找不到则回退环境变量 */
19
21
  export declare function getDefaultConfig(): LLMConfig;
@@ -34,6 +34,7 @@ export function fromModelConfig(mc) {
34
34
  maxTokens: mc.max_tokens ?? 4096,
35
35
  baseUrl: mc.api_url,
36
36
  temperature: mc.temperature,
37
+ extraBody: mc.extra_body,
37
38
  };
38
39
  }
39
40
  /** 将内部 TranscriptMessage[] 转为 OpenAI messages 格式 */
@@ -118,7 +119,15 @@ function parseSSELine(line) {
118
119
  return null;
119
120
  try {
120
121
  const parsed = JSON.parse(data);
121
- return parsed.choices?.[0]?.delta ?? null;
122
+ const choice = parsed.choices?.[0];
123
+ if (!choice)
124
+ return null;
125
+ const delta = choice.delta;
126
+ if (!delta)
127
+ return null;
128
+ // 兼容 Qwen/vLLM:thinking 内容可能在 delta.reasoning_content 或 delta.content(role=thinking 时)
129
+ // 部分 vLLM 部署会将 thinking 内容放在 content 字段,通过 choice.finish_reason 或特殊标记区分
130
+ return delta;
122
131
  }
123
132
  catch {
124
133
  return null;
@@ -166,6 +175,10 @@ export class LLMServiceImpl {
166
175
  body.tools = openaiTools;
167
176
  body.tool_choice = 'auto';
168
177
  }
178
+ // 合并额外请求体参数(如 enable_thinking、chat_template_kwargs 等)
179
+ if (this.config.extraBody) {
180
+ Object.assign(body, this.config.extraBody);
181
+ }
169
182
  const url = this.config.baseUrl || 'https://api.openai.com/v1/chat/completions';
170
183
  // 创建 AbortController 用于取消 HTTP 请求
171
184
  const controller = new AbortController();
@@ -213,6 +226,15 @@ export class LLMServiceImpl {
213
226
  let buffer = '';
214
227
  // 用于累积 tool_calls(可能跨多个 chunk)
215
228
  const pendingToolCalls = new Map();
229
+ // 轮询 abortSignal,一旦外部中断立即 abort HTTP 请求,打断 reader.read() 阻塞
230
+ const abortPollTimer = abortSignal
231
+ ? setInterval(() => {
232
+ if (abortSignal.aborted) {
233
+ controller.abort();
234
+ clearInterval(abortPollTimer);
235
+ }
236
+ }, 50)
237
+ : null;
216
238
  try {
217
239
  while (true) {
218
240
  // 检查是否需要中断
@@ -240,8 +262,8 @@ export class LLMServiceImpl {
240
262
  if (delta.reasoning_content && callbacks.onThinking) {
241
263
  callbacks.onThinking(delta.reasoning_content);
242
264
  }
243
- // 文本内容
244
- if (delta.content) {
265
+ // 文本内容(注意:用 != null 判断,避免空字符串 "" 被跳过)
266
+ if (delta.content != null && delta.content !== '') {
245
267
  callbacks.onText(delta.content);
246
268
  }
247
269
  // 工具调用(增量拼接)
@@ -285,16 +307,46 @@ export class LLMServiceImpl {
285
307
  }
286
308
  }
287
309
  }
288
- // 流结束后,触发工具调用回调(只取第一个,与 agentic loop 单步设计一致)
310
+ // 流结束后,触发工具调用回调
289
311
  if (pendingToolCalls.size > 0) {
290
- const first = pendingToolCalls.get(0);
291
- if (first && first.name) {
292
- let input = {};
293
- try {
294
- input = JSON.parse(first.args);
312
+ // index 排序,收集所有有效的 tool_call
313
+ const sortedCalls = Array.from(pendingToolCalls.entries())
314
+ .sort(([a], [b]) => a - b)
315
+ .map(([, tc]) => tc)
316
+ .filter((tc) => tc.name);
317
+ if (sortedCalls.length > 0) {
318
+ if (sortedCalls.length === 1) {
319
+ // 单工具:走原有路径
320
+ const tc = sortedCalls[0];
321
+ let input = {};
322
+ try {
323
+ input = JSON.parse(tc.args);
324
+ }
325
+ catch { /* 参数解析失败则传空 */ }
326
+ callbacks.onToolUse(tc.id, tc.name, input);
327
+ }
328
+ else if (callbacks.onMultiToolUse) {
329
+ // 多工具:触发并行回调
330
+ const calls = sortedCalls.map((tc) => {
331
+ let input = {};
332
+ try {
333
+ input = JSON.parse(tc.args);
334
+ }
335
+ catch { /* ignore */ }
336
+ return { id: tc.id, name: tc.name, input };
337
+ });
338
+ callbacks.onMultiToolUse(calls);
339
+ }
340
+ else {
341
+ // 降级:逐个触发 onToolUse(只触发第一个,保持原有行为)
342
+ const tc = sortedCalls[0];
343
+ let input = {};
344
+ try {
345
+ input = JSON.parse(tc.args);
346
+ }
347
+ catch { /* ignore */ }
348
+ callbacks.onToolUse(tc.id, tc.name, input);
295
349
  }
296
- catch { /* 参数解析失败则传空 */ }
297
- callbacks.onToolUse(first.id, first.name, input);
298
350
  return; // tool_use 时不触发 onComplete
299
351
  }
300
352
  }
@@ -308,6 +360,8 @@ export class LLMServiceImpl {
308
360
  callbacks.onError(new Error(`流式读取失败: ${err.message}`));
309
361
  }
310
362
  finally {
363
+ if (abortPollTimer)
364
+ clearInterval(abortPollTimer);
311
365
  reader.releaseLock();
312
366
  }
313
367
  }
@@ -110,7 +110,7 @@ async function executeSkillScript(scriptPath, skill, args) {
110
110
  // 写入临时 Python 文件,避免 shell -c 的转义问题
111
111
  const tmpFile = path.join(skill.dirPath, `_tmp_run_${Date.now()}.py`);
112
112
  const pyCode = [
113
- 'import sys, json',
113
+ 'import sys,os,json',
114
114
  `sys.path.insert(0, ${JSON.stringify(path.dirname(scriptPath))})`,
115
115
  `from skill import ${funcName}`,
116
116
  `result = ${funcName}(${kwargs.join(', ')})`,
@@ -4,8 +4,9 @@ import { writeFile } from './writeFile.js';
4
4
  import { runCommand } from './runCommand.js';
5
5
  import { listDirectory } from './listDirectory.js';
6
6
  import { searchFiles } from './searchFiles.js';
7
+ import { semanticSearch } from './semanticSearch.js';
7
8
  import { createSkill } from './createSkill.js';
8
- export { readFile, writeFile, runCommand, listDirectory, searchFiles, createSkill };
9
+ export { readFile, writeFile, runCommand, listDirectory, searchFiles, semanticSearch, createSkill };
9
10
  /** 所有内置工具 */
10
11
  export declare const allTools: Tool[];
11
12
  /** 按名称查找内置工具 */
@@ -3,10 +3,11 @@ import { writeFile } from './writeFile.js';
3
3
  import { runCommand } from './runCommand.js';
4
4
  import { listDirectory } from './listDirectory.js';
5
5
  import { searchFiles } from './searchFiles.js';
6
+ import { semanticSearch } from './semanticSearch.js';
6
7
  import { createSkill } from './createSkill.js';
7
- export { readFile, writeFile, runCommand, listDirectory, searchFiles, createSkill };
8
+ export { readFile, writeFile, runCommand, listDirectory, searchFiles, semanticSearch, createSkill };
8
9
  /** 所有内置工具 */
9
- export const allTools = [readFile, writeFile, runCommand, listDirectory, searchFiles, createSkill];
10
+ export const allTools = [readFile, writeFile, runCommand, listDirectory, searchFiles, semanticSearch, createSkill];
10
11
  /** 按名称查找内置工具 */
11
12
  export function findTool(name) {
12
13
  return allTools.find((t) => t.name === name);
@@ -1,11 +1,20 @@
1
1
  import { exec } from 'child_process';
2
2
  import { sanitizeOutput } from '../core/safeguard.js';
3
3
  /**
4
- * 异步执行命令,不阻塞事件循环,保证 TUI 渲染正常
4
+ * 异步执行命令,支持通过 abortSignal 中断子进程
5
5
  */
6
- function execAsync(command, options) {
6
+ function execAsync(command, options, abortSignal) {
7
7
  return new Promise((resolve, reject) => {
8
- exec(command, options, (error, stdout, stderr) => {
8
+ const child = exec(command, options, (error, stdout, stderr) => {
9
+ // 清理轮询
10
+ if (pollTimer !== null)
11
+ clearInterval(pollTimer);
12
+ // 被中断时直接返回已有输出,不视为错误
13
+ if (abortSignal?.aborted) {
14
+ const partial = sanitizeOutput(String(stdout ?? '').trim());
15
+ resolve(partial ? `(命令被中断)\n${partial}` : '(命令被中断)');
16
+ return;
17
+ }
9
18
  if (error) {
10
19
  const parts = [];
11
20
  if (stderr)
@@ -21,6 +30,29 @@ function execAsync(command, options) {
21
30
  }
22
31
  resolve(sanitizeOutput(String(stdout).trim()) || '(命令执行完成,无输出)');
23
32
  });
33
+ // 轮询 abortSignal,检测到中断时 kill 子进程
34
+ let pollTimer = null;
35
+ if (abortSignal) {
36
+ pollTimer = setInterval(() => {
37
+ if (abortSignal.aborted && child.pid) {
38
+ if (pollTimer !== null)
39
+ clearInterval(pollTimer);
40
+ pollTimer = null;
41
+ // 先尝试 SIGTERM,给进程优雅退出的机会
42
+ try {
43
+ child.kill('SIGTERM');
44
+ }
45
+ catch { /* ignore */ }
46
+ // 500ms 后强制 SIGKILL
47
+ setTimeout(() => {
48
+ try {
49
+ child.kill('SIGKILL');
50
+ }
51
+ catch { /* ignore */ }
52
+ }, 500);
53
+ }
54
+ }, 100);
55
+ }
24
56
  });
25
57
  }
26
58
  export const runCommand = {
@@ -29,15 +61,14 @@ export const runCommand = {
29
61
  parameters: {
30
62
  command: { type: 'string', description: '要执行的 Bash 命令', required: true },
31
63
  },
32
- execute: async (args) => {
64
+ execute: async (args, abortSignal) => {
33
65
  const command = args.command;
34
- // 安全围栏拦截已在 query 层(executeTool)统一处理,此处仅负责执行 + 脱敏
35
66
  return execAsync(command, {
36
67
  encoding: 'utf-8',
37
68
  timeout: 30000,
38
69
  maxBuffer: 1024 * 1024,
39
70
  env: sanitizeEnv(process.env),
40
- });
71
+ }, abortSignal);
41
72
  },
42
73
  };
43
74
  /**
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 语义搜索工具
3
+ *
4
+ * 当配置了大模型时,先通过 LLM 对中文关键词进行语义扩展(同义词、近义词、相关表达),
5
+ * 再用扩展后的词集合在文件中进行匹配搜索,实现"相似搜索"能力。
6
+ * 未配置大模型时降级为普通关键词搜索。
7
+ */
8
+ import { Tool } from '../types/index.js';
9
+ export declare const semanticSearch: Tool;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * 语义搜索工具
3
+ *
4
+ * 当配置了大模型时,先通过 LLM 对中文关键词进行语义扩展(同义词、近义词、相关表达),
5
+ * 再用扩展后的词集合在文件中进行匹配搜索,实现"相似搜索"能力。
6
+ * 未配置大模型时降级为普通关键词搜索。
7
+ */
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import { loadConfig, getActiveModel } from '../config/loader.js';
11
+ /** 通过 LLM 扩展关键词,返回扩展后的词列表(含原始关键词) */
12
+ async function expandKeywords(keyword) {
13
+ const config = loadConfig();
14
+ const activeModel = getActiveModel(config);
15
+ if (!activeModel) {
16
+ return [keyword];
17
+ }
18
+ const prompt = `你是一个关键词扩展助手。请对以下搜索关键词进行语义扩展,生成同义词、近义词、相关表达和常见别名。
19
+
20
+ 关键词: "${keyword}"
21
+
22
+ 要求:
23
+ 1. 返回 JSON 数组格式,例如: ["词1", "词2", "词3"]
24
+ 2. 包含原始关键词
25
+ 3. 包含中文同义词、近义词
26
+ 4. 如果关键词是中文,也包含对应的英文表达
27
+ 5. 如果关键词是英文,也包含对应的中文表达
28
+ 6. 包含常见缩写或别名
29
+ 7. 最多返回 15 个词
30
+ 8. 只返回 JSON 数组,不要其他内容`;
31
+ try {
32
+ const url = activeModel.api_url;
33
+ const response = await fetch(url, {
34
+ method: 'POST',
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ Authorization: `Bearer ${activeModel.api_key}`,
38
+ },
39
+ body: JSON.stringify({
40
+ model: activeModel.model,
41
+ messages: [
42
+ { role: 'system', content: '你是一个关键词扩展助手,只返回 JSON 数组,不要任何其他内容。' },
43
+ { role: 'user', content: prompt },
44
+ ],
45
+ max_tokens: 500,
46
+ temperature: 0.3,
47
+ stream: false,
48
+ }),
49
+ });
50
+ if (!response.ok) {
51
+ return [keyword];
52
+ }
53
+ const data = (await response.json());
54
+ const content = data.choices?.[0]?.message?.content?.trim() ?? '';
55
+ // 从返回内容中提取 JSON 数组
56
+ const jsonMatch = content.match(/\[[\s\S]*?\]/);
57
+ if (!jsonMatch) {
58
+ return [keyword];
59
+ }
60
+ const expanded = JSON.parse(jsonMatch[0]);
61
+ // 确保原始关键词在列表中
62
+ if (!expanded.includes(keyword)) {
63
+ expanded.unshift(keyword);
64
+ }
65
+ return expanded.filter((w) => typeof w === 'string' && w.trim().length > 0);
66
+ }
67
+ catch {
68
+ // LLM 调用失败,降级为原始关键词
69
+ return [keyword];
70
+ }
71
+ }
72
+ /** 在文件内容中检查是否匹配任意一个关键词(不区分大小写) */
73
+ function matchesAny(line, keywords) {
74
+ const lower = line.toLowerCase();
75
+ for (const kw of keywords) {
76
+ if (lower.includes(kw.toLowerCase())) {
77
+ return kw;
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+ /** 递归遍历目录搜索 */
83
+ function walkAndSearch(dir, keywords, results, maxResults) {
84
+ if (results.length >= maxResults)
85
+ return;
86
+ try {
87
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
88
+ for (const entry of entries) {
89
+ if (results.length >= maxResults)
90
+ return;
91
+ const full = path.join(dir, entry.name);
92
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist')
93
+ continue;
94
+ if (entry.isDirectory()) {
95
+ walkAndSearch(full, keywords, results, maxResults);
96
+ }
97
+ else {
98
+ try {
99
+ const content = fs.readFileSync(full, 'utf-8');
100
+ const lines = content.split('\n');
101
+ for (let i = 0; i < lines.length; i++) {
102
+ if (results.length >= maxResults)
103
+ return;
104
+ const matched = matchesAny(lines[i], keywords);
105
+ if (matched) {
106
+ results.push({
107
+ file: full,
108
+ line: i + 1,
109
+ content: lines[i].trim(),
110
+ matchedKeyword: matched,
111
+ });
112
+ }
113
+ }
114
+ }
115
+ catch { /* 跳过二进制文件 */ }
116
+ }
117
+ }
118
+ }
119
+ catch { /* 跳过无权限目录 */ }
120
+ }
121
+ export const semanticSearch = {
122
+ name: 'semantic_search',
123
+ description: '语义搜索:通过大模型扩展中文关键词的同义词和相关表达,在指定目录中进行相似搜索。未配置大模型时降级为普通搜索。',
124
+ parameters: {
125
+ path: { type: 'string', description: '搜索目录', required: true },
126
+ pattern: { type: 'string', description: '搜索关键词', required: true },
127
+ expand: { type: 'string', description: '是否启用语义扩展(true/false),默认 true', required: false },
128
+ },
129
+ execute: async (args) => {
130
+ const dirPath = args.path || '.';
131
+ const pattern = args.pattern;
132
+ const enableExpand = args.expand !== 'false';
133
+ // 第一步:关键词扩展
134
+ let keywords;
135
+ let expandInfo = '';
136
+ if (enableExpand) {
137
+ keywords = await expandKeywords(pattern);
138
+ if (keywords.length > 1) {
139
+ expandInfo = `语义扩展: "${pattern}" → [${keywords.map((k) => `"${k}"`).join(', ')}]\n\n`;
140
+ }
141
+ else {
142
+ expandInfo = `未配置大模型或扩展失败,使用原始关键词: "${pattern}"\n\n`;
143
+ }
144
+ }
145
+ else {
146
+ keywords = [pattern];
147
+ expandInfo = `普通搜索模式,关键词: "${pattern}"\n\n`;
148
+ }
149
+ // 第二步:搜索文件
150
+ const results = [];
151
+ walkAndSearch(dirPath, keywords, results, 50);
152
+ if (results.length === 0) {
153
+ return expandInfo + `未找到匹配的内容`;
154
+ }
155
+ // 格式化输出,标注匹配的关键词
156
+ const formatted = results.map((r) => `${r.file}:${r.line}: [匹配:"${r.matchedKeyword}"] ${r.content}`).join('\n');
157
+ return expandInfo + `找到 ${results.length} 条结果:\n${formatted}`;
158
+ },
159
+ };