@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/src/core/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * v1.2: 增加 Unix socket 服务,让 cc-notify 能发现并转发消息
6
6
  */
7
- import { createInterface } from 'readline'
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/raolin/projects\n Example: /cd ..",
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, { headers: { 'Authorization': `Bearer ${apiKeyForModels}` } })
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
- const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
545
-
546
- // 将 readline 注入引擎配置,用于 ask 模式确认和 AskUserQuestion 工具
547
- if (permissionMode === 'ask') {
548
- engine.config.onConfirmTool = async (toolName, input) => {
549
- // 如果已经启用会话全局自动允许,直接通过
550
- if (engine.permissionChecker.sessionAllowAll) return true
551
-
552
- const snippet = JSON.stringify(input).slice(0, 120) || '(no params)'
553
- return new Promise((resolve) => {
554
- rl.question(`\n⚠️ Allow tool "${toolName}"?\n Input: ${snippet}\n (y/N/a) a=all session `, (answer) => {
555
- const a = answer.toLowerCase()
556
- if (a === 'a') {
557
- engine.permissionChecker.allowAllForSession()
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
- // REPL 消息处理包装(留作扩展点)
580
- async function processInput(input) {
581
- return engine.processMessage(input)
582
- }
583
+ // Telegram 双向通道(可选):tgListener 监听 Telegram 消息,tgChatId 记录回复目标
584
+ let tgListener = null
585
+ let tgChatId = null
586
+ let tgReplyTarget = null
583
587
 
584
- rl.on('line', async (line) => {
585
- const input = line.trim()
586
- if (!input) { rl.prompt(); return }
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 (input.startsWith('/')) {
589
- const [cmd, ...rest] = input.slice(1).split(' ')
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
- if (!apiKey) { console.log('❌ API key not configured'); break }
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, { headers: { 'Authorization': `Bearer ${apiKey}` } })
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
- rl.prompt()
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
- rl.prompt()
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
- rl.on('close', () => { console.log('\nGoodbye!'); process.exit(0) })
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
@@ -1,3 +1,5 @@
1
+ export { UserMessage, AssistantMessage, ToolCall } from "../types/index.js"
2
+
1
3
  /**
2
4
  * 核心模块统一导出
3
5
  */
@@ -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._formatContent(msg.content) })
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
- if (!apiKey) {
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
- 'Authorization': `Bearer ${apiKey}`,
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
- process.stdout.write(event.text)
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()