@raolin2025/claude-code-node 2.5.2 → 2.6.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 +33 -2
- package/package.json +2 -2
- package/src/__tests__/llm-server.test.js +62 -0
- package/src/channel/notify-daemon.js +105 -18
- package/src/channel/qqbot-listener.js +3 -7
- package/src/channel/tg-listener.js +1 -1
- package/src/channel/tg-proxy.js +105 -32
- package/src/core/cli.js +220 -58
- package/src/core/index.js +2 -0
- package/src/core/query-engine.js +40 -9
- package/src/stdio/server.js +426 -0
- package/src/tools/index.js +3 -3
- package/src/tools/telegram-tools.js +319 -0
- package/src/types/index.js +6 -1
- package/src/utils/index.js +1 -0
- package/src/utils/llm-server.js +106 -0
- package/src/tools/qqbot-channel-api.js +0 -147
- package/src/tools/qqbot-media.js +0 -177
- package/src/tools/qqbot-remind.js +0 -42
- package/src/tools/qqbot-tools-wrapper.js +0 -185
package/src/core/cli.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* v1.2: 增加 Unix socket 服务,让 cc-notify 能发现并转发消息
|
|
6
6
|
*/
|
|
7
|
-
import
|
|
7
|
+
import * as readline from 'readline'
|
|
8
8
|
import { createServer as createNetServer } from 'net'
|
|
9
9
|
import { writeFileSync, unlinkSync, existsSync, mkdirSync, readFileSync, chmodSync } from 'fs'
|
|
10
10
|
import { join } from 'path'
|
|
@@ -16,7 +16,10 @@ import { TokenBudget } from './token-budget.js'
|
|
|
16
16
|
import { ChannelManager } from '../channel/index.js'
|
|
17
17
|
import { CostTracker } from './cost-tracker.js'
|
|
18
18
|
import { autoCompact } from './compact.js'
|
|
19
|
+
import { isLocalLlmServer } from '../utils/index.js'
|
|
19
20
|
import { SOCK_DIR, SOCK_PATH, CC_NODE_PID } from './paths.js'
|
|
21
|
+
import { TelegramListener } from '../channel/tg-listener.js'
|
|
22
|
+
import { fetchViaSocks5 } from '../channel/tg-proxy.js'
|
|
20
23
|
|
|
21
24
|
// ============================================================
|
|
22
25
|
// Unix Socket — 让 cc-notify 能发现 cc-node
|
|
@@ -314,7 +317,7 @@ const DETAILED_HELP = {
|
|
|
314
317
|
|
|
315
318
|
compact: "/compact\n Manually trigger context compression.\n Compresses the conversation history to fit within the token budget.\n Keeps recent turns intact, compresses older ones.\n\n Typically triggered automatically at 80% budget usage.",
|
|
316
319
|
|
|
317
|
-
cd: "/cd <path>\n Change the working directory of cc-node.\n Affects all subsequent tool executions (Bash, Read, Write, etc.).\n\n Without path: show the current working directory.\n\n Example: /cd /home/
|
|
320
|
+
cd: "/cd <path>\n Change the working directory of cc-node.\n Affects all subsequent tool executions (Bash, Read, Write, etc.).\n\n Without path: show the current working directory.\n\n Example: /cd /home/yourname/projects\n Example: /cd ..",
|
|
318
321
|
|
|
319
322
|
allow: "/allow [tool|all|reset]\n Manage tool permissions for this session.\n\n Options:\n <tool> — allow a specific tool (e.g. Bash, Write, Read)\n all — automatically allow ALL remaining tools for this session\n reset — reset to ask mode (ask for each tool)\n (no arg) — same as /allow all\n\n When asked to confirm a tool, you can also type:\n y — allow this once\n a — allow all for the rest of the session\n\n Example: /allow Bash\n Example: /allow all\n Example: /allow reset",
|
|
320
323
|
|
|
@@ -351,6 +354,8 @@ function parseArgs(argv) {
|
|
|
351
354
|
case '--resume': case '-r': args.resume = argv[++i]; break
|
|
352
355
|
case '--verbose': case '-v': args.verbose = true; break
|
|
353
356
|
case '--no-stream': args.noStream = true; break
|
|
357
|
+
case '--stdio': args.stdio = true; break
|
|
358
|
+
case '--with-notify': args.withNotify = true; break
|
|
354
359
|
case '--version':
|
|
355
360
|
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
356
361
|
console.log(pkg.version)
|
|
@@ -369,6 +374,8 @@ Options:
|
|
|
369
374
|
--version Show version
|
|
370
375
|
-v, --verbose Verbose mode
|
|
371
376
|
--no-stream Disable streaming
|
|
377
|
+
--with-notify Start built-in channel listener (Telegram)
|
|
378
|
+
(replaces cc-notify daemon — no external script needed)
|
|
372
379
|
-h, --help Show this help
|
|
373
380
|
|
|
374
381
|
Environment variables:
|
|
@@ -404,19 +411,35 @@ Unix Socket (for cc-notify):
|
|
|
404
411
|
export async function main() {
|
|
405
412
|
const cliArgs = parseArgs(process.argv)
|
|
406
413
|
|
|
414
|
+
// P1: stdio 服务器模式(JSON-RPC 2.0 over NDJSON,供桥接层/外部客户端接入)
|
|
415
|
+
if (cliArgs.stdio) {
|
|
416
|
+
const { StdioServer } = await import('../stdio/server.js')
|
|
417
|
+
const server = new StdioServer({ cliArgs })
|
|
418
|
+
server.start()
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
|
|
407
422
|
const config = new Config()
|
|
408
423
|
await config.load(process.cwd())
|
|
409
424
|
|
|
410
425
|
const apiBase = cliArgs.apiBase || config.get('apiBase') || process.env.LLM_API_BASE || ''
|
|
426
|
+
// apiBase 指向自建本地服务(Ollama / llama.cpp / vLLM 等)时允许缺省 apiKey
|
|
427
|
+
const localApiBase = isLocalLlmServer(apiBase)
|
|
411
428
|
let model = cliArgs.model || config.get('model') || ''
|
|
412
429
|
// 未指定模型 → 自动从 API 拉取模型列表让用户选择
|
|
430
|
+
// 本地自建服务即使无 key 也尝试拉取(这些服务通常无需认证)
|
|
413
431
|
if (!model && apiBase) {
|
|
414
432
|
const apiKeyForModels = cliArgs.apiKey || config.get('apiKey') || process.env.LLM_API_KEY || process.env.DEEPSEEK_API_KEY || ''
|
|
415
|
-
if (apiKeyForModels) {
|
|
433
|
+
if (apiKeyForModels || localApiBase) {
|
|
416
434
|
try {
|
|
417
435
|
const modelsUrl = apiBase.replace(/\/+$/, '') + '/models'
|
|
418
436
|
console.log('⚠️ 未指定模型,正在从 API 获取可用模型列表...')
|
|
419
|
-
const res = await fetch(modelsUrl, {
|
|
437
|
+
const res = await fetch(modelsUrl, {
|
|
438
|
+
headers: {
|
|
439
|
+
'Content-Type': 'application/json',
|
|
440
|
+
...(apiKeyForModels ? { 'Authorization': `Bearer ${apiKeyForModels}` } : {}),
|
|
441
|
+
},
|
|
442
|
+
})
|
|
420
443
|
if (res.ok) {
|
|
421
444
|
const data = await res.json()
|
|
422
445
|
const models = data.data || []
|
|
@@ -428,7 +451,7 @@ export async function main() {
|
|
|
428
451
|
})
|
|
429
452
|
console.log('输入编号选择,或直接输入模型名(回车跳过用 deepseek-chat):')
|
|
430
453
|
// 用 readline 等待输入(此时 REPL 还没启动,需要临时创建)
|
|
431
|
-
const tmpRl = createInterface({ input: process.stdin, output: process.stdout })
|
|
454
|
+
const tmpRl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
432
455
|
const answer = await new Promise(resolve => tmpRl.question('> ', resolve))
|
|
433
456
|
tmpRl.close()
|
|
434
457
|
const num = parseInt(answer, 10)
|
|
@@ -541,52 +564,35 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
541
564
|
// REPL 模式 — 启动 Unix socket 让 cc-notify 能发现
|
|
542
565
|
startSocketServer(engine, session, sessionManager, channelManager, verbose)
|
|
543
566
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
resolve(true)
|
|
559
|
-
} else {
|
|
560
|
-
resolve(a === 'y')
|
|
561
|
-
}
|
|
562
|
-
})
|
|
563
|
-
})
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
engine.config.readline = rl
|
|
567
|
-
|
|
568
|
-
console.log(buildBanner({ model, permissionMode, session, maxTokens: tokenBudget.maxTokens }))
|
|
569
|
-
console.log(`Model: ${model} | Permission: ${permissionMode} | Tools: ${registry.getNames().join(', ')}`)
|
|
570
|
-
console.log(`Socket: ${SOCK_PATH} (cc-notify can connect)`)
|
|
571
|
-
if (channelManager.list().length > 0) {
|
|
572
|
-
const chList = channelManager.list().join(', ')
|
|
573
|
-
const def = channelManager.defaultChannel ? ` (default: ${channelManager.defaultChannel})` : ''
|
|
574
|
-
console.log(`Channels: ${chList}${def}`)
|
|
567
|
+
// ============================================================
|
|
568
|
+
// REPL 输入处理
|
|
569
|
+
//
|
|
570
|
+
// 使用标准 readline 的 line 事件(terminal: true)实现输入读取:
|
|
571
|
+
// - 字符回显、退格删除、行回绕、方向键历史由 readline 原生处理,
|
|
572
|
+
// 行为与 v2.5 一致,避免手动 keypress 回显导致的显示错乱。
|
|
573
|
+
// - Enter 提交;Ctrl+C 退出。
|
|
574
|
+
// - 非 TTY(管道/重定向)模式复用同一 readline 接口。
|
|
575
|
+
// ============================================================
|
|
576
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
|
|
577
|
+
|
|
578
|
+
// 显示提示符
|
|
579
|
+
function showPrompt() {
|
|
580
|
+
rl.prompt()
|
|
575
581
|
}
|
|
576
|
-
console.log()
|
|
577
|
-
rl.prompt()
|
|
578
582
|
|
|
579
|
-
//
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
+
// Telegram 双向通道(可选):tgListener 监听 Telegram 消息,tgChatId 记录回复目标
|
|
584
|
+
let tgListener = null
|
|
585
|
+
let tgChatId = null
|
|
586
|
+
let tgReplyTarget = null
|
|
583
587
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
588
|
+
// 处理输入行
|
|
589
|
+
// source: 'cli' 来自终端输入, 'telegram' 来自 Telegram
|
|
590
|
+
async function processInputLine(input, source = 'cli', tgChatId = null) {
|
|
591
|
+
const trimmed = input.trim()
|
|
592
|
+
if (!trimmed) { showPrompt(); return }
|
|
587
593
|
|
|
588
|
-
if (
|
|
589
|
-
const [cmd, ...rest] =
|
|
594
|
+
if (trimmed.startsWith('/')) {
|
|
595
|
+
const [cmd, ...rest] = trimmed.slice(1).split(' ')
|
|
590
596
|
switch (cmd) {
|
|
591
597
|
case 'help':
|
|
592
598
|
if (rest[0]) {
|
|
@@ -604,11 +610,18 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
604
610
|
case 'models': {
|
|
605
611
|
const apiBase = engine.config.apiBase
|
|
606
612
|
const apiKey = engine.config.apiKey
|
|
607
|
-
|
|
613
|
+
// 自建本地服务(Ollama/llama.cpp/vLLM)无需 apiKey 也能拉取模型列表
|
|
614
|
+
const localServer = isLocalLlmServer(apiBase)
|
|
615
|
+
if (!apiKey && !localServer) { console.log('❌ API key not configured'); break }
|
|
608
616
|
const modelsUrl = apiBase.replace(/\/+$/, '') + '/models'
|
|
609
617
|
console.log(`📡 Fetching models from ${modelsUrl}...`)
|
|
610
618
|
try {
|
|
611
|
-
const res = await fetch(modelsUrl, {
|
|
619
|
+
const res = await fetch(modelsUrl, {
|
|
620
|
+
headers: {
|
|
621
|
+
'Content-Type': 'application/json',
|
|
622
|
+
...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}),
|
|
623
|
+
},
|
|
624
|
+
})
|
|
612
625
|
if (!res.ok) { console.log(`❌ API error: ${res.status}`); break }
|
|
613
626
|
const data = await res.json()
|
|
614
627
|
const models = data.data || []
|
|
@@ -656,7 +669,6 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
656
669
|
}
|
|
657
670
|
const target = rest.join(' ')
|
|
658
671
|
let sessionId = target
|
|
659
|
-
// 支持数字序号选择(从 /sessions 列表中)
|
|
660
672
|
if (/^\d+$/.test(target)) {
|
|
661
673
|
const idx = parseInt(target, 10) - 1
|
|
662
674
|
const list = await sessionManager.list()
|
|
@@ -671,19 +683,16 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
671
683
|
console.log(`❌ Session not found: ${sessionId}`)
|
|
672
684
|
break
|
|
673
685
|
}
|
|
674
|
-
// 恢复会话内容
|
|
675
686
|
session.id = loaded.id
|
|
676
687
|
session.title = loaded.title
|
|
677
688
|
session.messages = loaded.messages
|
|
678
689
|
session.state = loaded.state || {}
|
|
679
|
-
// 恢复引擎消息历史
|
|
680
690
|
engine.state.messages = loaded.messages.map(m => ({
|
|
681
691
|
role: m.role,
|
|
682
692
|
content: m.content,
|
|
683
693
|
toolCalls: m.toolCalls,
|
|
684
694
|
toolCallId: m.toolCallId,
|
|
685
695
|
}))
|
|
686
|
-
// 恢复计数和预算
|
|
687
696
|
engine.state.turnCount = loaded.state?.turnCount || 0
|
|
688
697
|
if (engine.tokenBudget && loaded.state?.budgetUsed != null) {
|
|
689
698
|
engine.tokenBudget.used = loaded.state.budgetUsed
|
|
@@ -791,7 +800,7 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
791
800
|
default:
|
|
792
801
|
console.log(`Unknown command: /${cmd}. Type /help for available commands.`)
|
|
793
802
|
}
|
|
794
|
-
|
|
803
|
+
showPrompt()
|
|
795
804
|
return
|
|
796
805
|
}
|
|
797
806
|
|
|
@@ -803,26 +812,179 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
|
|
|
803
812
|
console.log()
|
|
804
813
|
await sessionManager.appendMessage({ role: 'user', content: input })
|
|
805
814
|
await sessionManager.appendMessage({ role: 'assistant', content: result.response })
|
|
806
|
-
// 保存引擎状态到会话
|
|
807
815
|
session.state = session.state || {}
|
|
808
816
|
session.state.turnCount = engine.state.turnCount
|
|
809
817
|
session.state.costHistory = engine.costTracker.history.slice(-50)
|
|
810
818
|
await sessionManager.save(session)
|
|
811
819
|
if (verbose) console.log(`[Turns: ${result.turns} | Tools: ${result.toolResults.length}]`)
|
|
812
|
-
// 显示费用(即使非 verbose 也显示)
|
|
813
820
|
if (engine.costTracker && engine.costTracker.totalApiCalls > 0) {
|
|
814
821
|
console.log(engine.costTracker.formatShort())
|
|
815
822
|
}
|
|
823
|
+
// 将 AI 回复同步发送到 Telegram(镜像 CLI 显示)
|
|
824
|
+
if (tgListener?.bot && result?.response) {
|
|
825
|
+
await sendTelegram(result.response, tgChatId || null)
|
|
826
|
+
}
|
|
816
827
|
} catch (err) {
|
|
817
828
|
console.error(`\nError: ${err.message}\n`)
|
|
829
|
+
if (tgListener?.bot) {
|
|
830
|
+
await sendTelegram(`❌ Error: ${err.message}`, tgChatId || null)
|
|
831
|
+
}
|
|
818
832
|
if (channelManager.list().length > 0) {
|
|
819
833
|
await channelManager.sendTemplate('error', {
|
|
820
834
|
task: input.slice(0, 80), error: err.message.slice(0, 200),
|
|
821
835
|
}).catch(() => {})
|
|
822
836
|
}
|
|
823
837
|
}
|
|
824
|
-
|
|
838
|
+
showPrompt()
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// 发送文本到 Telegram(支持分片,>4000 字符自动拆分)
|
|
842
|
+
async function sendTelegram(text, chatId = null) {
|
|
843
|
+
try {
|
|
844
|
+
const target = chatId || tgChatId
|
|
845
|
+
if (!target) return
|
|
846
|
+
const MAX_LEN = 4000
|
|
847
|
+
if (text.length <= MAX_LEN) {
|
|
848
|
+
await tgListener.bot.sendMessage(target, text, { parseMode: 'HTML' })
|
|
849
|
+
} else {
|
|
850
|
+
const parts = []
|
|
851
|
+
let cur = ''
|
|
852
|
+
for (const line of text.split('\n')) {
|
|
853
|
+
if (cur.length + line.length > 3800) { parts.push(cur); cur = line }
|
|
854
|
+
else { cur += (cur ? '\n' : '') + line }
|
|
855
|
+
}
|
|
856
|
+
if (cur) parts.push(cur)
|
|
857
|
+
for (let i = 0; i < parts.length; i++) {
|
|
858
|
+
const header = i > 0 ? `📎 (${i + 1}/${parts.length})\n` : ''
|
|
859
|
+
await tgListener.bot.sendMessage(target, header + parts[i], { parseMode: 'HTML' })
|
|
860
|
+
await new Promise(r => setTimeout(r, 300))
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
} catch (e) {
|
|
864
|
+
console.error(`[TG] send failed: ${e.message}`)
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// REPL 主循环 — 由 readline 原生处理回显、退格、行回绕与 Enter 提交
|
|
869
|
+
rl.on('line', (line) => {
|
|
870
|
+
processInputLine(line)
|
|
825
871
|
})
|
|
826
872
|
|
|
827
|
-
|
|
873
|
+
// 将 readline 注入引擎配置,用于 ask 模式确认和 AskUserQuestion 工具
|
|
874
|
+
if (permissionMode === 'ask') {
|
|
875
|
+
engine.config.onConfirmTool = async (toolName, input) => {
|
|
876
|
+
// 如果已经启用会话全局自动允许,直接通过
|
|
877
|
+
if (engine.permissionChecker.sessionAllowAll) return true
|
|
878
|
+
|
|
879
|
+
const snippet = JSON.stringify(input).slice(0, 120) || '(no params)'
|
|
880
|
+
return new Promise((resolve) => {
|
|
881
|
+
rl.question(`\n⚠️ Allow tool "${toolName}"?\n Input: ${snippet}\n (y/N/a) a=all session `, (answer) => {
|
|
882
|
+
const a = answer.toLowerCase()
|
|
883
|
+
if (a === 'a') {
|
|
884
|
+
engine.permissionChecker.allowAllForSession()
|
|
885
|
+
resolve(true)
|
|
886
|
+
} else {
|
|
887
|
+
resolve(a === 'y')
|
|
888
|
+
}
|
|
889
|
+
})
|
|
890
|
+
})
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
engine.config.readline = rl
|
|
894
|
+
|
|
895
|
+
// ============================================================
|
|
896
|
+
// Telegram 双向通道启动(可选)
|
|
897
|
+
// 配置了 CC_NODE_CHANNEL_TELEGRAM_TOKEN 或 config 中 telegram 时启用:
|
|
898
|
+
// - CLI 的 AI 回复会同步镜像发送到 Telegram
|
|
899
|
+
// - Telegram 消息会作为 REPL 输入,与 CLI 共享同一个引擎和对话记忆
|
|
900
|
+
// ============================================================
|
|
901
|
+
const tgToken = process.env.CC_NODE_CHANNEL_TELEGRAM_TOKEN || config.get('channels')?.telegram?.token || ''
|
|
902
|
+
if (tgToken) {
|
|
903
|
+
try {
|
|
904
|
+
tgListener = new TelegramListener({
|
|
905
|
+
channels: {
|
|
906
|
+
telegram: {
|
|
907
|
+
token: tgToken,
|
|
908
|
+
proxy: process.env.CC_NODE_CHANNEL_TELEGRAM_PROXY || config.get('channels')?.telegram?.proxy || '',
|
|
909
|
+
apiBase: process.env.CC_NODE_CHANNEL_TELEGRAM_API_BASE || config.get('channels')?.telegram?.apiBase || '',
|
|
910
|
+
},
|
|
911
|
+
},
|
|
912
|
+
})
|
|
913
|
+
tgListener.start(async (msg) => {
|
|
914
|
+
// Telegram 消息 → 复用 REPL 引擎处理(共享同一份对话记忆)
|
|
915
|
+
tgChatId = msg.chatId || tgChatId
|
|
916
|
+
tgReplyTarget = msg.replyTo || null
|
|
917
|
+
const text = msg.text || msg.callbackData || ''
|
|
918
|
+
if (!text) return
|
|
919
|
+
// 命令由 listener 内部处理,普通消息转给引擎
|
|
920
|
+
if (!text.startsWith('/')) {
|
|
921
|
+
await processInputLine(text, 'telegram', msg.chatId)
|
|
922
|
+
}
|
|
923
|
+
}).catch(e => console.error(`[TG] listener error: ${e.message}`))
|
|
924
|
+
console.log(`✅ Telegram channel ready (bot ${tgToken.slice(0, 12)}...)`)
|
|
925
|
+
} catch (e) {
|
|
926
|
+
console.error(`[TG] init failed: ${e.message}`)
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ============================================================
|
|
931
|
+
// --with-notify: 内置频道监听器(替代 cc-notify 守护进程)
|
|
932
|
+
// 当 cc-node 启动时同时启动 Telegram 监听器,
|
|
933
|
+
// 无需外部 bash 脚本,跨平台(Windows/Linux/macOS)都能用。
|
|
934
|
+
// ============================================================
|
|
935
|
+
if (cliArgs.withNotify) {
|
|
936
|
+
const { startBuiltinListeners } = await import('../channel/notify-daemon.js')
|
|
937
|
+
// 启动内部的 notify 监听器(不 fork 新进程,直接在当前进程运行)
|
|
938
|
+
const builtinNotify = await startBuiltinListeners({
|
|
939
|
+
channels: config.get('channels') || {},
|
|
940
|
+
defaultChannel: config.get('defaultChannel') || process.env.CC_NODE_CHANNEL_DEFAULT || null,
|
|
941
|
+
onMessage: async (msg) => {
|
|
942
|
+
// 来自外部的消息 → 转发到 REPL 引擎
|
|
943
|
+
const text = msg.text || ''
|
|
944
|
+
if (text && !text.startsWith('/')) {
|
|
945
|
+
await processInputLine(text, msg.channel || 'external', msg.chatId)
|
|
946
|
+
}
|
|
947
|
+
},
|
|
948
|
+
})
|
|
949
|
+
console.log('📡 Built-in channel listeners started (--with-notify)')
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
console.log(buildBanner({ model, permissionMode, session, maxTokens: tokenBudget.maxTokens }))
|
|
953
|
+
console.log(`Model: ${model} | Permission: ${permissionMode} | Tools: ${registry.getNames().join(', ')}`)
|
|
954
|
+
console.log(`Socket: ${SOCK_PATH} (cc-notify can connect)`)
|
|
955
|
+
if (channelManager.list().length > 0) {
|
|
956
|
+
const chList = channelManager.list().join(', ')
|
|
957
|
+
const def = channelManager.defaultChannel ? ` (default: ${channelManager.defaultChannel})` : ''
|
|
958
|
+
console.log(`Channels: ${chList}${def}`)
|
|
959
|
+
}
|
|
960
|
+
console.log()
|
|
961
|
+
// 显示初始提示符
|
|
962
|
+
showPrompt()
|
|
963
|
+
|
|
964
|
+
// REPL 消息处理包装(留作扩展点)
|
|
965
|
+
async function processInput(input) {
|
|
966
|
+
return engine.processMessage(input)
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// ============================================================
|
|
971
|
+
// 全局退出处理 — 回收 stdin 的 raw mode
|
|
972
|
+
// ============================================================
|
|
973
|
+
function cleanupStdin() {
|
|
974
|
+
try {
|
|
975
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
976
|
+
process.stdin.removeAllListeners('keypress')
|
|
977
|
+
} catch {}
|
|
828
978
|
}
|
|
979
|
+
|
|
980
|
+
process.on('SIGINT', () => {
|
|
981
|
+
cleanupStdin()
|
|
982
|
+
process.exit(0)
|
|
983
|
+
})
|
|
984
|
+
process.on('SIGTERM', () => {
|
|
985
|
+
cleanupStdin()
|
|
986
|
+
process.exit(0)
|
|
987
|
+
})
|
|
988
|
+
process.on('exit', () => {
|
|
989
|
+
cleanupStdin()
|
|
990
|
+
})
|
package/src/core/index.js
CHANGED
package/src/core/query-engine.js
CHANGED
|
@@ -17,6 +17,7 @@ import { parseStream, parseNonStreamResponse } from './streaming.js'
|
|
|
17
17
|
import { autoCompact } from './compact.js'
|
|
18
18
|
import { CostTracker } from './cost-tracker.js'
|
|
19
19
|
import { EnhancedPermissionChecker } from '../security/enhanced-permission.js'
|
|
20
|
+
import { isLocalLlmServer, buildAuthHeaders } from '../utils/index.js'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* 配置选项
|
|
@@ -44,6 +45,7 @@ export class QueryEngineConfig {
|
|
|
44
45
|
this.initialMessages = options.initialMessages || []
|
|
45
46
|
this.onConfirmTool = options.onConfirmTool || null // ask 模式确认回调
|
|
46
47
|
this.readline = options.readline || null // 用于 AskUserQuestion 工具
|
|
48
|
+
this.onDelta = options.onDelta || null // 流式增量回调 {type:'text'|'reasoning', text}(供 VS Code 扩展等 UI 消费)
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
|
@@ -65,15 +67,17 @@ export class QueryEngine {
|
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
69
|
* 主入口 — 处理用户消息
|
|
70
|
+
* @param {string} userInput 文本
|
|
71
|
+
* @param {string[]} [images] 图片 URL(data URL / http),视觉模型用
|
|
68
72
|
*/
|
|
69
|
-
async processMessage(userInput) {
|
|
73
|
+
async processMessage(userInput, images = []) {
|
|
70
74
|
if (this.state.isRunning) {
|
|
71
75
|
throw new Error('引擎正在运行中,请等待当前回合完成')
|
|
72
76
|
}
|
|
73
77
|
this.state.isRunning = true
|
|
74
78
|
this.state.turnCount++
|
|
75
79
|
this.abortController = new AbortController()
|
|
76
|
-
const userMsg = new UserMessage(userInput)
|
|
80
|
+
const userMsg = new UserMessage(userInput, images)
|
|
77
81
|
this.state.messages.push(userMsg)
|
|
78
82
|
|
|
79
83
|
// M3: 自动上下文压缩
|
|
@@ -165,7 +169,7 @@ export class QueryEngine {
|
|
|
165
169
|
if (msg.role === 'system') {
|
|
166
170
|
request.push({ role: 'system', content: msg.content })
|
|
167
171
|
} else if (msg.role === 'user') {
|
|
168
|
-
request.push({ role: 'user', content: this.
|
|
172
|
+
request.push({ role: 'user', content: this._buildUserContent(msg) })
|
|
169
173
|
} else if (msg.role === 'assistant') {
|
|
170
174
|
// 构建 assistant 消息基础
|
|
171
175
|
const asstMsg = {
|
|
@@ -265,7 +269,9 @@ export class QueryEngine {
|
|
|
265
269
|
const apiKey = this.config.apiKey
|
|
266
270
|
const apiBase = this.config.apiBase
|
|
267
271
|
|
|
268
|
-
|
|
272
|
+
// apiBase 指向自建本地服务(Ollama / llama.cpp / vLLM 等)时,允许缺省 apiKey
|
|
273
|
+
const isLocalServer = isLocalLlmServer(apiBase)
|
|
274
|
+
if (!isLocalServer && !apiKey) {
|
|
269
275
|
throw new Error(
|
|
270
276
|
`未设置 API Key。请设置以下环境变量之一:\n` +
|
|
271
277
|
` LLM_API_KEY=xxx (通用,推荐)\n` +
|
|
@@ -331,7 +337,7 @@ export class QueryEngine {
|
|
|
331
337
|
method: 'POST',
|
|
332
338
|
headers: {
|
|
333
339
|
'Content-Type': 'application/json',
|
|
334
|
-
|
|
340
|
+
...buildAuthHeaders(apiBase, apiKey),
|
|
335
341
|
},
|
|
336
342
|
body: JSON.stringify(body),
|
|
337
343
|
signal: this.abortController?.signal,
|
|
@@ -399,9 +405,18 @@ export class QueryEngine {
|
|
|
399
405
|
try {
|
|
400
406
|
for await (const event of parseStream(response)) {
|
|
401
407
|
if (event.type === 'text') {
|
|
402
|
-
//
|
|
403
|
-
|
|
408
|
+
// 实时输出(有 onDelta 回调时交给调用方,如 VS Code 扩展;否则写终端)
|
|
409
|
+
if (typeof this.config.onDelta === 'function') {
|
|
410
|
+
this.config.onDelta({ type: 'text', text: event.text })
|
|
411
|
+
} else {
|
|
412
|
+
process.stdout.write(event.text)
|
|
413
|
+
}
|
|
404
414
|
currentText += event.text
|
|
415
|
+
} else if (event.type === 'reasoning') {
|
|
416
|
+
// 推理内容(thinking)同样支持回调
|
|
417
|
+
if (typeof this.config.onDelta === 'function') {
|
|
418
|
+
this.config.onDelta({ type: 'reasoning', text: event.text })
|
|
419
|
+
}
|
|
405
420
|
} else if (event.type === 'tool_use') {
|
|
406
421
|
// 收集工具调用
|
|
407
422
|
result.toolCalls.push(new ToolCall(
|
|
@@ -425,8 +440,8 @@ export class QueryEngine {
|
|
|
425
440
|
}
|
|
426
441
|
}
|
|
427
442
|
|
|
428
|
-
//
|
|
429
|
-
if (currentText) process.stdout.write('\n')
|
|
443
|
+
// 流式输出后换行(仅终端直写模式;onDelta 回调模式由调用方处理,避免污染协议流)
|
|
444
|
+
if (currentText && typeof this.config.onDelta !== 'function') process.stdout.write('\n')
|
|
430
445
|
|
|
431
446
|
return result
|
|
432
447
|
}
|
|
@@ -439,6 +454,22 @@ export class QueryEngine {
|
|
|
439
454
|
return String(content)
|
|
440
455
|
}
|
|
441
456
|
|
|
457
|
+
/**
|
|
458
|
+
* 构建用户消息 content(支持多模态:文本 + 图片)
|
|
459
|
+
* 有 images 时输出 OpenAI 兼容 content 数组;否则纯文本(向后兼容)
|
|
460
|
+
*/
|
|
461
|
+
_buildUserContent(msg) {
|
|
462
|
+
const text = this._formatContent(msg.content)
|
|
463
|
+
const images = msg.images && msg.images.length ? msg.images : []
|
|
464
|
+
if (images.length === 0) return text
|
|
465
|
+
const parts = []
|
|
466
|
+
if (text) parts.push({ type: 'text', text })
|
|
467
|
+
for (const url of images) {
|
|
468
|
+
parts.push({ type: 'image_url', image_url: { url } })
|
|
469
|
+
}
|
|
470
|
+
return parts
|
|
471
|
+
}
|
|
472
|
+
|
|
442
473
|
/** 取消当前运行 */
|
|
443
474
|
abort() {
|
|
444
475
|
this.abortController?.abort()
|