@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
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 jarvis-agent
3
+ Copyright (c) 2025 Code4Bug
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -58,8 +58,16 @@ function MessageItem({ msg, showDetails = false }) {
58
58
  // Bash 工具直接显示命令内容
59
59
  const isBash = msg.toolName === 'Bash';
60
60
  const bashCmd = isBash && msg.toolArgs?.command ? String(msg.toolArgs.command) : '';
61
+ // Skill 工具:skill_xxx → 名称(参数摘要) 格式
62
+ const isSkill = msg.toolName?.startsWith('skill_');
63
+ const skillName = isSkill ? msg.toolName.replace(/^skill_/, '') : '';
64
+ const skillArgsSummary = isSkill && msg.toolArgs
65
+ ? Object.values(msg.toolArgs).map((v) => String(v)).filter(Boolean).join(', ')
66
+ : '';
61
67
  const toolLabel = isBash && bashCmd ? `Bash(${bashCmd})` : (msg.toolName || 'tool');
62
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: dotColor, children: [dot, " "] }), isBash && bashCmd ? (_jsxs(Text, { children: [_jsx(Text, { color: "white", bold: true, children: "Bash" }), _jsxs(Text, { color: "gray", children: ["(", bashCmd, ")"] })] })) : (_jsx(Text, { color: "magenta", bold: true, children: toolLabel }))] }), showDetails && msg.toolArgs && !isBash && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "gray", dimColor: true, wrap: "wrap", children: JSON.stringify(msg.toolArgs) }) })), showDetails && msg.toolResult && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "gray", wrap: "wrap", children: msg.toolResult.length > 300 ? msg.toolResult.slice(0, 300) + '…' : msg.toolResult }) })), _jsx(MessageStats, { msg: msg, show: showDetails })] }));
68
+ // 并行组标识
69
+ const isParallel = !!msg.parallelGroupId;
70
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [isParallel && _jsx(Text, { color: "cyan", dimColor: true, children: "\u21C9 " }), _jsxs(Text, { color: dotColor, children: [dot, " "] }), isBash && bashCmd ? (_jsxs(Text, { children: [_jsx(Text, { color: "white", bold: true, children: "Bash" }), _jsxs(Text, { color: "gray", children: ["(", bashCmd, ")"] })] })) : isSkill ? (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: skillName }), _jsxs(Text, { color: "gray", children: ["(", skillArgsSummary, ")"] })] })) : (_jsx(Text, { color: "magenta", bold: true, children: toolLabel }))] }), showDetails && msg.toolArgs && !isBash && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "gray", dimColor: true, wrap: "wrap", children: JSON.stringify(msg.toolArgs) }) })), showDetails && msg.toolResult && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "gray", wrap: "wrap", children: msg.toolResult.length > 300 ? msg.toolResult.slice(0, 300) + '…' : msg.toolResult }) })), _jsx(MessageStats, { msg: msg, show: showDetails })] }));
63
71
  }
64
72
  if (msg.type === 'error') {
65
73
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: "red", children: [_jsx(Text, { color: dotColor, children: dot }), " ", msg.content] }), _jsx(MessageStats, { msg: msg, show: showDetails })] }));
@@ -19,6 +19,12 @@ interface MultilineInputProps {
19
19
  onSlashMenuClose?: () => void;
20
20
  /** 输入为空时按 Tab 的回调(用于填入 placeholder) */
21
21
  onTabFillPlaceholder?: () => void;
22
+ /**
23
+ * 输入框下方的行数(分隔线 + StatusBar 等),用于将终端物理光标
24
+ * 上移到输入框行,使 IME composing 显示在正确位置而非状态栏上。
25
+ * 默认值 2(底部分隔线 1 行 + StatusBar 1 行)。
26
+ */
27
+ rowsBelow?: number;
22
28
  }
