621 lines
29 KiB
JavaScript
621 lines
29 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* thinking.mjs —— ZCode 思考档位(双写:热 + 持久)。
|
|
*
|
|
* node thinking.mjs status [--json] [--model <name>]
|
|
* node thinking.mjs set [<low|max|high>] [--model <name>] [--json] [--hot-only | --persist-only]
|
|
* node thinking.mjs rollback [--json] [--steps N]
|
|
* node thinking.mjs history [--json] [--limit N]
|
|
* node thinking.mjs variants [--model <name>] [--json]
|
|
* node thinking.mjs defaults [--json] | defaults set [--variant X] [--model Y] | defaults clear
|
|
* node thinking.mjs doctor [--json]
|
|
*
|
|
* 环境变量:
|
|
* ZCODE_V2_CONFIG v2/config.json 路径(默认 ~/.zcode/v2/config.json)
|
|
* ZCODE_SESSION_DB session 库路径(默认 ~/.zcode/cli/db/db.sqlite)
|
|
* ZCODE_THINKING_CONFIG 本插件的默认值文件(默认 ~/.zcode/zcode-thinking-mode.json)
|
|
* ZCODE_THINKING_STATE 切换历史 state 文件(默认与默认值文件同目录 zcode-thinking-mode-state.json)
|
|
* ZCODE_THINKING_VARIANT / ZCODE_THINKING_MODEL 临时覆盖默认值(优先于文件)
|
|
*
|
|
* 双写说明:
|
|
* 1) session 库 local_setting(user/default/model/reasoningLevel)—— 全局热档位,
|
|
* 新会话/新 turn 模型解析直接读它;/effort 切的也是这一格。
|
|
* 2) v2/config.json 各模型 reasoning.defaultVariant —— 模型配置里的持久默认。
|
|
* v2/config 写前自动备份: config.json.bak-<yyyyMMdd-HHmmss>(同秒撞名时补 .1/.2)。
|
|
* sqlite 单格 upsert,旧值会打印出来(回滚就是再 set 回去)。
|
|
*
|
|
* 默认值文件说明:
|
|
* plugin.json 里的 userConfig(default_variant / target_model)在设置界面能填,
|
|
* 但宿主只把 ${user_config.*} 插值到 MCP server 字段与 hook,不会插到命令正文里,
|
|
* 所以脚本必须自己读一份落地文件才有意义 —— 就是这个 defaults 文件。
|
|
* 优先级:命令行参数 > 环境变量 > 默认值文件 > 内置兜底。
|
|
*
|
|
* 生效:
|
|
* 新会话/重启后按新档位;**当前会话要立刻生效,再敲一行内置命令 /effort <档位>**(热切,不重启)。
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const V2_CONFIG = process.env.ZCODE_V2_CONFIG
|
|
|| path.join(os.homedir(), ".zcode", "v2", "config.json");
|
|
const SESSION_DB = process.env.ZCODE_SESSION_DB
|
|
|| path.join(os.homedir(), ".zcode", "cli", "db", "db.sqlite");
|
|
const DEFAULTS_FILE = process.env.ZCODE_THINKING_CONFIG
|
|
|| path.join(os.homedir(), ".zcode", "zcode-thinking-mode.json");
|
|
const STATE_FILE = process.env.ZCODE_THINKING_STATE
|
|
|| path.join(path.dirname(DEFAULTS_FILE), "zcode-thinking-mode-state.json");
|
|
|
|
/** 没有默认值文件、也没有环境变量时的兜底 */
|
|
const BUILTIN_DEFAULTS = { variant: null, model: null };
|
|
|
|
// ---------------------------------------------------------------- 小工具
|
|
|
|
function fail(msg, json, extra) {
|
|
if (json) console.log(JSON.stringify({ error: msg, v2config: V2_CONFIG, sessionDb: SESSION_DB, defaultsFile: DEFAULTS_FILE, stateFile: STATE_FILE, ...(extra ?? {}) }, null, 2));
|
|
else {
|
|
console.log(`思考模式切换失败:${msg}`);
|
|
console.log(`模型配置:${V2_CONFIG}`);
|
|
console.log(`热档位库:${SESSION_DB}(可用 ZCODE_V2_CONFIG / ZCODE_SESSION_DB 指定)`);
|
|
}
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
function loadConfig() {
|
|
return JSON.parse(fs.readFileSync(V2_CONFIG, "utf8"));
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- 默认值
|
|
* plugin.json 的 userConfig 在设置界面能填,但宿主只把 ${user_config.*} 插值进
|
|
* MCP server 字段与 hook,命令正文拿不到 —— 所以脚本自己读这份落地文件,
|
|
* 否则那两个配置项就是摆设(填了也不生效)。
|
|
*/
|
|
|
|
function readDefaults() {
|
|
let file = {};
|
|
try { file = JSON.parse(fs.readFileSync(DEFAULTS_FILE, "utf8")) ?? {}; } catch { /* 无文件/坏文件都当空 */ }
|
|
const pick = (...vs) => vs.find((v) => typeof v === "string" && v.trim() !== "") ?? null;
|
|
return {
|
|
file,
|
|
variant: pick(process.env.ZCODE_THINKING_VARIANT, file.variant, BUILTIN_DEFAULTS.variant),
|
|
model: pick(process.env.ZCODE_THINKING_MODEL, file.model, BUILTIN_DEFAULTS.model),
|
|
source: {
|
|
variant: pick(process.env.ZCODE_THINKING_VARIANT) ? "env" : (pick(file.variant) ? "file" : "none"),
|
|
model: pick(process.env.ZCODE_THINKING_MODEL) ? "env" : (pick(file.model) ? "file" : "none"),
|
|
},
|
|
};
|
|
}
|
|
function writeDefaults(patch) {
|
|
const cur = readDefaults().file ?? {};
|
|
for (const [k, v] of Object.entries(patch)) {
|
|
if (v === null || v === undefined || v === "") delete cur[k];
|
|
else cur[k] = v;
|
|
}
|
|
fs.mkdirSync(path.dirname(DEFAULTS_FILE), { recursive: true });
|
|
fs.writeFileSync(DEFAULTS_FILE, JSON.stringify(cur, null, 2) + "\n", "utf8");
|
|
return cur;
|
|
}
|
|
const DEFAULTS_HINT = "默认值文件:~/.zcode/zcode-thinking-mode.json(可用 `defaults set` 写、`defaults clear` 清)";
|
|
|
|
function defaultsReport(asJson) {
|
|
const d = readDefaults();
|
|
if (asJson) {
|
|
console.log(JSON.stringify({
|
|
file: DEFAULTS_FILE, exists: fs.existsSync(DEFAULTS_FILE), content: d.file,
|
|
effective: { variant: d.variant, model: d.model }, source: d.source,
|
|
builtin: BUILTIN_DEFAULTS,
|
|
}, null, 2));
|
|
return 0;
|
|
}
|
|
console.log("## 思考模式默认值");
|
|
console.log("");
|
|
console.log(`- 默认档位:\`${d.variant ?? "(未设)"}\`${d.variant ? ` (来源:${d.source.variant === "env" ? "环境变量" : "默认值文件"})` : ""}`);
|
|
console.log(`- 默认模型:\`${d.model ?? "(全部)"}\`${d.model ? ` (来源:${d.source.model === "env" ? "环境变量" : "默认值文件"})` : ""}`);
|
|
console.log(`- 文件:${DEFAULTS_FILE}${fs.existsSync(DEFAULTS_FILE) ? "" : "(尚未创建)"}`);
|
|
if (fs.existsSync(DEFAULTS_FILE)) console.log(`- 内容:${JSON.stringify(d.file)}`);
|
|
console.log("");
|
|
console.log("优先级:命令行参数 > 环境变量(ZCODE_THINKING_VARIANT / ZCODE_THINKING_MODEL) > 默认值文件 > 内置兜底");
|
|
console.log("");
|
|
console.log("set 不带档位参数时会用这里的默认档位;status/variants 不带 --model 时用这里的默认模型。");
|
|
return 0;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- doctor
|
|
* 自检:安装是否完整、路径能否定位、两个写入目标是否可写、node:sqlite 是否可用。
|
|
* 只读检查,不改任何东西;有 fail 时退出码 1。
|
|
*/
|
|
|
|
async function doctor(asJson) {
|
|
const checks = [];
|
|
const add = (level, name, detail, fix) => checks.push({ level, name, detail, fix });
|
|
const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
|
|
|
|
const major = Number(process.versions.node.split(".")[0]);
|
|
add(major >= 22 ? "ok" : "warn", "Node 版本", `v${process.versions.node}`,
|
|
major >= 22 ? null : "热档位读写需要 node:sqlite(Node ≥22.5),否则只能改模型配置");
|
|
|
|
let sqlite = null;
|
|
try { ({ DatabaseSync: sqlite } = await import("node:sqlite")); } catch { /* 下面报 */ }
|
|
add(sqlite ? "ok" : "fail", "node:sqlite", sqlite ? "可用" : "导入失败",
|
|
sqlite ? null : "升级 Node 到 ≥22.5,否则热档位那一半写不了");
|
|
|
|
// 模型配置
|
|
if (!exists(V2_CONFIG)) add("fail", "模型配置", `读不到 ${V2_CONFIG}`, "确认已配置过模型;或设 ZCODE_V2_CONFIG 指向实际路径");
|
|
else {
|
|
let doc = null;
|
|
try { doc = loadConfig(); } catch (e) { add("fail", "模型配置", `JSON 解析失败(${e.message})`, "修好 JSON 或从 .bak-* 恢复"); }
|
|
if (doc) {
|
|
const withReasoning = collectModels(doc).filter((m) => m.reasoning);
|
|
add(withReasoning.length ? "ok" : "warn", "可切换模型", `${withReasoning.length} 个带 reasoning`,
|
|
withReasoning.length ? null : "没有任何模型声明 reasoning.variants,档位切不了");
|
|
add("ok", "模型配置解析", V2_CONFIG, null);
|
|
}
|
|
}
|
|
|
|
// 热档位库
|
|
if (!exists(SESSION_DB)) add("fail", "热档位库", `读不到 ${SESSION_DB}`, "确认 ZCode 跑过至少一次;或设 ZCODE_SESSION_DB");
|
|
else if (sqlite) {
|
|
try {
|
|
const db = new sqlite(SESSION_DB, { readOnly: true });
|
|
const tbl = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='local_setting'").get();
|
|
db.close();
|
|
add(tbl ? "ok" : "fail", "热档位库结构", tbl ? "有 local_setting 表" : "缺 local_setting 表",
|
|
tbl ? null : "这不是有效的 session 库,热档位写不进去");
|
|
} catch (e) {
|
|
add("fail", "热档位库", `打开失败(${e.message})`, "检查文件权限/是否被独占");
|
|
}
|
|
}
|
|
// 热档位当前值与库是否一致
|
|
const hot = await readGlobalLevel();
|
|
add(hot.error ? "warn" : "ok", "当前热档位", hot.error ? hot.error : (hot.level ?? `(未设:${hot.note ?? "无此行"})`), null);
|
|
|
|
// 默认值文件
|
|
const d = readDefaults();
|
|
add("ok", "默认值", `档位=${d.variant ?? "(未设)"} 模型=${d.model ?? "(全部)"}`,
|
|
`来源:${d.source.variant}/${d.source.model};${DEFAULTS_HINT}`);
|
|
|
|
// 写入能力
|
|
const dir = path.dirname(V2_CONFIG);
|
|
add(exists(dir) ? "ok" : "warn", "配置目录", dir, exists(dir) ? null : "目录不存在,切换时会创建失败");
|
|
if (exists(V2_CONFIG)) {
|
|
try { fs.accessSync(V2_CONFIG, fs.constants.W_OK); add("ok", "配置可写", "有写权限", null); }
|
|
catch { add("fail", "配置可写", "无写权限", "检查文件是否为只读或属主不对"); }
|
|
}
|
|
add(exists(path.dirname(DEFAULTS_FILE)) ? "ok" : "warn", "默认值目录", path.dirname(DEFAULTS_FILE),
|
|
exists(path.dirname(DEFAULTS_FILE)) ? null : "目录不存在,`defaults set` 时会自动创建");
|
|
|
|
// 备份可写
|
|
const bakProbe = `${V2_CONFIG}.bak-probe`;
|
|
let backupWritable = false;
|
|
try { fs.writeFileSync(bakProbe, ""); fs.unlinkSync(bakProbe); backupWritable = true; } catch { /* 下面报 */ }
|
|
add(backupWritable ? "ok" : "fail", "备份可写", backupWritable ? "能在配置目录写备份" : "写不了备份文件",
|
|
backupWritable ? null : "配置目录不可写,切换时会因无法备份而中止");
|
|
|
|
const fails = checks.filter((c) => c.level === "fail");
|
|
const warns = checks.filter((c) => c.level === "warn");
|
|
if (asJson) {
|
|
console.log(JSON.stringify({ v2config: V2_CONFIG, sessionDb: SESSION_DB, defaultsFile: DEFAULTS_FILE, checks, fails: fails.length, warns: warns.length }, null, 2));
|
|
} else {
|
|
const icon = { ok: "✓", warn: "⚠", fail: "✗" };
|
|
console.log("# zcode-thinking-mode 自检");
|
|
console.log(`- ${checks.length} 项:${checks.length - fails.length - warns.length} ok / ${warns.length} warn / ${fails.length} fail`);
|
|
console.log("");
|
|
for (const c of checks) {
|
|
console.log(`${icon[c.level]} ${c.name}:${c.detail}`);
|
|
if (c.fix) console.log(` → ${c.fix}`);
|
|
}
|
|
console.log("");
|
|
console.log(fails.length ? `结论:有 ${fails.length} 项必须先修,否则切换会失败。`
|
|
: warns.length ? `结论:可正常工作,${warns.length} 项警告建议处理。` : "结论:一切就绪。");
|
|
}
|
|
return fails.length ? 1 : 0;
|
|
}
|
|
|
|
/**
|
|
* 备份名精确到秒,同一秒内连切两次会撞名 —— 后一份会覆盖前一份,
|
|
* 于是"回滚到改动前"实际退回的是倒数第二次的中间态。撞名时补序号。
|
|
*/
|
|
function saveConfig(doc) {
|
|
const ts = new Date();
|
|
const p = (x) => String(x).padStart(2, "0");
|
|
const stamp = `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}-${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}`;
|
|
let bak = `${V2_CONFIG}.bak-${stamp}`;
|
|
for (let i = 1; fs.existsSync(bak); i++) bak = `${V2_CONFIG}.bak-${stamp}.${i}`;
|
|
fs.copyFileSync(V2_CONFIG, bak);
|
|
fs.writeFileSync(V2_CONFIG, JSON.stringify(doc, null, 2), "utf8");
|
|
return bak;
|
|
}
|
|
|
|
function collectModels(doc) {
|
|
const out = [];
|
|
const providers = doc?.provider ?? {};
|
|
for (const [pid, p] of Object.entries(providers)) {
|
|
const models = p?.models ?? {};
|
|
for (const [mname, m] of Object.entries(models)) {
|
|
out.push({
|
|
providerId: pid,
|
|
providerName: p?.name ?? pid,
|
|
model: mname,
|
|
enabled: p?.enabled !== false,
|
|
reasoning: m?.reasoning ?? null,
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function filterByModel(list, name) {
|
|
if (!name) return list;
|
|
const n = name.toLowerCase();
|
|
return list.filter((m) => m.model.toLowerCase() === n);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 热档位(sqlite)
|
|
|
|
async function readGlobalLevel() {
|
|
let DatabaseSync;
|
|
try {
|
|
({ DatabaseSync } = await import("node:sqlite"));
|
|
} catch {
|
|
return { error: "当前 node 不支持 node:sqlite(需 Node 22.5+),热档位读不到" };
|
|
}
|
|
let db;
|
|
try {
|
|
db = new DatabaseSync(SESSION_DB, { readOnly: true });
|
|
const row = db.prepare(
|
|
"SELECT value FROM local_setting WHERE scope='user' AND scope_id='default' AND namespace='model' AND key='reasoningLevel'"
|
|
).get();
|
|
if (!row) return { level: null, note: "库里没有 reasoningLevel 行(从未用 /effort 切过)" };
|
|
const v = JSON.parse(row.value);
|
|
return { level: typeof v?.level === "string" ? v.level : null };
|
|
} catch (e) {
|
|
return { error: `读热档位库失败(${e?.message ?? e})` };
|
|
} finally {
|
|
try { db?.close(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
async function writeGlobalLevel(level) {
|
|
let DatabaseSync;
|
|
try {
|
|
({ DatabaseSync } = await import("node:sqlite"));
|
|
} catch {
|
|
throw new Error("当前 node 不支持 node:sqlite(需 Node 22.5+),热档位写不了(模型配置仍可写)");
|
|
}
|
|
const db = new DatabaseSync(SESSION_DB);
|
|
try {
|
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
const tbl = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='local_setting'").get();
|
|
if (!tbl) throw new Error("库里没有 local_setting 表,不是有效的 session 库");
|
|
const prev = db.prepare(
|
|
"SELECT value FROM local_setting WHERE scope='user' AND scope_id='default' AND namespace='model' AND key='reasoningLevel'"
|
|
).get();
|
|
const now = Date.now();
|
|
db.prepare(`
|
|
INSERT INTO local_setting (scope, scope_id, namespace, key, value, schema_version, time_created, time_updated)
|
|
VALUES ('user', 'default', 'model', 'reasoningLevel', ?, 1, ?, ?)
|
|
ON CONFLICT(scope, scope_id, namespace, key) DO UPDATE SET
|
|
value = excluded.value,
|
|
schema_version = excluded.schema_version,
|
|
time_updated = excluded.time_updated
|
|
`).run(JSON.stringify({ level }), now, now);
|
|
let previous = null;
|
|
try { previous = prev ? JSON.parse(prev.value)?.level ?? null : null; } catch { /* ignore */ }
|
|
return { previous };
|
|
} finally {
|
|
try { db?.close(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- state 历史
|
|
// 每次 set 成功后追加一条:{ts, variant, model, hotOnly, persistOnly,
|
|
// hotPrevious, hotLevel, persistPrevious:[{provider,model,from,to}], persistCurrent,
|
|
// backup}。rollback 按最新一条把热档位与持久默认写回去,不删历史(可多次回滚)。
|
|
|
|
function readState() {
|
|
try {
|
|
const raw = fs.readFileSync(STATE_FILE, "utf8");
|
|
const j = JSON.parse(raw);
|
|
return Array.isArray(j?.entries) ? j.entries : [];
|
|
} catch { return []; }
|
|
}
|
|
function appendState(entry) {
|
|
const entries = readState();
|
|
entries.push({ ts: new Date().toISOString(), ...entry });
|
|
// 只保留最近 50 条,防止无限增长
|
|
const kept = entries.slice(-50);
|
|
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
fs.writeFileSync(STATE_FILE, JSON.stringify({ entries: kept }, null, 2) + "\n", "utf8");
|
|
return kept;
|
|
}
|
|
function historyReport(asJson, limit) {
|
|
const entries = readState().slice(-(limit ?? 10));
|
|
if (asJson) {
|
|
console.log(JSON.stringify({ stateFile: STATE_FILE, count: entries.length, entries }, null, 2));
|
|
return 0;
|
|
}
|
|
console.log("## 思考模式切换历史");
|
|
console.log("");
|
|
console.log(`- 文件:${STATE_FILE}${fs.existsSync(STATE_FILE) ? "" : "(尚无记录)"}`);
|
|
if (!entries.length) { console.log("- 暂无切换记录"); return 0; }
|
|
for (const e of entries) {
|
|
const scope = e.hotOnly ? "仅热" : e.persistOnly ? "仅持久" : "热+持久";
|
|
console.log(`- ${e.ts} ${e.hotPrevious ?? "—"} → \`${e.hotLevel}\` [${scope}]${e.model ? ` (模型:${e.model})` : ""}${e.backup ? ` 备份:${e.backup}` : ""}`);
|
|
}
|
|
console.log("");
|
|
console.log(`回滚:node scripts/thinking.mjs rollback(把最近一次的改动写回去)`);
|
|
return 0;
|
|
}
|
|
async function doRollback(asJson, steps) {
|
|
const entries = readState();
|
|
if (!entries.length) { fail("没有切换历史可回滚", asJson, { stateFile: STATE_FILE }); process.exit(1); }
|
|
const n = Math.max(1, Math.min(steps ?? 1, entries.length));
|
|
const target = entries[entries.length - n];
|
|
// 1) 热档位写回
|
|
if (target.hotPrevious !== undefined && target.hotPrevious !== null) {
|
|
try { await writeGlobalLevel(target.hotPrevious); }
|
|
catch (e) { fail(`回滚热档位失败(${e?.message ?? e})——模型配置未动`, asJson); process.exit(1); }
|
|
}
|
|
// 2) 持久默认逐个写回(只动历史里记录过的模型)
|
|
let doc;
|
|
try { doc = loadConfig(); } catch (e) { fail(`读模型配置失败(${e?.message ?? e})`, asJson); process.exit(1); }
|
|
const restored = [];
|
|
for (const p of target.persistPrevious ?? []) {
|
|
const m = doc?.provider?.[p.provider]?.models?.[p.model];
|
|
if (m?.reasoning && m.reasoning.defaultVariant !== p.from) {
|
|
m.reasoning.defaultVariant = p.from;
|
|
restored.push({ provider: p.provider, model: p.model, defaultVariant: p.from });
|
|
}
|
|
}
|
|
let bak = null;
|
|
if (restored.length) {
|
|
try { bak = saveConfig(doc); }
|
|
catch (e) { fail(`热档位已回滚到 ${target.hotPrevious},但写模型配置失败(${e?.message ?? e})`, asJson); process.exit(1); }
|
|
}
|
|
if (asJson) {
|
|
console.log(JSON.stringify({
|
|
stateFile: STATE_FILE, rolledBack: target.ts, hotLevel: target.hotPrevious,
|
|
restored, backup: bak,
|
|
}, null, 2));
|
|
} else {
|
|
console.log("## 已回滚到上次切换之前");
|
|
console.log("");
|
|
console.log(`- 回滚目标:${target.ts} (${target.hotPrevious ?? "—"} → \`${target.hotLevel}\`)`);
|
|
console.log(`- 全局热档位 → \`${target.hotPrevious ?? "(历史未记录,不动)"}\``);
|
|
if (restored.length) {
|
|
for (const r of restored) console.log(`- **${r.model}** (provider \`${r.provider}\`)持久默认 → \`${r.defaultVariant}\``);
|
|
console.log(`备份:${bak}`);
|
|
} else console.log("- 模型配置持久默认:与历史一致,未改写");
|
|
console.log("");
|
|
console.log(`当前会话要立刻生效,再敲一行内置命令:\`/effort ${target.hotPrevious}\`(热切,不重启)`);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- CLI
|
|
|
|
const argv = process.argv.slice(2);
|
|
const asJson = argv.includes("--json");
|
|
const ACTIONS = ["status", "set", "variants", "defaults", "doctor", "rollback", "history"];
|
|
const action = argv.find((a) => ACTIONS.includes(a)) ?? "status";
|
|
|
|
function opt(name) {
|
|
const i = argv.indexOf(name);
|
|
return i >= 0 ? argv[i + 1] : undefined;
|
|
}
|
|
/** --model 优先;没给就用默认值文件/环境变量里的,再没有就是 null(全部) */
|
|
const defaults = readDefaults();
|
|
const modelOpt = opt("--model") ?? defaults.model ?? undefined;
|
|
|
|
if (action === "doctor") { process.exit(await doctor(asJson)); }
|
|
|
|
if (action === "history") {
|
|
const lim = opt("--limit") ?? opt("--steps");
|
|
process.exit(historyReport(asJson, lim ? Number(lim) : 10));
|
|
}
|
|
|
|
if (action === "rollback") {
|
|
const steps = opt("--steps");
|
|
await doRollback(asJson, steps ? Number(steps) : 1);
|
|
}
|
|
|
|
if (action === "defaults") {
|
|
// defaults / defaults set / defaults clear
|
|
const sub = argv[argv.indexOf("defaults") + 1];
|
|
if (sub === "set") {
|
|
const v = opt("--variant");
|
|
const m = opt("--model");
|
|
if (v === undefined && m === undefined) {
|
|
fail("用法: node thinking.mjs defaults set [--variant <low|max|high>] [--model <name>] [--json]", asJson);
|
|
process.exit(1);
|
|
}
|
|
const content = writeDefaults({ variant: v ?? undefined, model: m ?? undefined });
|
|
if (asJson) console.log(JSON.stringify({ file: DEFAULTS_FILE, content }, null, 2));
|
|
else {
|
|
console.log("## 默认值已写入");
|
|
console.log("");
|
|
console.log(`- 文件:\`${DEFAULTS_FILE}\``);
|
|
console.log(`- 内容:${JSON.stringify(content)}`);
|
|
console.log("");
|
|
console.log("之后 `set` 不带档位就用这个默认档位;`status`/`variants` 不带 --model 就用这个默认模型。");
|
|
}
|
|
process.exit(0);
|
|
}
|
|
if (sub === "clear") {
|
|
try { fs.rmSync(DEFAULTS_FILE, { force: true }); } catch (e) { fail(`删除默认值文件失败(${e?.message ?? e})`, asJson); process.exit(1); }
|
|
if (asJson) console.log(JSON.stringify({ file: DEFAULTS_FILE, removed: true }, null, 2));
|
|
else console.log(`## 默认值已清空\n\n- 已删除 \`${DEFAULTS_FILE}\`\n- 之后回到内置兜底(档位不预设、模型=全部)`);
|
|
process.exit(0);
|
|
}
|
|
process.exit(defaultsReport(asJson));
|
|
}
|
|
|
|
const hot = await readGlobalLevel();
|
|
|
|
if (action === "variants") {
|
|
let doc;
|
|
try { doc = loadConfig(); } catch (e) { fail(`读模型配置失败(${e?.message ?? e})`, asJson); process.exit(1); }
|
|
const list = filterByModel(collectModels(doc).filter((m) => m.reasoning), modelOpt);
|
|
if (asJson) {
|
|
console.log(JSON.stringify({
|
|
v2config: V2_CONFIG, sessionDb: SESSION_DB, hotLevel: hot.level ?? null,
|
|
models: list.map((m) => ({ provider: m.providerId, model: m.model, variants: m.reasoning.variants ?? [], defaultVariant: m.reasoning.defaultVariant ?? null })),
|
|
}, null, 2));
|
|
} else if (!list.length) {
|
|
console.log("没有带 reasoning 的模型(或 --model 名称不对)。");
|
|
} else {
|
|
console.log(`全局热档位:${hot.level ?? "(读失败:" + (hot.error ?? hot.note) + ")"}`);
|
|
console.log("");
|
|
console.log("## 可选思考档位");
|
|
console.log("");
|
|
for (const m of list) {
|
|
console.log(`- **${m.model}** (provider \`${m.providerId}\`):可选 \`${(m.reasoning.variants ?? []).join(" / ") || "—"}\`,持久默认 \`${m.reasoning.defaultVariant ?? "—"}\``);
|
|
}
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
if (action === "status") {
|
|
let doc;
|
|
try { doc = loadConfig(); } catch (e) { fail(`读模型配置失败(${e?.message ?? e})`, asJson); process.exit(1); }
|
|
const list = filterByModel(collectModels(doc), modelOpt);
|
|
if (asJson) {
|
|
console.log(JSON.stringify({
|
|
v2config: V2_CONFIG, sessionDb: SESSION_DB,
|
|
hotLevel: hot.level ?? null, hotError: hot.error ?? hot.note ?? null,
|
|
models: list.map((m) => ({
|
|
provider: m.providerId, model: m.model, enabled: m.enabled,
|
|
reasoning: m.reasoning ? { enabled: m.reasoning.enabled ?? null, variants: m.reasoning.variants ?? [], defaultVariant: m.reasoning.defaultVariant ?? null } : null,
|
|
})),
|
|
}, null, 2));
|
|
} else if (!list.length) {
|
|
console.log("模型配置里没找到模型(或 --model 名称不对)。");
|
|
} else {
|
|
console.log("## 思考模式现状");
|
|
console.log("");
|
|
if (hot.level) console.log(`全局热档位:\`${hot.level}\`(新会话直接按它;当前会话要立刻生效再敲 \`/effort ${hot.level}\`)`);
|
|
else console.log(`全局热档位:读失败(${(hot.error ?? hot.note ?? "未知")})`);
|
|
console.log(`模型配置:\`${V2_CONFIG}\``);
|
|
console.log("");
|
|
for (const m of list) {
|
|
if (!m.reasoning) console.log(`- **${m.model}** (provider \`${m.providerId}\`):无 reasoning 配置(不支持切换档位)`);
|
|
else console.log(`- **${m.model}** (provider \`${m.providerId}\`):持久默认 \`${m.reasoning.defaultVariant ?? "—"}\`,可选 \`${(m.reasoning.variants ?? []).join(" / ") || "—"}\``);
|
|
}
|
|
console.log("");
|
|
console.log("切换示例:`/thinking:set high` 或 `/thinking:set low --model GLM-5.3-Flash`");
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
// --- set ---
|
|
const setIdx = argv.indexOf("set");
|
|
const variantArg = setIdx >= 0 ? argv[setIdx + 1] : undefined;
|
|
// 不带档位时用默认值文件里的档位;两处都没有才报用法错(不再硬性要求手打)
|
|
const variant = (variantArg && !variantArg.startsWith("--")) ? variantArg : defaults.variant;
|
|
const variantFromDefault = !(variantArg && !variantArg.startsWith("--"));
|
|
const hotOnly = argv.includes("--hot-only");
|
|
const persistOnly = argv.includes("--persist-only");
|
|
if (hotOnly && persistOnly) {
|
|
fail("用法:--hot-only 与 --persist-only 不能同时用(只写热档位,或只写模型配置)", asJson);
|
|
process.exit(1);
|
|
}
|
|
if (!variant) {
|
|
fail("没给档位,默认值文件里也没设。用法: node thinking.mjs set <low|max|high> [--model <name>] [--json] [--hot-only | --persist-only]\n"
|
|
+ " 或先定个默认:`node thinking.mjs defaults set --variant high`", asJson);
|
|
process.exit(1);
|
|
}
|
|
|
|
let doc;
|
|
try { doc = loadConfig(); } catch (e) { fail(`读模型配置失败(${e?.message ?? e})`, asJson); process.exit(1); }
|
|
const targets = filterByModel(collectModels(doc).filter((m) => m.reasoning), modelOpt);
|
|
if (!targets.length) {
|
|
fail(modelOpt ? `没找到带 reasoning 的模型:${modelOpt}` : "模型配置里没有带 reasoning 的模型", asJson);
|
|
process.exit(1);
|
|
}
|
|
// 校验收紧:① 档位必须在任一目标的 variants 并集里(防 ultra 之类非法档位两边都不写);
|
|
// ② 之后仍做逐模型整批校验,有一个不支持就整批中止。
|
|
const unionVariants = [...new Set(targets.flatMap((t) => (t.reasoning.variants ?? []).map((v) => String(v).toLowerCase())))];
|
|
if (unionVariants.length && !unionVariants.includes(String(variant).toLowerCase())) {
|
|
fail(`非法档位 ${variant}(全部目标的可选:${unionVariants.join("/")})——两边都没写`, asJson);
|
|
process.exit(1);
|
|
}
|
|
for (const t of targets) {
|
|
const vs = t.reasoning.variants ?? [];
|
|
if (vs.length && !vs.map((v) => String(v).toLowerCase()).includes(variant.toLowerCase())) {
|
|
fail(`模型 ${t.model} 不支持档位 ${variant}(可选:${vs.join("/")})——整批中止,两边都没写`, asJson);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
// 规范大小写(跟模型自带的 variants 一致)
|
|
const canonicalOf = (t) => (t.reasoning.variants ?? []).find((v) => String(v).toLowerCase() === variant.toLowerCase()) ?? variant;
|
|
const canonical = canonicalOf(targets[0]);
|
|
const persistPrevious = targets.map((t) => ({
|
|
provider: t.providerId, model: t.model, from: t.reasoning.defaultVariant ?? null,
|
|
}));
|
|
|
|
// 1) 热档位(默认写;--persist-only 时跳过)
|
|
let hotPrev = null;
|
|
if (!persistOnly) {
|
|
try {
|
|
({ previous: hotPrev } = await writeGlobalLevel(canonical));
|
|
} catch (e) {
|
|
fail(`写热档位库失败(${e?.message ?? e})——模型配置未动`, asJson);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
try { hotPrev = (await readGlobalLevel()).level ?? null; } catch { hotPrev = null; }
|
|
}
|
|
|
|
// 2) 模型配置持久默认(默认写;--hot-only 时跳过)
|
|
const changed = [];
|
|
if (!hotOnly) {
|
|
for (const t of targets) {
|
|
const m = doc.provider[t.providerId].models[t.model];
|
|
const c = canonicalOf(t);
|
|
if (m?.reasoning && m.reasoning.defaultVariant !== c) {
|
|
m.reasoning.defaultVariant = c;
|
|
changed.push({ provider: t.providerId, model: t.model, defaultVariant: c });
|
|
}
|
|
}
|
|
}
|
|
let bak = null;
|
|
if (changed.length) {
|
|
try { bak = saveConfig(doc); }
|
|
catch (e) { fail(`热档位已切到 ${canonical},但写模型配置失败(${e?.message ?? e})`, asJson, { hotLevel: canonical }); process.exit(1); }
|
|
}
|
|
// set 成功才记历史(含分写标记与持久旧值,供 rollback 用)
|
|
appendState({
|
|
variant: canonical, model: modelOpt ?? null, hotOnly, persistOnly,
|
|
hotPrevious: hotOnly ? hotPrev : hotPrev, hotLevel: canonical,
|
|
persistPrevious, persistCurrent: canonical, backup: bak,
|
|
});
|
|
|
|
const scopeLabel = hotOnly ? "仅热" : persistOnly ? "仅持久" : "热 + 持久";
|
|
if (asJson) {
|
|
console.log(JSON.stringify({
|
|
v2config: V2_CONFIG, sessionDb: SESSION_DB, stateFile: STATE_FILE,
|
|
backup: bak, hotPrevious: hotPrev, hotLevel: canonical,
|
|
hotOnly, persistOnly,
|
|
variantFromDefault, modelFromDefault: !opt("--model") && !!defaults.model, changed,
|
|
}, null, 2));
|
|
} else {
|
|
console.log(`## 思考模式已切换(${scopeLabel})`);
|
|
console.log("");
|
|
if (variantFromDefault) console.log(`- 档位来自默认值(${defaults.source.variant === "env" ? "环境变量" : "默认值文件"}):\`${canonical}\``);
|
|
if (!persistOnly) console.log(`- 全局热档位:${hotPrev ?? "—"} → \`${canonical}\`(新会话直接生效)`);
|
|
else console.log(`- 全局热档位:本次仅持久,未动(当前 \`${hotPrev ?? "—"}\`)`);
|
|
if (!opt("--model") && defaults.model) console.log(`- 作用模型来自默认值(未给 --model):\`${defaults.model}\``);
|
|
if (hotOnly) console.log("- 模型配置持久默认:本次仅热,未改写");
|
|
else if (changed.length) {
|
|
for (const c of changed) console.log(`- **${c.model}** (provider \`${c.provider}\`)持久默认 → \`${c.defaultVariant}\``);
|
|
console.log("");
|
|
console.log(`备份:${bak}(回滚:node scripts/thinking.mjs rollback,或把它拷回 ${V2_CONFIG})`);
|
|
} else {
|
|
console.log("- 模型配置持久默认:本来就是目标档位,未改写");
|
|
}
|
|
console.log("");
|
|
console.log(`历史已记:${STATE_FILE}`);
|
|
if (!persistOnly) console.log(`当前这轮会话要立刻生效,再敲一行内置命令:\`/effort ${canonical}\`(热切,不重启)`);
|
|
}
|