dsh-neotui 0.2.3 → 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 +30 -13
- package/bin/dsh-tui.js +1 -1
- package/package.json +5 -2
- package/src/config.js +46 -12
- package/src/file-picker.js +4 -4
- package/src/keybindings.js +172 -0
- package/src/md.js +23 -21
- package/src/panels.js +1771 -427
- package/src/term.js +18 -10
- package/src/text.js +11 -3
- package/src/views.js +925 -263
- 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
|
}
|
|
@@ -1670,7 +1876,7 @@ export class JobsPanel extends Popup {
|
|
|
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);
|
|
1673
|
-
lines.push([{ t: ` ${open ? "▾" : "▸"}
|
|
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);
|
|
1674
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); }
|
|
1675
1881
|
}
|
|
1676
1882
|
this.lines = lines; this.rowOf = rowOf; this.#ensureVisible(); return;
|
|
@@ -1721,10 +1927,10 @@ export class JobsPanel extends Popup {
|
|
|
1721
1927
|
const current = this.page === "jobs" ? this.jobs : this.subagents;
|
|
1722
1928
|
if (current.length === 0) return super.onKey(ev);
|
|
1723
1929
|
if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
|
|
1724
|
-
this.sel =
|
|
1930
|
+
this.sel = wrapIndex(this.sel - 1, current.length); this.rebuild(); return true;
|
|
1725
1931
|
}
|
|
1726
1932
|
if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
|
|
1727
|
-
this.sel =
|
|
1933
|
+
this.sel = wrapIndex(this.sel + 1, current.length); this.rebuild(); return true;
|
|
1728
1934
|
}
|
|
1729
1935
|
if (ev.name === "enter" || (ev.name === "char" && ev.key === "l" && !ev.ctrl)) {
|
|
1730
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;
|
|
@@ -1752,25 +1958,157 @@ export class QueuePanel extends Popup {
|
|
|
1752
1958
|
const items = app.queueItems ?? [];
|
|
1753
1959
|
const w = Math.max(24, Math.min(84, app.screen.w - 4));
|
|
1754
1960
|
const h = Math.max(7, Math.min(24, app.screen.h - 4));
|
|
1755
|
-
super({
|
|
1756
|
-
|
|
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();
|
|
1757
1975
|
}
|
|
1758
|
-
|
|
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) {
|
|
1759
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
|
+
}
|
|
1760
2053
|
for (let i = 0; i < this.items.length; i++) {
|
|
1761
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;
|
|
1762
2058
|
const text = partsText(item.message?.content).replace(/\s+/g, " ");
|
|
1763
|
-
|
|
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
|
+
}
|
|
1764
2071
|
}
|
|
1765
|
-
if (!this.items.length) lines.push([{ t: " (队列为空)", fg: K.FAINT }]);
|
|
2072
|
+
if (!this.items.length) { lines.push([{ t: " (队列为空)", fg: K.FAINT }]); rowOf.push(-1); }
|
|
1766
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();
|
|
1767
2103
|
}
|
|
1768
2104
|
syncItems(items) {
|
|
1769
2105
|
const selectedId = this.items[this.sel]?.id;
|
|
1770
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);
|
|
1771
2109
|
const next = selectedId ? this.items.findIndex((item) => item.id === selectedId) : -1;
|
|
1772
2110
|
this.sel = next >= 0 ? next : Math.min(this.sel, Math.max(0, this.items.length - 1));
|
|
1773
|
-
this.rebuild();
|
|
2111
|
+
this.rebuild(); this.#ensureSelected();
|
|
1774
2112
|
}
|
|
1775
2113
|
#errorCode(error) { return error?.code ?? error?.details?.code ?? error?.cause?.code; }
|
|
1776
2114
|
async #mutate(kind, content) {
|
|
@@ -1793,21 +2131,58 @@ export class QueuePanel extends Popup {
|
|
|
1793
2131
|
} finally { this.pending = false; this.rebuild(); this.app.redraw(); }
|
|
1794
2132
|
}
|
|
1795
2133
|
onKey(ev) {
|
|
1796
|
-
const ch=ev.type==="text"?ev.text:ev.type==="key"&&ev.name==="char"?ev.key:null;
|
|
1797
|
-
|
|
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
|
+
}
|
|
1798
2150
|
if (ev.type === "key") {
|
|
1799
2151
|
if (ev.name === "escape") { this.app.closeOverlay(); return true; }
|
|
1800
|
-
|
|
1801
|
-
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
|
+
}
|
|
1802
2174
|
}
|
|
1803
|
-
this.dArmed=
|
|
2175
|
+
this.dArmed = false;
|
|
2176
|
+
return false;
|
|
1804
2177
|
}
|
|
1805
2178
|
onMouse(ev) {
|
|
2179
|
+
if (super.onMouse(ev)) return true;
|
|
1806
2180
|
if (ev.kind === "press" && ev.button === 0) {
|
|
1807
|
-
const
|
|
1808
|
-
|
|
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; }
|
|
1809
2184
|
}
|
|
1810
|
-
return
|
|
2185
|
+
return false;
|
|
1811
2186
|
}
|
|
1812
2187
|
}
|
|
1813
2188
|
|
|
@@ -1893,8 +2268,8 @@ export class GoalPanel extends Popup {
|
|
|
1893
2268
|
onKey(ev) {
|
|
1894
2269
|
if (ev.type === "key") {
|
|
1895
2270
|
if (ev.name === "escape") { this.app.closeOverlay(); return true; }
|
|
1896
|
-
if (ev.name === "up") { this.actionSel =
|
|
1897
|
-
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; }
|
|
1898
2273
|
if (ev.name === "enter") { this.actions[this.actionSel]?.run(); return true; }
|
|
1899
2274
|
}
|
|
1900
2275
|
return super.onKey(ev);
|
|
@@ -1950,7 +2325,7 @@ export class SettingsPanel extends Widget {
|
|
|
1950
2325
|
this.writable = d.writable;
|
|
1951
2326
|
} catch (e) {
|
|
1952
2327
|
this.app.toast(`设置加载失败: ${e.message}`);
|
|
1953
|
-
this.app.setMode("chat");
|
|
2328
|
+
this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat");
|
|
1954
2329
|
return;
|
|
1955
2330
|
}
|
|
1956
2331
|
// TUI-local settings ride the same tree editor, but persist to the TUI
|
|
@@ -1979,7 +2354,7 @@ export class SettingsPanel extends Widget {
|
|
|
1979
2354
|
this.pendingOps = [];
|
|
1980
2355
|
this.editing = false;
|
|
1981
2356
|
const ns = this.namespaces[this.nsIdx];
|
|
1982
|
-
if (ns.modelsEntry) { this.app.setMode("models"); return; }
|
|
2357
|
+
if (ns.modelsEntry) { (this.app.showModelsBuffer ? this.app.showModelsBuffer() : this.app.setMode?.("models")); return; }
|
|
1983
2358
|
this.secrets = new Set((ns.secrets ?? []).map((s) => JSON.stringify(s.path ?? [])));
|
|
1984
2359
|
this.rebuildRows();
|
|
1985
2360
|
const items = this.namespaces.map((n) => ({
|
|
@@ -2082,7 +2457,7 @@ export class SettingsPanel extends Widget {
|
|
|
2082
2457
|
return true;
|
|
2083
2458
|
}
|
|
2084
2459
|
if (ev.type !== "key") return false;
|
|
2085
|
-
if (ev.name === "escape") { this.app.setMode("chat"); return true; }
|
|
2460
|
+
if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
|
|
2086
2461
|
if (ev.ctrl && ev.key === "s") { this.save(); return true; }
|
|
2087
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);
|
|
2088
2463
|
if (ev.name === "enter") {
|
|
@@ -2210,7 +2585,7 @@ export class EditPopup extends Popup {
|
|
|
2210
2585
|
this.input.render(screen);
|
|
2211
2586
|
}
|
|
2212
2587
|
onKey(ev) {
|
|
2213
|
-
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; }
|
|
2214
2589
|
if (ev.type === "key" && ev.name === "tab" && this.completions?.length) {
|
|
2215
2590
|
// Tab 选取/补全: an exact current value cycles to the next candidate,
|
|
2216
2591
|
// anything else completes to the first prefix match
|
|
@@ -2231,7 +2606,7 @@ export class EditPopup extends Popup {
|
|
|
2231
2606
|
if (ev.type === "key" && ev.name === "enter") {
|
|
2232
2607
|
const v = this.input.value;
|
|
2233
2608
|
this.app.closeOverlay();
|
|
2234
|
-
this.app.focus(this.app.chat);
|
|
2609
|
+
this.app.focus(this.app.fullBuffer ?? this.app.chat);
|
|
2235
2610
|
this.onCommit?.(v);
|
|
2236
2611
|
return true;
|
|
2237
2612
|
}
|
|
@@ -2250,6 +2625,17 @@ export class EditPopup extends Popup {
|
|
|
2250
2625
|
* same union the web settings page reads out of the namespace schema, so the
|
|
2251
2626
|
* choices offered here cannot drift from the ones the host validates. */
|
|
2252
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]+)*$/;
|
|
2253
2639
|
/** Credential reference names must be POSIX shell identifiers. */
|
|
2254
2640
|
const KEY_REF_OK = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
2255
2641
|
/** The web settings page's v1 convention: a provider route's key lives under
|
|
@@ -2258,16 +2644,114 @@ function deriveKeyRef(provider) {
|
|
|
2258
2644
|
return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
|
2259
2645
|
}
|
|
2260
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
|
+
|
|
2261
2729
|
export class ModelPanel extends Widget {
|
|
2262
2730
|
constructor(app) {
|
|
2263
|
-
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) });
|
|
2264
2732
|
this.app = app;
|
|
2265
|
-
this.providers = {};
|
|
2266
|
-
this.
|
|
2267
|
-
this.
|
|
2268
|
-
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
|
|
2269
2748
|
this.loaded = false;
|
|
2749
|
+
this.writable = true;
|
|
2270
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
|
|
2271
2755
|
this.sel = 0; // list cursor (routes.length = the + 添加供应商 row)
|
|
2272
2756
|
this.mode = "list"; // list | form
|
|
2273
2757
|
this.formIdx = 0; // form item cursor
|
|
@@ -2282,36 +2766,262 @@ export class ModelPanel extends Widget {
|
|
|
2282
2766
|
this.scanSel = new Set();
|
|
2283
2767
|
this.scanCursor = 0;
|
|
2284
2768
|
this.scanning = false;
|
|
2285
|
-
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
|
|
2286
2771
|
this.keyStatus = {}; // ref → {configured, writable, source} from credentials.describe
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
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 });
|
|
2290
2797
|
}
|
|
2291
2798
|
relayout(x, y, w, h) {
|
|
2292
|
-
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
2293
|
-
const listW = 26;
|
|
2294
|
-
this.listView.x = x + 1; this.listView.y = y + 1; this.listView.w = listW; this.listView.h = h - 2;
|
|
2295
|
-
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);
|
|
2296
2803
|
}
|
|
2297
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;
|
|
2298
2814
|
try {
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
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
|
+
}
|
|
2303
2857
|
this.revision = ns?.revision ?? 0;
|
|
2304
|
-
this.
|
|
2305
|
-
this.
|
|
2306
|
-
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;
|
|
2307
2865
|
} catch (e) { this.app.toast(`模型配置加载失败: ${e.message}`); }
|
|
2308
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: [] };
|
|
2309
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("、")}`);
|
|
2310
2874
|
this.loaded = true;
|
|
2311
2875
|
this.modelsSel = -1;
|
|
2312
2876
|
this.#rebuild();
|
|
2313
2877
|
this.app.redraw();
|
|
2314
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
|
+
}
|
|
2315
3025
|
/** One batched credentials.describe over every referenced key, exactly like
|
|
2316
3026
|
* the web page's store join. Reads are structurally value-free: only the
|
|
2317
3027
|
* configured/source/writable view ever reaches this panel. */
|
|
@@ -2320,55 +3030,129 @@ export class ModelPanel extends Widget {
|
|
|
2320
3030
|
// only well-formed references can cross the wire (the describe payload
|
|
2321
3031
|
// validates each name); an ill-formed derived ref is skipped here and
|
|
2322
3032
|
// reported by the row's edit guard instead
|
|
2323
|
-
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)))];
|
|
2324
3037
|
if (refs.length === 0) { this.keyStatus = {}; return; }
|
|
2325
3038
|
const res = await this.app.api.call("credentials.describe", { refs });
|
|
2326
3039
|
this.keyStatus = res?.credentials ?? {};
|
|
2327
3040
|
} catch (e) { this.keyStatus = {}; }
|
|
2328
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
|
+
}
|
|
2329
3054
|
/** The credential reference a profile names, or the web's derived default. */
|
|
2330
3055
|
#keyRef(route) {
|
|
2331
3056
|
const p = this.#profile(route);
|
|
2332
3057
|
return (p.apiKeyEnv && p.apiKeyEnv.length > 0) ? p.apiKeyEnv : deriveKeyRef(route);
|
|
2333
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
|
+
}
|
|
2334
3063
|
#route() { return this.routes[this.sel] ?? null; }
|
|
2335
|
-
#
|
|
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
|
+
}
|
|
2336
3099
|
#formRows() {
|
|
2337
3100
|
const route = this.#route();
|
|
2338
3101
|
if (route == null) return [];
|
|
2339
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;
|
|
2340
3107
|
const items = [];
|
|
2341
|
-
//
|
|
3108
|
+
// Only hand-declared custom routes own their route identity/display name.
|
|
2342
3109
|
if (this.draftRoute === route) items.push({ kind: "field", key: "route", label: "路由名", value: route });
|
|
2343
|
-
items.push({ kind: "field", key: "displayName", label: "显示名", value: p.displayName ?? "" });
|
|
3110
|
+
if (ownsIdentity) items.push({ kind: "field", key: "displayName", label: "显示名", value: p.displayName ?? "" });
|
|
2344
3111
|
// the api protocol is a CHOICE in the web UI (a select over the namespace
|
|
2345
3112
|
// schema's union), so here Tab cycles the options in the form and Enter
|
|
2346
3113
|
// opens an edit buffer with every candidate shown as an autocomplete hint
|
|
2347
|
-
const api = p.api ?? "
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
items.push({ kind: "field", key: "
|
|
2356
|
-
|
|
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
|
+
}
|
|
2357
3136
|
// the api key: web-synced handling — the stored value is NEVER shown
|
|
2358
3137
|
// (credentials.describe is structurally value-free), only its status dot;
|
|
2359
|
-
// Enter opens a masked, always-empty editor
|
|
2360
|
-
//
|
|
3138
|
+
// Enter opens a masked, always-empty editor. The typed value remains a
|
|
3139
|
+
// write-only draft until save persists it through credentials.set.
|
|
2361
3140
|
const keyRef = this.#keyRef(route);
|
|
2362
|
-
items.push({ kind: "key", key: "apiKeyEnv", label: "API 密钥", ref: keyRef, action: () => this.#editKey(route, keyRef) });
|
|
2363
|
-
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) });
|
|
2364
3143
|
// models are NOT flat here: one 模型管理 entry summarizing the first
|
|
2365
3144
|
// five, which opens its own sub-buffer (scan on top, model form below)
|
|
2366
3145
|
const models = p.models ?? [];
|
|
2367
|
-
const names = models.slice(0, 5).map((m) => m.id || "(未命名)").join(" · ");
|
|
2368
|
-
|
|
2369
|
-
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() });
|
|
2370
3149
|
items.push({ kind: "button", label: "💾 保存配置", action: () => this.#save() });
|
|
2371
|
-
|
|
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
|
+
}
|
|
2372
3156
|
return items;
|
|
2373
3157
|
}
|
|
2374
3158
|
/** The 模型管理 sub-buffer: scan first, then the model-info form rows. */
|
|
@@ -2376,8 +3160,14 @@ export class ModelPanel extends Widget {
|
|
|
2376
3160
|
const route = this.#route();
|
|
2377
3161
|
if (route == null) return [];
|
|
2378
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;
|
|
2379
3166
|
const items = [];
|
|
2380
|
-
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
|
+
}
|
|
2381
3171
|
const models = p.models ?? [];
|
|
2382
3172
|
for (let mi = 0; mi < models.length; mi++) {
|
|
2383
3173
|
const m = models[mi];
|
|
@@ -2387,15 +3177,31 @@ export class ModelPanel extends Widget {
|
|
|
2387
3177
|
items.push({ kind: "field", key: `model.${mi}.name`, label: " 模型名", value: m.name ?? "" });
|
|
2388
3178
|
items.push({ kind: "field", key: `model.${mi}.contextWindow`, label: " 上下文窗口", value: m.contextWindow ?? "", numeric: true });
|
|
2389
3179
|
items.push({ kind: "field", key: `model.${mi}.maxTokens`, label: " 最大输出", value: m.maxTokens ?? "", numeric: true });
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
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
|
+
}
|
|
2393
3200
|
}
|
|
2394
3201
|
}
|
|
2395
3202
|
items.push({ kind: "button", label: "+ 添加模型", action: () => this.#addModel() });
|
|
2396
3203
|
items.push({ kind: "button", label: "🗑 删除选中模型", action: () => this.#deleteModel() });
|
|
2397
|
-
items.push({ kind: "button", label: "◉
|
|
2398
|
-
items.push({ kind: "button", label: "★ 设为默认 Agent/Subagent 目标", action: () => this.#setAgentDefault() });
|
|
3204
|
+
items.push({ kind: "button", label: "◉ 设为当前会话及后续 Agent 默认模型", action: () => this.#setDefaultModel() });
|
|
2399
3205
|
return items;
|
|
2400
3206
|
}
|
|
2401
3207
|
#openModels() {
|
|
@@ -2410,36 +3216,89 @@ export class ModelPanel extends Widget {
|
|
|
2410
3216
|
const listLines = [];
|
|
2411
3217
|
for (let i = 0; i < this.routes.length; i++) {
|
|
2412
3218
|
const r = this.routes[i];
|
|
2413
|
-
const p = this
|
|
3219
|
+
const p = this.#profile(r);
|
|
3220
|
+
const entry = this.#entry(r);
|
|
2414
3221
|
const cur = i === this.sel;
|
|
2415
3222
|
const editing = cur && this.mode === "form";
|
|
2416
3223
|
listLines.push([{
|
|
2417
|
-
t: ` ${cur ? "●" : " "} ${truncate(p.displayName || r, 18)}${editing ? " ✎" : ""}`,
|
|
3224
|
+
t: ` ${cur ? "●" : " "} ${truncate(inlineLabel(p.displayName || entry?.displayName || r), 18)}${editing ? " ✎" : ""}`,
|
|
2418
3225
|
fg: cur ? T.SELFG : T.TXT, bg: cur ? (editing ? T.MENUSEL : T.SELBG) : T.BG2, bold: cur,
|
|
2419
3226
|
}]);
|
|
2420
3227
|
}
|
|
2421
3228
|
const addCur = this.sel === this.routes.length;
|
|
2422
3229
|
listLines.push([{ t: ` ${addCur ? "●" : " "} + 添加供应商`, fg: addCur ? T.SELFG : T.ACCENT, bg: addCur ? T.MENUSEL : T.BG2, bold: true }]);
|
|
2423
3230
|
this.listView.setLines(listLines);
|
|
2424
|
-
|
|
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.
|
|
2425
3236
|
const route = this.#route();
|
|
2426
3237
|
const formLines = [];
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
formLines.push(
|
|
2430
|
-
|
|
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 }]);
|
|
2431
3286
|
this.formItems = [];
|
|
2432
3287
|
} else if (this.scanMode) {
|
|
2433
|
-
|
|
2434
|
-
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;
|
|
2435
3291
|
for (let i = 0; i < this.scanItems.length; i++) {
|
|
2436
3292
|
const m = this.scanItems[i];
|
|
2437
3293
|
const on = this.scanSel.has(m.id);
|
|
2438
3294
|
const cur = i === this.scanCursor;
|
|
2439
|
-
|
|
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 });
|
|
2440
3297
|
}
|
|
2441
|
-
|
|
3298
|
+
pushForm([{ t: " Enter 添加选中 · Esc 取消扫描", fg: K.FAINT }]);
|
|
2442
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);
|
|
2443
3302
|
} else {
|
|
2444
3303
|
const isSub = this.sub != null;
|
|
2445
3304
|
const items = isSub ? this.#subItems() : this.#formRows();
|
|
@@ -2447,41 +3306,49 @@ export class ModelPanel extends Widget {
|
|
|
2447
3306
|
else this.formItems = items;
|
|
2448
3307
|
const w = Math.max(30, this.formView.w - 4);
|
|
2449
3308
|
const cursor = isSub ? this.sub.cursor : this.formIdx;
|
|
2450
|
-
|
|
3309
|
+
let cursorLine = null;
|
|
3310
|
+
if (isSub) pushForm([{ t: ` 模型管理 — ${truncate(inlineLabel(this.#profile(route).displayName || route), 30)} (Esc 返回)`, fg: K.ACCENT, bold: true }]);
|
|
2451
3311
|
for (let i = 0; i < items.length; i++) {
|
|
2452
3312
|
const it = items[i];
|
|
2453
3313
|
const cur = i === cursor;
|
|
2454
3314
|
let t;
|
|
2455
3315
|
if (it.kind === "field" || it.kind === "choice") {
|
|
2456
|
-
const v = it.value === "" || it.value == null ? "(空)" :
|
|
3316
|
+
const v = it.value === "" || it.value == null ? "(空)" : inlineLabel(it.value);
|
|
2457
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)}`;
|
|
2458
3320
|
} else if (it.kind === "key") {
|
|
2459
3321
|
// status dot + reference, NEVER the value (web-synced posture)
|
|
2460
3322
|
const st = this.keyStatus?.[it.ref];
|
|
2461
|
-
const status = st?.configured ? "● 已配置" : "○ 未配置";
|
|
3323
|
+
const status = it.pending ? "◐ 待保存" : st?.configured ? "● 已配置" : "○ 未配置";
|
|
2462
3324
|
const ro = st && st.writable === false ? " [只读]" : "";
|
|
2463
3325
|
t = ` ${cur ? "▸" : " "} ${it.label}: ${status}${ro} (${it.ref})`;
|
|
2464
3326
|
} else if (it.kind === "model") {
|
|
2465
3327
|
const extras = [it.ctx != null ? `ctx ${it.ctx}` : "", it.max != null ? `max ${it.max}` : ""].filter(Boolean).join(" ");
|
|
2466
|
-
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)}`;
|
|
2467
3329
|
} else {
|
|
2468
3330
|
t = ` ${cur ? "▸" : " "} ${it.label}`;
|
|
2469
3331
|
}
|
|
2470
|
-
|
|
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 });
|
|
2471
3334
|
// the 模型管理 preview: an indented, non-focusable summary line
|
|
2472
3335
|
if (!isSub && it.kind === "button" && it.sub) {
|
|
2473
|
-
|
|
3336
|
+
pushForm([{ t: ` ${truncate(it.sub, w - 8)}`, fg: K.FAINT, bg: T.BG2 }]);
|
|
2474
3337
|
}
|
|
2475
3338
|
}
|
|
2476
|
-
|
|
3339
|
+
pushForm([{ t: isSub
|
|
2477
3340
|
? " ↑/↓ 移动 · Enter 编辑或执行 · Esc 返回供应商"
|
|
2478
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);
|
|
2479
3344
|
}
|
|
3345
|
+
if (!this.writable) pushForm([{ t: " 模型配置只读 · 可浏览、发现模型和切换当前会话模型", fg: K.WARN }]);
|
|
2480
3346
|
this.formView.setLines(formLines);
|
|
3347
|
+
this.formView.scrollY = Math.max(0, Math.min(this.formView.scrollY, this.formView.maxScroll()));
|
|
2481
3348
|
}
|
|
2482
3349
|
render(screen) {
|
|
2483
3350
|
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", {});
|
|
2484
|
-
const mid = this.x
|
|
3351
|
+
const mid = this.formView.x - 1;
|
|
2485
3352
|
screen.vline(mid, this.y, this.y + this.h - 1, "│", { fg: T.BORDER });
|
|
2486
3353
|
screen.text(this.x + 1, this.y, " 模型供应商", { fg: K.DIM });
|
|
2487
3354
|
this.listView.render(screen);
|
|
@@ -2511,18 +3378,40 @@ export class ModelPanel extends Widget {
|
|
|
2511
3378
|
if (!/^[\x21-\x7E]+$/.test(value)) return "密钥只能包含可打印 ASCII 字符";
|
|
2512
3379
|
return null;
|
|
2513
3380
|
}
|
|
2514
|
-
/** Edit the API key value the web-synced way: a masked, always-empty editor
|
|
2515
|
-
*
|
|
2516
|
-
*
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
const
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
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
|
+
});
|
|
2526
3415
|
this.app.overlay = confirm; this.app.redraw();
|
|
2527
3416
|
}
|
|
2528
3417
|
#editKey(route, ref) {
|
|
@@ -2531,6 +3420,7 @@ export class ModelPanel extends Widget {
|
|
|
2531
3420
|
return;
|
|
2532
3421
|
}
|
|
2533
3422
|
const st = this.keyStatus?.[ref];
|
|
3423
|
+
if (st?.writable === false) { this.app.toast(`${ref} 为只读凭据`); return; }
|
|
2534
3424
|
const popup = new EditPopup(this.app, {
|
|
2535
3425
|
title: `设置 API 密钥 — ${ref}`,
|
|
2536
3426
|
value: "",
|
|
@@ -2542,18 +3432,11 @@ export class ModelPanel extends Widget {
|
|
|
2542
3432
|
if (failure) { this.app.toast(failure); this.#rebuild(); this.app.redraw(); return; }
|
|
2543
3433
|
const v = text.trim();
|
|
2544
3434
|
if (v === "") { this.app.toast("未输入新密钥,保持原值不变"); this.#rebuild(); this.app.redraw(); return; }
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
// the web create flow records the derived reference in the profile;
|
|
2551
|
-
// persist it with the provider save
|
|
2552
|
-
p.apiKeyEnv = ref;
|
|
2553
|
-
this.app.toast(`已记录 apiKeyEnv · 点💾保存配置使供应商生效`);
|
|
2554
|
-
}
|
|
2555
|
-
await this.#refreshKeys();
|
|
2556
|
-
} 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} · 可先用于自动发现`);
|
|
2557
3440
|
this.#rebuild();
|
|
2558
3441
|
this.app.redraw();
|
|
2559
3442
|
},
|
|
@@ -2562,12 +3445,28 @@ export class ModelPanel extends Widget {
|
|
|
2562
3445
|
this.app.focus(popup.input);
|
|
2563
3446
|
this.app.redraw();
|
|
2564
3447
|
}
|
|
2565
|
-
#
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
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);
|
|
2569
3467
|
this.draftRoute = name;
|
|
2570
|
-
this.
|
|
3468
|
+
this.addMode = false;
|
|
3469
|
+
this.#syncRoutes();
|
|
2571
3470
|
this.sel = this.routes.indexOf(name);
|
|
2572
3471
|
this.mode = "form";
|
|
2573
3472
|
this.formIdx = 0;
|
|
@@ -2576,9 +3475,45 @@ export class ModelPanel extends Widget {
|
|
|
2576
3475
|
this.#rebuild();
|
|
2577
3476
|
this.app.redraw();
|
|
2578
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
|
+
}
|
|
2579
3513
|
#activateItem() {
|
|
2580
3514
|
if (this.mode === "list") {
|
|
2581
|
-
if (this.sel === this.routes.length) { this.#
|
|
3515
|
+
if (this.sel === this.routes.length) { this.#openAddProvider(); return; }
|
|
3516
|
+
this.addMode = false;
|
|
2582
3517
|
this.mode = "form";
|
|
2583
3518
|
this.formIdx = 0;
|
|
2584
3519
|
this.modelsSel = -1;
|
|
@@ -2592,51 +3527,136 @@ export class ModelPanel extends Widget {
|
|
|
2592
3527
|
const it = items[idx];
|
|
2593
3528
|
if (!it) return;
|
|
2594
3529
|
const route = this.#route();
|
|
2595
|
-
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; }
|
|
2596
3534
|
if (it.kind === "field") {
|
|
2597
3535
|
// Enter always opens the standalone edit buffer; Tab (handled in onKey)
|
|
2598
3536
|
// cycles a field that declares cycle options, and the buffer itself
|
|
2599
3537
|
// offers every completion as an autocomplete hint
|
|
2600
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);
|
|
2601
3547
|
if (it.key === "route") {
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
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
|
+
}
|
|
2605
3555
|
if (t !== route) {
|
|
2606
|
-
|
|
3556
|
+
const profile = this.providers[route];
|
|
3557
|
+
const oldDerivedRef = deriveKeyRef(route);
|
|
3558
|
+
this.providers[t] = profile;
|
|
2607
3559
|
delete this.providers[route];
|
|
2608
|
-
|
|
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();
|
|
2609
3567
|
this.sel = this.routes.indexOf(t);
|
|
2610
3568
|
}
|
|
2611
3569
|
} else if (it.numeric) {
|
|
2612
3570
|
const n = text.trim() === "" ? undefined : Number(text);
|
|
2613
|
-
if (n !== undefined && (!isFinite(n) || n <= 0)) { this.app.toast("请输入正数"); return; }
|
|
2614
3571
|
if (it.key.startsWith("model.")) {
|
|
2615
|
-
const [, mi, field] = it.key.split(".");
|
|
2616
|
-
|
|
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;
|
|
2617
3578
|
} else if (it.key.startsWith("model.")) {
|
|
2618
3579
|
const [, mi, field, detail] = it.key.split(".");
|
|
2619
|
-
const model =
|
|
3580
|
+
const model = this.#models(route, { mutable: true })[Number(mi)];
|
|
2620
3581
|
if (field === "reasoning") {
|
|
2621
|
-
if (model.reasoningEfforts === false) model.reasoningEfforts = {};
|
|
3582
|
+
if (!model.reasoningEfforts || model.reasoningEfforts === false) model.reasoningEfforts = {};
|
|
2622
3583
|
const value = text.trim();
|
|
2623
3584
|
if (!value) delete model.reasoningEfforts[detail];
|
|
2624
|
-
else
|
|
2625
|
-
|
|
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;
|
|
2626
3619
|
} else {
|
|
2627
|
-
|
|
2628
|
-
if (it.key
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
} 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);
|
|
2632
3624
|
}
|
|
3625
|
+
this.#pruneEmptyDraft(route);
|
|
2633
3626
|
}, it.completions);
|
|
2634
3627
|
return;
|
|
2635
3628
|
}
|
|
2636
3629
|
if (it.kind === "choice") {
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
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
|
+
}
|
|
2640
3660
|
this.#rebuild(); this.app.redraw(); return;
|
|
2641
3661
|
}
|
|
2642
3662
|
if (it.kind === "model") {
|
|
@@ -2651,22 +3671,32 @@ export class ModelPanel extends Widget {
|
|
|
2651
3671
|
return;
|
|
2652
3672
|
}
|
|
2653
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
|
+
}
|
|
2654
3685
|
#addModel() {
|
|
2655
3686
|
const route = this.#route();
|
|
2656
3687
|
if (!route) return;
|
|
2657
|
-
const
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
this.modelsSel = p.models.length - 1;
|
|
3688
|
+
const models = this.#models(route, { mutable: true });
|
|
3689
|
+
models.push({ id: "" });
|
|
3690
|
+
this.modelsSel = models.length - 1;
|
|
2661
3691
|
this.#rebuild();
|
|
2662
3692
|
this.app.redraw();
|
|
2663
3693
|
}
|
|
2664
3694
|
#deleteModel() {
|
|
2665
3695
|
const route = this.#route();
|
|
2666
3696
|
if (!route || this.modelsSel < 0) { this.app.toast("先选中一个模型"); return; }
|
|
2667
|
-
const model = this.#
|
|
3697
|
+
const model = this.#models(route)[this.modelsSel];
|
|
2668
3698
|
this.#confirmDelete(`删除模型 ${model?.name || model?.id || "(未命名)"}?`, () => {
|
|
2669
|
-
this.#
|
|
3699
|
+
this.#models(route, { mutable: true }).splice(this.modelsSel, 1);
|
|
2670
3700
|
this.modelsSel = -1;
|
|
2671
3701
|
this.#rebuild(); this.app.redraw();
|
|
2672
3702
|
});
|
|
@@ -2674,60 +3704,214 @@ export class ModelPanel extends Widget {
|
|
|
2674
3704
|
async #setDefaultModel() {
|
|
2675
3705
|
const route = this.#route();
|
|
2676
3706
|
if (!route) return;
|
|
2677
|
-
const
|
|
2678
|
-
const m = p.models?.[this.modelsSel];
|
|
3707
|
+
const m = this.#models(route)[this.modelsSel];
|
|
2679
3708
|
if (!m?.id) { this.app.toast("先选中一个模型"); return; }
|
|
2680
3709
|
if (!this.app.currentSession) { this.app.toast("先打开一个会话"); return; }
|
|
2681
3710
|
try {
|
|
2682
3711
|
await this.app.api.call("session.selectModel", { sessionId: this.app.currentSession, provider: route, model: m.id });
|
|
2683
|
-
this.app.updateModel();
|
|
2684
|
-
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 默认使用此模型`);
|
|
2685
3714
|
} catch (e) { this.app.toast(`切换失败: ${e.message}`); }
|
|
2686
3715
|
}
|
|
2687
|
-
#
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
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);
|
|
2695
3782
|
try {
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
if (this.defaultModel) {
|
|
2705
|
-
const profile = this.providers[this.defaultModel.provider];
|
|
2706
|
-
if (!profile || !(profile.models ?? []).some((model) => model.id === this.defaultModel.model)) {
|
|
2707
|
-
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);
|
|
2708
3791
|
}
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
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);
|
|
2714
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);
|
|
2715
3837
|
}
|
|
2716
|
-
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 密钥已保存" : "配置未变化");
|
|
2717
3842
|
return true;
|
|
2718
|
-
} 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
|
+
}
|
|
2719
3851
|
}
|
|
2720
|
-
#dirty() {
|
|
2721
|
-
|
|
2722
|
-
|
|
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
|
+
}
|
|
2723
3895
|
this.providers = JSON.parse(this.savedSnapshot);
|
|
2724
|
-
|
|
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();
|
|
2725
3907
|
this.draftRoute = null;
|
|
3908
|
+
this.addMode = false;
|
|
2726
3909
|
this.modelsSel = -1;
|
|
2727
3910
|
this.sub = null;
|
|
2728
|
-
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);
|
|
2729
3912
|
this.#rebuild();
|
|
2730
3913
|
this.app.redraw();
|
|
3914
|
+
return true;
|
|
2731
3915
|
}
|
|
2732
3916
|
/** Leave the provider form for another level. With unsaved changes this asks
|
|
2733
3917
|
* 保存/不保存/取消 first; a failed save keeps the user on the form. */
|
|
@@ -2748,13 +3932,13 @@ export class ModelPanel extends Widget {
|
|
|
2748
3932
|
],
|
|
2749
3933
|
onAction: async (btn) => {
|
|
2750
3934
|
this.app.closeOverlay();
|
|
2751
|
-
this.app.focus(this.app.chat);
|
|
3935
|
+
this.app.focus(this.app.fullBuffer ?? this.app.chat);
|
|
2752
3936
|
if (btn?.action === "cancel") return; // stay on the form
|
|
2753
3937
|
if (btn?.action === "save") {
|
|
2754
3938
|
const ok = await this.#save();
|
|
2755
3939
|
if (!ok) return; // save failed: stay (toast shown)
|
|
2756
3940
|
} else if (btn?.action === "discard") {
|
|
2757
|
-
this.#discard();
|
|
3941
|
+
if (!await this.#discard()) return;
|
|
2758
3942
|
} else {
|
|
2759
3943
|
return; // Esc = __cancel__
|
|
2760
3944
|
}
|
|
@@ -2772,19 +3956,139 @@ export class ModelPanel extends Widget {
|
|
|
2772
3956
|
w, h: Math.min(7, this.app.screen.h), title: "确认删除",
|
|
2773
3957
|
lines: [[{ t: " " + prompt, fg: K.WARN }]],
|
|
2774
3958
|
buttons: [{ label: "取消", action: "cancel" }, { label: "删除", action: "delete" }],
|
|
2775
|
-
onAction: (btn) => { this.app.closeOverlay(); if (btn?.action === "delete") action(); },
|
|
3959
|
+
onAction: (btn) => { this.app.closeOverlay(); if (btn?.action === "delete") return action(); },
|
|
2776
3960
|
});
|
|
2777
3961
|
this.app.redraw();
|
|
2778
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
|
+
}
|
|
2779
3993
|
async #deleteProvider() {
|
|
2780
3994
|
const route = this.#route();
|
|
2781
3995
|
if (!route) return;
|
|
3996
|
+
if (this.#dirty()) { this.app.toast("请先保存或放弃其他修改,再删除供应商"); return; }
|
|
2782
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
|
+
}
|
|
2783
4070
|
delete this.providers[route];
|
|
2784
|
-
|
|
2785
|
-
|
|
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);
|
|
2786
4079
|
this.modelsSel = -1;
|
|
2787
|
-
|
|
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
|
+
}
|
|
2788
4092
|
this.#rebuild(); this.app.redraw();
|
|
2789
4093
|
});
|
|
2790
4094
|
}
|
|
@@ -2792,47 +4096,47 @@ export class ModelPanel extends Widget {
|
|
|
2792
4096
|
const route = this.#route();
|
|
2793
4097
|
if (!route) return;
|
|
2794
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
|
+
}
|
|
2795
4105
|
const base = String(p.baseURL ?? "").replace(/\/+$/, "");
|
|
2796
|
-
if (!base) { this.app.toast("先填写 baseURL"); return; }
|
|
2797
4106
|
this.scanning = true;
|
|
2798
4107
|
this.scanMode = true;
|
|
2799
4108
|
this.scanItems = [];
|
|
2800
4109
|
this.scanCursor = 0;
|
|
2801
4110
|
this.#rebuild();
|
|
2802
4111
|
this.app.redraw();
|
|
2803
|
-
const key = p.apiKeyEnv ? process.env[p.apiKeyEnv] : null;
|
|
2804
|
-
const headers = key ? { Authorization: `Bearer ${key}` } : {};
|
|
2805
|
-
const tryFetch = async (dispatcher) => {
|
|
2806
|
-
const res = await fetch(`${base}/models`, { headers, dispatcher });
|
|
2807
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
2808
|
-
return res.json();
|
|
2809
|
-
};
|
|
2810
4112
|
try {
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
}
|
|
2820
|
-
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
|
+
});
|
|
2821
4122
|
const seen = new Set();
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
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 [];
|
|
2827
4127
|
seen.add(id);
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
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 添加`);
|
|
2834
4138
|
} catch (e) {
|
|
2835
|
-
this.app.toast(
|
|
4139
|
+
this.app.toast(`扫描失败:${String(e.message ?? e).replace(/^[^:]+:\s*/, "")}`);
|
|
2836
4140
|
this.scanMode = false;
|
|
2837
4141
|
}
|
|
2838
4142
|
this.scanning = false;
|
|
@@ -2842,13 +4146,17 @@ export class ModelPanel extends Widget {
|
|
|
2842
4146
|
#scanCommit() {
|
|
2843
4147
|
const route = this.#route();
|
|
2844
4148
|
if (!route) return;
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
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));
|
|
2848
4152
|
let added = 0;
|
|
2849
|
-
for (const m of
|
|
2850
|
-
|
|
2851
|
-
|
|
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
|
+
});
|
|
2852
4160
|
added++;
|
|
2853
4161
|
}
|
|
2854
4162
|
this.scanMode = false;
|
|
@@ -2858,10 +4166,26 @@ export class ModelPanel extends Widget {
|
|
|
2858
4166
|
}
|
|
2859
4167
|
onKey(ev) {
|
|
2860
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
|
+
}
|
|
2861
4185
|
if (this.scanMode) {
|
|
2862
4186
|
if (ev.name === "escape") { this.scanMode = false; this.#rebuild(); return true; }
|
|
2863
|
-
if (ev.name === "up") { this.scanCursor =
|
|
2864
|
-
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; }
|
|
2865
4189
|
if (ev.name === "char" && ev.key === " " && !ev.ctrl) {
|
|
2866
4190
|
const m = this.scanItems[this.scanCursor];
|
|
2867
4191
|
if (m) { if (this.scanSel.has(m.id)) this.scanSel.delete(m.id); else this.scanSel.add(m.id); }
|
|
@@ -2876,13 +4200,13 @@ export class ModelPanel extends Widget {
|
|
|
2876
4200
|
// inside the 模型管理 sub-buffer: ↑/↓ walk its rows; Esc returns
|
|
2877
4201
|
if (ev.name === "escape") { this.sub = null; this.#rebuild(); return true; }
|
|
2878
4202
|
if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
|
|
2879
|
-
this.sub.cursor =
|
|
4203
|
+
this.sub.cursor = wrapIndex(this.sub.cursor - 1, this.#subItems().length);
|
|
2880
4204
|
this.#rebuild();
|
|
2881
4205
|
this.app.redraw();
|
|
2882
4206
|
return true;
|
|
2883
4207
|
}
|
|
2884
4208
|
if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
|
|
2885
|
-
this.sub.cursor =
|
|
4209
|
+
this.sub.cursor = wrapIndex(this.sub.cursor + 1, this.#subItems().length);
|
|
2886
4210
|
this.#rebuild();
|
|
2887
4211
|
this.app.redraw();
|
|
2888
4212
|
return true;
|
|
@@ -2898,21 +4222,21 @@ export class ModelPanel extends Widget {
|
|
|
2898
4222
|
this.#leaveForm(() => { this.mode = "list"; this.sub = null; this.#rebuild(); this.app.redraw(); });
|
|
2899
4223
|
return true;
|
|
2900
4224
|
}
|
|
2901
|
-
return false; // list level: App
|
|
4225
|
+
return false; // list level: App closes the full-screen buffer on the unhandled Escape
|
|
2902
4226
|
}
|
|
2903
4227
|
// dual-focus navigation: ↑/↓ move the cursor INSIDE the focused region —
|
|
2904
4228
|
// the provider column in list focus, the option rows in form focus.
|
|
2905
4229
|
// → enters the form, ← returns to the list.
|
|
2906
4230
|
if (ev.name === "up" || (ev.name === "char" && ev.key === "k" && !ev.ctrl)) {
|
|
2907
|
-
if (this.mode === "list") this.sel =
|
|
2908
|
-
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);
|
|
2909
4233
|
this.#rebuild();
|
|
2910
4234
|
this.app.redraw();
|
|
2911
4235
|
return true;
|
|
2912
4236
|
}
|
|
2913
4237
|
if (ev.name === "down" || (ev.name === "char" && ev.key === "j" && !ev.ctrl)) {
|
|
2914
|
-
if (this.mode === "list") this.sel =
|
|
2915
|
-
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);
|
|
2916
4240
|
this.#rebuild();
|
|
2917
4241
|
this.app.redraw();
|
|
2918
4242
|
return true;
|
|
@@ -2922,12 +4246,17 @@ export class ModelPanel extends Widget {
|
|
|
2922
4246
|
// walks to the next form item. Otherwise (list focus) it behaves like →.
|
|
2923
4247
|
if (ev.name === "tab" && this.mode === "form" && this.sub == null) {
|
|
2924
4248
|
const it = this.formItems[this.formIdx];
|
|
4249
|
+
if (it?.cycle?.length && !this.writable) { this.app.toast("模型配置为只读"); return true; }
|
|
2925
4250
|
if (it?.cycle?.length) {
|
|
2926
4251
|
const cur = String(it.value ?? "");
|
|
2927
4252
|
const idx = it.cycle.indexOf(cur);
|
|
2928
|
-
|
|
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());
|
|
2929
4258
|
} else if (this.formItems.length > 0) {
|
|
2930
|
-
this.formIdx =
|
|
4259
|
+
this.formIdx = wrapIndex(this.formIdx + 1, this.formItems.length);
|
|
2931
4260
|
}
|
|
2932
4261
|
this.#rebuild();
|
|
2933
4262
|
this.app.redraw();
|
|
@@ -2960,7 +4289,7 @@ export class ModelPanel extends Widget {
|
|
|
2960
4289
|
if (this.mode === "form") {
|
|
2961
4290
|
if (idx === this.sel) return true; // already editing this provider
|
|
2962
4291
|
if (idx === this.routes.length) {
|
|
2963
|
-
this.#leaveForm(() => this.#
|
|
4292
|
+
this.#leaveForm(() => this.#openAddProvider());
|
|
2964
4293
|
} else {
|
|
2965
4294
|
this.#leaveForm(() => {
|
|
2966
4295
|
this.sel = idx;
|
|
@@ -2980,25 +4309,40 @@ export class ModelPanel extends Widget {
|
|
|
2980
4309
|
}
|
|
2981
4310
|
return false;
|
|
2982
4311
|
}
|
|
2983
|
-
if (this.
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
}
|
|
2992
|
-
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;
|
|
2993
4320
|
}
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
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;
|
|
3000
4345
|
}
|
|
3001
|
-
if (idx >= 0 && idx < this.formItems.length) { this.formIdx = idx; this.mode = "form"; this.#activateItem(); return true; }
|
|
3002
4346
|
return false;
|
|
3003
4347
|
}
|
|
3004
4348
|
}
|
|
@@ -3197,13 +4541,13 @@ export class SubagentPanel extends Widget {
|
|
|
3197
4541
|
onKey(ev) {
|
|
3198
4542
|
if (ev.type === "text") { this.input.insert(ev.text); this.app.redraw(); return true; }
|
|
3199
4543
|
if (ev.type !== "key") return false;
|
|
3200
|
-
if (ev.name === "escape") { this.app.setMode("chat"); return true; }
|
|
4544
|
+
if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
|
|
3201
4545
|
if (ev.name === "char" && ev.key === "x" && !ev.ctrl) { this.interrupt(); return true; }
|
|
3202
4546
|
if (ev.name === "char" && ev.key === "r" && !ev.ctrl) { this.selectChild(this.selIdx); return true; }
|
|
3203
4547
|
if (ev.name === "up" || ev.name === "down") {
|
|
3204
4548
|
if (this.entries.length === 0) return false;
|
|
3205
|
-
const next = this.selIdx + (ev.name === "up" ? -1 : 1);
|
|
3206
|
-
|
|
4549
|
+
const next = wrapIndex(this.selIdx + (ev.name === "up" ? -1 : 1), this.entries.length);
|
|
4550
|
+
this.selectChild(next);
|
|
3207
4551
|
return true;
|
|
3208
4552
|
}
|
|
3209
4553
|
if (this.input.onKey(ev)) { this.app.redraw(); return true; }
|
|
@@ -3294,11 +4638,11 @@ export class SkillsPanel extends Widget {
|
|
|
3294
4638
|
}
|
|
3295
4639
|
onKey(ev) {
|
|
3296
4640
|
if (ev.type !== "key") return false;
|
|
3297
|
-
if (ev.name === "escape") { this.app.setMode("chat"); return true; }
|
|
4641
|
+
if (ev.name === "escape") { this.app.closeFullBuffer?.() ?? this.app.setMode?.("chat"); return true; }
|
|
3298
4642
|
if (ev.name === "up" || ev.name === "down") {
|
|
3299
4643
|
if (this.skills.length === 0) return false;
|
|
3300
|
-
const next = this.selIdx + (ev.name === "up" ? -1 : 1);
|
|
3301
|
-
|
|
4644
|
+
const next = wrapIndex(this.selIdx + (ev.name === "up" ? -1 : 1), this.skills.length);
|
|
4645
|
+
this.select(next);
|
|
3302
4646
|
return true;
|
|
3303
4647
|
}
|
|
3304
4648
|
if (ev.name === "char" && ev.key === "c" && !ev.ctrl && this.skills[this.selIdx]) {
|