23
29
  /**
24
30
  * 多行文本输入组件,支持光标移动和粘贴折叠。
@@ -30,5 +36,5 @@ interface MultilineInputProps {
30
36
  * - Backspace: 删除光标前一个字符(占位符整体删除)
31
37
  * - 粘贴多行内容: 折叠为 [Pasted text #N +X lines] 占位符
32
38
  */
33
- export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, onDownArrow, placeholder, isActive, showCursor, slashMenuActive, onSlashMenuUp, onSlashMenuDown, onSlashMenuSelect, onSlashMenuClose, onTabFillPlaceholder, }: MultilineInputProps): import("react/jsx-runtime").JSX.Element;
39
+ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, onDownArrow, placeholder, isActive, showCursor, slashMenuActive, onSlashMenuUp, onSlashMenuDown, onSlashMenuSelect, onSlashMenuClose, onTabFillPlaceholder, rowsBelow, }: MultilineInputProps): import("react/jsx-runtime").JSX.Element;
34
40
  export {};
@@ -5,6 +5,16 @@ import { Box, Text, useInput, useStdin } from 'ink';
5
5
  const PASTE_PLACEHOLDER_RE = /\[Pasted text #(\d+) \+(\d+) lines\]/g;
6
6
  /** 粘贴检测的时间窗口(ms),在此窗口内连续到达的数据视为一次粘贴 */
7
7
  const PASTE_DETECT_WINDOW_MS = 8;
8
+ /**
9
+ * IME composing 检测时间窗口(ms)。
10
+ * 在此窗口内连续到达的可打印字符视为 IME composing 输入,
11
+ * 延迟渲染以避免拼音字母逐个触发 re-render 导致 TUI 偏移。
12
+ */
13
+ const IME_COMPOSE_WINDOW_MS = 80;
14
+ /** 判断字符串是否全部为 ASCII 小写字母(拼音输入的典型特征) */
15
+ const isAsciiLowerAlpha = (s) => /^[a-z]+$/.test(s);
16
+ /** 判断字符串是否包含非 ASCII 字符(中文、日文等 CJK 字符) */
17
+ const hasNonAscii = (s) => /[^\x00-\x7F]/.test(s);
8
18
  /**
9
19
  * 多行文本输入组件,支持光标移动和粘贴折叠。
10
20
  *
@@ -15,7 +25,7 @@ const PASTE_DETECT_WINDOW_MS = 8;
15
25
  * - Backspace: 删除光标前一个字符(占位符整体删除)
16
26
  * - 粘贴多行内容: 折叠为 [Pasted text #N +X lines] 占位符
17
27
  */
18
- export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, onDownArrow, placeholder = '', isActive = true, showCursor = true, slashMenuActive = false, onSlashMenuUp, onSlashMenuDown, onSlashMenuSelect, onSlashMenuClose, onTabFillPlaceholder, }) {
28
+ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, onDownArrow, placeholder = '', isActive = true, showCursor = true, slashMenuActive = false, onSlashMenuUp, onSlashMenuDown, onSlashMenuSelect, onSlashMenuClose, onTabFillPlaceholder, rowsBelow = 2, }) {
19
29
  const { stdin } = useStdin();
20
30
  const [cursor, setCursor] = useState(value.length);
21
31
  // 粘贴内容存储
@@ -26,10 +36,23 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
26
36
  // 时间窗口粘贴检测:收集短时间内连续到达的数据块
27
37
  const batchBufferRef = useRef('');
28
38
  const batchTimerRef = useRef(null);
29
- // 启用 bracketed paste mode
39
+ // IME composing 缓冲:积累拼音字母,等 composing 结束后再一次性更新
40
+ // imeBufferRef: 当前正在 composing 的拼音字母
41
+ // imeTimerRef: composing 超时定时器,超时后将拼音作为普通文本提交
42
+ // imeInsertedLen: 已经临时插入到 value 中的 composing 文本长度(用于替换)
43
+ const imeBufferRef = useRef('');
44
+ const imeTimerRef = useRef(null);
45
+ const imeInsertedLenRef = useRef(0);
46
+ // 终端光标重定位定时器(用于 IME composing 位置修正)
47
+ const cursorRelocTimerRef = useRef(null);
48
+ // 启用 bracketed paste mode,并在激活时清空 stdin 缓冲区
30
49
  useEffect(() => {
31
50
  if (!stdin || !isActive)
32
51
  return;
52
+ // 清空 stdin 缓冲区,丢弃 processing 期间积累的输入
53
+ if (typeof stdin.read === 'function') {
54
+ while (stdin.read() !== null) { /* drain */ }
55
+ }
33
56
  process.stdout.write('\x1B[?2004h');
34
57
  return () => {
35
58
  process.stdout.write('\x1B[?2004l');
@@ -273,12 +296,72 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
273
296
  // 多行 → 粘贴
274
297
  if (cleaned.includes('\n') && cleaned.length > 1) {
275
298
  insertPaste(cleaned);
299
+ return;
276
300
  }
277
- else {
278
- // 单字符或单行短文本,按普通输入处理
301
+ // 检测是否为 IME composing 输入(连续小写字母 = 拼音)
302
+ if (isAsciiLowerAlpha(buf)) {
303
+ // 可能是拼音 composing,进入 IME 缓冲模式
304
+ imeBufferRef.current += buf;
305
+ // 不更新 value/不触发 re-render,只重置 IME 超时
306
+ if (imeTimerRef.current !== null) {
307
+ clearTimeout(imeTimerRef.current);
308
+ }
309
+ imeTimerRef.current = setTimeout(flushImeBuffer, IME_COMPOSE_WINDOW_MS);
310
+ return;
311
+ }
312
+ // 收到非 ASCII 字符(中文等):如果有 IME 缓冲,说明 composing 结束
313
+ if (hasNonAscii(buf) && imeBufferRef.current.length > 0) {
314
+ // 丢弃之前积累的拼音字母(它们只是 composing 中间态),
315
+ // 回退已临时插入的 composing 文本
316
+ if (imeTimerRef.current !== null) {
317
+ clearTimeout(imeTimerRef.current);
318
+ imeTimerRef.current = null;
319
+ }
320
+ const insertedLen = imeInsertedLenRef.current;
321
+ imeBufferRef.current = '';
322
+ imeInsertedLenRef.current = 0;
323
+ if (insertedLen > 0) {
324
+ // 回退之前临时插入的拼音
325
+ const v = valueRef.current;
326
+ const c = cursorRef.current;
327
+ const newVal = v.slice(0, c - insertedLen) + v.slice(c);
328
+ emitChange(newVal);
329
+ setCursor(c - insertedLen);
330
+ }
331
+ // 插入最终的中文字符
279
332
  handleNormalInput(buf);
333
+ return;
334
+ }
335
+ // 收到非拼音的 ASCII 可打印字符,且有 IME 缓冲:先提交 IME 缓冲
336
+ if (imeBufferRef.current.length > 0) {
337
+ commitImeBuffer();
338
+ }
339
+ // 单字符或单行短文本,按普通输入处理
340
+ handleNormalInput(buf);
341
+ };
342
+ /** 提交 IME 缓冲区中的拼音为普通文本(composing 超时或被打断时调用) */
343
+ const commitImeBuffer = () => {
344
+ if (imeTimerRef.current !== null) {
345
+ clearTimeout(imeTimerRef.current);
346
+ imeTimerRef.current = null;
347
+ }
348
+ const buf = imeBufferRef.current;
349
+ const insertedLen = imeInsertedLenRef.current;
350
+ imeBufferRef.current = '';
351
+ imeInsertedLenRef.current = 0;
352
+ if (!buf)
353
+ return;
354
+ // 需要插入的是 buf 中尚未被临时插入的部分
355
+ const remaining = buf.slice(insertedLen);
356
+ if (remaining.length > 0) {
357
+ handleNormalInput(remaining);
280
358
  }
281
359
  };
360
+ /** IME composing 超时:将积累的拼音作为普通文本提交 */
361
+ const flushImeBuffer = () => {
362
+ imeTimerRef.current = null;
363
+ commitImeBuffer();
364
+ };
282
365
  // Alt+Enter 组合键检测:ESC 可能单独到达,需要等待后续字符
283
366
  const escTimerRef = useRef(null);
284
367
  const ESC_WAIT_MS = 50; // 等待后续字符的时间窗口
@@ -350,6 +433,10 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
350
433
  }
351
434
  if (isSingleControl && batchBufferRef.current === '') {
352
435
  // 没有正在积累的缓冲,直接处理控制字符
436
+ // 如果有 IME 缓冲,先提交
437
+ if (imeBufferRef.current.length > 0) {
438
+ commitImeBuffer();
439
+ }
353
440
  handleNormalInput(raw);
354
441
  return;
355
442
  }
@@ -376,6 +463,10 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
376
463
  clearTimeout(escTimerRef.current);
377
464
  escTimerRef.current = null;
378
465
  }
466
+ if (imeTimerRef.current !== null) {
467
+ clearTimeout(imeTimerRef.current);
468
+ imeTimerRef.current = null;
469
+ }
379
470
  };
380
471
  }, [stdin, isActive]);
