thincoder 0.12.60 → 0.12.61
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/CHANGELOG.md +24 -1
- package/README.md +8 -6
- package/bin/thincoder.mjs +27 -122
- package/package.json +3 -2
- package/src/abort-provenance.mjs +116 -0
- package/src/acp/bridge.mjs +38 -17
- package/src/acp.mjs +6 -1
- package/src/advisor/citations.mjs +83 -21
- package/src/advisor/compaction.mjs +174 -0
- package/src/advisor/loop.mjs +293 -0
- package/src/advisor/messages.mjs +36 -134
- package/src/advisor/project-context.mjs +194 -0
- package/src/advisor/repos.mjs +17 -40
- package/src/advisor/run.mjs +124 -329
- package/src/advisor/truncate.mjs +57 -0
- package/src/advisor.mjs +3 -2
- package/src/agent/completion.mjs +1 -1
- package/src/agent/dispatch.mjs +47 -12
- package/src/agent/helpers.mjs +71 -13
- package/src/agent/record-results.mjs +13 -5
- package/src/agent/relay-prefix.mjs +39 -0
- package/src/agent/run-stages.mjs +21 -6
- package/src/agent/setup-reminders.mjs +16 -9
- package/src/agent/setup.mjs +92 -128
- package/src/agent/spawn-child.mjs +26 -9
- package/src/agent-tools/advisor-async.mjs +70 -180
- package/src/agent-tools/advisor-settle.mjs +231 -0
- package/src/agent-tools/advisor.mjs +69 -20
- package/src/agent-tools/batch-segment.mjs +195 -0
- package/src/agent-tools/consult.mjs +23 -10
- package/src/agent-tools/design-token.mjs +14 -1
- package/src/agent-tools/digest-budget.mjs +76 -0
- package/src/agent-tools/eng.mjs +3 -3
- package/src/agent-tools/escalate-async.mjs +16 -13
- package/src/agent-tools/read-history.mjs +13 -3
- package/src/agent-tools/review-streak.mjs +93 -0
- package/src/agent-tools/settings.mjs +130 -17
- package/src/agent-tools/subagent-actions.mjs +15 -6
- package/src/agent-tools/subagent-async.mjs +66 -14
- package/src/agent-tools/subagent-panel.mjs +22 -15
- package/src/agent-tools/subagent-run.mjs +9 -6
- package/src/agent-tools/subagent-scheduler.mjs +57 -8
- package/src/agent-tools/subagent-spawn.mjs +63 -16
- package/src/agent-tools/subagent.mjs +175 -49
- package/src/agent-tools/verify.mjs +13 -34
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +38 -21
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +23 -7
- package/src/cli/memory-command.mjs +2 -2
- package/src/cli/setup-wizard.mjs +29 -9
- package/src/completions.mjs +114 -0
- package/src/config-migrate.mjs +70 -0
- package/src/config.mjs +132 -63
- package/src/conventions.mjs +223 -0
- package/src/crash-reports.mjs +7 -2
- package/src/expand-home.mjs +16 -0
- package/src/generate-title.mjs +1 -1
- package/src/hooks.mjs +7 -3
- package/src/memory/code-index.mjs +9 -3
- package/src/memory/code-sync.mjs +70 -31
- package/src/memory/delete.mjs +2 -0
- package/src/memory/docs.mjs +10 -6
- package/src/memory/file-walk.mjs +109 -0
- package/src/memory/schema.mjs +15 -3
- package/src/model-ref.mjs +66 -0
- package/src/model-specs.mjs +42 -8
- package/src/prompt-overlays.mjs +73 -16
- package/src/prompts/advisor-design.md +18 -8
- package/src/prompts/advisor-round1.md +7 -1
- package/src/prompts/advisor-round2.md +13 -2
- package/src/prompts/advisor-round3.md +13 -2
- package/src/prompts/common.md +115 -0
- package/src/prompts/consult-base.md +2 -0
- package/src/prompts/discipline-engineering.md +217 -0
- package/src/prompts/discipline-normal.md +179 -0
- package/src/prompts/persona-coder.md +21 -0
- package/src/prompts/persona-eng-coder.md +37 -0
- package/src/prompts/persona-eng-designer.md +55 -0
- package/src/prompts/persona-engineering.md +54 -0
- package/src/prompts/persona-explore.md +15 -0
- package/src/prompts/persona-normal.md +27 -0
- package/src/prompts/persona-plan.md +26 -0
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +13 -32
- package/src/provider/errors.mjs +26 -1
- package/src/provider/google.mjs +5 -6
- package/src/provider/index.mjs +2 -1
- package/src/provider/list-models.mjs +93 -0
- package/src/provider/rate.mjs +2 -1
- package/src/provider/responses.mjs +5 -3
- package/src/provider/sse.mjs +3 -4
- package/src/proxy.mjs +9 -14
- package/src/session-slots.mjs +7 -2
- package/src/session.mjs +59 -38
- package/src/token-ttl.mjs +2 -1
- package/src/tools/{system.mjs → bash.mjs} +6 -243
- package/src/tools/file.mjs +30 -10
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +15 -34
- package/src/tools/index.mjs +4 -2
- package/src/tools/ops.mjs +20 -7
- package/src/tools/question.md +1 -0
- package/src/tools/question.mjs +26 -0
- package/src/tools/read.md +1 -1
- package/src/tools/read_image.md +1 -1
- package/src/tools/search.mjs +236 -0
- package/src/tui/agent-turn.mjs +32 -13
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +7 -1
- package/src/tui/cmd-advisor.mjs +3 -2
- package/src/tui/cmd-config.mjs +108 -37
- package/src/tui/cmd-eng.mjs +11 -27
- package/src/tui/cmd-exit.mjs +6 -8
- package/src/tui/cmd-model.mjs +14 -12
- package/src/tui/cmd-reindex.mjs +7 -0
- package/src/tui/cmd-submodel.mjs +8 -5
- package/src/tui/cmd-undo.mjs +4 -3
- package/src/tui/index.mjs +38 -37
- package/src/tui/key-handler.mjs +61 -17
- package/src/tui/key-modes.mjs +86 -8
- package/src/tui/layout.mjs +18 -10
- package/src/tui/model-catalog.mjs +89 -0
- package/src/tui/model-picker.mjs +498 -0
- package/src/tui/mouse.mjs +47 -10
- package/src/tui/pickers.mjs +28 -410
- package/src/tui/render-frame.mjs +32 -16
- package/src/tui/render-loop.mjs +2 -0
- package/src/tui/render-segments.mjs +5 -19
- package/src/tui/render.mjs +37 -5
- package/src/tui/slash-commands.mjs +2 -2
- package/src/tui/subagent-blocks.mjs +69 -90
- package/src/tui/subagent-children.mjs +50 -64
- package/src/tui/subagent-freeze.mjs +40 -43
- package/src/tui/subagent-panel.mjs +21 -66
- package/src/tui/suspension-drive.mjs +28 -82
- package/src/tui/tool-args.mjs +4 -2
- package/src/tui/tool-display.mjs +4 -3
- package/src/tui/tool-events.mjs +23 -16
- package/src/tui/tui-lifecycle.mjs +18 -5
- package/src/tui/wizard.mjs +52 -18
- package/src/tui/wrapped-spawn.mjs +38 -0
- package/src/prompts/coder.md +0 -13
- package/src/prompts/discipline.md +0 -84
- package/src/prompts/eng-coder.md +0 -19
- package/src/prompts/engineering-sub.md +0 -14
- package/src/prompts/engineering.md +0 -87
- package/src/prompts/explore.md +0 -12
- package/src/prompts/main.md +0 -34
- package/src/prompts/methodology-template.md +0 -38
- package/src/prompts/plan.md +0 -9
- package/src/prompts/system.md +0 -44
package/src/tui/layout.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Computes position and height of each panel from state + terminal dimensions.
|
|
4
4
|
* Does not modify state — side effects are performed by the caller before rendering.
|
|
5
5
|
*
|
|
6
|
-
* header → conversation → subagent 面板 → todo → picker → permission →
|
|
6
|
+
* header → conversation → subagent 面板 → todo → picker → permission → input → status
|
|
7
7
|
* Running subagent activity renders in a FIXED bottom panel between the
|
|
8
8
|
* conversation and the todo panel (AGENT-LOOP.md §7.2.1) — full adaptive height
|
|
9
9
|
* (the conversation shrinks); compressed away first on small terminals (to 0 =
|
|
@@ -26,6 +26,13 @@ const MAX_INPUT_LINES = 5
|
|
|
26
26
|
const MAX_TASK_LINES = 5
|
|
27
27
|
const QWIN = 5
|
|
28
28
|
|
|
29
|
+
/** 输入框内容宽度单源(第 31 批——TUI-INPUT-BOX.md §9.3 #3):`computeLayout` 的输入框
|
|
30
|
+
* layoutInput 宽度与键处理侧竖移(`moveCursorVertical`)共用——布局与竖移零漂移。
|
|
31
|
+
* 含 2 列行前缀(`▸ ` / 续行 2 空格);layoutInput 内部再为前缀预留 2 列。 */
|
|
32
|
+
export function inputContentWidth(cols) {
|
|
33
|
+
return Math.max(20, cols - 1) - 4
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
/** question 自由文本态渲染布局:与主输入框同实现(TUI-INPUT-BOX.md §7.2 round2 #1/#6)——
|
|
30
37
|
* 折行展开(`▸ ` 首行 + 续行 2 空格缩进)+ 光标行列;answer 单行不变式(无 \n 字符)由
|
|
31
38
|
* 输入侧硬守卫(Ctrl+J no-op / 粘贴 \n→空格),此处只复用 layoutInput 的折行/光标语义,
|
|
@@ -40,11 +47,14 @@ export function layoutAnswer(chars, cursor, width) {
|
|
|
40
47
|
*/
|
|
41
48
|
export function computeLayout(state, { cols, rows }) {
|
|
42
49
|
const W = Math.max(20, cols - 1)
|
|
50
|
+
const inputW = inputContentWidth(cols) // 输入框内容宽(= W - 4)——单源:布局与竖移共用
|
|
43
51
|
|
|
44
52
|
// --- input box ---
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
const
|
|
53
|
+
// Ctrl+I 注入框(第 31 批):{ chars, cursor } codepoint 数组 + 光标——与主输入框同渲染路径(§8)。
|
|
54
|
+
const ip = state.interruptPrompt
|
|
55
|
+
const inputBuf = state.search ? [...state.search.query] : (ip ? ip.chars : state.input)
|
|
56
|
+
const inputCursor = state.search ? inputBuf.length : (ip ? Math.max(0, Math.min(ip.cursor ?? inputBuf.length, inputBuf.length)) : state.cursor)
|
|
57
|
+
const inputLayout = layoutInput(inputBuf, inputCursor, inputW)
|
|
48
58
|
let inputOffset = 0
|
|
49
59
|
if (inputLayout.lines.length > MAX_INPUT_LINES) {
|
|
50
60
|
inputOffset = Math.min(inputLayout.cursorLine, inputLayout.lines.length - MAX_INPUT_LINES)
|
|
@@ -66,7 +76,7 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
66
76
|
} else {
|
|
67
77
|
const answerChars = Array.isArray(q.answer) ? q.answer : q.answer ? [...q.answer] : []
|
|
68
78
|
const qCursor = Math.max(0, Math.min(q.cursor ?? answerChars.length, answerChars.length))
|
|
69
|
-
questionLayout = layoutAnswer(answerChars, qCursor,
|
|
79
|
+
questionLayout = layoutAnswer(answerChars, qCursor, inputW)
|
|
70
80
|
if (questionLayout.lines.length > MAX_INPUT_LINES) {
|
|
71
81
|
questionOffset = Math.min(questionLayout.cursorLine, questionLayout.lines.length - MAX_INPUT_LINES)
|
|
72
82
|
}
|
|
@@ -127,11 +137,10 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
127
137
|
permPreviewH = 1 + permPreviewLines.length // 1 for header line
|
|
128
138
|
}
|
|
129
139
|
|
|
130
|
-
// Queue
|
|
131
|
-
const queueH = state.queue.length > 0 && state.processing ? 1 : 0
|
|
140
|
+
// Queue 面板已撤(INPUT-LOCK-ASYNC F-7——2026-09-09——busy 提交吞——无排队展示)
|
|
132
141
|
|
|
133
142
|
// --- elastic panel: conversation takes remaining space ---
|
|
134
|
-
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH +
|
|
143
|
+
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + subagentH
|
|
135
144
|
let convH = Math.max(1, rows - fixedH)
|
|
136
145
|
|
|
137
146
|
// 小终端高度补偿:subagent 面板最先让位(可至 0 隐藏,活动仍进缓冲区不丢),
|
|
@@ -183,13 +192,12 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
183
192
|
const todo = todoFinalH > 0 ? { y, h: todoFinalH } : null; y += todoFinalH
|
|
184
193
|
const picker = pickerFinalH > 0 ? { y, h: pickerFinalH } : null; y += pickerFinalH
|
|
185
194
|
const permission = permFinalH > 0 ? { y, h: permFinalH } : null; y += permFinalH
|
|
186
|
-
const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
|
|
187
195
|
const inputBox = { y, h: inputBoxH }; y += inputBoxH
|
|
188
196
|
const status = { y, h: statusH }
|
|
189
197
|
|
|
190
198
|
return {
|
|
191
199
|
W, cols, rows,
|
|
192
|
-
panels: { header, conversation, subagent, picker, todo, permission,
|
|
200
|
+
panels: { header, conversation, subagent, picker, todo, permission, inputBox, status },
|
|
193
201
|
// precomputed content (affects height, reused during render)
|
|
194
202
|
inputLayout,
|
|
195
203
|
inputOffset,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-catalog.mjs — 会话级模型清单 helper(PROVIDER.md §16.2 M2/M9——2026-09-10 MODEL-SELECTION)。
|
|
3
|
+
*
|
|
4
|
+
* 清单唯一权威 = provider 运行期拉取(`GET /models`——provider/list-models.mjs 按 format 分派)。
|
|
5
|
+
* 本模块提供:
|
|
6
|
+
* - `getProviderModels(providerConfig)`:拉取 + **会话级缓存**(TTL 60s;失败不缓存——下次重试);
|
|
7
|
+
* - `probeChannelModels(providerConfig)`:配置阶段准入探(M9——探通入缓存候选直接可用 / 探不通返回
|
|
8
|
+
* 明示文案;调用方决定标注与「不入可选来源」——绝不阻断保存);
|
|
9
|
+
* - `modelListFailureText(error)`:M8 失败消息本体(逐字长句——界面明示的唯一断言对象);
|
|
10
|
+
* - `dedupeModels` / `modelSeries`:显示归并(自 model-picker.mjs 迁入——会话面与槽位面共用)。
|
|
11
|
+
*
|
|
12
|
+
* 缓存时钟可注入(`_catalogHooks.now`——先例 rate.mjs `_rateHooks`):测试假时钟确定性断言 T6 不依赖壁钟。
|
|
13
|
+
*/
|
|
14
|
+
import { listModels } from "../provider/list-models.mjs"
|
|
15
|
+
|
|
16
|
+
/** 会话级缓存 TTL(§16.4 a2:连续操作不重复付网络;60s 新鲜窗口)。 */
|
|
17
|
+
const CACHE_TTL_MS = 60_000
|
|
18
|
+
|
|
19
|
+
/** 测试钩子(先例 `rate.mjs` `_rateHooks`):时钟可注入——T6 假时钟断言。 */
|
|
20
|
+
export const _catalogHooks = { now: () => Date.now() }
|
|
21
|
+
|
|
22
|
+
/** 会话内缓存:key = name + baseURL(同渠道不同端点各自成条);value = { models, at }。 */
|
|
23
|
+
const _cache = new Map()
|
|
24
|
+
|
|
25
|
+
/** 测试缝:清空会话缓存(跨用例隔离——生产无调用点)。 */
|
|
26
|
+
export function _clearModelCatalogCache() { _cache.clear() }
|
|
27
|
+
|
|
28
|
+
function cacheKey(providerConfig) {
|
|
29
|
+
return `${providerConfig?.name ?? ""}\u0000${providerConfig?.baseURL ?? ""}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 拉取渠道模型清单(会话缓存 TTL 60s)。命中缓存(TTL 内)直接返回,否则走 M1 拉取;
|
|
34
|
+
* **失败不缓存**(下次调用重试——失败态由调用方降级展示)。
|
|
35
|
+
* `fresh: true` 跳过缓存读(仍写回——M9 准入探必须真发一次请求:换 key 不得被旧 key 的缓存短路)。
|
|
36
|
+
* 抛错契约与 listModels 一致。
|
|
37
|
+
*/
|
|
38
|
+
export async function getProviderModels(providerConfig, { signal, fresh = false } = {}) {
|
|
39
|
+
const key = cacheKey(providerConfig)
|
|
40
|
+
if (!fresh) {
|
|
41
|
+
const hit = _cache.get(key)
|
|
42
|
+
if (hit && _catalogHooks.now() - hit.at < CACHE_TTL_MS) return hit.models
|
|
43
|
+
}
|
|
44
|
+
const models = await listModels(providerConfig, { signal })
|
|
45
|
+
_cache.set(key, { models, at: _catalogHooks.now() })
|
|
46
|
+
return models
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** M8 失败消息本体(逐字长句——状态 = HTTP 状态码或网络错误摘要;无绕过指引)。
|
|
50
|
+
* 分工:消息本体 = 本长句;状态标签 = `不可用`(列表行内短标)。 */
|
|
51
|
+
export function modelListFailureText(error) {
|
|
52
|
+
const status = /GET \/models failed (\d+)/.exec(error?.message ?? "")?.[1]
|
|
53
|
+
?? String(error?.message ?? error)
|
|
54
|
+
return `该渠道不提供模型列表(GET /models ${status})——无法选择模型,请改用其他渠道`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* M9 配置阶段准入探:对目标渠道探一次 `GET /models`(复用 M1 + 既有超时;**fresh——真发一次
|
|
59
|
+
* 请求**(换 key 不被旧缓存短路);探通入会话缓存——该流内候选直接可用)。
|
|
60
|
+
* 返回 `{ ok:true, list }` | `{ ok:false, message }`——**不抛错**(探不通不是异常:调用方标注
|
|
61
|
+
* 不可用 + 明示原因,配置流不阻断)。
|
|
62
|
+
*/
|
|
63
|
+
export async function probeChannelModels(providerConfig) {
|
|
64
|
+
try {
|
|
65
|
+
return { ok: true, list: await getProviderModels(providerConfig, { fresh: true }) }
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { ok: false, message: modelListFailureText(error) }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Strip known version/date suffixes to get the "series" name of a model.
|
|
72
|
+
* e.g. "qwen-max-latest" → "qwen-max", "qwen-max-2024-09-19" → "qwen-max" */
|
|
73
|
+
export function modelSeries(name) {
|
|
74
|
+
return name
|
|
75
|
+
.replace(/-latest$/, "")
|
|
76
|
+
.replace(/-\d{4}-\d{2}-\d{2}$/, "") // date suffix like -2024-09-19
|
|
77
|
+
.replace(/-\d{8}$/, "") // date suffix like -20240919
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Dedupe model list: group by series, keep shortest name per group. */
|
|
81
|
+
export function dedupeModels(models) {
|
|
82
|
+
const groups = new Map()
|
|
83
|
+
for (const m of models) {
|
|
84
|
+
const series = modelSeries(m)
|
|
85
|
+
const existing = groups.get(series)
|
|
86
|
+
if (!existing || m.length < existing.length) groups.set(series, m)
|
|
87
|
+
}
|
|
88
|
+
return [...groups.values()].sort()
|
|
89
|
+
}
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-picker.mjs — /model two-level picker + provider management + session-slot model
|
|
3
|
+
* selection(MODEL-MERGE-SESSION 拆分产物——/model 两级面自 pickers.mjs 迁出)。
|
|
4
|
+
*
|
|
5
|
+
* 语义(2026-09-10 MODEL-SELECTION v2):selectModel 写**会话槽**(agent 内存态 + saveSession)——
|
|
6
|
+
* 不写 config(/model 不再串扰全局默认——根治)。候选 = **运行期拉取**(`GET /models`——M2;
|
|
7
|
+
* 拉到的行直接可选);显式 `provider:model` 一律放行(M4——仅[空值/裸值/未知 provider]无效)。
|
|
8
|
+
* 切换成功回显规格来源(M6);渠道默认模型 = `providers[].model` 单值(M3——显示回退读取)。
|
|
9
|
+
* 配置写入面(加渠道 / 设 key)探一次 `/models`(M9——探不通标「不可用」+ 明示原因,不阻断保存)。
|
|
10
|
+
* 渠道管理流(add/remove/key/context)是 provider 级 config 写——语义不变。F-4 (ISSUE-FIX-BATCH):
|
|
11
|
+
* removeProviderFlow 级联清理悬挂引用(cascadeRemoveProvider——文件尾导出)。
|
|
12
|
+
* ctx: { agent, state, render, ansi, C, pushLine, persistRaw, askQuestion, maskKey, showPicker,
|
|
13
|
+
* closePicker, renderPickerLines }(后三者为 pickers.mjs 通用 picker 绑定——闭包入参,环安全)。
|
|
14
|
+
*/
|
|
15
|
+
import { sliceByWidth } from "./render.mjs"
|
|
16
|
+
import { PROVIDER_PRESETS as PRESETS, providerSpec, specMatch } from "../config.mjs"
|
|
17
|
+
import { saveSession } from "../session.mjs"
|
|
18
|
+
import { getProviderModels, probeChannelModels, modelListFailureText, dedupeModels } from "./model-catalog.mjs"
|
|
19
|
+
|
|
20
|
+
/** createModelPicker(ctx) → { openModelPicker, selectModel, setProviderKey, setContextFlow, pickModelForSlot } */
|
|
21
|
+
export function createModelPicker(ctx) {
|
|
22
|
+
const { agent, state, pushLine, persistRaw, askQuestion, maskKey, showPicker, closePicker, renderPickerLines } = ctx
|
|
23
|
+
const { ansi, C } = ctx
|
|
24
|
+
|
|
25
|
+
/** entry 唯一标识:异步更新 entries 后按它恢复选中项 */
|
|
26
|
+
function entryKey(e) {
|
|
27
|
+
if (!e) return null
|
|
28
|
+
return e.action === "switch" ? `switch:${e.provider}:${e.model}` : `action:${e.action}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 模型信息显示的 context 窗口(PROVIDER.md §15 D-C5):K 单位形态跟随覆盖值。 */
|
|
32
|
+
function fmtContextK(tokens) {
|
|
33
|
+
if (tokens >= 1_000_000) return `${Math.round(tokens / 1_048_576)}M`
|
|
34
|
+
return `${Math.round(tokens / 1024)}K`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Get API key for a provider (config.json only — env vars are not a key source) */
|
|
38
|
+
function getApiKey(providerConfig) {
|
|
39
|
+
return providerConfig.apiKey
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 渠道默认模型显示值(M3③:无默认模型显 "(no default model)"——替代旧 "(no candidates)")。 */
|
|
43
|
+
function defaultModelLabel(p) {
|
|
44
|
+
return p?.model ? p.model : "(no default model)"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 异步更新 entries 后的选中项恢复(selKey 命中则回原位,否则钳位)。 */
|
|
48
|
+
function restoreSelection(entries, selKey) {
|
|
49
|
+
const pk = state.picker
|
|
50
|
+
if (!pk) return
|
|
51
|
+
const itemRows = entries.filter((e) => e.type === "item")
|
|
52
|
+
const restored = selKey ? itemRows.findIndex((e) => entryKey(e) === selKey) : -1
|
|
53
|
+
pk.index = restored >= 0 ? restored : Math.min(pk.index, Math.max(0, itemRows.length - 1))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Level 1: provider list(/model 入口——session 复合两级面)。 */
|
|
57
|
+
async function openModelPicker() {
|
|
58
|
+
for (;;) {
|
|
59
|
+
const entries = buildProviderEntries()
|
|
60
|
+
const items = entries.filter((e) => e.type === "item")
|
|
61
|
+
const current = items.findIndex(
|
|
62
|
+
(e) => e.action === "open-models" && e.provider === agent.activeProvider)
|
|
63
|
+
const picked = showPicker("Models & Providers", entries, { defaultIndex: Math.max(0, current) })
|
|
64
|
+
const e = await picked
|
|
65
|
+
if (!e) return
|
|
66
|
+
if (e.action === "open-models") {
|
|
67
|
+
const modelSelected = await openModelListForProvider(e.provider)
|
|
68
|
+
if (modelSelected) return // model selected, close picker
|
|
69
|
+
// Esc from model list → return to provider list
|
|
70
|
+
} else if (e.action === "add") {
|
|
71
|
+
await addProviderFlow()
|
|
72
|
+
} else if (e.action === "remove") {
|
|
73
|
+
await removeProviderFlow()
|
|
74
|
+
} else if (e.action === "key") {
|
|
75
|
+
await setKeyFlow()
|
|
76
|
+
} else if (e.action === "context") {
|
|
77
|
+
await setContextFlow()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Level 2: model list for a specific provider(M2——候选 = 运行期拉取)。返回 true = 已选定模型。 */
|
|
83
|
+
async function openModelListForProvider(providerName) {
|
|
84
|
+
const providerConfig = agent.providers.find((p) => p.name === providerName)
|
|
85
|
+
if (!providerConfig) return false
|
|
86
|
+
|
|
87
|
+
const entries = buildModelEntriesForProvider(providerName, providerConfig)
|
|
88
|
+
const items = entries.filter((e) => e.type === "item")
|
|
89
|
+
const sessionProvider = providerName === agent.activeProvider
|
|
90
|
+
const currentModel = sessionProvider ? agent.activeModel : null
|
|
91
|
+
const current = currentModel ? items.findIndex((e) => e.model === currentModel) : -1
|
|
92
|
+
const picked = showPicker(`${providerName} models`, entries, { defaultIndex: Math.max(0, current) })
|
|
93
|
+
|
|
94
|
+
// 进入 L2 即触发拉取(会话缓存 TTL 60s——失败不缓存;失败态在 header + 提示行展示)
|
|
95
|
+
loadSessionModels(providerName, providerConfig, entries)
|
|
96
|
+
.catch((err) => pushLine(`[model] fetch models failed: ${err.message}`, C.error))
|
|
97
|
+
|
|
98
|
+
const e = await picked
|
|
99
|
+
if (!e) return false // Esc → back to provider list
|
|
100
|
+
if (e.action === "switch") {
|
|
101
|
+
await selectModel(e).catch((err) => pushLine(`[error] ${err.message}`, C.error))
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
if (e.action === "keep") return true // 当前会话模型行——保持(无写)
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Build entries for Level 1: provider list(L1 = 渠道面——session 复合态随行显示) */
|
|
109
|
+
function buildProviderEntries() {
|
|
110
|
+
const entries = []
|
|
111
|
+
for (const p of agent.providers) {
|
|
112
|
+
const active = p.name === agent.activeProvider
|
|
113
|
+
const shown = active ? (agent.activeModel ?? defaultModelLabel(p)) : defaultModelLabel(p)
|
|
114
|
+
const marker = active ? "●" : ""
|
|
115
|
+
// A1(第 20 批 §12.5):渠道警示((no key) / (不可用))上移 text——与既有状态标
|
|
116
|
+
// ((ctx …) / ← session)同簇(先例 = 本文件 setKeyFlow / wizard 的 (added, no key)):
|
|
117
|
+
// 警示不再位于最先牺牲段。note 收窄为 baseURL(补充信息——超宽随行右截断可接受)。
|
|
118
|
+
const sessionNote = active ? " ← session" : ""
|
|
119
|
+
const keyStatus = p.apiKey ? "" : " (no key)"
|
|
120
|
+
const unavailable = p._unavailable ? " (不可用)" : ""
|
|
121
|
+
const ctxTag = ` (ctx ${fmtContextK(providerSpec(p).context)})`
|
|
122
|
+
entries.push({
|
|
123
|
+
type: "item",
|
|
124
|
+
text: `${p.name.padEnd(12)} ${shown}${ctxTag}${keyStatus}${unavailable}${sessionNote}`,
|
|
125
|
+
action: "open-models",
|
|
126
|
+
provider: p.name,
|
|
127
|
+
marker,
|
|
128
|
+
note: p.baseURL,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
entries.push({ type: "header", text: "Management" })
|
|
132
|
+
entries.push({ type: "item", text: "Add provider…", action: "add" })
|
|
133
|
+
if (agent.providers.length > 1) entries.push({ type: "item", text: "Remove provider…", action: "remove" })
|
|
134
|
+
entries.push({ type: "item", text: "Set / change API key…", action: "key" })
|
|
135
|
+
entries.push({ type: "item", text: "Set context window (K units)…", action: "context" })
|
|
136
|
+
return entries
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Level 2 entries(/model 会话面——候选运行期拉取):当前会话行 + 候选区(拉取填充)。
|
|
140
|
+
* 占位行必须存在:showPicker 对 0 item **立即 resolve(null)**(picker 不打开、entries 不可达)——
|
|
141
|
+
* 非会话渠道/空槽渠道的候选区在拉取落地前不能是空的。 */
|
|
142
|
+
function buildModelEntriesForProvider(providerName, providerConfig) {
|
|
143
|
+
const entries = []
|
|
144
|
+
const sessionProvider = providerName === agent.activeProvider
|
|
145
|
+
const sessionModel = sessionProvider ? agent.activeModel : null
|
|
146
|
+
if (sessionModel) {
|
|
147
|
+
entries.push({ type: "header", text: `Current (session): ${sessionModel}` })
|
|
148
|
+
entries.push({ type: "item", text: `${sessionModel} ← keep`, action: "keep", provider: providerName, model: sessionModel })
|
|
149
|
+
}
|
|
150
|
+
entries.push({ type: "header", text: "Available models (loading…)" })
|
|
151
|
+
entries.push(loadingRow())
|
|
152
|
+
return entries
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 拉取期占位行——选中该行视为返回上一级(action:"none" → 非 switch/keep → `return false`;
|
|
156
|
+
* 拉取后台继续、缓存照写——通用 picker 无禁用项概念);拉取落地后由 loader 移除。 */
|
|
157
|
+
function loadingRow() {
|
|
158
|
+
return { type: "item", text: "(loading…)", action: "none", placeholder: true }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 清占位行 + 在 `Available models` header 后填行(空结果/失败也给一条不可选行——
|
|
162
|
+
* picker 已打开,0 item 列表不可交互)。 */
|
|
163
|
+
function fillAvailableModels(entries, rows, emptyText) {
|
|
164
|
+
const phIdx = entries.findIndex((e) => e.placeholder)
|
|
165
|
+
if (phIdx >= 0) entries.splice(phIdx, 1)
|
|
166
|
+
const headerIdx = entries.findIndex((e) => e.type === "header" && e.text.startsWith("Available models"))
|
|
167
|
+
if (headerIdx < 0) return
|
|
168
|
+
entries[headerIdx].text = rows.length ? `Available models (${rows.length} — type to filter)` : "Available models (none)"
|
|
169
|
+
if (rows.length) entries.splice(headerIdx + 1, 0, ...rows)
|
|
170
|
+
else entries.splice(headerIdx + 1, 0, { type: "item", text: emptyText, action: "none" })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 会话面拉取(M2):候选 = 拉取结果直接可选(会话面提升到槽位面语义);归并后并入。
|
|
174
|
+
* 失败:header 标 `(fetch failed: …)` + 一行失败消息本体(M8 长句——该渠道不可选)。 */
|
|
175
|
+
async function loadSessionModels(providerName, providerConfig, entries) {
|
|
176
|
+
let selKey = null
|
|
177
|
+
try {
|
|
178
|
+
const models = await getProviderModels(providerConfig)
|
|
179
|
+
if (state.picker?.entries !== entries) return // picker closed or changed
|
|
180
|
+
const itemRows = entries.filter((e) => e.type === "item" && !e.placeholder)
|
|
181
|
+
selKey = itemRows[state.picker.index] ? entryKey(itemRows[state.picker.index]) : null
|
|
182
|
+
const listed = new Set(itemRows.map((e) => e.model).filter(Boolean))
|
|
183
|
+
const rows = dedupeModels(models).filter((m) => !listed.has(m))
|
|
184
|
+
fillAvailableModels(entries, rows.map((m) => ({
|
|
185
|
+
type: "item", text: m, action: "switch", provider: providerName, model: m,
|
|
186
|
+
})), "(no models)")
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (state.picker?.entries !== entries) return
|
|
189
|
+
const phIdx = entries.findIndex((e) => e.placeholder)
|
|
190
|
+
if (phIdx >= 0) entries.splice(phIdx, 1)
|
|
191
|
+
const headerIdx = entries.findIndex((e) => e.type === "header" && e.text.startsWith("Available models"))
|
|
192
|
+
if (headerIdx >= 0) entries[headerIdx].text = `Available models (fetch failed: ${sliceByWidth(error.message, 30)})`
|
|
193
|
+
entries.splice(headerIdx + 1, 0, { type: "item", text: "(no models — 该渠道不可用)", action: "none" })
|
|
194
|
+
pushLine(modelListFailureText(error), C.error)
|
|
195
|
+
}
|
|
196
|
+
restoreSelection(entries, selKey)
|
|
197
|
+
renderPickerLines()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Level 2 entries(槽位面——/submodel + /config consult 池——advisor/subagent 覆盖语义
|
|
201
|
+
* 独立不受清单约束——红线零改):当前行 + 拉取建议可直接选(写 agent.* 自由串)。
|
|
202
|
+
* 占位行同会话面:候选区在拉取落地前不能为空(showPicker 0 item = 不打开)。 */
|
|
203
|
+
function buildSlotEntriesForProvider(providerName, providerConfig) {
|
|
204
|
+
const entries = []
|
|
205
|
+
const sessionProvider = providerName === agent.activeProvider
|
|
206
|
+
const fallback = providerConfig.model ?? ""
|
|
207
|
+
const currentModel = sessionProvider ? (agent.activeModel || fallback) : fallback
|
|
208
|
+
entries.push({ type: "header", text: "Current model" })
|
|
209
|
+
if (currentModel) {
|
|
210
|
+
entries.push({ type: "item", text: currentModel, action: "switch", provider: providerName, model: currentModel })
|
|
211
|
+
}
|
|
212
|
+
entries.push({ type: "header", text: "Available models (loading…)" })
|
|
213
|
+
entries.push(loadingRow())
|
|
214
|
+
return entries
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** F-3 /model 纯会话级:写槽(agent 内存态 + saveSession)——绝不写 config。
|
|
218
|
+
* 放行语义(M4):仅未知 provider 拒——显式 p:m 一律放行(候选外/多冒号不再拒)。 */
|
|
219
|
+
async function selectModel(item) {
|
|
220
|
+
closePicker()
|
|
221
|
+
const target = agent.providers.find((pp) => pp.name === item.provider)
|
|
222
|
+
if (!target) {
|
|
223
|
+
throw new Error(`Unknown provider: ${item.provider}`)
|
|
224
|
+
}
|
|
225
|
+
if (!item.model) {
|
|
226
|
+
throw new Error(`Missing model name for provider: ${item.provider}`)
|
|
227
|
+
}
|
|
228
|
+
// 内存态(agent 会话运行时)——不触碰 config
|
|
229
|
+
agent.activeProvider = target.name
|
|
230
|
+
agent.activeModel = item.model
|
|
231
|
+
agent.provider = { ...target }
|
|
232
|
+
agent.provider.model = item.model
|
|
233
|
+
if (agent.config?.agent?.compactThresholdAuto) {
|
|
234
|
+
const { resolveCompactThreshold } = await import("../config.mjs")
|
|
235
|
+
agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
|
|
236
|
+
}
|
|
237
|
+
// 写会话槽(saveSession——槽双字段恒非空)——config 文件零写
|
|
238
|
+
saveSession(agent)
|
|
239
|
+
// M6 切换回显:规格来源一行(DEFAULT 兜底 → 警示色 + /config 提示)
|
|
240
|
+
const { matched } = specMatch(agent.provider.model)
|
|
241
|
+
const spec = providerSpec(agent.provider)
|
|
242
|
+
pushLine(
|
|
243
|
+
matched
|
|
244
|
+
? `Model: ${item.provider}:${item.model} — spec found (ctx ${fmtContextK(spec.context)} / out ${fmtContextK(spec.maxOutput)})`
|
|
245
|
+
: `Model: ${item.provider}:${item.model} — spec not found in MODEL_SPECS — default 128K ctx / 32K out; set context in /config to override`,
|
|
246
|
+
matched ? C.tool : C.error,
|
|
247
|
+
)
|
|
248
|
+
if (!agent.provider.apiKey) {
|
|
249
|
+
const selKey = await askQuestion(`Enter API key for ${item.provider} (leave empty to skip):`)
|
|
250
|
+
if (selKey) await setProviderKey(item.provider, selKey)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Slot-bound picker(/submodel + consult 池——红线:subagent/advisor 覆盖语义独立不受清单
|
|
255
|
+
* 约束):两级 provider → model——fetch 建议可直接选(写 agent.* 自由串——provider:model
|
|
256
|
+
* 或自由值——与 subagent 工具 model 参数同语义)。返回 { provider, model } 或 null。 */
|
|
257
|
+
async function pickModelForSlot() {
|
|
258
|
+
for (;;) {
|
|
259
|
+
const providers = agent.providers
|
|
260
|
+
if (!providers.length) return null
|
|
261
|
+
const e = await showPicker("Select provider", providers.map((p) => ({
|
|
262
|
+
type: "item",
|
|
263
|
+
text: `${p.name.padEnd(12)} ${defaultModelLabel(p)}`,
|
|
264
|
+
action: "open-models",
|
|
265
|
+
provider: p.name,
|
|
266
|
+
})))
|
|
267
|
+
if (!e?.provider) return null
|
|
268
|
+
const providerConfig = providers.find((p) => p.name === e.provider)
|
|
269
|
+
if (!providerConfig) return null
|
|
270
|
+
const entries = buildSlotEntriesForProvider(e.provider, providerConfig)
|
|
271
|
+
fetchSlotModels(e.provider, providerConfig, entries).catch((err) => {
|
|
272
|
+
pushLine(`[model] fetch models failed: ${err.message}`, C.error)
|
|
273
|
+
})
|
|
274
|
+
const me = await showPicker(`${e.provider} models`, entries)
|
|
275
|
+
if (!me?.model) continue // Esc from model list → back to provider list
|
|
276
|
+
return { provider: e.provider, model: me.model }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Slot 面 fetch:模型直接并入可选行(旧 pickModelForSlot 语义——自由面;槽位面零改——
|
|
281
|
+
* 仅随 M1 获得三 format 支持 + 占位行(同会话面:0 item 的 picker 不会打开))。 */
|
|
282
|
+
async function fetchSlotModels(providerName, providerConfig, entries) {
|
|
283
|
+
const { listModels } = await import("../provider/index.mjs")
|
|
284
|
+
let selKey = null
|
|
285
|
+
try {
|
|
286
|
+
const models = await listModels(
|
|
287
|
+
{ baseURL: providerConfig.baseURL, apiKey: getApiKey(providerConfig) ?? "", format: providerConfig.format },
|
|
288
|
+
{ signal: AbortSignal.timeout(10000) }
|
|
289
|
+
)
|
|
290
|
+
if (state.picker?.entries !== entries) return
|
|
291
|
+
const itemRows = entries.filter((e) => e.type === "item" && !e.placeholder)
|
|
292
|
+
selKey = itemRows[state.picker.index] ? entryKey(itemRows[state.picker.index]) : null
|
|
293
|
+
const deduped = dedupeModels(models).filter((m) => !itemRows.some((en) => en.model === m))
|
|
294
|
+
fillAvailableModels(entries, deduped.map((m) => ({
|
|
295
|
+
type: "item", text: m, action: "switch", provider: providerName, model: m,
|
|
296
|
+
})), "(no models)")
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (state.picker?.entries !== entries) return
|
|
299
|
+
const phIdx = entries.findIndex((e) => e.placeholder)
|
|
300
|
+
if (phIdx >= 0) entries.splice(phIdx, 1)
|
|
301
|
+
const headerIdx = entries.findIndex((e) => e.type === "header" && e.text.startsWith("Available models"))
|
|
302
|
+
if (headerIdx >= 0) entries[headerIdx].text = `Available models (fetch failed: ${sliceByWidth(error.message, 30)})`
|
|
303
|
+
entries.splice(headerIdx + 1, 0, { type: "item", text: "(no models)", action: "none" })
|
|
304
|
+
}
|
|
305
|
+
restoreSelection(entries, selKey)
|
|
306
|
+
renderPickerLines()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ═══ 渠道管理流(provider 级 config 写——语义不变——与 /model 会话选择分离)═══
|
|
310
|
+
|
|
311
|
+
/** M9 配置阶段准入探:探通 → 清标记;探不通 → 会话内存标「不可用」+ 明示原因(绝不落 config;
|
|
312
|
+
* 不阻断保存——条目已保存,仅标注)。 */
|
|
313
|
+
async function probeChannelFlow(cfg, label) {
|
|
314
|
+
const r = await probeChannelModels(cfg)
|
|
315
|
+
if (r.ok) {
|
|
316
|
+
delete cfg._unavailable
|
|
317
|
+
pushLine(`${label}: /models 可用(${r.list.length} 个模型可候选)`, C.tool)
|
|
318
|
+
} else {
|
|
319
|
+
cfg._unavailable = true
|
|
320
|
+
pushLine(`${label} 不可用 — ${r.message}`, C.error)
|
|
321
|
+
}
|
|
322
|
+
return r
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function addProviderFlow() {
|
|
326
|
+
const entries = [
|
|
327
|
+
{ type: "header", text: "Select a preset provider" },
|
|
328
|
+
...Object.entries(PRESETS).filter(([name]) => !agent.providers.some((p) => p.name === name))
|
|
329
|
+
.map(([name, p]) => ({ type: "item", text: `${name.padEnd(10)} ${p.desc ?? ""} (${p.model ?? ""})`, name, kind: "preset" })),
|
|
330
|
+
{ type: "header", text: "Other" },
|
|
331
|
+
{ type: "item", text: "Custom (manual config)", name: "__custom__", kind: "custom" },
|
|
332
|
+
]
|
|
333
|
+
const se = await showPicker("Add Provider", entries)
|
|
334
|
+
if (!se) return // Esc → 返回上级(openModelPicker 循环会重开主菜单)
|
|
335
|
+
if (se.kind === "custom") {
|
|
336
|
+
const name = await askQuestion("Enter provider name:")
|
|
337
|
+
if (!name) return
|
|
338
|
+
if (agent.providers.some((p) => p.name === name)) return
|
|
339
|
+
const baseURL = (await askQuestion("Enter baseURL:")).replace(/\/+$/, "")
|
|
340
|
+
if (!baseURL) return
|
|
341
|
+
const model = await askQuestion("Enter model name:")
|
|
342
|
+
if (!model) return
|
|
343
|
+
const format = await showPicker("API format", [
|
|
344
|
+
{ type: "item", text: "openai", name: "openai" },
|
|
345
|
+
{ type: "item", text: "anthropic", name: "anthropic" },
|
|
346
|
+
{ type: "item", text: "google", name: "google" },
|
|
347
|
+
])
|
|
348
|
+
if (!format) return // Esc/取消 → 中止流程
|
|
349
|
+
const cfg = { name, baseURL, model }
|
|
350
|
+
if (format.name === "anthropic" || format.name === "google") cfg.format = format.name
|
|
351
|
+
else if (format.name !== "openai") return // 防御:未知格式(理论不可达——picker 枚举)
|
|
352
|
+
await persistRaw((raw) => { raw.providers ??= []; raw.providers.push(cfg) }) // D-F5a 先盘后存
|
|
353
|
+
agent.providers.push(cfg)
|
|
354
|
+
const key = await askQuestion(`Enter API key for ${name} (skip if none):`)
|
|
355
|
+
if (key) await setProviderKey(name, key, { probe: false }) // 探统一在流尾(M9——精确一次)
|
|
356
|
+
await probeChannelFlow(cfg, name) // M9 准入探(加渠道配置写入面;保存已落——不阻断)
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
const preset = PRESETS[se.name]
|
|
360
|
+
if (!preset || agent.providers.some((p) => p.name === se.name)) return
|
|
361
|
+
const cfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
|
|
362
|
+
if (preset.thinking) cfg.thinking = preset.thinking
|
|
363
|
+
if (preset.reasoningEffort) cfg.reasoningEffort = preset.reasoningEffort
|
|
364
|
+
if (preset.maxTokens) cfg.maxTokens = preset.maxTokens
|
|
365
|
+
if (preset.chatPath) cfg.chatPath = preset.chatPath
|
|
366
|
+
if (preset.format) cfg.format = preset.format
|
|
367
|
+
await persistRaw((raw) => { raw.providers ??= []; raw.providers.push(cfg) }) // D-F5a 先盘后存
|
|
368
|
+
agent.providers.push(cfg)
|
|
369
|
+
const key = await askQuestion(`Enter API key for ${se.name} (skip if none):`)
|
|
370
|
+
if (key) await setProviderKey(se.name, key, { probe: false }) // 探统一在流尾(M9——精确一次)
|
|
371
|
+
await probeChannelFlow(cfg, se.name) // M9 准入探(加渠道配置写入面;保存已落——不阻断)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function removeProviderFlow() {
|
|
375
|
+
const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
|
|
376
|
+
if (!candidates.length) return
|
|
377
|
+
const se = await showPicker("Remove Provider", [
|
|
378
|
+
{ type: "header", text: "Select provider to remove" },
|
|
379
|
+
...candidates.map((p) => ({ type: "item", text: `${p.name} (${defaultModelLabel(p)})`, name: p.name })),
|
|
380
|
+
])
|
|
381
|
+
if (!se) return
|
|
382
|
+
await persistRaw((raw) => {
|
|
383
|
+
raw.providers ??= []
|
|
384
|
+
const idx = raw.providers.findIndex((p) => p?.name === se.name)
|
|
385
|
+
if (idx !== -1) raw.providers.splice(idx, 1)
|
|
386
|
+
// F-4 级联(IKCDMR——AC-4):同盘清 consultModels/subagentModels/advisor.provider 悬挂引用
|
|
387
|
+
cascadeRemoveProvider(raw, se.name)
|
|
388
|
+
})
|
|
389
|
+
agent.providers.splice(agent.providers.findIndex((p) => p.name === se.name), 1)
|
|
390
|
+
// 会话内存镜像同清(agent.config = loadConfig 产物——consult/escalate 同会话读
|
|
391
|
+
// agent.config.agent.*;merged.advisor 为 agent.advisor 的提升拷贝——双处清理)
|
|
392
|
+
if (agent.config) {
|
|
393
|
+
cascadeRemoveProvider(agent.config, se.name)
|
|
394
|
+
if (agent.config.advisor?.provider === se.name) delete agent.config.advisor.provider
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function setKeyFlow() {
|
|
399
|
+
const se = await showPicker("Configure API Key", [
|
|
400
|
+
{ type: "header", text: "Select provider" },
|
|
401
|
+
...agent.providers.map((p) => ({ type: "item", text: `${p.name} ${p.apiKey ? `(has key: ${maskKey(p.apiKey)})` : "(no key)"}`, name: p.name })),
|
|
402
|
+
])
|
|
403
|
+
if (!se) return
|
|
404
|
+
const key = await askQuestion(`Enter API key for ${se.name}:`)
|
|
405
|
+
if (key) await setProviderKey(se.name, key)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** 设 API key(可以来自渠道管理流 / 加渠道流内的 key 步):写盘 + 内存镜像;
|
|
409
|
+
* M9:设 key 属配置写入面——默认探一次 `/models`(不阻断——key 已保存);
|
|
410
|
+
* 加渠道流内调用传 `{ probe: false }`(探由该流尾部统一执行——精确一次)。 */
|
|
411
|
+
async function setProviderKey(name, key, { probe = true } = {}) {
|
|
412
|
+
const target = agent.providers.find((p) => p.name === name)
|
|
413
|
+
if (!target) return
|
|
414
|
+
await persistRaw((raw) => {
|
|
415
|
+
raw.providers ??= []
|
|
416
|
+
const t = raw.providers.find((p) => p?.name === name)
|
|
417
|
+
if (t) t.apiKey = key
|
|
418
|
+
})
|
|
419
|
+
target.apiKey = key
|
|
420
|
+
if (name === agent.activeProvider) agent.provider.apiKey = key
|
|
421
|
+
if (probe) await probeChannelFlow(target, name)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** /model provider 管理:context 窗口字段(K 单位)——picker 选 provider + 表单输入。 */
|
|
425
|
+
async function setContextFlow() {
|
|
426
|
+
const se = await showPicker("Set Context Window", [
|
|
427
|
+
{ type: "header", text: "Select provider" },
|
|
428
|
+
...agent.providers.map((p) => ({
|
|
429
|
+
type: "item",
|
|
430
|
+
text: `${p.name} (ctx ${fmtContextK(providerSpec(p).context)})`,
|
|
431
|
+
name: p.name,
|
|
432
|
+
})),
|
|
433
|
+
])
|
|
434
|
+
if (!se?.name) return // Esc
|
|
435
|
+
const target = agent.providers.find((p) => p.name === se.name)
|
|
436
|
+
if (!target) return
|
|
437
|
+
const current = Number.isInteger(target.context) && target.context > 0 ? target.context : null
|
|
438
|
+
const val = (await askQuestion(
|
|
439
|
+
`Context window for ${se.name} in K units (current: ${current ? `${current}K` : "spec default"} — e.g. 128 = 128K; empty to clear):`
|
|
440
|
+
))?.trim() ?? ""
|
|
441
|
+
let newCtx // undefined = 清空(回 spec 值)
|
|
442
|
+
if (val !== "") {
|
|
443
|
+
const n = Number(val)
|
|
444
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
445
|
+
pushLine(`Invalid context: "${val}" — must be a positive integer in K units (e.g. 128 = 128K)`, C.error)
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
newCtx = n
|
|
449
|
+
}
|
|
450
|
+
await persistRaw((raw) => {
|
|
451
|
+
raw.providers ??= []
|
|
452
|
+
const t = raw.providers.find((p) => p?.name === se.name)
|
|
453
|
+
if (!t) return
|
|
454
|
+
if (newCtx === undefined) delete t.context
|
|
455
|
+
else t.context = newCtx
|
|
456
|
+
})
|
|
457
|
+
if (newCtx === undefined) delete target.context
|
|
458
|
+
else target.context = newCtx
|
|
459
|
+
if (se.name === agent.activeProvider) {
|
|
460
|
+
if (newCtx === undefined) delete agent.provider.context
|
|
461
|
+
else agent.provider.context = newCtx
|
|
462
|
+
if (agent.config?.agent?.compactThresholdAuto) {
|
|
463
|
+
const { resolveCompactThreshold } = await import("../config.mjs")
|
|
464
|
+
agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
pushLine(target.context !== undefined
|
|
468
|
+
? `ctx = ${target.context}K (${target.context * 1024} tokens)`
|
|
469
|
+
: `context cleared — using model spec (${fmtContextK(providerSpec(target).context)})`, C.tool)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return { openModelPicker, selectModel, setProviderKey, setContextFlow, pickModelForSlot }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** F-4 (IKCDMR) 级联清理(删渠道共享写点):raw/merged config 移除 name 渠道后清悬挂引用——
|
|
476
|
+
* agent.consultModels 条目 / agent.subagentModels 角色值(=== name 或 "name:…" 前缀——
|
|
477
|
+
* 角色值是 "provider:model" | 裸渠道名 | 裸模型名)/ agent.advisor.provider。
|
|
478
|
+
* 纯 mutate(removeProviderFlow persistRaw 的 D-F5 新鲜 raw 上调用;agent.config 内存镜像
|
|
479
|
+
* 子树与 raw.agent 同构可复用)。空数组/空对象键删除(规范形态)。 */
|
|
480
|
+
export function cascadeRemoveProvider(raw, name) {
|
|
481
|
+
const a = raw?.agent
|
|
482
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) return
|
|
483
|
+
if (Array.isArray(a.consultModels)) {
|
|
484
|
+
const keep = a.consultModels.filter((m) => m?.provider !== name)
|
|
485
|
+
if (keep.length) a.consultModels = keep
|
|
486
|
+
else delete a.consultModels
|
|
487
|
+
}
|
|
488
|
+
if (a.subagentModels && typeof a.subagentModels === "object" && !Array.isArray(a.subagentModels)) {
|
|
489
|
+
for (const role of Object.keys(a.subagentModels)) {
|
|
490
|
+
const v = a.subagentModels[role]
|
|
491
|
+
if (typeof v === "string" && (v === name || v.startsWith(`${name}:`))) delete a.subagentModels[role]
|
|
492
|
+
}
|
|
493
|
+
if (Object.keys(a.subagentModels).length === 0) delete a.subagentModels
|
|
494
|
+
}
|
|
495
|
+
if (a.advisor && typeof a.advisor === "object" && !Array.isArray(a.advisor) && a.advisor.provider === name) {
|
|
496
|
+
delete a.advisor.provider
|
|
497
|
+
}
|
|
498
|
+
}
|