mslxdff 0.1.54 → 0.1.55

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/bin/mslxdff.js CHANGED
@@ -8,7 +8,7 @@ import { DEFAULT_PORT, defaultStateFile } from "../src/state.js";
8
8
  import { createRouter } from "../src/routes.js";
9
9
  import { createUpstreamClient } from "../src/upstream.js";
10
10
  import { createModelsService } from "../src/models.js";
11
- import { loadToken, refreshToken, setPort, getPort, loadGroupsJoined, saveGroupsJoined, loadModelErrors, savePreferredModel, loadPreferredModel } from "../src/state.js";
11
+ import { loadToken, refreshToken, setPort, getPort, loadGroupsJoined, saveGroupsJoined, loadModelErrors, savePreferredModel, loadPreferredModel, loadModelPicks, saveModelPicks } from "../src/state.js";
12
12
  import { getPreferredModel } from "../src/auto.js";
13
13
  import { normalizeModel } from "../src/reasoning.js";
14
14
  import { syncToWorkbuddy, workbuddyModelsPath } from "../src/sync-workbuddy.js";
@@ -185,11 +185,42 @@ if (args.includes("-model") || args.includes("-models")) {
185
185
  if (sub === "set" && args[idx + 2]) {
186
186
  const id = args[idx + 2];
187
187
  savePreferredModel(id);
188
+ const picks = [...new Set([...loadModelPicks(), id])];
189
+ saveModelPicks(picks);
188
190
  console.log(`default model set to: ${id} (daemon hot-reloads on next request)`);
191
+ console.log(`picked: ${picks.join(", ") || "(none)"} (auto will pick within these)`);
192
+ process.exit(0);
193
+ }
194
+ // -model pick <id> | -model unpick <id> | -model picks : 常用模型勾选集管理(非 TTY 用)
195
+ if (sub === "pick" && args[idx + 2] && args[idx + 2] !== "clear") {
196
+ const picks = [...new Set([...loadModelPicks(), args[idx + 2]])];
197
+ saveModelPicks(picks);
198
+ console.log(`picked: ${picks.join(", ") || "(none)"} (auto will pick within these)`);
199
+ process.exit(0);
200
+ }
201
+ if (sub === "pick" && args[idx + 2] === "clear") {
202
+ saveModelPicks([]);
203
+ console.log("picks cleared — auto uses the full model list again");
204
+ process.exit(0);
205
+ }
206
+ if (sub === "unpick" && args[idx + 2]) {
207
+ const picks = loadModelPicks().filter((x) => x !== args[idx + 2]);
208
+ saveModelPicks(picks);
209
+ console.log(`picked: ${picks.join(", ") || "(none)"}${picks.length === 0 ? " (auto uses full list)" : ""}`);
210
+ process.exit(0);
211
+ }
212
+ if (sub === "picks") {
213
+ const picks = loadModelPicks();
214
+ if (!picks.length) {
215
+ console.log("no picks — auto uses the full model list");
216
+ } else {
217
+ console.log(`${picks.length} picked model(s), auto only selects within these:`);
218
+ }
219
+ for (const id of picks) console.log(` ${id}`);
189
220
  process.exit(0);
190
221
  }
191
222
  if (sub !== undefined && sub !== "list") {
192
- console.error("usage: mslxdff -models (interactive picker) | mslxdff -model list | mslxdff -model set <id> | mslxdff -model status | mslxdff -model refresh");
223
+ console.error("usage: mslxdff -models (interactive multi-pick) | mslxdff -model list | mslxdff -model set <id> | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear | mslxdff -model picks | mslxdff -model status | mslxdff -model refresh");
193
224
  process.exit(1);
194
225
  }
195
226
  const cacheFile = join(logDir(), "models.json");
@@ -235,31 +266,35 @@ if (args.includes("-model") || args.includes("-models")) {
235
266
  console.log("no models available — try: mslxdff -model refresh");
236
267
  process.exit(0);
237
268
  }
238
- // TTY:交互式箭头选择默认模型;非 TTY(管道/脚本):保持纯列表
269
+ // TTY:交互式多选勾选常用模型(空格勾选,Enter 保存);非 TTY(管道/脚本):保持纯列表并标注勾选
239
270
  if (process.stdin.isTTY && process.stdout.isTTY) {
240
271
  const statuses = loadModelErrors();
241
272
  const current = getPreferredModel();
273
+ const pickedIds = loadModelPicks();
242
274
  const items = ids.map((id) => {
243
275
  const e = statuses[id];
244
276
  return {
245
277
  id,
246
278
  status: typeof e === "number" ? "error" : e?.status || "normal",
247
279
  current: id === current,
280
+ picked: pickedIds.includes(id),
248
281
  };
249
282
  });
250
- const picked = await pickInteractive(items, Math.max(0, items.findIndex((x) => x.current)));
251
- if (!picked) {
252
- console.log("cancelled — default model unchanged");
283
+ const result = await pickInteractiveMulti(items, new Set(pickedIds), Math.max(0, items.findIndex((x) => x.current)));
284
+ if (!result) {
285
+ console.log("cancelled — picks unchanged");
253
286
  process.exit(0);
254
287
  }
255
- savePreferredModel(picked);
256
- console.log(`default model set to: ${picked} (daemon hot-reloads on next request)`);
288
+ saveModelPicks([...result]);
289
+ console.log(`saved ${result.size} picked model(s): ${[...result].join(", ") || "(none auto uses full list)"}`);
257
290
  process.exit(0);
258
291
  }
259
292
  const at = cachedAt ? ` (cached ${new Date(cachedAt).toISOString().slice(0, 16).replace("T", " ")})` : "";
260
- console.log(`${ids.length} free model(s)${at}:`);
261
- for (const id of ids) console.log(` ${id}`);
262
- console.log(`\ninteractive pick needs a TTY; set directly with: mslxdff -model set <id>`);
293
+ const pickedIds = loadModelPicks();
294
+ const mark = (id) => (pickedIds.includes(id) ? "*" : " ");
295
+ console.log(`${ids.length} free model(s)${at} (${pickedIds.length} picked, * = picked):`);
296
+ for (const id of ids) console.log(` ${mark(id)} ${id}`);
297
+ console.log(`\npicked only constrains auto; manage with: mslxdff -models (TTY) | mslxdff -model pick <id> | mslxdff -model unpick <id> | mslxdff -model pick clear`);
263
298
  } catch (err) {
264
299
  console.error(`could not fetch models: ${String(err?.message || err)}`);
265
300
  process.exit(1);
@@ -375,6 +410,53 @@ async function pickInteractive(items, startCursor = 0) {
375
410
  });
376
411
  }