381
472
  // 组件卸载时清理
@@ -387,9 +478,61 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
387
478
  if (escTimerRef.current !== null) {
388
479
  clearTimeout(escTimerRef.current);
389
480
  }
481
+ if (imeTimerRef.current !== null) {
482
+ clearTimeout(imeTimerRef.current);
483
+ }
484
+ if (cursorRelocTimerRef.current !== null) {
485
+ clearTimeout(cursorRelocTimerRef.current);
486
+ }
390
487
  };
391
488
  }, []);
392
489
  useInput(() => { }, { isActive });
490
+ /**
491
+ * 将终端物理光标固定到当前行第 3 列,避免 IME composing 从行末溢出。
492
+ *
493
+ * 背景:ink 渲染完成后,终端光标停留在最后一行(StatusBar)末尾。
494
+ * macOS 终端模拟器的内联 IME 会在物理光标位置显示 composing 拼音,
495
+ * 若光标在行末会导致行宽溢出、TUI 布局被挤压偏移。
496
+ *
497
+ * 注意:只能移列,绝对不能移行(\x1B[1A 等)。
498
+ * ink 增量渲染以当前光标行为起点,移行后每次 re-render 都会导致
499
+ * TUI 整体上偏移一行(每次输入字符都触发 re-render)。
500
+ *
501
+ * 解决方案:仅将列定位到 3(❯ 占 2 列,输入框第一个字符从第 3 列开始),
502
+ * IME composing 文本从该列开始,不会从行末溢出。
503
+ */
504
+ useEffect(() => {
505
+ // 取消之前的定时器
506
+ if (cursorRelocTimerRef.current !== null) {
507
+ clearTimeout(cursorRelocTimerRef.current);
508
+ }
509
+ if (!showCursor || !isActive) {
510
+ // 非输入状态:将物理光标移到列 1(行首),避免 IME 候选框在可见区域弹出
511
+ cursorRelocTimerRef.current = setTimeout(() => {
512
+ cursorRelocTimerRef.current = null;
513
+ process.stdout.write('\x1B[1G');
514
+ }, 80);
515
+ }
516
+ else {
517
+ // 输入激活状态:
518
+ // 1. 上移 rowsBelow 行,到达输入框所在行
519
+ // 2. 将列定位到 3(❯ 占 2 列,输入框第一个字符从第 3 列开始)
520
+ // 3. 下移 rowsBelow 行,回到原来的光标行(ink 渲染起点不变)
521
+ // 这样 IME composing 显示在输入框行而非 StatusBar 行
522
+ cursorRelocTimerRef.current = setTimeout(() => {
523
+ cursorRelocTimerRef.current = null;
524
+ const up = rowsBelow > 0 ? `\x1B[${rowsBelow}A` : '';
525
+ const down = rowsBelow > 0 ? `\x1B[${rowsBelow}B` : '';
526
+ process.stdout.write(`${up}\x1B[3G${down}`);
527
+ }, 80);
528
+ }
529
+ return () => {
530
+ if (cursorRelocTimerRef.current !== null) {
531
+ clearTimeout(cursorRelocTimerRef.current);
532
+ cursorRelocTimerRef.current = null;
533
+ }
534
+ };
535
+ });
393
536
  // --- 渲染 ---
