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/CHANGELOG.md +98 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +42 -9
- package/dist/core/config.js.map +1 -1
- package/dist/core/types.d.ts +8 -8
- package/dist/core/types.js +8 -8
- package/dist/core/types.js.map +1 -1
- package/dist/repl/loop.d.ts +92 -17
- package/dist/repl/loop.d.ts.map +1 -1
- package/dist/repl/loop.js +458 -96
- package/dist/repl/loop.js.map +1 -1
- package/dist/repl/welcome.d.ts.map +1 -1
- package/dist/repl/welcome.js +33 -28
- package/dist/repl/welcome.js.map +1 -1
- package/package.json +1 -1
package/dist/repl/loop.js
CHANGED
|
@@ -101,7 +101,10 @@ export class REPLLoop {
|
|
|
101
101
|
continue;
|
|
102
102
|
}
|
|
103
103
|
// input layer 自动重置 cancel 计数,无需手动调
|
|
104
|
-
|
|
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
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
173
|
+
case '模型':
|
|
154
174
|
await this.handleModelSwitch();
|
|
155
175
|
return true;
|
|
156
176
|
case 'provider':
|
|
157
|
-
|
|
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 —
|
|
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
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
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
|
|
298
|
+
const saved = this.loadSavedModels();
|
|
272
299
|
const currentModel = currentConfig.model || builtin[0]?.id;
|
|
273
|
-
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
const recents =
|
|
277
|
-
|
|
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(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
this.output.printInfo(`
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const choice = await this.askOnce(chalk.cyan('
|
|
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
|
-
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const k = Number.parseInt(
|
|
313
|
-
|
|
314
|
-
if (!
|
|
315
|
-
this.output.printError(
|
|
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
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
388
|
+
// === 自由输入 id ===
|
|
389
|
+
if (trimmed === '') {
|
|
390
|
+
this.output.printInfo(chalk.gray('(保持当前模型)'));
|
|
391
|
+
return;
|
|
326
392
|
}
|
|
327
|
-
//
|
|
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
|
-
|
|
330
|
-
|
|
489
|
+
this.output.printWarning(`未识别的 thinking 值 "${t}", 按 none 处理。`);
|
|
490
|
+
thinking = 'none';
|
|
331
491
|
}
|
|
332
|
-
|
|
333
|
-
|
|
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.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
364
|
-
*
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
|
|
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
|
|
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
|
-
//
|
|
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\`
|
|
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
|
*/
|