@raolin2025/claude-code-node 2.7.0 → 2.7.2
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.
- package/README.md +25 -30
- package/package.json +1 -1
- package/src/__tests__/multiline-input.test.js +243 -0
- package/src/__tests__/npm-publish.test.js +84 -0
- package/src/channel/tg-listener.js +81 -1
- package/src/core/cli.js +81 -16
- package/src/core/multiline-input.js +301 -0
- package/src/core/query-engine.js +6 -0
- package/src/tools/ask-user.js +16 -2
- package/src/tools/index.js +2 -0
- package/src/tools/npm-publish.js +371 -0
package/src/core/cli.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* v1.2: 增加 Unix socket 服务,让 cc-notify 能发现并转发消息
|
|
6
6
|
*/
|
|
7
7
|
import * as readline from 'readline'
|
|
8
|
+
import { createMultilineInput } from './multiline-input.js'
|
|
8
9
|
import { createServer as createNetServer } from 'net'
|
|
9
10
|
import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync, chmodSync } from 'fs'
|
|
10
11
|
import { join } from 'path'
|
|
@@ -527,7 +528,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
527
528
|
engine.config.onConfirmTool = async () => true
|
|
528
529
|
}
|
|
529
530
|
const result = await engine.processMessage(cliArgs.oneShot)
|
|
530
|
-
|
|
531
|
+
// 引擎已流式输出正文(无 onDelta 时引擎内部直写终端)→ 不再重复打印,避免"回答两次"
|
|
532
|
+
if (!engine.lastStreamed) console.log(result.response)
|
|
531
533
|
// 保存会话
|
|
532
534
|
session = await sessionManager.create(`one-shot: ${cliArgs.oneShot.slice(0, 50)}`)
|
|
533
535
|
await sessionManager.appendMessage({ role: 'user', content: cliArgs.oneShot })
|
|
@@ -545,19 +547,37 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
545
547
|
startSocketServer(engine, session, sessionManager, channelManager, verbose)
|
|
546
548
|
|
|
547
549
|
// ============================================================
|
|
548
|
-
// REPL 输入处理
|
|
550
|
+
// REPL 输入处理 — 多行输入(读取完整输入再处理)
|
|
549
551
|
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
// - Enter
|
|
554
|
-
// -
|
|
552
|
+
// 不再用 readline 的 line 事件(它遇到 \n 就提交当前行,导致
|
|
553
|
+
// 多行文本被断句,后续行在引擎忙时被丢弃)。
|
|
554
|
+
// 改用 keypress + raw mode 自己管理输入缓冲(见 multiline-input.js):
|
|
555
|
+
// - Enter(\r)→ 提交整段输入(含内嵌换行)
|
|
556
|
+
// - Ctrl+Enter / Alt+Enter / Ctrl+J → 折行,多行输入(不提交)
|
|
557
|
+
// - 可打印字符回显、退格、上下方向键历史、Ctrl+C
|
|
558
|
+
// - 非 TTY(管道/重定向)回退到 readline line 事件
|
|
555
559
|
// ============================================================
|
|
556
|
-
const
|
|
560
|
+
const inputCtrl = createMultilineInput({
|
|
561
|
+
prompt: '> ',
|
|
562
|
+
onSubmit: (text) => { processInputLine(text) },
|
|
563
|
+
onExit: () => { process.exit(0) },
|
|
564
|
+
})
|
|
557
565
|
|
|
558
566
|
// 显示提示符
|
|
559
567
|
function showPrompt() {
|
|
560
|
-
|
|
568
|
+
inputCtrl.showPrompt()
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 单行问题收集(权限确认 / /models 选择 / AskUserQuestion)
|
|
572
|
+
// 返回 Promise<string>,在 keypress 模式下与多行输入共用同一 stdin,
|
|
573
|
+
// 避免 readline 的 question 与主循环 keypress 互相干扰。
|
|
574
|
+
function askQuestion(questionText) {
|
|
575
|
+
return inputCtrl.ask(questionText)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// 兼容对象:供 AskUserQuestion 工具和引擎的 ctx.readline.question() 使用
|
|
579
|
+
const rl = {
|
|
580
|
+
question: (q, cb) => { askQuestion(q).then(cb) },
|
|
561
581
|
}
|
|
562
582
|
|
|
563
583
|
// Telegram 双向通道(可选):tgListener 监听 Telegram 消息,tgChatId 记录回复目标
|
|
@@ -568,10 +588,14 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
568
588
|
let currentSource = 'cli'
|
|
569
589
|
// 等待中的 Telegram 权限确认(resolve 回调)— 远程用户回复 y/n/a 时响应
|
|
570
590
|
let pendingConfirm = null
|
|
591
|
+
// 等待中的 Telegram AskUserQuestion 回答(resolve 回调)— 远程用户回复任意文本时响应
|
|
592
|
+
let pendingAskUser = null
|
|
571
593
|
// 最近一次 /models 拉取的模型列表(供 /model <编号> 选择)
|
|
572
594
|
let modelList = []
|
|
573
595
|
// /models 从 Telegram 发出后,等待用户回复编号/名字来选择模型
|
|
574
596
|
let pendingModelSelect = false
|
|
597
|
+
// 流式输出标志 — onDelta 已实时把回答输出到终端时,不再重复打印完整 response
|
|
598
|
+
let streamedText = false
|
|
575
599
|
|
|
576
600
|
// 处理输入行
|
|
577
601
|
// source: 'cli' 来自终端输入, 'telegram' 来自 Telegram
|
|
@@ -585,6 +609,13 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
585
609
|
// 关键:来自 Telegram 的消息,如果当前正在等待远程权限确认(pendingConfirm),
|
|
586
610
|
// 则把它当作确认回复(y/n/a)处理,而不是当作新的 REPL 输入。
|
|
587
611
|
// 注意:/ 开头的命令优先走命令分支,不被权限确认拦截(否则 /quit、/stop 会被吞)。
|
|
612
|
+
// 优先处理 pendingAskUser(AskUserQuestion 需要任意文本答案,且通常是用户正在等待的问题)。
|
|
613
|
+
if (source === 'telegram' && pendingAskUser && !trimmed.startsWith('/')) {
|
|
614
|
+
const ask = pendingAskUser
|
|
615
|
+
pendingAskUser = null
|
|
616
|
+
ask(trimmed)
|
|
617
|
+
return
|
|
618
|
+
}
|
|
588
619
|
if (source === 'telegram' && pendingConfirm && !trimmed.startsWith('/')) {
|
|
589
620
|
const confirm = pendingConfirm
|
|
590
621
|
pendingConfirm = null
|
|
@@ -867,9 +898,15 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
867
898
|
}
|
|
868
899
|
break
|
|
869
900
|
}
|
|
870
|
-
case 'exit': case 'quit':
|
|
901
|
+
case 'exit': case 'quit': {
|
|
902
|
+
// 退出前确认消费 Telegram update(避免 /quit 等命令残留在服务器缓冲区,
|
|
903
|
+
// 下次启动重放导致死循环起不来)。配合 tg-listener 的 offset 持久化双保险。
|
|
904
|
+
if (tgListener?.bot) {
|
|
905
|
+
try { await tgListener.flushOffset() } catch {}
|
|
906
|
+
}
|
|
871
907
|
console.log('Goodbye!')
|
|
872
908
|
process.exit(0)
|
|
909
|
+
}
|
|
873
910
|
default:
|
|
874
911
|
console.log(`Unknown command: /${cmd}. Type /help for available commands.`)
|
|
875
912
|
}
|
|
@@ -903,7 +940,11 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
903
940
|
// 处理结束,清掉思维链流(最终回复随后发送)
|
|
904
941
|
tgThinkingEnd()
|
|
905
942
|
console.log()
|
|
906
|
-
|
|
943
|
+
// 正文已被 onDelta 实时流式输出到终端 → 不重复打印(否则出现"回答两次"的双层显示)。
|
|
944
|
+
// 仅当没有流式输出(noStream / 流式失败 / 无 onDelta 回调)时才打印完整 response。
|
|
945
|
+
if (!engine.lastStreamed && !streamedText) {
|
|
946
|
+
console.log(result.response)
|
|
947
|
+
}
|
|
907
948
|
console.log()
|
|
908
949
|
await sessionManager.appendMessage({ role: 'user', content: input })
|
|
909
950
|
await sessionManager.appendMessage({ role: 'assistant', content: result.response })
|
|
@@ -1025,12 +1066,11 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
1025
1066
|
tgThinking.flushing = false
|
|
1026
1067
|
}
|
|
1027
1068
|
|
|
1028
|
-
// REPL 主循环 — 由
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
})
|
|
1069
|
+
// REPL 主循环 — 由 multiline-input 处理多行输入(keypress / 非TTY readline)
|
|
1070
|
+
// 输入由 createMultilineInput 在创建时挂接,start() 显示初始提示符
|
|
1071
|
+
inputCtrl.start()
|
|
1032
1072
|
|
|
1033
|
-
// 将 readline
|
|
1073
|
+
// 将 readline 兼容对象注入引擎配置,用于 ask 模式确认和 AskUserQuestion 工具
|
|
1034
1074
|
if (permissionMode === 'ask') {
|
|
1035
1075
|
engine.config.onConfirmTool = async (toolName, input) => {
|
|
1036
1076
|
// 如果已经启用会话全局自动允许,直接通过
|
|
@@ -1071,6 +1111,29 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
1071
1111
|
}
|
|
1072
1112
|
engine.config.readline = rl
|
|
1073
1113
|
|
|
1114
|
+
// AskUserQuestion 工具回调 — 按当前来源分流:
|
|
1115
|
+
// - Telegram 远程模式:推送问题到 Telegram 并等待远程回复(带超时兜底,避免死锁)
|
|
1116
|
+
// - CLI 本地模式:走本地终端交互(inputCtrl.ask)
|
|
1117
|
+
engine.config.onAskUser = (question) => {
|
|
1118
|
+
if (currentSource === 'telegram' && tgListener?.bot) {
|
|
1119
|
+
const promptText = `❓ ${question}\n\n请直接回复你的回答。`
|
|
1120
|
+
sendTelegram(promptText, null).catch(() => {})
|
|
1121
|
+
return new Promise((resolve) => {
|
|
1122
|
+
// 60 秒内未回复则自动跳过,避免远程提问永久挂起阻塞引擎
|
|
1123
|
+
const timer = setTimeout(() => {
|
|
1124
|
+
if (pendingAskUser === resolve) pendingAskUser = null
|
|
1125
|
+
resolve('(用户未在 60 秒内回答)')
|
|
1126
|
+
}, 60000)
|
|
1127
|
+
pendingAskUser = (val) => {
|
|
1128
|
+
clearTimeout(timer)
|
|
1129
|
+
resolve(val)
|
|
1130
|
+
}
|
|
1131
|
+
})
|
|
1132
|
+
}
|
|
1133
|
+
// CLI 本地模式
|
|
1134
|
+
return askQuestion(`❓ ${question}\n> `)
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1074
1137
|
// ============================================================
|
|
1075
1138
|
// Telegram 双向通道启动(可选)
|
|
1076
1139
|
// 配置了 CC_NODE_CHANNEL_TELEGRAM_TOKEN 或 config 中 telegram 时启用:
|
|
@@ -1146,6 +1209,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
1146
1209
|
engine.config.onDelta = ({ type, text }) => {
|
|
1147
1210
|
// 保持 CLI 实时输出
|
|
1148
1211
|
process.stdout.write(text)
|
|
1212
|
+
// 记录已有文本被实时输出到终端(供 processInputLine 判断是否需重复打印 response)
|
|
1213
|
+
if (type === 'text' && text) streamedText = true
|
|
1149
1214
|
// 无论 text 还是 reasoning,都实时推送到 Telegram(节流)
|
|
1150
1215
|
tgThinkingPush(text)
|
|
1151
1216
|
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// multiline-input.js — REPL 多行输入(读取完整输入再处理)
|
|
3
|
+
// ------------------------------------------------------------
|
|
4
|
+
// 解决「输入缓存未读完就按 \n 断句」的根因问题:
|
|
5
|
+
// readline 的 line 事件遇到 \n 就提交当前行,导致多行文本
|
|
6
|
+
// 被拆成多段,且后续行在引擎忙时被丢弃。
|
|
7
|
+
//
|
|
8
|
+
// 本模块改用 keypress + raw mode 自己管理输入缓冲:
|
|
9
|
+
// - Enter(\r / CR) → 提交整段输入(含内嵌换行)
|
|
10
|
+
// - Ctrl+Enter / Alt+Enter / Ctrl+J → 折行,累积到缓冲,不提交(多行输入)
|
|
11
|
+
// - 可打印字符 → 回显并累积
|
|
12
|
+
// - 退格 → 删除并回显
|
|
13
|
+
// - 上/下方向键 → 浏览输入历史
|
|
14
|
+
// - Ctrl+C → 清空 / 退出
|
|
15
|
+
// - 非 TTY(管道/重定向) → 回退到 readline line 事件
|
|
16
|
+
//
|
|
17
|
+
// 接口:
|
|
18
|
+
// createMultilineInput({ prompt, onSubmit, onExit, stdin, stdout })
|
|
19
|
+
// -> { start(), showPrompt(), ask(questionText), dispose() }
|
|
20
|
+
// onSubmit(text) 完整输入(可能含 \n)提交回调
|
|
21
|
+
// ask(qText) 单行问题收集(返回 Promise<string>)
|
|
22
|
+
// ============================================================
|
|
23
|
+
|
|
24
|
+
import * as readline from 'readline'
|
|
25
|
+
|
|
26
|
+
export function createMultilineInput({ prompt = '> ', onSubmit, onExit, stdin, stdout } = {}) {
|
|
27
|
+
const input = stdin || process.stdin
|
|
28
|
+
const output = stdout || process.stdout
|
|
29
|
+
const isTTY = input.isTTY
|
|
30
|
+
|
|
31
|
+
let inputBuffer = '' // 当前多行输入缓冲
|
|
32
|
+
let inputHistory = [] // 输入历史
|
|
33
|
+
let historyIndex = -1 // 历史浏览索引(-1 = 编辑新输入)
|
|
34
|
+
let skipNextLF = false // CRLF 提交后忽略残留 \n
|
|
35
|
+
let questioning = false // 是否正在收集单行问题(权限确认等)
|
|
36
|
+
let questionResolve = null
|
|
37
|
+
let questionBuf = ''
|
|
38
|
+
let escPending = false // 刚收到独立 ESC 事件(某些终端把 Alt+Enter 拆成 ESC 和 Enter 两个事件)
|
|
39
|
+
let escTimer = null // ESC 后跟 Enter 的超时还原定时器
|
|
40
|
+
|
|
41
|
+
const PROMPT = prompt
|
|
42
|
+
let displayedRows = 0 // 当前输入区在终端实际占用的屏幕行数(含提示符行,考虑换行与CJK宽字符)
|
|
43
|
+
|
|
44
|
+
// ============================================================
|
|
45
|
+
// 非 TTY 模式:回退到 readline line 事件(管道/重定向)
|
|
46
|
+
// ============================================================
|
|
47
|
+
if (!isTTY) {
|
|
48
|
+
const rl = readline.createInterface({ input, output, prompt })
|
|
49
|
+
return {
|
|
50
|
+
start() { rl.on('line', (line) => onSubmit(line)); rl.prompt() },
|
|
51
|
+
showPrompt() { rl.prompt() },
|
|
52
|
+
ask(q) {
|
|
53
|
+
return new Promise((resolve) => { rl.question(q + ' ', resolve) })
|
|
54
|
+
},
|
|
55
|
+
dispose() { rl.close() },
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================================
|
|
60
|
+
// TTY 模式:keypress + raw mode 多行输入
|
|
61
|
+
// ============================================================
|
|
62
|
+
readline.emitKeypressEvents(input)
|
|
63
|
+
try { input.setRawMode(true) } catch {}
|
|
64
|
+
|
|
65
|
+
// 终端列数(缺省 80)
|
|
66
|
+
function cols() {
|
|
67
|
+
return (output.columns && output.columns > 0) ? output.columns : 80
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 单字符在终端占用的列宽(CJK/全角 = 2,其余 = 1)
|
|
71
|
+
function charWidth(ch) {
|
|
72
|
+
const code = ch.codePointAt(0)
|
|
73
|
+
if (
|
|
74
|
+
(code >= 0x1100 && code <= 0x115F) ||
|
|
75
|
+
(code >= 0x2E80 && code <= 0x303E) ||
|
|
76
|
+
(code >= 0x3041 && code <= 0x33FF) ||
|
|
77
|
+
(code >= 0x3400 && code <= 0x4DBF) ||
|
|
78
|
+
(code >= 0x4E00 && code <= 0x9FFF) ||
|
|
79
|
+
(code >= 0xA000 && code <= 0xA4CF) ||
|
|
80
|
+
(code >= 0xAC00 && code <= 0xD7A3) ||
|
|
81
|
+
(code >= 0xF900 && code <= 0xFAFF) ||
|
|
82
|
+
(code >= 0xFE30 && code <= 0xFE4F) ||
|
|
83
|
+
(code >= 0xFF00 && code <= 0xFF60) ||
|
|
84
|
+
(code >= 0xFFE0 && code <= 0xFFE6)
|
|
85
|
+
) return 2
|
|
86
|
+
return 1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 文本可见列宽(按 CJK 宽字符计数)
|
|
90
|
+
function visibleWidth(text) {
|
|
91
|
+
let w = 0
|
|
92
|
+
for (const ch of text) w += charWidth(ch)
|
|
93
|
+
return w
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 渲染文本占用的终端屏幕行数(考虑自动换行;每逻辑行至少 1 行)
|
|
97
|
+
function renderedRows(text) {
|
|
98
|
+
const c = cols()
|
|
99
|
+
let rows = 0
|
|
100
|
+
for (const segment of text.split('\n')) {
|
|
101
|
+
const w = visibleWidth(segment)
|
|
102
|
+
rows += Math.max(1, Math.ceil(w / c))
|
|
103
|
+
}
|
|
104
|
+
return rows
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 生成要显示的文本(提示符 + 输入缓冲;换行后按提示符宽度缩进)
|
|
108
|
+
function displayText() {
|
|
109
|
+
return PROMPT + inputBuffer.replace(/\n/g, '\n' + ' '.repeat(PROMPT.length))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 清空并重绘整个输入区(保证回显稳定、光标不乱跳)
|
|
113
|
+
// - 先把光标移回输入区首行行首
|
|
114
|
+
// - 清到屏尾,重绘全部内容
|
|
115
|
+
// - 重绘后光标自然停在输入末尾
|
|
116
|
+
function render() {
|
|
117
|
+
// 上移到输入区首行(若是多行);单行时不动
|
|
118
|
+
if (displayedRows > 1) output.write(`\x1b[${displayedRows - 1}A`)
|
|
119
|
+
output.write('\r') // 回到首行行首
|
|
120
|
+
output.write('\x1b[J') // 清到屏尾
|
|
121
|
+
|
|
122
|
+
const rendered = displayText()
|
|
123
|
+
output.write(rendered)
|
|
124
|
+
displayedRows = renderedRows(rendered)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function showPrompt() {
|
|
128
|
+
output.write(PROMPT)
|
|
129
|
+
displayedRows = 1
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function submit() {
|
|
133
|
+
const inputText = inputBuffer
|
|
134
|
+
inputBuffer = ''
|
|
135
|
+
skipNextLF = true
|
|
136
|
+
displayedRows = 0
|
|
137
|
+
output.write('\n')
|
|
138
|
+
if (inputText.trim()) {
|
|
139
|
+
inputHistory.push(inputText)
|
|
140
|
+
historyIndex = inputHistory.length
|
|
141
|
+
}
|
|
142
|
+
if (onSubmit) onSubmit(inputText)
|
|
143
|
+
else showPrompt()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function loadHistory(dir) {
|
|
147
|
+
if (inputHistory.length === 0) return
|
|
148
|
+
if (dir === -1) {
|
|
149
|
+
if (historyIndex <= 0) { historyIndex = 0; inputBuffer = inputHistory[0]; render(); return }
|
|
150
|
+
historyIndex--
|
|
151
|
+
} else {
|
|
152
|
+
if (historyIndex >= inputHistory.length) return
|
|
153
|
+
historyIndex++
|
|
154
|
+
}
|
|
155
|
+
inputBuffer = historyIndex < inputHistory.length ? inputHistory[historyIndex] : ''
|
|
156
|
+
render()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function onKeypress(str, key) {
|
|
160
|
+
if (!key) return
|
|
161
|
+
|
|
162
|
+
// 正在收集单行问题(权限确认 / AskUserQuestion)
|
|
163
|
+
if (questioning) {
|
|
164
|
+
if (key.ctrl && key.name === 'c') {
|
|
165
|
+
output.write('\n')
|
|
166
|
+
const r = questionResolve; questionResolve = null; questioning = false
|
|
167
|
+
if (r) r('')
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
171
|
+
output.write('\n')
|
|
172
|
+
const ans = questionBuf; questionBuf = ''
|
|
173
|
+
const r = questionResolve; questionResolve = null; questioning = false
|
|
174
|
+
if (r) r(ans)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (key.name === 'backspace') {
|
|
178
|
+
questionBuf = questionBuf.slice(0, -1)
|
|
179
|
+
output.write('\b \b')
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (str && key.name !== 'enter') {
|
|
183
|
+
questionBuf += str
|
|
184
|
+
output.write(str)
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Ctrl+C
|
|
190
|
+
if (key.ctrl && key.name === 'c') {
|
|
191
|
+
if (inputBuffer) {
|
|
192
|
+
inputBuffer = ''
|
|
193
|
+
displayedRows = 0
|
|
194
|
+
output.write('\n')
|
|
195
|
+
showPrompt()
|
|
196
|
+
} else {
|
|
197
|
+
output.write('\nGoodbye!\n')
|
|
198
|
+
if (onExit) onExit()
|
|
199
|
+
}
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ESC 键 — 单独出现(未与后续字符合并)时,标记 escPending,
|
|
204
|
+
// 下一个 Enter 视为 Alt+Enter → 折行(兼容部分终端把 Alt+Enter 拆成两个事件)
|
|
205
|
+
if (key.name === 'escape') {
|
|
206
|
+
escPending = true
|
|
207
|
+
// 清掉 pending 状态:若后续 300ms 无 Enter 则还原,避免误把普通 Enter 当折行
|
|
208
|
+
if (escTimer) clearTimeout(escTimer)
|
|
209
|
+
escTimer = setTimeout(() => { escPending = false }, 400)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Enter / 换行
|
|
214
|
+
//
|
|
215
|
+
// 提交 vs 折行判定:
|
|
216
|
+
// - 普通 Enter(\r,无 ctrl/meta) → 提交整段输入
|
|
217
|
+
// - Ctrl+Enter(部分终端发送 ctrl+return) → 折行(多行输入)
|
|
218
|
+
// - Alt+Enter / Esc+Enter(meta+return) → 折行(跨终端可靠)
|
|
219
|
+
// - Ctrl+J / 字面 \n(key.name === enter) → 折行
|
|
220
|
+
// - 某些终端的 Ctrl/Alt+Enter 发送 CSI 序列 \x1b[13~(key.name === 'f3')→ 折行
|
|
221
|
+
//
|
|
222
|
+
// 注意:多数终端按下 Ctrl+Enter 发送的仍是 \r,与普通 Enter 无法区分,
|
|
223
|
+
// 因此跨终端可靠的多行换行键是 Alt+Enter(Esc+Enter)和 Ctrl+J(\n)。两者都支持。
|
|
224
|
+
if (key.name === 'return' || key.name === 'enter' || key.name === 'f3') {
|
|
225
|
+
// 紧跟 ESC 的 Enter → 视为 Alt+Enter,折行
|
|
226
|
+
if (escPending) {
|
|
227
|
+
escPending = false
|
|
228
|
+
if (escTimer) { clearTimeout(escTimer); escTimer = null }
|
|
229
|
+
inputBuffer += '\n'
|
|
230
|
+
render()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
// CSI 序列 \x1b[13~(某些终端的 Ctrl/Alt+Enter)→ 折行
|
|
234
|
+
if (key.name === 'f3') {
|
|
235
|
+
inputBuffer += '\n'
|
|
236
|
+
render()
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
if (key.name === 'enter') {
|
|
240
|
+
// 字面换行 \n / Ctrl+J → 折行累积
|
|
241
|
+
if (skipNextLF) { skipNextLF = false; return } // CRLF 残留 \n
|
|
242
|
+
inputBuffer += '\n'
|
|
243
|
+
render()
|
|
244
|
+
} else {
|
|
245
|
+
// key.name === 'return'(\r)
|
|
246
|
+
if (key.ctrl || key.meta) {
|
|
247
|
+
// Ctrl+Enter 或 Alt+Enter/Esc+Enter → 折行(多行输入)
|
|
248
|
+
if (skipNextLF) { skipNextLF = false; return }
|
|
249
|
+
inputBuffer += '\n'
|
|
250
|
+
render()
|
|
251
|
+
} else {
|
|
252
|
+
submit() // 普通 Enter → 提交
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 非 Enter 键输入会清除 escPending(ESC 后跟普通字符不是 Alt+Enter)
|
|
259
|
+
if (escPending && str) {
|
|
260
|
+
escPending = false
|
|
261
|
+
if (escTimer) { clearTimeout(escTimer); escTimer = null }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 退格
|
|
265
|
+
if (key.name === 'backspace') {
|
|
266
|
+
if (inputBuffer.length > 0) {
|
|
267
|
+
inputBuffer = inputBuffer.slice(0, -1)
|
|
268
|
+
render()
|
|
269
|
+
}
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 方向键上/下:历史
|
|
274
|
+
if (key.name === 'up') { loadHistory(-1); return }
|
|
275
|
+
if (key.name === 'down') { loadHistory(1); return }
|
|
276
|
+
|
|
277
|
+
// 普通可打印字符(含中文,str 为完整字符)
|
|
278
|
+
if (str && !key.ctrl && !key.meta) {
|
|
279
|
+
inputBuffer += str
|
|
280
|
+
render()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
input.on('keypress', onKeypress)
|
|
285
|
+
|
|
286
|
+
function ask(q) {
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
output.write('\n' + q + ' ')
|
|
289
|
+
questioning = true
|
|
290
|
+
questionResolve = resolve
|
|
291
|
+
questionBuf = ''
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function dispose() {
|
|
296
|
+
try { input.setRawMode(false) } catch {}
|
|
297
|
+
input.removeListener('keypress', onKeypress)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { start: showPrompt, showPrompt, ask, dispose }
|
|
301
|
+
}
|
package/src/core/query-engine.js
CHANGED
|
@@ -44,6 +44,7 @@ export class QueryEngineConfig {
|
|
|
44
44
|
this.tokenBudget = options.tokenBudget || null
|
|
45
45
|
this.initialMessages = options.initialMessages || []
|
|
46
46
|
this.onConfirmTool = options.onConfirmTool || null // ask 模式确认回调
|
|
47
|
+
this.onAskUser = options.onAskUser || null // AskUserQuestion 工具回调(宿主按来源分流,避免远程死锁)
|
|
47
48
|
this.readline = options.readline || null // 用于 AskUserQuestion 工具
|
|
48
49
|
this.onDelta = options.onDelta || null // 流式增量回调 {type:'text'|'reasoning', text}(供 VS Code 扩展等 UI 消费)
|
|
49
50
|
}
|
|
@@ -63,6 +64,8 @@ export class QueryEngine {
|
|
|
63
64
|
this.abortController = null
|
|
64
65
|
this.costTracker = this.config.costTracker || new CostTracker({ model: this.config.model })
|
|
65
66
|
this.tokenBudget = this.config.tokenBudget || null
|
|
67
|
+
// 最近一次 processMessage 是否已通过流式回调/直写把正文输出(供调用方避免重复打印 response)
|
|
68
|
+
this.lastStreamed = false
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/**
|
|
@@ -76,6 +79,7 @@ export class QueryEngine {
|
|
|
76
79
|
}
|
|
77
80
|
this.state.isRunning = true
|
|
78
81
|
this.state.turnCount++
|
|
82
|
+
this.lastStreamed = false // 本轮是否已流式输出正文
|
|
79
83
|
this.abortController = new AbortController()
|
|
80
84
|
const userMsg = new UserMessage(userInput, images)
|
|
81
85
|
this.state.messages.push(userMsg)
|
|
@@ -405,6 +409,8 @@ export class QueryEngine {
|
|
|
405
409
|
try {
|
|
406
410
|
for await (const event of parseStream(response)) {
|
|
407
411
|
if (event.type === 'text') {
|
|
412
|
+
// 记录已流式输出正文(供调用方避免重复打印最终 response)
|
|
413
|
+
if (event.text) this.lastStreamed = true
|
|
408
414
|
// 实时输出(有 onDelta 回调时交给调用方,如 VS Code 扩展;否则写终端)
|
|
409
415
|
if (typeof this.config.onDelta === 'function') {
|
|
410
416
|
this.config.onDelta({ type: 'text', text: event.text })
|
package/src/tools/ask-user.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AskUserQuestion 工具 — 向用户提问
|
|
3
3
|
* 对应原版: src/tools/AskUserQuestionTool/
|
|
4
|
+
*
|
|
5
|
+
* 交互策略(按优先级):
|
|
6
|
+
* 1. ctx.engine.config.onAskUser — 由宿主(cli.js)注入,按当前来源(CLI/Telegram)分流,
|
|
7
|
+
* Telegram 模式推送问题到远程并等待回复(带超时兜底),CLI 模式走本地终端交互。
|
|
8
|
+
* 这是最完善的路径,避免远程模式下本地阻塞导致引擎死锁。
|
|
9
|
+
* 2. ctx.readline — 回退:本地 CLI 终端 question。
|
|
10
|
+
* 3. 都不存在 — 返回提示信息(不阻塞)。
|
|
4
11
|
*/
|
|
5
12
|
import { ToolDef } from '../types/index.js'
|
|
6
13
|
|
|
@@ -19,7 +26,13 @@ Use this when you need clarification or user input to proceed.`,
|
|
|
19
26
|
required: ['question'],
|
|
20
27
|
},
|
|
21
28
|
async (input, ctx) => {
|
|
22
|
-
//
|
|
29
|
+
// 优先级 1:宿主注入的 onAskUser(处理 CLI / Telegram 分流,含远程等待与超时兜底)
|
|
30
|
+
const onAskUser = ctx?.engine?.config?.onAskUser
|
|
31
|
+
if (typeof onAskUser === 'function') {
|
|
32
|
+
return onAskUser(input.question)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 优先级 2:CLI 模式,通过 readline 接口提问
|
|
23
36
|
if (ctx?.readline) {
|
|
24
37
|
return new Promise((resolve) => {
|
|
25
38
|
ctx.readline.question(`\n❓ ${input.question}\n> `, (answer) => {
|
|
@@ -27,7 +40,8 @@ Use this when you need clarification or user input to proceed.`,
|
|
|
27
40
|
})
|
|
28
41
|
})
|
|
29
42
|
}
|
|
30
|
-
|
|
43
|
+
|
|
44
|
+
// 优先级 3:非 CLI 模式 — 返回提示信息,不阻塞
|
|
31
45
|
return `[AskUserQuestion: ${input.question} (no interactive terminal available)]`
|
|
32
46
|
},
|
|
33
47
|
'always-allow'
|
package/src/tools/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { webFetchTool } from './web-fetch.js'
|
|
|
11
11
|
import { webSearchTool } from './web-search.js'
|
|
12
12
|
import { askUserTool } from './ask-user.js'
|
|
13
13
|
import { gitTool } from './git-tool.js'
|
|
14
|
+
import { npmPublishTool } from './npm-publish.js'
|
|
14
15
|
// Telegram 工具(替代原 QQ Bot 工具,QQ 通道已放弃)
|
|
15
16
|
import { telegramTools } from './telegram-tools.js'
|
|
16
17
|
|
|
@@ -28,6 +29,7 @@ export const builtinTools = [
|
|
|
28
29
|
webSearchTool,
|
|
29
30
|
askUserTool,
|
|
30
31
|
gitTool,
|
|
32
|
+
npmPublishTool,
|
|
31
33
|
...telegramTools
|
|
32
34
|
]
|
|
33
35
|
|