394
537
  const isEmpty = value.length === 0;
395
538
  if (isEmpty) {
@@ -398,6 +541,7 @@ export default function MultilineInput({ value, onChange, onSubmit, onUpArrow, o
398
541
  pasteCountRef.current = 0;
399
542
  pastedChunksRef.current.clear();
400
543
  }
544
+ // 更新光标位置信息(空输入时光标在起始位置)
401
545
  if (showCursor && placeholder.length > 0) {
402
546
  return (_jsxs(Box, { children: [_jsx(Text, { inverse: true, color: "white", children: placeholder[0] }), _jsx(Text, { color: "gray", children: placeholder.slice(1) }), _jsx(Text, { color: "gray", dimColor: true, children: " [Tab]" })] }));
403
547
  }
@@ -11,6 +11,8 @@ export interface ModelConfig {
11
11
  model: string;
12
12
  temperature?: number;
13
13
  max_tokens?: number;
14
+ /** 额外请求体参数,会直接合并到 API 请求 body 中(如 enable_thinking、chat_template_kwargs 等) */
15
+ extra_body?: Record<string, unknown>;
14
16
  }
15
17
  export interface SystemConfig {
16
18
  context_token_limit?: number;
@@ -15,13 +15,13 @@ export declare class QueryEngine {
15
15
  private service;
16
16
  private session;
17
17
  private transcript;
18
- private abortSignal;
18
+ private workerBridge;
19
19
  constructor();
20
20
  private createSession;
21
21
  private ensureSessionDir;
22
- /** 处理用户输入 */
22
+ /** 处理用户输入(在独立 Worker 线程中执行) */
23
23
  handleQuery(userInput: string, callbacks: EngineCallbacks): Promise<void>;
24
- /** 终止当前任务 */
24
+ /** 终止当前任务(通知 Worker 中断) */
25
25
  abort(): void;
26
26
  /** 重置会话 */
27
27
  reset(): void;
@@ -1,8 +1,7 @@
1
1
  import { v4 as uuid } from 'uuid';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import { getAllTools } from '../tools/index.js';
5
- import { executeQuery } from './query.js';
4
+ import { WorkerBridge } from './WorkerBridge.js';
6
5
  import { MockService } from '../services/api/mock.js';
7
6
  import { LLMServiceImpl } from '../services/api/llm.js';
8
7
  import { loadConfig, getActiveModel } from '../config/loader.js';
@@ -13,7 +12,7 @@ export class QueryEngine {
13
12
  service;
14
13
  session;
15
14
  transcript = [];
16
- abortSignal = { aborted: false };
15
+ workerBridge = new WorkerBridge();
17
16
  constructor() {
18
17
  // 尝试从配置文件加载 LLM,失败则回退 MockService
19
18
  const config = loadConfig();
@@ -47,9 +46,8 @@ export class QueryEngine {
47
46
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
48
47
  }
49
48
  }
50
- /** 处理用户输入 */
49
+ /** 处理用户输入(在独立 Worker 线程中执行) */
51
50
  async handleQuery(userInput, callbacks) {
52
- this.abortSignal = { aborted: false };
53
51
  const userMsg = {
54
52
  id: uuid(),
55
53
  type: 'user',
@@ -59,24 +57,25 @@ export class QueryEngine {
59
57
  };
60
58
  callbacks.onMessage(userMsg);
61
59
  this.session.messages.push(userMsg);
62
- const queryCallbacks = {
60
+ // 将回调包装后传给 WorkerBridge,Worker 事件会映射回这里
61
+ const bridgeCallbacks = {
63
62
  onMessage: (msg) => {
64
63
  callbacks.onMessage(msg);
65
64
  this.session.messages.push(msg);
66
65
  },
67
66
  onUpdateMessage: callbacks.onUpdateMessage,
68
67
  onStreamText: (text) => {
69
- // 每收到一个 chunk 视为一个 token,实时递增并通知 UI
70
68
  this.session.totalTokens++;
71
69
  callbacks.onStreamText(text);
72
70
  callbacks.onSessionUpdate(this.session);
73
71
  },
74
72
  onClearStreamText: callbacks.onClearStreamText,
75
73
  onLoopStateChange: callbacks.onLoopStateChange,
74
+ onSessionUpdate: callbacks.onSessionUpdate,
76
75
  onConfirmDangerousCommand: callbacks.onConfirmDangerousCommand,
77
76
  };
78
77
  try {
79
- this.transcript = await executeQuery(userInput, this.transcript, getAllTools(), this.service, queryCallbacks, this.abortSignal);
78
+ this.transcript = await this.workerBridge.run(userInput, this.transcript, bridgeCallbacks);
80
79
  }
81
80
  catch (err) {
82
81
  const errMsg = {
@@ -92,9 +91,9 @@ export class QueryEngine {
92
91
  callbacks.onSessionUpdate(this.session);
93
92
  this.saveSession();
94
93
  }
95
- /** 终止当前任务 */
94
+ /** 终止当前任务(通知 Worker 中断) */
96
95
  abort() {
97
- this.abortSignal.aborted = true;
96
+ this.workerBridge.abort();
98
97
  }
99
98
  /** 重置会话 */
100
99
  reset() {
@@ -182,6 +181,9 @@ export class QueryEngine {
182
181
  this.saveSession();
183
182
  // 恢复会话状态
184
183
  this.session = loaded;
184
+ // 过滤掉 pending 状态的 thinking 消息(上次会话 abort 时可能残留)
185
+ const cleanedMessages = loaded.messages.filter((m) => !(m.type === 'thinking' && m.status === 'pending'));
186
+ this.session.messages = cleanedMessages;
185
187
  // 从历史消息重建 transcript
186
188
  this.transcript = [];
187
189
  for (const msg of loaded.messages) {
@@ -208,7 +210,7 @@ export class QueryEngine {
208
210
  });
209
211
  }
210
212
  }
211
- return { session: this.session, messages: loaded.messages };
213
+ return { session: this.session, messages: cleanedMessages };
212
214
  }
213
215
  catch {
214
216
  return null;
@@ -0,0 +1,9 @@
1
+ import { TranscriptMessage } from '../types/index.js';
2
+ import { EngineCallbacks } from './QueryEngine.js';
3
+ export declare class WorkerBridge {
4
+ private worker;
5
+ /** 在独立 Worker 线程中执行查询,返回更新后的 transcript */
6
+ run(userInput: string, transcript: TranscriptMessage[], callbacks: EngineCallbacks): Promise<TranscriptMessage[]>;
7
+ /** 向 Worker 发送中断信号 */
8
+ abort(): void;
9
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * WorkerBridge — 主线程侧封装
3
+ * 负责创建/复用 Worker、转发回调事件、处理危险命令确认的双向通信
4
+ */
5
+ import { Worker } from 'worker_threads';
6
+ import { fileURLToPath } from 'url';
7
+ import path from 'path';
8
+ // 兼容 ESM __dirname
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ /**
12
+ * 构造 Worker 实例:
13
+ * - tsx 开发模式(__filename 以 .ts 结尾):用内联脚本通过 tsImport 加载 .ts
14
+ * - tsc 编译后(__filename 以 .js 结尾):直接加载 .js
15
+ */
16
+ function createWorker(workerTsPath) {
17
+ const isTsx = __filename.endsWith('.ts');
18
+ if (isTsx) {
19
+ // tsImport 第二个参数是 parent URL,用 file:// 绝对路径代替 import.meta.url
20
+ const inlineScript = `
21
+ import { tsImport } from 'tsx/esm/api';
22
+ import { workerData } from 'worker_threads';
23
+ import { pathToFileURL } from 'url';
24
+ await tsImport(workerData.__workerFile, pathToFileURL(workerData.__workerFile).href);
25
+ `;
26
+ return new Worker(inlineScript, {
27
+ eval: true,
28
+ workerData: { __workerFile: workerTsPath },
29
+ });
30
+ }
31
+ const jsPath = workerTsPath.replace(/\.ts$/, '.js');
32
+ return new Worker(jsPath);
33
+ }
34
+ export class WorkerBridge {
35
+ worker = null;
36
+ /** 在独立 Worker 线程中执行查询,返回更新后的 transcript */
37
+ run(userInput, transcript, callbacks) {
38
+ return new Promise((resolve, reject) => {
39
+ const workerTsPath = path.join(__dirname, 'queryWorker.ts');
40
+ const worker = createWorker(workerTsPath);
41
+ this.worker = worker;
42
+ worker.on('message', async (msg) => {
43
+ switch (msg.type) {
44
+ case 'message':
45
+ callbacks.onMessage(msg.msg);
46
+ break;
47
+ case 'update_message':
48
+ callbacks.onUpdateMessage(msg.id, msg.updates);
49
+ break;
50
+ case 'stream_text':
51
+ callbacks.onStreamText(msg.text);
52
+ break;
53
+ case 'clear_stream_text':
54
+ callbacks.onClearStreamText?.();
55
+ break;
56
+ case 'loop_state':
57
+ callbacks.onLoopStateChange(msg.state);
58
+ break;
59
+ case 'session_update':
60
+ callbacks.onSessionUpdate(msg.session);
61
+ break;
62
+ case 'danger_confirm_request': {
63
+ // 主线程弹出交互式确认,结果回传 Worker
64
+ const choice = callbacks.onConfirmDangerousCommand
65
+ ? await callbacks.onConfirmDangerousCommand(msg.command, msg.reason, msg.ruleName)
66
+ : 'cancel';
67
+ const reply = {
68
+ type: 'danger_confirm_result',
69
+ requestId: msg.requestId,
70
+ choice,
71
+ };
72
+ worker.postMessage(reply);
73
+ break;
74
+ }
75
+ case 'done':
76
+ this.worker = null;
77
+ worker.terminate();
78
+ resolve(msg.transcript);
79
+ break;
80
+ case 'error':
81
+ this.worker = null;
82
+ worker.terminate();
83
+ reject(new Error(msg.message));
84
+ break;
85
+ }
86
+ });
87
+ worker.on('error', (err) => {
88
+ this.worker = null;
89
+ reject(err);
90
+ });
91
+ worker.on('exit', (code) => {
92
+ if (code !== 0 && this.worker) {
93
+ this.worker = null;
94
+ reject(new Error(`Worker 异常退出,code=${code}`));
95
+ }
96
+ });
97
+ // 启动执行
98
+ const runMsg = { type: 'run', userInput, transcript };
99
+ worker.postMessage(runMsg);
100
+ });
101
+ }
102
+ /** 向 Worker 发送中断信号 */
103
+ abort() {
104
+ if (this.worker) {
105
+ const msg = { type: 'abort' };
106
+ this.worker.postMessage(msg);
107
+ }
108
+ }
109
+ }
@@ -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>;