mocode-ai 0.4.5 → 0.4.6
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/README.md +219 -219
- package/dist/config/index.js +2 -1
- package/dist/config/presets.js +177 -0
- package/dist/llm/index.js +5 -1
- package/dist/memory/reflect.js +5 -2
- package/dist/repl/index.js +211 -3
- package/dist/tools/builtins/ask-human.js +70 -43
- package/dist/ui/batch.js +21 -4
- package/dist/ui/diff.js +41 -4
- package/dist/ui/intervention.js +59 -19
- package/dist/ui/layout.js +258 -59
- package/dist/ui/theme.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* 多模型预设(`/model save <name>` 保存的命名配置)的纯 I/O 叶子。
|
|
6
|
+
*
|
|
7
|
+
* 存储:每个预设一个独立 JSON 文件 `~/.mocode/models/<name>.json`。
|
|
8
|
+
* - 选用 per-file 而非单一 index.json:写不需要读-合并-写整本,天然原子(单文件 rename),
|
|
9
|
+
* 符合 memory/store.ts 的惯例;并发写多个预设互不打架。
|
|
10
|
+
* - 路径前缀与 CONFIG_PATH 共享 `~/.mocode/`,保证权限/位置一致。
|
|
11
|
+
*
|
|
12
|
+
* 不读 process.env、不触发 config 单例初始化——repl(/model 子命令)与 commands/config.ts
|
|
13
|
+
* 之外的脚本都能安全 import。错误一律抛(写失败属异常路径,调用方决定怎么提示用户)。
|
|
14
|
+
*
|
|
15
|
+
* 命名约束:[a-zA-Z0-9_-]{1,32}。理由:
|
|
16
|
+
* - 排除路径分隔符与 .. 防止越权写文件。
|
|
17
|
+
* - 与 dotenv key 风格一致,便于未来扩展到同名 env override。
|
|
18
|
+
* - 32 位上限防极长名撑爆文件名系统。
|
|
19
|
+
*/
|
|
20
|
+
/** 预设目录:`~/.mocode/models/`(按需创建)。 */
|
|
21
|
+
export const MODELS_DIR = path.join(os.homedir(), '.mocode', 'models');
|
|
22
|
+
const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
|
|
23
|
+
/** 名字是否合法(调用方复用,避免在多处重复同一正则)。 */
|
|
24
|
+
export function isValidPresetName(name) {
|
|
25
|
+
return NAME_RE.test(name);
|
|
26
|
+
}
|
|
27
|
+
/** 把合法的 name 拼成文件路径;非法 name 抛错(路径穿越防护的第二道)。 */
|
|
28
|
+
function filePathFor(name) {
|
|
29
|
+
if (!isValidPresetName(name)) {
|
|
30
|
+
throw new Error(`非法预设名: ${JSON.stringify(name)}(仅允许 [a-zA-Z0-9_-]{1,32})`);
|
|
31
|
+
}
|
|
32
|
+
return path.join(MODELS_DIR, `${name}.json`);
|
|
33
|
+
}
|
|
34
|
+
/** 把磁盘上的 raw JSON 解析并校验为 ModelPreset;非法字段抛错。 */
|
|
35
|
+
function parsePreset(raw) {
|
|
36
|
+
const obj = JSON.parse(raw);
|
|
37
|
+
const { name, baseURL, apiKey, model, contextWindow } = obj;
|
|
38
|
+
if (typeof name !== 'string' || !isValidPresetName(name)) {
|
|
39
|
+
throw new Error('预设文件 name 缺失或非法');
|
|
40
|
+
}
|
|
41
|
+
if (typeof baseURL !== 'string' || !baseURL) {
|
|
42
|
+
throw new Error(`预设 ${name}: baseURL 缺失`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof apiKey !== 'string' || !apiKey) {
|
|
45
|
+
throw new Error(`预设 ${name}: apiKey 缺失`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof model !== 'string' || !model) {
|
|
48
|
+
throw new Error(`预设 ${name}: model 缺失`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof contextWindow !== 'number' || !Number.isFinite(contextWindow) || contextWindow <= 0) {
|
|
51
|
+
throw new Error(`预设 ${name}: contextWindow 必须为正数`);
|
|
52
|
+
}
|
|
53
|
+
return { name, baseURL, apiKey, model, contextWindow: Math.floor(contextWindow) };
|
|
54
|
+
}
|
|
55
|
+
/** 读单个预设;不存在抛错。 */
|
|
56
|
+
export function getPreset(name) {
|
|
57
|
+
const p = filePathFor(name);
|
|
58
|
+
return parsePreset(fs.readFileSync(p, 'utf8'));
|
|
59
|
+
}
|
|
60
|
+
/** 读单个预设;不存在返回 null(供列表/可选切换场景)。 */
|
|
61
|
+
export function readPreset(name) {
|
|
62
|
+
try {
|
|
63
|
+
return getPreset(name);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
if (e.code === 'ENOENT')
|
|
67
|
+
return null;
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** 写/覆盖一个预设(原子:写 tmp 再 rename)。 */
|
|
72
|
+
export function savePreset(preset) {
|
|
73
|
+
if (!isValidPresetName(preset.name)) {
|
|
74
|
+
throw new Error(`非法预设名: ${JSON.stringify(preset.name)}`);
|
|
75
|
+
}
|
|
76
|
+
fs.mkdirSync(MODELS_DIR, { recursive: true });
|
|
77
|
+
const dest = filePathFor(preset.name);
|
|
78
|
+
const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`;
|
|
79
|
+
fs.writeFileSync(tmp, JSON.stringify(preset, null, 2), 'utf8');
|
|
80
|
+
fs.renameSync(tmp, dest);
|
|
81
|
+
}
|
|
82
|
+
/** 删除一个预设;不存在返回 false,成功返回 true。 */
|
|
83
|
+
export function deletePreset(name) {
|
|
84
|
+
try {
|
|
85
|
+
fs.unlinkSync(filePathFor(name));
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
if (e.code === 'ENOENT')
|
|
90
|
+
return false;
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 重命名一个预设(原子:link+unlink,跨设备时退化为 copy+unlink)。
|
|
96
|
+
* 用于 /model rename <old> <new>;不存在的旧名 / 已存在的新名返回 false,具体由调用方决定提示文案。
|
|
97
|
+
*/
|
|
98
|
+
export function renamePreset(oldName, newName) {
|
|
99
|
+
if (!isValidPresetName(newName)) {
|
|
100
|
+
throw new Error(`非法新名: ${JSON.stringify(newName)}`);
|
|
101
|
+
}
|
|
102
|
+
const oldPath = filePathFor(oldName);
|
|
103
|
+
const newPath = filePathFor(newName);
|
|
104
|
+
if (!fs.existsSync(oldPath))
|
|
105
|
+
return false;
|
|
106
|
+
if (fs.existsSync(newPath))
|
|
107
|
+
return false; // 拒绝覆盖,避免静默吞用户数据
|
|
108
|
+
fs.mkdirSync(MODELS_DIR, { recursive: true });
|
|
109
|
+
try {
|
|
110
|
+
fs.renameSync(oldPath, newPath);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
if (e.code !== 'EXDEV')
|
|
114
|
+
throw e;
|
|
115
|
+
// 跨设备:rename 会抛 EXDEV,改 copy+unlink。
|
|
116
|
+
fs.copyFileSync(oldPath, newPath);
|
|
117
|
+
fs.unlinkSync(oldPath);
|
|
118
|
+
}
|
|
119
|
+
// 重命名后同步更新文件内的 name 字段(我们写出去时总一致,但允许用户手改过 JSON 后不一致)。
|
|
120
|
+
const p = parsePreset(fs.readFileSync(newPath, 'utf8'));
|
|
121
|
+
if (p.name !== newName)
|
|
122
|
+
savePreset({ ...p, name: newName });
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
/** 列出全部预设(按 name 升序);目录不存在返回空数组。 */
|
|
126
|
+
export function listPresets() {
|
|
127
|
+
if (!fs.existsSync(MODELS_DIR))
|
|
128
|
+
return [];
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const entry of fs.readdirSync(MODELS_DIR)) {
|
|
131
|
+
if (!entry.endsWith('.json'))
|
|
132
|
+
continue;
|
|
133
|
+
const name = entry.slice(0, -'.json'.length);
|
|
134
|
+
if (!isValidPresetName(name))
|
|
135
|
+
continue; // 跳过非我们写的杂文件
|
|
136
|
+
try {
|
|
137
|
+
out.push(parsePreset(fs.readFileSync(path.join(MODELS_DIR, entry), 'utf8')));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// 单个坏文件不阻断列表;用户用 /model delete 显式清理即可。
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* 把当前 config 的 LLM 四键(baseURL/apiKey/model/contextWindow)迁为命名预设。
|
|
148
|
+
* 用于启动时一次性兜底老用户:加 /model 之前就已经在 ~/.mocode/config 写过的配置,
|
|
149
|
+
* /model list 应该立刻能看到,而不是空。已有同名预设则不重复写。
|
|
150
|
+
*
|
|
151
|
+
* 返回新建的预设名;若未配置完整(baseURL/apiKey 缺失)或已有同名则返回 null。
|
|
152
|
+
* 设计为幂等:重启调用一次也只会生效一次。
|
|
153
|
+
*/
|
|
154
|
+
export function migrateCurrentToPreset(input) {
|
|
155
|
+
if (!input.baseURL || !input.apiKey || !input.model)
|
|
156
|
+
return null;
|
|
157
|
+
if (!Number.isFinite(input.contextWindow) || input.contextWindow <= 0)
|
|
158
|
+
return null;
|
|
159
|
+
const existing = listPresets();
|
|
160
|
+
const dup = existing.find((p) => p.baseURL === input.baseURL &&
|
|
161
|
+
p.apiKey === input.apiKey &&
|
|
162
|
+
p.model === input.model &&
|
|
163
|
+
p.contextWindow === input.contextWindow);
|
|
164
|
+
if (dup)
|
|
165
|
+
return null;
|
|
166
|
+
// 'default' 已被占 → 用户已显式起过预设,无需老数据迁入;返回 null 让调用方跳过即可。
|
|
167
|
+
if (existing.some((p) => p.name === 'default'))
|
|
168
|
+
return null;
|
|
169
|
+
savePreset({
|
|
170
|
+
name: 'default',
|
|
171
|
+
baseURL: input.baseURL,
|
|
172
|
+
apiKey: input.apiKey,
|
|
173
|
+
model: input.model,
|
|
174
|
+
contextWindow: input.contextWindow,
|
|
175
|
+
});
|
|
176
|
+
return 'default';
|
|
177
|
+
}
|
package/dist/llm/index.js
CHANGED
|
@@ -264,11 +264,15 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
|
|
|
264
264
|
const create = createImplOverride
|
|
265
265
|
? createImplOverride
|
|
266
266
|
: (body, opts) => client.chat.completions.create(body, opts);
|
|
267
|
+
const activeTools = toolsOverride ?? chatTools;
|
|
267
268
|
const stream = await create({
|
|
268
269
|
model: config.model,
|
|
269
270
|
messages,
|
|
270
|
-
tools:
|
|
271
|
+
tools: activeTools,
|
|
271
272
|
stream: true,
|
|
273
|
+
// 显式声明允许一次响应携带多个 tool_call(OpenAI 兼容协议标准字段)。
|
|
274
|
+
// 不设置时依赖各家后端的默认值,某些第三方网关/模型在缺省时会退化为串行单步调用。
|
|
275
|
+
...(activeTools.length > 0 ? { parallel_tool_calls: true } : {}),
|
|
272
276
|
...(config.maxTokens ? { max_tokens: config.maxTokens } : {}),
|
|
273
277
|
...(config.includeUsage ? { stream_options: { include_usage: true } } : {}),
|
|
274
278
|
}, signal ? { signal } : undefined);
|
package/dist/memory/reflect.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { chat } from '../llm/index.js';
|
|
14
|
-
import { config } from '../config/index.js';
|
|
14
|
+
import { config, isMemoryEnabled } from '../config/index.js';
|
|
15
15
|
import { saveEntry, updateEntry, forgetEntry, loadAll, gcMemories, } from './store.js';
|
|
16
16
|
// ── 日志(静默容错,裁尾保最近)─────────────────────────────────────────────
|
|
17
17
|
function logPath() {
|
|
@@ -224,7 +224,8 @@ export function formatReflectResult(r) {
|
|
|
224
224
|
return `记忆反思:${parts.join(' ')}${r.error ? ` [${r.error}]` : ''}`;
|
|
225
225
|
}
|
|
226
226
|
/**
|
|
227
|
-
* fire-and-forget 触发反思。已有在飞任务 / autoReflect 关闭 → 跳过。
|
|
227
|
+
* fire-and-forget 触发反思。已有在飞任务 / autoReflect 关闭 / 记忆子系统总开关关闭 → 跳过。
|
|
228
|
+
* 记忆关闭时反思毫无意义(没有可存的地方),且会误打日志、误弹摘要,故一并短路。
|
|
228
229
|
* repl 轮末调:与下一轮 agent 并发跑,不阻塞。
|
|
229
230
|
*/
|
|
230
231
|
export function kickoffReflection(transcript) {
|
|
@@ -232,6 +233,8 @@ export function kickoffReflection(transcript) {
|
|
|
232
233
|
return;
|
|
233
234
|
if (!config.autoReflect)
|
|
234
235
|
return;
|
|
236
|
+
if (!isMemoryEnabled())
|
|
237
|
+
return;
|
|
235
238
|
inflight = (async () => {
|
|
236
239
|
try {
|
|
237
240
|
const r = await runReflection(transcript);
|
package/dist/repl/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { emitKeypressEvents } from 'node:readline';
|
|
|
3
3
|
import { stdin, stdout } from 'node:process';
|
|
4
4
|
import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, buildBasePrompt, getPlanModeSuffix, } from '../config/index.js';
|
|
5
5
|
import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
|
|
6
|
+
import { deletePreset, getPreset, isValidPresetName, listPresets, migrateCurrentToPreset, savePreset, } from '../config/presets.js';
|
|
6
7
|
import { runAgent } from '../agent/index.js';
|
|
7
8
|
import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
|
|
8
9
|
import { togglePet, killPetProcess, listSkins, setSkin, sendState } from '../pet/bridge.js';
|
|
@@ -44,7 +45,10 @@ const SLASH_COMMANDS = [
|
|
|
44
45
|
{ name: '/reflect', desc: '手动触发后台记忆反思 pass(需先开启记忆)' },
|
|
45
46
|
{ name: '/init', desc: '扫描项目生成 MOCODE.md 项目记忆(需先开启记忆)' },
|
|
46
47
|
{ name: '/theme', desc: '切换颜色主题(↑↓·Enter)' },
|
|
47
|
-
{ name: '/model', desc: '
|
|
48
|
+
{ name: '/model', desc: '配置新模型(向导);/model switch·list·delete 管理已配置预设' },
|
|
49
|
+
{ name: '/model switch', desc: '↑↓·Enter 在已配置预设间切换' },
|
|
50
|
+
{ name: '/model list', desc: '列出已经配置的模型' },
|
|
51
|
+
{ name: '/model delete <name>', desc: '删除已配置的模型' },
|
|
48
52
|
{ name: '/plan', desc: '切到 plan 模式(只读探查+产出计划)' },
|
|
49
53
|
{ name: '/auto', desc: '切回 auto 模式(全工具执行)' },
|
|
50
54
|
{ name: '/pet', desc: '开关桌宠(独立悬浮窗,展示 agent 状态动画)' },
|
|
@@ -595,6 +599,26 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
595
599
|
// 未配置 baseURL/apiKey:醒目提示引导 /model(不退出,REPL 仍可用;发消息会失败但不崩)。
|
|
596
600
|
layout.contentWrite(`${ui.yellow} ⚠ 未配置大模型。输入 ${ui.cyan}/model${ui.yellow} 配置 baseURL / apiKey / model(即时生效),或退出后运行 ${ui.cyan}mocode config${ui.yellow} 走向导。${ui.reset}\n`);
|
|
597
601
|
}
|
|
602
|
+
else {
|
|
603
|
+
// 老用户兜底:若 ~/.mocode/models/ 空,自动把 ~/.mocode/config 的当前 LLM 四键迁成 'default' 预设。
|
|
604
|
+
// 这样 /model list / switch 立即可见,无需手动 /model 重存一遍。幂等:重启只生效一次。
|
|
605
|
+
if (listPresets().length === 0) {
|
|
606
|
+
try {
|
|
607
|
+
const migrated = migrateCurrentToPreset({
|
|
608
|
+
baseURL: config.baseURL,
|
|
609
|
+
apiKey: config.apiKey,
|
|
610
|
+
model: config.model,
|
|
611
|
+
contextWindow: config.contextWindowTokens,
|
|
612
|
+
});
|
|
613
|
+
if (migrated) {
|
|
614
|
+
layout.contentWrite(`${ui.dim} ↳ 检测到老配置,已自动迁为预设 “${migrated}”(${ui.cyan}/model list${ui.dim} 查看 · /model switch 切换)${ui.reset}\n`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
// 迁移失败不阻塞启动;用户后续 /model 时仍能手动配。
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
598
622
|
layout.contentWrite(`${ui.dim} /plan · /auto · Shift+Tab 切换模式(plan:只读探查 + 产出计划,审批后切 auto 执行)${ui.reset}\n`);
|
|
599
623
|
/**
|
|
600
624
|
* 切换 agent 模式(Shift+Tab 触发,经 prompt.ts 的 onCycleMode 回调)。
|
|
@@ -1019,6 +1043,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1019
1043
|
continue;
|
|
1020
1044
|
}
|
|
1021
1045
|
if (line === '/reflect') {
|
|
1046
|
+
// 记忆子系统总开关关闭时反思无意义(kickoffReflection 内部也会短路),直接提示,不误导用户"已触发"。
|
|
1047
|
+
if (!isMemoryEnabled()) {
|
|
1048
|
+
layout.contentWrite(`${ui.dim}(记忆子系统已关闭,/reflect 无效。用 /memory_switch 打开后再试)${ui.reset}\n`);
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1022
1051
|
// 手动触发后台反思 pass(不等;完成后下次 INPUT 态显摘要)。
|
|
1023
1052
|
kickoffReflection(snapshotTranscript(history, 20));
|
|
1024
1053
|
layout.contentWrite(`${ui.dim}(反思已触发,后台进行;完成后下次输入态显示摘要。日志见 .mocode/memory.log)${ui.reset}\n`);
|
|
@@ -1210,8 +1239,114 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1210
1239
|
// 即时生效(updateModelConfig 改内存 + env,reconfigureClient 重建 OpenAI 实例)+ 持久化(writeConfigKeys 写 ~/.mocode/config)。
|
|
1211
1240
|
// 仿 /theme:promptIntervention 弹菜单/输入 → 改 config → refreshStatusBase 刷底栏 → clearContent+banner 重显横幅 → dim 警告(shell env 覆盖)。
|
|
1212
1241
|
const arg = line.startsWith('/model ') ? line.slice('/model '.length).trim() : '';
|
|
1213
|
-
// /model list
|
|
1214
|
-
|
|
1242
|
+
// ── /model 子命令(use/save/list/delete/rename)优先派发,免得被无参向导路径吞掉。
|
|
1243
|
+
// 共用:apply 一个预设到 config + 持久化 + 重建 client + 重显横幅。无参 /model 选菜单和 /model use 都走这里。
|
|
1244
|
+
const applyPresetAndPersist = (target) => {
|
|
1245
|
+
updateModelConfig({
|
|
1246
|
+
model: target.model,
|
|
1247
|
+
baseURL: target.baseURL,
|
|
1248
|
+
apiKey: target.apiKey,
|
|
1249
|
+
contextWindowTokens: target.contextWindow,
|
|
1250
|
+
});
|
|
1251
|
+
writeConfigKeys({
|
|
1252
|
+
LLM_BASE_URL: target.baseURL,
|
|
1253
|
+
LLM_API_KEY: target.apiKey,
|
|
1254
|
+
LLM_MODEL: target.model,
|
|
1255
|
+
CONTEXT_WINDOW_TOKENS: String(target.contextWindow),
|
|
1256
|
+
});
|
|
1257
|
+
reconfigureClient();
|
|
1258
|
+
refreshStatusBase(history);
|
|
1259
|
+
layout.clearContent();
|
|
1260
|
+
if (history.some((m) => m.role === 'user')) {
|
|
1261
|
+
renderHistory(history);
|
|
1262
|
+
}
|
|
1263
|
+
else {
|
|
1264
|
+
layout.contentWrite(bannerString(banner()));
|
|
1265
|
+
}
|
|
1266
|
+
layout.contentWrite(`${ui.dim}(已切换到预设 “${target.name}” → ${target.model} @ ${target.baseURL})${ui.reset}\n`);
|
|
1267
|
+
if (config.llmKeysFromShell.length > 0) {
|
|
1268
|
+
layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},文件写入下次启动被其覆盖)${ui.reset}\n`);
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
// 决定自动存的预设名:用 desired(model 字段),若与已有预设四元组完全相同则不重复存(返 null);
|
|
1272
|
+
// 否则若 desired 已存在则追加 -2/-3/...。desired 含非法字符(如 glm-4.6 的 '.')时先 sanitize(. → -),
|
|
1273
|
+
// sanitize 后仍空才退化到 'preset'。
|
|
1274
|
+
const uniquePresetName = (desired, baseURL, apiKey, model, contextWindow) => {
|
|
1275
|
+
const existing = listPresets();
|
|
1276
|
+
const sameEntry = existing.find((p) => p.baseURL === baseURL && p.apiKey === apiKey && p.model === model && p.contextWindow === contextWindow);
|
|
1277
|
+
if (sameEntry)
|
|
1278
|
+
return null; // 完全相同,不重复存
|
|
1279
|
+
// sanitize:把非 [a-zA-Z0-9_-] 字符(如 glm-4.6 的 '.')替换为 -,压缩两端 -,裁 1-32。
|
|
1280
|
+
const sanitized = desired
|
|
1281
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
|
1282
|
+
.replace(/^-+|-+$/g, '')
|
|
1283
|
+
.slice(0, 32) || 'preset';
|
|
1284
|
+
const base = isValidPresetName(sanitized) ? sanitized : 'preset';
|
|
1285
|
+
if (!existing.some((p) => p.name === base))
|
|
1286
|
+
return base;
|
|
1287
|
+
for (let i = 2; i < 1000; i++) {
|
|
1288
|
+
const candidate = `${base}-${i}`;
|
|
1289
|
+
if (candidate.length > 32)
|
|
1290
|
+
return `${base.slice(0, 32 - String(i).length - 1)}-${i}`;
|
|
1291
|
+
if (!existing.some((p) => p.name === candidate))
|
|
1292
|
+
return candidate;
|
|
1293
|
+
}
|
|
1294
|
+
return `${base}-${Date.now()}`;
|
|
1295
|
+
};
|
|
1296
|
+
// /model switch:弹 ↑↓·Enter 菜单挑预设切换。无预设时给一行引导。
|
|
1297
|
+
if (arg === 'switch') {
|
|
1298
|
+
const presets = listPresets();
|
|
1299
|
+
if (presets.length === 0) {
|
|
1300
|
+
layout.contentWrite(`${ui.dim}(还没有预设;先跑 /model 添加一个)${ui.reset}\n`);
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
const isCurrent = (p) => p.baseURL === config.baseURL &&
|
|
1304
|
+
p.apiKey === config.apiKey &&
|
|
1305
|
+
p.model === config.model &&
|
|
1306
|
+
p.contextWindow === config.contextWindowTokens;
|
|
1307
|
+
const cols = layout.getGeo().cols;
|
|
1308
|
+
const labelFor = (p) => {
|
|
1309
|
+
const tag = isCurrent(p) ? ' ★current' : '';
|
|
1310
|
+
const right = `${p.model} @ ${p.baseURL}`;
|
|
1311
|
+
const left = `${p.name}${tag}`;
|
|
1312
|
+
const sep = left.length + 1 + right.length;
|
|
1313
|
+
if (sep <= cols - 2)
|
|
1314
|
+
return `${left} ${ui.dim}${right}${ui.reset}`;
|
|
1315
|
+
return left;
|
|
1316
|
+
};
|
|
1317
|
+
const choice = await promptIntervention({
|
|
1318
|
+
type: 'choice',
|
|
1319
|
+
title: '切换模型预设',
|
|
1320
|
+
detail: `当前: ${config.model} @ ${config.baseURL}(★ = 已匹配)`,
|
|
1321
|
+
options: presets.map(labelFor),
|
|
1322
|
+
allowCustom: false, // 纯切换,不需要「其他」干扰
|
|
1323
|
+
});
|
|
1324
|
+
if (choice.action === 'selected' && choice.value) {
|
|
1325
|
+
// value 含 ANSI 序列(labelFor 用了 ui.dim);按 preset.name 前缀匹配。
|
|
1326
|
+
const idx = presets.findIndex((p) => choice.value.startsWith(p.name));
|
|
1327
|
+
const target = idx >= 0 ? presets[idx] : presets[0];
|
|
1328
|
+
applyPresetAndPersist(target);
|
|
1329
|
+
}
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
// /model list:列已配置的预设(★ 标当前);无预设给一行引导。
|
|
1333
|
+
// /model presets 是同义别名(老用户习惯)。
|
|
1334
|
+
if (arg === 'list' || arg === 'presets') {
|
|
1335
|
+
const ps = listPresets();
|
|
1336
|
+
if (ps.length === 0) {
|
|
1337
|
+
layout.contentWrite(`${ui.dim}(还没有预设;先跑 /model 添加一个)${ui.reset}\n`);
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
layout.contentWrite(`${ui.dim}已配置 ${ps.length} 个预设:${ui.reset}\n`);
|
|
1341
|
+
for (const p of ps) {
|
|
1342
|
+
const star = p.baseURL === config.baseURL && p.apiKey === config.apiKey && p.model === config.model ? ' ★' : '';
|
|
1343
|
+
layout.contentWrite(` ${ui.cyan}${p.name}${ui.reset}${star} ${ui.dim}${p.model} @ ${p.baseURL}${ui.reset}\n`);
|
|
1344
|
+
}
|
|
1345
|
+
layout.contentWrite(`${ui.dim}(★ = 与当前一致;切换用 /model switch)${ui.reset}\n`);
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
// /model show:显示当前四项配置(apiKey 脱敏)。
|
|
1349
|
+
if (arg === 'show') {
|
|
1215
1350
|
layout.contentWrite(`${ui.dim}当前模型配置:${ui.reset}\n`);
|
|
1216
1351
|
layout.contentWrite(` ${ui.cyan}baseURL${ui.reset} ${config.baseURL}\n`);
|
|
1217
1352
|
layout.contentWrite(` ${ui.cyan}apiKey ${ui.reset} ${maskKey(config.apiKey)}\n`);
|
|
@@ -1220,6 +1355,61 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1220
1355
|
layout.contentWrite(`${ui.dim}(配置文件: ${CONFIG_PATH})${ui.reset}\n`);
|
|
1221
1356
|
continue;
|
|
1222
1357
|
}
|
|
1358
|
+
// /model use <name>:一键把预设应用到 config + 持久化 + 重建 client。
|
|
1359
|
+
if (arg.startsWith('use ')) {
|
|
1360
|
+
const name = arg.slice(4).trim();
|
|
1361
|
+
if (!name) {
|
|
1362
|
+
layout.contentWrite(`${ui.yellow}用法: /model use <name>${ui.reset}\n`);
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
if (!isValidPresetName(name)) {
|
|
1366
|
+
layout.contentWrite(`${ui.yellow}非法名字: ${name}(仅允许 [a-zA-Z0-9_-]{1,32})${ui.reset}\n`);
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
let preset;
|
|
1370
|
+
try {
|
|
1371
|
+
preset = getPreset(name);
|
|
1372
|
+
}
|
|
1373
|
+
catch (e) {
|
|
1374
|
+
if (e.code === 'ENOENT') {
|
|
1375
|
+
layout.contentWrite(`${ui.yellow}没有预设 “${name}”;先 /model save ${name}${ui.reset}\n`);
|
|
1376
|
+
}
|
|
1377
|
+
else {
|
|
1378
|
+
layout.contentWrite(`${ui.red}/model use 失败: ${e.message}${ui.reset}\n`);
|
|
1379
|
+
}
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
applyPresetAndPersist(preset);
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
// /model delete <name>:删一个预设。无参 / 重名 / 非法名字分别给不同提示。
|
|
1386
|
+
if (arg.startsWith('delete ')) {
|
|
1387
|
+
const name = arg.slice(7).trim();
|
|
1388
|
+
if (!name) {
|
|
1389
|
+
layout.contentWrite(`${ui.yellow}用法: /model delete <name>${ui.reset}\n`);
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
if (!isValidPresetName(name)) {
|
|
1393
|
+
layout.contentWrite(`${ui.yellow}非法名字: ${name}(仅允许 [a-zA-Z0-9_-]{1,32})${ui.reset}\n`);
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
if (deletePreset(name)) {
|
|
1397
|
+
layout.contentWrite(`${ui.dim}已删除预设 “${name}”${ui.reset}\n`);
|
|
1398
|
+
}
|
|
1399
|
+
else {
|
|
1400
|
+
layout.contentWrite(`${ui.yellow}没有预设 “${name}”${ui.reset}\n`);
|
|
1401
|
+
}
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
// /model 后跟了未知子命令 → 给一行简短用法提示,免得静默吞用户输入。
|
|
1405
|
+
// arg === '' → 直接进向导(下方 4 步链);这里只兜底非法子命令。
|
|
1406
|
+
if (arg !== '') {
|
|
1407
|
+
layout.contentWrite(`${ui.yellow}未知子命令: ${arg}${ui.reset}\n` +
|
|
1408
|
+
`${ui.dim}用法: /model(配置新模型向导) · /model switch · /model list · /model delete <name>${ui.reset}\n`);
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
// /model 无参 → 直接进入「配置新模型」4 步向导(不弹动作菜单)。
|
|
1412
|
+
// 切换 / 查看 / 删除已配置预设改用显式子命令:/model switch · /model list · /model delete <name>。
|
|
1223
1413
|
// 1) 选 provider 预设(预填 baseURL,后续仍可逐项改)。
|
|
1224
1414
|
let preset;
|
|
1225
1415
|
try {
|
|
@@ -1253,6 +1443,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1253
1443
|
title: `应用 ${preset.label}?`,
|
|
1254
1444
|
detail: `model ${preset.model}\nbaseURL ${preset.baseURL}\napiKey ${maskKey(config.apiKey)}(直接应用=保留当前)\n窗口 ${preset.window}`,
|
|
1255
1445
|
options: ['直接应用', '逐项修改'],
|
|
1446
|
+
allowCustom: false,
|
|
1256
1447
|
});
|
|
1257
1448
|
if (res.action === 'cancelled') {
|
|
1258
1449
|
continue;
|
|
@@ -1362,6 +1553,20 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1362
1553
|
CONTEXT_WINDOW_TOKENS: String(window),
|
|
1363
1554
|
});
|
|
1364
1555
|
reconfigureClient();
|
|
1556
|
+
// 3.5) 自动存为命名预设:用 model 字段,重名追加 -2/-3,完全相同的四元组不重复存。
|
|
1557
|
+
// 让 /model 跑一次就多一份可切换的预设,/model switch 切回去。
|
|
1558
|
+
let savedName = null;
|
|
1559
|
+
try {
|
|
1560
|
+
const finalName = uniquePresetName(model, baseURL, apiKey, model, window);
|
|
1561
|
+
if (finalName) {
|
|
1562
|
+
savePreset({ name: finalName, baseURL, apiKey, model, contextWindow: window });
|
|
1563
|
+
savedName = finalName;
|
|
1564
|
+
}
|
|
1565
|
+
// finalName === null 表示与某个已存在预设完全一致,不再重复保存。
|
|
1566
|
+
}
|
|
1567
|
+
catch (e) {
|
|
1568
|
+
layout.contentWrite(`${ui.red}保存预设失败: ${e.message}${ui.reset}\n`);
|
|
1569
|
+
}
|
|
1365
1570
|
// 4) 刷新 UI:底栏模型名 + 重显横幅(banner() 闭包实时读 config,自动反映新值)。
|
|
1366
1571
|
refreshStatusBase(history);
|
|
1367
1572
|
layout.clearContent();
|
|
@@ -1372,6 +1577,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1372
1577
|
layout.contentWrite(bannerString(banner()));
|
|
1373
1578
|
}
|
|
1374
1579
|
layout.contentWrite(`${ui.dim}(已切换模型 → ${model} @ ${baseURL})${ui.reset}\n`);
|
|
1580
|
+
if (savedName) {
|
|
1581
|
+
layout.contentWrite(`${ui.dim}(已保存为预设 “${savedName}”,下次 /model use ${savedName} 一键切回)${ui.reset}\n`);
|
|
1582
|
+
}
|
|
1375
1583
|
// 5) dim 警告:shell export 的 LLM 键下次启动会覆盖文件值。
|
|
1376
1584
|
if (config.llmKeysFromShell.length > 0) {
|
|
1377
1585
|
layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},文件写入下次启动被其覆盖;取消该 shell 设置后生效)${ui.reset}\n`);
|