@code4bug/jarvis-agent 1.0.2 → 1.0.3

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 (59) hide show
  1. package/LICENSE +1 -1
  2. package/dist/cli.js +2 -2
  3. package/dist/commands/index.js +2 -2
  4. package/dist/commands/init.js +1 -1
  5. package/dist/components/MessageItem.d.ts +1 -1
  6. package/dist/components/MessageItem.js +10 -2
  7. package/dist/components/MultilineInput.d.ts +7 -1
  8. package/dist/components/MultilineInput.js +148 -4
  9. package/dist/components/SlashCommandMenu.d.ts +1 -1
  10. package/dist/components/StatusBar.js +1 -1
  11. package/dist/components/StreamingText.js +1 -1
  12. package/dist/components/WelcomeHeader.js +1 -1
  13. package/dist/config/constants.js +3 -3
  14. package/dist/config/loader.d.ts +2 -0
  15. package/dist/core/QueryEngine.d.ts +4 -4
  16. package/dist/core/QueryEngine.js +19 -17
  17. package/dist/core/WorkerBridge.d.ts +9 -0
  18. package/dist/core/WorkerBridge.js +109 -0
  19. package/dist/core/hint.js +4 -4
  20. package/dist/core/query.d.ts +8 -1
  21. package/dist/core/query.js +279 -57
  22. package/dist/core/queryWorker.d.ts +44 -0
  23. package/dist/core/queryWorker.js +66 -0
  24. package/dist/core/safeguard.js +1 -1
  25. package/dist/hooks/useDoubleCtrlCExit.d.ts +5 -0
  26. package/dist/hooks/useDoubleCtrlCExit.js +34 -0
  27. package/dist/hooks/useInputHistory.js +35 -3
  28. package/dist/hooks/useSlashMenu.d.ts +36 -0
  29. package/dist/hooks/useSlashMenu.js +216 -0
  30. package/dist/hooks/useStreamThrottle.d.ts +20 -0
  31. package/dist/hooks/useStreamThrottle.js +120 -0
  32. package/dist/hooks/useTerminalWidth.d.ts +2 -0
  33. package/dist/hooks/useTerminalWidth.js +13 -0
  34. package/dist/hooks/useTokenDisplay.d.ts +13 -0
  35. package/dist/hooks/useTokenDisplay.js +45 -0
  36. package/dist/index.js +1 -1
  37. package/dist/screens/repl.js +164 -636
  38. package/dist/screens/slashCommands.d.ts +7 -0
  39. package/dist/screens/slashCommands.js +134 -0
  40. package/dist/services/api/llm.d.ts +4 -2
  41. package/dist/services/api/llm.js +70 -16
  42. package/dist/services/api/mock.d.ts +1 -1
  43. package/dist/skills/index.d.ts +2 -2
  44. package/dist/skills/index.js +3 -3
  45. package/dist/tools/createSkill.d.ts +1 -1
  46. package/dist/tools/createSkill.js +3 -3
  47. package/dist/tools/index.d.ts +9 -8
  48. package/dist/tools/index.js +10 -9
  49. package/dist/tools/listDirectory.d.ts +1 -1
  50. package/dist/tools/readFile.d.ts +1 -1
  51. package/dist/tools/runCommand.d.ts +1 -1
  52. package/dist/tools/runCommand.js +38 -7
  53. package/dist/tools/searchFiles.d.ts +1 -1
  54. package/dist/tools/semanticSearch.d.ts +9 -0
  55. package/dist/tools/semanticSearch.js +159 -0
  56. package/dist/tools/writeFile.d.ts +1 -1
  57. package/dist/tools/writeFile.js +125 -25
  58. package/dist/types/index.d.ts +10 -1
  59. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { Message, LoopState, Tool, LLMService, TranscriptMessage } from '../types/index.js';
1
+ import { Message, LoopState, Tool, LLMService, TranscriptMessage, ToolCallInfo } from '../types/index.js';
2
2
  /** 危险命令确认结果 */
3
3
  export type DangerConfirmResult = 'once' | 'always' | 'cancel';