377
412
 
413
+ // 多选勾选:↑/↓ 移动,Space 勾选/取消,Enter 保存,q/Esc 取消(返回 Set 或 null)
414
+ async function pickInteractiveMulti(items, initialPicked = new Set(), startCursor = 0) {
415
+ const { renderChooser, renderChooserHelp, parseKey } = await import("../src/chooser.js");
416
+ let cursor = Math.min(Math.max(startCursor, 0), items.length - 1);
417
+ const picked = new Set(items.filter((it) => initialPicked.has(it.id)).map((it) => it.id));
418
+ const draw = () => {
419
+ const rows = items.map((it, i) => ({ ...it, picked: picked.has(it.id) }));
420
+ const lines = [...renderChooser(rows, cursor, { multi: true }), ...renderChooserHelp(true)];
421
+ process.stdout.write("\x1b[G\x1b[J" + lines.join("\n"));
422
+ };
423
+ draw();
424
+ return new Promise((resolve) => {
425
+ const wasRaw = process.stdin.isRaw;
426
+ process.stdin.setRawMode(true);
427
+ process.stdin.resume();
428
+ process.stdin.setEncoding("utf8");
429
+ const cleanup = () => {
430
+ process.stdin.removeListener("data", onData);
431
+ process.stdin.setRawMode(false);
432
+ process.stdin.pause();
433
+ process.stdout.write("\n");
434
+ };
435
+ const onData = (chunk) => {
436
+ const key = parseKey(String(chunk));
437
+ if (key === "up") {
438
+ cursor = (cursor - 1 + items.length) % items.length;
439
+ draw();
440
+ } else if (key === "down") {
441
+ cursor = (cursor + 1) % items.length;
442
+ draw();
443
+ } else if (key === "space") {
444
+ const id = items[cursor].id;
445
+ if (picked.has(id)) picked.delete(id);
446
+ else picked.add(id);
447
+ draw();
448
+ } else if (key === "enter") {
449
+ cleanup();
450
+ resolve(new Set(picked));
451
+ } else if (key === "cancel") {
452
+ cleanup();
453
+ resolve(null);
454
+ }
455
+ };
456
+ process.stdin.on("data", onData);
457
+ });
458
+ }
459
+
378
460
  // -debug: stop the background daemon and run the server in THIS terminal
