thincoder 0.1.0 → 0.2.0
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 +32 -26
- package/bin/thincoder.mjs +151 -21
- package/package.json +1 -1
- package/src/agent.mjs +15 -3
- package/src/config.mjs +78 -17
- package/src/provider.mjs +7 -1
- package/src/session.mjs +5 -4
- package/src/tui.mjs +697 -53
package/src/tui.mjs
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
import { emitKeypressEvents } from "node:readline"
|
|
8
8
|
import { PassThrough } from "node:stream"
|
|
9
9
|
import { basename } from "node:path"
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
10
11
|
import { runAgent } from "./agent.mjs"
|
|
11
12
|
import { saveSession, clearSession } from "./session.mjs"
|
|
13
|
+
import { PROVIDER_PRESETS as PRESETS } from "./config.mjs"
|
|
12
14
|
|
|
13
15
|
// ---------------------------------------------------------------- ANSI 工具
|
|
14
16
|
|
|
@@ -227,8 +229,11 @@ export async function startTUI(agent, opts = {}) {
|
|
|
227
229
|
scroll: 0, // 从底部向上的滚动行数
|
|
228
230
|
processing: false,
|
|
229
231
|
permission: null, // { name, args, resolve }
|
|
232
|
+
picker: null, // 模型选择器 { entries, lines, index, scroll, selectedLine }
|
|
233
|
+
wizard: null, // 首次配置向导 { step, index, scroll, selectedLine, fields, error, lines }
|
|
230
234
|
tasks: [], // task 工具的任务列表(状态栏显示进度)
|
|
231
235
|
reasoning: "", // 思考流缓冲(暗色展示)
|
|
236
|
+
completion: null, // Tab 补全状态 { candidates, index }
|
|
232
237
|
toolStream: "", // 当前工具的实时输出(暗色展示,bash 流式)
|
|
233
238
|
currentTool: null, // 正在执行的工具名(状态栏显示)
|
|
234
239
|
processingStarted: 0, // 本轮处理开始时间(状态栏计时)
|
|
@@ -325,6 +330,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
325
330
|
const cols = process.stdout.columns || 80
|
|
326
331
|
const rows = process.stdout.rows || 24
|
|
327
332
|
const model = agent.provider.model
|
|
333
|
+
const thinking = agent.provider.thinking
|
|
334
|
+
const effort = agent.provider.reasoningEffort
|
|
335
|
+
const thinkBadge = thinking?.type === "disabled" ? "│ think: off"
|
|
336
|
+
: effort ? `│ think: ${effort}` : thinking?.type === "enabled" ? "│ think: on" : ""
|
|
328
337
|
|
|
329
338
|
// 输入区:全边框盒,宽度 W(所有输出行严格 ≤ cols-1,防自动折行错位)
|
|
330
339
|
const W = Math.max(20, cols - 1)
|
|
@@ -340,7 +349,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
340
349
|
|
|
341
350
|
const headerH = 1
|
|
342
351
|
const statusH = 1
|
|
343
|
-
|
|
352
|
+
// 浮层(模型选择器 / 初始配置向导)打开时,在对话区下方预留一块(标题 + 列表窗口)
|
|
353
|
+
const overlay = state.picker ?? state.wizard
|
|
354
|
+
const pickerH = overlay
|
|
355
|
+
? Math.min(overlay.lines.length + 1, Math.max(6, rows - 12))
|
|
356
|
+
: 0
|
|
357
|
+
const convH = Math.max(1, rows - headerH - inputBoxH - statusH - pickerH)
|
|
344
358
|
|
|
345
359
|
// 对话区内容行(含流式缓冲);markdown 表格先按显示宽度重排
|
|
346
360
|
const convLines = []
|
|
@@ -381,7 +395,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
381
395
|
|
|
382
396
|
// header
|
|
383
397
|
out.push(
|
|
384
|
-
`${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${model} │ ${basename(agent.cwd)}${ansi.reset}${ansi.clearLine}`,
|
|
398
|
+
`${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${model}${thinkBadge ? " " + thinkBadge : ""} │ ${basename(agent.cwd)}${ansi.reset}${ansi.clearLine}`,
|
|
385
399
|
)
|
|
386
400
|
|
|
387
401
|
// 对话区(不足部分补空行,把输入框钉在底部)
|
|
@@ -391,13 +405,32 @@ export async function startTUI(agent, opts = {}) {
|
|
|
391
405
|
out.push(`${l.color}${l.text}${ansi.reset}${ansi.clearLine}`)
|
|
392
406
|
}
|
|
393
407
|
|
|
408
|
+
// 浮层(模型选择器 / 初始配置向导):列表滚动跟随选中行
|
|
409
|
+
if (overlay) {
|
|
410
|
+
const winH = pickerH - 1
|
|
411
|
+
if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
|
|
412
|
+
if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
|
|
413
|
+
const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
|
|
414
|
+
const shown = overlay.lines.slice(start, start + winH)
|
|
415
|
+
const overlayTitle = state.picker ? " ❯ 选择模型 " : " ❯ 初始配置 "
|
|
416
|
+
out.push(`${ansi.bold}${C.tool}${overlayTitle}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ 移动, Enter 确认, Esc 取消)" : ""}${ansi.reset}${ansi.clearLine}`)
|
|
417
|
+
for (const l of shown) {
|
|
418
|
+
out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}${ansi.clearLine}`)
|
|
419
|
+
}
|
|
420
|
+
for (let i = shown.length; i < winH; i++) out.push(ansi.clearLine)
|
|
421
|
+
}
|
|
422
|
+
|
|
394
423
|
// 输入框(全边框,宽 W)
|
|
395
424
|
const borderColor = state.permission ? C.warn : C.tool
|
|
396
425
|
const title = state.permission
|
|
397
|
-
? ` Allow ${state.permission.name}? (y/n) `
|
|
398
|
-
: state.
|
|
399
|
-
? "
|
|
400
|
-
:
|
|
426
|
+
? ` Allow ${state.permission.name}? (y/n/a) `
|
|
427
|
+
: state.picker
|
|
428
|
+
? " Model "
|
|
429
|
+
: state.wizard
|
|
430
|
+
? " Setup "
|
|
431
|
+
: state.processing
|
|
432
|
+
? " Processing... "
|
|
433
|
+
: " Input "
|
|
401
434
|
const topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 3 - stringWidth(title)))}╮`
|
|
402
435
|
out.push(`${borderColor}${topBorder}${ansi.reset}${ansi.clearLine}`)
|
|
403
436
|
for (const l of inputLines) {
|
|
@@ -411,12 +444,35 @@ export async function startTUI(agent, opts = {}) {
|
|
|
411
444
|
const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
|
|
412
445
|
const rawInput = state.input.join("")
|
|
413
446
|
let statusLine
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
statusLine =
|
|
418
|
-
|
|
419
|
-
|
|
447
|
+
if (state.permission) {
|
|
448
|
+
statusLine = " y: 批准 │ n: 拒绝 │ a: 批准并全部放行(AUTO)"
|
|
449
|
+
} else if (state.picker) {
|
|
450
|
+
statusLine = " ↑↓: 选择 │ Enter: 确认 │ Esc: 取消"
|
|
451
|
+
} else if (state.wizard) {
|
|
452
|
+
statusLine = state.wizard.step === "provider"
|
|
453
|
+
? " ↑↓: 选择 │ Enter: 确认 │ Esc: 跳过"
|
|
454
|
+
: " 输入后 Enter 确认 │ Esc: 取消"
|
|
455
|
+
} else if (rawInput.startsWith("/") && !state.processing && !state.permission) {
|
|
456
|
+
const [cmd, sub] = rawInput.split(/\s+/)
|
|
457
|
+
const cmds = SLASH_COMMANDS.filter((c) => c.name.startsWith(cmd))
|
|
458
|
+
const match = cmds.length === 1 ? cmds[0] : null
|
|
459
|
+
if (match?.name === "/config" && cmd === "/config") {
|
|
460
|
+
statusLine = " /config 查看 │ key / embedkey 配 key"
|
|
461
|
+
} else if (match?.name === "/model" && cmd === "/model" && !sub) {
|
|
462
|
+
statusLine = " /model 打开选择器 │ /model <名称> 直接切换"
|
|
463
|
+
} else if (match?.name === "/provider" && cmd === "/provider") {
|
|
464
|
+
statusLine = " /provider 列表 │ add / remove / key 管理"
|
|
465
|
+
} else if (match?.name === "/think" && cmd === "/think") {
|
|
466
|
+
statusLine = " /think 查看 │ on / off 开关 │ effort high / max 强度"
|
|
467
|
+
} else if (cmds.length > 0) {
|
|
468
|
+
if (cmds.length <= 4) {
|
|
469
|
+
statusLine = ` ${cmds.map((c) => `${c.name} ${c.desc}`).join(" │ ")}`
|
|
470
|
+
} else {
|
|
471
|
+
statusLine = ` ${cmds.map((c) => c.name).join(" ")} │ Tab 补全`
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
statusLine = ` 未知命令(/help 查看可用命令)`
|
|
475
|
+
}
|
|
420
476
|
} else {
|
|
421
477
|
const taskHint = state.tasks.length > 0
|
|
422
478
|
? ` │ ▶${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}`
|
|
@@ -427,6 +483,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
427
483
|
statusLine = ` ${statusText}${taskHint}${scrollHint} │ Enter: send │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+C: exit`
|
|
428
484
|
}
|
|
429
485
|
const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
|
|
486
|
+
// 状态栏最多一行,超出终端宽度会被终端折行导致光标偏移
|
|
487
|
+
statusLine = sliceByWidth(statusLine, cols - 1)
|
|
430
488
|
out.push(`${ansi.dim}${autoBanner}${statusLine}${ansi.reset}${ansi.clearLine}`)
|
|
431
489
|
|
|
432
490
|
const frame = out.join("\r\n")
|
|
@@ -435,8 +493,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
435
493
|
process.stdout.write(frame)
|
|
436
494
|
}
|
|
437
495
|
|
|
438
|
-
// 光标:输入态定位到输入框内(IME
|
|
439
|
-
if (state.processing || state.permission) {
|
|
496
|
+
// 光标:输入态定位到输入框内(IME 候选框跟随真实光标);处理中/权限确认/菜单态时隐藏
|
|
497
|
+
if (state.processing || state.permission || state.picker || state.wizard?.step === "provider") {
|
|
440
498
|
process.stdout.write(ansi.hideCursor)
|
|
441
499
|
} else {
|
|
442
500
|
const cursorRow = 1 + convH + 2 + (layout.cursorLine - inputOffset) // header + 对话区 + 上边框 + 行偏移
|
|
@@ -565,6 +623,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
565
623
|
pushLine(` [auto] ${name} ${summarize(args)}`, C.warn)
|
|
566
624
|
return Promise.resolve(true)
|
|
567
625
|
}
|
|
626
|
+
// 把关键参数摆出来:批什么要让人看明白
|
|
627
|
+
pushLabel(`❯ 权限请求`, ansi.bold + C.warn)
|
|
628
|
+
for (const line of formatPermission(name, args)) pushLine(` ${line}`, C.text)
|
|
568
629
|
return new Promise((resolve) => {
|
|
569
630
|
state.permission = { name, args, resolve }
|
|
570
631
|
state.status = `Waiting: ${name}`
|
|
@@ -572,12 +633,25 @@ export async function startTUI(agent, opts = {}) {
|
|
|
572
633
|
})
|
|
573
634
|
}
|
|
574
635
|
|
|
636
|
+
/** 权限请求的关键信息(按工具定制),返回行数组 */
|
|
637
|
+
function formatPermission(name, args) {
|
|
638
|
+
const cap = (s, n = 1000) => (s.length > n ? `${s.slice(0, n)}…(共 ${s.length} 字符)` : s)
|
|
639
|
+
if (name === "bash") return cap(args.command ?? "").split("\n")
|
|
640
|
+
if (name === "write") return [`${args.path}(写入 ${(args.content ?? "").length} 字符)`]
|
|
641
|
+
if (name === "edit") return [`${args.path}(替换 ${(args.old_string ?? "").length} 字符 → ${(args.new_string ?? "").length} 字符)`]
|
|
642
|
+
if (name === "subagent") return cap(args.task ?? "", 500).split("\n")
|
|
643
|
+
if (name === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`]
|
|
644
|
+
return [cap(summarize(args), 300)]
|
|
645
|
+
}
|
|
646
|
+
|
|
575
647
|
// ---------------------------------------------------------- 斜杠命令
|
|
576
648
|
|
|
577
649
|
const SLASH_COMMANDS = [
|
|
578
650
|
{ name: "/help", desc: "命令列表" },
|
|
579
|
-
{ name: "/model", desc: "
|
|
580
|
-
{ name: "/
|
|
651
|
+
{ name: "/model", desc: "模型选择器/切换" },
|
|
652
|
+
{ name: "/provider", desc: "管理 provider(增/删/配 key)" },
|
|
653
|
+
{ name: "/think", desc: "思维模式与推理强度" },
|
|
654
|
+
{ name: "/config", desc: "查看配置、配 key" },
|
|
581
655
|
{ name: "/auto", desc: "自动授权开关" },
|
|
582
656
|
{ name: "/rewind", desc: "回滚到存档点" },
|
|
583
657
|
{ name: "/reindex", desc: "重建记忆索引" },
|
|
@@ -606,7 +680,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
606
680
|
return
|
|
607
681
|
case "/exit":
|
|
608
682
|
cleanup()
|
|
609
|
-
process.exit(0)
|
|
683
|
+
setTimeout(() => process.exit(0), 100) // 延迟一拍:fetch 后立刻 exit 在 Windows/Node 24 会触发 libuv 断言
|
|
610
684
|
return
|
|
611
685
|
case "/reindex": {
|
|
612
686
|
const { syncDir } = await import("./memory.mjs")
|
|
@@ -668,48 +742,220 @@ export async function startTUI(agent, opts = {}) {
|
|
|
668
742
|
agent.autoApprove ? C.warn : C.dim,
|
|
669
743
|
)
|
|
670
744
|
return
|
|
745
|
+
case "/think": {
|
|
746
|
+
const sub = rest[0]
|
|
747
|
+
const cur = agent.provider
|
|
748
|
+
const thinkingEnabled = cur.thinking?.type === "enabled" || cur.thinking?.type === undefined
|
|
749
|
+
// ---- /think(无参): 查看状态 ----
|
|
750
|
+
if (!sub) {
|
|
751
|
+
pushLabel(`❯ Think`, ansi.bold + C.tool)
|
|
752
|
+
pushLine(`思维模式: ${thinkingEnabled ? "🟢 开启" : "⚫ 关闭"}`, C.dim)
|
|
753
|
+
pushLine(`推理强度: ${cur.reasoningEffort ?? "(未设置)"}`, C.dim)
|
|
754
|
+
pushLine(`切换: /think on | off | effort high | effort max`, C.dim)
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
// ---- /think on / off ----
|
|
758
|
+
if (sub === "on" || sub === "off") {
|
|
759
|
+
const enable = sub === "on"
|
|
760
|
+
cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
|
|
761
|
+
if (!enable) delete cur.reasoningEffort
|
|
762
|
+
else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
763
|
+
await syncProviderField("thinking", cur.thinking)
|
|
764
|
+
if (!enable) await syncProviderField("reasoningEffort", undefined)
|
|
765
|
+
else await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
766
|
+
pushLabel(`❯ Think`, ansi.bold + C.tool)
|
|
767
|
+
pushLine(`思维模式已${enable ? "开启" : "关闭"}`, C.tool)
|
|
768
|
+
if (enable) pushLine(`推理强度: ${cur.reasoningEffort}`, C.dim)
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
// ---- /think effort <level> ----
|
|
772
|
+
if (sub === "effort") {
|
|
773
|
+
const level = rest[1]
|
|
774
|
+
if (!level || !["low", "high", "max"].includes(level)) {
|
|
775
|
+
pushLine("用法: /think effort low | high | max", C.error)
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
cur.reasoningEffort = level
|
|
779
|
+
await syncProviderField("reasoningEffort", level)
|
|
780
|
+
pushLabel(`❯ Think`, ansi.bold + C.tool)
|
|
781
|
+
pushLine(`推理强度已设为 ${level}`, C.tool)
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
pushLine(`未知参数: ${sub}(可用: on / off / effort / effort high|max)`, C.error)
|
|
785
|
+
return
|
|
786
|
+
}
|
|
671
787
|
case "/model": {
|
|
672
788
|
const arg = rest[0]
|
|
673
789
|
if (!arg) {
|
|
790
|
+
// 打开交互选择器:全部 provider 的全部模型,方向键选择
|
|
674
791
|
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
675
|
-
pushLine(
|
|
676
|
-
pushLine(
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
792
|
+
pushLine(`/model <名称> 直接切换 provider 或模型(如 /model deepseek-v4-pro)`, C.dim)
|
|
793
|
+
pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
|
|
794
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
795
|
+
return
|
|
796
|
+
}
|
|
797
|
+
if (arg === "add" || arg === "--add") {
|
|
798
|
+
pushLine(`添加 provider 已移到 /provider add(/provider 查看全部管理命令)`, C.warn)
|
|
799
|
+
return
|
|
800
|
+
}
|
|
801
|
+
// 一个参数两种含义:先按 provider 名匹配,匹配不到就当模型名改当前 provider
|
|
802
|
+
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
803
|
+
const p = agent.providers.find((pp) => pp.name === arg)
|
|
804
|
+
const newModel = p ? p.model : arg
|
|
805
|
+
let thresholdNote = ""
|
|
806
|
+
if (agent.config?.agent?.compactThresholdAuto) {
|
|
807
|
+
const { value } = resolveCompactThreshold(null, newModel)
|
|
808
|
+
agent.config.agent.compactThreshold = value
|
|
809
|
+
thresholdNote = `,压缩阈值随模型调整为 ${value}`
|
|
810
|
+
}
|
|
811
|
+
if (p) {
|
|
812
|
+
agent.activeProvider = arg
|
|
813
|
+
agent.provider = { ...p }
|
|
814
|
+
// key 的环境变量兜底和 loadConfig 保持一致
|
|
815
|
+
if (!agent.provider.apiKey) {
|
|
816
|
+
agent.provider.apiKey =
|
|
817
|
+
process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
|
|
689
818
|
}
|
|
819
|
+
await persistRaw((raw) => { raw.activeProvider = arg })
|
|
820
|
+
agent.config.activeProvider = arg
|
|
821
|
+
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
822
|
+
pushLine(`已切换到 ${arg} / ${p.model}${thresholdNote}(已持久化)`, C.tool)
|
|
823
|
+
if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /config key <apikey>`, C.warn)
|
|
690
824
|
} else {
|
|
825
|
+
const target = agent.providers.find((pp) => pp.name === agent.activeProvider) ?? agent.providers[0]
|
|
826
|
+
if (target) target.model = arg
|
|
691
827
|
agent.provider.model = arg
|
|
692
|
-
|
|
693
|
-
let thresholdNote = ""
|
|
694
|
-
if (agent.config?.agent?.compactThresholdAuto) {
|
|
695
|
-
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
696
|
-
const { value } = resolveCompactThreshold(null, arg)
|
|
697
|
-
agent.config.agent.compactThreshold = value
|
|
698
|
-
thresholdNote = `,压缩阈值随模型调整为 ${value}`
|
|
699
|
-
}
|
|
828
|
+
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
700
829
|
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
701
|
-
pushLine(
|
|
830
|
+
pushLine(`已将 ${target?.name ?? agent.activeProvider} 的模型改为 ${arg}${thresholdNote}(已持久化)`, C.tool)
|
|
702
831
|
}
|
|
703
832
|
return
|
|
704
833
|
}
|
|
834
|
+
case "/provider": {
|
|
835
|
+
const sub = rest[0]
|
|
836
|
+
// ---- /provider add <名称> <baseURL> <模型>,或 /provider add <预设> ----
|
|
837
|
+
if (sub === "add") {
|
|
838
|
+
const name = rest[1]
|
|
839
|
+
if (!name) {
|
|
840
|
+
pushLine(`用法: /provider add <名称> <baseURL> <模型>,或 /provider add <预设>(${Object.keys(PRESETS).join(", ")})`, C.error)
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
if (agent.providers.some((p) => p.name === name)) {
|
|
844
|
+
pushLine(`"${name}" 已存在;要重建可先 /provider remove ${name}`, C.warn)
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
const preset = PRESETS[name]
|
|
848
|
+
const baseURL = (rest[2] ?? preset?.baseURL)?.replace(/\/+$/, "")
|
|
849
|
+
const model = rest[3] ?? preset?.model
|
|
850
|
+
if (!baseURL || !model) {
|
|
851
|
+
pushLine(`缺少参数: /provider add ${name} <baseURL> <模型>`, C.error)
|
|
852
|
+
if (!preset) pushLine(`("${name}" 不是预设;预设: ${Object.keys(PRESETS).join(", ")})`, C.dim)
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
if (!/^https?:\/\//.test(baseURL)) {
|
|
856
|
+
pushLine(`baseURL 应以 http(s):// 开头`, C.error)
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
agent.providers.push({ name, baseURL, model, ...(preset?.desc ? { desc: preset.desc } : {}) })
|
|
860
|
+
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
861
|
+
pushLabel(`❯ Provider`, ansi.bold + C.tool)
|
|
862
|
+
pushLine(`已添加 ${name}(${baseURL} / ${model})`, C.tool)
|
|
863
|
+
pushLine(`下一步: /provider key ${name} <apikey> 配 key,/model ${name} 切换`, C.dim)
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
// ---- /provider remove <名称> ----
|
|
867
|
+
if (sub === "remove" || sub === "rm") {
|
|
868
|
+
const name = rest[1]
|
|
869
|
+
if (!name) { pushLine("用法: /provider remove <名称>", C.error); return }
|
|
870
|
+
const at = agent.providers.findIndex((p) => p.name === name)
|
|
871
|
+
if (at < 0) { pushLine(`未找到 provider "${name}"`, C.error); return }
|
|
872
|
+
if (name === agent.activeProvider) {
|
|
873
|
+
pushLine(`"${name}" 正在使用中,先 /model 切换到别的 provider 再删`, C.warn)
|
|
874
|
+
return
|
|
875
|
+
}
|
|
876
|
+
agent.providers.splice(at, 1)
|
|
877
|
+
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
878
|
+
pushLabel(`❯ Provider`, ansi.bold + C.tool)
|
|
879
|
+
pushLine(`已删除 ${name}`, C.tool)
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
// ---- /provider key [名称] <apikey>(不填名称配当前) ----
|
|
883
|
+
if (sub === "key") {
|
|
884
|
+
let name = agent.activeProvider
|
|
885
|
+
let keyParts = rest.slice(1)
|
|
886
|
+
if (rest[1] && agent.providers.some((p) => p.name === rest[1])) {
|
|
887
|
+
name = rest[1]
|
|
888
|
+
keyParts = rest.slice(2)
|
|
889
|
+
}
|
|
890
|
+
const key = keyParts.join(" ")
|
|
891
|
+
if (!key) { pushLine("用法: /provider key [名称] <apikey>(不填名称则配当前 provider)", C.error); return }
|
|
892
|
+
await setProviderKey(name, key)
|
|
893
|
+
return
|
|
894
|
+
}
|
|
895
|
+
if (sub) { pushLine(`未知参数: ${sub}(可用: add / remove / key)`, C.error); return }
|
|
896
|
+
// ---- /provider(无参): 列表 ----
|
|
897
|
+
pushLabel(`❯ Providers (${agent.providers.length})`, ansi.bold + C.tool)
|
|
898
|
+
for (const p of agent.providers) {
|
|
899
|
+
const active = p.name === agent.activeProvider
|
|
900
|
+
pushLine(
|
|
901
|
+
`${active ? " ▸" : " "} ${p.name.padEnd(12)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●key" : " ○无key"}${active ? " ← 当前" : ""}`,
|
|
902
|
+
active ? C.tool : C.dim,
|
|
903
|
+
)
|
|
904
|
+
}
|
|
905
|
+
pushLabel(`❯ 操作`, ansi.bold + C.tool)
|
|
906
|
+
pushLine(`/provider add <名称> <baseURL> <模型> 添加自定义(或 /provider add <预设>: ${Object.keys(PRESETS).join(" ")})`, C.dim)
|
|
907
|
+
pushLine(`/provider remove <名称> 删除(使用中的不可删)`, C.dim)
|
|
908
|
+
pushLine(`/provider key [名称] <apikey> 配 key(不填名称配当前)`, C.dim)
|
|
909
|
+
return
|
|
910
|
+
}
|
|
705
911
|
case "/config": {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
912
|
+
const sub = rest[0]
|
|
913
|
+
// ---- /config key <apikey>:写入当前激活 provider ----
|
|
914
|
+
if (sub === "key") {
|
|
915
|
+
const key = rest.slice(1).join(" ")
|
|
916
|
+
if (!key) { pushLine("用法: /config key <apikey>(配当前 provider)", C.error); return }
|
|
917
|
+
const target = agent.providers.find((p) => p.name === agent.activeProvider) ?? agent.providers[0]
|
|
918
|
+
await setProviderKey(target?.name, key)
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
// ---- /config embedkey <apikey>:embedding 服务的 key(向量检索) ----
|
|
922
|
+
if (sub === "embedkey") {
|
|
923
|
+
const key = rest.slice(1).join(" ")
|
|
924
|
+
if (!key) { pushLine("用法: /config embedkey <apikey>(embedding 服务,默认 SiliconFlow bge-m3)", C.error); return }
|
|
925
|
+
agent.config.embedding ??= {}
|
|
926
|
+
agent.config.embedding.apiKey = key
|
|
927
|
+
await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: key } })
|
|
928
|
+
if (agent.memory) {
|
|
929
|
+
const { createEmbedder } = await import("./embedding.mjs")
|
|
930
|
+
agent.memory.embedder = createEmbedder(agent.config.embedding)
|
|
931
|
+
}
|
|
932
|
+
pushLabel(`❯ Config`, ansi.bold + C.tool)
|
|
933
|
+
pushLine(`embedding key 已保存,向量检索已启用`, C.tool)
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
if (sub) { pushLine(`未知参数: ${sub}(/config 查看,/config key 配 key)`, C.error); return }
|
|
937
|
+
// ---- /config(无参): 查看 ----
|
|
938
|
+
const { configPath } = await import("./config.mjs")
|
|
939
|
+
pushLabel(`❯ 当前配置`, ansi.bold + C.tool)
|
|
940
|
+
pushLine(`激活: ${agent.activeProvider}`, C.dim)
|
|
941
|
+
pushLine(`模型: ${agent.provider.model}`, C.dim)
|
|
942
|
+
pushLine(`Key: ${maskKey(agent.provider.apiKey)}`, C.dim)
|
|
709
943
|
const ac = agent.config?.agent ?? {}
|
|
710
|
-
const
|
|
711
|
-
pushLine(`agent:
|
|
712
|
-
pushLine(`
|
|
944
|
+
const tn = `${ac.compactThreshold ?? 100000}${ac.compactThresholdAuto ? " (auto)" : ""}`
|
|
945
|
+
pushLine(`agent: maxTurns=${ac.maxTurns ?? 50} | compactThreshold=${tn}`, C.dim)
|
|
946
|
+
pushLine(`embedding: ${agent.memory?.embedder ? `enabled (${agent.config?.embedding?.model ?? ""})` : "disabled(纯 FTS 检索)"}`, C.dim)
|
|
947
|
+
pushLabel(`❯ 所有 providers (${agent.providers.length})`, ansi.bold + C.tool)
|
|
948
|
+
for (const p of agent.providers) {
|
|
949
|
+
const active = p.name === agent.activeProvider
|
|
950
|
+
pushLine(`${active ? " ▸" : " "} ${p.name.padEnd(10)} ${p.model.padEnd(20)} ${p.baseURL}${p.apiKey ? " ●" : " ○"}${active ? " ← 当前" : ""}`, active ? C.tool : C.dim)
|
|
951
|
+
}
|
|
952
|
+
pushLabel(`❯ 操作`, ansi.bold + C.tool)
|
|
953
|
+
pushLine(`/model 方向键选择模型(全部 provider 的全部模型)`, C.dim)
|
|
954
|
+
pushLine(`/provider 管理 provider(添加/删除/配 key)`, C.dim)
|
|
955
|
+
if (!agent.provider.apiKey) pushLine("⚡ /config key <apikey> ← 当前 provider 还没配 key", C.warn)
|
|
956
|
+
else pushLine(`/config key <apikey> 更换当前 provider 的 key`, C.dim)
|
|
957
|
+
if (!agent.memory?.embedder) pushLine(`/config embedkey <key> 开启向量检索(SiliconFlow)`, C.dim)
|
|
958
|
+
pushLine(`配置文件: ${configPath}(自定义 provider 可直接编辑)`, C.dim)
|
|
713
959
|
return
|
|
714
960
|
}
|
|
715
961
|
case "/help": {
|
|
@@ -729,6 +975,343 @@ export async function startTUI(agent, opts = {}) {
|
|
|
729
975
|
return `${key.slice(0, 5)}…${key.slice(-4)}`
|
|
730
976
|
}
|
|
731
977
|
|
|
978
|
+
/** Tab 补全候选:命令名 / 子命令 / provider 名 / 预设名 / think 参数 */
|
|
979
|
+
function completions(input) {
|
|
980
|
+
if (!input.startsWith("/")) return []
|
|
981
|
+
const parts = input.split(/\s+/)
|
|
982
|
+
// 还在敲第一个 token:补命令名
|
|
983
|
+
if (parts.length === 1) {
|
|
984
|
+
return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
|
|
985
|
+
}
|
|
986
|
+
const cmd = parts[0]
|
|
987
|
+
const last = parts.at(-1) // 结尾是空格时为 "",即列出全部候选
|
|
988
|
+
const head = parts.slice(0, -1).join(" ")
|
|
989
|
+
const argIndex = parts.length - 2 // 正在敲第几个参数(0 基)
|
|
990
|
+
const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
|
|
991
|
+
if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
|
|
992
|
+
if (cmd === "/provider") {
|
|
993
|
+
if (argIndex === 0) return match(["add", "remove", "key"])
|
|
994
|
+
if (argIndex === 1 && parts[1] === "add") return match(Object.keys(PRESETS))
|
|
995
|
+
if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "key")) return match(agent.providers.map((p) => p.name))
|
|
996
|
+
}
|
|
997
|
+
if (cmd === "/think") {
|
|
998
|
+
if (argIndex === 0) return match(["on", "off", "effort"])
|
|
999
|
+
if (argIndex === 1 && parts[1] === "effort") return match(["low", "high", "max"])
|
|
1000
|
+
}
|
|
1001
|
+
if (cmd === "/config" && argIndex === 0) return match(["key", "embedkey"])
|
|
1002
|
+
return []
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Tab:计算候选并循环替换输入 */
|
|
1006
|
+
function handleTab() {
|
|
1007
|
+
const input = state.input.join("")
|
|
1008
|
+
if (state.completion && input === state.completion.candidates[state.completion.index]) {
|
|
1009
|
+
// 上一次的候选还在输入框:循环到下一个
|
|
1010
|
+
state.completion.index = (state.completion.index + 1) % state.completion.candidates.length
|
|
1011
|
+
} else {
|
|
1012
|
+
const candidates = completions(input)
|
|
1013
|
+
if (candidates.length === 0) return
|
|
1014
|
+
state.completion = { candidates, index: 0 }
|
|
1015
|
+
}
|
|
1016
|
+
const text = state.completion.candidates[state.completion.index]
|
|
1017
|
+
state.input = [...text]
|
|
1018
|
+
state.cursor = state.input.length
|
|
1019
|
+
render()
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** 读配置文件 → 修改 → 写回;文件不存在时从空对象开始 */
|
|
1023
|
+
async function persistRaw(mutate) {
|
|
1024
|
+
const { saveConfig, configPath } = await import("./config.mjs")
|
|
1025
|
+
const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
|
|
1026
|
+
mutate(raw)
|
|
1027
|
+
saveConfig(raw)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** 把当前激活 provider 的某个字段同步到 providers 列表并持久化 */
|
|
1031
|
+
async function syncProviderField(field, value) {
|
|
1032
|
+
const target = agent.providers.find((p) => p.name === agent.activeProvider)
|
|
1033
|
+
if (!target) return
|
|
1034
|
+
if (value === undefined) delete target[field]
|
|
1035
|
+
else target[field] = value
|
|
1036
|
+
// 全量写回:raw 里的 providers 顺序/内容可能与运行时列表不一致,逐字段改容易写错位
|
|
1037
|
+
await persistRaw((raw) => {
|
|
1038
|
+
raw.providers = agent.providers
|
|
1039
|
+
})
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// ---------------------------------------------------------- 模型选择器(/model)
|
|
1043
|
+
|
|
1044
|
+
const pickerItems = () => state.picker.entries.filter((e) => e.type === "item")
|
|
1045
|
+
|
|
1046
|
+
/** 按 entries 重建显示行并刷新;高亮选中项、标注当前模型 */
|
|
1047
|
+
function renderPickerLines() {
|
|
1048
|
+
const p = state.picker
|
|
1049
|
+
if (!p) return
|
|
1050
|
+
const lines = []
|
|
1051
|
+
let row = 0
|
|
1052
|
+
let selectedLine = 0
|
|
1053
|
+
for (const e of p.entries) {
|
|
1054
|
+
if (e.type === "header") {
|
|
1055
|
+
lines.push({ text: ` ${e.name}${e.note ? ` ${e.note}` : ""}`, color: ansi.bold + C.tool })
|
|
1056
|
+
} else {
|
|
1057
|
+
const selected = row === p.index
|
|
1058
|
+
if (selected) selectedLine = lines.length
|
|
1059
|
+
const current = e.provider === agent.activeProvider && e.model === agent.provider.model
|
|
1060
|
+
lines.push({
|
|
1061
|
+
text: `${selected ? " ▸ " : " "}${e.model}${current ? " ← 当前" : ""}`,
|
|
1062
|
+
color: selected ? ansi.bold + C.text : C.dim,
|
|
1063
|
+
})
|
|
1064
|
+
row++
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
p.lines = lines
|
|
1068
|
+
p.selectedLine = selectedLine
|
|
1069
|
+
render()
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** 打开选择器:先列出各 provider 已配置的模型,再并发拉取各端点的全部模型展开进去 */
|
|
1073
|
+
async function openModelPicker() {
|
|
1074
|
+
const entries = []
|
|
1075
|
+
for (const p of agent.providers) {
|
|
1076
|
+
entries.push({ type: "header", name: p.name, note: `${p.baseURL}${p.apiKey ? "" : "(未配 key)"} 加载中...` })
|
|
1077
|
+
entries.push({ type: "item", provider: p.name, model: p.model })
|
|
1078
|
+
}
|
|
1079
|
+
state.picker = { entries, lines: [], index: 0, scroll: 0, selectedLine: 0 }
|
|
1080
|
+
// 默认选中当前在用的模型
|
|
1081
|
+
const current = pickerItems().findIndex(
|
|
1082
|
+
(e) => e.provider === agent.activeProvider && e.model === agent.provider.model,
|
|
1083
|
+
)
|
|
1084
|
+
if (current >= 0) state.picker.index = current
|
|
1085
|
+
renderPickerLines()
|
|
1086
|
+
|
|
1087
|
+
const { listModels } = await import("./provider.mjs")
|
|
1088
|
+
await Promise.all(
|
|
1089
|
+
agent.providers.map(async (p) => {
|
|
1090
|
+
const header = entries.find((e) => e.type === "header" && e.name === p.name)
|
|
1091
|
+
const noteBase = `${p.baseURL}${p.apiKey ? "" : "(未配 key)"}`
|
|
1092
|
+
try {
|
|
1093
|
+
// key 的环境变量兜底和 loadConfig 保持一致
|
|
1094
|
+
const apiKey =
|
|
1095
|
+
p.apiKey || process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
|
|
1096
|
+
const models = await listModels(
|
|
1097
|
+
{ baseURL: p.baseURL, apiKey: apiKey ?? "" },
|
|
1098
|
+
{ signal: AbortSignal.timeout(10000) },
|
|
1099
|
+
)
|
|
1100
|
+
// 展开到该 provider 已配置模型的后面(去重)
|
|
1101
|
+
const at = entries.findIndex((e) => e.type === "item" && e.provider === p.name && e.model === p.model)
|
|
1102
|
+
entries.splice(
|
|
1103
|
+
at + 1,
|
|
1104
|
+
0,
|
|
1105
|
+
...models.filter((m) => m !== p.model).map((m) => ({ type: "item", provider: p.name, model: m })),
|
|
1106
|
+
)
|
|
1107
|
+
header.note = noteBase
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
header.note = `${noteBase} (拉取失败: ${sliceByWidth(error.message, 60)})`
|
|
1110
|
+
}
|
|
1111
|
+
if (state.picker?.entries === entries) renderPickerLines() // 已关闭就不再刷新
|
|
1112
|
+
}),
|
|
1113
|
+
)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function closeModelPicker() {
|
|
1117
|
+
state.picker = null
|
|
1118
|
+
render()
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** 给指定 provider 写 key(内存 + 配置文件);若它是当前激活的,同步运行时 */
|
|
1122
|
+
async function setProviderKey(name, key) {
|
|
1123
|
+
const target = agent.providers.find((p) => p.name === name)
|
|
1124
|
+
if (!target) {
|
|
1125
|
+
pushLine(`未找到 provider "${name}"`, C.error)
|
|
1126
|
+
return
|
|
1127
|
+
}
|
|
1128
|
+
target.apiKey = key
|
|
1129
|
+
if (name === agent.activeProvider) agent.provider.apiKey = key
|
|
1130
|
+
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
1131
|
+
pushLabel(`❯ Provider`, ansi.bold + C.tool)
|
|
1132
|
+
pushLine(`apiKey 已保存到 ${name}`, C.tool)
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ---------------------------------------------------------- 初始配置向导(首次启动)
|
|
1136
|
+
|
|
1137
|
+
/** 菜单步的候选项:已有 provider(未配 key 的标注)+ 未添加的预设 + 自定义 */
|
|
1138
|
+
function wizardProviderItems() {
|
|
1139
|
+
const items = []
|
|
1140
|
+
for (const p of agent.providers) {
|
|
1141
|
+
items.push({ kind: "existing", name: p.name, baseURL: p.baseURL, model: p.model, label: `${p.name}(已添加${p.apiKey ? "" : ",未配 key"})` })
|
|
1142
|
+
}
|
|
1143
|
+
for (const [name, p] of Object.entries(PRESETS)) {
|
|
1144
|
+
if (!agent.providers.some((x) => x.name === name)) {
|
|
1145
|
+
items.push({ kind: "preset", name, baseURL: p.baseURL, model: p.model, label: `${name}(${p.desc})` })
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
items.push({ kind: "custom", name: null, label: "自定义端点…" })
|
|
1149
|
+
return items
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** 文本步骤定义:提示语 + 校验(通过返回 true,否则返回错误文案) */
|
|
1153
|
+
const WIZARD_STEPS = {
|
|
1154
|
+
name: {
|
|
1155
|
+
prompt: "给这个 provider 起个名字(字母/数字/-/_,如 my-openai)",
|
|
1156
|
+
validate: (v) =>
|
|
1157
|
+
(/^[\w-]+$/.test(v) && !agent.providers.some((p) => p.name === v)) || "名字需为字母/数字/-/_,且不与已有 provider 重名",
|
|
1158
|
+
},
|
|
1159
|
+
baseURL: {
|
|
1160
|
+
prompt: "输入 baseURL(如 https://api.openai.com/v1)",
|
|
1161
|
+
validate: (v) => /^https?:\/\/.+/.test(v) || "baseURL 应以 http(s):// 开头",
|
|
1162
|
+
},
|
|
1163
|
+
model: {
|
|
1164
|
+
prompt: "输入模型名(如 gpt-4o)",
|
|
1165
|
+
validate: (v) => v.length > 0 || "模型名不能为空",
|
|
1166
|
+
},
|
|
1167
|
+
key: {
|
|
1168
|
+
prompt: "输入 API key",
|
|
1169
|
+
validate: (v) => v.length > 0 || "key 不能为空",
|
|
1170
|
+
},
|
|
1171
|
+
embedkey: {
|
|
1172
|
+
prompt: "可选:embedding API key(SiliconFlow,记忆向量检索用;直接回车跳过)",
|
|
1173
|
+
validate: () => true, // 可跳过
|
|
1174
|
+
},
|
|
1175
|
+
}
|
|
1176
|
+
const WIZARD_NEXT = { name: "baseURL", baseURL: "model", model: "key", key: "embedkey", embedkey: null }
|
|
1177
|
+
|
|
1178
|
+
function startWizard() {
|
|
1179
|
+
state.wizard = { step: "provider", index: 0, scroll: 0, selectedLine: 0, fields: {}, error: null, lines: [] }
|
|
1180
|
+
renderWizard()
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function renderWizard() {
|
|
1184
|
+
const w = state.wizard
|
|
1185
|
+
if (!w) return
|
|
1186
|
+
const lines = []
|
|
1187
|
+
if (w.step === "provider") {
|
|
1188
|
+
lines.push({ text: " 选择一个模型提供商:", color: C.text })
|
|
1189
|
+
wizardProviderItems().forEach((it, i) => {
|
|
1190
|
+
if (i === w.index) w.selectedLine = lines.length
|
|
1191
|
+
lines.push({
|
|
1192
|
+
text: `${i === w.index ? " ▸ " : " "}${it.label}`,
|
|
1193
|
+
color: i === w.index ? ansi.bold + C.text : C.dim,
|
|
1194
|
+
})
|
|
1195
|
+
})
|
|
1196
|
+
} else {
|
|
1197
|
+
const f = w.fields
|
|
1198
|
+
if (f.name) lines.push({ text: ` 提供商: ${f.name}`, color: C.dim })
|
|
1199
|
+
if (f.baseURL) lines.push({ text: ` baseURL: ${f.baseURL}`, color: C.dim })
|
|
1200
|
+
if (f.model) lines.push({ text: ` 模型: ${f.model}`, color: C.dim })
|
|
1201
|
+
lines.push({ text: ` ❯ ${WIZARD_STEPS[w.step].prompt}`, color: ansi.bold + C.text })
|
|
1202
|
+
lines.push({ text: " (在下方输入框输入)", color: C.dim })
|
|
1203
|
+
w.selectedLine = 0
|
|
1204
|
+
}
|
|
1205
|
+
if (w.error) lines.push({ text: ` ${w.error}`, color: C.error })
|
|
1206
|
+
w.lines = lines
|
|
1207
|
+
render()
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function wizardChooseProvider(item) {
|
|
1211
|
+
const w = state.wizard
|
|
1212
|
+
if (item.kind === "custom") {
|
|
1213
|
+
w.step = "name"
|
|
1214
|
+
} else {
|
|
1215
|
+
w.fields = { name: item.name, baseURL: item.baseURL, model: item.model }
|
|
1216
|
+
w.step = "key"
|
|
1217
|
+
}
|
|
1218
|
+
renderWizard()
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function wizardSubmitText() {
|
|
1222
|
+
const w = state.wizard
|
|
1223
|
+
const value = state.input.join("").trim()
|
|
1224
|
+
const ok = WIZARD_STEPS[w.step].validate(value)
|
|
1225
|
+
if (ok !== true) {
|
|
1226
|
+
w.error = ok
|
|
1227
|
+
renderWizard()
|
|
1228
|
+
return
|
|
1229
|
+
}
|
|
1230
|
+
w.error = null
|
|
1231
|
+
state.input = []
|
|
1232
|
+
state.cursor = 0
|
|
1233
|
+
w.fields[w.step === "key" ? "key" : w.step] = w.step === "baseURL" ? value.replace(/\/+$/, "") : value
|
|
1234
|
+
const next = WIZARD_NEXT[w.step]
|
|
1235
|
+
if (next) {
|
|
1236
|
+
w.step = next
|
|
1237
|
+
renderWizard()
|
|
1238
|
+
} else {
|
|
1239
|
+
finishWizard().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function cancelWizard() {
|
|
1244
|
+
state.wizard = null
|
|
1245
|
+
pushLine("已跳过初始配置。之后随时可用 /provider add 添加提供商、/provider key 配 key。", C.dim)
|
|
1246
|
+
render()
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** 向导完成:写入 provider(有则更新)、设为激活、持久化,然后接模型选择器 */
|
|
1250
|
+
async function finishWizard() {
|
|
1251
|
+
const f = state.wizard.fields
|
|
1252
|
+
state.wizard = null
|
|
1253
|
+
const existing = agent.providers.find((p) => p.name === f.name)
|
|
1254
|
+
if (existing) Object.assign(existing, { baseURL: f.baseURL, model: f.model, apiKey: f.key })
|
|
1255
|
+
else agent.providers.push({ name: f.name, baseURL: f.baseURL, model: f.model, apiKey: f.key })
|
|
1256
|
+
agent.activeProvider = f.name
|
|
1257
|
+
agent.provider = { ...agent.providers.find((p) => p.name === f.name) }
|
|
1258
|
+
if (agent.config?.agent?.compactThresholdAuto) {
|
|
1259
|
+
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
1260
|
+
agent.config.agent.compactThreshold = resolveCompactThreshold(null, f.model).value
|
|
1261
|
+
}
|
|
1262
|
+
await persistRaw((raw) => {
|
|
1263
|
+
raw.providers = agent.providers
|
|
1264
|
+
raw.activeProvider = f.name
|
|
1265
|
+
})
|
|
1266
|
+
agent.config.activeProvider = f.name
|
|
1267
|
+
pushLabel(`❯ Setup`, ansi.bold + C.tool)
|
|
1268
|
+
pushLine(`配置完成:${f.name} / ${f.model}(已写入配置文件)`, C.tool)
|
|
1269
|
+
// embedding key:配了就启用向量检索,没配提示事后通道
|
|
1270
|
+
if (f.embedkey) {
|
|
1271
|
+
agent.config.embedding ??= {}
|
|
1272
|
+
agent.config.embedding.apiKey = f.embedkey
|
|
1273
|
+
await persistRaw((raw) => { raw.embedding = { ...(raw.embedding ?? {}), apiKey: f.embedkey } })
|
|
1274
|
+
if (agent.memory && !agent.memory.embedder) {
|
|
1275
|
+
const { createEmbedder } = await import("./embedding.mjs")
|
|
1276
|
+
agent.memory.embedder = createEmbedder(agent.config.embedding)
|
|
1277
|
+
}
|
|
1278
|
+
pushLine(`向量检索已启用(${agent.config.embedding.model ?? "BAAI/bge-m3"})`, C.tool)
|
|
1279
|
+
} else {
|
|
1280
|
+
pushLine(`向量检索未启用(记忆退化为纯文本检索);之后可 /config embedkey <key> 开启`, C.dim)
|
|
1281
|
+
}
|
|
1282
|
+
pushLine(`选择要用的模型(Esc 保持 ${f.model})`, C.dim)
|
|
1283
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/** 选中:切换 provider + 模型,持久化,阈值随模型走 */
|
|
1287
|
+
async function selectModel(item) {
|
|
1288
|
+
closeModelPicker()
|
|
1289
|
+
const target = agent.providers.find((pp) => pp.name === item.provider)
|
|
1290
|
+
if (!target) return
|
|
1291
|
+
target.model = item.model
|
|
1292
|
+
agent.activeProvider = item.provider
|
|
1293
|
+
agent.provider = { ...target }
|
|
1294
|
+
if (!agent.provider.apiKey) {
|
|
1295
|
+
agent.provider.apiKey =
|
|
1296
|
+
process.env.THINCODER_API_KEY || process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY
|
|
1297
|
+
}
|
|
1298
|
+
let thresholdNote = ""
|
|
1299
|
+
if (agent.config?.agent?.compactThresholdAuto) {
|
|
1300
|
+
const { resolveCompactThreshold } = await import("./config.mjs")
|
|
1301
|
+
const { value } = resolveCompactThreshold(null, item.model)
|
|
1302
|
+
agent.config.agent.compactThreshold = value
|
|
1303
|
+
thresholdNote = `,压缩阈值随模型调整为 ${value}`
|
|
1304
|
+
}
|
|
1305
|
+
await persistRaw((raw) => {
|
|
1306
|
+
raw.providers = agent.providers
|
|
1307
|
+
raw.activeProvider = item.provider
|
|
1308
|
+
})
|
|
1309
|
+
agent.config.activeProvider = item.provider
|
|
1310
|
+
pushLabel(`❯ Model`, ansi.bold + C.tool)
|
|
1311
|
+
pushLine(`已切换到 ${item.provider} / ${item.model}${thresholdNote}(已持久化)`, C.tool)
|
|
1312
|
+
if (!agent.provider.apiKey) pushLine(`该 provider 还没配 key: /config key <apikey>`, C.warn)
|
|
1313
|
+
}
|
|
1314
|
+
|
|
732
1315
|
/** /distill:从当前会话提取候选,逐条 y/n 确认后入库 */
|
|
733
1316
|
async function runDistill() {
|
|
734
1317
|
if (agent.history.length === 0) {
|
|
@@ -774,14 +1357,18 @@ export async function startTUI(agent, opts = {}) {
|
|
|
774
1357
|
|
|
775
1358
|
// keypress 挂在过滤后的 keyStream 上:鼠标序列已在上游滤网中处理并剥除
|
|
776
1359
|
keyStream.on("keypress", (str, key = {}) => {
|
|
777
|
-
//
|
|
1360
|
+
// 权限确认态:y 批准 / n 拒绝 / a 批准并开启 AUTO(后续不再询问)
|
|
778
1361
|
if (state.permission) {
|
|
779
1362
|
const answer = (str || "").toLowerCase()
|
|
780
|
-
if (answer === "y" || answer === "n" || key.name === "escape") {
|
|
1363
|
+
if (answer === "y" || answer === "n" || answer === "a" || key.name === "escape") {
|
|
781
1364
|
const { resolve } = state.permission
|
|
782
1365
|
state.permission = null
|
|
783
1366
|
state.status = "Processing..."
|
|
784
|
-
|
|
1367
|
+
if (answer === "a") {
|
|
1368
|
+
agent.autoApprove = true
|
|
1369
|
+
pushLine(` [auto] AUTO 已开启:后续工具调用不再询问(/auto 关闭)`, C.warn)
|
|
1370
|
+
}
|
|
1371
|
+
resolve(answer === "y" || answer === "a")
|
|
785
1372
|
render()
|
|
786
1373
|
}
|
|
787
1374
|
return
|
|
@@ -789,7 +1376,52 @@ export async function startTUI(agent, opts = {}) {
|
|
|
789
1376
|
|
|
790
1377
|
if (key.ctrl && key.name === "c") {
|
|
791
1378
|
cleanup()
|
|
792
|
-
process.exit(0)
|
|
1379
|
+
setTimeout(() => process.exit(0), 100) // 同 /exit:延迟退出避开 libuv 断言
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// 模型选择器:↑↓ 移动,Enter 确认,Esc 取消,其余按键吞掉
|
|
1383
|
+
if (state.picker) {
|
|
1384
|
+
const items = pickerItems()
|
|
1385
|
+
if (key.name === "escape") {
|
|
1386
|
+
closeModelPicker()
|
|
1387
|
+
} else if (key.name === "up" && items.length) {
|
|
1388
|
+
state.picker.index = (state.picker.index - 1 + items.length) % items.length
|
|
1389
|
+
renderPickerLines()
|
|
1390
|
+
} else if (key.name === "down" && items.length) {
|
|
1391
|
+
state.picker.index = (state.picker.index + 1) % items.length
|
|
1392
|
+
renderPickerLines()
|
|
1393
|
+
} else if (key.name === "return" && items.length) {
|
|
1394
|
+
selectModel(items[state.picker.index]).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
1395
|
+
}
|
|
1396
|
+
return
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// 初始配置向导:菜单步 ↑↓/Enter/Esc;文本步 Enter 提交、Esc 取消,编辑键落到正常输入
|
|
1400
|
+
if (state.wizard) {
|
|
1401
|
+
const w = state.wizard
|
|
1402
|
+
if (key.name === "escape") {
|
|
1403
|
+
cancelWizard()
|
|
1404
|
+
return
|
|
1405
|
+
}
|
|
1406
|
+
if (w.step === "provider") {
|
|
1407
|
+
const items = wizardProviderItems()
|
|
1408
|
+
if (key.name === "up" && items.length) {
|
|
1409
|
+
w.index = (w.index - 1 + items.length) % items.length
|
|
1410
|
+
renderWizard()
|
|
1411
|
+
} else if (key.name === "down" && items.length) {
|
|
1412
|
+
w.index = (w.index + 1) % items.length
|
|
1413
|
+
renderWizard()
|
|
1414
|
+
} else if (key.name === "return" && items.length) {
|
|
1415
|
+
wizardChooseProvider(items[w.index])
|
|
1416
|
+
}
|
|
1417
|
+
return
|
|
1418
|
+
}
|
|
1419
|
+
if (key.name === "return") {
|
|
1420
|
+
wizardSubmitText()
|
|
1421
|
+
return
|
|
1422
|
+
}
|
|
1423
|
+
// 文本步骤屏蔽翻页/历史,其余编辑键放行到下面的普通输入逻辑
|
|
1424
|
+
if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
|
|
793
1425
|
}
|
|
794
1426
|
|
|
795
1427
|
// 翻页
|
|
@@ -806,6 +1438,12 @@ export async function startTUI(agent, opts = {}) {
|
|
|
806
1438
|
|
|
807
1439
|
if (state.processing) return // 处理中锁定输入
|
|
808
1440
|
|
|
1441
|
+
// Tab:斜杠命令补全(循环候选);其余输入忽略(\t 会顶破输入框,永不直接插入)
|
|
1442
|
+
if (key.name === "tab") {
|
|
1443
|
+
handleTab()
|
|
1444
|
+
return
|
|
1445
|
+
}
|
|
1446
|
+
|
|
809
1447
|
// 输入历史
|
|
810
1448
|
if (key.name === "up") {
|
|
811
1449
|
if (state.history.length) {
|
|
@@ -874,9 +1512,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
874
1512
|
return
|
|
875
1513
|
}
|
|
876
1514
|
|
|
877
|
-
// 可打印字符 / 粘贴(str
|
|
1515
|
+
// 可打印字符 / 粘贴(str 可能一次多个字符);Tab 一律转成两个空格(\t 显示宽度不定,会顶破输入框)
|
|
878
1516
|
if (str && !key.ctrl && !key.meta) {
|
|
879
|
-
const chars = [...str.replace(/\r/g, "")]
|
|
1517
|
+
const chars = [...str.replace(/\r/g, "").replace(/\t/g, " ")]
|
|
880
1518
|
state.input.splice(state.cursor, 0, ...chars)
|
|
881
1519
|
state.cursor += chars.length
|
|
882
1520
|
render()
|
|
@@ -884,7 +1522,13 @@ export async function startTUI(agent, opts = {}) {
|
|
|
884
1522
|
})
|
|
885
1523
|
|
|
886
1524
|
// 启动画面
|
|
887
|
-
|
|
1525
|
+
if (!agent.provider.apiKey) {
|
|
1526
|
+
pushLabel(`欢迎使用 ThinCoder!`, ansi.bold + C.tool)
|
|
1527
|
+
pushLine("检测到还没配置 API key,进入初始配置(Esc 可随时跳过)", C.text)
|
|
1528
|
+
startWizard()
|
|
1529
|
+
} else {
|
|
1530
|
+
pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
|
|
1531
|
+
}
|
|
888
1532
|
pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
|
|
889
1533
|
// 恢复上次会话:重建对话区显示(tool 结果行省略,保持清爽)
|
|
890
1534
|
if (opts.restored?.history?.length) {
|