@leonhardthust/dsh-decision-engine 1.9.2 → 1.10.0
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 +3 -2
- package/README.zh.md +2 -2
- package/config.example.yml +50 -23
- package/docs/releasing.md +4 -4
- package/lib/client.js +695 -227
- package/lib/decision-log.js +501 -0
- package/lib/decision-store.js +756 -97
- package/lib/engine.js +38 -30
- package/lib/guard/effects.js +14 -0
- package/lib/guard/listener.js +28 -3
- package/lib/guard/messages.js +35 -0
- package/lib/index.js +312 -15
- package/lib/outcomes.js +3 -1
- package/lib/providers.js +57 -8
- package/lib/tolerance.js +50 -40
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
27
27
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
|
|
28
|
-
import { dirname, join } from "node:path";
|
|
28
|
+
import { basename, dirname, join } from "node:path";
|
|
29
29
|
import z from "@deepseek-ai/schemastery";
|
|
30
30
|
|
|
31
31
|
import { DecisionCache } from "./decision-cache.js";
|
|
@@ -46,7 +46,19 @@ import { resolveDshHome } from "./home-paths.js";
|
|
|
46
46
|
import { applyRefUpdates, listRefNames, pickRefValue } from "./credentials.js";
|
|
47
47
|
import { RANGE_LABELS, RANGE_PRESETS, aggregate, buildRecord, resolveRange, sortRows } from "./stats.js";
|
|
48
48
|
import { StatsStore } from "./stats-store.js";
|
|
49
|
-
import { buildDecisionRecord, DecisionStore, MAX_ENTRIES_PER_QUERY } from "./decision-store.js";
|
|
49
|
+
import { buildDecisionRecord, DecisionStore, MAX_ENTRIES_PER_QUERY, maskSecrets } from "./decision-store.js";
|
|
50
|
+
// 日志分片命名与紧凑格式:纯函数(零 I/O),放在独立模块里才能单测到
|
|
51
|
+
// 时区、Windows 保留名、截断哈希这些边界。
|
|
52
|
+
import {
|
|
53
|
+
DATE_SEPARATORS,
|
|
54
|
+
DEFAULT_DATE_SEPARATOR,
|
|
55
|
+
DEFAULT_RETENTION_DAYS,
|
|
56
|
+
DEFAULT_SPLIT_MODE,
|
|
57
|
+
DEFAULT_TOTAL_MAX_BYTES,
|
|
58
|
+
SPLIT_MODES,
|
|
59
|
+
normalizeDateSeparator,
|
|
60
|
+
normalizeSplitMode,
|
|
61
|
+
} from "./decision-log.js";
|
|
50
62
|
// 作用域边界(DEC-045):越出工作区的写入、模型给用户挑方案、模型等用户
|
|
51
63
|
// 澄清——这三类决定不属于本插件,遇到时让位给用户。
|
|
52
64
|
import { GUARD_FALLBACKS, guardModelCatalog } from "./guard/fallback.js";
|
|
@@ -354,8 +366,41 @@ const DEFAULTS = {
|
|
|
354
366
|
approvalDetailReadable: true,
|
|
355
367
|
/** 单条内容的序列化长度上限;超了按「参数 → 摘要 → 整个 state」依次裁剪。 */
|
|
356
368
|
decisionsMaxContentChars: 4000,
|
|
357
|
-
/**
|
|
369
|
+
/** 单个分片的体积上限(字节);到顶轮转成 `<分片>.1`(只留一代)。 */
|
|
358
370
|
decisionsMaxBytes: 8 * 1024 * 1024,
|
|
371
|
+
/**
|
|
372
|
+
* 决策日志的分文件方式(T3)。
|
|
373
|
+
*
|
|
374
|
+
* 默认 **date**:单文件在体量上来之后有两个硬问题——按会话查历史要扫全文件,
|
|
375
|
+
* 体积轮转还会把同一个会话的记录切开。按日期分片最贴合「今天的日志」这个
|
|
376
|
+
* 问法,也是清理(按保留天数删)最自然的单位。
|
|
377
|
+
*
|
|
378
|
+
* 其余三档给不同习惯的用户:session 按会话隔离、workspace 按项目归档、
|
|
379
|
+
* single 保留旧行为(一个文件,升级不改体验)。
|
|
380
|
+
*/
|
|
381
|
+
decisionsSplitMode: DEFAULT_SPLIT_MODE,
|
|
382
|
+
/**
|
|
383
|
+
* 日期分片的日期分隔符:`-` / `_` / `.`。
|
|
384
|
+
*
|
|
385
|
+
* 三档都是「同一种信息的三种写法」,纯粹为了兼容用户已有的脚本/别名
|
|
386
|
+
* (有人用 `2026-11-15`,也有人拿 `2026_11_15` 当搜索词)。
|
|
387
|
+
*/
|
|
388
|
+
decisionsDateSeparator: DEFAULT_DATE_SEPARATOR,
|
|
389
|
+
/**
|
|
390
|
+
* 日志保留天数:`0` = 不按天删(只受总量上限约束)。
|
|
391
|
+
*
|
|
392
|
+
* 默认 30 天:够回答「上个月这个问题出现过没有」,又不会让磁盘无限长。
|
|
393
|
+
* 清理只删**今天之前的**分片,绝不碰正在写的那个。
|
|
394
|
+
*/
|
|
395
|
+
decisionsRetentionDays: DEFAULT_RETENTION_DAYS,
|
|
396
|
+
/**
|
|
397
|
+
* `logs/` 的总量上限(字节,默认 64 MB)。
|
|
398
|
+
*
|
|
399
|
+
* 与 `decisionsMaxBytes`(单分片上限)是两个维度:前者约束整体磁盘占用,
|
|
400
|
+
* 后者约束「一个文件多大就开始轮转」。这是**运维旋钮**,刻意不进
|
|
401
|
+
* `/config` 白名单——用户日常不需要拨它,需要时手改 config.yml 即可。
|
|
402
|
+
*/
|
|
403
|
+
decisionsTotalMaxBytes: DEFAULT_TOTAL_MAX_BYTES,
|
|
359
404
|
/**
|
|
360
405
|
* 人工审核比例(tolerance,DEC-035)。
|
|
361
406
|
*
|
|
@@ -464,7 +509,7 @@ export const Config = z.object({
|
|
|
464
509
|
retryAttempts: z.number().step(1).min(0).max(3).default(DEFAULTS.retryAttempts)
|
|
465
510
|
.description("仅对超时与网络错误重试的次数;4xx/5xx 不重试(配置错与服务端故障,重发只会多烧额度)"),
|
|
466
511
|
thinkMode: z.union(["never", "auto", "always"]).default(DEFAULTS.thinkMode)
|
|
467
|
-
.description("never
|
|
512
|
+
.description("never 最省延迟;auto 由服务端决定;always 总是思考"),
|
|
468
513
|
cooldownMs: z.number().step(1).min(1000).default(DEFAULTS.cooldownMs),
|
|
469
514
|
cooldownProbeMs: z.number().step(1).min(1000).default(DEFAULTS.cooldownProbeMs)
|
|
470
515
|
.description("冷却期内每隔这么久放行一次试探请求;试探成功即彻底解除冷却"),
|
|
@@ -572,11 +617,22 @@ export const Config = z.object({
|
|
|
572
617
|
decisionsMaxContentChars: z.number().step(1).min(200).default(DEFAULTS.decisionsMaxContentChars)
|
|
573
618
|
.description("单条决策内容的字符上限;超出时依次裁剪工具参数、对话摘要、整个 state"),
|
|
574
619
|
decisionsMaxBytes: z.number().step(1).min(1024 * 1024).default(DEFAULTS.decisionsMaxBytes)
|
|
575
|
-
.description("
|
|
620
|
+
.description("单个日志分片的体积上限(字节);到顶轮转成 <分片>.1,只留一代"),
|
|
621
|
+
decisionsSplitMode: z.union([...SPLIT_MODES]).default(DEFAULTS.decisionsSplitMode)
|
|
622
|
+
.description(
|
|
623
|
+
"日志分文件:single 单一文件 / date 按日期(默认)/ session 按会话 / workspace 按工作区。"
|
|
624
|
+
+ "文件名可能包含会话名或工作区名",
|
|
625
|
+
),
|
|
626
|
+
decisionsDateSeparator: z.union([...DATE_SEPARATORS]).default(DEFAULTS.decisionsDateSeparator)
|
|
627
|
+
.description("date 模式的日期分隔符:- / _ / .;只影响文件名"),
|
|
628
|
+
decisionsRetentionDays: z.number().step(1).min(0).max(3650).default(DEFAULTS.decisionsRetentionDays)
|
|
629
|
+
.description("日志保留天数(0 = 不按天删);每 6 小时清理一次,绝不删今天与正在写的分片"),
|
|
630
|
+
decisionsTotalMaxBytes: z.number().step(1).min(1024 * 1024).default(DEFAULTS.decisionsTotalMaxBytes)
|
|
631
|
+
.description("logs 目录的总量上限(字节,默认 64 MB);运维旋钮,不在设置页提供"),
|
|
576
632
|
tolerance: z.union([...TOLERANCE_LEVELS]).default(DEFAULTS.tolerance)
|
|
577
633
|
.description(
|
|
578
|
-
"
|
|
579
|
-
+ "default
|
|
634
|
+
"安全档位(模型无关的打扰频率):autonomous 不问(软性审查不弹窗)/ relaxed 少问(只按严重度尾部)/ "
|
|
635
|
+
+ "default 默认(沿用决策模型判断)/ cautious 多问(默认判断之上再加严重度带);四档都不改变权限强制的人工批准与拒绝",
|
|
580
636
|
),
|
|
581
637
|
});
|
|
582
638
|
|
|
@@ -748,6 +804,33 @@ export function validateProviderPatch(patch, current) {
|
|
|
748
804
|
};
|
|
749
805
|
}
|
|
750
806
|
|
|
807
|
+
// 日志分文件(T3):三档之外的值一律拒在落盘前。
|
|
808
|
+
//
|
|
809
|
+
// 为什么必须拦在这里:这些键直接决定**文件往哪写**。收到一个拼错的
|
|
810
|
+
// 模式名后若放行,`normalizeSplitMode()` 会静默退回 date——用户以为切到了
|
|
811
|
+
// 「按会话」,实际仍在按日期写,文件名里也不会出现会话名。这种静默降级
|
|
812
|
+
// 只能靠落盘前的校验暴露出来。
|
|
813
|
+
if (Object.hasOwn(patch, "decisionsSplitMode") && !SPLIT_MODES.includes(patch.decisionsSplitMode)) {
|
|
814
|
+
return {
|
|
815
|
+
ok: false,
|
|
816
|
+
error: `未知的日志分文件方式: ${String(patch.decisionsSplitMode)}(可选 ${SPLIT_MODES.join(" / ")})`,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
if (Object.hasOwn(patch, "decisionsDateSeparator") && !DATE_SEPARATORS.includes(patch.decisionsDateSeparator)) {
|
|
820
|
+
return {
|
|
821
|
+
ok: false,
|
|
822
|
+
error: `未知的日期分隔符: ${String(patch.decisionsDateSeparator)}(可选 ${DATE_SEPARATORS.join(" / ")})`,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
if (Object.hasOwn(patch, "decisionsRetentionDays")) {
|
|
826
|
+
const days = patch.decisionsRetentionDays;
|
|
827
|
+
// 只认整数:`"30"` 这样字符串落盘后会被 Number() 读成 30,但界面的数字
|
|
828
|
+
// 输入框会把它当成非法值显示成空——两边对不上比直接拒绝更难排查。
|
|
829
|
+
if (typeof days !== "number" || !Number.isInteger(days) || days < 0 || days > 3650) {
|
|
830
|
+
return { ok: false, error: `日志保留天数必须是 0–3650 的整数: ${String(days)}` };
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
751
834
|
// 变量名必须能当环境变量用,否则凭据永远读不到。
|
|
752
835
|
// 逐个预设取它自己的变量名覆盖字段,而不是写死三个名字——否则新增预设时
|
|
753
836
|
// 漏加这一项,用户填的变量名会被原样写进 yml,然后永远读不到 key
|
|
@@ -1805,7 +1888,18 @@ class StartluxClient {
|
|
|
1805
1888
|
// 这样即便服务商将来换模型名,留空配置也不会因此 400。
|
|
1806
1889
|
const model = (provider.model ?? "").trim();
|
|
1807
1890
|
|
|
1808
|
-
|
|
1891
|
+
/**
|
|
1892
|
+
* 精确键必须包含 `thinkMode`:它改变**请求体**(`think_mode` 字段),
|
|
1893
|
+
* 同一份 state 在 `never` 与 `always` 下拿到的裁决可能不同。不把它算进
|
|
1894
|
+
* 键里,用户切换思考模式后还会命中旧裁决——「改了设置没效果」。
|
|
1895
|
+
*/
|
|
1896
|
+
const exactKey = DecisionCache.exactKey([
|
|
1897
|
+
endpoint,
|
|
1898
|
+
model,
|
|
1899
|
+
normalizeThinkMode(cfg.thinkMode),
|
|
1900
|
+
call.state,
|
|
1901
|
+
call.questions,
|
|
1902
|
+
]);
|
|
1809
1903
|
// 软键(归一化「同类」复用)默认关闭,fail-closed:只有调用方显式传
|
|
1810
1904
|
// `softCacheable: true` 才启用。授权(authorize)这类安全检查绝不启用——
|
|
1811
1905
|
// 归一化签名只保留「工具名 + 命令族 + 风险标记」,丢弃了参数内容,
|
|
@@ -2271,13 +2365,33 @@ export function apply(ctx, config) {
|
|
|
2271
2365
|
getServerOverride: serverOverride,
|
|
2272
2366
|
refreshServer: refreshServerInBackground,
|
|
2273
2367
|
});
|
|
2368
|
+
/**
|
|
2369
|
+
* 会话名 / 工作区名解析器(session / workspace 分文件模式的命名依据)。
|
|
2370
|
+
*
|
|
2371
|
+
* 实现放在模块级 `createSessionMetaResolver()`:读标题的顺序、隐私清洗
|
|
2372
|
+
* 与缓存都是**纯逻辑**,可以和宿主解耦单测(见 decisions-route.test.js)。
|
|
2373
|
+
* 这里只把宿主服务接进去。
|
|
2374
|
+
*/
|
|
2375
|
+
const sessionTitleService = () => {
|
|
2376
|
+
try {
|
|
2377
|
+
return ctx.get?.("sessionTitle");
|
|
2378
|
+
} catch {
|
|
2379
|
+
return undefined;
|
|
2380
|
+
}
|
|
2381
|
+
};
|
|
2382
|
+
const sessionMetaOf = createSessionMetaResolver({ getTitleService: sessionTitleService });
|
|
2383
|
+
|
|
2274
2384
|
/**
|
|
2275
2385
|
* 决策明细存储(「决策历史」页的数据源,见 lib/decision-store.js)。
|
|
2276
2386
|
*
|
|
2277
|
-
*
|
|
2278
|
-
*
|
|
2279
|
-
*
|
|
2280
|
-
* 只反映 `truncateContent()`
|
|
2387
|
+
* 所有取值函数都由配置现读,因此配置页改了开关/分文件方式立刻生效——
|
|
2388
|
+
* 不需要重启,与插件其余配置的处理方式一致。开关关闭时 `record()` 直接
|
|
2389
|
+
* 返回 false,既不建目录也不占磁盘;开启时明细一定带内容(每条记录上的
|
|
2390
|
+
* `capture` 只反映 `truncateContent()` 的截断结果,没有任何配置能把它
|
|
2391
|
+
* 降级成 meta)。
|
|
2392
|
+
*
|
|
2393
|
+
* 时区不单独配置:日期分片按**系统时区**切(用户看到的日历就是系统日历)。
|
|
2394
|
+
* 想按别处时区归档,是罕见需求;为它加一个配置键会把设置页撑得更长。
|
|
2281
2395
|
*/
|
|
2282
2396
|
const decisionStore = new DecisionStore({
|
|
2283
2397
|
dir: telemetryDir,
|
|
@@ -2285,6 +2399,10 @@ export function apply(ctx, config) {
|
|
|
2285
2399
|
getEnabled: () => cfg.decisionsTabEnabled !== false,
|
|
2286
2400
|
getMaxBytes: () => cfg.decisionsMaxBytes,
|
|
2287
2401
|
getMaxContentChars: () => cfg.decisionsMaxContentChars,
|
|
2402
|
+
getSplitMode: () => cfg.decisionsSplitMode,
|
|
2403
|
+
getDateSeparator: () => cfg.decisionsDateSeparator,
|
|
2404
|
+
getRetentionDays: () => cfg.decisionsRetentionDays,
|
|
2405
|
+
getTotalMaxBytes: () => cfg.decisionsTotalMaxBytes,
|
|
2288
2406
|
});
|
|
2289
2407
|
|
|
2290
2408
|
/**
|
|
@@ -2335,9 +2453,59 @@ export function apply(ctx, config) {
|
|
|
2335
2453
|
}
|
|
2336
2454
|
})
|
|
2337
2455
|
.catch(() => {
|
|
2338
|
-
/*
|
|
2456
|
+
/* 历史统计整理失败不影响插件启动 */
|
|
2457
|
+
})
|
|
2458
|
+
.then(() => {
|
|
2459
|
+
/**
|
|
2460
|
+
* 决策日志的两步整理,**必须排在统计回填之后、且自己串行**。
|
|
2461
|
+
*
|
|
2462
|
+
* 1. `migrateLegacyOnce()`:旧 `decisions.jsonl` → `logs/` 日期分片。
|
|
2463
|
+
* 它自己靠标记文件保证只做一次;失败只 warn,旧文件仍可读。
|
|
2464
|
+
* 2. `cleanupOnce()`:按保留天数 + 总量上限清理过期分片。
|
|
2465
|
+
*
|
|
2466
|
+
* 为什么不能与第 1 步并发:清理会枚举 `logs/`,若迁移正往目录里写分片,
|
|
2467
|
+
* 清理可能对着一个刚创建、还没写完的文件做删除判断(Windows 上删不掉、
|
|
2468
|
+
* 其它平台可能删掉半份数据)。迁移是毫秒级动作,串行没有成本。
|
|
2469
|
+
*
|
|
2470
|
+
* 注意上面那道 `.catch()` 的位置:统计整理失败时必须**继续**往下走,
|
|
2471
|
+
* 否则「旧遥测坏了」会连带把日志迁移也永久跳过(两个子系统本无关系)。
|
|
2472
|
+
*/
|
|
2473
|
+
return decisionStore.migrateLegacyOnce().then((migrated) => {
|
|
2474
|
+
if (migrated > 0) {
|
|
2475
|
+
ctx.logger?.info?.(
|
|
2476
|
+
`dsh-decision-engine: 已把 ${migrated} 条旧决策明细迁移到按日期分片(旧文件已改名 .migrated,未删除)`,
|
|
2477
|
+
);
|
|
2478
|
+
}
|
|
2479
|
+
return decisionStore.cleanupOnce();
|
|
2480
|
+
});
|
|
2481
|
+
})
|
|
2482
|
+
.then((report) => {
|
|
2483
|
+
if (report && report.removed > 0) {
|
|
2484
|
+
ctx.logger?.info?.(
|
|
2485
|
+
`dsh-decision-engine: 已清理 ${report.removed} 个过期决策日志分片(释放 ${report.removedBytes} 字节)`,
|
|
2486
|
+
);
|
|
2487
|
+
}
|
|
2488
|
+
})
|
|
2489
|
+
.catch(() => {
|
|
2490
|
+
/* 日志整理失败不影响插件启动 */
|
|
2339
2491
|
});
|
|
2340
2492
|
|
|
2493
|
+
/**
|
|
2494
|
+
* 定期清理(每 6 小时)。
|
|
2495
|
+
*
|
|
2496
|
+
* 为什么需要它:只在启动时清理的话,DSH 长期不重启(真实用法常常是开着
|
|
2497
|
+
* 几天)就会一直涨;而 `unref()` 保证这个定时器不会拦住进程退出。
|
|
2498
|
+
* 每一轮都是**尽力而为**:失败只 warn,下一轮再来。
|
|
2499
|
+
*
|
|
2500
|
+
* 6 小时的选择:清理本身很便宜(一次 readdir + 若干 unlink),而频率再高
|
|
2501
|
+
* 也不会有更多收益——保留期以「天」为单位。
|
|
2502
|
+
*/
|
|
2503
|
+
const decisionsCleanupTimer = setInterval(() => {
|
|
2504
|
+
void decisionStore.cleanupOnce().catch(() => {});
|
|
2505
|
+
}, 6 * 60 * 60 * 1000);
|
|
2506
|
+
// 定时器不该成为进程退出的理由(Node 的 unref 语义;测试进程尤为重要)。
|
|
2507
|
+
decisionsCleanupTimer.unref?.();
|
|
2508
|
+
|
|
2341
2509
|
|
|
2342
2510
|
/**
|
|
2343
2511
|
* 匿名使用统计(DEC-020)。
|
|
@@ -2623,6 +2791,8 @@ export function apply(ctx, config) {
|
|
|
2623
2791
|
...(result?.answers === undefined ? {} : { answers: result.answers }),
|
|
2624
2792
|
},
|
|
2625
2793
|
}),
|
|
2794
|
+
// 分文件模式要会话名/工作区名;守卫路径本来就握着 Session 对象。
|
|
2795
|
+
sessionMetaOf(options.session),
|
|
2626
2796
|
);
|
|
2627
2797
|
} catch (error) {
|
|
2628
2798
|
ctx.logger?.warn?.(
|
|
@@ -2664,10 +2834,14 @@ export function apply(ctx, config) {
|
|
|
2664
2834
|
* 决策明细(「决策历史」页)。与 `log`(遥测)是两条独立旁路:
|
|
2665
2835
|
* 遥测是「服务端说了什么」的结论口径,明细是本地的现场 + 会话归属。
|
|
2666
2836
|
* 这里补上限指针——截断策略属于存储层,引擎不该知道配置项叫什么。
|
|
2837
|
+
*
|
|
2838
|
+
* 第二个参数是**会话引用**(Session 对象或 id 字符串,由 engine.js 原样
|
|
2839
|
+
* 传下来):session/workspace 分文件模式要拿它的名字做文件名。
|
|
2667
2840
|
*/
|
|
2668
|
-
recordDetail: (record) =>
|
|
2841
|
+
recordDetail: (record, session) =>
|
|
2669
2842
|
decisionStore.record(
|
|
2670
2843
|
buildDecisionRecord({ ...record, maxContentChars: cfg.decisionsMaxContentChars }),
|
|
2844
|
+
sessionMetaOf(session),
|
|
2671
2845
|
),
|
|
2672
2846
|
});
|
|
2673
2847
|
|
|
@@ -3160,6 +3334,21 @@ export function apply(ctx, config) {
|
|
|
3160
3334
|
// 才是真值;回 `undefined` 会让界面把开关画成关。
|
|
3161
3335
|
approvalDetailReadable: cfg.approvalDetailReadable !== false,
|
|
3162
3336
|
decisionsFile: decisionStore.path,
|
|
3337
|
+
// 日志分文件与保留(T3):配置页的三行控件都靠这几项回显。
|
|
3338
|
+
//
|
|
3339
|
+
// **必须回传生效值**(归一化之后再回):用户可能把 config.yml 手改成
|
|
3340
|
+
// `decisionsSplitMode: Day` 这种拼错的值,`*normalize*` 会把它当 date;
|
|
3341
|
+
// 若照原样回显,界面会把「按日期」画成选中、却把一个不存在的选项
|
|
3342
|
+
// 塞进下拉——保存一次就又多了一个坏值。
|
|
3343
|
+
decisionsSplitMode: normalizeSplitMode(cfg.decisionsSplitMode),
|
|
3344
|
+
decisionsSplitModes: SPLIT_MODES,
|
|
3345
|
+
decisionsDateSeparator: normalizeDateSeparator(cfg.decisionsDateSeparator),
|
|
3346
|
+
decisionsDateSeparators: DATE_SEPARATORS,
|
|
3347
|
+
decisionsRetentionDays: decisionStore.retentionDays(),
|
|
3348
|
+
// 清理结果(文件数 / 字节数 / 上次清理时间):设置页用它说明
|
|
3349
|
+
// 「现在磁盘上大概有多少日志」。这是**上次清理时**的快照,不是实时
|
|
3350
|
+
// 扫描——状态接口不该每次都去 stat 一整个目录。
|
|
3351
|
+
decisionsStorage: decisionStore.storageReport(),
|
|
3163
3352
|
// 人工审核比例(DEC-035):配置页滑块的唯一数据源。
|
|
3164
3353
|
tolerance: tolerancePayload(),
|
|
3165
3354
|
stats: client.stats,
|
|
@@ -3282,6 +3471,12 @@ export function apply(ctx, config) {
|
|
|
3282
3471
|
// 改截断长度),不是用户日常会拨的开关,走配置页会把界面变复杂;
|
|
3283
3472
|
// 需要时手改 config.yml 即可。
|
|
3284
3473
|
"decisionsTabEnabled",
|
|
3474
|
+
// 日志分文件与保留(T3):这三个是**用户日常会拨**的开关,必须能
|
|
3475
|
+
// 保存。`decisionsTotalMaxBytes` 与 `decisionsMaxBytes` 则是运维
|
|
3476
|
+
// 旋钮,刻意不在白名单里(理由见上面那段)。
|
|
3477
|
+
"decisionsSplitMode",
|
|
3478
|
+
"decisionsDateSeparator",
|
|
3479
|
+
"decisionsRetentionDays",
|
|
3285
3480
|
// 审批卡正文可读化(DEC-042):配置页要能拨,否则拨了也存不下去。
|
|
3286
3481
|
"approvalDetailReadable",
|
|
3287
3482
|
// 服务端下发开关(DEC-030):不在白名单里的后果与上面 telemetryLevel
|
|
@@ -3684,7 +3879,8 @@ export function apply(ctx, config) {
|
|
|
3684
3879
|
* 与 `/log` 的区别不是路径而是**口径**:
|
|
3685
3880
|
*
|
|
3686
3881
|
* - `/log` 读的是 telemetry.jsonl(全机、无会话维度、只有结论);
|
|
3687
|
-
* - 这里读的是
|
|
3882
|
+
* - 这里读的是 `logs/` 下的决策明细分片(有会话归属、有决策内容;
|
|
3883
|
+
* 默认按日期分片,跨分片读取后按会话过滤)。
|
|
3688
3884
|
*
|
|
3689
3885
|
* `sessionId` 是**必填**的:没有会话参数时返回 400 而不是「返回全部」。
|
|
3690
3886
|
* 返回全部会让人以为「这个会话里居然有别的会话的记录」,而正确的回答是
|
|
@@ -3714,7 +3910,11 @@ export function apply(ctx, config) {
|
|
|
3714
3910
|
scanned: page.scanned,
|
|
3715
3911
|
truncated: page.truncated,
|
|
3716
3912
|
// 文件路径给排查用:用户常问「记录到底写到哪了」。
|
|
3913
|
+
// `file` 是**最新读到的那个分片**(兼容旧界面的单文件展示),
|
|
3914
|
+
// `files` 是本次实际读的全部分片(从新到旧)——分文件模式下
|
|
3915
|
+
// 一个会话的记录可能跨多个文件,界面要能显示「几个文件、在哪」。
|
|
3717
3916
|
file: page.file,
|
|
3917
|
+
files: page.files,
|
|
3718
3918
|
// 单条内容长度上限:界面据此说明内容可能被截断。截断是内容丢失的
|
|
3719
3919
|
// **唯一**来源——没有任何配置键能把整条内容剥成只留元数据。
|
|
3720
3920
|
contentLimit: cfg.decisionsMaxContentChars,
|
|
@@ -3769,6 +3969,103 @@ function stripUndefined(source) {
|
|
|
3769
3969
|
return out;
|
|
3770
3970
|
}
|
|
3771
3971
|
|
|
3972
|
+
/** 会话名解析缓存的默认上限(条)。 */
|
|
3973
|
+
const SESSION_META_CACHE_LIMIT = 512;
|
|
3974
|
+
|
|
3975
|
+
/**
|
|
3976
|
+
* 造一个「会话 → { sessionName, workspaceName }」的解析器。
|
|
3977
|
+
*
|
|
3978
|
+
* 这两个名字是 `session` / `workspace` 分文件模式的**文件名来源**,但它们
|
|
3979
|
+
* 只挂在宿主 Session 上(记录体里只有 id),所以要在 index 侧反查。取名字
|
|
3980
|
+
* 的顺序是「越接近用户所见越优先」:
|
|
3981
|
+
*
|
|
3982
|
+
* 1. 宿主 `sessionTitle` 服务给出的标题(DSH 界面上显示的就是它);
|
|
3983
|
+
* 2. `session.header.title`(老宿主 / 测试里的形态);
|
|
3984
|
+
* 3. 会话 cwd 的目录名(没有标题时,目录名比一串 uuid 有用得多);
|
|
3985
|
+
* 4. `unknown`。
|
|
3986
|
+
*
|
|
3987
|
+
* 工作区名:`workspaceRoots[0]`(`session.header.workspaceRoots` /
|
|
3988
|
+
* `session.workspaceRoots` / `session.header.workspaceRoot`)的基名,
|
|
3989
|
+
* 拿不到再退回 cwd 的基名,最后退回进程 cwd。
|
|
3990
|
+
*
|
|
3991
|
+
* **隐私**:名字会进文件名,所以先过 `maskSecrets()` 再截断到 64 字符——
|
|
3992
|
+
* 会话标题里贴过的一次 key 不该出现在目录列表里。`session` 模式另有 id
|
|
3993
|
+
* 后缀兜底,名字缺失时也不会串会话。
|
|
3994
|
+
*
|
|
3995
|
+
* 按 sessionId 缓存(上限 512):一条会话会写很多条明细,每次都去问宿主
|
|
3996
|
+
* 服务既慢又可能抛错;标题改名是低频事件,重启后自然刷新。
|
|
3997
|
+
*
|
|
3998
|
+
* @param options.getTitleService - 返回宿主 `sessionTitle` 服务的取值函数。
|
|
3999
|
+
* @returns `(session) => { sessionName, workspaceName }`;永不抛错。
|
|
4000
|
+
*/
|
|
4001
|
+
export function createSessionMetaResolver(options = {}) {
|
|
4002
|
+
const cache = new Map();
|
|
4003
|
+
const limit = Number.isFinite(options.cacheLimit) && options.cacheLimit > 0
|
|
4004
|
+
? Math.floor(options.cacheLimit)
|
|
4005
|
+
: SESSION_META_CACHE_LIMIT;
|
|
4006
|
+
const getTitleService =
|
|
4007
|
+
typeof options.getTitleService === "function" ? options.getTitleService : () => undefined;
|
|
4008
|
+
const basenameOfPath = (value) => {
|
|
4009
|
+
try {
|
|
4010
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
4011
|
+
return text.length > 0 ? basename(text) : "";
|
|
4012
|
+
} catch {
|
|
4013
|
+
return "";
|
|
4014
|
+
}
|
|
4015
|
+
};
|
|
4016
|
+
const privacyName = (value, fallback) => {
|
|
4017
|
+
let text = typeof value === "string" ? value.trim() : "";
|
|
4018
|
+
text = maskSecrets(text);
|
|
4019
|
+
if (text.length === 0) text = fallback;
|
|
4020
|
+
return String(text).slice(0, 64);
|
|
4021
|
+
};
|
|
4022
|
+
return (session) => {
|
|
4023
|
+
try {
|
|
4024
|
+
const id = typeof session === "string"
|
|
4025
|
+
? session
|
|
4026
|
+
: typeof session?.id === "string"
|
|
4027
|
+
? session.id
|
|
4028
|
+
: "";
|
|
4029
|
+
const cached = id.length > 0 ? cache.get(id) : undefined;
|
|
4030
|
+
if (cached !== undefined) return cached;
|
|
4031
|
+
let title = "";
|
|
4032
|
+
try {
|
|
4033
|
+
const titled = getTitleService()?.get?.(session);
|
|
4034
|
+
if (typeof titled?.title === "string") title = titled.title;
|
|
4035
|
+
} catch {
|
|
4036
|
+
/* 宿主没有这个服务时照常往下走 */
|
|
4037
|
+
}
|
|
4038
|
+
if (title.length === 0 && typeof session?.header?.title === "string") {
|
|
4039
|
+
title = session.header.title;
|
|
4040
|
+
}
|
|
4041
|
+
const cwd = typeof session?.header?.cwd === "string" ? session.header.cwd : "";
|
|
4042
|
+
if (title.length === 0) title = basenameOfPath(cwd);
|
|
4043
|
+
const roots = Array.isArray(session?.header?.workspaceRoots)
|
|
4044
|
+
? session.header.workspaceRoots
|
|
4045
|
+
: Array.isArray(session?.workspaceRoots)
|
|
4046
|
+
? session.workspaceRoots
|
|
4047
|
+
: typeof session?.header?.workspaceRoot === "string"
|
|
4048
|
+
? [session.header.workspaceRoot]
|
|
4049
|
+
: [];
|
|
4050
|
+
const workspaceRoot = typeof roots[0] === "string" ? roots[0] : "";
|
|
4051
|
+
const workspace = basenameOfPath(workspaceRoot) || basenameOfPath(cwd) || basenameOfPath(process.cwd());
|
|
4052
|
+
const meta = {
|
|
4053
|
+
// 会话名兜底用会话 id:至少文件名可读、可相关性对照。
|
|
4054
|
+
sessionName: privacyName(title, id.length > 0 ? id : "unknown"),
|
|
4055
|
+
workspaceName: privacyName(workspace, "unknown"),
|
|
4056
|
+
};
|
|
4057
|
+
if (id.length > 0) {
|
|
4058
|
+
cache.set(id, meta);
|
|
4059
|
+
if (cache.size > limit) cache.delete(cache.keys().next().value);
|
|
4060
|
+
}
|
|
4061
|
+
return meta;
|
|
4062
|
+
} catch {
|
|
4063
|
+
// 解析失败只能退化成 unknown:分文件名不该因为宿主形状变化而抛错。
|
|
4064
|
+
return { sessionName: "unknown", workspaceName: "unknown" };
|
|
4065
|
+
}
|
|
4066
|
+
};
|
|
4067
|
+
}
|
|
4068
|
+
|
|
3772
4069
|
/**
|
|
3773
4070
|
* 从完整端点里取出它的路径风格(`decisions` / `systemone`)。
|
|
3774
4071
|
*
|
package/lib/outcomes.js
CHANGED
|
@@ -59,7 +59,9 @@ export const OUTCOMES = ["allow", "ask", "reject"];
|
|
|
59
59
|
*/
|
|
60
60
|
export const OUTCOME_LABELS = {
|
|
61
61
|
allow: "放行",
|
|
62
|
-
|
|
62
|
+
// 「转人工」而不是「询问」(T4 统一口径):这一格的意思是「升级到人来确认」,
|
|
63
|
+
// 不是「问了一句」。裁决历史页的裁决格用的是同一个词,两处必须一致。
|
|
64
|
+
ask: "转人工",
|
|
63
65
|
reject: "拦截",
|
|
64
66
|
none: "未裁决",
|
|
65
67
|
};
|
package/lib/providers.js
CHANGED
|
@@ -93,9 +93,9 @@ const ALTERNATE_STYLE = { decisions: "systemone", systemone: "decisions" };
|
|
|
93
93
|
/**
|
|
94
94
|
* 合法的思考模式(与 startlux 服务的 `think_mode` 取值一致)。
|
|
95
95
|
*
|
|
96
|
-
* never
|
|
97
|
-
* auto
|
|
98
|
-
* always
|
|
96
|
+
* never 不思考——最省延迟(bamboonode 实测;默认值)
|
|
97
|
+
* auto 由服务端决定——实测比 never 慢(约 7.2s vs 1.9s)
|
|
98
|
+
* always 总是思考——每次多等约 5s;质量收益尚未实测
|
|
99
99
|
*
|
|
100
100
|
* 单独导出是为了让校验、前端下拉、文档三处只有一份取值来源。
|
|
101
101
|
*/
|
|
@@ -131,7 +131,7 @@ export function normalizeThinkMode(raw) {
|
|
|
131
131
|
* 默认模型与三个配置字段名。用户选了预设就用这里的值,不需要在设置界面
|
|
132
132
|
* 重复填写。
|
|
133
133
|
*
|
|
134
|
-
*
|
|
134
|
+
* 三个可选标记(都是「按服务商实测」得出的,不是风格偏好):
|
|
135
135
|
*
|
|
136
136
|
* - `requiresModel: true`:该端点**不接受**省略 model 的请求(实测
|
|
137
137
|
* SiliconFlow 的 /v1/systemone 省略或空串都回 400
|
|
@@ -144,6 +144,14 @@ export function normalizeThinkMode(raw) {
|
|
|
144
144
|
* `supported_endpoint_types`(实测 SiliconFlow 返回 98 个,其中 chat
|
|
145
145
|
* 模型打到 /v1/systemone 是 400 `Model does not exist.`)。
|
|
146
146
|
* 列了这份清单就按它筛,不让下拉里出现「选了就失败」的选项。
|
|
147
|
+
* - `thinkModeSupport`:`think_mode` 这个字段该端点认不认。`supported` =
|
|
148
|
+
* 实测认(bamboonode 的决策端点就是靠它区分 never / auto / always);
|
|
149
|
+
* `unknown` = 未实测(TypeSafe、两个 SiliconFlow 站点与 custom 网关都会
|
|
150
|
+
* 收下请求,但「思考」是否真被兑现没有验证过——SiliconFlow 的实测只覆盖
|
|
151
|
+
* 了路径与 model 必填,没覆盖 think_mode);`unsupported` = 公开 API 是
|
|
152
|
+
* chat 补全形状、没有 think_mode 这个字段(Perplexity / OpenRouter)。
|
|
153
|
+
* 界面据此决定要不要提示「切 always 会更快/更慢」,而不是对每家的行为
|
|
154
|
+
* 都编一套说法。
|
|
147
155
|
*/
|
|
148
156
|
export const PROVIDER_PRESETS = {
|
|
149
157
|
systemone: {
|
|
@@ -160,6 +168,9 @@ export const PROVIDER_PRESETS = {
|
|
|
160
168
|
defaultModel: "jev-latest",
|
|
161
169
|
modelField: "systemoneModel",
|
|
162
170
|
keyEnvField: "systemoneApiKeyEnv",
|
|
171
|
+
// 请求体形状与 bamboonode 相同(见模块头的线协议约定),但该端点对
|
|
172
|
+
// think_mode 是否真被兑现未实测,因此给 unknown 而不是 supported。
|
|
173
|
+
thinkModeSupport: "unknown",
|
|
163
174
|
hint: "TypeSafe / Jev 的 System One 协议(POST /v1/systemone),"
|
|
164
175
|
+ "也是 llama.cpp、vLLM 等自建决策服务通用的线协议",
|
|
165
176
|
hintEn: "TypeSafe / Jev System One protocol (POST /v1/systemone); "
|
|
@@ -181,6 +192,9 @@ export const PROVIDER_PRESETS = {
|
|
|
181
192
|
defaultModel: "startlux-decision-v1.1-27b-autothink",
|
|
182
193
|
modelField: "model",
|
|
183
194
|
keyEnvField: "bamboonodeApiKeyEnv",
|
|
195
|
+
// 这是 think_mode 的原始出处:never / auto / always 三档由该端点兑现
|
|
196
|
+
//(实测 never ≈1.9s、auto ≈7.2s;always 的额外耗时未单独实测)。
|
|
197
|
+
thinkModeSupport: "supported",
|
|
184
198
|
hint: "StartLux 官方端点(POST /v1/decisions),本插件的原有默认值",
|
|
185
199
|
hintEn: "StartLux official endpoint (POST /v1/decisions); the original default",
|
|
186
200
|
},
|
|
@@ -208,6 +222,9 @@ export const PROVIDER_PRESETS = {
|
|
|
208
222
|
// 不带 supported_endpoint_types(实测把 chat 模型打过来是 400
|
|
209
223
|
// `Model does not exist.`),所以按这份清单筛,不让下拉里出现选了就坏的项。
|
|
210
224
|
knownModels: ["Kev-4B", "SemIf"],
|
|
225
|
+
// think_mode 在 SiliconFlow 上没有实测过(实测只覆盖了路径与 model 必填),
|
|
226
|
+
// 因此给 unknown:界面不该声称「切 always 会更准」或「会更快」。
|
|
227
|
+
thinkModeSupport: "unknown",
|
|
211
228
|
modelField: "siliconflowModel",
|
|
212
229
|
keyEnvField: "siliconflowApiKeyEnv",
|
|
213
230
|
hint: "硅基流动(国内站)的 System One 端点(POST /v1/systemone);可用模型 Kev-4B / SemIf,默认 Kev-4B,只填一个 API key 即可使用",
|
|
@@ -228,6 +245,8 @@ export const PROVIDER_PRESETS = {
|
|
|
228
245
|
requiresModel: true,
|
|
229
246
|
defaultModel: "Kev-4B",
|
|
230
247
|
knownModels: ["Kev-4B", "SemIf"],
|
|
248
|
+
// 与国内站同源,同样没有覆盖过 think_mode 的实测。
|
|
249
|
+
thinkModeSupport: "unknown",
|
|
231
250
|
modelField: "siliconflowIntlModel",
|
|
232
251
|
keyEnvField: "siliconflowIntlApiKeyEnv",
|
|
233
252
|
hint: "Silicon Flow(国际站)的 System One 端点(POST /v1/systemone);与国内站账号不通用,可用模型 Kev-4B / SemIf,默认 Kev-4B,只填一个 API key 即可使用",
|
|
@@ -251,6 +270,9 @@ export const PROVIDER_PRESETS = {
|
|
|
251
270
|
// 占位值,未实测。
|
|
252
271
|
requiresModel: true,
|
|
253
272
|
defaultModel: "sonar",
|
|
273
|
+
// Perplexity 的公开 API 是 chat 补全形状,没有 think_mode 这个字段:
|
|
274
|
+
// 带上它只会被忽略或 400,因此明确标 unsupported。
|
|
275
|
+
thinkModeSupport: "unsupported",
|
|
254
276
|
modelField: "perplexityModel",
|
|
255
277
|
keyEnvField: "perplexityApiKeyEnv",
|
|
256
278
|
hint: "Perplexity Decider 端点(api.perplexity.ai);地址与默认模型待实测,接通后建议把端点风格钉死",
|
|
@@ -270,6 +292,8 @@ export const PROVIDER_PRESETS = {
|
|
|
270
292
|
// requiresModel;默认 slug 是占位值,未实测,换模型直接改配置即可。
|
|
271
293
|
requiresModel: true,
|
|
272
294
|
defaultModel: "openai/gpt-4o-mini",
|
|
295
|
+
// 同 Perplexity:OpenRouter 的 /chat/completions 形状里没有 think_mode。
|
|
296
|
+
thinkModeSupport: "unsupported",
|
|
273
297
|
modelField: "openrouterModel",
|
|
274
298
|
keyEnvField: "openrouterApiKeyEnv",
|
|
275
299
|
hint: "OpenRouter 端点(openrouter.ai/api/v1);模型填 OpenRouter 的 slug,地址与默认模型待实测",
|
|
@@ -287,6 +311,9 @@ export const PROVIDER_PRESETS = {
|
|
|
287
311
|
legacyApiKeyEnvs: [],
|
|
288
312
|
keyField: "customApiKey",
|
|
289
313
|
defaultModel: "",
|
|
314
|
+
// 用户自己的服务:认不认 think_mode 只有他知道,因此 unknown 而不是
|
|
315
|
+
// supported——能力位是「已知事实」,不是「希望如此」。
|
|
316
|
+
thinkModeSupport: "unknown",
|
|
290
317
|
modelField: "customModel",
|
|
291
318
|
keyEnvField: "customApiKeyEnv",
|
|
292
319
|
hint: "任意提供决策端点的服务:自建 llama.cpp / vLLM、企业网关、第三方中转",
|
|
@@ -624,15 +651,23 @@ export function parseCustomHeaders(raw) {
|
|
|
624
651
|
*
|
|
625
652
|
* @param cfg - 已合并的配置对象(可能缺字段)。
|
|
626
653
|
* @param options.server - 服务端下发的覆盖值(`{ baseUrl, model, endpointStyle }`)。
|
|
627
|
-
*
|
|
628
|
-
*
|
|
654
|
+
* 下发载荷里**没有** thinkMode 这一项(见 `server-config.js` 的 `parseServerConfig`,
|
|
655
|
+
* 它只返回 baseUrl / model / endpointStyle / key / ttlSeconds)。存在时整段走
|
|
656
|
+
* 覆盖分支,本地配置只保留 `thinkMode` 一项:它是**本机行为偏好**(省延迟还是
|
|
657
|
+
* 判得更充分),与「往哪发、用谁的 key」无关,跟着服务端下发一起丢掉会让用户
|
|
658
|
+
* 配的 `always` 静默退回 `never`。其余本地字段一律不读——服务端下发代表
|
|
659
|
+
* **当前权威配置**,若还让本地字段参与覆盖,用户机器上残留的旧地址就会盖掉
|
|
660
|
+
* 管理员的改动。
|
|
629
661
|
* @returns 生效配置;`valid=false` 时 `error` 是可展示的中文原因。
|
|
630
662
|
*/
|
|
631
663
|
export function resolveProvider(cfg = {}, options = {}) {
|
|
632
664
|
// 只有拿到对象才算「服务端下发了配置」:null / 字符串等一律视为没有,
|
|
633
665
|
// 完全走既有逻辑。既有调用方只传 cfg,因此升级后行为逐字不变。
|
|
634
666
|
const server = options?.server;
|
|
635
|
-
if (server !== null && typeof server === "object")
|
|
667
|
+
if (server !== null && typeof server === "object") {
|
|
668
|
+
// 可选链:这条早退分支过去完全不读 cfg,别为了一项本机偏好引入新的抛错点。
|
|
669
|
+
return buildServerProvider(server, cfg?.thinkMode);
|
|
670
|
+
}
|
|
636
671
|
|
|
637
672
|
const requested = typeof cfg.provider === "string" ? cfg.provider.trim() : "";
|
|
638
673
|
const id = PROVIDER_ID_SET.has(requested) ? requested : DEFAULT_PROVIDER;
|
|
@@ -746,14 +781,21 @@ export function resolveProvider(cfg = {}, options = {}) {
|
|
|
746
781
|
* 因此这里 `explicitKey` 为空、`apiKeyEnv` 也为空。刻意不留本地变量名:
|
|
747
782
|
* 留了就会在下发失效时退回用户自己的 key 打向服务端地址,那是拿错了
|
|
748
783
|
* 账号的 key,而守卫是 fail-open 的,表现为静默失效。宁可「没有 key」。
|
|
784
|
+
* 3. `thinkMode` 仍是**本机**设定(由 `resolveProvider` 从 cfg 传进来):
|
|
785
|
+
* 它是省延迟还是判得更充分的偏好,不属于「往哪发、用谁的 key」这套下发
|
|
786
|
+
* 契约。此前这里整个字段缺失,`buildRequestBody` 的
|
|
787
|
+
* `normalizeThinkMode(payload.thinkMode ?? provider.thinkMode)` 于是收到
|
|
788
|
+
* undefined → 恒为 never,用户配的 `always` 在服务端下发期间静默失效。
|
|
749
789
|
*
|
|
750
790
|
* base URL 非法时不抛错,只把 `valid` 置 false 并给出中文原因:解析点遍布
|
|
751
791
|
* 请求路径,抛错会把一次「配置暂时不可用」放大成决策失败。
|
|
752
792
|
*
|
|
753
793
|
* @param server - 服务端下发的覆盖值,字段可能缺失或类型不对。
|
|
794
|
+
* @param configuredThinkMode - 本机配置里的 `thinkMode`(未归一,交给
|
|
795
|
+
* `normalizeThinkMode` 兜底)。
|
|
754
796
|
* @returns 与预设分支同形状的 provider 对象。
|
|
755
797
|
*/
|
|
756
|
-
function buildServerProvider(server) {
|
|
798
|
+
function buildServerProvider(server, configuredThinkMode) {
|
|
757
799
|
const checked = validateBaseUrl(server.baseUrl);
|
|
758
800
|
// 风格必须是三者之一;缺失或非法回落 auto,避免拼出 `/undefined` 这类路径。
|
|
759
801
|
const endpointStyle = ENDPOINT_STYLES.includes(server.endpointStyle) ? server.endpointStyle : "auto";
|
|
@@ -772,6 +814,8 @@ function buildServerProvider(server) {
|
|
|
772
814
|
candidates: [...plan.candidates],
|
|
773
815
|
endpointStyle,
|
|
774
816
|
model,
|
|
817
|
+
// 与两个本地分支逐字相同:非法/缺失一律归一为 never。
|
|
818
|
+
thinkMode: normalizeThinkMode(configuredThinkMode),
|
|
775
819
|
headers: {},
|
|
776
820
|
apiKeyEnv: "",
|
|
777
821
|
legacyApiKeyEnvs: [],
|
|
@@ -918,6 +962,11 @@ export function providerCatalog() {
|
|
|
918
962
|
// 都由预设表下发,前端不必知道任何一家服务商的脾气。
|
|
919
963
|
requiresModel: preset.requiresModel === true,
|
|
920
964
|
knownModels: [...(preset.knownModels ?? [])],
|
|
965
|
+
// think_mode 能力位:`supported` / `unknown` / `unsupported`。
|
|
966
|
+
// 界面据此决定「思考模式」那一格要不要提示代价、要不要警告可能无效,
|
|
967
|
+
// 而不是对每家服务商各编一套说法。缺省按 unknown 处理——未知不等于
|
|
968
|
+
// 可用,也不等于不可用。
|
|
969
|
+
thinkModeSupport: preset.thinkModeSupport ?? "unknown",
|
|
921
970
|
hint: preset.hint,
|
|
922
971
|
hintEn: preset.hintEn,
|
|
923
972
|
customizable: id === "custom",
|