@raolin2025/claude-code-node 2.7.0 → 2.7.1
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/package.json +1 -1
- package/src/__tests__/multiline-input.test.js +243 -0
- package/src/channel/tg-listener.js +81 -1
- package/src/core/cli.js +49 -16
- package/src/core/multiline-input.js +301 -0
- package/src/core/query-engine.js +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raolin2025/claude-code-node",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.1",
|
|
4
4
|
"description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/core/index.js",
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert'
|
|
3
|
+
import { PassThrough, Writable } from 'node:stream'
|
|
4
|
+
import { createMultilineInput } from '../core/multiline-input.js'
|
|
5
|
+
|
|
6
|
+
// 创建一个模拟 TTY 的输入流和一个收集 stdout 的输出流
|
|
7
|
+
function makeEnv() {
|
|
8
|
+
const input = new PassThrough()
|
|
9
|
+
input.isTTY = true
|
|
10
|
+
input.setRawMode = () => {}
|
|
11
|
+
const output = new Writable({ write(c, _e, cb) { this.buf += c.toString(); cb() } })
|
|
12
|
+
output.buf = ''
|
|
13
|
+
return { input, output }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 让事件循环跑一下,等待异步提交
|
|
17
|
+
const tick = () => new Promise((r) => setTimeout(r, 30))
|
|
18
|
+
|
|
19
|
+
test('多行输入被完整提交(含内嵌换行,不被断句)', async () => {
|
|
20
|
+
const { input, output } = makeEnv()
|
|
21
|
+
const submitted = []
|
|
22
|
+
const ctrl = createMultilineInput({
|
|
23
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
24
|
+
onSubmit: (t) => submitted.push(t),
|
|
25
|
+
})
|
|
26
|
+
ctrl.start()
|
|
27
|
+
|
|
28
|
+
input.write('第一行')
|
|
29
|
+
input.write('\n') // 文本换行 → 折行,不提交
|
|
30
|
+
input.write('第二行')
|
|
31
|
+
input.write('\r') // Enter → 提交
|
|
32
|
+
|
|
33
|
+
await tick()
|
|
34
|
+
assert.deepStrictEqual(submitted, ['第一行\n第二行'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('CRLF 提交后残留 \\n 被忽略,不产生脏输入', async () => {
|
|
38
|
+
const { input, output } = makeEnv()
|
|
39
|
+
const submitted = []
|
|
40
|
+
const ctrl = createMultilineInput({
|
|
41
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
42
|
+
onSubmit: (t) => submitted.push(t),
|
|
43
|
+
})
|
|
44
|
+
ctrl.start()
|
|
45
|
+
|
|
46
|
+
input.write('A段')
|
|
47
|
+
input.write('\r\n') // CRLF
|
|
48
|
+
input.write('B段')
|
|
49
|
+
input.write('\r')
|
|
50
|
+
|
|
51
|
+
await tick()
|
|
52
|
+
assert.deepStrictEqual(submitted, ['A段', 'B段'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('连续多条输入互不干扰,各自完整', async () => {
|
|
56
|
+
const { input, output } = makeEnv()
|
|
57
|
+
const submitted = []
|
|
58
|
+
const ctrl = createMultilineInput({
|
|
59
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
60
|
+
onSubmit: (t) => submitted.push(t),
|
|
61
|
+
})
|
|
62
|
+
ctrl.start()
|
|
63
|
+
|
|
64
|
+
input.write('文章一\n带换行')
|
|
65
|
+
input.write('\r')
|
|
66
|
+
input.write('文章二')
|
|
67
|
+
input.write('\r')
|
|
68
|
+
|
|
69
|
+
await tick()
|
|
70
|
+
assert.deepStrictEqual(submitted, ['文章一\n带换行', '文章二'])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('上方向键调出输入历史', async () => {
|
|
74
|
+
const { input, output } = makeEnv()
|
|
75
|
+
const submitted = []
|
|
76
|
+
const ctrl = createMultilineInput({
|
|
77
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
78
|
+
onSubmit: (t) => submitted.push(t),
|
|
79
|
+
})
|
|
80
|
+
ctrl.start()
|
|
81
|
+
|
|
82
|
+
input.write('历史输入')
|
|
83
|
+
input.write('\r')
|
|
84
|
+
input.write('\x1b[A') // 上方向键
|
|
85
|
+
input.write('\r')
|
|
86
|
+
|
|
87
|
+
await tick()
|
|
88
|
+
assert.deepStrictEqual(submitted, ['历史输入', '历史输入'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('ask() 单行问题返回用户回答', async () => {
|
|
92
|
+
const { input, output } = makeEnv()
|
|
93
|
+
const ctrl = createMultilineInput({
|
|
94
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
95
|
+
})
|
|
96
|
+
ctrl.start()
|
|
97
|
+
|
|
98
|
+
const p = ctrl.ask('是否允许? (y/N)')
|
|
99
|
+
input.write('y')
|
|
100
|
+
input.write('\r')
|
|
101
|
+
|
|
102
|
+
const ans = await p
|
|
103
|
+
assert.strictEqual(ans, 'y')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('ask() 在提问期间不污染主输入缓冲', async () => {
|
|
107
|
+
const { input, output } = makeEnv()
|
|
108
|
+
const submitted = []
|
|
109
|
+
const ctrl = createMultilineInput({
|
|
110
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
111
|
+
onSubmit: (t) => submitted.push(t),
|
|
112
|
+
})
|
|
113
|
+
ctrl.start()
|
|
114
|
+
|
|
115
|
+
const p = ctrl.ask('选一个? ')
|
|
116
|
+
input.write('x')
|
|
117
|
+
input.write('\r')
|
|
118
|
+
await p
|
|
119
|
+
|
|
120
|
+
// 提问结束后,主输入缓冲应为空,输入新内容提交正常
|
|
121
|
+
input.write('新输入')
|
|
122
|
+
input.write('\r')
|
|
123
|
+
await tick()
|
|
124
|
+
assert.deepStrictEqual(submitted, ['新输入'])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('单行输入回显稳定:不发出光标上移\\x1b[1A(防止乱跳)', async () => {
|
|
128
|
+
const { input, output } = makeEnv()
|
|
129
|
+
output.columns = 80
|
|
130
|
+
const ctrl = createMultilineInput({
|
|
131
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
132
|
+
onSubmit: () => {},
|
|
133
|
+
})
|
|
134
|
+
ctrl.start()
|
|
135
|
+
|
|
136
|
+
input.write('hello')
|
|
137
|
+
await tick()
|
|
138
|
+
|
|
139
|
+
const raw = output.buf
|
|
140
|
+
// 单行输入时绝不出现光标上移序列(会导致屏幕乱跳)
|
|
141
|
+
assert.ok(!raw.includes('\x1b[1A'), `单行输入不应上移光标,实际输出: ${JSON.stringify(raw)}`)
|
|
142
|
+
// 应通过 \r + 清屏 + 重绘实现回显
|
|
143
|
+
assert.ok(raw.includes('\x1b[J'), '应有清屏序列')
|
|
144
|
+
// 最终内容应完整显示
|
|
145
|
+
assert.ok(raw.includes('> hello'), '输入内容应完整回显')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('多行输入回显稳定:重绘只上移到首行一次,无多余跳转', async () => {
|
|
149
|
+
const { input, output } = makeEnv()
|
|
150
|
+
output.columns = 80
|
|
151
|
+
const ctrl = createMultilineInput({
|
|
152
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
153
|
+
onSubmit: () => {},
|
|
154
|
+
})
|
|
155
|
+
ctrl.start()
|
|
156
|
+
|
|
157
|
+
input.write('第一行')
|
|
158
|
+
input.write('\n') // 折行
|
|
159
|
+
input.write('第二行')
|
|
160
|
+
await tick()
|
|
161
|
+
|
|
162
|
+
const raw = output.buf
|
|
163
|
+
// 折行后输入第二行时,应只发出一次 \x1b[1A(上移到首行)+ 清屏 + 重绘
|
|
164
|
+
// 不应出现 \x1b[1B(下移)或 \x1b[nC 这类多余跳转组合
|
|
165
|
+
assert.ok(!raw.includes('\x1b[1B'), '不应有光标下移跳转')
|
|
166
|
+
// 内容应包含两行完整回显
|
|
167
|
+
assert.ok(raw.includes('> 第一行'), '第一行应回显')
|
|
168
|
+
assert.ok(raw.includes('第二行'), '第二行应回显')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('Alt+Enter(Esc+Enter)折行、普通 Enter 提交 — 跨终端可靠多行输入', async () => {
|
|
172
|
+
const { input, output } = makeEnv()
|
|
173
|
+
const submitted = []
|
|
174
|
+
const ctrl = createMultilineInput({
|
|
175
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
176
|
+
onSubmit: (t) => submitted.push(t),
|
|
177
|
+
})
|
|
178
|
+
ctrl.start()
|
|
179
|
+
|
|
180
|
+
// 第一行 + Alt+Enter(\x1b\r → meta+return)折行
|
|
181
|
+
input.write('第一行')
|
|
182
|
+
input.write('\x1b\r')
|
|
183
|
+
// 第二行 + 普通 Enter 提交
|
|
184
|
+
input.write('第二行')
|
|
185
|
+
input.write('\r')
|
|
186
|
+
|
|
187
|
+
await tick()
|
|
188
|
+
assert.deepStrictEqual(submitted, ['第一行\n第二行'])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('普通 Enter 仍是提交(单行输入不受影响)', async () => {
|
|
192
|
+
const { input, output } = makeEnv()
|
|
193
|
+
const submitted = []
|
|
194
|
+
const ctrl = createMultilineInput({
|
|
195
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
196
|
+
onSubmit: (t) => submitted.push(t),
|
|
197
|
+
})
|
|
198
|
+
ctrl.start()
|
|
199
|
+
|
|
200
|
+
input.write('hello world')
|
|
201
|
+
input.write('\r')
|
|
202
|
+
|
|
203
|
+
await tick()
|
|
204
|
+
assert.deepStrictEqual(submitted, ['hello world'])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('独立 ESC 事件 + Enter(终端把 Alt+Enter 拆成两事件)也折行', async () => {
|
|
208
|
+
const { input, output } = makeEnv()
|
|
209
|
+
const submitted = []
|
|
210
|
+
const ctrl = createMultilineInput({
|
|
211
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
212
|
+
onSubmit: (t) => submitted.push(t),
|
|
213
|
+
})
|
|
214
|
+
ctrl.start()
|
|
215
|
+
|
|
216
|
+
// 模拟终端把 Alt+Enter 拆成独立 ESC 事件和独立 \r 事件(分两次写入)
|
|
217
|
+
input.write('第一行')
|
|
218
|
+
input.write('\x1b') // 独立 ESC 事件
|
|
219
|
+
input.write('\r') // 随后的 Enter → 应视为 Alt+Enter 折行
|
|
220
|
+
input.write('第二行')
|
|
221
|
+
input.write('\r') // 普通 Enter 提交
|
|
222
|
+
|
|
223
|
+
await tick()
|
|
224
|
+
assert.deepStrictEqual(submitted, ['第一行\n第二行'])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('CSI 序列 \\x1b[13~(部分终端的 Ctrl/Alt+Enter)折行', async () => {
|
|
228
|
+
const { input, output } = makeEnv()
|
|
229
|
+
const submitted = []
|
|
230
|
+
const ctrl = createMultilineInput({
|
|
231
|
+
stdin: input, stdout: output, prompt: '> ',
|
|
232
|
+
onSubmit: (t) => submitted.push(t),
|
|
233
|
+
})
|
|
234
|
+
ctrl.start()
|
|
235
|
+
|
|
236
|
+
input.write('第一行')
|
|
237
|
+
input.write('\x1b[13~') // CSI 序列 → f3 → 折行
|
|
238
|
+
input.write('第二行')
|
|
239
|
+
input.write('\r') // 普通 Enter 提交
|
|
240
|
+
|
|
241
|
+
await tick()
|
|
242
|
+
assert.deepStrictEqual(submitted, ['第一行\n第二行'])
|
|
243
|
+
})
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
// MarkdownV2 安全编码
|
|
17
17
|
// ============================================================
|
|
18
18
|
|
|
19
|
+
// 静态导入用于 offset 持久化(ESM 顶层导入,可在构造函数内同步使用)
|
|
20
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs'
|
|
21
|
+
import { homedir } from 'os'
|
|
22
|
+
import { join, dirname } from 'path'
|
|
23
|
+
|
|
19
24
|
const TG_MD_ESCAPE_CHARS = /[_*[\]()~`>#+\-=|{}.!]/g
|
|
20
25
|
const TG_CODE_ESCAPE_CHARS = /[`\\]/g
|
|
21
26
|
const TG_LINK_ESCAPE_CHARS = /[()]/g
|
|
@@ -270,7 +275,8 @@ export class TelegramListener {
|
|
|
270
275
|
this.proxyAddr = ch.proxy || process.env.CC_NODE_CHANNEL_TELEGRAM_PROXY || ''
|
|
271
276
|
this.apiBase = ch.apiBase || ''
|
|
272
277
|
this.bot = this.token ? new TelegramBotClient(this.token, { proxy: this.proxyAddr, apiBase: this.apiBase }) : null
|
|
273
|
-
|
|
278
|
+
// 从磁盘恢复持久化的 offset,避免进程重启后重放旧消息(含 /quit 等历史命令)
|
|
279
|
+
this.lastUpdateId = this._loadOffset()
|
|
274
280
|
this.running = false
|
|
275
281
|
this._pollTimer = null
|
|
276
282
|
this._retryDelay = 1000
|
|
@@ -350,6 +356,9 @@ export class TelegramListener {
|
|
|
350
356
|
if (data.result?.length) {
|
|
351
357
|
for (const update of data.result) {
|
|
352
358
|
this.lastUpdateId = Math.max(this.lastUpdateId, update.update_id)
|
|
359
|
+
// 推进 offset 后立即持久化到磁盘,保证进程异常退出(如 /quit)
|
|
360
|
+
// 重启后从正确位置继续,不重放旧消息。
|
|
361
|
+
this._saveOffset()
|
|
353
362
|
|
|
354
363
|
// 处理回调查询(按钮点击)— 快速操作,直接 await
|
|
355
364
|
if (update.callback_query) {
|
|
@@ -638,6 +647,77 @@ export class TelegramListener {
|
|
|
638
647
|
_sleep(ms) {
|
|
639
648
|
return new Promise(r => setTimeout(r, ms))
|
|
640
649
|
}
|
|
650
|
+
|
|
651
|
+
// ============================================================
|
|
652
|
+
// Offset 持久化 — 防止进程重启后重放旧消息(含 /quit 等)
|
|
653
|
+
// Telegram 的 getUpdates 用 offset 确认消费;若 offset 只存内存,
|
|
654
|
+
// 进程异常退出(如 /quit 的 process.exit)后重启 lastUpdateId 归零,
|
|
655
|
+
// 会重放服务器上所有未确认的 update,导致 /quit 死循环起不来。
|
|
656
|
+
// 将 offset 持久化到磁盘,重启后从正确位置继续,彻底根治此问题。
|
|
657
|
+
// ============================================================
|
|
658
|
+
|
|
659
|
+
/** offset 状态文件路径 */
|
|
660
|
+
_offsetFile() {
|
|
661
|
+
return join(homedir(), '.cc-node', 'tg-offset.json')
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** 从磁盘加载已持久化的 offset */
|
|
665
|
+
_loadOffset() {
|
|
666
|
+
try {
|
|
667
|
+
const file = this._offsetFile()
|
|
668
|
+
if (!existsSync(file)) return 0
|
|
669
|
+
const raw = readFileSync(file, 'utf8').trim()
|
|
670
|
+
if (!raw) return 0
|
|
671
|
+
const data = JSON.parse(raw)
|
|
672
|
+
const id = Number(data.lastUpdateId) || 0
|
|
673
|
+
if (id > 0) log(`[TG] Restored offset ${id} (persisted, skip replayed updates)`)
|
|
674
|
+
return id
|
|
675
|
+
} catch {
|
|
676
|
+
return 0
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** 持久化当前 offset 到磁盘 */
|
|
681
|
+
_saveOffset() {
|
|
682
|
+
try {
|
|
683
|
+
const file = this._offsetFile()
|
|
684
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
685
|
+
writeFileSync(file, JSON.stringify({ lastUpdateId: this.lastUpdateId, updatedAt: Date.now() }), 'utf8')
|
|
686
|
+
} catch (e) {
|
|
687
|
+
log(`[TG] Failed to persist offset: ${e.message}`)
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* 退出前确认消费已接收的 update(双保险)。
|
|
693
|
+
* 正常路径下 next poll 会提交 offset,但 /quit 等命令走 process.exit
|
|
694
|
+
* 同步强杀进程,没有机会再 poll 一次 → 未确认的 update 残留在服务器。
|
|
695
|
+
* 调用 getUpdates(offset=lastUpdateId+1) 可让 Telegram 立即确认消费。
|
|
696
|
+
* 返回 Promise,调用方应 await 后再退出。
|
|
697
|
+
*/
|
|
698
|
+
async flushOffset() {
|
|
699
|
+
if (!this.bot) return
|
|
700
|
+
try {
|
|
701
|
+
const url = `${this.bot.apiBase}/getUpdates`
|
|
702
|
+
const res = await this._fetch(url, {
|
|
703
|
+
method: 'POST',
|
|
704
|
+
headers: { 'Content-Type': 'application/json' },
|
|
705
|
+
timeout: 10000,
|
|
706
|
+
body: JSON.stringify({
|
|
707
|
+
offset: this.lastUpdateId + 1,
|
|
708
|
+
timeout: 0,
|
|
709
|
+
allowed_updates: ['message', 'callback_query', 'edited_message'],
|
|
710
|
+
}),
|
|
711
|
+
})
|
|
712
|
+
if (res.ok) {
|
|
713
|
+
const data = await res.json()
|
|
714
|
+
if (data.ok) {
|
|
715
|
+
log(`[TG] Flushed offset ${this.lastUpdateId + 1} before exit`)
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
} catch {}
|
|
720
|
+
}
|
|
641
721
|
}
|
|
642
722
|
|
|
643
723
|
// ============================================================
|
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 记录回复目标
|
|
@@ -572,6 +592,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
572
592
|
let modelList = []
|
|
573
593
|
// /models 从 Telegram 发出后,等待用户回复编号/名字来选择模型
|
|
574
594
|
let pendingModelSelect = false
|
|
595
|
+
// 流式输出标志 — onDelta 已实时把回答输出到终端时,不再重复打印完整 response
|
|
596
|
+
let streamedText = false
|
|
575
597
|
|
|
576
598
|
// 处理输入行
|
|
577
599
|
// source: 'cli' 来自终端输入, 'telegram' 来自 Telegram
|
|
@@ -867,9 +889,15 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
867
889
|
}
|
|
868
890
|
break
|
|
869
891
|
}
|
|
870
|
-
case 'exit': case 'quit':
|
|
892
|
+
case 'exit': case 'quit': {
|
|
893
|
+
// 退出前确认消费 Telegram update(避免 /quit 等命令残留在服务器缓冲区,
|
|
894
|
+
// 下次启动重放导致死循环起不来)。配合 tg-listener 的 offset 持久化双保险。
|
|
895
|
+
if (tgListener?.bot) {
|
|
896
|
+
try { await tgListener.flushOffset() } catch {}
|
|
897
|
+
}
|
|
871
898
|
console.log('Goodbye!')
|
|
872
899
|
process.exit(0)
|
|
900
|
+
}
|
|
873
901
|
default:
|
|
874
902
|
console.log(`Unknown command: /${cmd}. Type /help for available commands.`)
|
|
875
903
|
}
|
|
@@ -903,7 +931,11 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
903
931
|
// 处理结束,清掉思维链流(最终回复随后发送)
|
|
904
932
|
tgThinkingEnd()
|
|
905
933
|
console.log()
|
|
906
|
-
|
|
934
|
+
// 正文已被 onDelta 实时流式输出到终端 → 不重复打印(否则出现"回答两次"的双层显示)。
|
|
935
|
+
// 仅当没有流式输出(noStream / 流式失败 / 无 onDelta 回调)时才打印完整 response。
|
|
936
|
+
if (!engine.lastStreamed && !streamedText) {
|
|
937
|
+
console.log(result.response)
|
|
938
|
+
}
|
|
907
939
|
console.log()
|
|
908
940
|
await sessionManager.appendMessage({ role: 'user', content: input })
|
|
909
941
|
await sessionManager.appendMessage({ role: 'assistant', content: result.response })
|
|
@@ -1025,12 +1057,11 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
1025
1057
|
tgThinking.flushing = false
|
|
1026
1058
|
}
|
|
1027
1059
|
|
|
1028
|
-
// REPL 主循环 — 由
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
})
|
|
1060
|
+
// REPL 主循环 — 由 multiline-input 处理多行输入(keypress / 非TTY readline)
|
|
1061
|
+
// 输入由 createMultilineInput 在创建时挂接,start() 显示初始提示符
|
|
1062
|
+
inputCtrl.start()
|
|
1032
1063
|
|
|
1033
|
-
// 将 readline
|
|
1064
|
+
// 将 readline 兼容对象注入引擎配置,用于 ask 模式确认和 AskUserQuestion 工具
|
|
1034
1065
|
if (permissionMode === 'ask') {
|
|
1035
1066
|
engine.config.onConfirmTool = async (toolName, input) => {
|
|
1036
1067
|
// 如果已经启用会话全局自动允许,直接通过
|
|
@@ -1146,6 +1177,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
1146
1177
|
engine.config.onDelta = ({ type, text }) => {
|
|
1147
1178
|
// 保持 CLI 实时输出
|
|
1148
1179
|
process.stdout.write(text)
|
|
1180
|
+
// 记录已有文本被实时输出到终端(供 processInputLine 判断是否需重复打印 response)
|
|
1181
|
+
if (type === 'text' && text) streamedText = true
|
|
1149
1182
|
// 无论 text 还是 reasoning,都实时推送到 Telegram(节流)
|
|
1150
1183
|
tgThinkingPush(text)
|
|
1151
1184
|
}
|
|
@@ -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
|
@@ -63,6 +63,8 @@ export class QueryEngine {
|
|
|
63
63
|
this.abortController = null
|
|
64
64
|
this.costTracker = this.config.costTracker || new CostTracker({ model: this.config.model })
|
|
65
65
|
this.tokenBudget = this.config.tokenBudget || null
|
|
66
|
+
// 最近一次 processMessage 是否已通过流式回调/直写把正文输出(供调用方避免重复打印 response)
|
|
67
|
+
this.lastStreamed = false
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
/**
|
|
@@ -76,6 +78,7 @@ export class QueryEngine {
|
|
|
76
78
|
}
|
|
77
79
|
this.state.isRunning = true
|
|
78
80
|
this.state.turnCount++
|
|
81
|
+
this.lastStreamed = false // 本轮是否已流式输出正文
|
|
79
82
|
this.abortController = new AbortController()
|
|
80
83
|
const userMsg = new UserMessage(userInput, images)
|
|
81
84
|
this.state.messages.push(userMsg)
|
|
@@ -405,6 +408,8 @@ export class QueryEngine {
|
|
|
405
408
|
try {
|
|
406
409
|
for await (const event of parseStream(response)) {
|
|
407
410
|
if (event.type === 'text') {
|
|
411
|
+
// 记录已流式输出正文(供调用方避免重复打印最终 response)
|
|
412
|
+
if (event.text) this.lastStreamed = true
|
|
408
413
|
// 实时输出(有 onDelta 回调时交给调用方,如 VS Code 扩展;否则写终端)
|
|
409
414
|
if (typeof this.config.onDelta === 'function') {
|
|
410
415
|
this.config.onDelta({ type: 'text', text: event.text })
|