dsh-neotui 0.2.2 → 0.3.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/CHANGELOG.md +25 -0
- package/README.md +32 -14
- package/bin/dsh-tui.js +1 -1
- package/package.json +5 -2
- package/src/config.js +46 -11
- package/src/file-picker.js +4 -4
- package/src/keybindings.js +172 -0
- package/src/md.js +23 -21
- package/src/panels.js +1772 -427
- package/src/term.js +18 -10
- package/src/text.js +11 -3
- package/src/views.js +1045 -281
- package/src/widgets.js +37 -27
package/src/panels.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// panels.js — Command palette, model picker, workspace browser, trajectory
|
|
2
2
|
// timeline, jobs/goal panels, and the terminal image viewer (kitty graphics
|
|
3
3
|
// protocol with external-viewer / chafa fallbacks).
|
|
4
|
-
import { Widget, ScrollView, Input, Popup } from "./widgets.js";
|
|
5
|
-
import { strWidth, truncate, pad } from "./text.js";
|
|
4
|
+
import { Widget, ScrollView, Input, Popup, wrapIndex } from "./widgets.js";
|
|
5
|
+
import { strWidth, truncate, pad, graphemes, graphemeWidth } from "./text.js";
|
|
6
6
|
import { renderMd, C } from "./md.js";
|
|
7
7
|
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
@@ -11,6 +11,7 @@ import { spawn, spawnSync, execFileSync } from "node:child_process";
|
|
|
11
11
|
|
|
12
12
|
import { T, cycleTheme, themeName } from "./theme.js";
|
|
13
13
|
import { loadTuiConfig, saveTuiConfig, userPrefix, userName, foldDefaults, keyBindings, setKeyBinding, resetKeyBinding } from "./config.js";
|
|
14
|
+
import { validateKeySpec, describeSpec } from "./keybindings.js";
|
|
14
15
|
// Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
|
|
15
16
|
const K = new Proxy({}, { get(_k, key) { return T[key]; } });
|
|
16
17
|
|
|
@@ -61,6 +62,7 @@ export class Picker extends Widget {
|
|
|
61
62
|
return scored;
|
|
62
63
|
}
|
|
63
64
|
render(screen) {
|
|
65
|
+
if (this.input.value !== this.query) this.input.setValue(this.query);
|
|
64
66
|
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
|
|
65
67
|
screen.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: K.ACCENT, bg: T.BG2 }, this.title);
|
|
66
68
|
this.input.render(screen);
|
|
@@ -88,16 +90,16 @@ export class Picker extends Widget {
|
|
|
88
90
|
if (idx >= 0 && idx < list.length) { this.onPick?.(list[idx]); return true; }
|
|
89
91
|
return true;
|
|
90
92
|
}
|
|
91
|
-
if (ev.kind === "wheel-up") { this.sel =
|
|
92
|
-
if (ev.kind === "wheel-down") { this.sel =
|
|
93
|
+
if (ev.kind === "wheel-up") { this.sel = wrapIndex(this.sel - 1, this.filtered().length); return true; }
|
|
94
|
+
if (ev.kind === "wheel-down") { this.sel = wrapIndex(this.sel + 1, this.filtered().length); return true; }
|
|
93
95
|
return true;
|
|
94
96
|
}
|
|
95
97
|
onKey(ev) {
|
|
96
98
|
if (ev.type === "text") { this.query += ev.text; this.sel = 0; return true; }
|
|
97
99
|
if (ev.type !== "key") return false;
|
|
98
100
|
switch (ev.name) {
|
|
99
|
-
case "up": this.sel =
|
|
100
|
-
case "down": this.sel =
|
|
101
|
+
case "up": this.sel = wrapIndex(this.sel - 1, this.filtered().length); return true;
|
|
102
|
+
case "down": this.sel = wrapIndex(this.sel + 1, this.filtered().length); return true;
|
|
101
103
|
case "enter": { const l = this.filtered(); if (l[this.sel]) { this.onPick?.(l[this.sel]); } return true; }
|
|
102
104
|
case "escape": this.onCancel?.(); return true;
|
|
103
105
|
case "backspace": this.query = this.query.slice(0, -1); this.sel = 0; return true;
|
|
@@ -107,69 +109,205 @@ export class Picker extends Widget {
|
|
|
107
109
|
}
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
// ---- Model picker ----
|
|
112
|
+
// ---- Model picker: provider folders → model files ----
|
|
111
113
|
|
|
112
|
-
export
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
export class ModelPickerBuffer extends Widget {
|
|
115
|
+
constructor(app) {
|
|
116
|
+
const w = Math.max(1, Math.min(88, app.screen.w - 4)), h = Math.max(1, Math.min(28, app.screen.h - 4));
|
|
117
|
+
super({ x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2), w, h });
|
|
118
|
+
this.app = app;
|
|
119
|
+
this.title = "选择模型";
|
|
120
|
+
this.query = "";
|
|
121
|
+
this.filtering = false; // "/" enters filter mode, Ctrl+/ exits (like the other buffers)
|
|
122
|
+
this.sel = 0;
|
|
123
|
+
this.scroll = 0;
|
|
124
|
+
this.collapsed = new Set(); // folded provider ids
|
|
125
|
+
this.items = []; // provider groups [{provider,name,models:[…]}]
|
|
126
|
+
this.rows = []; // flattened tree rows ({kind:"provider"|"model"|"manage"})
|
|
127
|
+
this.loading = false;
|
|
128
|
+
this.manageRow = { kind: "manage" };
|
|
129
|
+
this.input = new Input({ x: this.x + 1, y: this.y + 1, w: this.w - 2, h: 1, prompt: "❯ ", placeholder: "输入以筛选模型…", bg: T.BG2 });
|
|
130
|
+
this.#load();
|
|
131
|
+
}
|
|
132
|
+
async #load() {
|
|
133
|
+
this.loading = true;
|
|
122
134
|
try {
|
|
123
|
-
await app.api.call("
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
135
|
+
const { groups } = await this.app.api.call("llm.models");
|
|
136
|
+
this.items = (groups ?? []).map((g) => ({
|
|
137
|
+
provider: g.id, name: g.name ?? g.id,
|
|
138
|
+
models: (g.models ?? []).map((m) => ({ id: m.id, name: m.name ?? m.id, description: m.description ?? "", efforts: m.reasoning?.efforts ?? [], defaultEffort: m.reasoning?.defaultEffort, provider: g.id })),
|
|
139
|
+
}));
|
|
140
|
+
const cur = this.app.currentModel;
|
|
141
|
+
this.collapsed = new Set(this.items.map((g) => g.provider).filter((p) => p !== cur?.provider));
|
|
142
|
+
this.#rebuildRows();
|
|
143
|
+
const idx = this.rows.findIndex((r) => r.kind === "model" && r.model.provider === cur?.provider && r.model.id === cur?.model);
|
|
144
|
+
this.sel = idx >= 0 ? idx : 0;
|
|
145
|
+
} catch (e) { this.app.toast?.(`模型列表失败: ${e.message}`); }
|
|
146
|
+
this.loading = false;
|
|
147
|
+
this.#clampScroll();
|
|
148
|
+
this.app.redraw?.();
|
|
149
|
+
}
|
|
150
|
+
filteredMatches() {
|
|
151
|
+
if (!this.query) return null;
|
|
152
|
+
const q = this.query.toLowerCase();
|
|
153
|
+
return this.items.flatMap((g) => g.models.filter((m) => fuzzyScore(q, `${m.id} ${m.name} ${m.description}`) > 0).map((model) => ({ group: g, model })));
|
|
154
|
+
}
|
|
155
|
+
#rebuildRows() {
|
|
156
|
+
const rows = [];
|
|
157
|
+
const filtered = this.filteredMatches();
|
|
158
|
+
if (filtered) {
|
|
159
|
+
// Filter mode: every provider with a hit renders expanded.
|
|
160
|
+
const byProvider = new Map();
|
|
161
|
+
for (const { group, model } of filtered) {
|
|
162
|
+
if (!byProvider.has(group.provider)) byProvider.set(group.provider, []);
|
|
163
|
+
byProvider.get(group.provider).push(model);
|
|
152
164
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
for (const m of g.models) {
|
|
159
|
-
items.push({
|
|
160
|
-
label: `${g.id}/${m.id}`,
|
|
161
|
-
hint: m.name ?? m.id,
|
|
162
|
-
provider: g.id, model: m.id,
|
|
163
|
-
efforts: m.reasoning?.efforts ?? [],
|
|
164
|
-
defaultEffort: m.reasoning?.defaultEffort,
|
|
165
|
-
keywords: `${m.description ?? ""} ${g.name}`,
|
|
166
|
-
});
|
|
165
|
+
for (const group of this.items) {
|
|
166
|
+
const models = byProvider.get(group.provider);
|
|
167
|
+
if (!models) continue;
|
|
168
|
+
rows.push({ kind: "provider", group, count: models.length });
|
|
169
|
+
for (const model of models) rows.push({ kind: "model", group, model });
|
|
167
170
|
}
|
|
171
|
+
} else {
|
|
172
|
+
for (const group of this.items) {
|
|
173
|
+
const open = !this.collapsed.has(group.provider);
|
|
174
|
+
rows.push({ kind: "provider", group, count: group.models.length });
|
|
175
|
+
if (open) for (const model of group.models) rows.push({ kind: "model", group, model });
|
|
176
|
+
}
|
|
177
|
+
rows.push(this.manageRow);
|
|
168
178
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
179
|
+
this.rows = rows;
|
|
180
|
+
if (this.sel >= rows.length) this.sel = Math.max(0, rows.length - 1);
|
|
181
|
+
}
|
|
182
|
+
#clampScroll() {
|
|
183
|
+
const lh = Math.max(1, this.h - 3);
|
|
184
|
+
if (this.sel < this.scroll) this.scroll = this.sel;
|
|
185
|
+
else if (this.sel >= this.scroll + lh) this.scroll = this.sel - lh + 1;
|
|
186
|
+
this.scroll = Math.max(0, this.scroll);
|
|
187
|
+
}
|
|
188
|
+
#toggleGroup(provider) {
|
|
189
|
+
if (this.query) return; // filter mode is always expanded
|
|
190
|
+
if (this.collapsed.has(provider)) this.collapsed.delete(provider);
|
|
191
|
+
else this.collapsed.add(provider);
|
|
192
|
+
this.#rebuildRows();
|
|
193
|
+
const idx = this.rows.findIndex((r) => r.kind === "provider" && r.group.provider === provider);
|
|
194
|
+
if (idx >= 0) this.sel = idx; // keep the cursor on the folder so Space toggles in place
|
|
195
|
+
this.#clampScroll();
|
|
196
|
+
this.app.redraw?.();
|
|
197
|
+
}
|
|
198
|
+
async #selectModel(entry) {
|
|
199
|
+
const it = { provider: entry.model.provider, model: entry.model.id, efforts: entry.model.efforts, defaultEffort: entry.model.defaultEffort };
|
|
200
|
+
const efforts = it.efforts ?? [];
|
|
201
|
+
if (efforts.length > 0) {
|
|
202
|
+
const w2 = Math.max(1, Math.min(60, this.app.screen.w - 4)), h2 = Math.max(1, Math.min(efforts.length + 4, this.app.screen.h - 4));
|
|
203
|
+
this.app.overlay = new Picker({
|
|
204
|
+
x: Math.floor((this.app.screen.w - w2) / 2), y: Math.floor((this.app.screen.h - h2) / 2),
|
|
205
|
+
w: w2, h: h2, title: `思考强度 — ${it.model}`,
|
|
206
|
+
items: efforts.map((e) => ({ label: e.name ?? e.id, hint: e.id === it.defaultEffort ? "默认" : (e.description ?? "").slice(0, 28), provider: it.provider, model: it.model, effort: e.id })),
|
|
207
|
+
onCancel: () => { this.app.overlay = this; this.app.redraw(); },
|
|
208
|
+
onPick: (eff) => this.#commitModel({ provider: eff.provider, model: eff.model, effort: eff.effort }),
|
|
209
|
+
});
|
|
210
|
+
this.app.redraw();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await this.#commitModel(it);
|
|
214
|
+
}
|
|
215
|
+
async #commitModel(it) {
|
|
216
|
+
this.app.overlay = null; this.app.redraw?.();
|
|
217
|
+
if (!this.app.currentSession) { this.app.toast?.("先打开一个会话"); return; }
|
|
218
|
+
try {
|
|
219
|
+
await this.app.api.call("session.selectModel", { sessionId: this.app.currentSession, provider: it.provider, model: it.model, ...(it.effort ? { reasoningEffort: it.effort } : {}) });
|
|
220
|
+
this.app.updateModel?.();
|
|
221
|
+
this.app.toast?.(`已切换 ${it.provider}/${it.model}${it.effort ? ` (${it.effort})` : ""}`);
|
|
222
|
+
} catch (e) { this.app.toast?.(`切换失败: ${e.message}`); }
|
|
223
|
+
}
|
|
224
|
+
#activate() {
|
|
225
|
+
const row = this.rows[this.sel];
|
|
226
|
+
if (!row) return;
|
|
227
|
+
if (row.kind === "manage") {
|
|
228
|
+
this.app.overlay = null;
|
|
229
|
+
(typeof this.app.showModelsBuffer === "function" ? this.app.showModelsBuffer() : this.app.setMode?.("models"));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (row.kind === "provider") { this.#toggleGroup(row.group.provider); return; }
|
|
233
|
+
if (row.kind === "model") void this.#selectModel(row);
|
|
234
|
+
}
|
|
235
|
+
render(screen) {
|
|
236
|
+
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
|
|
237
|
+
screen.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: K.ACCENT, bg: T.BG2 }, this.w >= 44 ? " 选择模型 · / 筛选 · Ctrl+/ 退出 · Space 展开 · Enter 确认 " : " 选择模型 ");
|
|
238
|
+
this.input.prompt = this.filtering ? "/ " : "❯ ";
|
|
239
|
+
this.input.setValue(this.filtering ? this.query : "");
|
|
240
|
+
this.input.render(screen);
|
|
241
|
+
const lh = Math.max(1, this.h - 3);
|
|
242
|
+
this.#clampScroll();
|
|
243
|
+
for (let i = 0; i < lh; i++) {
|
|
244
|
+
const idx = this.scroll + i;
|
|
245
|
+
const row = this.rows[idx];
|
|
246
|
+
const y = this.y + 2 + i;
|
|
247
|
+
if (!row) { screen.hline(this.x + 1, this.x + this.w - 2, y, " ", { bg: T.BG2 }); continue; }
|
|
248
|
+
const sel = idx === this.sel;
|
|
249
|
+
const cur = this.app.currentModel;
|
|
250
|
+
screen.fillRect(this.x + 1, y, this.x + this.w - 2, y, " ", { bg: sel ? T.MENUSEL : T.BG2 });
|
|
251
|
+
const style = { fg: sel ? 0xffffff : K.TXT, bg: sel ? T.MENUSEL : T.BG2, attrs: sel ? 1 : 0 };
|
|
252
|
+
let text;
|
|
253
|
+
if (row.kind === "manage") text = "⚙ 管理供应商…";
|
|
254
|
+
else if (row.kind === "provider") {
|
|
255
|
+
const open = this.query ? true : !this.collapsed.has(row.group.provider);
|
|
256
|
+
text = `${open ? "▾" : "▸"} 📁 ${row.group.name} (${row.count})`;
|
|
257
|
+
} else {
|
|
258
|
+
const mark = cur?.provider === row.model.provider && cur?.model === row.model.id ? "●" : "○";
|
|
259
|
+
text = ` ${mark} ${row.model.id}${row.model.name !== row.model.id ? ` ${row.model.name}` : ""}`;
|
|
260
|
+
}
|
|
261
|
+
screen.text(this.x + 2, y, truncate(text, this.w - 4), row.kind === "provider" ? { fg: K.ACCENT, bg: sel ? T.MENUSEL : T.BG2, attrs: sel ? 1 : 0 } : style);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
onMouse(ev) {
|
|
265
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
266
|
+
if (ev.y === this.y + 1) { this.input.onMouse(ev); return true; }
|
|
267
|
+
const idx = this.scroll + (ev.y - this.y - 2);
|
|
268
|
+
if (idx >= 0 && idx < this.rows.length) { this.sel = idx; this.#activate(); }
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
if (ev.kind === "wheel-up") { this.sel = wrapIndex(this.sel - 1, this.rows.length); this.#clampScroll(); this.app.redraw?.(); return true; }
|
|
272
|
+
if (ev.kind === "wheel-down") { this.sel = wrapIndex(this.sel + 1, this.rows.length); this.#clampScroll(); this.app.redraw?.(); return true; }
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
onKey(ev) {
|
|
276
|
+
if (ev.type === "text") {
|
|
277
|
+
// Legacy terminals: "/" as text enters filter mode; typed text filters.
|
|
278
|
+
if (this.filtering) { this.query += ev.text; this.sel = 0; this.#rebuildRows(); this.app.redraw?.(); return true; }
|
|
279
|
+
if (ev.text === "/") { this.filtering = true; this.query = ""; this.#rebuildRows(); this.app.redraw?.(); return true; }
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
if (ev.type !== "key") return false;
|
|
283
|
+
if (ev.ctrl && ev.name === "char" && ev.key === "/") { this.filtering = false; this.query = ""; this.#rebuildRows(); this.app.redraw?.(); return true; }
|
|
284
|
+
switch (ev.name) {
|
|
285
|
+
case "up": this.sel = wrapIndex(this.sel - 1, this.rows.length); this.#clampScroll(); this.app.redraw?.(); return true;
|
|
286
|
+
case "down": this.sel = wrapIndex(this.sel + 1, this.rows.length); this.#clampScroll(); this.app.redraw?.(); return true;
|
|
287
|
+
case "pgup": this.scroll = Math.max(0, this.scroll - Math.max(1, this.h - 3)); return true;
|
|
288
|
+
case "pgdn": this.scroll = this.scroll + Math.max(1, this.h - 3); return true;
|
|
289
|
+
case "enter": this.#activate(); return true;
|
|
290
|
+
case "escape":
|
|
291
|
+
if (this.filtering) { this.filtering = false; this.query = ""; this.#rebuildRows(); this.app.redraw?.(); return true; }
|
|
292
|
+
this.app.overlay = null; this.app.redraw?.(); return true;
|
|
293
|
+
case "backspace":
|
|
294
|
+
if (!this.filtering) return true;
|
|
295
|
+
this.query = this.query.slice(0, -1); this.sel = 0; this.#rebuildRows(); this.app.redraw?.(); return true;
|
|
296
|
+
case "char":
|
|
297
|
+
if (ev.key === " " && !ev.ctrl) { const row = this.rows[this.sel]; if (row?.kind === "provider") this.#toggleGroup(row.group.provider); else if (row?.kind === "model") this.#toggleGroup(row.group.provider); return true; }
|
|
298
|
+
if (!ev.ctrl) {
|
|
299
|
+
if (ev.key === "/") { this.filtering = true; this.query = ""; this.sel = 0; this.#rebuildRows(); this.app.redraw?.(); return true; }
|
|
300
|
+
if (this.filtering) { this.query += ev.text ?? ev.key; this.sel = 0; this.#rebuildRows(); this.app.redraw?.(); }
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function buildModelPicker(app) {
|
|
310
|
+
return new ModelPickerBuffer(app);
|
|
173
311
|
}
|
|
174
312
|
|
|
175
313
|
// ---- Mode (agent preset) & permission pickers ----
|
|
@@ -182,7 +320,7 @@ export function permName(id) { return PERM_NAMES[id] ?? id; }
|
|
|
182
320
|
|
|
183
321
|
/** Four-mode selector: the shipped agent presets (standard/code/minimal/cordis). */
|
|
184
322
|
export function buildModePicker(app) {
|
|
185
|
-
const w = Math.min(66, app.screen.w - 4), h = Math.min(18, app.screen.h - 4);
|
|
323
|
+
const w = Math.max(1, Math.min(66, app.screen.w - 4)), h = Math.max(1, Math.min(18, app.screen.h - 4));
|
|
186
324
|
const picker = new Picker({
|
|
187
325
|
x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2),
|
|
188
326
|
w, h, title: "模式(Agent 预设)",
|
|
@@ -208,7 +346,7 @@ export function buildPermissionPicker(app) {
|
|
|
208
346
|
const perms = app.projections.permissions;
|
|
209
347
|
const options = (perms?.options ?? []).filter((o) => o.value !== "custom");
|
|
210
348
|
const current = perms?.currentValue;
|
|
211
|
-
const w = Math.min(60, app.screen.w - 4), h = Math.min(options.length + 4, 16);
|
|
349
|
+
const w = Math.max(1, Math.min(60, app.screen.w - 4)), h = Math.max(1, Math.min(options.length + 4, 16, app.screen.h - 4));
|
|
212
350
|
return new Picker({
|
|
213
351
|
x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2),
|
|
214
352
|
w, h, title: "权限(沙箱 + 审批)",
|
|
@@ -237,8 +375,8 @@ export class ArchivePanel extends Popup {
|
|
|
237
375
|
onKey(ev) {
|
|
238
376
|
if (ev.type !== "key") return false; const items = this.items(), item = items[this.sel];
|
|
239
377
|
if (ev.name === "escape") { this.app.closeOverlay(); return true; }
|
|
240
|
-
if (ev.name === "up" || (ev.name === "char" && ev.key === "k")) { this.sel =
|
|
241
|
-
if (ev.name === "down" || (ev.name === "char" && ev.key === "j")) { this.sel =
|
|
378
|
+
if (ev.name === "up" || (ev.name === "char" && ev.key === "k")) { this.sel = wrapIndex(this.sel - 1, items.length); this.rebuild(); return true; }
|
|
379
|
+
if (ev.name === "down" || (ev.name === "char" && ev.key === "j")) { this.sel = wrapIndex(this.sel + 1, items.length); this.rebuild(); return true; }
|
|
242
380
|
if (ev.name === "enter" && item) { this.app.closeOverlay(); this.app.openSession(item.sessionId); return true; }
|
|
243
381
|
if (ev.name === "char" && ev.key === "y" && item) { this.app.copyText(item.sessionId); return true; }
|
|
244
382
|
if (ev.name === "char" && ev.key === "e" && item) { this.app.exportSession(item); return true; }
|
|
@@ -284,8 +422,8 @@ export class PresetPanel extends Popup {
|
|
|
284
422
|
onKey(ev) {
|
|
285
423
|
if (ev.type !== "key") return false;
|
|
286
424
|
if (ev.name === "escape") { this.app.closeOverlay(); return true; }
|
|
287
|
-
if (ev.name === "up" || (ev.name === "char" && ev.key === "k")) { this.sel =
|
|
288
|
-
if (ev.name === "down" || (ev.name === "char" && ev.key === "j")) { this.sel =
|
|
425
|
+
if (ev.name === "up" || (ev.name === "char" && ev.key === "k")) { this.sel = wrapIndex(this.sel - 1, this.items.length); this.read(); return true; }
|
|
426
|
+
if (ev.name === "down" || (ev.name === "char" && ev.key === "j")) { this.sel = wrapIndex(this.sel + 1, this.items.length); this.read(); return true; }
|
|
289
427
|
if (ev.name === "enter") { this.read(); return true; }
|
|
290
428
|
if (ev.name === "char" && ev.key === "c") { this.#copy(); return true; }
|
|
291
429
|
if (ev.name === "char" && ev.key === "o") { this.#open(); return true; }
|
|
@@ -302,13 +440,13 @@ export function buildCommandPalette(app) {
|
|
|
302
440
|
{ label: "新建会话", hint: "n", action: () => app.newSession(), keywords: "new session create" },
|
|
303
441
|
{ label: "新建工作区…", action: () => app.addWorkspace(), keywords: "new workspace create directory" },
|
|
304
442
|
{ label: "打开会话…", hint: "o", action: () => app.openSessionPicker(), keywords: "open session" },
|
|
305
|
-
{ label: "
|
|
443
|
+
{ label: "跨会话全文搜索", hint: "Ctrl+F /", action: () => app.startSearch(), keywords: "search find full text" },
|
|
306
444
|
{ label: "重命名当前会话", action: () => app.renameCurrent(), keywords: "rename title" },
|
|
307
445
|
{ label: "切换模型", hint: "m", action: () => { app.overlay = buildModelPicker(app); app.redraw(); }, keywords: "model provider llm" },
|
|
308
446
|
{ label: "模式(Agent 预设)", action: () => app.showModePicker(), keywords: "mode preset standard code minimal cordis" },
|
|
309
447
|
{ label: "管理 Agent 预设", action: () => { app.overlay = new PresetPanel(app); app.redraw(); }, keywords: "preset inspect copy edit delete" },
|
|
310
448
|
{ label: "权限(沙箱 + 审批)", action: () => app.showPermissionPicker(), keywords: "permission sandbox read-only write full access" },
|
|
311
|
-
{ label: "工作区文件", hint: "w", action: () => app.setMode("workspace"), keywords: "workspace files tree" },
|
|
449
|
+
{ label: "工作区文件", hint: "w", action: () => (app.showWorkspaceBuffer ? app.showWorkspaceBuffer() : app.setMode?.("workspace")), keywords: "workspace files tree" },
|
|
312
450
|
{ label: "轨迹视图", hint: "t", action: () => app.setMode("trajectory"), keywords: "trajectory timeline trace" },
|
|
313
451
|
{ label: "任务列表", hint: "j", action: () => app.showJobs(), keywords: "jobs tasks" },
|
|
314
452
|
{ label: "目标状态", hint: "g", action: () => app.showGoal(), keywords: "goal objective" },
|
|
@@ -338,6 +476,10 @@ export class WorkspacePanel extends Widget {
|
|
|
338
476
|
this.treeScroll = new ScrollView({ x: this.x + 1, y: this.y + 1, w: Math.floor(this.w / 2), h: this.h - 2, showScrollbar: true });
|
|
339
477
|
this.preview = new ScrollView({ x: this.x + Math.floor(this.w / 2) + 1, y: this.y + 1, w: this.w - Math.floor(this.w / 2) - 2, h: this.h - 2, showScrollbar: true });
|
|
340
478
|
this.previewPath = null;
|
|
479
|
+
this.query = "";
|
|
480
|
+
this.searchSel = 0;
|
|
481
|
+
this.searchResults = [];
|
|
482
|
+
this.searchScroll = 0;
|
|
341
483
|
}
|
|
342
484
|
relayout(x, y, w, h) {
|
|
343
485
|
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
@@ -360,7 +502,7 @@ export class WorkspacePanel extends Widget {
|
|
|
360
502
|
this.rebuildTree();
|
|
361
503
|
} catch (e) {
|
|
362
504
|
this.app.toast(`工作区加载失败: ${e.message}`);
|
|
363
|
-
this.app.setMode("chat");
|
|
505
|
+
this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat");
|
|
364
506
|
}
|
|
365
507
|
}
|
|
366
508
|
expand(node) {
|
|
@@ -516,12 +658,12 @@ export class WorkspacePanel extends Widget {
|
|
|
516
658
|
if (ev.type !== "key") return false;
|
|
517
659
|
if (ev.name === "escape") {
|
|
518
660
|
if (this.query) { this.query = ""; this.app.redraw(); return true; }
|
|
519
|
-
this.app.setMode("chat");
|
|
661
|
+
this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat");
|
|
520
662
|
return true;
|
|
521
663
|
}
|
|
522
664
|
if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.app.redraw(); return true; }
|
|
523
|
-
if (ev.name === "down" && this.query) { this.searchSel =
|
|
524
|
-
if (ev.name === "up" && this.query) { this.searchSel =
|
|
665
|
+
if (ev.name === "down" && this.query) { this.searchSel = wrapIndex((this.searchSel ?? 0) + 1, this.searchResults?.length ?? 0); this.app.redraw(); return true; }
|
|
666
|
+
if (ev.name === "up" && this.query) { this.searchSel = wrapIndex((this.searchSel ?? 0) - 1, this.searchResults?.length ?? 0); this.app.redraw(); return true; }
|
|
525
667
|
if (ev.name === "enter" && this.query && this.searchResults?.length) { this.previewFile(this.searchResults[this.searchSel ?? 0]); return true; }
|
|
526
668
|
if (ev.name === "up" || ev.name === "down" || ev.name === "pgup" || ev.name === "pgdn") return this.treeScroll.onKey?.(ev) ?? false;
|
|
527
669
|
return false;
|
|
@@ -591,8 +733,8 @@ export class DirPicker extends Widget {
|
|
|
591
733
|
if (ev.type !== "key") return false;
|
|
592
734
|
const items = this.#items();
|
|
593
735
|
switch (ev.name) {
|
|
594
|
-
case "up": this.sel =
|
|
595
|
-
case "down": this.sel =
|
|
736
|
+
case "up": this.sel = wrapIndex(this.sel - 1, items.length); return true;
|
|
737
|
+
case "down": this.sel = wrapIndex(this.sel + 1, items.length); return true;
|
|
596
738
|
case "enter": {
|
|
597
739
|
const it = items[this.sel];
|
|
598
740
|
if (it.kind === "pick") { this.onPick?.(this.path); return true; }
|
|
@@ -601,8 +743,8 @@ export class DirPicker extends Widget {
|
|
|
601
743
|
}
|
|
602
744
|
case "backspace": if (this.parentPath) { this.path = this.parentPath; this.load(); } return true;
|
|
603
745
|
case "char":
|
|
604
|
-
if (ev.key === "j" && !ev.ctrl) { this.sel =
|
|
605
|
-
if (ev.key === "k" && !ev.ctrl) { this.sel =
|
|
746
|
+
if (ev.key === "j" && !ev.ctrl) { this.sel = wrapIndex(this.sel + 1, items.length); return true; }
|
|
747
|
+
if (ev.key === "k" && !ev.ctrl) { this.sel = wrapIndex(this.sel - 1, items.length); return true; }
|
|
606
748
|
if (ev.key === "h" && !ev.ctrl) { if (this.parentPath) { this.path = this.parentPath; this.load(); } return true; }
|
|
607
749
|
if (ev.key === "l" && !ev.ctrl) {
|
|
608
750
|
const it = items[this.sel];
|
|
@@ -627,8 +769,8 @@ export class DirPicker extends Widget {
|
|
|
627
769
|
}
|
|
628
770
|
return true;
|
|
629
771
|
}
|
|
630
|
-
if (ev.kind === "wheel-up") { this.sel =
|
|
631
|
-
if (ev.kind === "wheel-down") { this.sel =
|
|
772
|
+
if (ev.kind === "wheel-up") { this.sel = wrapIndex(this.sel - 1, this.#items().length); return true; }
|
|
773
|
+
if (ev.kind === "wheel-down") { this.sel = wrapIndex(this.sel + 1, this.#items().length); return true; }
|
|
632
774
|
return true;
|
|
633
775
|
}
|
|
634
776
|
}
|
|
@@ -640,7 +782,7 @@ export class AttachmentPanel extends Widget {
|
|
|
640
782
|
openItem(external=false){const a=this.items()[this.sel];if(!a)return;if(external){if(!a.path){this.app.toast("这不是本地文件,无法用默认程序定位");return;}try{const cmd=process.platform==="darwin"?"open":"xdg-open";spawn(cmd,[a.path],{detached:true,stdio:"ignore"}).unref();}catch(e){this.app.toast(`打开失败: ${e.message}`);}return;}if(a.mediaType?.startsWith("image/"))this.app.openImage(a,{all:this.items(),index:this.sel,returnTo:this});else this.app.toast(a.path?`文件: ${a.path}`:"这不是本地文件");}
|
|
641
783
|
remove(){const a=this.items()[this.sel];if(!a)return;this.app.chat.attachments.splice(this.sel,1);this.app.chat.clipboardImages=this.app.chat.clipboardImages.filter(x=>x.id!==a.id);this.sel=Math.max(0,Math.min(this.sel,this.items().length-1));this.app.chat.inputChanged();this.app.redraw();}
|
|
642
784
|
render(s){s.fillRect(this.x,this.y,this.x+this.w-1,this.y+this.h-1," ",{bg:T.BG2});s.box(this.x,this.y,this.x+this.w-1,this.y+this.h-1,{fg:K.ACCENT,bg:T.BG2},"附件管理器");const items=this.items();for(let i=0;i<Math.min(items.length,this.h-3);i++){const a=items[i],on=i===this.sel,y=this.y+1+i;s.fillRect(this.x+1,y,this.x+this.w-2,y," ",{bg:on?T.MENUSEL:T.BG2});s.text(this.x+2,y,truncate(`${a.mediaType?.startsWith("image/")?"":""} ${a.name}`,this.w-6),{fg:on?T.SELFG:K.TXT,bg:on?T.MENUSEL:T.BG2});}if(!items.length)s.text(this.x+2,this.y+2,"暂无附件",{fg:K.FAINT,bg:T.BG2});s.text(this.x+2,this.y+this.h-1,"Enter 查看 · Shift+Enter/双击 默认程序 · dd 移除 · Esc 退出",{fg:K.FAINT,bg:T.BG2});}
|
|
643
|
-
onKey(ev){const ch=ev.type==="text"?ev.text:ev.type==="key"&&ev.name==="char"?ev.key:null;if(ch==="d"){if(this.dArmed){this.dArmed=false;this.remove();}else{this.dArmed=true;this.app.toast("再按 d 删除附件");}return true;}if(ev.type!=="key"){this.dArmed=false;return false;}if(ev.name==="escape"){this.close();return true;}if(ev.name==="up"||(ev.name==="char"&&ev.key==="k")){this.dArmed=false;this.sel=
|
|
785
|
+
onKey(ev){const ch=ev.type==="text"?ev.text:ev.type==="key"&&ev.name==="char"?ev.key:null;if(ch==="d"){if(this.dArmed){this.dArmed=false;this.remove();}else{this.dArmed=true;this.app.toast("再按 d 删除附件");}return true;}if(ev.type!=="key"){this.dArmed=false;return false;}if(ev.name==="escape"){this.close();return true;}if(ev.name==="up"||(ev.name==="char"&&ev.key==="k")){this.dArmed=false;this.sel=wrapIndex(this.sel-1,this.items().length);return true;}if(ev.name==="down"||(ev.name==="char"&&ev.key==="j")){this.dArmed=false;this.sel=wrapIndex(this.sel+1,this.items().length);return true;}if(ev.name==="enter"){this.dArmed=false;this.openItem(!!ev.shift);return true;}this.dArmed=false;return false;}
|
|
644
786
|
onMouse(ev){if(ev.kind==="press"&&ev.button===0){const i=ev.y-this.y-1;if(i>=0&&i<this.items().length){const now=Date.now();this.sel=i;if(this.lastClick&&now-this.lastClick<400)this.openItem(true);this.lastClick=now;}return true;}return false;}
|
|
645
787
|
}
|
|
646
788
|
|
|
@@ -653,8 +795,8 @@ export class FilePicker extends Widget {
|
|
|
653
795
|
items() { return [{ name: "..", dir: true }, ...this.entries.map((e) => ({ name: e.name, dir: e.isDirectory() }))]; }
|
|
654
796
|
activate() { const it = this.items()[this.sel]; if (!it) return; const path = it.name === ".." ? dirname(this.path) : join(this.path, it.name); if (it.dir) { this.path = path; this.load(); } else this.onPick?.(path); }
|
|
655
797
|
render(s) { s.fillRect(this.x,this.y,this.x+this.w-1,this.y+this.h-1," ",{bg:T.BG2}); s.box(this.x,this.y,this.x+this.w-1,this.y+this.h-1,{fg:K.ACCENT,bg:T.BG2},"Yazi 风格文件选择"); s.text(this.x+2,this.y+1,truncate(this.path,this.w-4),{fg:K.DIM,bg:T.BG2}); const items=this.items(), n=this.h-4; if(this.sel<this.scroll)this.scroll=this.sel; if(this.sel>=this.scroll+n)this.scroll=this.sel-n+1; for(let i=0;i<n;i++){const idx=this.scroll+i,it=items[idx];if(!it)continue;const on=idx===this.sel,y=this.y+2+i;s.fillRect(this.x+1,y,this.x+this.w-2,y," ",{bg:on?T.MENUSEL:T.BG2});s.text(this.x+2,y,`${it.dir?"▸":"·"} ${truncate(it.name,this.w-7)}${it.dir?"/":""}`,{fg:on?T.SELFG:it.dir?K.ACCENT:K.TXT,bg:on?T.MENUSEL:T.BG2});} s.text(this.x+2,this.y+this.h-1,"↑↓/jk 选择 · Enter/l 打开 · h上级 · Esc取消",{fg:K.FAINT,bg:T.BG2}); }
|
|
656
|
-
onKey(ev){if(ev.type!=="key")return false;const n=this.items().length;if(ev.name==="escape"){this.onCancel?.();return true;}if(ev.name==="up"||(ev.name==="char"&&ev.key==="k")){this.sel=
|
|
657
|
-
onMouse(ev){if(ev.kind==="press"&&ev.button===0){const idx=this.scroll+ev.y-this.y-2;if(idx>=0&&idx<this.items().length){this.sel=idx;this.activate();}return true;}if(ev.kind==="wheel-up"){this.sel=
|
|
798
|
+
onKey(ev){if(ev.type!=="key")return false;const n=this.items().length;if(ev.name==="escape"){this.onCancel?.();return true;}if(ev.name==="up"||(ev.name==="char"&&ev.key==="k")){this.sel=wrapIndex(this.sel-1,n);return true;}if(ev.name==="down"||(ev.name==="char"&&ev.key==="j")){this.sel=wrapIndex(this.sel+1,n);return true;}if(ev.name==="enter"||(ev.name==="char"&&ev.key==="l")){this.activate();return true;}if(ev.name==="backspace"||(ev.name==="char"&&ev.key==="h")){this.path=dirname(this.path);this.load();return true;}return false;}
|
|
799
|
+
onMouse(ev){if(ev.kind==="press"&&ev.button===0){const idx=this.scroll+ev.y-this.y-2;if(idx>=0&&idx<this.items().length){this.sel=idx;this.activate();}return true;}if(ev.kind==="wheel-up"){this.sel=wrapIndex(this.sel-1,this.items().length);return true;}if(ev.kind==="wheel-down"){this.sel=wrapIndex(this.sel+1,this.items().length);return true;}return false;}
|
|
658
800
|
}
|
|
659
801
|
|
|
660
802
|
// ---- Trajectory view ----
|
|
@@ -672,6 +814,8 @@ export class TrajectoryPanel extends Widget {
|
|
|
672
814
|
this.allEvents = [];
|
|
673
815
|
this.sessionId = null;
|
|
674
816
|
this.expandedSteps = new Set(); // step identity keys rendered 详细 (expanded)
|
|
817
|
+
this.selectedStepKey = null; // stable first-event seq of the keyboard-selected step
|
|
818
|
+
this.visibleStepIndices = []; // current render order for circular ↑/↓ navigation
|
|
675
819
|
this.flashKey = null; // step key just jumped to (brief highlight)
|
|
676
820
|
this.flashUntil = 0;
|
|
677
821
|
this.loadPromise = null; // dedupes concurrent load(currentSession)
|
|
@@ -714,42 +858,46 @@ export class TrajectoryPanel extends Widget {
|
|
|
714
858
|
return e.type;
|
|
715
859
|
}
|
|
716
860
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
if (
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
else body = JSON.stringify(d, null, 2);
|
|
738
|
-
const lines = body.split("\n").slice(0, 40).map((l) => [{ t: truncate(l, w - 6), fg: K.TXT }]);
|
|
739
|
-
const pw = Math.min(80, this.app.screen.w - 4);
|
|
740
|
-
const ph = Math.min(lines.length + 5, 24);
|
|
741
|
-
this.app.overlay = new Popup({
|
|
742
|
-
x: Math.floor((this.app.screen.w - pw) / 2), y: Math.floor((this.app.screen.h - ph) / 2),
|
|
743
|
-
w: pw, h: ph, title: `#${e.seq} ${e.type}`,
|
|
744
|
-
lines: [[{ t: "" }], ...lines], buttons: [{ label: "关闭", action: "close" }],
|
|
745
|
-
onAction: () => this.app.closeOverlay(),
|
|
746
|
-
});
|
|
747
|
-
this.app.redraw();
|
|
748
|
-
},
|
|
749
|
-
});
|
|
861
|
+
#selectedIndex() {
|
|
862
|
+
if (this.steps.length === 0) return -1;
|
|
863
|
+
let index = this.steps.findIndex((step) => this.stepKey(step) === this.selectedStepKey);
|
|
864
|
+
if (index < 0) {
|
|
865
|
+
index = this.visibleStepIndices[0] ?? this.steps.length - 1;
|
|
866
|
+
this.selectedStepKey = this.stepKey(this.steps[index]);
|
|
867
|
+
}
|
|
868
|
+
return index;
|
|
869
|
+
}
|
|
870
|
+
#moveSelection(delta) {
|
|
871
|
+
if (this.visibleStepIndices.length === 0) return false;
|
|
872
|
+
const current = this.#selectedIndex();
|
|
873
|
+
let pos = this.visibleStepIndices.indexOf(current);
|
|
874
|
+
if (pos < 0) pos = this.visibleStepIndices.length - 1;
|
|
875
|
+
const index = this.visibleStepIndices[wrapIndex(pos + delta, this.visibleStepIndices.length)];
|
|
876
|
+
this.selectedStepKey = this.stepKey(this.steps[index]);
|
|
877
|
+
const line = this.stepLines.indexOf(index);
|
|
878
|
+
if (line < this.view.scrollY) this.view.scrollY = line;
|
|
879
|
+
else if (line >= this.view.scrollY + this.view.h) this.view.scrollY = Math.max(0, line - this.view.h + 1);
|
|
880
|
+
this.buildLines();
|
|
750
881
|
this.app.redraw();
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
#menuItems(si) {
|
|
885
|
+
const step = this.steps[si];
|
|
886
|
+
const key = step ? this.stepKey(step) : null;
|
|
887
|
+
const currentIndex = () => this.steps.findIndex((candidate) => this.stepKey(candidate) === key);
|
|
888
|
+
const open = step && this.expandedSteps.has(key);
|
|
889
|
+
return step ? [
|
|
890
|
+
{ label: open ? "折叠(简略)" : "展开(详细)", action: () => { const index = currentIndex(); if (index >= 0) this.#toggleStep(index); } },
|
|
891
|
+
{ label: "转跳对话", action: () => { const index = currentIndex(); if (index >= 0) this.app.jumpToChatStep(index); } },
|
|
892
|
+
] : [];
|
|
893
|
+
}
|
|
894
|
+
openSelectedMenu() {
|
|
895
|
+
const si = this.#selectedIndex();
|
|
896
|
+
if (si < 0) return false;
|
|
897
|
+
const line = this.stepLines.indexOf(si);
|
|
898
|
+
this.app.openMenu(this.#menuItems(si), { x: this.view.x + 4, y: this.view.y + Math.max(0, line - this.view.scrollY) });
|
|
899
|
+
return true;
|
|
751
900
|
}
|
|
752
|
-
|
|
753
901
|
#toggleStep(si) {
|
|
754
902
|
const step = this.steps[si];
|
|
755
903
|
if (!step) return;
|
|
@@ -768,9 +916,10 @@ export class TrajectoryPanel extends Widget {
|
|
|
768
916
|
this.app.redraw();
|
|
769
917
|
}
|
|
770
918
|
|
|
771
|
-
/** Stable identity
|
|
772
|
-
*
|
|
773
|
-
|
|
919
|
+
/** Stable identity across prepends: prefer the step/turn start sequence.
|
|
920
|
+
* A history page may begin in the middle of a step; its leading fragment
|
|
921
|
+
* then merges into the real start when the previous page arrives. */
|
|
922
|
+
stepKey(step) { return step.startSeq ?? step.events[0]?.seq ?? `step-${step.step}`; }
|
|
774
923
|
|
|
775
924
|
/** Step index whose events carry the given message id (-1 when absent). */
|
|
776
925
|
indexOfMessage(messageId) {
|
|
@@ -831,6 +980,7 @@ export class TrajectoryPanel extends Widget {
|
|
|
831
980
|
if (si < 0 || si >= this.steps.length) return;
|
|
832
981
|
const key = this.stepKey(this.steps[si]);
|
|
833
982
|
this.expandedSteps.add(key);
|
|
983
|
+
this.selectedStepKey = key;
|
|
834
984
|
this.flashKey = key;
|
|
835
985
|
this.flashUntil = Date.now() + 3000;
|
|
836
986
|
// load older pages until at least 20 steps sit above the target
|
|
@@ -966,6 +1116,13 @@ export class TrajectoryPanel extends Widget {
|
|
|
966
1116
|
this.stats = null;
|
|
967
1117
|
this.hasMore = false;
|
|
968
1118
|
this.minSeq = null;
|
|
1119
|
+
this.expandedSteps.clear();
|
|
1120
|
+
this.selectedStepKey = null;
|
|
1121
|
+
this.visibleStepIndices = [];
|
|
1122
|
+
this.flashKey = null; this.flashUntil = 0;
|
|
1123
|
+
this.winSeqLo = null; this.winSeqHi = null;
|
|
1124
|
+
this.query = "";
|
|
1125
|
+
this.view.scrollY = 0;
|
|
969
1126
|
this.app.setStatus("加载轨迹…");
|
|
970
1127
|
try {
|
|
971
1128
|
// One bounded call for the recent steps (maxMessages = model messages =
|
|
@@ -975,7 +1132,9 @@ export class TrajectoryPanel extends Widget {
|
|
|
975
1132
|
this.stats = h.projections?.values?.sessionStats ?? null;
|
|
976
1133
|
this.minSeq = h.events[0]?.event?.seq ?? null;
|
|
977
1134
|
this.hasMore = h.hasMore;
|
|
978
|
-
|
|
1135
|
+
const bySeq = new Map();
|
|
1136
|
+
for (const wrapped of h.events ?? []) { const seq = wrapped?.event?.seq; if (seq != null) bySeq.set(seq, wrapped); }
|
|
1137
|
+
this.allEvents = [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq);
|
|
979
1138
|
this.build();
|
|
980
1139
|
} catch (e) { this.app.toast(`轨迹加载失败: ${e.message}`); }
|
|
981
1140
|
this.loading = false;
|
|
@@ -994,10 +1153,22 @@ export class TrajectoryPanel extends Widget {
|
|
|
994
1153
|
if (this.sessionId !== sessionId || this.loadToken !== token) { this.loadingOlder = false; return; }
|
|
995
1154
|
if (h.events.length === 0) { this.hasMore = false; }
|
|
996
1155
|
else {
|
|
1156
|
+
const previousMinSeq = this.minSeq;
|
|
997
1157
|
this.minSeq = h.events[0]?.event?.seq ?? this.minSeq;
|
|
998
|
-
this.hasMore = h.hasMore;
|
|
999
|
-
|
|
1158
|
+
this.hasMore = h.hasMore && this.minSeq < previousMinSeq;
|
|
1159
|
+
const bySeq = new Map();
|
|
1160
|
+
for (const wrapped of [...h.events, ...this.allEvents]) {
|
|
1161
|
+
const seq = wrapped?.event?.seq;
|
|
1162
|
+
if (seq == null) continue;
|
|
1163
|
+
bySeq.set(seq, wrapped);
|
|
1164
|
+
}
|
|
1165
|
+
const selectedEventSeq = this.steps.find((step) => this.stepKey(step) === this.selectedStepKey)?.events[0]?.seq ?? null;
|
|
1166
|
+
const expandedEventSeqs = [...this.expandedSteps].map((key) => this.steps.find((step) => this.stepKey(step) === key)?.events[0]?.seq).filter((seq) => seq != null);
|
|
1167
|
+
this.allEvents = [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq);
|
|
1000
1168
|
this.build();
|
|
1169
|
+
const keyForEvent = (seq) => { const step = this.steps.find((candidate) => candidate.events.some((event) => event.seq === seq)); return step ? this.stepKey(step) : null; };
|
|
1170
|
+
if (selectedEventSeq != null) this.selectedStepKey = keyForEvent(selectedEventSeq);
|
|
1171
|
+
this.expandedSteps = new Set(expandedEventSeqs.map(keyForEvent).filter((key) => key != null));
|
|
1001
1172
|
}
|
|
1002
1173
|
} catch (e) { this.app.toast(`加载更早失败: ${e.message}`); }
|
|
1003
1174
|
this.loadingOlder = false;
|
|
@@ -1006,18 +1177,33 @@ export class TrajectoryPanel extends Widget {
|
|
|
1006
1177
|
this.app.redraw();
|
|
1007
1178
|
}
|
|
1008
1179
|
build() {
|
|
1009
|
-
// Segment on step/start:
|
|
1010
|
-
//
|
|
1180
|
+
// Segment on step/start: turn/start is turn metadata, not a model step.
|
|
1181
|
+
// Keep leading pre-step/page fragments so events remain inspectable and
|
|
1182
|
+
// merge them into the first real step rather than fabricating phantom rows.
|
|
1011
1183
|
const steps = [];
|
|
1012
1184
|
let cur = null;
|
|
1185
|
+
let pending = [];
|
|
1013
1186
|
for (const { event } of this.allEvents) {
|
|
1014
1187
|
const d = event.data ?? {};
|
|
1015
|
-
if (event.type === "
|
|
1188
|
+
if (event.type === "turn/start") {
|
|
1016
1189
|
if (cur && cur.events.length) steps.push(cur);
|
|
1017
|
-
|
|
1190
|
+
else if (pending.length) steps.push({ events: pending, step: "?", startSeq: null, partial: true });
|
|
1191
|
+
cur = null; pending = [event];
|
|
1192
|
+
} else if (event.type === "step/start") {
|
|
1193
|
+
if (cur && cur.events.length) steps.push(cur);
|
|
1194
|
+
if (pending.length && pending[0]?.type !== "turn/start") {
|
|
1195
|
+
steps.push({ events: pending, step: "?", startSeq: null, partial: true });
|
|
1196
|
+
pending = [];
|
|
1197
|
+
}
|
|
1198
|
+
cur = { events: [...pending, event], step: d.step ?? steps.length + 1, turn: d.turn ?? pending[0]?.data?.turn, startSeq: event.seq };
|
|
1199
|
+
pending = [];
|
|
1200
|
+
} else if (!cur) {
|
|
1201
|
+
pending.push(event);
|
|
1202
|
+
} else {
|
|
1203
|
+
cur.events.push(event);
|
|
1018
1204
|
}
|
|
1019
|
-
if (cur) cur.events.push(event);
|
|
1020
1205
|
}
|
|
1206
|
+
if (!cur && pending.length) cur = { events: pending, step: "?", turn: pending.find((event) => event.type === "turn/start")?.data?.turn, startSeq: null, partial: true };
|
|
1021
1207
|
if (cur && cur.events.length) steps.push(cur);
|
|
1022
1208
|
// Keep every loaded step: Home/End navigation pages across the whole
|
|
1023
1209
|
// session, so older steps must survive until `r` re-fetches fresh.
|
|
@@ -1035,19 +1221,26 @@ export class TrajectoryPanel extends Widget {
|
|
|
1035
1221
|
}
|
|
1036
1222
|
const winIdxLo = this.steps.findIndex((s) => this.stepKey(s) === loSeq);
|
|
1037
1223
|
const winIdxHi = this.steps.findIndex((s) => this.stepKey(s) === hiSeq);
|
|
1038
|
-
|
|
1039
|
-
|
|
1224
|
+
if (this.steps.length && (winIdxLo < 0 || winIdxHi < 0)) {
|
|
1225
|
+
loSeq = this.stepKey(this.steps[Math.max(0, this.steps.length - 20)]);
|
|
1226
|
+
hiSeq = this.stepKey(this.steps[this.steps.length - 1]);
|
|
1227
|
+
this.winSeqLo = loSeq; this.winSeqHi = hiSeq;
|
|
1228
|
+
}
|
|
1229
|
+
const safeLo = this.steps.findIndex((s) => this.stepKey(s) === loSeq);
|
|
1230
|
+
const safeHi = this.steps.findIndex((s) => this.stepKey(s) === hiSeq);
|
|
1231
|
+
const loStepNum = this.steps[safeLo]?.step ?? "?";
|
|
1232
|
+
const hiStepNum = this.steps[safeHi]?.step ?? "?";
|
|
1040
1233
|
const lines = [];
|
|
1041
|
-
lines.push([{ t: "轨迹 —
|
|
1234
|
+
lines.push([{ t: "轨迹 — ↑↓ 选择 · Ctrl+↑↓ 滚动 · Space 展开 · Enter 转跳对话 · Ctrl+R 菜单 · PgUp/PgDn 加载", fg: K.ACCENT, bold: true }]);
|
|
1042
1235
|
if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp 向上加载 10 步)", fg: K.FAINT }]);
|
|
1043
1236
|
else lines.push([{ t: "" }]);
|
|
1044
1237
|
const st = this.stats;
|
|
1045
1238
|
if (st) {
|
|
1046
1239
|
lines.push([{ t: `回合 ${st.turns} · 步骤 ${st.steps} · LLM ${fmtMs(st.llmMs)} · 工具 ${fmtMs(st.toolMs)}`, fg: K.DIM }]);
|
|
1047
1240
|
}
|
|
1048
|
-
lines.push([{ t: `窗口 #${
|
|
1241
|
+
lines.push([{ t: `窗口 #${safeLo + 1}–#${safeHi + 1}(已加载 ${this.steps.length}${this.hasMore ? "+" : ""})· step ${loStepNum}–${hiStepNum}${this.winSeqLo == null ? "(跟随最新)" : ""}:`, fg: K.DIM, underline: true }]);
|
|
1049
1242
|
this.stepLines = [];
|
|
1050
|
-
const list = this.query
|
|
1243
|
+
const list = (this.query
|
|
1051
1244
|
? this.steps.filter((t) => t.events.some((e) => {
|
|
1052
1245
|
const d = e.data ?? {};
|
|
1053
1246
|
const hay = `${e.type} ${d.name ?? ""} ${typeof d.content === "string" ? d.content : ""}`.toLowerCase();
|
|
@@ -1056,22 +1249,29 @@ export class TrajectoryPanel extends Widget {
|
|
|
1056
1249
|
: this.steps.filter((s) => {
|
|
1057
1250
|
const k = this.stepKey(s);
|
|
1058
1251
|
return k >= loSeq && k <= hiSeq;
|
|
1059
|
-
});
|
|
1060
|
-
|
|
1252
|
+
})).reverse();
|
|
1253
|
+
this.visibleStepIndices = list.map((step) => this.steps.indexOf(step));
|
|
1254
|
+
if (this.visibleStepIndices.length && !this.visibleStepIndices.some((si) => this.stepKey(this.steps[si]) === this.selectedStepKey)) {
|
|
1255
|
+
// The newest step is the first rendered row because trajectory is reverse chronological.
|
|
1256
|
+
this.selectedStepKey = this.stepKey(this.steps[this.visibleStepIndices[0]]);
|
|
1257
|
+
}
|
|
1258
|
+
for (const step of list) {
|
|
1061
1259
|
const si = this.steps.indexOf(step);
|
|
1062
1260
|
const tools = [...new Set(step.events.filter((e) => e.type === "tool/call").map((e) => e.data?.name))];
|
|
1063
|
-
const hasResult = step.events.some((e) => e.type === "tool/result");
|
|
1064
1261
|
const hasReasoning = step.events.some((e) => e.type === "assistant/chunk" && e.data?.chunk?.blockType === "reasoning");
|
|
1065
1262
|
const t0 = step.events[0]?.time, t1 = step.events[step.events.length - 1]?.time;
|
|
1066
1263
|
// deep-dive style live timer: the newest step ticks while the turn runs
|
|
1067
1264
|
const isLiveTail = this.app.chat?.running && this.winSeqLo == null && si === this.steps.length - 1;
|
|
1068
1265
|
const dur = isLiveTail ? `⏱${fmtMs(Date.now() - (t0 ?? Date.now()))}` : (t0 && t1 ? fmtMs(t1 - t0) : "—");
|
|
1069
|
-
|
|
1266
|
+
// Tool-heavy timelines stay neutral: gray reveals the clickable step
|
|
1267
|
+
// range without turning every successful bash call into a green slab.
|
|
1268
|
+
const bg = tools.length ? T.CARD : hasReasoning ? T.THINKBG : T.CARD;
|
|
1070
1269
|
const summary = tools.slice(0, 3).join(",") || (hasReasoning ? "模型推理" : "纯文本");
|
|
1071
1270
|
const open = this.expandedSteps.has(this.stepKey(step)); // 详细
|
|
1072
1271
|
const flash = this.flashKey === this.stepKey(step) && Date.now() < this.flashUntil;
|
|
1073
1272
|
const rowBg = flash ? T.ACCENT : bg;
|
|
1074
|
-
const
|
|
1273
|
+
const selected = this.stepKey(step) === this.selectedStepKey;
|
|
1274
|
+
const label = `${selected ? "=>" : " "} ${open ? "▾" : "▸"} step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary} ${open ? "[折叠]" : "[展开]"}`;
|
|
1075
1275
|
const segs = [{ t: label, fg: flash ? T.SELFG : K.TXT, bg: rowBg, bold: true }];
|
|
1076
1276
|
const fill = w - strWidth(label);
|
|
1077
1277
|
if (fill > 0) segs.push({ t: " ".repeat(fill), bg: rowBg });
|
|
@@ -1081,7 +1281,9 @@ export class TrajectoryPanel extends Widget {
|
|
|
1081
1281
|
// 详细 mode = deep dive: the step's events inline under its color
|
|
1082
1282
|
// block, each with its OWN duration (web-style Δ timer: time since
|
|
1083
1283
|
// the previous event; the first is measured from the step start).
|
|
1084
|
-
|
|
1284
|
+
// Expanded means complete: every event stays reachable by ordinary
|
|
1285
|
+
// viewport scrolling, replacing the removed duplicate detail picker.
|
|
1286
|
+
const evs = step.events;
|
|
1085
1287
|
let prev = null;
|
|
1086
1288
|
for (const e of evs) {
|
|
1087
1289
|
const dt = prev != null && e.time != null ? ` Δ${fmtMs(e.time - prev)}` : "";
|
|
@@ -1089,10 +1291,6 @@ export class TrajectoryPanel extends Widget {
|
|
|
1089
1291
|
this.stepLines[lines.length - 1] = si;
|
|
1090
1292
|
prev = e.time;
|
|
1091
1293
|
}
|
|
1092
|
-
if (step.events.length > evs.length) {
|
|
1093
|
-
lines.push([{ t: ` …共 ${step.events.length} 个事件(右键 → 查看详情)`, fg: K.FAINT, bg }]);
|
|
1094
|
-
this.stepLines[lines.length - 1] = si;
|
|
1095
|
-
}
|
|
1096
1294
|
}
|
|
1097
1295
|
}
|
|
1098
1296
|
this.view.setLines(lines);
|
|
@@ -1120,15 +1318,18 @@ export class TrajectoryPanel extends Widget {
|
|
|
1120
1318
|
}
|
|
1121
1319
|
/** Re-fetch the tail window while following the live turn. */
|
|
1122
1320
|
async #refreshTail() {
|
|
1123
|
-
if (!this.sessionId) return;
|
|
1321
|
+
if (!this.sessionId || this.loadingOlder) return;
|
|
1124
1322
|
const sessionId = this.sessionId;
|
|
1125
1323
|
const token = this.loadToken;
|
|
1126
1324
|
this.refreshing = true;
|
|
1127
1325
|
try {
|
|
1128
1326
|
const h = await this.app.api.call("session.history", { sessionId, maxMessages: 20 });
|
|
1129
1327
|
if (this.sessionId !== sessionId || this.loadToken !== token) { this.refreshing = false; return; }
|
|
1130
|
-
this.
|
|
1131
|
-
|
|
1328
|
+
if (this.loadingOlder || this.winSeqLo != null) { this.refreshing = false; return; }
|
|
1329
|
+
const bySeq = new Map();
|
|
1330
|
+
for (const wrapped of h.events ?? []) { const seq = wrapped?.event?.seq; if (seq != null) bySeq.set(seq, wrapped); }
|
|
1331
|
+
this.allEvents = [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq);
|
|
1332
|
+
this.minSeq = this.allEvents[0]?.event?.seq ?? this.minSeq;
|
|
1132
1333
|
this.hasMore = h.hasMore;
|
|
1133
1334
|
this.stats = h.projections?.values?.sessionStats ?? this.stats;
|
|
1134
1335
|
this.build();
|
|
@@ -1144,12 +1345,9 @@ export class TrajectoryPanel extends Widget {
|
|
|
1144
1345
|
const si = this.stepLines[y];
|
|
1145
1346
|
const step = si !== undefined ? this.steps[si] : null;
|
|
1146
1347
|
if (step) {
|
|
1147
|
-
|
|
1148
|
-
this.
|
|
1149
|
-
|
|
1150
|
-
{ label: "转跳对话", action: () => this.app.jumpToChatStep(si) },
|
|
1151
|
-
{ label: "查看详情", action: () => this.#showDetail(si) },
|
|
1152
|
-
], ev);
|
|
1348
|
+
this.selectedStepKey = this.stepKey(step);
|
|
1349
|
+
this.buildLines();
|
|
1350
|
+
this.app.openMenu(this.#menuItems(si), ev);
|
|
1153
1351
|
return true;
|
|
1154
1352
|
}
|
|
1155
1353
|
if (this.hasMore && y === 1) {
|
|
@@ -1168,6 +1366,9 @@ export class TrajectoryPanel extends Widget {
|
|
|
1168
1366
|
return false;
|
|
1169
1367
|
}
|
|
1170
1368
|
onKey(ev) {
|
|
1369
|
+
// Legacy terminals deliver an unmodified Space as text rather than a key
|
|
1370
|
+
// event. It is a trajectory action, not a hidden-query character.
|
|
1371
|
+
if (ev.type === "text" && ev.text === " ") { const si = this.#selectedIndex(); if (si >= 0) this.#toggleStep(si); return true; }
|
|
1171
1372
|
if (ev.type === "text") { this.query += ev.text; this.buildLines(); this.app.redraw(); return true; }
|
|
1172
1373
|
if (ev.type !== "key") return false;
|
|
1173
1374
|
if (ev.name === "escape") {
|
|
@@ -1176,9 +1377,15 @@ export class TrajectoryPanel extends Widget {
|
|
|
1176
1377
|
return true;
|
|
1177
1378
|
}
|
|
1178
1379
|
if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.buildLines(); this.app.redraw(); return true; }
|
|
1380
|
+
if (ev.ctrl && (ev.name === "up" || ev.name === "down")) { this.view.scroll(ev.name === "up" ? -1 : 1); this.app.redraw(); return true; }
|
|
1381
|
+
if (ev.name === "up" || ev.name === "down") return this.#moveSelection(ev.name === "up" ? -1 : 1);
|
|
1382
|
+
if (ev.name === "char" && ev.key === " " && !ev.ctrl) { const si = this.#selectedIndex(); if (si >= 0) this.#toggleStep(si); return true; }
|
|
1383
|
+
if (ev.name === "enter") { const si = this.#selectedIndex(); if (si >= 0) this.app.jumpToChatStep(si); return true; }
|
|
1384
|
+
if (ev.name === "char" && ev.key === "r" && ev.ctrl) return this.openSelectedMenu();
|
|
1179
1385
|
if (ev.name === "char" && ev.key === "r" && !ev.ctrl) {
|
|
1180
1386
|
this.winSeqLo = this.winSeqHi = null;
|
|
1181
1387
|
this.steps = [];
|
|
1388
|
+
this.selectedStepKey = null;
|
|
1182
1389
|
this.load(this.sessionId);
|
|
1183
1390
|
return true;
|
|
1184
1391
|
}
|
|
@@ -1186,7 +1393,6 @@ export class TrajectoryPanel extends Widget {
|
|
|
1186
1393
|
if (ev.name === "pgdn") { this.extendDown(); return true; }
|
|
1187
1394
|
if (ev.name === "home") { this.gotoHome(); return true; }
|
|
1188
1395
|
if (ev.name === "end") { this.gotoEnd(); return true; }
|
|
1189
|
-
if (ev.name === "up" || ev.name === "down") return this.view.onKey(ev);
|
|
1190
1396
|
return false;
|
|
1191
1397
|
}
|
|
1192
1398
|
}
|
|
@@ -1431,7 +1637,7 @@ export class ControlPanel extends Widget {
|
|
|
1431
1637
|
this.loadCommands();
|
|
1432
1638
|
this.loadPlugins();
|
|
1433
1639
|
}
|
|
1434
|
-
editShortcut(id){const back=this,b=keyBindings()[id];const input=new Input({x:this.x+8,y:this.y+7,w:this.w-16,h:1,prompt:'JSON: ',allowEmptyEnter:true,onEnter(value){try{const parsed=JSON.parse(value);if(!["normal","insert","all"].includes(parsed.mode)||typeof parsed.key!=="string"||!parsed.key.trim())throw new Error('需要 {"mode":"normal|insert|all","key":"..."}');if(!setKeyBinding(id,parsed))throw new Error('写入配置失败');back.app.overlay=back;back.app.focus(back);back.app.toast('快捷键已保存');}catch(e){input.setValue(value);back.app.toast(`语法错误: ${e.message}`);}}});input.setValue(JSON.stringify(b));const pop=new Popup({x:this.x+6,y:this.y+5,w:this.w-12,h:
|
|
1640
|
+
editShortcut(id){const back=this,b=keyBindings()[id];const input=new Input({x:this.x+8,y:this.y+7,w:this.w-16,h:1,prompt:'JSON: ',allowEmptyEnter:true,onEnter(value){try{const parsed=JSON.parse(value);if(!["normal","insert","all"].includes(parsed.mode)||typeof parsed.key!=="string"||!parsed.key.trim())throw new Error('需要 {"mode":"normal|insert|all","key":"..."[, "key2":"..."]}');const k2=typeof parsed.key2==="string"?parsed.key2.trim():"";const vk=validateKeySpec(parsed.key);if(!vk.ok)throw new Error(`key: ${vk.reason}`);if(k2){const vk2=validateKeySpec(k2);if(!vk2.ok)throw new Error(`key2: ${vk2.reason}`);}if(!setKeyBinding(id,{mode:parsed.mode,key:parsed.key.trim(),key2:k2}))throw new Error('写入配置失败');back.app.overlay=back;back.app.focus(back);back.app.toast('快捷键已保存');}catch(e){input.setValue(value);back.app.toast(`语法错误: ${e.message}`);}}});input.setValue(JSON.stringify(b));const pop=new Popup({x:this.x+6,y:this.y+5,w:this.w-12,h:7,title:`编辑 tui-config.json · keyBindings.${id}`,lines:[`配置项: keyBindings.${id} · 两个槽位 key(主)/ key2(备)`,`示例: {"mode":"normal","key":"Ctrl+F","key2":"/"}`],buttons:[]});pop.render=(s)=>{Popup.prototype.render.call(pop,s);input.render(s);};pop.onKey=(ev)=>{if(ev.type==='key'&&ev.name==='escape'){back.app.overlay=back;back.app.focus(back);return true;}return input.onKey(ev);};this.app.overlay=pop;this.app.focus(input);}
|
|
1435
1641
|
async loadCommands() {
|
|
1436
1642
|
try {
|
|
1437
1643
|
const agentId = this.app.currentSession;
|
|
@@ -1451,9 +1657,9 @@ export class ControlPanel extends Widget {
|
|
|
1451
1657
|
}
|
|
1452
1658
|
shortcutItems() {
|
|
1453
1659
|
const b=keyBindings();
|
|
1454
|
-
const row=(id,desc)=>[`${(b[id]?.mode??"all").toUpperCase()}\t${b[id]?.key
|
|
1660
|
+
const row=(id,desc)=>[`${(b[id]?.mode??"all").toUpperCase()}\t${describeSpec(b[id]?.key)}\t${describeSpec(b[id]?.key2)}`,desc,null,id];
|
|
1455
1661
|
return [
|
|
1456
|
-
row("think","思考块 展开/折叠"),row("tools","工具块 展开/折叠"),row("insert","进入输入"),row("leaveInsert","退出输入"),row("sessionFilter","
|
|
1662
|
+
row("think","思考块 展开/折叠"),row("tools","工具块 展开/折叠"),row("insert","进入输入"),row("leaveInsert","退出输入"),row("sessionFilter","跨会话搜索"),row("newSession","新建会话"),row("top","跳到首个正文块"),row("bottom","跳到最新正文块"),row("prevQuestion","上一提问的终点"),row("nextQuestion","下一提问的终点"),row("expandInput","输入栏 展开/折叠"),row("copyInput","复制输入栏选区"),row("panel","控制面板"),row("model","切换模型"),row("trajectory","轨迹视图"),row("homeSwitch","pane 焦点切换"),row("permissionRotate","权限模式轮换"),row("workspace","工作区"),row("settings","设置"),row("subagent","子代理"),row("skills","技能"),row("goal","目标"),row("jobs","后台任务"),row("queue","后台队列"),row("busyEnter","运行中 Enter 策略"),row("attachments","附件管理"),row("stepJump","步骤转跳"),row("sidebar","侧栏显示/隐藏"),row("editConfig","编辑配置文件(默认编辑器)"),row("quit","退出"),
|
|
1457
1663
|
];
|
|
1458
1664
|
}
|
|
1459
1665
|
items() {
|
|
@@ -1475,7 +1681,7 @@ export class ControlPanel extends Widget {
|
|
|
1475
1681
|
["模型管理(含思考强度)", "切换模型并选择思考强度", () => { this.app.overlay = buildModelPicker(this.app); }],
|
|
1476
1682
|
["模式(Agent 预设)", "标准 / PTC / 极简 / 创造", () => { this.app.overlay = buildModePicker(this.app); this.app.redraw(); }],
|
|
1477
1683
|
["权限(沙箱 + 审批)", "只读 / 工作区写入 / 完全访问", () => { this.app.overlay = buildPermissionPicker(this.app); this.app.redraw(); }],
|
|
1478
|
-
["完整设置(JSON 编辑器)", "所有命名空间的原始值", () => { this.app.closeOverlay(); this.app.setMode("settings"); }],
|
|
1684
|
+
["完整设置(JSON 编辑器)", "所有命名空间的原始值", () => { this.app.closeOverlay(); this.app.showSettingsBuffer ? this.app.showSettingsBuffer() : this.app.setMode?.("settings"); }],
|
|
1479
1685
|
["切换主题", "dark / light / gruvbox", () => { cycleTheme(); this.app.toast(`主题: ${themeName()}`); }],
|
|
1480
1686
|
["侧栏显示/隐藏", "nvim 式整体收起", () => this.app.toggleSidebar()],
|
|
1481
1687
|
["导出当前会话日志", "下载 ZIP", () => { const sess = this.app.sessions.find((x) => x.sessionId === this.app.currentSession); if (sess) { this.app.closeOverlay(); this.app.exportSession(sess); } }],
|
|
@@ -1505,7 +1711,7 @@ export class ControlPanel extends Widget {
|
|
|
1505
1711
|
if (this.sel < this.scroll) this.scroll = this.sel;
|
|
1506
1712
|
else if (this.sel >= this.scroll + visible) this.scroll = this.sel - visible + 1;
|
|
1507
1713
|
this.scroll = Math.max(0, Math.min(Math.max(0, items.length - visible), this.scroll));
|
|
1508
|
-
if(this.page===0){s.text(this.x+2,this.y+1,"MODE",{fg:T.PURPLE,bg:T.PANEL,attrs:1});s.text(this.x+13,this.y+1,"
|
|
1714
|
+
if(this.page===0){s.text(this.x+2,this.y+1,"MODE",{fg:T.PURPLE,bg:T.PANEL,attrs:1});s.text(this.x+13,this.y+1,"KEY1",{fg:T.ACCENT,bg:T.PANEL,attrs:1});s.text(this.x+31,this.y+1,"KEY2",{fg:T.ACCENT,bg:T.PANEL,attrs:1});s.text(this.x+49,this.y+1,"FUNCTION",{fg:T.OK,bg:T.PANEL,attrs:1});}
|
|
1509
1715
|
if(this.page===3&&this.pluginFilter){s.text(this.x+2,this.y+1,`/ ${this.pluginQuery}`,{fg:T.ACCENT,bg:T.PANEL,attrs:1});}
|
|
1510
1716
|
for (let i = 0; i < visible; i++) {
|
|
1511
1717
|
const idx = this.scroll + i;
|
|
@@ -1514,7 +1720,7 @@ export class ControlPanel extends Widget {
|
|
|
1514
1720
|
const sel = idx === this.sel;
|
|
1515
1721
|
s.fillRect(this.x + 1, this.y + 2 + i, this.x + this.w - 2, this.y + 2 + i, " ", { bg: sel ? T.MENUSEL : T.PANEL });
|
|
1516
1722
|
const label = it[0];
|
|
1517
|
-
if(this.page===0){const [mode,
|
|
1723
|
+
if(this.page===0){const [mode,key1,key2]=label.split("\t");s.text(this.x+2,this.y+2+i,pad(mode,9),{fg:T.PURPLE,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+13,this.y+2+i,pad(truncate(key1,16),17),{fg:T.ACCENT,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+31,this.y+2+i,pad(truncate(key2,16),17),{fg:T.ACCENT,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+49,this.y+2+i,truncate(it[1],this.w-52),{fg:T.OK,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});}
|
|
1518
1724
|
else{s.text(this.x + 2, this.y + 2 + i, truncate(label, this.w - 34), { fg: sel ? T.BOLD : T.TXT, bg: sel ? T.MENUSEL : T.PANEL, attrs: sel ? 1 : 0 });if (it[1]) s.text(this.x + this.w - 30, this.y + 2 + i, truncate(it[1], 28), { fg: T.FAINT, bg: sel ? T.MENUSEL : T.PANEL });}
|
|
1519
1725
|
}
|
|
1520
1726
|
s.text(this.x + 2, this.y + this.h - 1, this.page===0?"↑↓ 选择 · Enter 编辑 · Shift+Tab 轮换模式 · Alt+Enter 恢复默认 · Esc 关闭":this.page===3?`/ 筛选插件 · Ctrl+/ 清除 · ↑↓ 选择 · Esc 关闭${this.pluginQuery?` · ${this.pluginQuery}`:""}`:"↑↓ 选择 · Enter 执行 · Esc 关闭", { fg: T.FAINT });
|
|
@@ -1537,8 +1743,8 @@ export class ControlPanel extends Widget {
|
|
|
1537
1743
|
if (ev.name === "backtab" || ev.name === "left") { this.page = (this.page + this.pages.length - 1) % this.pages.length; this.sel = 0; this.app.redraw(); return true; }
|
|
1538
1744
|
if (ev.name === "pgup" || ev.name === "home") { this.sel = 0; this.app.redraw(); return true; }
|
|
1539
1745
|
if (ev.name === "pgdn" || ev.name === "end") { this.sel = this.items().length - 1; this.app.redraw(); return true; }
|
|
1540
|
-
if (ev.name === "up") { this.sel =
|
|
1541
|
-
if (ev.name === "down") { this.sel =
|
|
1746
|
+
if (ev.name === "up") { this.sel = wrapIndex(this.sel - 1, this.items().length); this.app.redraw(); return true; }
|
|
1747
|
+
if (ev.name === "down") { this.sel = wrapIndex(this.sel + 1, this.items().length); this.app.redraw(); return true; }
|
|
1542
1748
|
if (ev.name === "enter") {
|
|
1543
1749
|
const it = this.items()[this.sel];
|
|
1544
1750
|
if(this.page===0&&it?.[3]){this.editShortcut(it[3]);return true;}
|
|
@@ -1558,7 +1764,7 @@ export class ControlPanel extends Widget {
|
|
|
1558
1764
|
tx += wTab;
|
|
1559
1765
|
}
|
|
1560
1766
|
// sub-page tabs (on 设置)
|
|
1561
|
-
if (this.page === 2) {
|
|
1767
|
+
if (this.page === 2 && Array.isArray(this.subPages)) {
|
|
1562
1768
|
let sx = this.x + 2 + strWidth(" 快捷键 命令 设置 ");
|
|
1563
1769
|
for (let i = 0; i < this.subPages.length; i++) {
|
|
1564
1770
|
const wTab = strWidth(` ${this.subPages[i]} `);
|
|
@@ -1578,8 +1784,8 @@ export class ControlPanel extends Widget {
|
|
|
1578
1784
|
return true;
|
|
1579
1785
|
}
|
|
1580
1786
|
}
|
|
1581
|
-
if (ev.kind === "wheel-up") { this.sel =
|
|
1582
|
-
if (ev.kind === "wheel-down") { this.sel =
|
|
1787
|
+
if (ev.kind === "wheel-up") { this.sel = wrapIndex(this.sel - 1, this.items().length); this.app.redraw(); return true; }
|
|
1788
|
+
if (ev.kind === "wheel-down") { this.sel = wrapIndex(this.sel + 1, this.items().length); this.app.redraw(); return true; }
|
|
1583
1789
|
return true;
|
|
1584
1790
|
}
|
|
1585
1791
|
}
|
|
@@ -1669,7 +1875,8 @@ export class JobsPanel extends Popup {
|
|
|
1669
1875
|
for (let i = 0; i < this.subagents.length; i++) {
|
|
1670
1876
|
const child = this.subagents[i], bg = i === this.sel ? T.MENUSEL : T.BG2;
|
|
1671
1877
|
const status = child.activity ?? child.status ?? child.mode ?? "idle";
|
|
1672
|
-
|
|
1878
|
+
const open = this.expanded.has(i);
|
|
1879
|
+
lines.push([{ t: ` ${open ? "▾" : "▸"} ◇ ${truncate(child.label ?? child.sessionId ?? child.id ?? "子代理", 42)} `, fg: K.TXT, bg, bold: i === this.sel }, { t: status, fg: status === "running" ? K.WARN : K.DIM, bg }]); rowOf.push(i);
|
|
1673
1880
|
if (this.expanded.has(i)) for (const [key, value] of Object.entries(child)) { lines.push([{ t: ` ${key}: ${truncate(typeof value === "object" ? JSON.stringify(value) : value, this.w - 16)}`, fg: K.DIM }]); rowOf.push(-1); }
|
|
1674
1881
|
}
|
|
1675
1882
|
this.lines = lines; this.rowOf = rowOf; this.#ensureVisible(); return;
|
|
@@ -1720,10 +1927,10 @@ export class JobsPanel extends Popup {
|
|
|
1720
1927
|
const current = this.page === "jobs" ? this.jobs : this.subagents;
|
|
1721
1928
|
if (current.length === 0) return super.onKey(ev);
|
|
1722
1929
|
if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
|
|
1723
|
-
this.sel =
|
|
1930
|
+
this.sel = wrapIndex(this.sel - 1, current.length); this.rebuild(); return true;
|
|
1724
1931
|
}
|
|
1725
1932
|
if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
|
|
1726
|
-
this.sel =
|
|
1933
|
+
this.sel = wrapIndex(this.sel + 1, current.length); this.rebuild(); return true;
|
|
1727
1934
|
}
|
|
1728
1935
|
if (ev.name === "enter" || (ev.name === "char" && ev.key === "l" && !ev.ctrl)) {
|
|
1729
1936
|
if (current[this.sel]) { if (this.expanded.has(this.sel)) this.expanded.delete(this.sel); else this.expanded.add(this.sel); this.rebuild(); } return true;
|
|
@@ -1751,25 +1958,157 @@ export class QueuePanel extends Popup {
|
|
|
1751
1958
|
const items = app.queueItems ?? [];
|
|
1752
1959
|
const w = Math.max(24, Math.min(84, app.screen.w - 4));
|
|
1753
1960
|
const h = Math.max(7, Math.min(24, app.screen.h - 4));
|
|
1754
|
-
super({
|
|
1755
|
-
|
|
1961
|
+
super({
|
|
1962
|
+
x: Math.max(0, Math.floor((app.screen.w - w) / 2)), y: Math.max(0, Math.floor((app.screen.h - h) / 2)), w, h,
|
|
1963
|
+
title: "排队命令 · j/k 选择 · Enter 展开 · PgUp/PgDn 滚动 · ? 帮助",
|
|
1964
|
+
lines: [], buttons: [], scrollable: true,
|
|
1965
|
+
});
|
|
1966
|
+
this.app = app;
|
|
1967
|
+
this.items = items;
|
|
1968
|
+
this.sel = 0;
|
|
1969
|
+
this.pending = false;
|
|
1970
|
+
this.dArmed = false;
|
|
1971
|
+
this.helpVisible = false;
|
|
1972
|
+
this.expanded = new Set(); // stable queue item keys, survives mux refresh/reorder
|
|
1973
|
+
this.rowOf = []; // rendered line → queue item index
|
|
1974
|
+
this.rebuild();
|
|
1756
1975
|
}
|
|
1757
|
-
|
|
1976
|
+
#itemKey(item, index = this.items.indexOf(item)) {
|
|
1977
|
+
return String(item?.id ?? item?.message?.id ?? `${item?.placement ?? "queue"}:${index}:${partsText(item?.message?.content).slice(0, 80)}`);
|
|
1978
|
+
}
|
|
1979
|
+
#wrap(label, value, fg = K.TXT) {
|
|
1980
|
+
const rows = [];
|
|
1981
|
+
const firstHead = ` ${label}: `;
|
|
1982
|
+
const nextHead = " ".repeat(strWidth(firstHead));
|
|
1983
|
+
const width = Math.max(8, this.w - 4 - strWidth(firstHead));
|
|
1984
|
+
let first = true;
|
|
1985
|
+
for (const raw of String(value ?? "").split("\n")) {
|
|
1986
|
+
if (raw === "") {
|
|
1987
|
+
rows.push([{ t: first ? firstHead : nextHead, fg: K.DIM }]);
|
|
1988
|
+
first = false;
|
|
1989
|
+
continue;
|
|
1990
|
+
}
|
|
1991
|
+
let line = "", used = 0;
|
|
1992
|
+
for (const ch of graphemes(raw)) {
|
|
1993
|
+
const cw = graphemeWidth(ch);
|
|
1994
|
+
if (line && used + cw > width) {
|
|
1995
|
+
rows.push([{ t: first ? firstHead : nextHead, fg: K.DIM }, { t: line, fg }]);
|
|
1996
|
+
first = false; line = ""; used = 0;
|
|
1997
|
+
}
|
|
1998
|
+
line += ch; used += cw;
|
|
1999
|
+
}
|
|
2000
|
+
rows.push([{ t: first ? firstHead : nextHead, fg: K.DIM }, { t: line, fg }]);
|
|
2001
|
+
first = false;
|
|
2002
|
+
if (rows.length >= 180) break;
|
|
2003
|
+
}
|
|
2004
|
+
return rows;
|
|
2005
|
+
}
|
|
2006
|
+
#content(item) {
|
|
2007
|
+
const texts = [], extras = [];
|
|
2008
|
+
const walk = (content) => {
|
|
2009
|
+
if (typeof content === "string") { texts.push(content); return; }
|
|
2010
|
+
if (!Array.isArray(content)) return;
|
|
2011
|
+
for (const part of content) {
|
|
2012
|
+
if (!part || typeof part !== "object") continue;
|
|
2013
|
+
if (part.type === "text" && typeof part.text === "string") texts.push(part.text);
|
|
2014
|
+
else {
|
|
2015
|
+
const identity = part.name ?? part.fileName ?? part.attachmentId ?? part.id ?? part.mediaType ?? part.url ?? "";
|
|
2016
|
+
extras.push(`[${part.type ?? "内容"}]${identity ? ` ${identity}` : ""}`);
|
|
2017
|
+
}
|
|
2018
|
+
if (Array.isArray(part.content)) walk(part.content);
|
|
2019
|
+
}
|
|
2020
|
+
};
|
|
2021
|
+
walk(item?.message?.content);
|
|
2022
|
+
return { text: texts.join("\n"), extras };
|
|
2023
|
+
}
|
|
2024
|
+
#detailLines(item) {
|
|
1758
2025
|
const lines = [];
|
|
2026
|
+
const placement = item.placement === "queued" ? "排队(下一回合)"
|
|
2027
|
+
: item.placement === "steering" ? "追加到当前回合"
|
|
2028
|
+
: item.placement === "context" ? "只读上下文" : (item.placement ?? "未知");
|
|
2029
|
+
lines.push(...this.#wrap("ID", item.id ?? "(无)", K.DIM));
|
|
2030
|
+
lines.push(...this.#wrap("位置", placement, K.DIM));
|
|
2031
|
+
const source = item.message?.source?.kind ?? item.source?.kind;
|
|
2032
|
+
if (source) lines.push(...this.#wrap("来源", source, K.DIM));
|
|
2033
|
+
for (const key of ["createdAt", "updatedAt", "clientTimeZone"]) {
|
|
2034
|
+
if (item[key] != null) lines.push(...this.#wrap(key, item[key], K.DIM));
|
|
2035
|
+
}
|
|
2036
|
+
const content = this.#content(item);
|
|
2037
|
+
lines.push(...this.#wrap("内容", content.text || "(无文本内容)"));
|
|
2038
|
+
for (const extra of content.extras) lines.push(...this.#wrap("附件", extra, K.ACCENT));
|
|
2039
|
+
if (lines.length > 200) return [...lines.slice(0, 200), [{ t: " …详情超过 200 行,已截断", fg: K.FAINT }]];
|
|
2040
|
+
return lines;
|
|
2041
|
+
}
|
|
2042
|
+
rebuild() {
|
|
2043
|
+
const lines = [], rowOf = [];
|
|
2044
|
+
if (this.helpVisible) {
|
|
2045
|
+
for (const text of [
|
|
2046
|
+
" 键盘优先:j/k 或 ↑/↓ 选择命令;Enter/→/l 展开;←/h 折叠",
|
|
2047
|
+
" PgUp/PgDn 或 Ctrl+B/F 整页滚动;Ctrl+U/D 半页滚动",
|
|
2048
|
+
" Ctrl+Y/E 或 Shift+↑/↓ 逐行滚动;Home/End 到详情首尾",
|
|
2049
|
+
" dd 删除当前命令;? 隐藏帮助;q/Esc 关闭",
|
|
2050
|
+
]) { lines.push([{ t: text, fg: K.DIM }]); rowOf.push(-1); }
|
|
2051
|
+
lines.push([{ t: "" }]); rowOf.push(-1);
|
|
2052
|
+
}
|
|
1759
2053
|
for (let i = 0; i < this.items.length; i++) {
|
|
1760
2054
|
const item = this.items[i];
|
|
2055
|
+
const key = this.#itemKey(item, i);
|
|
2056
|
+
const open = this.expanded.has(key);
|
|
2057
|
+
const selected = i === this.sel;
|
|
1761
2058
|
const text = partsText(item.message?.content).replace(/\s+/g, " ");
|
|
1762
|
-
|
|
2059
|
+
const placement = item.placement === "queued" ? "⏳" : item.placement === "steering" ? "↪" : "ℹ";
|
|
2060
|
+
lines.push([{
|
|
2061
|
+
t: `${selected ? "▸" : " "} ${open ? "▾" : "▸"} ${placement} ${truncate(text || item.id, this.w - 10)}`,
|
|
2062
|
+
fg: selected ? T.SELFG : K.TXT, bg: selected ? T.MENUSEL : -1, bold: selected,
|
|
2063
|
+
}]);
|
|
2064
|
+
rowOf.push(i);
|
|
2065
|
+
if (open) {
|
|
2066
|
+
for (const line of this.#detailLines(item)) {
|
|
2067
|
+
lines.push(line.map((seg) => ({ ...seg, bg: selected ? T.MENUSEL : seg.bg })));
|
|
2068
|
+
rowOf.push(i);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
1763
2071
|
}
|
|
1764
|
-
if (!this.items.length) lines.push([{ t: " (队列为空)", fg: K.FAINT }]);
|
|
2072
|
+
if (!this.items.length) { lines.push([{ t: " (队列为空)", fg: K.FAINT }]); rowOf.push(-1); }
|
|
1765
2073
|
this.lines = lines;
|
|
2074
|
+
this.rowOf = rowOf;
|
|
2075
|
+
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll()));
|
|
2076
|
+
}
|
|
2077
|
+
#ensureSelected() {
|
|
2078
|
+
const row = this.rowOf.findIndex((idx) => idx === this.sel);
|
|
2079
|
+
if (row < 0) return;
|
|
2080
|
+
if (row < this.scrollY) this.scrollY = row;
|
|
2081
|
+
else if (row >= this.scrollY + this.contentRows()) this.scrollY = Math.max(0, row - this.contentRows() + 1);
|
|
2082
|
+
}
|
|
2083
|
+
/** Detail scrolling is intentionally independent from queue selection.
|
|
2084
|
+
* j/k chooses a command; these operations move only the viewport. */
|
|
2085
|
+
#scrollBy(delta) {
|
|
2086
|
+
this.dArmed = false;
|
|
2087
|
+
this.scrollY = Math.max(0, Math.min(this.maxScroll(), this.scrollY + delta));
|
|
2088
|
+
this.app.redraw();
|
|
2089
|
+
return true;
|
|
2090
|
+
}
|
|
2091
|
+
#scrollTo(position) {
|
|
2092
|
+
this.dArmed = false;
|
|
2093
|
+
this.scrollY = position === "end" ? this.maxScroll() : 0;
|
|
2094
|
+
this.app.redraw();
|
|
2095
|
+
return true;
|
|
2096
|
+
}
|
|
2097
|
+
#toggle(index = this.sel) {
|
|
2098
|
+
const item = this.items[index];
|
|
2099
|
+
if (!item) return;
|
|
2100
|
+
const key = this.#itemKey(item, index);
|
|
2101
|
+
if (this.expanded.has(key)) this.expanded.delete(key); else this.expanded.add(key);
|
|
2102
|
+
this.rebuild(); this.#ensureSelected(); this.app.redraw();
|
|
1766
2103
|
}
|
|
1767
2104
|
syncItems(items) {
|
|
1768
2105
|
const selectedId = this.items[this.sel]?.id;
|
|
1769
2106
|
this.items = items ?? [];
|
|
2107
|
+
const live = new Set(this.items.map((item, i) => this.#itemKey(item, i)));
|
|
2108
|
+
for (const key of [...this.expanded]) if (!live.has(key)) this.expanded.delete(key);
|
|
1770
2109
|
const next = selectedId ? this.items.findIndex((item) => item.id === selectedId) : -1;
|
|
1771
2110
|
this.sel = next >= 0 ? next : Math.min(this.sel, Math.max(0, this.items.length - 1));
|
|
1772
|
-
this.rebuild();
|
|
2111
|
+
this.rebuild(); this.#ensureSelected();
|
|
1773
2112
|
}
|
|
1774
2113
|
#errorCode(error) { return error?.code ?? error?.details?.code ?? error?.cause?.code; }
|
|
1775
2114
|
async #mutate(kind, content) {
|
|
@@ -1792,21 +2131,58 @@ export class QueuePanel extends Popup {
|
|
|
1792
2131
|
} finally { this.pending = false; this.rebuild(); this.app.redraw(); }
|
|
1793
2132
|
}
|
|
1794
2133
|
onKey(ev) {
|
|
1795
|
-
const ch=ev.type==="text"?ev.text:ev.type==="key"&&ev.name==="char"?ev.key:null;
|
|
1796
|
-
|
|
2134
|
+
const ch = ev.type === "text" ? ev.text : ev.type === "key" && ev.name === "char" ? ev.key : null;
|
|
2135
|
+
// Plain character bindings only. Modified d belongs to Ctrl+D half-page
|
|
2136
|
+
// scrolling and must never arm the destructive dd sequence.
|
|
2137
|
+
const plain = !ev.ctrl && !ev.alt && !ev.shift;
|
|
2138
|
+
if (plain && ch === "q") { this.app.closeOverlay(); return true; }
|
|
2139
|
+
if (!ev.ctrl && !ev.alt && ch === "?") {
|
|
2140
|
+
this.helpVisible = !this.helpVisible;
|
|
2141
|
+
this.dArmed = false;
|
|
2142
|
+
this.rebuild(); this.scrollY = 0; this.app.redraw();
|
|
2143
|
+
return true;
|
|
2144
|
+
}
|
|
2145
|
+
if (plain && ch === "d") {
|
|
2146
|
+
if (this.dArmed) { this.dArmed = false; this.#mutate("remove"); }
|
|
2147
|
+
else { this.dArmed = true; this.app.toast("再按 d 删除这条排队命令"); }
|
|
2148
|
+
return true;
|
|
2149
|
+
}
|
|
1797
2150
|
if (ev.type === "key") {
|
|
1798
2151
|
if (ev.name === "escape") { this.app.closeOverlay(); return true; }
|
|
1799
|
-
|
|
1800
|
-
if (ev.name === "
|
|
2152
|
+
// Keyboard-first detail scrolling. Selection is intentionally unchanged.
|
|
2153
|
+
if (ev.name === "pgup" || (ev.ctrl && ev.key === "b")) return this.#scrollBy(-this.contentRows());
|
|
2154
|
+
if (ev.name === "pgdn" || (ev.ctrl && ev.key === "f")) return this.#scrollBy(this.contentRows());
|
|
2155
|
+
if (ev.ctrl && ev.key === "u") return this.#scrollBy(-Math.max(1, Math.floor(this.contentRows() / 2)));
|
|
2156
|
+
if (ev.ctrl && ev.key === "d") return this.#scrollBy(Math.max(1, Math.floor(this.contentRows() / 2)));
|
|
2157
|
+
if ((ev.ctrl && ev.key === "y") || (ev.name === "up" && ev.shift)) return this.#scrollBy(-1);
|
|
2158
|
+
if ((ev.ctrl && ev.key === "e") || (ev.name === "down" && ev.shift)) return this.#scrollBy(1);
|
|
2159
|
+
if (ev.name === "home") return this.#scrollTo("home");
|
|
2160
|
+
if (ev.name === "end") return this.#scrollTo("end");
|
|
2161
|
+
if (ev.name === "enter" || ev.name === "right" || (ev.name === "char" && ev.key === "l" && plain)) { this.dArmed = false; this.#toggle(); return true; }
|
|
2162
|
+
if (ev.name === "left" || (ev.name === "char" && ev.key === "h" && plain)) {
|
|
2163
|
+
this.dArmed = false;
|
|
2164
|
+
const item = this.items[this.sel], key = this.#itemKey(item, this.sel);
|
|
2165
|
+
if (item && this.expanded.delete(key)) { this.rebuild(); this.#ensureSelected(); this.app.redraw(); }
|
|
2166
|
+
return true;
|
|
2167
|
+
}
|
|
2168
|
+
if ((ev.name === "up" && !ev.shift) || (ev.name === "char" && ev.key === "k" && plain)) {
|
|
2169
|
+
this.dArmed = false; this.sel = wrapIndex(this.sel - 1, this.items.length); this.rebuild(); this.#ensureSelected(); return true;
|
|
2170
|
+
}
|
|
2171
|
+
if ((ev.name === "down" && !ev.shift) || (ev.name === "char" && ev.key === "j" && plain)) {
|
|
2172
|
+
this.dArmed = false; this.sel = wrapIndex(this.sel + 1, this.items.length); this.rebuild(); this.#ensureSelected(); return true;
|
|
2173
|
+
}
|
|
1801
2174
|
}
|
|
1802
|
-
this.dArmed=
|
|
2175
|
+
this.dArmed = false;
|
|
2176
|
+
return false;
|
|
1803
2177
|
}
|
|
1804
2178
|
onMouse(ev) {
|
|
2179
|
+
if (super.onMouse(ev)) return true;
|
|
1805
2180
|
if (ev.kind === "press" && ev.button === 0) {
|
|
1806
|
-
const
|
|
1807
|
-
|
|
2181
|
+
const row = ev.y - this.y - 1 + this.scrollY;
|
|
2182
|
+
const idx = this.rowOf[row];
|
|
2183
|
+
if (idx >= 0) { this.sel = idx; this.#toggle(idx); return true; }
|
|
1808
2184
|
}
|
|
1809
|
-
return
|
|
2185
|
+
return false;
|
|
1810
2186
|
}
|
|
1811
2187
|
}
|
|
1812
2188
|
|
|
@@ -1892,8 +2268,8 @@ export class GoalPanel extends Popup {
|
|
|
1892
2268
|
onKey(ev) {
|
|
1893
2269
|
if (ev.type === "key") {
|
|
1894
2270
|
if (ev.name === "escape") { this.app.closeOverlay(); return true; }
|
|
1895
|
-
if (ev.name === "up") { this.actionSel =
|
|
1896
|
-
if (ev.name === "down") { this.actionSel =
|
|
2271
|
+
if (ev.name === "up") { this.actionSel = wrapIndex(this.actionSel - 1, this.actions.length); this.rebuild(); return true; }
|
|
2272
|
+
if (ev.name === "down") { this.actionSel = wrapIndex(this.actionSel + 1, this.actions.length); this.rebuild(); return true; }
|
|
1897
2273
|
if (ev.name === "enter") { this.actions[this.actionSel]?.run(); return true; }
|
|
1898
2274
|
}
|
|
1899
2275
|
return super.onKey(ev);
|
|
@@ -1949,7 +2325,7 @@ export class SettingsPanel extends Widget {
|
|
|
1949
2325
|
this.writable = d.writable;
|
|
1950
2326
|
} catch (e) {
|
|
1951
2327
|
this.app.toast(`设置加载失败: ${e.message}`);
|
|
1952
|
-
this.app.setMode("chat");
|
|
2328
|
+
this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat");
|
|
1953
2329
|
return;
|
|
1954
2330
|
}
|
|
1955
2331
|
// TUI-local settings ride the same tree editor, but persist to the TUI
|
|
@@ -1978,7 +2354,7 @@ export class SettingsPanel extends Widget {
|
|
|
1978
2354
|
this.pendingOps = [];
|
|
1979
2355
|
this.editing = false;
|
|
1980
2356
|
const ns = this.namespaces[this.nsIdx];
|
|
1981
|
-
if (ns.modelsEntry) { this.app.setMode("models"); return; }
|
|
2357
|
+
if (ns.modelsEntry) { (this.app.showModelsBuffer ? this.app.showModelsBuffer() : this.app.setMode?.("models")); return; }
|
|
1982
2358
|
this.secrets = new Set((ns.secrets ?? []).map((s) => JSON.stringify(s.path ?? [])));
|
|
1983
2359
|
this.rebuildRows();
|
|
1984
2360
|
const items = this.namespaces.map((n) => ({
|
|
@@ -2081,7 +2457,7 @@ export class SettingsPanel extends Widget {
|
|
|
2081
2457
|
return true;
|
|
2082
2458
|
}
|
|
2083
2459
|
if (ev.type !== "key") return false;
|
|
2084
|
-
if (ev.name === "escape") { this.app.setMode("chat"); return true; }
|
|
2460
|
+
if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
|
|
2085
2461
|
if (ev.ctrl && ev.key === "s") { this.save(); return true; }
|
|
2086
2462
|
if (ev.name === "up" || ev.name === "down" || ev.name === "pgup" || ev.name === "pgdn") return this.tree.scroll(ev.name === "up" || ev.name === "pgup" ? -3 : 3);
|
|
2087
2463
|
if (ev.name === "enter") {
|
|
@@ -2209,7 +2585,7 @@ export class EditPopup extends Popup {
|
|
|
2209
2585
|
this.input.render(screen);
|
|
2210
2586
|
}
|
|
2211
2587
|
onKey(ev) {
|
|
2212
|
-
if (ev.type === "key" && ev.name === "escape") { this.app.closeOverlay(); this.app.focus(this.app.chat); return true; }
|
|
2588
|
+
if (ev.type === "key" && ev.name === "escape") { this.app.closeOverlay(); this.app.focus(this.app.fullBuffer ?? this.app.chat); return true; }
|
|
2213
2589
|
if (ev.type === "key" && ev.name === "tab" && this.completions?.length) {
|
|
2214
2590
|
// Tab 选取/补全: an exact current value cycles to the next candidate,
|
|
2215
2591
|
// anything else completes to the first prefix match
|
|
@@ -2230,7 +2606,7 @@ export class EditPopup extends Popup {
|
|
|
2230
2606
|
if (ev.type === "key" && ev.name === "enter") {
|
|
2231
2607
|
const v = this.input.value;
|
|
2232
2608
|
this.app.closeOverlay();
|
|
2233
|
-
this.app.focus(this.app.chat);
|
|
2609
|
+
this.app.focus(this.app.fullBuffer ?? this.app.chat);
|
|
2234
2610
|
this.onCommit?.(v);
|
|
2235
2611
|
return true;
|
|
2236
2612
|
}
|
|
@@ -2249,6 +2625,17 @@ export class EditPopup extends Popup {
|
|
|
2249
2625
|
* same union the web settings page reads out of the namespace schema, so the
|
|
2250
2626
|
* choices offered here cannot drift from the ones the host validates. */
|
|
2251
2627
|
const API_PROTOCOLS = ["openai-completions", "openai-responses", "anthropic-messages"];
|
|
2628
|
+
/** pi-ai reasoning levels the adapter schema accepts, in escalation order. */
|
|
2629
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
2630
|
+
/** Request modalities the pi-ai adapter schema accepts (audio is NOT one of
|
|
2631
|
+
* them: the adapter rejects any profile that tries to declare it). */
|
|
2632
|
+
const INPUT_MODALITIES = ["text", "image"];
|
|
2633
|
+
/** Adapter fallback when neither a model nor its installed catalog declares input. */
|
|
2634
|
+
const DEFAULT_INPUT_MODALITIES = ["text"];
|
|
2635
|
+
/** compat.thinkingFormat spellings the adapter accepts on openai-completions. */
|
|
2636
|
+
const THINKING_FORMATS = ["openai", "deepseek", "openrouter", "together", "zai", "qwen", "string-thinking", "ant-ling"];
|
|
2637
|
+
/** New custom routes follow the web editor's unambiguous identifier grammar. */
|
|
2638
|
+
const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
2252
2639
|
/** Credential reference names must be POSIX shell identifiers. */
|
|
2253
2640
|
const KEY_REF_OK = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
2254
2641
|
/** The web settings page's v1 convention: a provider route's key lives under
|
|
@@ -2257,16 +2644,114 @@ function deriveKeyRef(provider) {
|
|
|
2257
2644
|
return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
|
2258
2645
|
}
|
|
2259
2646
|
|
|
2647
|
+
/** Keep untrusted catalog/profile text inside one terminal row. */
|
|
2648
|
+
function inlineLabel(value) {
|
|
2649
|
+
return String(value ?? "").replace(/[\x00-\x1F\x7F]/g, "?");
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
function isRecord(value) {
|
|
2653
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
function cloneConfig(value) {
|
|
2657
|
+
return JSON.parse(JSON.stringify(value));
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
/** Remove paths owned by the stored user layer, leaving inherited fallback. */
|
|
2661
|
+
function withoutOwned(resolved, owned) {
|
|
2662
|
+
if (!isRecord(resolved)) return {};
|
|
2663
|
+
const result = {};
|
|
2664
|
+
const stored = isRecord(owned) ? owned : {};
|
|
2665
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
2666
|
+
if (!Object.hasOwn(stored, key)) result[key] = value;
|
|
2667
|
+
else if (isRecord(value) && isRecord(stored[key])) {
|
|
2668
|
+
const child = withoutOwned(value, stored[key]);
|
|
2669
|
+
if (Object.keys(child).length > 0) result[key] = child;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
return result;
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
function mergeConfig(inherited, draft) {
|
|
2676
|
+
const result = { ...(isRecord(inherited) ? inherited : {}) };
|
|
2677
|
+
for (const [key, value] of Object.entries(isRecord(draft) ? draft : {})) {
|
|
2678
|
+
result[key] = isRecord(value) && isRecord(result[key]) ? mergeConfig(result[key], value) : value;
|
|
2679
|
+
}
|
|
2680
|
+
return result;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
/** Read one settings subtree without enumerating or serializing Host objects. */
|
|
2684
|
+
function configAt(value, path) {
|
|
2685
|
+
let current = value;
|
|
2686
|
+
for (const key of path ?? []) {
|
|
2687
|
+
if (!isRecord(current) && !Array.isArray(current)) return undefined;
|
|
2688
|
+
current = current[key];
|
|
2689
|
+
}
|
|
2690
|
+
return current;
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
/** Minimal field operations rooted at an arbitrary provider settings address. */
|
|
2694
|
+
function profileOps(base, before, after) {
|
|
2695
|
+
const previous = isRecord(before) ? before : {};
|
|
2696
|
+
const next = isRecord(after) ? after : {};
|
|
2697
|
+
const ops = [];
|
|
2698
|
+
for (const [field, value] of Object.entries(next)) {
|
|
2699
|
+
if (JSON.stringify(previous[field]) !== JSON.stringify(value)) ops.push({ op: "set", path: [...base, field], value });
|
|
2700
|
+
}
|
|
2701
|
+
for (const field of Object.keys(previous)) if (!(field in next)) ops.push({ op: "unset", path: [...base, field] });
|
|
2702
|
+
return ops;
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
/** Minimal user-layer operations for the provider fields this panel changed. */
|
|
2706
|
+
function providerOps(before, after, wholeRoutes = new Set()) {
|
|
2707
|
+
const ops = [];
|
|
2708
|
+
for (const [route, profile] of Object.entries(after)) {
|
|
2709
|
+
const previous = before[route];
|
|
2710
|
+
if (previous === undefined && wholeRoutes.has(route)) {
|
|
2711
|
+
ops.push({ op: "set", path: ["providers", route], value: profile });
|
|
2712
|
+
continue;
|
|
2713
|
+
}
|
|
2714
|
+
const priorFields = previous ?? {};
|
|
2715
|
+
for (const [field, value] of Object.entries(profile)) {
|
|
2716
|
+
if (JSON.stringify(priorFields[field]) === JSON.stringify(value)) continue;
|
|
2717
|
+
ops.push({ op: "set", path: ["providers", route, field], value });
|
|
2718
|
+
}
|
|
2719
|
+
for (const field of Object.keys(priorFields)) {
|
|
2720
|
+
if (!(field in profile)) ops.push({ op: "unset", path: ["providers", route, field] });
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
for (const route of Object.keys(before)) {
|
|
2724
|
+
if (!(route in after)) ops.push({ op: "unset", path: ["providers", route] });
|
|
2725
|
+
}
|
|
2726
|
+
return ops;
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2260
2729
|
export class ModelPanel extends Widget {
|
|
2261
2730
|
constructor(app) {
|
|
2262
|
-
super({ x: 30, y: 0, w: app.screen.w - 30, h: app.screen.h - 1 });
|
|
2731
|
+
super({ x: Math.min(30, Math.max(0, app.screen.w - 1)), y: 0, w: Math.max(1, app.screen.w - 30), h: Math.max(1, app.screen.h - 1) });
|
|
2263
2732
|
this.app = app;
|
|
2264
|
-
this.providers = {};
|
|
2265
|
-
this.
|
|
2266
|
-
this.
|
|
2267
|
-
this.
|
|
2733
|
+
this.providers = {}; // llm-pi-ai route → user-layer profile draft
|
|
2734
|
+
this.resolvedProviders = {}; // llm-pi-ai route → effective profile received from Host
|
|
2735
|
+
this.inheritedProviders = {}; // llm-pi-ai fields not owned by loaded user layer
|
|
2736
|
+
this.baseProviders = {}; // composition-owned llm-pi-ai profiles
|
|
2737
|
+
this.directory = []; // Host llm.providers entries (official + catalog + declared)
|
|
2738
|
+
this.namespaceViews = new Map(); // every settings namespace addressed by the directory
|
|
2739
|
+
this.configuredDirectory = new Set(); // configured provider identities in this draft
|
|
2740
|
+
this.initialConfiguredDirectory = new Set(); // discard target
|
|
2741
|
+
this.externalDrafts = new Map(); // non-pi-ai provider → user-layer profile draft
|
|
2742
|
+
this.externalInherited = new Map(); // non-pi-ai provider → effective fields below user layer
|
|
2743
|
+
this.externalUserConfigured = new Set(); // routes with an actual user-layer settings subtree
|
|
2744
|
+
this.externalSnapshots = new Map(); // provider → fully successful save point
|
|
2745
|
+
this.externalHostSnapshots = new Map(); // provider → settings confirmed by Host
|
|
2746
|
+
this.revisions = new Map(); // settings namespace → CAS revision
|
|
2747
|
+
this.revision = 0; // llm-pi-ai compatibility alias used by existing paths
|
|
2268
2748
|
this.loaded = false;
|
|
2749
|
+
this.writable = true;
|
|
2269
2750
|
this.routes = [];
|
|
2751
|
+
this.addMode = false; // Host-directory chooser; custom is its final row
|
|
2752
|
+
this.addItems = [];
|
|
2753
|
+
this.addCursor = 0;
|
|
2754
|
+
this.materializeRoutes = new Set(); // dormant catalog routes selected but not saved
|
|
2270
2755
|
this.sel = 0; // list cursor (routes.length = the + 添加供应商 row)
|
|
2271
2756
|
this.mode = "list"; // list | form
|
|
2272
2757
|
this.formIdx = 0; // form item cursor
|
|
@@ -2281,36 +2766,262 @@ export class ModelPanel extends Widget {
|
|
|
2281
2766
|
this.scanSel = new Set();
|
|
2282
2767
|
this.scanCursor = 0;
|
|
2283
2768
|
this.scanning = false;
|
|
2284
|
-
this.savedSnapshot = "{}"; //
|
|
2769
|
+
this.savedSnapshot = "{}"; // last fully successful llm-pi-ai providers+credentials save point
|
|
2770
|
+
this.hostSnapshot = "{}"; // llm-pi-ai provider settings last confirmed by the Host
|
|
2285
2771
|
this.keyStatus = {}; // ref → {configured, writable, source} from credentials.describe
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2772
|
+
this.pendingProbeKeys = new Map(); // route → write-only key draft for discovery and save
|
|
2773
|
+
// Journal managed cleanup on the App and in tui-config before provider
|
|
2774
|
+
// deletion. Closing/recreating this panel cannot lose the retry surface;
|
|
2775
|
+
// this map contains references and errors, never credential values.
|
|
2776
|
+
if (!(app.pendingModelCredentialCleanups instanceof Map)) {
|
|
2777
|
+
const savedCleanups = loadTuiConfig().pendingModelCredentialCleanups;
|
|
2778
|
+
app.pendingModelCredentialCleanups = new Map((Array.isArray(savedCleanups) ? savedCleanups : []).flatMap((item) => {
|
|
2779
|
+
const ref = typeof item?.ref === "string" ? item.ref : "";
|
|
2780
|
+
const route = typeof item?.route === "string" ? item.route : "";
|
|
2781
|
+
// Only this panel's route-derived managed credentials may ever enter the
|
|
2782
|
+
// automatic cleanup path. Existing Host configs may predate the route
|
|
2783
|
+
// grammar enforced for new drafts, so do not reject legacy route names.
|
|
2784
|
+
if (!route || ref !== deriveKeyRef(route)) return [];
|
|
2785
|
+
return [[ref, {
|
|
2786
|
+
route,
|
|
2787
|
+
error: typeof item.error === "string" ? item.error : "等待重试",
|
|
2788
|
+
reconcile: item.reconcile === true,
|
|
2789
|
+
}]];
|
|
2790
|
+
}));
|
|
2791
|
+
}
|
|
2792
|
+
this.pendingCredentialCleanups = app.pendingModelCredentialCleanups; // ref → {route, error}
|
|
2793
|
+
this.formClickMap = []; // rendered form line → item, scan result, or cleanup action
|
|
2794
|
+
const listW = Math.max(1, Math.min(26, this.w - 3));
|
|
2795
|
+
this.listView = new ScrollView({ x: this.x + 1, y: this.y + 1, w: listW, h: Math.max(1, this.h - 2), showScrollbar: true });
|
|
2796
|
+
this.formView = new ScrollView({ x: this.x + listW + 1, y: this.y + 1, w: Math.max(1, this.w - listW - 2), h: Math.max(1, this.h - 2), showScrollbar: true });
|
|
2289
2797
|
}
|
|
2290
2798
|
relayout(x, y, w, h) {
|
|
2291
|
-
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
2292
|
-
const listW = 26;
|
|
2293
|
-
this.listView.x = x + 1; this.listView.y = y + 1; this.listView.w = listW; this.listView.h = h - 2;
|
|
2294
|
-
this.formView.x = x + listW + 1; this.formView.y = y + 1; this.formView.w = w - listW - 2; this.formView.h = h - 2;
|
|
2799
|
+
this.x = x; this.y = y; this.w = Math.max(1, w); this.h = Math.max(1, h);
|
|
2800
|
+
const listW = Math.max(1, Math.min(26, this.w - 3));
|
|
2801
|
+
this.listView.x = x + 1; this.listView.y = y + 1; this.listView.w = listW; this.listView.h = Math.max(1, this.h - 2);
|
|
2802
|
+
this.formView.x = x + listW + 1; this.formView.y = y + 1; this.formView.w = Math.max(1, this.w - listW - 2); this.formView.h = Math.max(1, this.h - 2);
|
|
2295
2803
|
}
|
|
2296
2804
|
async load() {
|
|
2805
|
+
if (this.loaded && this.#dirty()) {
|
|
2806
|
+
this.app.toast("模型配置仍有未保存修改");
|
|
2807
|
+
this.#rebuild();
|
|
2808
|
+
this.app.redraw();
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
this.pendingProbeKeys.clear();
|
|
2812
|
+
let described = false;
|
|
2813
|
+
let providerState = null;
|
|
2297
2814
|
try {
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2815
|
+
// WebUI parity: the Host directory is the source of truth for official,
|
|
2816
|
+
// catalog, and declared provider identities. settings.describe alone can
|
|
2817
|
+
// only reveal routes that are already configured.
|
|
2818
|
+
const [d, listing] = await Promise.all([
|
|
2819
|
+
this.app.api.call("settings.describe"),
|
|
2820
|
+
this.app.api.call("llm.providers").catch(() => ({ providers: [] })),
|
|
2821
|
+
]);
|
|
2822
|
+
this.directory = (listing?.providers ?? []).filter((entry) => entry && typeof entry.provider === "string");
|
|
2823
|
+
this.namespaceViews = new Map((d.namespaces ?? []).map((view) => [view.ns, view]));
|
|
2824
|
+
this.revisions = new Map((d.namespaces ?? []).map((view) => [view.ns, view.revision ?? 0]));
|
|
2825
|
+
const ns = this.namespaceViews.get("llm-pi-ai");
|
|
2826
|
+
const hasLayerView = ns && (Object.hasOwn(ns, "user") || Object.hasOwn(ns, "base"));
|
|
2827
|
+
const configured = hasLayerView ? ns.user?.providers : ns?.value?.providers;
|
|
2828
|
+
this.providers = { ...(configured ?? {}) };
|
|
2829
|
+
this.resolvedProviders = { ...(ns?.value?.providers ?? this.providers) };
|
|
2830
|
+
this.baseProviders = { ...(ns?.base?.providers ?? {}) };
|
|
2831
|
+
this.inheritedProviders = Object.fromEntries(Object.entries(this.resolvedProviders).map(([route, profile]) => [
|
|
2832
|
+
route,
|
|
2833
|
+
mergeConfig(withoutOwned(profile, this.providers[route]), this.baseProviders[route]),
|
|
2834
|
+
]));
|
|
2835
|
+
this.configuredDirectory.clear();
|
|
2836
|
+
this.configuredDirectory = new Set(this.directory.filter((entry) => this.#configuredEntry(entry)).map((entry) => entry.provider));
|
|
2837
|
+
this.initialConfiguredDirectory = new Set(this.configuredDirectory);
|
|
2838
|
+
this.externalDrafts = new Map();
|
|
2839
|
+
this.externalInherited = new Map();
|
|
2840
|
+
this.externalUserConfigured = new Set();
|
|
2841
|
+
this.externalSnapshots = new Map();
|
|
2842
|
+
this.externalHostSnapshots = new Map();
|
|
2843
|
+
for (const entry of this.directory) {
|
|
2844
|
+
if (entry.settingsNs === "llm-pi-ai") continue;
|
|
2845
|
+
const view = this.namespaceViews.get(entry.settingsNs);
|
|
2846
|
+
if (!view) continue;
|
|
2847
|
+
const stored = configAt(view.user, entry.settingsPath);
|
|
2848
|
+
if (stored !== undefined && entry.settingsPath.length > 0) this.externalUserConfigured.add(entry.provider);
|
|
2849
|
+
const draft = cloneConfig(stored ?? {});
|
|
2850
|
+
const inherited = mergeConfig(withoutOwned(configAt(view.value, entry.settingsPath), stored), configAt(view.base, entry.settingsPath));
|
|
2851
|
+
const snapshot = JSON.stringify(draft);
|
|
2852
|
+
this.externalDrafts.set(entry.provider, draft);
|
|
2853
|
+
this.externalInherited.set(entry.provider, inherited);
|
|
2854
|
+
this.externalSnapshots.set(entry.provider, snapshot);
|
|
2855
|
+
this.externalHostSnapshots.set(entry.provider, snapshot);
|
|
2856
|
+
}
|
|
2302
2857
|
this.revision = ns?.revision ?? 0;
|
|
2303
|
-
this.
|
|
2304
|
-
this.
|
|
2305
|
-
this.
|
|
2858
|
+
this.writable = d.writable !== false;
|
|
2859
|
+
this.materializeRoutes.clear();
|
|
2860
|
+
this.addMode = false;
|
|
2861
|
+
this.#syncRoutes();
|
|
2862
|
+
this.sel = Math.max(0, Math.min(this.sel, this.routes.length));
|
|
2863
|
+
providerState = this.#providerStateFromDescription(d);
|
|
2864
|
+
described = providerState !== null;
|
|
2306
2865
|
} catch (e) { this.app.toast(`模型配置加载失败: ${e.message}`); }
|
|
2307
2866
|
this.savedSnapshot = JSON.stringify(this.providers);
|
|
2867
|
+
this.hostSnapshot = this.savedSnapshot;
|
|
2868
|
+
const cleanup = described
|
|
2869
|
+
? await this.#retryPendingCredentialCleanups({ notify: false, providerState })
|
|
2870
|
+
: { completed: [], failed: [] };
|
|
2308
2871
|
await this.#refreshKeys();
|
|
2872
|
+
if (cleanup.failed.length > 0) this.#showCredentialCleanupFailure(cleanup.failed[0]);
|
|
2873
|
+
else if (cleanup.completed.length > 0) this.app.toast(`已清理托管密钥 ${cleanup.completed.join("、")}`);
|
|
2309
2874
|
this.loaded = true;
|
|
2310
2875
|
this.modelsSel = -1;
|
|
2311
2876
|
this.#rebuild();
|
|
2312
2877
|
this.app.redraw();
|
|
2313
2878
|
}
|
|
2879
|
+
#persistCredentialCleanups() {
|
|
2880
|
+
return saveTuiConfig({
|
|
2881
|
+
pendingModelCredentialCleanups: [...this.pendingCredentialCleanups].map(([ref, task]) => ({
|
|
2882
|
+
ref,
|
|
2883
|
+
route: task.route,
|
|
2884
|
+
error: task.error,
|
|
2885
|
+
...(task.reconcile ? { reconcile: true } : {}),
|
|
2886
|
+
})),
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
#providerStateFromDescription(description) {
|
|
2890
|
+
const ns = (description?.namespaces ?? []).find((item) => item.ns === "llm-pi-ai");
|
|
2891
|
+
if (!isRecord(ns?.value) || !isRecord(ns.value.providers)) return null;
|
|
2892
|
+
const providers = ns.value.providers;
|
|
2893
|
+
const routes = new Set(Object.keys(providers));
|
|
2894
|
+
const refs = new Set(Object.entries(providers).map(([route, profile]) => {
|
|
2895
|
+
const configured = isRecord(profile) && typeof profile.apiKeyEnv === "string" ? profile.apiKeyEnv : "";
|
|
2896
|
+
return configured || deriveKeyRef(route);
|
|
2897
|
+
}));
|
|
2898
|
+
return { routes, refs };
|
|
2899
|
+
}
|
|
2900
|
+
#providerStateFromProfiles(providers) {
|
|
2901
|
+
return this.#providerStateFromDescription({ namespaces: [{ ns: "llm-pi-ai", value: { providers } }] });
|
|
2902
|
+
}
|
|
2903
|
+
#cleanupRouteReserved(route) {
|
|
2904
|
+
return [...this.pendingCredentialCleanups.values()].some((task) => task.route === route);
|
|
2905
|
+
}
|
|
2906
|
+
async #retryPendingCredentialCleanups({ onlyRef = null, notify = true, providerState = null } = {}) {
|
|
2907
|
+
const targets = [...this.pendingCredentialCleanups].filter(([ref]) => onlyRef === null || ref === onlyRef);
|
|
2908
|
+
if (targets.length === 0) return { completed: [], preserved: [], failed: [], persisted: true };
|
|
2909
|
+
const before = new Map([...this.pendingCredentialCleanups].map(([ref, task]) => [ref, { ...task }]));
|
|
2910
|
+
const completed = [], preserved = [], failed = [];
|
|
2911
|
+
let state = providerState;
|
|
2912
|
+
if (state === null) {
|
|
2913
|
+
try {
|
|
2914
|
+
state = this.#providerStateFromDescription(await this.app.api.call("settings.describe"));
|
|
2915
|
+
if (state === null) throw new Error("llm-pi-ai 配置暂不可用");
|
|
2916
|
+
} catch (error) {
|
|
2917
|
+
const message = `无法核对 Host 模型配置: ${String(error?.message ?? error).slice(0, 500)}`;
|
|
2918
|
+
for (const [ref, task] of targets) {
|
|
2919
|
+
const failure = { ref, route: task.route, error: message, reconcile: task.reconcile === true };
|
|
2920
|
+
this.pendingCredentialCleanups.set(ref, { route: task.route, error: message, reconcile: task.reconcile === true });
|
|
2921
|
+
failed.push(failure);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
if (state !== null) {
|
|
2926
|
+
for (const [ref, task] of targets) {
|
|
2927
|
+
// Any effective Host profile may reuse this reference. In that case the
|
|
2928
|
+
// old deletion task resolves by preservation, never by an unset.
|
|
2929
|
+
if (state.refs.has(ref)) {
|
|
2930
|
+
this.pendingCredentialCleanups.delete(ref);
|
|
2931
|
+
preserved.push(ref);
|
|
2932
|
+
continue;
|
|
2933
|
+
}
|
|
2934
|
+
// A pre-mutation journal survives ambiguous transport failures. If its
|
|
2935
|
+
// route now exists with another reference, do not guess whether it was
|
|
2936
|
+
// recreated; require an explicit keep decision.
|
|
2937
|
+
if (task.reconcile && state.routes.has(task.route)) {
|
|
2938
|
+
const message = `路由 ${task.route} 仍存在,无法自动确认旧密钥可清理`;
|
|
2939
|
+
const failure = { ref, route: task.route, error: message, reconcile: true };
|
|
2940
|
+
this.pendingCredentialCleanups.set(ref, { route: task.route, error: message, reconcile: true });
|
|
2941
|
+
failed.push(failure);
|
|
2942
|
+
continue;
|
|
2943
|
+
}
|
|
2944
|
+
try {
|
|
2945
|
+
await this.app.api.call("credentials.unset", { ref });
|
|
2946
|
+
this.pendingCredentialCleanups.delete(ref);
|
|
2947
|
+
completed.push(ref);
|
|
2948
|
+
} catch (error) {
|
|
2949
|
+
const message = String(error?.message ?? error).slice(0, 500);
|
|
2950
|
+
const failure = { ref, route: task.route, error: message };
|
|
2951
|
+
this.pendingCredentialCleanups.set(ref, { route: task.route, error: message });
|
|
2952
|
+
failed.push(failure);
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
const persisted = this.#persistCredentialCleanups();
|
|
2957
|
+
if (!persisted) {
|
|
2958
|
+
this.pendingCredentialCleanups.clear();
|
|
2959
|
+
for (const [ref, task] of before) this.pendingCredentialCleanups.set(ref, task);
|
|
2960
|
+
completed.length = 0;
|
|
2961
|
+
preserved.length = 0;
|
|
2962
|
+
failed.length = 0;
|
|
2963
|
+
for (const [ref, task] of targets) failed.push({
|
|
2964
|
+
ref,
|
|
2965
|
+
route: task.route,
|
|
2966
|
+
error: "待清理密钥状态无法写入 tui-config.json",
|
|
2967
|
+
reconcile: task.reconcile === true,
|
|
2968
|
+
});
|
|
2969
|
+
}
|
|
2970
|
+
if (notify) {
|
|
2971
|
+
if (failed.length > 0) this.app.toast(`托管密钥清理失败: ${failed[0].error}`);
|
|
2972
|
+
else if (completed.length > 0) this.app.toast(`已清理托管密钥 ${completed.join("、")}`);
|
|
2973
|
+
else if (preserved.length > 0) this.app.toast(`凭据 ${preserved.join("、")} 已被新配置使用,已保留`);
|
|
2974
|
+
}
|
|
2975
|
+
return { completed, preserved, failed, persisted };
|
|
2976
|
+
}
|
|
2977
|
+
#showCredentialCleanupFailure(task) {
|
|
2978
|
+
const current = this.pendingCredentialCleanups.get(task.ref);
|
|
2979
|
+
if (!current) return;
|
|
2980
|
+
const ref = task.ref;
|
|
2981
|
+
const error = current.error || task.error || "未知错误";
|
|
2982
|
+
const w = Math.max(30, Math.min(72, this.app.screen.w - 4));
|
|
2983
|
+
this.app.overlay = new Popup({
|
|
2984
|
+
x: Math.max(0, Math.floor((this.app.screen.w - w) / 2)),
|
|
2985
|
+
y: Math.max(0, Math.floor(this.app.screen.h / 2) - 4),
|
|
2986
|
+
w, h: Math.min(9, this.app.screen.h), title: "托管密钥待清理",
|
|
2987
|
+
lines: [
|
|
2988
|
+
[{ t: current.reconcile
|
|
2989
|
+
? ` 供应商 ${inlineLabel(current.route)} 的删除结果待核对,${ref} 暂不清理。`
|
|
2990
|
+
: ` 供应商 ${inlineLabel(current.route)} 已删除,但 ${ref} 尚未清理。`, fg: K.WARN }],
|
|
2991
|
+
[{ t: ` ${truncate(inlineLabel(error), w - 4)}`, fg: K.DIM }],
|
|
2992
|
+
[{ t: " 可立即重试;保留密钥会停止后续自动清理。", fg: K.TXT }],
|
|
2993
|
+
],
|
|
2994
|
+
buttons: [
|
|
2995
|
+
{ label: "稍后", action: "later" },
|
|
2996
|
+
{ label: "重试清理", action: "retry" },
|
|
2997
|
+
{ label: "保留密钥", action: "keep" },
|
|
2998
|
+
],
|
|
2999
|
+
onAction: async (button) => {
|
|
3000
|
+
if (button?.action === "retry") {
|
|
3001
|
+
this.app.closeOverlay();
|
|
3002
|
+
const result = await this.#retryPendingCredentialCleanups({ onlyRef: ref });
|
|
3003
|
+
await this.#refreshKeys();
|
|
3004
|
+
if (result.failed.length > 0) this.#showCredentialCleanupFailure(result.failed[0]);
|
|
3005
|
+
} else if (button?.action === "keep") {
|
|
3006
|
+
const saved = this.pendingCredentialCleanups.get(ref);
|
|
3007
|
+
this.pendingCredentialCleanups.delete(ref);
|
|
3008
|
+
if (!this.#persistCredentialCleanups()) {
|
|
3009
|
+
this.pendingCredentialCleanups.set(ref, saved);
|
|
3010
|
+
this.app.toast("无法保存保留决定,清理任务仍待处理");
|
|
3011
|
+
} else {
|
|
3012
|
+
this.app.closeOverlay();
|
|
3013
|
+
this.app.toast(`已保留 ${ref},不会再自动清理`);
|
|
3014
|
+
}
|
|
3015
|
+
} else {
|
|
3016
|
+
this.app.closeOverlay();
|
|
3017
|
+
this.app.toast(`${ref} 仍待清理`);
|
|
3018
|
+
}
|
|
3019
|
+
this.#rebuild();
|
|
3020
|
+
this.app.redraw();
|
|
3021
|
+
},
|
|
3022
|
+
});
|
|
3023
|
+
this.app.redraw();
|
|
3024
|
+
}
|
|
2314
3025
|
/** One batched credentials.describe over every referenced key, exactly like
|
|
2315
3026
|
* the web page's store join. Reads are structurally value-free: only the
|
|
2316
3027
|
* configured/source/writable view ever reaches this panel. */
|
|
@@ -2319,55 +3030,129 @@ export class ModelPanel extends Widget {
|
|
|
2319
3030
|
// only well-formed references can cross the wire (the describe payload
|
|
2320
3031
|
// validates each name); an ill-formed derived ref is skipped here and
|
|
2321
3032
|
// reported by the row's edit guard instead
|
|
2322
|
-
const refs = [...new Set(
|
|
3033
|
+
const refs = [...new Set([
|
|
3034
|
+
...this.routes.map((r) => this.#keyRef(r)),
|
|
3035
|
+
...this.directory.filter((entry) => this.configuredDirectory.has(entry.provider)).map((entry) => this.#keyRef(entry.provider)),
|
|
3036
|
+
].filter((ref) => KEY_REF_OK.test(ref)))];
|
|
2323
3037
|
if (refs.length === 0) { this.keyStatus = {}; return; }
|
|
2324
3038
|
const res = await this.app.api.call("credentials.describe", { refs });
|
|
2325
3039
|
this.keyStatus = res?.credentials ?? {};
|
|
2326
3040
|
} catch (e) { this.keyStatus = {}; }
|
|
2327
3041
|
}
|
|
3042
|
+
#entry(route) { return this.directory.find((entry) => entry.provider === route); }
|
|
3043
|
+
#namespace(route) { return this.#entry(route)?.settingsNs ?? "llm-pi-ai"; }
|
|
3044
|
+
#configuredEntry(entry) {
|
|
3045
|
+
if (this.configuredDirectory.has(entry.provider)) return true;
|
|
3046
|
+
const view = this.namespaceViews.get(entry.settingsNs);
|
|
3047
|
+
if (!view) return false;
|
|
3048
|
+
// A root-addressed adapter is not automatically configured merely because
|
|
3049
|
+
// its namespace exists. Official/active rows are host-owned; optional root
|
|
3050
|
+
// adapters need an actual user-layer section before they leave the chooser.
|
|
3051
|
+
if (entry.settingsPath.length === 0) return entry.active === true || configAt(view.user, entry.settingsPath) !== undefined;
|
|
3052
|
+
return configAt(view.value, entry.settingsPath) !== undefined;
|
|
3053
|
+
}
|
|
2328
3054
|
/** The credential reference a profile names, or the web's derived default. */
|
|
2329
3055
|
#keyRef(route) {
|
|
2330
3056
|
const p = this.#profile(route);
|
|
2331
3057
|
return (p.apiKeyEnv && p.apiKeyEnv.length > 0) ? p.apiKeyEnv : deriveKeyRef(route);
|
|
2332
3058
|
}
|
|
3059
|
+
#syncRoutes() {
|
|
3060
|
+
const configuredDirectory = this.directory.filter((entry) => this.configuredDirectory.has(entry.provider)).map((entry) => entry.provider);
|
|
3061
|
+
this.routes = [...new Set([...configuredDirectory, ...Object.keys(this.resolvedProviders), ...Object.keys(this.providers)])];
|
|
3062
|
+
}
|
|
2333
3063
|
#route() { return this.routes[this.sel] ?? null; }
|
|
2334
|
-
#
|
|
3064
|
+
#draftProfile(route) {
|
|
3065
|
+
if (route == null) return null;
|
|
3066
|
+
if (this.externalDrafts.has(route)) return this.externalDrafts.get(route);
|
|
3067
|
+
this.providers[route] ??= {};
|
|
3068
|
+
return this.providers[route];
|
|
3069
|
+
}
|
|
3070
|
+
#profile(route) {
|
|
3071
|
+
if (route == null) return null;
|
|
3072
|
+
if (this.externalDrafts.has(route)) return mergeConfig(this.externalInherited.get(route), this.externalDrafts.get(route));
|
|
3073
|
+
const effective = mergeConfig(this.inheritedProviders[route], this.providers[route]);
|
|
3074
|
+
const entry = this.#entry(route);
|
|
3075
|
+
// Dormant Host catalog entries are intentionally absent from settings, but
|
|
3076
|
+
// the directory still owns their display identity. Do not materialize this
|
|
3077
|
+
// fallback into the saved profile.
|
|
3078
|
+
if (!effective.displayName && entry?.declared !== true) effective.displayName = entry?.displayName;
|
|
3079
|
+
return effective;
|
|
3080
|
+
}
|
|
3081
|
+
#models(route, { mutable = false } = {}) {
|
|
3082
|
+
const draft = mutable ? this.#draftProfile(route) : (this.externalDrafts.has(route) ? this.externalDrafts.get(route) : this.providers[route]);
|
|
3083
|
+
if (mutable && !Array.isArray(draft.models)) draft.models = cloneConfig(this.#profile(route).models ?? []);
|
|
3084
|
+
return draft?.models ?? this.#profile(route).models ?? [];
|
|
3085
|
+
}
|
|
3086
|
+
#pruneEmptyDraft(route) {
|
|
3087
|
+
if (this.externalDrafts.has(route) || this.materializeRoutes.has(route)) return;
|
|
3088
|
+
if (!Object.hasOwn(JSON.parse(this.hostSnapshot), route) && Object.keys(this.providers[route] ?? {}).length === 0) {
|
|
3089
|
+
delete this.providers[route];
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
#stripCompat(route) {
|
|
3093
|
+
const profile = this.#draftProfile(route);
|
|
3094
|
+
delete profile.compat;
|
|
3095
|
+
if (this.#models(route).some((model) => model.compat !== undefined)) {
|
|
3096
|
+
for (const model of this.#models(route, { mutable: true })) delete model.compat;
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
2335
3099
|
#formRows() {
|
|
2336
3100
|
const route = this.#route();
|
|
2337
3101
|
if (route == null) return [];
|
|
2338
3102
|
const p = this.#profile(route);
|
|
3103
|
+
const entry = this.#entry(route);
|
|
3104
|
+
const officialDeepSeek = entry?.settingsNs === "llm-deepseek";
|
|
3105
|
+
const catalogRoute = entry?.settingsNs === "llm-pi-ai" && entry.declared !== true;
|
|
3106
|
+
const ownsIdentity = !officialDeepSeek && !catalogRoute;
|
|
2339
3107
|
const items = [];
|
|
2340
|
-
//
|
|
3108
|
+
// Only hand-declared custom routes own their route identity/display name.
|
|
2341
3109
|
if (this.draftRoute === route) items.push({ kind: "field", key: "route", label: "路由名", value: route });
|
|
2342
|
-
items.push({ kind: "field", key: "displayName", label: "显示名", value: p.displayName ?? "" });
|
|
3110
|
+
if (ownsIdentity) items.push({ kind: "field", key: "displayName", label: "显示名", value: p.displayName ?? "" });
|
|
2343
3111
|
// the api protocol is a CHOICE in the web UI (a select over the namespace
|
|
2344
3112
|
// schema's union), so here Tab cycles the options in the form and Enter
|
|
2345
3113
|
// opens an edit buffer with every candidate shown as an autocomplete hint
|
|
2346
|
-
const api = p.api ?? "
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
items.push({ kind: "field", key: "
|
|
2355
|
-
|
|
3114
|
+
const api = p.api ?? "";
|
|
3115
|
+
if (ownsIdentity) {
|
|
3116
|
+
items.push({
|
|
3117
|
+
kind: "field", key: "api", label: "协议 api", value: api,
|
|
3118
|
+
cycle: API_PROTOCOLS.includes(api) ? API_PROTOCOLS : ["", ...(api ? [api] : []), ...API_PROTOCOLS],
|
|
3119
|
+
completions: API_PROTOCOLS, note: "Tab 切换",
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
items.push({ kind: "field", key: "baseURL", label: "baseURL", value: p.baseURL ?? "", note: officialDeepSeek ? "留空=https://api.deepseek.com" : catalogRoute ? "留空=提供方默认" : undefined });
|
|
3123
|
+
if (ownsIdentity) {
|
|
3124
|
+
items.push({ kind: "field", key: "reasoning", label: "默认思考强度", value: p.reasoning ?? "", cycle: ["", ...THINKING_LEVELS], completions: THINKING_LEVELS, note: "留空=模型默认 · Tab 切换" });
|
|
3125
|
+
items.push({ kind: "field", key: "defaultContextWindow", label: "默认上下文", value: p.defaultContextWindow ?? "", numeric: true });
|
|
3126
|
+
items.push({ kind: "field", key: "defaultMaxTokens", label: "默认最大输出", value: p.defaultMaxTokens ?? "", numeric: true });
|
|
3127
|
+
// route-level input fallback: only the modalities the pi-ai adapter schema accepts.
|
|
3128
|
+
for (const modality of INPUT_MODALITIES) {
|
|
3129
|
+
items.push({ kind: "choice", key: `defaultInput.${modality}`, label: `默认输入 ${modality}`, value: (p.defaultInput ?? DEFAULT_INPUT_MODALITIES).includes(modality) ? "✓" : "·" });
|
|
3130
|
+
}
|
|
3131
|
+
if (api === "openai-completions") {
|
|
3132
|
+
items.push({ kind: "field", key: "compat.thinkingFormat", label: "compat.thinkingFormat", value: p.compat?.thinkingFormat ?? "", completions: THINKING_FORMATS, note: "可选 · Tab 补全" });
|
|
3133
|
+
items.push({ kind: "field", key: "compat.supportsReasoningEffort", label: "compat.supportsReasoningEffort", value: p.compat?.supportsReasoningEffort == null ? "" : String(p.compat.supportsReasoningEffort), completions: ["true", "false"], note: "可选 · true/false" });
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
2356
3136
|
// the api key: web-synced handling — the stored value is NEVER shown
|
|
2357
3137
|
// (credentials.describe is structurally value-free), only its status dot;
|
|
2358
|
-
// Enter opens a masked, always-empty editor
|
|
2359
|
-
//
|
|
3138
|
+
// Enter opens a masked, always-empty editor. The typed value remains a
|
|
3139
|
+
// write-only draft until save persists it through credentials.set.
|
|
2360
3140
|
const keyRef = this.#keyRef(route);
|
|
2361
|
-
items.push({ kind: "key", key: "apiKeyEnv", label: "API 密钥", ref: keyRef, action: () => this.#editKey(route, keyRef) });
|
|
2362
|
-
if (this.keyStatus?.[keyRef]?.configured) items.push({ kind: "button", label: "清除 API 密钥…", action: () => this.#clearKey(keyRef) });
|
|
3141
|
+
items.push({ kind: "key", key: "apiKeyEnv", label: "API 密钥", ref: keyRef, pending: this.pendingProbeKeys.has(route), action: () => this.#editKey(route, keyRef) });
|
|
3142
|
+
if (this.writable && this.keyStatus?.[keyRef]?.configured && this.keyStatus[keyRef].writable === true) items.push({ kind: "button", label: "清除 API 密钥…", action: () => this.#clearKey(route, keyRef) });
|
|
2363
3143
|
// models are NOT flat here: one 模型管理 entry summarizing the first
|
|
2364
3144
|
// five, which opens its own sub-buffer (scan on top, model form below)
|
|
2365
3145
|
const models = p.models ?? [];
|
|
2366
|
-
const names = models.slice(0, 5).map((m) => m.id || "(未命名)").join(" · ");
|
|
2367
|
-
|
|
2368
|
-
items.push({ kind: "
|
|
3146
|
+
const names = models.slice(0, 5).map((m) => inlineLabel(m.id || "(未命名)")).join(" · ");
|
|
3147
|
+
const inheritedCatalog = models.length === 0 && (officialDeepSeek || catalogRoute);
|
|
3148
|
+
items.push({ kind: "button", label: "模型管理", sub: inheritedCatalog ? "使用 Host 内置模型目录" : names + (models.length > 5 ? " · …" : ""), action: () => this.#openModels() });
|
|
2369
3149
|
items.push({ kind: "button", label: "💾 保存配置", action: () => this.#save() });
|
|
2370
|
-
|
|
3150
|
+
const externalUserConfig = !officialDeepSeek && this.externalUserConfigured.has(route);
|
|
3151
|
+
if (externalUserConfig) {
|
|
3152
|
+
items.push({ kind: "button", label: "🗑 取消配置提供方", action: () => this.#unconfigureExternalProvider() });
|
|
3153
|
+
} else if (!officialDeepSeek && Object.hasOwn(this.providers, route) && !Object.hasOwn(this.baseProviders, route)) {
|
|
3154
|
+
items.push({ kind: "button", label: catalogRoute ? "🗑 取消配置提供方" : "🗑 删除供应商", action: () => this.#deleteProvider() });
|
|
3155
|
+
}
|
|
2371
3156
|
return items;
|
|
2372
3157
|
}
|
|
2373
3158
|
/** The 模型管理 sub-buffer: scan first, then the model-info form rows. */
|
|
@@ -2375,8 +3160,14 @@ export class ModelPanel extends Widget {
|
|
|
2375
3160
|
const route = this.#route();
|
|
2376
3161
|
if (route == null) return [];
|
|
2377
3162
|
const p = this.#profile(route);
|
|
3163
|
+
const entry = this.#entry(route);
|
|
3164
|
+
const officialDeepSeek = entry?.settingsNs === "llm-deepseek";
|
|
3165
|
+
const catalogRoute = entry?.settingsNs === "llm-pi-ai" && entry.declared !== true;
|
|
2378
3166
|
const items = [];
|
|
2379
|
-
items.push({ kind: "button", label: "🔄
|
|
3167
|
+
if (!officialDeepSeek) items.push({ kind: "button", label: "🔄 自动发现可用模型", action: () => this.#scan() });
|
|
3168
|
+
if ((officialDeepSeek || catalogRoute) && Object.hasOwn(this.#draftProfile(route), "models")) {
|
|
3169
|
+
items.push({ kind: "button", label: "↺ 恢复 Host 内置模型目录", action: () => this.#resetModels() });
|
|
3170
|
+
}
|
|
2380
3171
|
const models = p.models ?? [];
|
|
2381
3172
|
for (let mi = 0; mi < models.length; mi++) {
|
|
2382
3173
|
const m = models[mi];
|
|
@@ -2386,15 +3177,31 @@ export class ModelPanel extends Widget {
|
|
|
2386
3177
|
items.push({ kind: "field", key: `model.${mi}.name`, label: " 模型名", value: m.name ?? "" });
|
|
2387
3178
|
items.push({ kind: "field", key: `model.${mi}.contextWindow`, label: " 上下文窗口", value: m.contextWindow ?? "", numeric: true });
|
|
2388
3179
|
items.push({ kind: "field", key: `model.${mi}.maxTokens`, label: " 最大输出", value: m.maxTokens ?? "", numeric: true });
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
3180
|
+
if (!officialDeepSeek && !catalogRoute) {
|
|
3181
|
+
const reasoningState = m.reasoningEfforts === undefined ? "继承" : m.reasoningEfforts === false ? "关闭" : "自定义";
|
|
3182
|
+
items.push({ kind: "choice", key: `model.${mi}.reasoningMode`, label: " 思考能力", value: reasoningState, cycle: ["继承", "关闭", "自定义"] });
|
|
3183
|
+
if (reasoningState === "自定义") {
|
|
3184
|
+
for (const level of THINKING_LEVELS) {
|
|
3185
|
+
const declared = Object.hasOwn(m.reasoningEfforts, level);
|
|
3186
|
+
const value = declared && m.reasoningEfforts[level] === null ? "null" : declared ? m.reasoningEfforts[level] : "";
|
|
3187
|
+
items.push({ kind: "field", key: `model.${mi}.reasoning.${level}`, label: ` ${level}`, value, note: level === "off" ? "null 表示关闭" : "至少填写一种非 off 强度" });
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
const inputState = m.input === undefined || m.input.length === 0 ? "继承" : "自定义";
|
|
3191
|
+
items.push({ kind: "choice", key: `model.${mi}.inputMode`, label: " 输入能力", value: inputState, cycle: ["继承", "自定义"] });
|
|
3192
|
+
if (inputState === "自定义") {
|
|
3193
|
+
for (const modality of INPUT_MODALITIES) items.push({ kind: "choice", key: `model.${mi}.input.${modality}`, label: ` ${modality}`, value: m.input.includes(modality) ? "✓" : "·", cycle: ["✓", "·"] });
|
|
3194
|
+
}
|
|
3195
|
+
if (p.api === "openai-completions") {
|
|
3196
|
+
items.push({ kind: "field", key: `model.${mi}.compat.thinkingFormat`, label: " compat.thinkingFormat", value: m.compat?.thinkingFormat ?? "", completions: THINKING_FORMATS, note: "可选 · Tab 补全" });
|
|
3197
|
+
items.push({ kind: "field", key: `model.${mi}.compat.supportsReasoningEffort`, label: " compat.supportsReasoningEffort", value: m.compat?.supportsReasoningEffort == null ? "" : String(m.compat.supportsReasoningEffort), completions: ["true", "false"], note: "可选 · true/false" });
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
2392
3200
|
}
|
|
2393
3201
|
}
|
|
2394
3202
|
items.push({ kind: "button", label: "+ 添加模型", action: () => this.#addModel() });
|
|
2395
3203
|
items.push({ kind: "button", label: "🗑 删除选中模型", action: () => this.#deleteModel() });
|
|
2396
|
-
items.push({ kind: "button", label: "◉
|
|
2397
|
-
items.push({ kind: "button", label: "★ 设为默认 Agent/Subagent 目标", action: () => this.#setAgentDefault() });
|
|
3204
|
+
items.push({ kind: "button", label: "◉ 设为当前会话及后续 Agent 默认模型", action: () => this.#setDefaultModel() });
|
|
2398
3205
|
return items;
|
|
2399
3206
|
}
|
|
2400
3207
|
#openModels() {
|
|
@@ -2409,36 +3216,89 @@ export class ModelPanel extends Widget {
|
|
|
2409
3216
|
const listLines = [];
|
|
2410
3217
|
for (let i = 0; i < this.routes.length; i++) {
|
|
2411
3218
|
const r = this.routes[i];
|
|
2412
|
-
const p = this
|
|
3219
|
+
const p = this.#profile(r);
|
|
3220
|
+
const entry = this.#entry(r);
|
|
2413
3221
|
const cur = i === this.sel;
|
|
2414
3222
|
const editing = cur && this.mode === "form";
|
|
2415
3223
|
listLines.push([{
|
|
2416
|
-
t: ` ${cur ? "●" : " "} ${truncate(p.displayName || r, 18)}${editing ? " ✎" : ""}`,
|
|
3224
|
+
t: ` ${cur ? "●" : " "} ${truncate(inlineLabel(p.displayName || entry?.displayName || r), 18)}${editing ? " ✎" : ""}`,
|
|
2417
3225
|
fg: cur ? T.SELFG : T.TXT, bg: cur ? (editing ? T.MENUSEL : T.SELBG) : T.BG2, bold: cur,
|
|
2418
3226
|
}]);
|
|
2419
3227
|
}
|
|
2420
3228
|
const addCur = this.sel === this.routes.length;
|
|
2421
3229
|
listLines.push([{ t: ` ${addCur ? "●" : " "} + 添加供应商`, fg: addCur ? T.SELFG : T.ACCENT, bg: addCur ? T.MENUSEL : T.BG2, bold: true }]);
|
|
2422
3230
|
this.listView.setLines(listLines);
|
|
2423
|
-
|
|
3231
|
+
this.listView.scrollY = Math.max(0, Math.min(this.listView.maxScroll(), this.sel < this.listView.scrollY ? this.sel : this.sel >= this.listView.scrollY + this.listView.h ? this.sel - this.listView.h + 1 : this.listView.scrollY));
|
|
3232
|
+
|
|
3233
|
+
// Keep a target beside every rendered row. Titles, previews and help text
|
|
3234
|
+
// deliberately map to null, so extra visual rows cannot shift mouse clicks
|
|
3235
|
+
// onto the following form action.
|
|
2424
3236
|
const route = this.#route();
|
|
2425
3237
|
const formLines = [];
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
formLines.push(
|
|
2429
|
-
|
|
3238
|
+
this.formClickMap = [];
|
|
3239
|
+
const pushForm = (line, target = null) => {
|
|
3240
|
+
formLines.push(line);
|
|
3241
|
+
this.formClickMap.push(target);
|
|
3242
|
+
};
|
|
3243
|
+
for (const [ref, task] of this.pendingCredentialCleanups) {
|
|
3244
|
+
pushForm([{
|
|
3245
|
+
t: truncate(` ⚠ ${ref} 待清理 (${inlineLabel(task.error)}) · [c 处理]`, Math.max(20, this.formView.w - 4)),
|
|
3246
|
+
fg: K.WARN, bold: true,
|
|
3247
|
+
}], { type: "cleanup", ref });
|
|
3248
|
+
}
|
|
3249
|
+
if (this.addMode) {
|
|
3250
|
+
const selectedAdd = this.addItems[this.addCursor];
|
|
3251
|
+
pushForm([{ t: " 添加提供方 — Host 可用目录", fg: K.ACCENT, bold: true }]);
|
|
3252
|
+
pushForm([{ t: " ↑/↓ 或 j/k 循环选择 · Enter 添加 · Esc 返回", fg: K.FAINT }]);
|
|
3253
|
+
// Fixed preview: selection changes never require opening a provider just
|
|
3254
|
+
// to learn whether it is official, catalog-backed, or fully custom.
|
|
3255
|
+
if (selectedAdd?.custom) {
|
|
3256
|
+
pushForm([{ t: " 预览 自定义提供方", fg: K.ACCENT, bold: true }]);
|
|
3257
|
+
pushForm([{ t: " 手动填写路由、协议、baseURL 与至少一个模型", fg: K.TXT }]);
|
|
3258
|
+
pushForm([{ t: " API 密钥写入 <ROUTE>_API_KEY;支持模型发现(协议允许时)", fg: K.DIM }]);
|
|
3259
|
+
} else if (selectedAdd?.entry) {
|
|
3260
|
+
const entry = selectedAdd.entry;
|
|
3261
|
+
const kind = entry.settingsNs === "llm-deepseek" ? "官方适配器" : entry.declared === true ? "已声明提供方" : "Host 内置目录";
|
|
3262
|
+
const address = `${entry.settingsNs}${entry.settingsPath.length ? ` · ${entry.settingsPath.join(".")}` : " · 根配置"}`;
|
|
3263
|
+
pushForm([{ t: ` 预览 ${truncate(inlineLabel(entry.displayName || entry.provider), 32)} [${kind}]`, fg: entry.settingsNs === "llm-deepseek" ? K.OK : K.ACCENT, bold: true }]);
|
|
3264
|
+
pushForm([{ t: ` 路由 ${truncate(inlineLabel(entry.provider), 30)} · ${entry.active ? "当前已激活" : "添加后激活"}`, fg: K.TXT }]);
|
|
3265
|
+
pushForm([{ t: ` ${truncate(address, Math.max(20, this.formView.w - 10))}`, fg: K.DIM }]);
|
|
3266
|
+
const resolvedProfile = isRecord(configAt(this.namespaceViews.get(entry.settingsNs)?.value, entry.settingsPath)) ? configAt(this.namespaceViews.get(entry.settingsNs)?.value, entry.settingsPath) : {};
|
|
3267
|
+
const credentialRef = typeof resolvedProfile.apiKeyEnv === "string" && resolvedProfile.apiKeyEnv ? resolvedProfile.apiKeyEnv : deriveKeyRef(entry.provider);
|
|
3268
|
+
pushForm([{ t: ` 模型/协议/默认端点由 Host 提供 · 密钥 ${credentialRef}`, fg: K.FAINT }]);
|
|
3269
|
+
}
|
|
3270
|
+
pushForm([{ t: "" }]);
|
|
3271
|
+
const addStartLine = formLines.length;
|
|
3272
|
+
for (let i = 0; i < this.addItems.length; i++) {
|
|
3273
|
+
const item = this.addItems[i], cur = i === this.addCursor;
|
|
3274
|
+
const meta = item.custom ? "手动填写端点/协议/模型" : item.entry.settingsNs === "llm-deepseek" ? "官方" : "内置目录";
|
|
3275
|
+
pushForm([{ t: ` ${cur ? "▸" : " "} ${truncate(inlineLabel(item.label), 30)} [${meta}]`, fg: cur ? T.SELFG : item.custom ? K.ACCENT : T.TXT, bg: cur ? T.MENUSEL : T.BG2, bold: cur }], { type: "add", index: i });
|
|
3276
|
+
}
|
|
3277
|
+
this.formItems = [];
|
|
3278
|
+
const cursorLine = addStartLine + this.addCursor;
|
|
3279
|
+
if (cursorLine < this.formView.scrollY) this.formView.scrollY = cursorLine;
|
|
3280
|
+
else if (cursorLine >= this.formView.scrollY + this.formView.h) this.formView.scrollY = Math.max(0, cursorLine - this.formView.h + 1);
|
|
3281
|
+
} else if (route == null) {
|
|
3282
|
+
pushForm([{ t: " 左侧 ↑/↓ 选择供应商,Enter 打开编辑", fg: K.FAINT }]);
|
|
3283
|
+
pushForm([{ t: " “+ 添加供应商”先显示 Host 官方/内置目录,末项为自定义提供方", fg: K.FAINT }]);
|
|
3284
|
+
pushForm([{ t: " 高级字段(modelOverrides/headers/重试/超时/transport)在 设置 中编辑", fg: K.FAINT }]);
|
|
3285
|
+
pushForm([{ t: " Esc 退出供应商配置", fg: K.FAINT }]);
|
|
2430
3286
|
this.formItems = [];
|
|
2431
3287
|
} else if (this.scanMode) {
|
|
2432
|
-
|
|
2433
|
-
if (this.scanning)
|
|
3288
|
+
pushForm([{ t: ` 扫描 ${truncate(inlineLabel(this.#profile(route).baseURL), 44)} — 空格勾选,Enter 添加,↑/↓ 移动`, fg: K.ACCENT, bold: true }]);
|
|
3289
|
+
if (this.scanning) pushForm([{ t: " 扫描中…", fg: K.WARN }]);
|
|
3290
|
+
let cursorLine = null;
|
|
2434
3291
|
for (let i = 0; i < this.scanItems.length; i++) {
|
|
2435
3292
|
const m = this.scanItems[i];
|
|
2436
3293
|
const on = this.scanSel.has(m.id);
|
|
2437
3294
|
const cur = i === this.scanCursor;
|
|
2438
|
-
|
|
3295
|
+
if (cur) cursorLine = formLines.length;
|
|
3296
|
+
pushForm([{ t: ` ${cur ? "▸" : " "} [${on ? "x" : " "}] ${truncate(inlineLabel(m.id), this.formView.w - 10)}`, fg: on ? K.OK : cur ? T.TXT : K.DIM, bg: cur ? T.MENUSEL : T.BG2 }], { type: "scan", index: i });
|
|
2439
3297
|
}
|
|
2440
|
-
|
|
3298
|
+
pushForm([{ t: " Enter 添加选中 · Esc 取消扫描", fg: K.FAINT }]);
|
|
2441
3299
|
this.formItems = [];
|
|
3300
|
+
if (cursorLine != null && cursorLine < this.formView.scrollY) this.formView.scrollY = cursorLine;
|
|
3301
|
+
else if (cursorLine != null && cursorLine >= this.formView.scrollY + this.formView.h) this.formView.scrollY = Math.max(0, cursorLine - this.formView.h + 1);
|
|
2442
3302
|
} else {
|
|
2443
3303
|
const isSub = this.sub != null;
|
|
2444
3304
|
const items = isSub ? this.#subItems() : this.#formRows();
|
|
@@ -2446,41 +3306,49 @@ export class ModelPanel extends Widget {
|
|
|
2446
3306
|
else this.formItems = items;
|
|
2447
3307
|
const w = Math.max(30, this.formView.w - 4);
|
|
2448
3308
|
const cursor = isSub ? this.sub.cursor : this.formIdx;
|
|
2449
|
-
|
|
3309
|
+
let cursorLine = null;
|
|
3310
|
+
if (isSub) pushForm([{ t: ` 模型管理 — ${truncate(inlineLabel(this.#profile(route).displayName || route), 30)} (Esc 返回)`, fg: K.ACCENT, bold: true }]);
|
|
2450
3311
|
for (let i = 0; i < items.length; i++) {
|
|
2451
3312
|
const it = items[i];
|
|
2452
3313
|
const cur = i === cursor;
|
|
2453
3314
|
let t;
|
|
2454
3315
|
if (it.kind === "field" || it.kind === "choice") {
|
|
2455
|
-
const v = it.value === "" || it.value == null ? "(空)" :
|
|
3316
|
+
const v = it.value === "" || it.value == null ? "(空)" : inlineLabel(it.value);
|
|
2456
3317
|
t = ` ${cur ? "▸" : " "} ${it.label}: ${truncate(v, w - strWidth(it.label) - 6)}${it.note ? ` [${it.note}]` : ""}`;
|
|
3318
|
+
} else if (it.kind === "notice") {
|
|
3319
|
+
t = ` ${it.label}: ${truncate(inlineLabel(it.value), w - strWidth(it.label) - 6)}`;
|
|
2457
3320
|
} else if (it.kind === "key") {
|
|
2458
3321
|
// status dot + reference, NEVER the value (web-synced posture)
|
|
2459
3322
|
const st = this.keyStatus?.[it.ref];
|
|
2460
|
-
const status = st?.configured ? "● 已配置" : "○ 未配置";
|
|
3323
|
+
const status = it.pending ? "◐ 待保存" : st?.configured ? "● 已配置" : "○ 未配置";
|
|
2461
3324
|
const ro = st && st.writable === false ? " [只读]" : "";
|
|
2462
3325
|
t = ` ${cur ? "▸" : " "} ${it.label}: ${status}${ro} (${it.ref})`;
|
|
2463
3326
|
} else if (it.kind === "model") {
|
|
2464
3327
|
const extras = [it.ctx != null ? `ctx ${it.ctx}` : "", it.max != null ? `max ${it.max}` : ""].filter(Boolean).join(" ");
|
|
2465
|
-
t = ` ${cur ? "▸" : " "} 模型 ${truncate(it.id || "(未命名)", 24)} ${truncate(it.name || "", 20)} ${truncate(extras, 24)}`;
|
|
3328
|
+
t = ` ${cur ? "▸" : " "} 模型 ${truncate(inlineLabel(it.id || "(未命名)"), 24)} ${truncate(inlineLabel(it.name || ""), 20)} ${truncate(extras, 24)}`;
|
|
2466
3329
|
} else {
|
|
2467
3330
|
t = ` ${cur ? "▸" : " "} ${it.label}`;
|
|
2468
3331
|
}
|
|
2469
|
-
|
|
3332
|
+
if (cur) cursorLine = formLines.length;
|
|
3333
|
+
pushForm([{ t: truncate(t, w), fg: cur ? T.SELFG : T.TXT, bg: cur ? T.MENUSEL : T.BG2 }], { type: "item", index: i, sub: isSub });
|
|
2470
3334
|
// the 模型管理 preview: an indented, non-focusable summary line
|
|
2471
3335
|
if (!isSub && it.kind === "button" && it.sub) {
|
|
2472
|
-
|
|
3336
|
+
pushForm([{ t: ` ${truncate(it.sub, w - 8)}`, fg: K.FAINT, bg: T.BG2 }]);
|
|
2473
3337
|
}
|
|
2474
3338
|
}
|
|
2475
|
-
|
|
3339
|
+
pushForm([{ t: isSub
|
|
2476
3340
|
? " ↑/↓ 移动 · Enter 编辑或执行 · Esc 返回供应商"
|
|
2477
3341
|
: " ↑/↓ 移动 · → 进入选项 · ← 返回列表 · Enter 编辑或执行 · Tab 切换选项 · Esc 返回列表", fg: K.FAINT }]);
|
|
3342
|
+
if (cursorLine != null && cursorLine < this.formView.scrollY) this.formView.scrollY = cursorLine;
|
|
3343
|
+
else if (cursorLine != null && cursorLine >= this.formView.scrollY + this.formView.h) this.formView.scrollY = Math.max(0, cursorLine - this.formView.h + 1);
|
|
2478
3344
|
}
|
|
3345
|
+
if (!this.writable) pushForm([{ t: " 模型配置只读 · 可浏览、发现模型和切换当前会话模型", fg: K.WARN }]);
|
|
2479
3346
|
this.formView.setLines(formLines);
|
|
3347
|
+
this.formView.scrollY = Math.max(0, Math.min(this.formView.scrollY, this.formView.maxScroll()));
|
|
2480
3348
|
}
|
|
2481
3349
|
render(screen) {
|
|
2482
3350
|
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
|
|
2483
|
-
const mid = this.x
|
|
3351
|
+
const mid = this.formView.x - 1;
|
|
2484
3352
|
screen.vline(mid, this.y, this.y + this.h - 1, "│", { fg: T.BORDER });
|
|
2485
3353
|
screen.text(this.x + 1, this.y, " 模型供应商", { fg: K.DIM });
|
|
2486
3354
|
this.listView.render(screen);
|
|
@@ -2510,18 +3378,40 @@ export class ModelPanel extends Widget {
|
|
|
2510
3378
|
if (!/^[\x21-\x7E]+$/.test(value)) return "密钥只能包含可打印 ASCII 字符";
|
|
2511
3379
|
return null;
|
|
2512
3380
|
}
|
|
2513
|
-
/** Edit the API key value the web-synced way: a masked, always-empty editor
|
|
2514
|
-
*
|
|
2515
|
-
*
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
const
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
3381
|
+
/** Edit the API key value the web-synced way: a masked, always-empty editor.
|
|
3382
|
+
* The stored value is never read back; a non-empty commit stays write-only
|
|
3383
|
+
* until #save persists it, while an empty commit keeps the existing key. */
|
|
3384
|
+
#clearKey(route, ref) {
|
|
3385
|
+
const users = this.routes.filter((candidate) => this.#keyRef(candidate) === ref);
|
|
3386
|
+
const others = users.filter((candidate) => candidate !== route);
|
|
3387
|
+
const pending = users.filter((candidate) => this.pendingProbeKeys.has(candidate));
|
|
3388
|
+
const lines = [
|
|
3389
|
+
[{ t: ` ${inlineLabel(ref)} 是凭据存储中的全局引用。`, fg: K.WARN }],
|
|
3390
|
+
...(others.length > 0 ? [[{ t: ` 其他引用者: ${truncate(others.map(inlineLabel).join("、"), 52)}`, fg: K.WARN }]] : []),
|
|
3391
|
+
...(ref !== deriveKeyRef(route) ? [[{ t: " 这是自定义引用,可能还被面板外配置使用。", fg: K.WARN }]] : []),
|
|
3392
|
+
...(pending.length > 0 ? [[{ t: ` ${pending.length} 个待保存密钥草稿也会取消。`, fg: K.TXT }]] : []),
|
|
3393
|
+
[{ t: " 清除后,所有引用者将立即失去此密钥。", fg: K.TXT }],
|
|
3394
|
+
];
|
|
3395
|
+
const w = Math.max(32, Math.min(64, this.app.screen.w - 4));
|
|
3396
|
+
const h = Math.min(lines.length + 4, this.app.screen.h);
|
|
3397
|
+
const confirm = new Popup({
|
|
3398
|
+
x: Math.max(0, Math.floor((this.app.screen.w - w) / 2)),
|
|
3399
|
+
y: Math.max(0, Math.floor((this.app.screen.h - h) / 2)),
|
|
3400
|
+
w, h, title: "全局清除 API 密钥", lines,
|
|
3401
|
+
buttons: [{ label: "取消", action: "cancel" }, { label: "全局清除", action: "clear" }],
|
|
3402
|
+
onAction: async (btn) => {
|
|
3403
|
+
this.app.closeOverlay();
|
|
3404
|
+
if (btn.action !== "clear") { this.app.redraw(); return; }
|
|
3405
|
+
try {
|
|
3406
|
+
await this.app.api.call("credentials.unset", { ref });
|
|
3407
|
+
for (const candidate of users) this.pendingProbeKeys.delete(candidate);
|
|
3408
|
+
this.app.toast(`已全局清除 ${ref}`);
|
|
3409
|
+
await this.#refreshKeys();
|
|
3410
|
+
this.#rebuild();
|
|
3411
|
+
} catch (e) { this.app.toast(`清除密钥失败: ${e.message}`); }
|
|
3412
|
+
this.app.redraw();
|
|
3413
|
+
},
|
|
3414
|
+
});
|
|
2525
3415
|
this.app.overlay = confirm; this.app.redraw();
|
|
2526
3416
|
}
|
|
2527
3417
|
#editKey(route, ref) {
|
|
@@ -2530,6 +3420,7 @@ export class ModelPanel extends Widget {
|
|
|
2530
3420
|
return;
|
|
2531
3421
|
}
|
|
2532
3422
|
const st = this.keyStatus?.[ref];
|
|
3423
|
+
if (st?.writable === false) { this.app.toast(`${ref} 为只读凭据`); return; }
|
|
2533
3424
|
const popup = new EditPopup(this.app, {
|
|
2534
3425
|
title: `设置 API 密钥 — ${ref}`,
|
|
2535
3426
|
value: "",
|
|
@@ -2541,18 +3432,11 @@ export class ModelPanel extends Widget {
|
|
|
2541
3432
|
if (failure) { this.app.toast(failure); this.#rebuild(); this.app.redraw(); return; }
|
|
2542
3433
|
const v = text.trim();
|
|
2543
3434
|
if (v === "") { this.app.toast("未输入新密钥,保持原值不变"); this.#rebuild(); this.app.redraw(); return; }
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
// the web create flow records the derived reference in the profile;
|
|
2550
|
-
// persist it with the provider save
|
|
2551
|
-
p.apiKeyEnv = ref;
|
|
2552
|
-
this.app.toast(`已记录 apiKeyEnv · 点💾保存配置使供应商生效`);
|
|
2553
|
-
}
|
|
2554
|
-
await this.#refreshKeys();
|
|
2555
|
-
} catch (e) { this.app.toast(`密钥写入失败: ${e.message}`); }
|
|
3435
|
+
// Like the web editor, keep the typed value only in this write-only
|
|
3436
|
+
// draft. #save persists settings first, then writes the credential.
|
|
3437
|
+
this.pendingProbeKeys.set(route, v);
|
|
3438
|
+
if (!this.#profile(route)?.apiKeyEnv) this.#draftProfile(route).apiKeyEnv = ref;
|
|
3439
|
+
this.app.toast(`密钥待保存到 ${ref} · 可先用于自动发现`);
|
|
2556
3440
|
this.#rebuild();
|
|
2557
3441
|
this.app.redraw();
|
|
2558
3442
|
},
|
|
@@ -2561,12 +3445,28 @@ export class ModelPanel extends Widget {
|
|
|
2561
3445
|
this.app.focus(popup.input);
|
|
2562
3446
|
this.app.redraw();
|
|
2563
3447
|
}
|
|
2564
|
-
#
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
3448
|
+
#openAddProvider() {
|
|
3449
|
+
if (!this.writable) { this.app.toast("模型配置为只读"); return; }
|
|
3450
|
+
const configured = new Set(this.routes);
|
|
3451
|
+
const entries = this.directory.filter((entry) => entry.settingsNs && !configured.has(entry.provider));
|
|
3452
|
+
this.addItems = [
|
|
3453
|
+
...entries.map((entry) => ({ entry, label: entry.displayName || entry.provider })),
|
|
3454
|
+
{ custom: true, label: "自定义提供方" },
|
|
3455
|
+
];
|
|
3456
|
+
this.addCursor = 0;
|
|
3457
|
+
this.addMode = true;
|
|
3458
|
+
this.mode = "list";
|
|
3459
|
+
this.#rebuild();
|
|
3460
|
+
this.app.redraw();
|
|
3461
|
+
}
|
|
3462
|
+
#addCustomProvider() {
|
|
3463
|
+
let name = "new-provider", i = 2;
|
|
3464
|
+
while (this.routes.includes(name) || this.#cleanupRouteReserved(name) || this.pendingCredentialCleanups.has(deriveKeyRef(name))) name = `new-provider-${i++}`;
|
|
3465
|
+
this.providers[name] = { api: "openai-completions", defaultInput: [...DEFAULT_INPUT_MODALITIES], models: [] };
|
|
3466
|
+
this.configuredDirectory.add(name);
|
|
2568
3467
|
this.draftRoute = name;
|
|
2569
|
-
this.
|
|
3468
|
+
this.addMode = false;
|
|
3469
|
+
this.#syncRoutes();
|
|
2570
3470
|
this.sel = this.routes.indexOf(name);
|
|
2571
3471
|
this.mode = "form";
|
|
2572
3472
|
this.formIdx = 0;
|
|
@@ -2575,9 +3475,45 @@ export class ModelPanel extends Widget {
|
|
|
2575
3475
|
this.#rebuild();
|
|
2576
3476
|
this.app.redraw();
|
|
2577
3477
|
}
|
|
3478
|
+
#addDirectoryProvider(entry) {
|
|
3479
|
+
if (!entry || !this.namespaceViews.has(entry.settingsNs)) {
|
|
3480
|
+
this.app.toast("该提供方的设置 namespace 当前不可用");
|
|
3481
|
+
return;
|
|
3482
|
+
}
|
|
3483
|
+
this.configuredDirectory.add(entry.provider);
|
|
3484
|
+
if (entry.settingsNs === "llm-pi-ai") {
|
|
3485
|
+
this.providers[entry.provider] ??= {};
|
|
3486
|
+
this.inheritedProviders[entry.provider] = {};
|
|
3487
|
+
this.materializeRoutes.add(entry.provider);
|
|
3488
|
+
} else {
|
|
3489
|
+
const view = this.namespaceViews.get(entry.settingsNs);
|
|
3490
|
+
const stored = configAt(view?.user, entry.settingsPath);
|
|
3491
|
+
this.externalDrafts.set(entry.provider, cloneConfig(stored ?? {}));
|
|
3492
|
+
if (stored !== undefined && entry.settingsPath.length > 0) this.externalUserConfigured.add(entry.provider);
|
|
3493
|
+
this.externalInherited.set(entry.provider, mergeConfig(withoutOwned(configAt(view?.value, entry.settingsPath), stored), configAt(view?.base, entry.settingsPath)));
|
|
3494
|
+
this.externalSnapshots.set(entry.provider, JSON.stringify(this.externalDrafts.get(entry.provider)));
|
|
3495
|
+
this.externalHostSnapshots.set(entry.provider, JSON.stringify(this.externalDrafts.get(entry.provider)));
|
|
3496
|
+
}
|
|
3497
|
+
this.addMode = false;
|
|
3498
|
+
this.#syncRoutes();
|
|
3499
|
+
this.sel = this.routes.indexOf(entry.provider);
|
|
3500
|
+
this.mode = "form";
|
|
3501
|
+
this.formIdx = 0;
|
|
3502
|
+
this.modelsSel = -1;
|
|
3503
|
+
this.sub = null;
|
|
3504
|
+
this.#rebuild();
|
|
3505
|
+
this.app.redraw();
|
|
3506
|
+
}
|
|
3507
|
+
#activateAddItem() {
|
|
3508
|
+
const item = this.addItems[this.addCursor];
|
|
3509
|
+
if (!item) return;
|
|
3510
|
+
if (item.custom) this.#addCustomProvider();
|
|
3511
|
+
else this.#addDirectoryProvider(item.entry);
|
|
3512
|
+
}
|
|
2578
3513
|
#activateItem() {
|
|
2579
3514
|
if (this.mode === "list") {
|
|
2580
|
-
if (this.sel === this.routes.length) { this.#
|
|
3515
|
+
if (this.sel === this.routes.length) { this.#openAddProvider(); return; }
|
|
3516
|
+
this.addMode = false;
|
|
2581
3517
|
this.mode = "form";
|
|
2582
3518
|
this.formIdx = 0;
|
|
2583
3519
|
this.modelsSel = -1;
|
|
@@ -2591,51 +3527,136 @@ export class ModelPanel extends Widget {
|
|
|
2591
3527
|
const it = items[idx];
|
|
2592
3528
|
if (!it) return;
|
|
2593
3529
|
const route = this.#route();
|
|
2594
|
-
const
|
|
3530
|
+
const effective = this.#profile(route);
|
|
3531
|
+
const settingsMutation = it.kind === "field" || it.kind === "choice" || it.kind === "key"
|
|
3532
|
+
|| (it.kind === "button" && /保存配置|删除供应商|取消配置提供方|添加模型|删除选中模型|恢复 Host 内置模型目录|清除 API 密钥/.test(it.label));
|
|
3533
|
+
if (!this.writable && settingsMutation) { this.app.toast("模型配置为只读"); return; }
|
|
2595
3534
|
if (it.kind === "field") {
|
|
2596
3535
|
// Enter always opens the standalone edit buffer; Tab (handled in onKey)
|
|
2597
3536
|
// cycles a field that declares cycle options, and the buffer itself
|
|
2598
3537
|
// offers every completion as an autocomplete hint
|
|
2599
3538
|
this.#startEdit(it.label, it.value, (text) => {
|
|
3539
|
+
if (it.key === "api" && text.trim() && !API_PROTOCOLS.includes(text.trim())) { this.app.toast(`协议 ${text.trim()} 不受支持`); return; }
|
|
3540
|
+
if (it.key === "reasoning" && text.trim() && !THINKING_LEVELS.includes(text.trim())) { this.app.toast(`思考强度 ${text.trim()} 不受支持`); return; }
|
|
3541
|
+
if (it.numeric) {
|
|
3542
|
+
const candidate = text.trim() === "" ? undefined : Number(text);
|
|
3543
|
+
if (candidate !== undefined && (!Number.isInteger(candidate) || candidate <= 0)) { this.app.toast("请输入正整数"); return; }
|
|
3544
|
+
}
|
|
3545
|
+
if (it.key !== "route" && text.trim() === String(it.value ?? "").trim()) return;
|
|
3546
|
+
const p = this.#draftProfile(route);
|
|
2600
3547
|
if (it.key === "route") {
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
if (t !== route && this.
|
|
3548
|
+
const t = text.trim();
|
|
3549
|
+
if (!ROUTE_PATTERN.test(t)) { this.app.toast("路由名须以小写字母开头,只能包含小写字母、数字和单连字符"); return; }
|
|
3550
|
+
if (t !== route && this.routes.includes(t)) { this.app.toast(`路由 ${t} 已存在`); return; }
|
|
3551
|
+
if (t !== route && (this.#cleanupRouteReserved(t) || this.pendingCredentialCleanups.has(deriveKeyRef(t)))) {
|
|
3552
|
+
this.app.toast(`路由 ${t} 的托管密钥仍待处理,请先完成清理`);
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
2604
3555
|
if (t !== route) {
|
|
2605
|
-
|
|
3556
|
+
const profile = this.providers[route];
|
|
3557
|
+
const oldDerivedRef = deriveKeyRef(route);
|
|
3558
|
+
this.providers[t] = profile;
|
|
2606
3559
|
delete this.providers[route];
|
|
2607
|
-
|
|
3560
|
+
if (profile.apiKeyEnv === oldDerivedRef) profile.apiKeyEnv = deriveKeyRef(t);
|
|
3561
|
+
if (this.pendingProbeKeys.has(route)) {
|
|
3562
|
+
this.pendingProbeKeys.set(t, this.pendingProbeKeys.get(route));
|
|
3563
|
+
this.pendingProbeKeys.delete(route);
|
|
3564
|
+
}
|
|
3565
|
+
this.draftRoute = t;
|
|
3566
|
+
this.#syncRoutes();
|
|
2608
3567
|
this.sel = this.routes.indexOf(t);
|
|
2609
3568
|
}
|
|
2610
3569
|
} else if (it.numeric) {
|
|
2611
3570
|
const n = text.trim() === "" ? undefined : Number(text);
|
|
2612
|
-
if (n !== undefined && (!isFinite(n) || n <= 0)) { this.app.toast("请输入正数"); return; }
|
|
2613
3571
|
if (it.key.startsWith("model.")) {
|
|
2614
|
-
const [, mi, field] = it.key.split(".");
|
|
2615
|
-
|
|
3572
|
+
const [, mi, field] = it.key.split(".");
|
|
3573
|
+
const model = this.#models(route, { mutable: true })[Number(mi)];
|
|
3574
|
+
if (n === undefined) delete model[field];
|
|
3575
|
+
else model[field] = n;
|
|
3576
|
+
} else if (n === undefined) delete p[it.key];
|
|
3577
|
+
else p[it.key] = n;
|
|
2616
3578
|
} else if (it.key.startsWith("model.")) {
|
|
2617
3579
|
const [, mi, field, detail] = it.key.split(".");
|
|
2618
|
-
const model =
|
|
3580
|
+
const model = this.#models(route, { mutable: true })[Number(mi)];
|
|
2619
3581
|
if (field === "reasoning") {
|
|
2620
|
-
if (model.reasoningEfforts === false) model.reasoningEfforts = {};
|
|
3582
|
+
if (!model.reasoningEfforts || model.reasoningEfforts === false) model.reasoningEfforts = {};
|
|
2621
3583
|
const value = text.trim();
|
|
2622
3584
|
if (!value) delete model.reasoningEfforts[detail];
|
|
2623
|
-
else
|
|
2624
|
-
|
|
3585
|
+
else if (value === "null") {
|
|
3586
|
+
if (detail !== "off") { this.app.toast("只有 off 强度可以使用 null"); return; }
|
|
3587
|
+
model.reasoningEfforts[detail] = null;
|
|
3588
|
+
} else model.reasoningEfforts[detail] = value;
|
|
3589
|
+
} else if (field === "compat") {
|
|
3590
|
+
model.compat ??= {};
|
|
3591
|
+
const value = text.trim();
|
|
3592
|
+
if (!value) delete model.compat[detail];
|
|
3593
|
+
else if (detail === "supportsReasoningEffort") {
|
|
3594
|
+
if (value !== "true" && value !== "false") { this.app.toast("请输入 true 或 false,或留空删除"); return; }
|
|
3595
|
+
model.compat[detail] = value === "true";
|
|
3596
|
+
} else {
|
|
3597
|
+
if (!THINKING_FORMATS.includes(value)) { this.app.toast("请选择有效的 thinkingFormat"); return; }
|
|
3598
|
+
model.compat[detail] = value;
|
|
3599
|
+
}
|
|
3600
|
+
if (Object.keys(model.compat).length === 0) delete model.compat;
|
|
3601
|
+
} else {
|
|
3602
|
+
const value = text.trim();
|
|
3603
|
+
if (!value && field !== "id") delete model[field];
|
|
3604
|
+
else model[field] = value;
|
|
3605
|
+
}
|
|
3606
|
+
} else if (it.key.startsWith("compat.")) {
|
|
3607
|
+
const field = it.key.slice("compat.".length);
|
|
3608
|
+
p.compat ??= {};
|
|
3609
|
+
const value = text.trim();
|
|
3610
|
+
if (!value) delete p.compat[field];
|
|
3611
|
+
else if (field === "supportsReasoningEffort") {
|
|
3612
|
+
if (value !== "true" && value !== "false") { this.app.toast("请输入 true 或 false,或留空删除"); return; }
|
|
3613
|
+
p.compat[field] = value === "true";
|
|
3614
|
+
} else {
|
|
3615
|
+
if (!THINKING_FORMATS.includes(value)) { this.app.toast("请选择有效的 thinkingFormat"); return; }
|
|
3616
|
+
p.compat[field] = value;
|
|
3617
|
+
}
|
|
3618
|
+
if (Object.keys(p.compat).length === 0) delete p.compat;
|
|
2625
3619
|
} else {
|
|
2626
|
-
|
|
2627
|
-
if (it.key
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
} else p[it.key] = text;
|
|
3620
|
+
const value = text.trim();
|
|
3621
|
+
if (!value) delete p[it.key];
|
|
3622
|
+
else p[it.key] = value;
|
|
3623
|
+
if (it.key === "api" && this.#profile(route).api !== "openai-completions") this.#stripCompat(route);
|
|
2631
3624
|
}
|
|
3625
|
+
this.#pruneEmptyDraft(route);
|
|
2632
3626
|
}, it.completions);
|
|
2633
3627
|
return;
|
|
2634
3628
|
}
|
|
2635
3629
|
if (it.kind === "choice") {
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
3630
|
+
if (it.key.startsWith("defaultInput.")) {
|
|
3631
|
+
const modality = it.key.slice("defaultInput.".length);
|
|
3632
|
+
const set = new Set(effective.defaultInput ?? DEFAULT_INPUT_MODALITIES);
|
|
3633
|
+
if (set.has(modality)) {
|
|
3634
|
+
if (set.size === 1) { this.app.toast("defaultInput 至少需要一种模态"); return; }
|
|
3635
|
+
set.delete(modality);
|
|
3636
|
+
} else set.add(modality);
|
|
3637
|
+
this.#draftProfile(route).defaultInput = INPUT_MODALITIES.filter((item) => set.has(item));
|
|
3638
|
+
} else if (it.key.startsWith("model.")) {
|
|
3639
|
+
const [, mi, field, detail] = it.key.split(".");
|
|
3640
|
+
if (field === "input") {
|
|
3641
|
+
const current = this.#models(route)[Number(mi)];
|
|
3642
|
+
if (current.input?.includes(detail) && current.input.length === 1) { this.app.toast("模型输入能力至少需要一种模态"); return; }
|
|
3643
|
+
}
|
|
3644
|
+
const model = this.#models(route, { mutable: true })[Number(mi)];
|
|
3645
|
+
if (field === "reasoningMode") {
|
|
3646
|
+
const next = it.value === "继承" ? "关闭" : it.value === "关闭" ? "自定义" : "继承";
|
|
3647
|
+
if (next === "继承") delete model.reasoningEfforts;
|
|
3648
|
+
else if (next === "关闭") model.reasoningEfforts = false;
|
|
3649
|
+
else model.reasoningEfforts = { medium: "medium" };
|
|
3650
|
+
} else if (field === "inputMode") {
|
|
3651
|
+
if (it.value === "继承") model.input = [...DEFAULT_INPUT_MODALITIES];
|
|
3652
|
+
else delete model.input;
|
|
3653
|
+
} else if (field === "input") {
|
|
3654
|
+
const set = new Set(model.input);
|
|
3655
|
+
if (set.has(detail)) set.delete(detail);
|
|
3656
|
+
else set.add(detail);
|
|
3657
|
+
model.input = INPUT_MODALITIES.filter((item) => set.has(item));
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
2639
3660
|
this.#rebuild(); this.app.redraw(); return;
|
|
2640
3661
|
}
|
|
2641
3662
|
if (it.kind === "model") {
|
|
@@ -2650,22 +3671,32 @@ export class ModelPanel extends Widget {
|
|
|
2650
3671
|
return;
|
|
2651
3672
|
}
|
|
2652
3673
|
}
|
|
3674
|
+
#resetModels() {
|
|
3675
|
+
const route = this.#route();
|
|
3676
|
+
if (!route) return;
|
|
3677
|
+
if (!this.writable) { this.app.toast("模型配置为只读"); return; }
|
|
3678
|
+
const profile = this.#draftProfile(route);
|
|
3679
|
+
delete profile.models;
|
|
3680
|
+
this.modelsSel = -1;
|
|
3681
|
+
this.app.toast("已恢复 Host 内置模型目录(保存后生效)");
|
|
3682
|
+
this.#rebuild();
|
|
3683
|
+
this.app.redraw();
|
|
3684
|
+
}
|
|
2653
3685
|
#addModel() {
|
|
2654
3686
|
const route = this.#route();
|
|
2655
3687
|
if (!route) return;
|
|
2656
|
-
const
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
this.modelsSel = p.models.length - 1;
|
|
3688
|
+
const models = this.#models(route, { mutable: true });
|
|
3689
|
+
models.push({ id: "" });
|
|
3690
|
+
this.modelsSel = models.length - 1;
|
|
2660
3691
|
this.#rebuild();
|
|
2661
3692
|
this.app.redraw();
|
|
2662
3693
|
}
|
|
2663
3694
|
#deleteModel() {
|
|
2664
3695
|
const route = this.#route();
|
|
2665
3696
|
if (!route || this.modelsSel < 0) { this.app.toast("先选中一个模型"); return; }
|
|
2666
|
-
const model = this.#
|
|
3697
|
+
const model = this.#models(route)[this.modelsSel];
|
|
2667
3698
|
this.#confirmDelete(`删除模型 ${model?.name || model?.id || "(未命名)"}?`, () => {
|
|
2668
|
-
this.#
|
|
3699
|
+
this.#models(route, { mutable: true }).splice(this.modelsSel, 1);
|
|
2669
3700
|
this.modelsSel = -1;
|
|
2670
3701
|
this.#rebuild(); this.app.redraw();
|
|
2671
3702
|
});
|
|
@@ -2673,60 +3704,214 @@ export class ModelPanel extends Widget {
|
|
|
2673
3704
|
async #setDefaultModel() {
|
|
2674
3705
|
const route = this.#route();
|
|
2675
3706
|
if (!route) return;
|
|
2676
|
-
const
|
|
2677
|
-
const m = p.models?.[this.modelsSel];
|
|
3707
|
+
const m = this.#models(route)[this.modelsSel];
|
|
2678
3708
|
if (!m?.id) { this.app.toast("先选中一个模型"); return; }
|
|
2679
3709
|
if (!this.app.currentSession) { this.app.toast("先打开一个会话"); return; }
|
|
2680
3710
|
try {
|
|
2681
3711
|
await this.app.api.call("session.selectModel", { sessionId: this.app.currentSession, provider: route, model: m.id });
|
|
2682
|
-
this.app.updateModel();
|
|
2683
|
-
this.app.toast(`已切换 ${route}/${m.id}
|
|
3712
|
+
if (typeof this.app.updateModel === "function") await this.app.updateModel();
|
|
3713
|
+
this.app.toast(`已切换 ${route}/${m.id},后续 Agent/Subagent 默认使用此模型`);
|
|
2684
3714
|
} catch (e) { this.app.toast(`切换失败: ${e.message}`); }
|
|
2685
3715
|
}
|
|
2686
|
-
#
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
3716
|
+
async #save({ savePendingKeys = true } = {}) {
|
|
3717
|
+
if (!this.writable) { this.app.toast("模型配置为只读"); return false; }
|
|
3718
|
+
const route = this.#route();
|
|
3719
|
+
// Official/non-pi providers own their own settings namespace. Save their
|
|
3720
|
+
// minimal profile ops there, then persist the write-only credential using
|
|
3721
|
+
// the same two-step posture as the WebUI.
|
|
3722
|
+
if (route && this.externalDrafts.has(route)) return this.#saveExternal(route, { savePendingKeys });
|
|
3723
|
+
const persisted = JSON.parse(this.hostSnapshot);
|
|
3724
|
+
for (const [route, profile] of Object.entries(this.providers)) {
|
|
3725
|
+
// An empty profile is meaningful for a Host catalog route: it activates
|
|
3726
|
+
// the provider while inheriting protocol, endpoint, and models.
|
|
3727
|
+
if (!Object.hasOwn(persisted, route) && Object.keys(profile).length === 0 && !this.pendingProbeKeys.has(route) && !this.materializeRoutes.has(route)) delete this.providers[route];
|
|
3728
|
+
}
|
|
3729
|
+
for (const [route, profile] of Object.entries(this.providers)) {
|
|
3730
|
+
if (!route.trim()) { this.app.toast("保存失败:供应商路由名不能为空"); return false; }
|
|
3731
|
+
if (this.draftRoute === route && !ROUTE_PATTERN.test(route)) { this.app.toast("保存失败:新供应商路由名格式无效"); return false; }
|
|
3732
|
+
const entry = this.#entry(route);
|
|
3733
|
+
// declared is advisory; absent/unknown must not be guessed as custom.
|
|
3734
|
+
const declared = entry?.declared === true || this.draftRoute === route;
|
|
3735
|
+
if (profile.displayName !== undefined && !String(profile.displayName).trim()) { this.app.toast(`保存失败:${route} 的显示名不能为空`); return false; }
|
|
3736
|
+
if (profile.baseURL !== undefined && !String(profile.baseURL).trim()) { this.app.toast(`保存失败:${route} 的 baseURL 不能为空`); return false; }
|
|
3737
|
+
if (profile.apiKeyEnv !== undefined && !KEY_REF_OK.test(profile.apiKeyEnv)) { this.app.toast(`保存失败:${route} 的密钥引用无效`); return false; }
|
|
3738
|
+
if (profile.api !== undefined && !API_PROTOCOLS.includes(profile.api)) { this.app.toast(`保存失败:${route} 的协议不受支持`); return false; }
|
|
3739
|
+
// Like WebUI's CustomProviderCard, a hand-declared route cannot inherit
|
|
3740
|
+
// these three facts from the installed catalog.
|
|
3741
|
+
if (declared && !API_PROTOCOLS.includes(profile.api)) { this.app.toast(`保存失败:${route} 的自定义提供方必须选择 API 协议`); return false; }
|
|
3742
|
+
if (declared && !String(profile.baseURL ?? "").trim()) { this.app.toast(`保存失败:${route} 的自定义提供方必须填写 baseURL`); return false; }
|
|
3743
|
+
if (declared && (!Array.isArray(profile.models) || profile.models.length === 0)) { this.app.toast(`保存失败:${route} 的自定义提供方至少需要一个模型`); return false; }
|
|
3744
|
+
for (const model of profile.models ?? []) {
|
|
3745
|
+
if (!String(model.id ?? "").trim()) { this.app.toast(`保存失败:${route} 有未填写 id 的模型`); return false; }
|
|
3746
|
+
if (model.reasoningEfforts && model.reasoningEfforts !== false) {
|
|
3747
|
+
const declared = Object.entries(model.reasoningEfforts);
|
|
3748
|
+
if (declared.some(([level, wire]) => level !== "off" && (typeof wire !== "string" || wire.length === 0))) {
|
|
3749
|
+
this.app.toast(`保存失败:${route}/${model.id} 的非 off 思考强度必须填写 wire 值`); return false;
|
|
3750
|
+
}
|
|
3751
|
+
if (!declared.some(([level, wire]) => level !== "off" && typeof wire === "string" && wire.length > 0)) {
|
|
3752
|
+
this.app.toast(`保存失败:${route}/${model.id} 的自定义思考能力至少需要一种非 off 强度`); return false;
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
let settingsChanged = false;
|
|
3758
|
+
const confirmed = JSON.parse(this.savedSnapshot);
|
|
3759
|
+
if (JSON.stringify(this.providers) !== this.hostSnapshot || this.materializeRoutes.size > 0) {
|
|
3760
|
+
try {
|
|
3761
|
+
const wholeRoutes = new Set([...(this.draftRoute ? [this.draftRoute] : []), ...this.materializeRoutes]);
|
|
3762
|
+
const ops = providerOps(JSON.parse(this.hostSnapshot), this.providers, wholeRoutes);
|
|
3763
|
+
const res = await this.app.api.call("settings.mutate", {
|
|
3764
|
+
ns: "llm-pi-ai",
|
|
3765
|
+
ops,
|
|
3766
|
+
expectedRevision: this.revision,
|
|
3767
|
+
});
|
|
3768
|
+
this.revision = res?.revision ?? this.revision;
|
|
3769
|
+
this.draftRoute = null;
|
|
3770
|
+
this.materializeRoutes.clear();
|
|
3771
|
+
this.hostSnapshot = JSON.stringify(this.providers);
|
|
3772
|
+
this.initialConfiguredDirectory = new Set(this.configuredDirectory);
|
|
3773
|
+
settingsChanged = true;
|
|
3774
|
+
} catch (e) { this.app.toast(`保存失败: ${e.message}`); return false; }
|
|
3775
|
+
}
|
|
3776
|
+
for (const route of new Set([...Object.keys(confirmed), ...Object.keys(this.providers)])) {
|
|
3777
|
+
if (this.pendingProbeKeys.has(route)) continue;
|
|
3778
|
+
if (Object.hasOwn(this.providers, route)) confirmed[route] = this.providers[route];
|
|
3779
|
+
else delete confirmed[route];
|
|
3780
|
+
}
|
|
3781
|
+
this.savedSnapshot = JSON.stringify(confirmed);
|
|
2694
3782
|
try {
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
if (this.defaultModel) {
|
|
2704
|
-
const profile = this.providers[this.defaultModel.provider];
|
|
2705
|
-
if (!profile || !(profile.models ?? []).some((model) => model.id === this.defaultModel.model)) {
|
|
2706
|
-
this.app.toast("供应商已保存;默认 Agent 模型引用已失效,请选择一个现有模型后重试"); return false;
|
|
3783
|
+
if (savePendingKeys) {
|
|
3784
|
+
for (const [route, value] of [...this.pendingProbeKeys]) {
|
|
3785
|
+
const ref = this.#keyRef(route);
|
|
3786
|
+
await this.app.api.call("credentials.set", { ref, value });
|
|
3787
|
+
this.pendingProbeKeys.delete(route);
|
|
3788
|
+
if (Object.hasOwn(this.providers, route)) confirmed[route] = this.providers[route];
|
|
3789
|
+
else delete confirmed[route];
|
|
3790
|
+
this.savedSnapshot = JSON.stringify(confirmed);
|
|
2707
3791
|
}
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
3792
|
+
}
|
|
3793
|
+
await this.#refreshKeys();
|
|
3794
|
+
this.app.toast(settingsChanged
|
|
3795
|
+
? `已保存 ${Object.keys(this.providers).length} 个供应商`
|
|
3796
|
+
: savePendingKeys ? "API 密钥已保存" : "配置未变化");
|
|
3797
|
+
return true;
|
|
3798
|
+
} catch (e) {
|
|
3799
|
+
await this.#refreshKeys();
|
|
3800
|
+
this.app.toast(`${settingsChanged ? "供应商已保存;" : ""}API 密钥保存失败: ${e.message}`);
|
|
3801
|
+
return false;
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
async #saveExternal(route, { savePendingKeys = true } = {}) {
|
|
3805
|
+
const entry = this.#entry(route);
|
|
3806
|
+
const draft = this.externalDrafts.get(route) ?? {};
|
|
3807
|
+
const hostSnapshot = this.externalHostSnapshots.get(route) ?? "{}";
|
|
3808
|
+
const savedSnapshot = this.externalSnapshots.get(route) ?? "{}";
|
|
3809
|
+
for (const model of draft.models ?? []) {
|
|
3810
|
+
if (!String(model.id ?? "").trim()) { this.app.toast(`保存失败:${route} 有未填写 id 的模型`); return false; }
|
|
3811
|
+
for (const field of ["contextWindow", "maxTokens"]) {
|
|
3812
|
+
if (model[field] !== undefined && (!Number.isInteger(model[field]) || model[field] <= 0)) {
|
|
3813
|
+
this.app.toast(`保存失败:${route}/${model.id} 的 ${field} 必须是正整数`); return false;
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
let settingsChanged = false;
|
|
3818
|
+
if (JSON.stringify(draft) !== hostSnapshot) {
|
|
3819
|
+
try {
|
|
3820
|
+
const ops = profileOps(entry.settingsPath, JSON.parse(hostSnapshot), draft);
|
|
3821
|
+
if (ops.length > 0) {
|
|
3822
|
+
const res = await this.app.api.call("settings.mutate", {
|
|
3823
|
+
ns: entry.settingsNs,
|
|
3824
|
+
ops,
|
|
3825
|
+
expectedRevision: this.revisions.get(entry.settingsNs) ?? 0,
|
|
3826
|
+
});
|
|
3827
|
+
this.revisions.set(entry.settingsNs, res?.revision ?? this.revisions.get(entry.settingsNs) ?? 0);
|
|
2713
3828
|
}
|
|
3829
|
+
this.externalHostSnapshots.set(route, JSON.stringify(draft));
|
|
3830
|
+
settingsChanged = true;
|
|
3831
|
+
} catch (e) { this.app.toast(`保存失败: ${e.message}`); return false; }
|
|
3832
|
+
}
|
|
3833
|
+
try {
|
|
3834
|
+
if (savePendingKeys && this.pendingProbeKeys.has(route)) {
|
|
3835
|
+
await this.app.api.call("credentials.set", { ref: this.#keyRef(route), value: this.pendingProbeKeys.get(route) });
|
|
3836
|
+
this.pendingProbeKeys.delete(route);
|
|
2714
3837
|
}
|
|
2715
|
-
this.
|
|
3838
|
+
this.externalSnapshots.set(route, JSON.stringify(draft));
|
|
3839
|
+
this.initialConfiguredDirectory.add(route);
|
|
3840
|
+
await this.#refreshKeys();
|
|
3841
|
+
this.app.toast(settingsChanged ? `已保存 ${entry.displayName || route}` : savePendingKeys ? "API 密钥已保存" : "配置未变化");
|
|
2716
3842
|
return true;
|
|
2717
|
-
} catch (e) {
|
|
3843
|
+
} catch (e) {
|
|
3844
|
+
// Profile changes already confirmed by Host remain the host snapshot, but
|
|
3845
|
+
// the fully successful save point waits for the credential write.
|
|
3846
|
+
this.externalSnapshots.set(route, savedSnapshot);
|
|
3847
|
+
await this.#refreshKeys();
|
|
3848
|
+
this.app.toast(`${settingsChanged ? "供应商已保存;" : ""}API 密钥保存失败: ${e.message}`);
|
|
3849
|
+
return false;
|
|
3850
|
+
}
|
|
2718
3851
|
}
|
|
2719
|
-
#dirty() {
|
|
2720
|
-
|
|
2721
|
-
|
|
3852
|
+
#dirty() {
|
|
3853
|
+
if (JSON.stringify(this.providers) !== this.savedSnapshot || this.materializeRoutes.size > 0 || this.pendingProbeKeys.size > 0) return true;
|
|
3854
|
+
for (const [route, draft] of this.externalDrafts) if (JSON.stringify(draft) !== (this.externalSnapshots.get(route) ?? "{}")) return true;
|
|
3855
|
+
return false;
|
|
3856
|
+
}
|
|
3857
|
+
/** Throw away the in-memory edits and restore the last fully successful state. */
|
|
3858
|
+
async #discard() {
|
|
3859
|
+
// Compensate any external namespace whose profile write succeeded before a
|
|
3860
|
+
// credential write failed, mirroring the pi-ai rollback below.
|
|
3861
|
+
for (const [route, hostSnapshot] of this.externalHostSnapshots) {
|
|
3862
|
+
const savedSnapshot = this.externalSnapshots.get(route) ?? "{}";
|
|
3863
|
+
if (hostSnapshot === savedSnapshot) continue;
|
|
3864
|
+
const entry = this.#entry(route);
|
|
3865
|
+
try {
|
|
3866
|
+
const ops = profileOps(entry.settingsPath, JSON.parse(hostSnapshot), JSON.parse(savedSnapshot));
|
|
3867
|
+
if (ops.length > 0) {
|
|
3868
|
+
const res = await this.app.api.call("settings.mutate", {
|
|
3869
|
+
ns: entry.settingsNs,
|
|
3870
|
+
ops,
|
|
3871
|
+
expectedRevision: this.revisions.get(entry.settingsNs) ?? 0,
|
|
3872
|
+
});
|
|
3873
|
+
this.revisions.set(entry.settingsNs, res?.revision ?? this.revisions.get(entry.settingsNs) ?? 0);
|
|
3874
|
+
}
|
|
3875
|
+
this.externalHostSnapshots.set(route, savedSnapshot);
|
|
3876
|
+
} catch (e) {
|
|
3877
|
+
this.app.toast(`放弃修改失败: ${e.message}`);
|
|
3878
|
+
return false;
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
if (this.hostSnapshot !== this.savedSnapshot) {
|
|
3882
|
+
try {
|
|
3883
|
+
const target = JSON.parse(this.savedSnapshot);
|
|
3884
|
+
const ops = providerOps(JSON.parse(this.hostSnapshot), target);
|
|
3885
|
+
if (ops.length > 0) {
|
|
3886
|
+
const res = await this.app.api.call("settings.mutate", { ns: "llm-pi-ai", ops, expectedRevision: this.revision });
|
|
3887
|
+
this.revision = res?.revision ?? this.revision;
|
|
3888
|
+
}
|
|
3889
|
+
this.hostSnapshot = this.savedSnapshot;
|
|
3890
|
+
} catch (e) {
|
|
3891
|
+
this.app.toast(`放弃修改失败: ${e.message}`);
|
|
3892
|
+
return false;
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
2722
3895
|
this.providers = JSON.parse(this.savedSnapshot);
|
|
2723
|
-
|
|
3896
|
+
// External namespaces are normally credential-only edits; restore their
|
|
3897
|
+
// fully successful in-memory save points as well. (A compensated Host
|
|
3898
|
+
// rollback is only needed for the legacy pi-ai partial-save path above.)
|
|
3899
|
+
for (const [route, snapshot] of this.externalSnapshots) {
|
|
3900
|
+
this.externalDrafts.set(route, JSON.parse(snapshot));
|
|
3901
|
+
this.externalHostSnapshots.set(route, snapshot);
|
|
3902
|
+
}
|
|
3903
|
+
this.configuredDirectory = new Set(this.initialConfiguredDirectory);
|
|
3904
|
+
this.materializeRoutes.clear();
|
|
3905
|
+
this.pendingProbeKeys.clear();
|
|
3906
|
+
this.#syncRoutes();
|
|
2724
3907
|
this.draftRoute = null;
|
|
3908
|
+
this.addMode = false;
|
|
2725
3909
|
this.modelsSel = -1;
|
|
2726
3910
|
this.sub = null;
|
|
2727
|
-
this.sel = Math.min(this.sel, this.routes.length - 1);
|
|
3911
|
+
this.sel = this.routes.length === 0 ? 0 : Math.min(this.sel, this.routes.length - 1);
|
|
2728
3912
|
this.#rebuild();
|
|
2729
3913
|
this.app.redraw();
|
|
3914
|
+
return true;
|
|
2730
3915
|
}
|
|
2731
3916
|
/** Leave the provider form for another level. With unsaved changes this asks
|
|
2732
3917
|
* 保存/不保存/取消 first; a failed save keeps the user on the form. */
|
|
@@ -2747,13 +3932,13 @@ export class ModelPanel extends Widget {
|
|
|
2747
3932
|
],
|
|
2748
3933
|
onAction: async (btn) => {
|
|
2749
3934
|
this.app.closeOverlay();
|
|
2750
|
-
this.app.focus(this.app.chat);
|
|
3935
|
+
this.app.focus(this.app.fullBuffer ?? this.app.chat);
|
|
2751
3936
|
if (btn?.action === "cancel") return; // stay on the form
|
|
2752
3937
|
if (btn?.action === "save") {
|
|
2753
3938
|
const ok = await this.#save();
|
|
2754
3939
|
if (!ok) return; // save failed: stay (toast shown)
|
|
2755
3940
|
} else if (btn?.action === "discard") {
|
|
2756
|
-
this.#discard();
|
|
3941
|
+
if (!await this.#discard()) return;
|
|
2757
3942
|
} else {
|
|
2758
3943
|
return; // Esc = __cancel__
|
|
2759
3944
|
}
|
|
@@ -2771,19 +3956,139 @@ export class ModelPanel extends Widget {
|
|
|
2771
3956
|
w, h: Math.min(7, this.app.screen.h), title: "确认删除",
|
|
2772
3957
|
lines: [[{ t: " " + prompt, fg: K.WARN }]],
|
|
2773
3958
|
buttons: [{ label: "取消", action: "cancel" }, { label: "删除", action: "delete" }],
|
|
2774
|
-
onAction: (btn) => { this.app.closeOverlay(); if (btn?.action === "delete") action(); },
|
|
3959
|
+
onAction: (btn) => { this.app.closeOverlay(); if (btn?.action === "delete") return action(); },
|
|
2775
3960
|
});
|
|
2776
3961
|
this.app.redraw();
|
|
2777
3962
|
}
|
|
3963
|
+
async #unconfigureExternalProvider() {
|
|
3964
|
+
const route = this.#route();
|
|
3965
|
+
const entry = this.#entry(route);
|
|
3966
|
+
if (!route || !entry || entry.settingsNs === "llm-pi-ai" || entry.settingsPath.length === 0 || !this.externalUserConfigured.has(route)) return;
|
|
3967
|
+
if (this.#dirty()) { this.app.toast("请先保存或放弃其他修改,再取消配置提供方"); return; }
|
|
3968
|
+
this.#confirmDelete(`取消配置 ${entry.displayName || route}?这会移除该提供方的用户层设置,但不会清除全局 API 密钥。`, async () => {
|
|
3969
|
+
try {
|
|
3970
|
+
const res = await this.app.api.call("settings.mutate", {
|
|
3971
|
+
ns: entry.settingsNs,
|
|
3972
|
+
ops: [{ op: "unset", path: entry.settingsPath }],
|
|
3973
|
+
expectedRevision: this.revisions.get(entry.settingsNs) ?? 0,
|
|
3974
|
+
});
|
|
3975
|
+
this.revisions.set(entry.settingsNs, res?.revision ?? this.revisions.get(entry.settingsNs) ?? 0);
|
|
3976
|
+
} catch (e) { this.app.toast(`取消配置失败: ${e.message}`); return; }
|
|
3977
|
+
this.externalUserConfigured.delete(route);
|
|
3978
|
+
this.externalDrafts.set(route, {});
|
|
3979
|
+
this.externalSnapshots.set(route, "{}");
|
|
3980
|
+
this.externalHostSnapshots.set(route, "{}");
|
|
3981
|
+
this.externalInherited.set(route, {});
|
|
3982
|
+
this.configuredDirectory.delete(route);
|
|
3983
|
+
this.initialConfiguredDirectory.delete(route);
|
|
3984
|
+
this.pendingProbeKeys.delete(route);
|
|
3985
|
+
this.#syncRoutes();
|
|
3986
|
+
this.sel = this.routes.length === 0 ? 0 : Math.min(this.sel, this.routes.length - 1);
|
|
3987
|
+
this.modelsSel = -1; this.sub = null;
|
|
3988
|
+
await this.#refreshKeys();
|
|
3989
|
+
this.app.toast(`已取消配置 ${entry.displayName || route};全局 API 密钥未改变`);
|
|
3990
|
+
this.#rebuild(); this.app.redraw();
|
|
3991
|
+
});
|
|
3992
|
+
}
|
|
2778
3993
|
async #deleteProvider() {
|
|
2779
3994
|
const route = this.#route();
|
|
2780
3995
|
if (!route) return;
|
|
3996
|
+
if (this.#dirty()) { this.app.toast("请先保存或放弃其他修改,再删除供应商"); return; }
|
|
2781
3997
|
this.#confirmDelete(`删除供应商 ${route}?此操作会立即保存。`, async () => {
|
|
3998
|
+
const profile = this.#profile(route);
|
|
3999
|
+
const ref = this.#keyRef(route);
|
|
4000
|
+
const managedCredential = profile.apiKeyEnv === deriveKeyRef(route)
|
|
4001
|
+
&& this.keyStatus?.[ref]?.configured === true
|
|
4002
|
+
&& this.keyStatus[ref].writable === true;
|
|
4003
|
+
if (managedCredential) {
|
|
4004
|
+
// Journal first. If the process exits after the Host mutation but before
|
|
4005
|
+
// credentials.unset, the next ModelPanel load can safely finish it.
|
|
4006
|
+
this.pendingCredentialCleanups.set(ref, { route, error: "等待确认供应商删除", reconcile: true });
|
|
4007
|
+
if (!this.#persistCredentialCleanups()) {
|
|
4008
|
+
this.pendingCredentialCleanups.delete(ref);
|
|
4009
|
+
this.app.toast("删除失败: 无法记录托管密钥清理任务");
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
let providerState = null;
|
|
4014
|
+
try {
|
|
4015
|
+
const res = await this.app.api.call("settings.mutate", {
|
|
4016
|
+
ns: "llm-pi-ai",
|
|
4017
|
+
ops: [{ op: "unset", path: ["providers", route] }],
|
|
4018
|
+
expectedRevision: this.revision,
|
|
4019
|
+
});
|
|
4020
|
+
this.revision = res?.revision ?? this.revision;
|
|
4021
|
+
providerState = this.#providerStateFromProfiles(res?.value?.providers ?? Object.fromEntries(
|
|
4022
|
+
Object.entries(this.resolvedProviders).filter(([candidate]) => candidate !== route),
|
|
4023
|
+
));
|
|
4024
|
+
} catch (e) {
|
|
4025
|
+
const conflict = e?.code === "settings-conflict";
|
|
4026
|
+
if (managedCredential && conflict) {
|
|
4027
|
+
const task = this.pendingCredentialCleanups.get(ref);
|
|
4028
|
+
this.pendingCredentialCleanups.delete(ref);
|
|
4029
|
+
if (!this.#persistCredentialCleanups()) this.pendingCredentialCleanups.set(ref, task);
|
|
4030
|
+
this.app.toast(`删除失败: ${e.message}`);
|
|
4031
|
+
this.#rebuild();
|
|
4032
|
+
this.app.redraw();
|
|
4033
|
+
return;
|
|
4034
|
+
}
|
|
4035
|
+
if (!managedCredential) {
|
|
4036
|
+
this.app.toast(`删除失败: ${e.message}`);
|
|
4037
|
+
return;
|
|
4038
|
+
}
|
|
4039
|
+
this.pendingCredentialCleanups.set(ref, { route, error: `等待核对删除结果: ${String(e?.message ?? e).slice(0, 500)}`, reconcile: true });
|
|
4040
|
+
this.#persistCredentialCleanups();
|
|
4041
|
+
try {
|
|
4042
|
+
providerState = this.#providerStateFromDescription(await this.app.api.call("settings.describe"));
|
|
4043
|
+
} catch {}
|
|
4044
|
+
if (providerState === null) {
|
|
4045
|
+
const failure = { ref, route, error: this.pendingCredentialCleanups.get(ref).error, reconcile: true };
|
|
4046
|
+
this.app.toast(`删除结果待核对: ${e.message}`);
|
|
4047
|
+
this.#showCredentialCleanupFailure(failure);
|
|
4048
|
+
this.#rebuild();
|
|
4049
|
+
this.app.redraw();
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
if (providerState.routes.has(route)) {
|
|
4053
|
+
if (providerState.refs.has(ref)) {
|
|
4054
|
+
const task = this.pendingCredentialCleanups.get(ref);
|
|
4055
|
+
this.pendingCredentialCleanups.delete(ref);
|
|
4056
|
+
if (!this.#persistCredentialCleanups()) this.pendingCredentialCleanups.set(ref, task);
|
|
4057
|
+
this.app.toast(`删除失败: ${e.message}`);
|
|
4058
|
+
} else {
|
|
4059
|
+
const failure = { ref, route, error: `路由 ${route} 仍存在,无法自动确认旧密钥可清理`, reconcile: true };
|
|
4060
|
+
this.pendingCredentialCleanups.set(ref, { route, error: failure.error, reconcile: true });
|
|
4061
|
+
this.#persistCredentialCleanups();
|
|
4062
|
+
this.app.toast(`删除结果待核对: ${e.message}`);
|
|
4063
|
+
this.#showCredentialCleanupFailure(failure);
|
|
4064
|
+
}
|
|
4065
|
+
this.#rebuild();
|
|
4066
|
+
this.app.redraw();
|
|
4067
|
+
return;
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
2782
4070
|
delete this.providers[route];
|
|
2783
|
-
|
|
2784
|
-
|
|
4071
|
+
delete this.resolvedProviders[route];
|
|
4072
|
+
delete this.inheritedProviders[route];
|
|
4073
|
+
this.configuredDirectory.delete(route);
|
|
4074
|
+
this.initialConfiguredDirectory.delete(route);
|
|
4075
|
+
this.materializeRoutes.delete(route);
|
|
4076
|
+
this.pendingProbeKeys.delete(route);
|
|
4077
|
+
this.#syncRoutes();
|
|
4078
|
+
this.sel = this.routes.length === 0 ? 0 : Math.min(this.sel, this.routes.length - 1);
|
|
2785
4079
|
this.modelsSel = -1;
|
|
2786
|
-
|
|
4080
|
+
this.savedSnapshot = JSON.stringify(this.providers);
|
|
4081
|
+
this.hostSnapshot = this.savedSnapshot;
|
|
4082
|
+
const cleanup = managedCredential
|
|
4083
|
+
? await this.#retryPendingCredentialCleanups({ onlyRef: ref, notify: false, providerState })
|
|
4084
|
+
: { completed: [], failed: [] };
|
|
4085
|
+
await this.#refreshKeys();
|
|
4086
|
+
if (cleanup.failed.length > 0) {
|
|
4087
|
+
this.app.toast(`供应商已删除;托管密钥待清理: ${cleanup.failed[0].error}`);
|
|
4088
|
+
this.#showCredentialCleanupFailure(cleanup.failed[0]);
|
|
4089
|
+
} else {
|
|
4090
|
+
this.app.toast(`已删除供应商 ${route}`);
|
|
4091
|
+
}
|
|
2787
4092
|
this.#rebuild(); this.app.redraw();
|
|
2788
4093
|
});
|
|
2789
4094
|
}
|
|
@@ -2791,47 +4096,47 @@ export class ModelPanel extends Widget {
|
|
|
2791
4096
|
const route = this.#route();
|
|
2792
4097
|
if (!route) return;
|
|
2793
4098
|
const p = this.#profile(route);
|
|
4099
|
+
const entry = this.#entry(route);
|
|
4100
|
+
const declared = entry?.declared === true || this.draftRoute === route;
|
|
4101
|
+
if (declared && p.api === "anthropic-messages") {
|
|
4102
|
+
this.app.toast("anthropic-messages 不支持自动列出模型,请手动添加模型 ID");
|
|
4103
|
+
return;
|
|
4104
|
+
}
|
|
2794
4105
|
const base = String(p.baseURL ?? "").replace(/\/+$/, "");
|
|
2795
|
-
if (!base) { this.app.toast("先填写 baseURL"); return; }
|
|
2796
4106
|
this.scanning = true;
|
|
2797
4107
|
this.scanMode = true;
|
|
2798
4108
|
this.scanItems = [];
|
|
2799
4109
|
this.scanCursor = 0;
|
|
2800
4110
|
this.#rebuild();
|
|
2801
4111
|
this.app.redraw();
|
|
2802
|
-
const key = p.apiKeyEnv ? process.env[p.apiKeyEnv] : null;
|
|
2803
|
-
const headers = key ? { Authorization: `Bearer ${key}` } : {};
|
|
2804
|
-
const tryFetch = async (dispatcher) => {
|
|
2805
|
-
const res = await fetch(`${base}/models`, { headers, dispatcher });
|
|
2806
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
2807
|
-
return res.json();
|
|
2808
|
-
};
|
|
2809
4112
|
try {
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
}
|
|
2819
|
-
const list = Array.isArray(body?.data) ? body.data : Array.isArray(body?.models) ? body.models : [];
|
|
4113
|
+
// The Host owns protocol handling and stored credentials. Keeping the
|
|
4114
|
+
// request on this path avoids exposing secrets or weakening TLS locally.
|
|
4115
|
+
const res = await this.app.api.call("llm.discoverModels", {
|
|
4116
|
+
settingsNs: this.#namespace(route),
|
|
4117
|
+
provider: route,
|
|
4118
|
+
...(p.api ? { api: p.api } : {}),
|
|
4119
|
+
...(base ? { baseURL: base } : {}),
|
|
4120
|
+
...(this.pendingProbeKeys.has(route) ? { apiKey: this.pendingProbeKeys.get(route) } : {}),
|
|
4121
|
+
});
|
|
2820
4122
|
const seen = new Set();
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
if (!id || seen.has(id)) continue;
|
|
4123
|
+
this.scanItems = (res?.models ?? []).flatMap((entry) => {
|
|
4124
|
+
if (!entry || typeof entry !== "object") return [];
|
|
4125
|
+
const id = String(entry.id ?? "").trim();
|
|
4126
|
+
if (!id || seen.has(id)) return [];
|
|
2826
4127
|
seen.add(id);
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
4128
|
+
return [{
|
|
4129
|
+
id,
|
|
4130
|
+
...(typeof entry.name === "string" && entry.name ? { name: entry.name } : {}),
|
|
4131
|
+
...(Number.isInteger(entry.contextWindow) && entry.contextWindow > 0 ? { contextWindow: entry.contextWindow } : {}),
|
|
4132
|
+
...(Number.isInteger(entry.maxTokens) && entry.maxTokens > 0 ? { maxTokens: entry.maxTokens } : {}),
|
|
4133
|
+
}];
|
|
4134
|
+
});
|
|
4135
|
+
this.scanSel = new Set(this.scanItems.map((model) => model.id));
|
|
4136
|
+
if (this.scanItems.length === 0) this.app.toast("扫描完成:未发现模型");
|
|
4137
|
+
else this.app.toast(`发现 ${this.scanItems.length} 个模型,空格勾选,Enter 添加`);
|
|
2833
4138
|
} catch (e) {
|
|
2834
|
-
this.app.toast(
|
|
4139
|
+
this.app.toast(`扫描失败:${String(e.message ?? e).replace(/^[^:]+:\s*/, "")}`);
|
|
2835
4140
|
this.scanMode = false;
|
|
2836
4141
|
}
|
|
2837
4142
|
this.scanning = false;
|
|
@@ -2841,13 +4146,17 @@ export class ModelPanel extends Widget {
|
|
|
2841
4146
|
#scanCommit() {
|
|
2842
4147
|
const route = this.#route();
|
|
2843
4148
|
if (!route) return;
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
const
|
|
4149
|
+
if (!this.writable) { this.scanMode = false; this.app.toast("模型配置为只读,未添加发现结果"); this.#rebuild(); this.app.redraw(); return; }
|
|
4150
|
+
const existing = new Set(this.#models(route).map((m) => m.id));
|
|
4151
|
+
const selected = this.scanItems.filter((model) => this.scanSel.has(model.id) && !existing.has(model.id));
|
|
2847
4152
|
let added = 0;
|
|
2848
|
-
for (const m of
|
|
2849
|
-
|
|
2850
|
-
|
|
4153
|
+
for (const m of selected) {
|
|
4154
|
+
this.#models(route, { mutable: true }).push({
|
|
4155
|
+
id: m.id,
|
|
4156
|
+
...(m.name != null ? { name: m.name } : {}),
|
|
4157
|
+
...(m.contextWindow != null ? { contextWindow: m.contextWindow } : {}),
|
|
4158
|
+
...(m.maxTokens != null ? { maxTokens: m.maxTokens } : {}),
|
|
4159
|
+
});
|
|
2851
4160
|
added++;
|
|
2852
4161
|
}
|
|
2853
4162
|
this.scanMode = false;
|
|
@@ -2857,10 +4166,26 @@ export class ModelPanel extends Widget {
|
|
|
2857
4166
|
}
|
|
2858
4167
|
onKey(ev) {
|
|
2859
4168
|
if (ev.type !== "key") return false;
|
|
4169
|
+
if (ev.name === "char" && ev.key === "c" && !ev.ctrl && this.pendingCredentialCleanups.size > 0) {
|
|
4170
|
+
const [ref, task] = this.pendingCredentialCleanups.entries().next().value;
|
|
4171
|
+
this.#showCredentialCleanupFailure({ ref, ...task });
|
|
4172
|
+
return true;
|
|
4173
|
+
}
|
|
4174
|
+
if (this.addMode) {
|
|
4175
|
+
if (ev.name === "escape") { this.addMode = false; this.#rebuild(); this.app.redraw(); return true; }
|
|
4176
|
+
if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
|
|
4177
|
+
this.addCursor = wrapIndex(this.addCursor - 1, this.addItems.length); this.#rebuild(); this.app.redraw(); return true;
|
|
4178
|
+
}
|
|
4179
|
+
if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
|
|
4180
|
+
this.addCursor = wrapIndex(this.addCursor + 1, this.addItems.length); this.#rebuild(); this.app.redraw(); return true;
|
|
4181
|
+
}
|
|
4182
|
+
if (ev.name === "enter") { this.#activateAddItem(); return true; }
|
|
4183
|
+
return false;
|
|
4184
|
+
}
|
|
2860
4185
|
if (this.scanMode) {
|
|
2861
4186
|
if (ev.name === "escape") { this.scanMode = false; this.#rebuild(); return true; }
|
|
2862
|
-
if (ev.name === "up") { this.scanCursor =
|
|
2863
|
-
if (ev.name === "down") { this.scanCursor =
|
|
4187
|
+
if (ev.name === "up") { this.scanCursor = wrapIndex(this.scanCursor - 1, this.scanItems.length); this.#rebuild(); this.app.redraw(); return true; }
|
|
4188
|
+
if (ev.name === "down") { this.scanCursor = wrapIndex(this.scanCursor + 1, this.scanItems.length); this.#rebuild(); this.app.redraw(); return true; }
|
|
2864
4189
|
if (ev.name === "char" && ev.key === " " && !ev.ctrl) {
|
|
2865
4190
|
const m = this.scanItems[this.scanCursor];
|
|
2866
4191
|
if (m) { if (this.scanSel.has(m.id)) this.scanSel.delete(m.id); else this.scanSel.add(m.id); }
|
|
@@ -2875,13 +4200,13 @@ export class ModelPanel extends Widget {
|
|
|
2875
4200
|
// inside the 模型管理 sub-buffer: ↑/↓ walk its rows; Esc returns
|
|
2876
4201
|
if (ev.name === "escape") { this.sub = null; this.#rebuild(); return true; }
|
|
2877
4202
|
if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
|
|
2878
|
-
this.sub.cursor =
|
|
4203
|
+
this.sub.cursor = wrapIndex(this.sub.cursor - 1, this.#subItems().length);
|
|
2879
4204
|
this.#rebuild();
|
|
2880
4205
|
this.app.redraw();
|
|
2881
4206
|
return true;
|
|
2882
4207
|
}
|
|
2883
4208
|
if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
|
|
2884
|
-
this.sub.cursor =
|
|
4209
|
+
this.sub.cursor = wrapIndex(this.sub.cursor + 1, this.#subItems().length);
|
|
2885
4210
|
this.#rebuild();
|
|
2886
4211
|
this.app.redraw();
|
|
2887
4212
|
return true;
|
|
@@ -2897,21 +4222,21 @@ export class ModelPanel extends Widget {
|
|
|
2897
4222
|
this.#leaveForm(() => { this.mode = "list"; this.sub = null; this.#rebuild(); this.app.redraw(); });
|
|
2898
4223
|
return true;
|
|
2899
4224
|
}
|
|
2900
|
-
return false; // list level: App
|
|
4225
|
+
return false; // list level: App closes the full-screen buffer on the unhandled Escape
|
|
2901
4226
|
}
|
|
2902
4227
|
// dual-focus navigation: ↑/↓ move the cursor INSIDE the focused region —
|
|
2903
4228
|
// the provider column in list focus, the option rows in form focus.
|
|
2904
4229
|
// → enters the form, ← returns to the list.
|
|
2905
4230
|
if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
|
|
2906
|
-
if (this.mode === "list") this.sel =
|
|
2907
|
-
else this.formIdx =
|
|
4231
|
+
if (this.mode === "list") this.sel = wrapIndex(this.sel - 1, this.routes.length + 1);
|
|
4232
|
+
else this.formIdx = wrapIndex(this.formIdx - 1, this.formItems.length);
|
|
2908
4233
|
this.#rebuild();
|
|
2909
4234
|
this.app.redraw();
|
|
2910
4235
|
return true;
|
|
2911
4236
|
}
|
|
2912
4237
|
if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
|
|
2913
|
-
if (this.mode === "list") this.sel =
|
|
2914
|
-
else this.formIdx =
|
|
4238
|
+
if (this.mode === "list") this.sel = wrapIndex(this.sel + 1, this.routes.length + 1);
|
|
4239
|
+
else this.formIdx = wrapIndex(this.formIdx + 1, this.formItems.length);
|
|
2915
4240
|
this.#rebuild();
|
|
2916
4241
|
this.app.redraw();
|
|
2917
4242
|
return true;
|
|
@@ -2921,12 +4246,17 @@ export class ModelPanel extends Widget {
|
|
|
2921
4246
|
// walks to the next form item. Otherwise (list focus) it behaves like →.
|
|
2922
4247
|
if (ev.name === "tab" && this.mode === "form" && this.sub == null) {
|
|
2923
4248
|
const it = this.formItems[this.formIdx];
|
|
4249
|
+
if (it?.cycle?.length && !this.writable) { this.app.toast("模型配置为只读"); return true; }
|
|
2924
4250
|
if (it?.cycle?.length) {
|
|
2925
4251
|
const cur = String(it.value ?? "");
|
|
2926
4252
|
const idx = it.cycle.indexOf(cur);
|
|
2927
|
-
|
|
4253
|
+
const value = it.cycle[(idx + 1) % it.cycle.length];
|
|
4254
|
+
const profile = this.#draftProfile(this.#route());
|
|
4255
|
+
if ((it.key === "api" || it.key === "reasoning") && value === "") delete profile[it.key];
|
|
4256
|
+
else profile[it.key] = value;
|
|
4257
|
+
if (it.key === "api" && value !== "openai-completions") this.#stripCompat(this.#route());
|
|
2928
4258
|
} else if (this.formItems.length > 0) {
|
|
2929
|
-
this.formIdx =
|
|
4259
|
+
this.formIdx = wrapIndex(this.formIdx + 1, this.formItems.length);
|
|
2930
4260
|
}
|
|
2931
4261
|
this.#rebuild();
|
|
2932
4262
|
this.app.redraw();
|
|
@@ -2959,7 +4289,7 @@ export class ModelPanel extends Widget {
|
|
|
2959
4289
|
if (this.mode === "form") {
|
|
2960
4290
|
if (idx === this.sel) return true; // already editing this provider
|
|
2961
4291
|
if (idx === this.routes.length) {
|
|
2962
|
-
this.#leaveForm(() => this.#
|
|
4292
|
+
this.#leaveForm(() => this.#openAddProvider());
|
|
2963
4293
|
} else {
|
|
2964
4294
|
this.#leaveForm(() => {
|
|
2965
4295
|
this.sel = idx;
|
|
@@ -2979,25 +4309,40 @@ export class ModelPanel extends Widget {
|
|
|
2979
4309
|
}
|
|
2980
4310
|
return false;
|
|
2981
4311
|
}
|
|
2982
|
-
if (this.
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
}
|
|
2991
|
-
return false;
|
|
4312
|
+
if (!this.formView.inside(ev.x, ev.y)) return false;
|
|
4313
|
+
const line = ev.y - this.formView.y + this.formView.scrollY;
|
|
4314
|
+
const target = this.formClickMap[line];
|
|
4315
|
+
if (!target) return true;
|
|
4316
|
+
if (target.type === "add" && this.addMode) {
|
|
4317
|
+
this.addCursor = target.index;
|
|
4318
|
+
this.#activateAddItem();
|
|
4319
|
+
return true;
|
|
2992
4320
|
}
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
4321
|
+
if (target.type === "cleanup") {
|
|
4322
|
+
const task = this.pendingCredentialCleanups.get(target.ref);
|
|
4323
|
+
if (task) this.#showCredentialCleanupFailure({ ref: target.ref, ...task });
|
|
4324
|
+
return true;
|
|
4325
|
+
}
|
|
4326
|
+
if (target.type === "scan") {
|
|
4327
|
+
const m = this.scanItems[target.index];
|
|
4328
|
+
if (!m) return false;
|
|
4329
|
+
this.scanCursor = target.index;
|
|
4330
|
+
if (this.scanSel.has(m.id)) this.scanSel.delete(m.id); else this.scanSel.add(m.id);
|
|
4331
|
+
this.#rebuild();
|
|
4332
|
+
this.app.redraw();
|
|
4333
|
+
return true;
|
|
4334
|
+
}
|
|
4335
|
+
if (target.type === "item" && target.sub && this.sub != null) {
|
|
4336
|
+
this.sub.cursor = target.index;
|
|
4337
|
+
this.#activateItem();
|
|
4338
|
+
return true;
|
|
4339
|
+
}
|
|
4340
|
+
if (target.type === "item" && !target.sub && this.sub == null && target.index < this.formItems.length) {
|
|
4341
|
+
this.formIdx = target.index;
|
|
4342
|
+
this.mode = "form";
|
|
4343
|
+
this.#activateItem();
|
|
4344
|
+
return true;
|
|
2999
4345
|
}
|
|
3000
|
-
if (idx >= 0 && idx < this.formItems.length) { this.formIdx = idx; this.mode = "form"; this.#activateItem(); return true; }
|
|
3001
4346
|
return false;
|
|
3002
4347
|
}
|
|
3003
4348
|
}
|
|
@@ -3196,13 +4541,13 @@ export class SubagentPanel extends Widget {
|
|
|
3196
4541
|
onKey(ev) {
|
|
3197
4542
|
if (ev.type === "text") { this.input.insert(ev.text); this.app.redraw(); return true; }
|
|
3198
4543
|
if (ev.type !== "key") return false;
|
|
3199
|
-
if (ev.name === "escape") { this.app.setMode("chat"); return true; }
|
|
4544
|
+
if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
|
|
3200
4545
|
if (ev.name === "char" && ev.key === "x" && !ev.ctrl) { this.interrupt(); return true; }
|
|
3201
4546
|
if (ev.name === "char" && ev.key === "r" && !ev.ctrl) { this.selectChild(this.selIdx); return true; }
|
|
3202
4547
|
if (ev.name === "up" || ev.name === "down") {
|
|
3203
4548
|
if (this.entries.length === 0) return false;
|
|
3204
|
-
const next = this.selIdx + (ev.name === "up" ? -1 : 1);
|
|
3205
|
-
|
|
4549
|
+
const next = wrapIndex(this.selIdx + (ev.name === "up" ? -1 : 1), this.entries.length);
|
|
4550
|
+
this.selectChild(next);
|
|
3206
4551
|
return true;
|
|
3207
4552
|
}
|
|
3208
4553
|
if (this.input.onKey(ev)) { this.app.redraw(); return true; }
|
|
@@ -3293,11 +4638,11 @@ export class SkillsPanel extends Widget {
|
|
|
3293
4638
|
}
|
|
3294
4639
|
onKey(ev) {
|
|
3295
4640
|
if (ev.type !== "key") return false;
|
|
3296
|
-
if (ev.name === "escape") { this.app.setMode("chat"); return true; }
|
|
4641
|
+
if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
|
|
3297
4642
|
if (ev.name === "up" || ev.name === "down") {
|
|
3298
4643
|
if (this.skills.length === 0) return false;
|
|
3299
|
-
const next = this.selIdx + (ev.name === "up" ? -1 : 1);
|
|
3300
|
-
|
|
4644
|
+
const next = wrapIndex(this.selIdx + (ev.name === "up" ? -1 : 1), this.skills.length);
|
|
4645
|
+
this.select(next);
|
|
3301
4646
|
return true;
|
|
3302
4647
|
}
|
|
3303
4648
|
if (ev.name === "char" && ev.key === "c" && !ev.ctrl && this.skills[this.selIdx]) {
|