4
4
  export interface QueryCallbacks {
@@ -22,3 +22,10 @@ export interface QueryCallbacks {
22
22
  export declare function executeQuery(userInput: string, transcript: TranscriptMessage[], _tools: Tool[], service: LLMService, callbacks: QueryCallbacks, abortSignal: {
23
23
  aborted: boolean;
24
24
  }): Promise<TranscriptMessage[]>;
25
+ /**
26
+ * 直接执行工具(供 Worker 线程调用)
27
+ * 注意:此函数在 Worker 线程中运行,不能使用 callbacks
28
+ */
29
+ export declare function runToolDirect(tc: ToolCallInfo, abortSignal: {
30
+ aborted: boolean;
31
+ }): Promise<string>;
@@ -1,7 +1,13 @@
1
1
  import { v4 as uuid } from 'uuid';
2
- import { findToolMerged as findTool } from '../tools/index.js';
3
- import { MAX_ITERATIONS } from '../config/constants.js';
4
- import { sanitizeOutput, validateCommand, authorizeCommand, authorizeRule } from './safeguard.js';
2
+ import { Worker } from 'worker_threads';
3
+ import { fileURLToPath } from 'url';
4
+ import path from 'path';
5
+ import { findToolMerged as findTool } from '../tools/index';
6
+ import { MAX_ITERATIONS } from '../config/constants';
7
+ import { sanitizeOutput, validateCommand, authorizeCommand, authorizeRule } from './safeguard';
8
+ // 兼容 ESM __dirname
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
5
11
  /**
6
12
  * 单轮 Agentic Loop:推理 → 工具调用 → 循环
7
13
  */
@@ -19,50 +25,59 @@ export async function executeQuery(userInput, transcript, _tools, service, callb
19
25
  loopState.iteration++;
20
26
  callbacks.onLoopStateChange({ ...loopState });
21
27
  const result = await runOneIteration(localTranscript, _tools, service, callbacks, abortSignal);
22
- // 添加推理消息
23
- if (result.text) {
24
- const blocks = [{ type: 'text', text: result.text }];
25
- if (result.toolCall) {
26
- blocks.push({
27
- type: 'tool_use',
28
- id: result.toolCall.id,
29
- name: result.toolCall.name,
30
- input: result.toolCall.input,
31
- });
32
- }
33
- localTranscript.push({ role: 'assistant', content: blocks });
28
+ // 构建 assistant transcript 块
29
+ const assistantBlocks = [];
30
+ if (result.text)
31
+ assistantBlocks.push({ type: 'text', text: result.text });
32
+ for (const tc of result.toolCalls) {
33
+ assistantBlocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.input });
34
34
  }
35
- else if (result.toolCall) {
36
- localTranscript.push({
37
- role: 'assistant',
38
- content: [{
39
- type: 'tool_use',
40
- id: result.toolCall.id,
41
- name: result.toolCall.name,
42
- input: result.toolCall.input,
43
- }],
44
- });
35
+ if (assistantBlocks.length > 0) {
36
+ localTranscript.push({ role: 'assistant', content: assistantBlocks });
45
37
  }
46
38
  // 无工具调用 → 结束
47
- if (!result.toolCall)
39
+ if (result.toolCalls.length === 0)
48
40
  break;
49
- if (abortSignal.aborted)
41
+ // 中断发生在推理阶段
42
+ if (abortSignal.aborted) {
43
+ for (const tc of result.toolCalls) {
44
+ const skippedResult = `[用户中断] 工具 ${tc.name} 未执行(用户按下 ESC 中断)`;
45
+ localTranscript.push({ role: 'tool_result', toolUseId: tc.id, content: skippedResult });
46
+ const skipMsgId = uuid();
47
+ callbacks.onMessage({
48
+ id: skipMsgId, type: 'tool_exec', status: 'aborted',
49
+ content: `${tc.name} 已跳过(用户中断)`, timestamp: Date.now(),
50
+ toolName: tc.name, toolArgs: tc.input, toolResult: skippedResult,
51
+ abortHint: '命令已跳过(ESC)',
52
+ });
53
+ }
50
54
  break;
51
- // 执行工具
52
- const tc = result.toolCall;
53
- const toolResult = await executeTool(tc, callbacks);
54
- localTranscript.push({
55
- role: 'tool_result',
56
- toolUseId: tc.id,
57
- content: toolResult.content,
58
- });
59
- if (toolResult.isError)
55
+ }
56
+ // 判断是否可并行执行
57
+ let toolResults;
58
+ if (result.toolCalls.length > 1 && canRunInParallel(result.toolCalls)) {
59
+ toolResults = await executeToolsInParallel(result.toolCalls, callbacks, abortSignal);
60
+ }
61
+ else {
62
+ toolResults = [];
63
+ for (const tc of result.toolCalls) {
64
+ const r = await executeTool(tc, callbacks, abortSignal);
65
+ toolResults.push({ tc, ...r });
66
+ if (r.isError)
67
+ break;
68
+ }
69
+ }
70
+ // 将所有工具结果写入 transcript
71
+ for (const { tc, content } of toolResults) {
72
+ localTranscript.push({ role: 'tool_result', toolUseId: tc.id, content });
73
+ }
74
+ // 任意工具出错则终止循环
75
+ if (toolResults.some((r) => r.isError))
60
76
  break;
61
77
  }
62
78
  loopState.isRunning = false;
63
79
  loopState.aborted = abortSignal.aborted;
64
80
  callbacks.onLoopStateChange({ ...loopState });
65
- // 如果被用户中断,在 transcript 中追加中断标记,让 LLM 知道上轮回复未完成
66
81
  if (abortSignal.aborted) {
67
82
  localTranscript.push({
68
83
  role: 'user',
@@ -76,7 +91,7 @@ async function runOneIteration(transcript, tools, service, callbacks, abortSigna
76
91
  const startTime = Date.now();
77
92
  let accumulatedText = '';
78
93
  let accumulatedThinking = '';
79
- let toolCall = null;
94
+ let toolCalls = [];
80
95
  let tokenCount = 0;
81
96
  let firstTokenTime = null;
82
97
  const thinkingId = uuid();
@@ -88,23 +103,29 @@ async function runOneIteration(transcript, tools, service, callbacks, abortSigna
88
103
  timestamp: Date.now(),
89
104
  });
90
105
  await new Promise((resolve, reject) => {
106
+ let resolved = false;
107
+ const safeResolve = () => { if (!resolved) {
108
+ resolved = true;
109
+ resolve();
110
+ } };
91
111
  service
92
112
  .streamMessage(transcript, tools, {
93
113
  onThinking: (text) => {
94
- if (abortSignal.aborted)
114
+ if (abortSignal.aborted) {
115
+ safeResolve();
95
116
  return;
117
+ }
96
118
  accumulatedThinking += text;
97
- // 实时更新 thinking 消息内容,让用户看到思考过程
119
+ tokenCount++;
98
120
  callbacks.onUpdateMessage(thinkingId, { content: accumulatedThinking });
99
121
  },
100
122
  onText: (text) => {
101
123
  if (abortSignal.aborted) {
102
- resolve();
124
+ safeResolve();
103
125
  return;
104
126
  }
105
127
  if (firstTokenTime === null) {
106
128
  firstTokenTime = Date.now();
107
- // 收到首 token,将 thinking 消息标记为完成(保留 think 内容)
108
129
  callbacks.onUpdateMessage(thinkingId, {
109
130
  status: 'success',
110
131
  content: accumulatedThinking ? '思考完成' : '',
@@ -117,13 +138,21 @@ async function runOneIteration(transcript, tools, service, callbacks, abortSigna
117
138
  },
118
139
  onToolUse: (id, name, input) => {
119
140
  if (abortSignal.aborted) {
120
- resolve();
141
+ safeResolve();
142
+ return;
143
+ }
144
+ toolCalls = [{ id, name, input }];
145
+ safeResolve();
146
+ },
147
+ onMultiToolUse: (calls) => {
148
+ if (abortSignal.aborted) {
149
+ safeResolve();
121
150
  return;
122
151
  }
123
- toolCall = { id, name, input };
124
- resolve();
152
+ toolCalls = calls;
153
+ safeResolve();
125
154
  },
126
- onComplete: () => resolve(),
155
+ onComplete: () => safeResolve(),
127
156
  onError: (err) => reject(err),
128
157
  }, abortSignal)
129
158
  .catch(reject);
@@ -132,7 +161,6 @@ async function runOneIteration(transcript, tools, service, callbacks, abortSigna
132
161
  const firstTokenLatency = firstTokenTime !== null ? firstTokenTime - startTime : 0;
133
162
  const durationSec = duration / 1000;
134
163
  const tokensPerSecond = durationSec > 0 ? tokenCount / durationSec : 0;
135
- // 最终更新 thinking 消息状态
136
164
  callbacks.onUpdateMessage(thinkingId, {
137
165
  status: 'success',
138
166
  content: accumulatedThinking ? '思考完成' : '',
@@ -141,7 +169,6 @@ async function runOneIteration(transcript, tools, service, callbacks, abortSigna
141
169
  });
142
170
  if (accumulatedText) {
143
171
  const isAborted = abortSignal.aborted;
144
- // 先清空流式文本,再 push reasoning 消息,避免 streamText 被后续工具消息挤到底部
145
172
  callbacks.onClearStreamText?.();
146
173
  callbacks.onMessage({
147
174
  id: uuid(),
@@ -156,15 +183,199 @@ async function runOneIteration(transcript, tools, service, callbacks, abortSigna
156
183
  ...(isAborted ? { abortHint: '推理已中断(ESC)' } : {}),
157
184
  });
158
185
  }
159
- return { text: accumulatedText, toolCall, duration, tokenCount, firstTokenLatency, tokensPerSecond };
186
+ else if (accumulatedThinking && toolCalls.length === 0) {
187
+ const isAborted = abortSignal.aborted;
188
+ callbacks.onClearStreamText?.();
189
+ callbacks.onStreamText(accumulatedThinking);
190
+ callbacks.onClearStreamText?.();
191
+ callbacks.onMessage({
192
+ id: uuid(),
193
+ type: 'reasoning',
194
+ status: isAborted ? 'aborted' : 'success',
195
+ content: accumulatedThinking,
196
+ timestamp: Date.now(),
197
+ duration,
198
+ tokenCount,
199
+ firstTokenLatency,
200
+ tokensPerSecond,
201
+ ...(isAborted ? { abortHint: '推理已中断(ESC)' } : {}),
202
+ });
203
+ }
204
+ const effectiveText = accumulatedText || (accumulatedThinking && toolCalls.length === 0 ? accumulatedThinking : '');
205
+ return { text: effectiveText, toolCalls, duration, tokenCount, firstTokenLatency, tokensPerSecond };
206
+ }
207
+ // ===== 并行执行判断 =====
208
+ /** 判断一组工具调用是否可以并行执行(无写-写冲突、无读-写冲突) */
209
+ function canRunInParallel(calls) {
210
+ // 写操作工具集合
211
+ const WRITE_TOOLS = new Set(['WriteFile', 'Bash']);
212
+ // 读操作工具集合
213
+ const READ_TOOLS = new Set(['ReadFile', 'ListDirectory', 'SearchFiles', 'SemanticSearch']);
214
+ const hasWrite = calls.some((c) => WRITE_TOOLS.has(c.name));
215
+ const hasRead = calls.some((c) => READ_TOOLS.has(c.name));
216
+ // 有写操作时:写-写 或 读-写 混合均不可并行(避免竞态)
217
+ if (hasWrite)
218
+ return false;
219
+ // 全部是读操作 → 可并行
220
+ if (hasRead && !hasWrite)
221
+ return true;
222
+ // Bash 命令:检查是否有文件路径重叠(简单启发式)
223
+ const bashCalls = calls.filter((c) => c.name === 'Bash');
224
+ if (bashCalls.length > 1) {
225
+ // 多个 Bash 命令默认不并行(无法静态分析副作用)
226
+ return false;
227
+ }
228
+ // 其余情况(如多个只读 skill)允许并行
229
+ return true;
230
+ }
231
+ // ===== 并行工具执行(多 Worker 线程) =====
232
+ /** 在独立 Worker 线程中执行单个工具,返回结果字符串 */
233
+ function runToolInWorker(tc, abortSignal) {
234
+ return new Promise((resolve, reject) => {
235
+ const isTsx = __filename.endsWith('.ts');
236
+ const workerScript = isTsx
237
+ ? `
238
+ import { tsImport } from 'tsx/esm/api';
239
+ import { workerData, parentPort } from 'worker_threads';
240
+ import { pathToFileURL } from 'url';
241
+ const mod = await tsImport(workerData.__file, pathToFileURL(workerData.__file).href);
242
+ const result = await mod.runToolDirect(workerData.tc, workerData.abortSignal);
243
+ parentPort.postMessage({ result });
244
+ `
245
+ : `
246
+ import { runToolDirect } from '${__filename.replace(/\.ts$/, '.js')}';
247
+ import { workerData, parentPort } from 'worker_threads';
248
+ const result = await runToolDirect(workerData.tc, workerData.abortSignal);
249
+ parentPort.postMessage({ result });
250
+ `;
251
+ const worker = new Worker(workerScript, {
252
+ eval: true,
253
+ workerData: {
254
+ __file: __filename,
255
+ tc,
256
+ abortSignal: { aborted: abortSignal.aborted },
257
+ },
258
+ });
259
+ worker.on('message', (msg) => {
260
+ worker.terminate();
261
+ resolve(msg.result);
262
+ });
263
+ worker.on('error', (err) => {
264
+ worker.terminate();
265
+ reject(err);
266
+ });
267
+ worker.on('exit', (code) => {
268
+ if (code !== 0)
269
+ reject(new Error(`工具 Worker 异常退出 code=${code}`));
270
+ });
271
+ });
272
+ }
273
+ /**
274
+ * 直接执行工具(供 Worker 线程调用)
275
+ * 注意:此函数在 Worker 线程中运行,不能使用 callbacks
276
+ */
277
+ export async function runToolDirect(tc, abortSignal) {
278
+ // 动态导入避免循环依赖
279
+ const { findToolMerged } = await import('../tools/index.js');
280
+ const tool = findToolMerged(tc.name);
281
+ if (!tool)
282
+ return `错误: 未知工具 ${tc.name}`;
283
+ try {
284
+ const result = await tool.execute(tc.input, abortSignal);
285
+ const { sanitizeOutput } = await import('./safeguard.js');
286
+ return sanitizeOutput(result);
287
+ }
288
+ catch (err) {
289
+ return `错误: ${err.message || '工具执行失败'}`;
290
+ }
291
+ }
292
+ /** 并行执行多个工具,每个工具在独立 Worker 线程中运行,实时更新 UI */
293
+ async function executeToolsInParallel(calls, callbacks, abortSignal) {
294
+ const groupId = uuid();
295
+ // 为每个工具预先创建 pending 消息节点(TUI 立即渲染占位)
296
+ const msgIds = calls.map((tc) => {
297
+ const msgId = uuid();
298
+ const isSkill = tc.name.startsWith('skill_');
299
+ const displayContent = tc.name === 'Bash' && tc.input.command
300
+ ? `Bash(${tc.input.command})`
301
+ : isSkill
302
+ ? `${tc.name.replace(/^skill_/, '')}(${Object.values(tc.input).join(', ')})`
303
+ : `调用工具: ${tc.name}`;
304
+ callbacks.onMessage({
305
+ id: msgId,
306
+ type: 'tool_exec',
307
+ status: 'pending',
308
+ content: displayContent,
309
+ timestamp: Date.now(),
310
+ toolName: tc.name,
311
+ toolArgs: tc.input,
312
+ parallelGroupId: groupId,
313
+ });
314
+ return msgId;
315
+ });
316
+ // 并行启动所有工具(各自在独立 Worker 线程)
317
+ const tasks = calls.map(async (tc, i) => {
318
+ const msgId = msgIds[i];
319
+ const start = Date.now();
320
+ // 安全围栏检查(Bash 命令)
321
+ if (tc.name === 'Bash' && tc.input.command) {
322
+ const { validateCommand } = await import('./safeguard.js');
323
+ const check = validateCommand(tc.input.command);
324
+ if (!check.allowed) {
325
+ if (!check.canOverride) {
326
+ const errMsg = `${check.reason}\n🚫 该命令已被永久禁止。\n命令: ${tc.input.command}`;
327
+ callbacks.onUpdateMessage(msgId, { status: 'error', content: errMsg, toolResult: errMsg });
328
+ return { tc, content: `错误: ${errMsg}`, isError: true };
329
+ }
330
+ // 危险命令在并行模式下直接跳过(无法弹出交互式确认)
331
+ const skipMsg = `⚠️ 并行模式下跳过危险命令: ${tc.input.command}\n原因: ${check.reason}`;
332
+ callbacks.onUpdateMessage(msgId, { status: 'error', content: skipMsg, toolResult: skipMsg });
333
+ return { tc, content: skipMsg, isError: true };
334
+ }
335
+ }
336
+ try {
337
+ const content = await runToolInWorker(tc, abortSignal);
338
+ const wasAborted = abortSignal.aborted;
339
+ const isSkill = tc.name.startsWith('skill_');
340
+ const doneContent = wasAborted
341
+ ? `${tc.name} 已中断`
342
+ : tc.name === 'Bash' && tc.input.command
343
+ ? `Bash(${tc.input.command}) 执行完成`
344
+ : isSkill
345
+ ? `${tc.name.replace(/^skill_/, '')}(${Object.values(tc.input).join(', ')}) 执行完成`
346
+ : `工具 ${tc.name} 执行完成`;
347
+ callbacks.onUpdateMessage(msgId, {
348
+ status: wasAborted ? 'aborted' : 'success',
349
+ content: doneContent,
350
+ toolResult: content,
351
+ duration: Date.now() - start,
352
+ parallelGroupId: groupId,
353
+ ...(wasAborted ? { abortHint: '命令已中断(ESC)' } : {}),
354
+ });
355
+ return { tc, content, isError: false };
356
+ }
357
+ catch (err) {
358
+ const errMsg = err.message || '工具执行失败';
359
+ callbacks.onUpdateMessage(msgId, { status: 'error', content: errMsg, toolResult: errMsg });
360
+ return { tc, content: `错误: ${errMsg}`, isError: false };
361
+ }
362
+ });
363
+ return Promise.all(tasks);
160
364
  }
161
365
  /** 执行工具并返回结果 */
162
- async function executeTool(tc, callbacks) {
366
+ async function executeTool(tc, callbacks, abortSignal) {
163
367
  const toolExecId = uuid();
164
- // 对 Bash 工具使用更直观的显示格式
368
+ // 对 Bash / Skill 工具使用更直观的显示格式
369
+ const isSkill = tc.name.startsWith('skill_');
370
+ const skillName = isSkill ? tc.name.replace(/^skill_/, '') : '';
371
+ const skillArgsSummary = isSkill
372
+ ? Object.values(tc.input).map((v) => String(v)).filter(Boolean).join(', ')
373
+ : '';
165
374
  const displayContent = tc.name === 'Bash' && tc.input.command
166
375
  ? `Bash(${tc.input.command})`
167
- : `调用工具: ${tc.name}`;
376
+ : isSkill
377
+ ? `${skillName}(${skillArgsSummary})`
378
+ : `调用工具: ${tc.name}`;
168
379
  callbacks.onMessage({
169
380
  id: toolExecId,
170
381
  type: 'tool_exec',
@@ -219,17 +430,28 @@ async function executeTool(tc, callbacks) {
219
430
  }
220
431
  try {
221
432
  const start = Date.now();
222
- const result = await tool.execute(tc.input);
433
+ const result = await tool.execute(tc.input, abortSignal);
223
434
  // 对工具输出统一脱敏
224
435
  const safeResult = sanitizeOutput(result);
225
- const doneContent = tc.name === 'Bash' && tc.input.command
226
- ? `Bash(${tc.input.command}) 执行完成`
227
- : `工具 ${tc.name} 执行完成`;
436
+ // 工具执行期间被中断
437
+ const wasAborted = abortSignal?.aborted;
438
+ const doneContent = wasAborted
439
+ ? (tc.name === 'Bash' && tc.input.command
440
+ ? `Bash(${tc.input.command}) 已中断`
441
+ : isSkill
442
+ ? `${skillName}(${skillArgsSummary}) 已中断`
443
+ : `工具 ${tc.name} 已中断`)
444
+ : (tc.name === 'Bash' && tc.input.command
445
+ ? `Bash(${tc.input.command}) 执行完成`
446
+ : isSkill
447
+ ? `${skillName}(${skillArgsSummary}) 执行完成`
448
+ : `工具 ${tc.name} 执行完成`);
228
449
  callbacks.onUpdateMessage(toolExecId, {
229
- status: 'success',
450
+ status: wasAborted ? 'aborted' : 'success',
230
451
  content: doneContent,
231
452
  toolResult: safeResult,
232
453
  duration: Date.now() - start,
454
+ ...(wasAborted ? { abortHint: '命令已中断(ESC)' } : {}),
233
455
  });
234
456
  return { content: safeResult, isError: false };
235
457
  }
@@ -0,0 +1,44 @@
1
+ import { DangerConfirmResult } from './query.js';
2
+ import { TranscriptMessage, Message, LoopState, Session } from '../types/index.js';
3
+ export type WorkerInbound = {
4
+ type: 'run';
5
+ userInput: string;
6
+ transcript: TranscriptMessage[];
7
+ } | {
8
+ type: 'abort';
9
+ } | {
10
+ type: 'danger_confirm_result';
11
+ requestId: string;
12
+ choice: DangerConfirmResult;
13
+ };
14
+ export type WorkerOutbound = {
15
+ type: 'message';
16
+ msg: Message;
17
+ } | {
18
+ type: 'update_message';
19
+ id: string;
20
+ updates: Partial<Message>;
21
+ } | {
22
+ type: 'stream_text';
23
+ text: string;
24
+ } | {
25
+ type: 'clear_stream_text';
26
+ } | {
27
+ type: 'loop_state';
28
+ state: LoopState;
29
+ } | {
30
+ type: 'session_update';
31
+ session: Session;
32
+ } | {
33
+ type: 'danger_confirm_request';
34
+ requestId: string;
35
+ command: string;
36
+ reason: string;
37
+ ruleName: string;
38
+ } | {
39
+ type: 'done';
40
+ transcript: TranscriptMessage[];
41
+ } | {
42
+ type: 'error';
43
+ message: string;
44
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Worker 线程入口 — 在独立线程中执行 executeQuery
3
+ * 通过 parentPort.postMessage 将回调事件传回主线程
4
+ */
5
+ import { parentPort } from 'worker_threads';
6
+ import { executeQuery } from './query.js';
7
+ import { getAllTools } from '../tools/index.js';
8
+ import { LLMServiceImpl } from '../services/api/llm.js';
9
+ import { MockService } from '../services/api/mock.js';
10
+ import { loadConfig, getActiveModel } from '../config/loader.js';
11
+ if (!parentPort)
12
+ throw new Error('queryWorker must run inside worker_threads');
13
+ // ===== 初始化 LLM 服务 =====
14
+ const config = loadConfig();
15
+ const activeModel = getActiveModel(config);
16
+ let service;
17
+ try {
18
+ service = activeModel ? new LLMServiceImpl() : new MockService();
19
+ }
20
+ catch {
21
+ service = new MockService();
22
+ }
23
+ // ===== 中断信号 =====
24
+ const abortSignal = { aborted: false };
25
+ // ===== 危险命令确认:挂起 Promise,等待主线程回复 =====
26
+ const pendingConfirms = new Map();
27
+ // ===== 监听主线程消息 =====
28
+ parentPort.on('message', async (msg) => {
29
+ if (msg.type === 'abort') {
30
+ abortSignal.aborted = true;
31
+ return;
32
+ }
33
+ if (msg.type === 'danger_confirm_result') {
34
+ const resolve = pendingConfirms.get(msg.requestId);
35
+ if (resolve) {
36
+ pendingConfirms.delete(msg.requestId);
37
+ resolve(msg.choice);
38
+ }
39
+ return;
40
+ }
41
+ if (msg.type === 'run') {
42
+ abortSignal.aborted = false;
43
+ const send = (out) => parentPort.postMessage(out);
44
+ const callbacks = {
45
+ onMessage: (m) => send({ type: 'message', msg: m }),
46
+ onUpdateMessage: (id, updates) => send({ type: 'update_message', id, updates }),
47
+ onStreamText: (text) => send({ type: 'stream_text', text }),
48
+ onClearStreamText: () => send({ type: 'clear_stream_text' }),
49
+ onLoopStateChange: (state) => send({ type: 'loop_state', state }),
50
+ onConfirmDangerousCommand: (command, reason, ruleName) => {
51
+ return new Promise((resolve) => {
52
+ const requestId = `${Date.now()}-${Math.random()}`;
53
+ pendingConfirms.set(requestId, resolve);
54
+ send({ type: 'danger_confirm_request', requestId, command, reason, ruleName });
55
+ });
56
+ },
57
+ };
58
+ try {
59
+ const newTranscript = await executeQuery(msg.userInput, msg.transcript, getAllTools(), service, callbacks, abortSignal);
60
+ send({ type: 'done', transcript: newTranscript });
61
+ }
62
+ catch (err) {
63
+ send({ type: 'error', message: err.message ?? '未知错误' });
64
+ }
65
+ }
66
+ });
@@ -42,7 +42,7 @@ export const DANGER_RULES = [
42
42
  /** 敏感信息匹配规则 — 用于输出脱敏 */
43
43
  export const SENSITIVE_PATTERNS = [
44
44
  { name: 'AWS Access Key', pattern: /\b(AKIA[0-9A-Z]{16})\b/g, replacement: '[AWS_ACCESS_KEY]' },
45
- { name: 'AWS Secret Key', pattern: /\b([A-Za-z0-9/+=]{40})\b/g, replacement: '[AWS_SECRET_KEY]' },
45
+ { name: 'AWS Secret Key', pattern: /(?<![A-Za-z0-9/])([A-Za-z0-9+=]{40})(?![A-Za-z0-9/])/g, replacement: '[AWS_SECRET_KEY]' },
46
46
  { name: 'Generic API Key', pattern: /\b(api[_-]?key|apikey)\s*[:=]\s*['"]?([^\s'"]+)/gi, replacement: '$1=[REDACTED]' },
47
47
  { name: 'Generic Secret', pattern: /\b(secret|token|password|passwd|pwd)\s*[:=]\s*['"]?([^\s'"]+)/gi, replacement: '$1=[REDACTED]' },
48
48
  { name: 'Bearer Token', pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/g, replacement: 'Bearer [REDACTED]' },
@@ -0,0 +1,5 @@
1
+ /** 双击 Ctrl+C 退出:第一次按下后显示倒计时,3 秒内再按一次退出,否则取消 */
2
+ export declare function useDoubleCtrlCExit(exit: () => void): {
3
+ countdown: number | null;
4
+ handleCtrlC: () => void;
5
+ };
@@ -0,0 +1,34 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+ /** 双击 Ctrl+C 退出:第一次按下后显示倒计时,3 秒内再按一次退出,否则取消 */
3
+ export function useDoubleCtrlCExit(exit) {
4
+ const [countdown, setCountdown] = useState(null);
5
+ const timerRef = useRef(null);
6
+ const clearTimer = useCallback(() => {
7
+ if (timerRef.current) {
8
+ clearInterval(timerRef.current);
9
+ timerRef.current = null;
10
+ }
11
+ setCountdown(null);
12
+ }, []);
13
+ const handleCtrlC = useCallback(() => {
14
+ if (countdown !== null) {
15
+ clearTimer();
16
+ exit();
17
+ return;
18
+ }
19
+ setCountdown(1);
20
+ timerRef.current = setInterval(() => {
21
+ setCountdown((prev) => {
22
+ if (prev === null || prev <= 1) {
23
+ clearTimer();
24
+ return null;
25
+ }
26
+ return prev - 1;
27
+ });
28
+ }, 1000);
29
+ }, [countdown, clearTimer, exit]);
30
+ // 组件卸载时清理
31
+ useEffect(() => () => { if (timerRef.current)
32
+ clearInterval(timerRef.current); }, []);
33
+ return { countdown, handleCtrlC };
34
+ }
@@ -5,6 +5,28 @@ import os from 'os';
5
5
  const HISTORY_DIR = path.join(os.homedir(), '.jarvis');
6
6
  const HISTORY_FILE = path.join(HISTORY_DIR, '.input_history');
7
7
  const MAX_HISTORY = 20;
8
+ // 历史记录使用 JSON 数组格式存储,天然支持多行内容,无需手动转义
9
+ /** 还原旧 encodeLine 格式:将字面量 \n 还原为真实换行,\\\\ 还原为 \\ */
10
+ function decodeLegacyLine(s) {
11
+ let result = '';
12
+ for (let i = 0; i < s.length; i++) {
13
+ if (s[i] === '\\' && i + 1 < s.length) {
14
+ const next = s[i + 1];
15
+ if (next === 'n') {
16
+ result += '\n';
17
+ i++;
18
+ continue;
19
+ }
20
+ if (next === '\\') {
21
+ result += '\\';
22
+ i++;
23
+ continue;
24
+ }
25
+ }
26
+ result += s[i];
27
+ }
28
+ return result;
29
+ }
8
30
  /** 从文件加载历史记录 */
9
31
  function loadHistory() {
10
32
  try {
@@ -13,19 +35,29 @@ function loadHistory() {
13
35
  const content = fs.readFileSync(HISTORY_FILE, 'utf-8').trim();
14
36
  if (!content)
15
37
  return [];
16
- return content.split('\n').slice(-MAX_HISTORY);
38
+ // 尝试 JSON 格式(新格式)
39
+ if (content.startsWith('[')) {
40
+ try {
41
+ const arr = JSON.parse(content);
42
+ if (Array.isArray(arr))
43
+ return arr.filter((s) => typeof s === 'string').slice(-MAX_HISTORY);
44
+ }
45
+ catch { /* JSON 解析失败,回退纯文本 */ }
46
+ }
47
+ // 回退:旧的纯文本格式,对每行做 decode 还原可能的转义
48
+ return content.split('\n').filter(Boolean).slice(-MAX_HISTORY).map(decodeLegacyLine);
17
49
  }
18
50
  catch {
19
51
  return [];
20
52
  }
21
53
  }
22
- /** 保存历史记录到文件 */
54
+ /** 保存历史记录到文件(JSON 数组格式) */
23
55
  function saveHistory(history) {
24
56
  try {
25
57
  if (!fs.existsSync(HISTORY_DIR)) {
26
58
  fs.mkdirSync(HISTORY_DIR, { recursive: true });
27
59
  }
28
- fs.writeFileSync(HISTORY_FILE, history.slice(-MAX_HISTORY).join('\n') + '\n', 'utf-8');
60
+ fs.writeFileSync(HISTORY_FILE, JSON.stringify(history.slice(-MAX_HISTORY)), 'utf-8');
29
61
  }
30
62
  catch {
31
63
  // 静默失败