thatgfsj-code 0.2.2 → 0.3.1

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/dist/repl/loop.js CHANGED
@@ -101,7 +101,10 @@ export class REPLLoop {
101
101
  continue;
102
102
  }
103
103
  // input layer 自动重置 cancel 计数,无需手动调
104
- const userInput = result.value;
104
+ // Trim and unwrap accidentally-pasted quoted prompts like `""` or `''`
105
+ // — common when the user copy-pastes from a markdown example.
106
+ let userInput = result.value;
107
+ userInput = userInput.replace(/^["'](.*)["']$/s, '$1').trim();
105
108
  if (!userInput)
106
109
  continue;
107
110
  const handled = await this.handleCommand(userInput);
@@ -114,49 +117,73 @@ export class REPLLoop {
114
117
  }
115
118
  /**
116
119
  * Handle built-in commands
120
+ *
121
+ * Accepts bare words, slash-prefixed forms (`/model`), AND Chinese aliases
122
+ * (`/模型`, `/提供商`, `/帮助`, `/清除`, `/退出`, `/历史`, `/工具`).
123
+ * The Chinese forms mirror thatgfsj-code@1.0.4 behaviour.
117
124
  */
118
125
  async handleCommand(input) {
119
- // Allow both bare words (mirrors REPL UI) and /slash-prefixed forms
120
- // (legacy / 1.0.4 habit), e.g. `/model` or `/provider`.
121
- const cmd = input.replace(/^\//, '').toLowerCase().trim();
126
+ // Strip leading slash and full-width slash; trim whitespace; lowercase.
127
+ const cmd = input
128
+ .replace(/^\//, '')
129
+ .replace(/^//, '')
130
+ .toLowerCase()
131
+ .trim();
122
132
  switch (cmd) {
133
+ // ── Session control ────────────────────────────────────────────
123
134
  case 'exit':
124
135
  case 'quit':
136
+ case '退出':
125
137
  case '\\x03':
126
138
  this.output.printInfo('\n👋 Goodbye!');
127
139
  this.running = false;
128
140
  return true;
129
141
  case 'clear':
142
+ case '清除':
143
+ case '清屏':
130
144
  this.output.clear();
131
145
  this.output.printBanner();
132
146
  return true;
147
+ // ── Inspection ─────────────────────────────────────────────────
133
148
  case 'context':
149
+ case '上下文':
134
150
  this.showContext();
135
151
  return true;
136
152
  case 'history':
153
+ case '历史':
137
154
  this.showHistory();
138
155
  return true;
139
156
  case 'tools':
157
+ case '工具':
140
158
  this.showTools();
141
159
  return true;
142
160
  case 'help':
161
+ case '帮助':
143
162
  this.output.printHelp();
144
163
  return true;
145
164
  case 'models':
146
- // Bare read-only listing (legacy alias for "/provider")
147
- this.showProviders();
148
- return true;
149
165
  case 'providers':
166
+ case '模型列表':
167
+ case '提供商':
168
+ // read-only listing
150
169
  this.showProviders();
151
170
  return true;
171
+ // ── Switching (interactive picker, actually mutates config) ────
152
172
  case 'model':
153
- // Interactive picker — actually switches the model
173
+ case '模型':
154
174
  await this.handleModelSwitch();
155
175
  return true;
156
176
  case 'provider':
157
- // Interactive picker — switches provider, then asks for model
177
+ case '提供商切换':
178
+ case '切换':
158
179
  await this.handleProviderSwitch();
159
180
  return true;
181
+ case 'edit':
182
+ case '修改':
183
+ // /edit [n] — directly jump to the edit wizard. With no arg,
184
+ // shows the saved-model list first (same as /model).
185
+ await this.runEditShortcut();
186
+ return true;
160
187
  default:
161
188
  return false;
162
189
  }
@@ -248,19 +275,19 @@ export class REPLLoop {
248
275
  this.output.printInfo(' ollama - 本地模型');
249
276
  }
250
277
  /**
251
- * /model — interactive model picker for the currently-active provider.
252
- * Persists the choice to ~/.thatgfsj/config.json and updates the running
253
- * AIEngine so subsequent requests use the new model immediately. Also
254
- * appends the picked model id to ~/.thatgfsj/models.json so it shows up
255
- * as a recent option across sessions (mirrors the 1.0.4 behaviour).
278
+ * /model — main entry point for model management.
256
279
  *
257
- * The user can:
258
- * 1. Enter the numbered index of a built-in choice
259
- * 2. Paste an exact model id (matched against the provider's model list,
260
- * recent history, OR the current model)
261
- * 3. Type any free-form model id to switch to a custom model
262
- * 4. Press Enter with empty input to keep the current model
263
- * 5. Press Ctrl+C to cancel
280
+ * 0.3.0 UX (per user feedback):
281
+ * - 先看到的是**已经保存的模型** (从 ~/.thatgfsj/models.json 读),
282
+ * 而不是内置 provider 列表。
283
+ * - 列表末尾是 `+ 添加新模型` 选项 → 触发完整向导
284
+ * (provider api_key baseUrl(可选,用于中转站) model_name
285
+ * context length (M) thinking effort)。
286
+ * - 顶部有 `edit` 模式,可以重新修改任意已保存模型。
287
+ * - 选某条 saved model 后立即切换并退出;按 Enter 不变。
288
+ * - 兼容 0.2.x / 1.0.4 老 history:
289
+ * - 字符串数组会透明迁移成 {id,...} 形式。
290
+ * - 内置模型 (`Qwen3-32B` 之类) 也可选择,作为「saved + builtin」展示。
264
291
  */
265
292
  async handleModelSwitch() {
266
293
  if (!this.ai || !this.session)
@@ -268,30 +295,53 @@ export class REPLLoop {
268
295
  const currentConfig = this.ai.getConfig();
269
296
  const provider = currentConfig.provider || 'siliconflow';
270
297
  const builtin = WelcomeScreen.getModelsForProvider(provider);
271
- const history = this.loadModelHistory();
298
+ const saved = this.loadSavedModels();
272
299
  const currentModel = currentConfig.model || builtin[0]?.id;
273
- // Build the displayed list: recent history first (newest), then
274
- // built-in models (dedup), with a trailing "+ add new" entry.
275
- const RECENT_LIMIT = 5;
276
- const recents = history.filter(id => id !== currentModel).slice(0, RECENT_LIMIT);
277
- this.output.printHeader(`🤖 /model 切换模型 (provider: ${provider})`);
300
+ const RECENT_LIMIT = 8;
301
+ // Most-recent first (exclude the current one in the listing — it's
302
+ // surfaced separately as the active row).
303
+ const recents = [...saved]
304
+ .sort((a, b) => (b.addedAt ?? 0) - (a.addedAt ?? 0))
305
+ .filter(m => m.id !== currentModel)
306
+ .slice(0, RECENT_LIMIT);
307
+ this.output.printHeader(`🤖 /model — 切换 / 管理模型 (provider: ${provider})`);
308
+ // 当前
309
+ if (currentModel) {
310
+ const curSaved = saved.find(m => m.id.toLowerCase() === currentModel.toLowerCase());
311
+ const meta = curSaved && (curSaved.ctx || curSaved.thinking)
312
+ ? chalk.gray(` ctx=${curSaved.ctx ?? '-'}M thinking=${curSaved.thinking ?? '-'}`)
313
+ : '';
314
+ this.output.printInfo(chalk.green(` ⮕ 当前: ${currentModel}`) + meta);
315
+ }
316
+ // 已保存
278
317
  if (recents.length > 0) {
279
- this.output.printInfo(chalk.yellow(' 最近使用:'));
280
- recents.forEach((id, idx) => {
281
- const mark = id === currentModel ? ' ✓' : '';
282
- this.output.printInfo(` r${idx + 1}. ${chalk.cyan(id)}${mark}`);
318
+ this.output.printInfo('');
319
+ this.output.printInfo(chalk.yellow(' 已保存模型:'));
320
+ recents.forEach((m, idx) => {
321
+ const meta = [];
322
+ if (m.ctx)
323
+ meta.push(`ctx=${m.ctx}M`);
324
+ if (m.thinking)
325
+ meta.push(`think=${m.thinking}`);
326
+ const extra = meta.length ? chalk.gray(` [${meta.join(' · ')}]`) : '';
327
+ this.output.printInfo(` ${(idx + 1).toString().padStart(2)}. ${chalk.cyan(m.id)}${extra}`);
283
328
  });
284
329
  }
330
+ else {
331
+ this.output.printInfo('');
332
+ this.output.printInfo(chalk.gray(' (尚无保存的模型)'));
333
+ }
334
+ // 添加 / 编辑 提示
285
335
  this.output.printInfo('');
286
- this.output.printInfo(' 内置模型:');
287
- builtin.forEach((m, idx) => {
288
- const mark = m.id === currentModel ? ' ✓' : '';
289
- this.output.printInfo(` ${(idx + 1).toString().padStart(2)}. ${m.name.padEnd(28)} ${chalk.gray(m.id)}${mark}`);
290
- });
291
- this.output.printInfo('');
292
- this.output.printInfo(chalk.gray('输入编号(r1-r' + recents.length + ' / 1-' + builtin.length +
293
- ')、完整 model id、或直接贴一个新 id。回车保持当前。'));
294
- const choice = await this.askOnce(chalk.cyan('model > ') + (currentModel ?? ''));
336
+ this.output.printInfo(chalk.yellow(' 操作:'));
337
+ this.output.printInfo(` a. ${chalk.cyan('+ 添加新模型')} (完整向导: provider → key → url → 名称 → ctx → thinking)`);
338
+ if (recents.length > 0) {
339
+ this.output.printInfo(` e. ${chalk.cyan('edit <编号>')} (修改已保存模型的 ctx / thinking / 备注)`);
340
+ }
341
+ if (builtin.length > 0) {
342
+ this.output.printInfo(chalk.gray(` 或输入编号 1-${recents.length} 切换到对应已保存模型;直接回车保持当前。`));
343
+ }
344
+ const choice = await this.askOnce(chalk.cyan('\nmodel > ') + (recents[0]?.id ? `${recents[0].id} (1) / a / e / id` : 'a / id'));
295
345
  if (choice === null) {
296
346
  this.output.printInfo(chalk.gray('(已取消,保持当前模型)'));
297
347
  return;
@@ -300,92 +350,330 @@ export class REPLLoop {
300
350
  this.output.printInfo(chalk.gray('(保持当前模型)'));
301
351
  return;
302
352
  }
303
- let pickedId = null;
304
- // Recent-history shortcut: "r1" .. "r<N>"
305
- const recentMatch = /^r(\d+)$/i.exec(choice);
306
- if (recentMatch) {
307
- const k = Number.parseInt(recentMatch[1], 10) - 1;
308
- pickedId = recents[k] ?? null;
309
- }
310
- // Numeric index into builtin
311
- else if (/^\d+$/.test(choice)) {
312
- const k = Number.parseInt(choice, 10) - 1;
313
- pickedId = builtin[k]?.id ?? null;
314
- if (!pickedId) {
315
- this.output.printError(`编号超出范围: "${choice}". 保持当前模型。`);
353
+ const trimmed = choice.trim().toLowerCase();
354
+ // === 添加新模型 ===
355
+ if (trimmed === 'a' || trimmed === '+' || trimmed === 'add') {
356
+ await this.runAddModelWizard();
357
+ return;
358
+ }
359
+ // === 编辑已保存 ===
360
+ const editMatch = /^e(?:dit)?\s*(\d+)$/.exec(trimmed);
361
+ if (editMatch) {
362
+ const k = Number.parseInt(editMatch[1], 10) - 1;
363
+ const target = recents[k];
364
+ if (!target) {
365
+ this.output.printError('编号超出范围。');
316
366
  return;
317
367
  }
368
+ await this.runEditModelWizard(target);
369
+ return;
318
370
  }
319
- // Exact id match against builtin
320
- else if (builtin.find(m => m.id === choice)) {
321
- pickedId = choice;
371
+ // === 数字 已保存模型中的第 k 个 ===
372
+ if (/^\d+$/.test(trimmed)) {
373
+ const k = Number.parseInt(trimmed, 10) - 1;
374
+ const target = recents[k];
375
+ if (!target) {
376
+ this.output.printError(`编号超出范围 (1-${recents.length} 或 a / edit).`);
377
+ return;
378
+ }
379
+ await this.applyModelSwitch(target.id);
380
+ this.appendSavedModel(target);
381
+ const meta = target.ctx || target.thinking
382
+ ? chalk.gray(` (ctx=${target.ctx ?? '-'}M, think=${target.thinking ?? '-'})`)
383
+ : '';
384
+ this.output.printSuccess(`模型已切换为: ${target.id}${meta}`);
385
+ this.session.addMessage('system', `[system: model switched to ${target.id}. Continue with the task.]`);
386
+ return;
322
387
  }
323
- // Exact id match against recent history
324
- else if (recents.includes(choice)) {
325
- pickedId = choice;
388
+ // === 自由输入 id ===
389
+ if (trimmed === '') {
390
+ this.output.printInfo(chalk.gray('(保持当前模型)'));
391
+ return;
326
392
  }
327
- // Free-form treat as a custom model id
393
+ // 把裸输入当成 model id(兼容老的 /model Qwen3-32B 行为)
394
+ await this.applyModelSwitch(trimmed);
395
+ this.appendSavedModel({ id: trimmed });
396
+ this.output.printSuccess(`模型已切换为: ${trimmed} (新保存)`);
397
+ this.session.addMessage('system', `[system: model switched to ${trimmed}. Continue with the task.]`);
398
+ }
399
+ /**
400
+ * Add-model wizard (0.3.0+):
401
+ * Step 1: Provider (内置 / custom_openai / custom_anthropic)
402
+ * Step 2: API Key (内置:给注册链接;custom:无)
403
+ * Step 3: Base URL (仅 custom_*;内置跳过)
404
+ * Step 4: 模型名称 (自由输入)
405
+ * Step 5: 上下文长度 (MiB,数字,默认 8)
406
+ * Step 6: 思考强度 (none / low / medium / high / max,默认 none)
407
+ *
408
+ * 完成后:写入 ~/.thatgfsj/models.json,顺便把新模型切到当前,
409
+ * 更新 AIEngine + 写入 ~/.thatgfsj/config.json model 字段。
410
+ */
411
+ async runAddModelWizard() {
412
+ if (!this.ai)
413
+ return;
414
+ this.output.printHeader('➕ 添加新模型');
415
+ // Step 1: provider
416
+ const providers = [
417
+ { id: 'siliconflow', name: '硅基流动 (SiliconFlow, OpenAI 兼容)' },
418
+ { id: 'openai', name: 'OpenAI' },
419
+ { id: 'anthropic', name: 'Anthropic (Claude, Anthropic Messages)' },
420
+ { id: 'minimax', name: 'MiniMax (M3)' },
421
+ { id: 'gemini', name: 'Google Gemini' },
422
+ { id: 'kimi', name: 'Kimi (Moonshot AI)' },
423
+ { id: 'deepseek', name: 'DeepSeek' },
424
+ { id: 'ernie', name: '文心一言 (ERNIE)' },
425
+ { id: 'custom_openai', name: '自定义 → OpenAI 兼容 (中转站)' },
426
+ { id: 'custom_anthropic', name: '自定义 → Anthropic 兼容 (中转站)' },
427
+ ];
428
+ this.output.printInfo(' 步骤 1/6: 选择 provider');
429
+ providers.forEach((p, i) => {
430
+ this.output.printInfo(` ${(i + 1).toString().padStart(2)}. ${p.name}`);
431
+ });
432
+ const provIdx = await this.askChoice('选择编号 (1-10): ', 1, 1, providers.length);
433
+ if (provIdx === null)
434
+ return;
435
+ const provider = providers[provIdx - 1];
436
+ // Step 2: api key
437
+ let apiKey = '';
438
+ this.output.printInfo(`\n 步骤 2/6: API Key (provider: ${provider.name})`);
439
+ if (provider.id.startsWith('custom_')) {
440
+ this.output.printInfo(' (中转站模式: 这一步可选,直接回车表示使用 baseUrl 自身的鉴权头)');
441
+ }
442
+ const keyRaw = await this.askOnce(' API Key (输入或回车跳过): ');
443
+ if (keyRaw === null)
444
+ return;
445
+ apiKey = keyRaw.trim();
446
+ // Step 3: baseUrl (only custom_*)
447
+ let baseUrl;
448
+ if (provider.id === 'custom_openai' || provider.id === 'custom_anthropic') {
449
+ this.output.printInfo('\n 步骤 3/6: baseUrl (中转站 URL)');
450
+ this.output.printInfo(chalk.gray(' 例如: https://api.example.com/v1'));
451
+ const url = await this.askOnce(' baseUrl: ');
452
+ if (url === null || !url.trim()) {
453
+ this.output.printError('需要 baseUrl 才能保存自定义 provider。');
454
+ return;
455
+ }
456
+ baseUrl = url.trim();
457
+ }
458
+ // Step 4: model name
459
+ this.output.printInfo('\n 步骤 4/6: 模型名称');
460
+ const modelId = await this.askOnce(' 模型 id (例如 gpt-4.1-mini, claude-sonnet-4-5): ');
461
+ if (modelId === null || !modelId.trim()) {
462
+ this.output.printError('模型名称不能为空。已取消。');
463
+ return;
464
+ }
465
+ const model = modelId.trim();
466
+ // Step 5: context length
467
+ this.output.printInfo('\n 步骤 5/6: 上下文长度 (MiB)');
468
+ this.output.printInfo(chalk.gray(' 常见的 8 / 32 / 128 / 200;默认 8'));
469
+ const ctxRaw = await this.askOnce(' ctx (M): ');
470
+ let ctx;
471
+ if (ctxRaw !== null && ctxRaw.trim()) {
472
+ const n = Number.parseInt(ctxRaw.trim(), 10);
473
+ if (Number.isFinite(n) && n > 0)
474
+ ctx = n;
475
+ }
476
+ if (!ctx)
477
+ ctx = 8;
478
+ // Step 6: thinking effort
479
+ this.output.printInfo('\n 步骤 6/6: 思考强度');
480
+ this.output.printInfo(chalk.gray(' none / low / medium / high / max (默认 none)'));
481
+ const thinkRaw = await this.askOnce(' thinking: ');
482
+ let thinking;
483
+ const t = (thinkRaw ?? '').trim().toLowerCase();
484
+ if (t === 'low' || t === 'medium' || t === 'high' || t === 'max')
485
+ thinking = t;
486
+ else if (t === 'none' || t === '')
487
+ thinking = 'none';
328
488
  else {
329
- pickedId = choice;
330
- this.output.printInfo(chalk.gray(`(未识别,按自定义模型处理: ${pickedId})`));
489
+ this.output.printWarning(`未识别的 thinking 值 "${t}", 按 none 处理。`);
490
+ thinking = 'none';
331
491
  }
332
- if (!pickedId) {
333
- this.output.printError(`未识别: "${choice}". 保持当前模型。`);
492
+ // 持久化 + 切换
493
+ const saved = {
494
+ id: model,
495
+ addedAt: Date.now(),
496
+ ctx,
497
+ thinking,
498
+ note: provider.id,
499
+ };
500
+ this.appendSavedModel(saved);
501
+ // 同步切到当前模型,顺带把 provider/baseUrl 写入 config.json
502
+ await this.applyProviderSwitchInternal(provider.id, baseUrl, apiKey);
503
+ await this.applyModelSwitch(model);
504
+ this.output.printSuccess(`已保存并切换到: ${model} (ctx=${ctx}M, thinking=${thinking}, provider=${provider.id}${baseUrl ? ', baseUrl=' + baseUrl : ''})`);
505
+ this.session?.addMessage('system', `[system: model ${model} added (ctx=${ctx}M, thinking=${thinking}, provider=${provider.id}). Continue with the task.]`);
506
+ }
507
+ /**
508
+ * Edit-model wizard (0.3.0+): pick a saved model and update ctx /
509
+ * thinking / note without leaving the REPL.
510
+ */
511
+ async runEditModelWizard(target) {
512
+ this.output.printHeader(`✏️ 修改已保存模型: ${target.id}`);
513
+ this.output.printInfo(chalk.gray(` provider=${target.note ?? '?'}, ctx=${target.ctx ?? '?'}M, thinking=${target.thinking ?? '?'}`));
514
+ this.output.printInfo(' 输入新值;直接回车保留旧值;输入 `-` 清空字段');
515
+ this.output.printInfo('');
516
+ const ctxRaw = await this.askOnce(` ctx (M) [${target.ctx ?? '?'}]: `);
517
+ if (ctxRaw === null)
334
518
  return;
519
+ const ctxTrim = ctxRaw.trim();
520
+ let newCtx = target.ctx;
521
+ if (ctxTrim === '-')
522
+ newCtx = undefined;
523
+ else if (ctxTrim !== '') {
524
+ const n = Number.parseInt(ctxTrim, 10);
525
+ if (Number.isFinite(n) && n > 0)
526
+ newCtx = n;
527
+ else
528
+ this.output.printWarning(`ctx 未识别 ("${ctxTrim}"), 保留 ${target.ctx}。`);
335
529
  }
336
- await this.applyModelSwitch(pickedId);
337
- this.appendModelHistory(pickedId);
338
- const builtinMatch = builtin.find(m => m.id === pickedId);
339
- const display = builtinMatch
340
- ? `${builtinMatch.name} (${builtinMatch.id})`
341
- : `${pickedId} (自定义)`;
342
- this.output.printSuccess(`模型已切换为: ${display}`);
343
- this.session.addMessage('system', `[system: model switched to ${pickedId}. Continue with the task.]`);
530
+ const thinkRaw = await this.askOnce(` thinking [${target.thinking ?? 'none'}]: `);
531
+ if (thinkRaw === null)
532
+ return;
533
+ const thinkTrim = thinkRaw.trim().toLowerCase();
534
+ let newThinking = target.thinking;
535
+ if (thinkTrim === '-')
536
+ newThinking = undefined;
537
+ else if (['none', 'low', 'medium', 'high', 'max'].includes(thinkTrim))
538
+ newThinking = thinkTrim;
539
+ else if (thinkTrim !== '')
540
+ this.output.printWarning(`thinking 未识别 ("${thinkRaw}"), 保留 ${target.thinking ?? 'none'}。`);
541
+ const noteRaw = await this.askOnce(` note [${target.note ?? ''}]: `);
542
+ if (noteRaw === null)
543
+ return;
544
+ const noteTrim = noteRaw.trim();
545
+ let newNote = target.note;
546
+ if (noteTrim === '-')
547
+ newNote = undefined;
548
+ else if (noteTrim !== '')
549
+ newNote = noteTrim;
550
+ const updated = {
551
+ ...target,
552
+ ctx: newCtx,
553
+ thinking: newThinking,
554
+ note: newNote,
555
+ };
556
+ this.replaceSavedModel(target.id, updated);
557
+ this.output.printSuccess(`已更新: ${target.id}`);
558
+ this.output.printInfo(chalk.gray(` ctx=${newCtx ?? '-'}M, thinking=${newThinking ?? '-'}, note=${newNote ?? '-'}`));
344
559
  }
345
560
  /**
346
- * Read ~/.thatgfsj/models.json — a flat array of model ids the user has
347
- * previously switched to. Order is "most recent last" in the file but the
348
- * caller treats it as time-ordered.
561
+ * Persist a provider choice + custom baseUrl + apiKey into
562
+ * ~/.thatgfsj/config.json and reload AIEngine config. Differs from
563
+ * applyProviderSwitch in that this also writes a custom baseUrl.
564
+ */
565
+ async applyProviderSwitchInternal(providerId, baseUrl, apiKey) {
566
+ if (!this.ai)
567
+ return;
568
+ const cfg = await this.readPersistedConfig();
569
+ cfg.provider = providerId;
570
+ if (baseUrl)
571
+ cfg.baseUrl = baseUrl;
572
+ if (apiKey)
573
+ cfg.apiKey = apiKey;
574
+ await this.persistConfig(cfg);
575
+ const next = await ConfigManager.load();
576
+ this.ai.updateConfig(next);
577
+ }
578
+ /**
579
+ * Read ~/.thatgfsj/models.json — a list of saved models the user has
580
+ * previously switched to or added. Starting from 0.3.0 each entry is
581
+ * `{ id, addedAt, ctx?, thinking?, note? }`. Older releases stored a
582
+ * flat array of strings; we transparently migrate that on read.
349
583
  */
350
584
  loadModelHistory() {
585
+ // Kept for backward compat with existing call sites. Returns just the
586
+ // model ids, newest last.
587
+ return this.loadSavedModels().map(m => m.id);
588
+ }
589
+ /**
590
+ * Read ~/.thatgfsj/models.json as a list of SavedModel entries.
591
+ * Migrates v0.2.x's `["id1","id2"]` shape into 0.3.0's `{id,...}` shape
592
+ * on the fly, leaving the on-disk file untouched until a write happens.
593
+ */
594
+ loadSavedModels() {
351
595
  try {
352
596
  const p = join(homedir(), '.thatgfsj', 'models.json');
353
597
  if (!existsSync(p))
354
598
  return [];
355
599
  const raw = JSON.parse(readFileSync(p, 'utf-8'));
356
- return Array.isArray(raw) ? raw.filter(x => typeof x === 'string') : [];
600
+ if (!Array.isArray(raw))
601
+ return [];
602
+ // v0.3.0+: [{id, ctx?, thinking?}, ...]
603
+ const looksLikeObjects = raw.length > 0 && raw[0] && typeof raw[0] === 'object';
604
+ if (looksLikeObjects) {
605
+ return raw
606
+ .filter(x => x && typeof x === 'object' && typeof x.id === 'string')
607
+ .map((x) => ({
608
+ id: x.id,
609
+ addedAt: typeof x.addedAt === 'number' ? x.addedAt : 0,
610
+ ctx: typeof x.ctx === 'number' ? x.ctx : undefined,
611
+ thinking: typeof x.thinking === 'string' ? x.thinking : undefined,
612
+ note: typeof x.note === 'string' ? x.note : undefined,
613
+ }));
614
+ }
615
+ // v0.2.x: ["id1", "id2", ...] — migrate in memory only.
616
+ return raw
617
+ .filter(x => typeof x === 'string')
618
+ .map(id => ({ id: id, addedAt: 0 }));
357
619
  }
358
620
  catch {
359
621
  return [];
360
622
  }
361
623
  }
362
624
  /**
363
- * Append a model id to ~/.thatgfsj/models.json, deduping the trailing
364
- * entry. Mirrors the 1.0.4 behaviour: history is "things you switched to".
625
+ * Legacy helper: append a model id to ~/.thatgfsj/models.json.
626
+ * New callers should prefer `appendSavedModel` so we don't lose
627
+ * ctx / thinking metadata.
365
628
  */
366
629
  appendModelHistory(modelId) {
630
+ this.appendSavedModel({ id: modelId });
631
+ }
632
+ /**
633
+ * Persist a SavedModel into ~/.thatgfsj/models.json, replacing any
634
+ * existing entry with the same id (case-insensitive). The original
635
+ * casing of the entry is preserved — i.e. if the user already has
636
+ * `Qwen3-32B` saved and types `qwen3-32b`, the canonical `Qwen3-32B`
637
+ * casing is kept. Newest entries go at the tail of the file.
638
+ */
639
+ appendSavedModel(model) {
367
640
  try {
368
641
  const dir = join(homedir(), '.thatgfsj');
369
642
  if (!existsSync(dir))
370
643
  mkdirSync(dir, { recursive: true });
371
644
  const p = join(dir, 'models.json');
372
- let history = [];
373
- if (existsSync(p)) {
374
- try {
375
- history = JSON.parse(readFileSync(p, 'utf-8'));
376
- }
377
- catch {
378
- history = [];
379
- }
380
- }
381
- if (!Array.isArray(history))
382
- history = [];
383
- const filtered = history.filter(x => typeof x === 'string' && x !== modelId);
384
- filtered.push(modelId);
645
+ const list = this.loadSavedModels();
646
+ const existing = list.find(m => m.id.toLowerCase() === model.id.toLowerCase());
647
+ const filtered = list.filter(m => m.id.toLowerCase() !== model.id.toLowerCase());
648
+ const canonical = {
649
+ ...(existing ?? {}),
650
+ ...model,
651
+ id: existing?.id ?? model.id, // canonical casing wins
652
+ addedAt: Date.now(),
653
+ };
654
+ filtered.push(canonical);
385
655
  writeFileSync(p, JSON.stringify(filtered, null, 2));
386
656
  }
387
657
  catch {
388
- // best-effort: history persistence is not critical
658
+ // best-effort
659
+ }
660
+ }
661
+ /** Replace an existing saved model entry (matched by id). */
662
+ replaceSavedModel(id, patch) {
663
+ try {
664
+ const dir = join(homedir(), '.thatgfsj');
665
+ if (!existsSync(dir))
666
+ mkdirSync(dir, { recursive: true });
667
+ const p = join(dir, 'models.json');
668
+ const list = this.loadSavedModels();
669
+ const idx = list.findIndex(m => m.id.toLowerCase() === id.toLowerCase());
670
+ if (idx < 0)
671
+ return;
672
+ list[idx] = { ...list[idx], ...patch, id: list[idx].id, addedAt: list[idx].addedAt };
673
+ writeFileSync(p, JSON.stringify(list, null, 2));
674
+ }
675
+ catch {
676
+ // best-effort
389
677
  }
390
678
  }
391
679
  /**
@@ -405,6 +693,8 @@ export class REPLLoop {
405
693
  { id: 'kimi', name: 'Kimi (Moonshot AI)', envKey: 'KIMI_API_KEY' },
406
694
  { id: 'deepseek', name: 'DeepSeek', envKey: 'DEEPSEEK_API_KEY' },
407
695
  { id: 'ernie', name: '文心一言 (ERNIE)', envKey: 'ERNIE_API_KEY' },
696
+ { id: 'custom_openai', name: '自定义 → OpenAI 兼容 (中转站)', envKey: 'CUSTOM_API_KEY' },
697
+ { id: 'custom_anthropic', name: '自定义 → Anthropic 兼容 (中转站)', envKey: 'CUSTOM_API_KEY' },
408
698
  ];
409
699
  this.output.printHeader('🌐 /provider — 切换提供商');
410
700
  providers.forEach((p, idx) => {
@@ -429,7 +719,22 @@ export class REPLLoop {
429
719
  this.output.printInfo(chalk.gray('(同一个 provider,无需切换)'));
430
720
  return;
431
721
  }
432
- // 校验 API Key 存在(从 env ~/.thatgfsj/config.json)
722
+ // 自定义 provider 走完整向导,内置 provider 只切 + 提示 key
723
+ if (pickedProvider.id.startsWith('custom_')) {
724
+ this.output.printInfo(chalk.cyan(`\n 切换到 ${pickedProvider.id},需要 baseUrl:`));
725
+ const url = await this.askOnce(' baseUrl (例如 https://api.example.com/v1): ');
726
+ if (!url || !url.trim()) {
727
+ this.output.printError('需要 baseUrl,已取消。');
728
+ return;
729
+ }
730
+ const keyRaw = await this.askOnce(` API Key (可回车跳过,env ${pickedProvider.envKey} 也行): `);
731
+ await this.applyProviderSwitchInternal(pickedProvider.id, url.trim(), (keyRaw ?? '').trim());
732
+ this.output.printSuccess(`provider 已切换为: ${pickedProvider.name} (${pickedProvider.id})`);
733
+ this.session.addMessage('system', `[system: provider switched to ${pickedProvider.id} (baseUrl=${url.trim()}).]`);
734
+ await this.handleModelSwitch();
735
+ return;
736
+ }
737
+ // 内置 provider
433
738
  const envHasKey = !!process.env[pickedProvider.envKey];
434
739
  let cfgHasKey = false;
435
740
  try {
@@ -441,15 +746,55 @@ export class REPLLoop {
441
746
  }
442
747
  catch { }
443
748
  if (!envHasKey && !cfgHasKey) {
444
- this.output.printWarning(`未检测到 ${pickedProvider.envKey}。运行 \`gfcode init\` 设置,或 export ${pickedProvider.envKey}=... 后重试。`);
445
- // 仍然继续切换,但 applyModelSwitch 内部会捕获到没有 key
749
+ this.output.printWarning(`未检测到 ${pickedProvider.envKey}。运行 \`gfcode init\` 设置,或 export ${pickedProvider.envKey}=... 后重试。`);
446
750
  }
447
751
  await this.applyProviderSwitch(pickedProvider.id);
448
752
  this.output.printSuccess(`provider 已切换为: ${pickedProvider.name} (${pickedProvider.id})`);
449
- // 自动跟进选模型
450
753
  this.session.addMessage('system', `[system: provider switched to ${pickedProvider.id}. Picking a new model…]`);
451
754
  await this.handleModelSwitch();
452
755
  }
756
+ /**
757
+ * /edit [n] — short-hand for the edit wizard.
758
+ * `/edit` → list saved models, ask which to edit.
759
+ * `/edit 1` → jump to wizard for saved model #1.
760
+ * `/edit Qwen3-32B` → jump to wizard for the model whose id matches.
761
+ */
762
+ async runEditShortcut() {
763
+ if (!this.ai)
764
+ return;
765
+ const saved = this.loadSavedModels().sort((a, b) => (b.addedAt ?? 0) - (a.addedAt ?? 0));
766
+ if (saved.length === 0) {
767
+ this.output.printWarning('没有已保存的模型。先用 /model 添加几条。');
768
+ return;
769
+ }
770
+ this.output.printHeader('✏️ /edit — 修改已保存模型');
771
+ saved.forEach((m, i) => {
772
+ const meta = [];
773
+ if (m.ctx)
774
+ meta.push(`ctx=${m.ctx}M`);
775
+ if (m.thinking)
776
+ meta.push(`think=${m.thinking}`);
777
+ const extra = meta.length ? chalk.gray(` [${meta.join(' · ')}]`) : '';
778
+ this.output.printInfo(` ${(i + 1).toString().padStart(2)}. ${chalk.cyan(m.id)}${extra}`);
779
+ });
780
+ this.output.printInfo('');
781
+ const arg = await this.askOnce(' 编号或完整 id: ');
782
+ if (arg === null)
783
+ return;
784
+ const trimmed = arg.trim();
785
+ let target = null;
786
+ if (/^\d+$/.test(trimmed)) {
787
+ target = saved[Number.parseInt(trimmed, 10) - 1] ?? null;
788
+ }
789
+ else {
790
+ target = saved.find(m => m.id.toLowerCase() === trimmed.toLowerCase()) ?? null;
791
+ }
792
+ if (!target) {
793
+ this.output.printError(`未识别: "${trimmed}".`);
794
+ return;
795
+ }
796
+ await this.runEditModelWizard(target);
797
+ }
453
798
  /**
454
799
  * Persist a model choice and update the running AIEngine.
455
800
  */
@@ -545,6 +890,23 @@ export class REPLLoop {
545
890
  });
546
891
  });
547
892
  }
893
+ /**
894
+ * Validate-and-collect a numeric choice within `[min..max]`. Accepts
895
+ * empty input as `defaultValue`. Returns `null` on Ctrl+C.
896
+ */
897
+ async askChoice(prefix, defaultValue, min, max) {
898
+ while (true) {
899
+ const raw = await this.askOnce(prefix);
900
+ if (raw === null)
901
+ return null;
902
+ if (raw === '')
903
+ return defaultValue;
904
+ const n = Number.parseInt(raw, 10);
905
+ if (Number.isInteger(n) && n >= min && n <= max)
906
+ return n;
907
+ this.output.printWarning(`请输入 ${min}-${max} 之间的整数。`);
908
+ }
909
+ }
548
910
  /**
549
911
  * Stop the REPL
550
912
  */