379
461
  // (foreground), printing every event to stdout in real time via the in-memory
380
462
  // event bus — no filesystem polling. Ctrl+C / SIGTERM restarts the daemon in
@@ -876,6 +958,7 @@ const models = createModelsService({
876
958
  const auto = createAutoSelector({
877
959
  cooldownMs: modelCooldownMs(),
878
960
  slowCooldownMs: slowCooldownMs(),
961
+ file: defaultStateFile(),
879
962
  loadCandidates: async () => {
880
963
  try {
881
964
  return (await models.get()).data.map((m) => m.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.54",
3
+ "version": "0.1.55",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auto.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { statSync } from "node:fs";
2
- import { loadModelErrors, saveModelErrors, loadModelLatencies, saveModelLatencies, loadPreferredModel, defaultStateFile } from "./state.js";
2
+ import { loadModelErrors, saveModelErrors, loadModelLatencies, saveModelLatencies, loadPreferredModel, loadModelPicks, saveModelPicks, defaultStateFile } from "./state.js";
3
3
 
4
4
  // 出厂默认首选模型(state.json 的 preferredModel / env MSLXDFF_PREFERRED_MODEL 可覆盖)
5
5
  export const DEFAULT_PREFERRED_MODEL = "big-pickle";
@@ -129,6 +129,8 @@ export function createAutoSelector({
129
129
  latencies: seedLatencies,
130
130
  persist = (errors, f = file) => saveModelErrors(errors, f ? { file: f } : {}),
131
131
  persistLatencies = (latencies, f = file) => saveModelLatencies(latencies, f ? { file: f } : {}),
132
+ loadPicks = () => (file ? loadModelPicks({ file }) : []),
133
+ persistPicks = (picks) => (file ? saveModelPicks(picks, { file }) : picks),
132
134
  } = {}) {
133
135
  const lastErrorAt = { ...(seedErrors ?? loadModelErrors(file ? { file } : {})) };
134
136
  const latencies = { ...(seedLatencies ?? loadModelLatencies(file ? { file } : {})) };
@@ -144,13 +146,29 @@ export function createAutoSelector({
144
146
  return [...new Set(list)].filter(Boolean);
145
147
  }
146
148
 
149
+ // 勾选集 = auto 候选池白名单:只在勾选的模型里择优;空勾选或勾选中无可用模型时回退全量
150
+ async function pickedPool(list) {
151
+ const picks = loadPicks();
152
+ if (!picks.length) return list;
153
+ const pickedSet = new Set(picks);
154
+ const filtered = list.filter((id) => pickedSet.has(id));
155
+ return filtered.length ? filtered : list;
156
+ }
157
+
147
158
  async function candidates() {
148
- return rankModels(await loadList(), lastErrorAt, { now: now(), cooldownMs, slowCooldownMs, latencies, preferred: getPreferredModel({ file: file ?? undefined }) });
159
+ const list = await loadList();
160
+ const pool = await pickedPool(list);
161
+ return rankModels(pool, lastErrorAt, { now: now(), cooldownMs, slowCooldownMs, latencies, preferred: getPreferredModel({ file: file ?? undefined }) });
149
162
  }
150
163
 
151
164
  async function candidatesFor(requested) {
152
165
  if (!requested) return candidates();
153
166
  const list = await loadList();
167
+ // 显式指定某模型 = 认可它,自动加入勾选集(仅当它是真实上游 free 模型时,避免垃圾 id 污染)
168
+ const picks = loadPicks();
169
+ if (list.includes(requested) && !picks.includes(requested) && persistPicks) {
170
+ await persistPicks([...picks, requested]);
171
+ }
154
172
  const all = list.includes(requested) ? list : [requested, ...list];
155
173
  const others = rankModels(all.filter((id) => id !== requested), lastErrorAt, {
156
174
  now: now(),
package/src/chooser.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // 交互式模型选择器的纯渲染逻辑(便于测试);键盘循环在 bin/mslxdff.js
2
2
 
3
- // items: [{ id, status?, ms?, fail?, current? }];cursor 当前高亮行
3
+ // items: [{ id, status?, ms?, fail?, current?, picked? }];cursor 当前高亮行
4
+ // multi=true 时多选勾选(picked 显示 [x]/[ ]),否则单选默认模型(当前显示 ✓)
4
5
  // 返回行数组(无 ANSI 颜色,纯文本标记,Windows 终端友好)
5
- export function renderChooser(items, cursor = 0) {
6
+ export function renderChooser(items, cursor = 0, { multi = false } = {}) {
6
7
  return items.map((it, i) => {
7
8
  const arrow = i === cursor ? "❯" : " ";
8
- const check = it.current ? " ✓ (current)" : "";
9
+ const check = multi
10
+ ? (it.picked ? " [✓]" : " [ ]")
11
+ : (it.current ? " ✓ (current)" : "");
9
12
  let state = "";
10
13
  if (it.fail) state = ` [fail: ${it.fail}]`;
11
14
  else if (it.ms != null) state = ` [${it.ms}ms]`;
@@ -14,16 +17,19 @@ export function renderChooser(items, cursor = 0) {
14
17
  });
15
18
  }
16
19
 
17
- export function renderChooserHelp() {
18
- return ["", "↑/↓ move · Enter select as default · q/Esc cancel"];
20
+ export function renderChooserHelp(multi = false) {
21
+ return multi
22
+ ? ["", "↑/↓ move · Space toggle pick · Enter save picks · q/Esc cancel"]
23
+ : ["", "↑/↓ move · Enter select as default · q/Esc cancel"];
19
24
  }
20
25
 
21
- // 解析按键:返回 "up" | "down" | "enter" | "cancel" | null(忽略)
26
+ // 解析按键:返回 "up" | "down" | "enter" | "cancel" | "space" | null(忽略)
22
27
  export function parseKey(str) {
23
28
  if (!str) return null;
24
29
  if (str === "\x1b[A" || str === "k") return "up";
25
30
  if (str === "\x1b[B" || str === "j") return "down";
26
31
  if (str === "\r" || str === "\n") return "enter";
32
+ if (str === " " || str === "\x1b[32") return "space"; // 空格(32=0x20 的 ASC 表示)或某些终端 terminator
27
33
  if (str === "\x1b" || str === "q" || str === "\x03") return "cancel";
28
34
  return null;
29
35
  }
package/src/state.js CHANGED
@@ -201,6 +201,19 @@ export function loadPreferredModel({ file = defaultStateFile() } = {}) {
201
201
  return typeof v === "string" && v.trim() ? v.trim() : null;
202
202
  }
203
203
 
204
+ // 常用模型勾选集(auto 候选池白名单):空数组 = 不启用筛选(全量 auto)
205
+ export function loadModelPicks({ file = defaultStateFile() } = {}) {
206
+ const picks = readState(file).modelPicks;
207
+ if (!Array.isArray(picks)) return [];
208
+ return [...new Set(picks.filter((x) => typeof x === "string" && x.trim().length))];
209
+ }
210
+
211
+ export function saveModelPicks(picks, { file = defaultStateFile() } = {}) {
212
+ const list = [...new Set((Array.isArray(picks) ? picks : []).filter((x) => typeof x === "string" && x.trim().length))];
213
+ writeStateImmediate(file, { modelPicks: list });
214
+ return list;
215
+ }
216
+
204
217
  export function savePreferredModel(id, { file = defaultStateFile() } = {}) {
205
218
  writeStateImmediate(file, { preferredModel: String(id || "").trim() });
206
219
  return String(id || "").trim();