dsh-decision-engine 0.5.1 → 0.6.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 CHANGED
@@ -66,6 +66,29 @@ cd ~/.dsh/profiles/desktop && pnpm install
66
66
  (笔记、备份)一概不碰。
67
67
  - **权限 600**:配置里可能有明文 key,迁移后仍保持只有你可读。
68
68
 
69
+ ### 数据目录解析规则(跨平台)
70
+
71
+ 数据目录是 **`$DSH_HOME/dsh-decision-engine/`**,`~/.dsh` 只是 `DSH_HOME`
72
+ 未设置时的默认值。解析顺序与宿主 `@deepseek-ai/dsh-home-paths` 完全一致:
73
+
74
+ | 优先级 | 来源 |
75
+ |---|---|
76
+ | 1 | 显式配置的路径(本插件暂无此入口) |
77
+ | 2 | 环境变量 `$DSH_HOME` |
78
+ | 3 | 操作系统家目录下的 `~/.dsh` |
79
+
80
+ 细节(都由 `lib/home-paths.js` 统一处理):
81
+
82
+ - **空白值等于未设置**:`DSH_HOME=""` 或纯空白会回退 `~/.dsh`,绝不会把数据
83
+ 目录解析到当前工作目录。
84
+ - **支持波浪号**:`DSH_HOME=~/myhome` 会展开;`~\`(Windows 形式)同样支持。
85
+ - **相对路径会被规范化成绝对路径**,否则落盘位置会随进程工作目录漂移。
86
+
87
+ **Windows 说明**:`~/.dsh` 里的 `.dsh` 只是**目录名**,不是路径分隔符——
88
+ Windows 允许点开头的目录。真正与平台相关的是分隔符与家目录位置,二者分别由
89
+ `node:path` 与 `node:os` 处理,所以 Windows 上实际路径是
90
+ `C:\Users\<你的用户名>\.dsh\dsh-decision-engine\config.yml`。
91
+
69
92
  ## 配置
70
93
 
71
94
  ### 插件配置页
@@ -77,14 +100,21 @@ cd ~/.dsh/profiles/desktop && pnpm install
77
100
  - 两个开关:`启用守卫`、`真正拦截`
78
101
  - **思考模式**下拉:`never` / `auto` / `always`(见下文「省 token 的手段」里的取舍)
79
102
  - **模型**下拉(来自当前 provider 的 `/v1/models`,含「自动(服务端默认)」)
80
- - 一个 **API Key 输入框** + `保存` 按钮
103
+ - 一个 **API Key 输入框**
81
104
  - 选 `custom` 时展开四个字段:`Base URL`(必填)、`端点风格`、`附加请求头`、`密钥变量名`
82
105
  - 一个 `测试端点` 按钮(回显 provider、实际 POST 的完整地址、HTTP 码、请求 id 与响应体)
83
106
  - **决策模型参与点**列表:每个决策点一行,右侧是 `关闭 / 观察 / 生效` 三态
84
107
  - `自身 token 预算` 输入框,以及实时的缓存命中率与每个点的 token / 延迟统计
85
108
 
86
- 改完点`保存`即写入 `config.yml`,**不需要重启**:监听器常驻注册,provider 与
87
- 模式在每次调用时现读;预算与缓存参数同样是取值函数而非启动期快照。
109
+ **改完即保存,没有「保存」按钮**:开关 / 下拉 / 分段控件点一下就落盘;文本框
110
+ (地址、请求头、预算)停手约 0.7 秒或失焦后落盘;API Key 只在**失焦或回车**
111
+ 时落盘(避免把打字途中的半截 key 写进凭据文件)。底部状态行实时显示
112
+ `待保存… / 保存中… / 已保存 / 未保存:原因`,失败时附「重试」。落盘即写入
113
+ `config.yml`,**不需要重启**:监听器常驻注册,provider 与模式在每次调用时现读;
114
+ 预算与缓存参数同样是取值函数而非启动期快照。
115
+
116
+ 一个例外:选 `custom` 但 `Base URL` 还没填或不合法时,配置改动会**整体延后**
117
+ (状态行给出原因),地址补全后自动落盘;此时决策点改动不受影响,照常保存。
88
118
 
89
119
  ## Provider:接任意 system-one 风格服务
90
120
 
@@ -170,7 +200,7 @@ content-type: application/json
170
200
  | `credentials`(默认) | `~/.dsh/.credentials.yaml` 的 `refs:` 段 | 与其他服务(DeepSeek / ModelScope …)统一托管,插件目录不留明文 |
171
201
  | `config` | 插件配置目录下的 `config.yml` | 希望配置自包含、能随目录整体备份或迁移;注意是**明文** |
172
202
 
173
- 在设置界面「密钥保存位置」下拉切换,保存后立即按新位置写入;已有的 key 不会被
203
+ 在设置界面「密钥保存位置」下拉切换,切换即按新位置写入;已有的 key 不会被
174
204
  自动搬家(避免"看不到的地方被改动"),需要时重新粘贴一次即可。
175
205
 
176
206
  写入 `credentials` 的行为约束:
@@ -187,7 +217,7 @@ content-type: application/json
187
217
 
188
218
  | 情形 | 表现 |
189
219
  |---|---|
190
- | base URL 非法 / 为空(custom) | 保存直接被拒(HTTP 400 + 具体原因,如「必须以 http:// 或 https:// 开头」),**不会写进 yml** |
220
+ | base URL 非法 / 为空(custom) | 该次落盘直接被拒(HTTP 400 + 具体原因,如「必须以 http:// 或 https:// 开头」),**不会写进 yml**;自动保存会一直挂着「未保存」并允许重试 |
191
221
  | 密钥缺失 | 不发请求,`no-key` 降级(记录里带该 provider 的变量名) |
192
222
  | 鉴权失败(401/403) | `auth-failed` 降级,提示检查该 provider 的 key 是否有效、是否与地址匹配 |
193
223
  | 地址错(404/400) | `http-404` / `http-400` 降级,记录里带 `provider`、`endpoint` 与响应体 |
@@ -393,7 +423,7 @@ pnpm verify # 前两条一起跑
393
423
  | `test/engine.test.js` | 适配器接线、off/observe/enforce 语义、全部降级分支 |
394
424
  | `test/providers.test.js` | provider 预设、地址拼接、URL 校验、鉴权与请求构造 |
395
425
  | `test/config-file.test.js` | `config.yml` 读写往返:保存后再打开的回显、切换不互相覆盖 |
396
- | `test/client-ui.test.js` | 设置界面渲染:provider 下拉、custom 字段、错误提示 |
426
+ | `test/client-ui.test.js` | 设置界面渲染与交互:provider 下拉、custom 字段、错误提示、**改动即保存**(无保存按钮、改动自动 PUT /config、API Key 失焦提交) |
397
427
 
398
428
  `test/providers.test.js` 是 provider 改造的验收用例(不依赖宿主依赖,
399
429
  裸仓库可直接跑),覆盖六项硬约束:
package/lib/client.js CHANGED
@@ -83,7 +83,7 @@ window.__ModuleLoader__.load({
83
83
  background: "#fff",
84
84
  transition: "transform .15s ease",
85
85
  },
86
- /** 保存按钮。 */
86
+ /** 小按钮(获取模型 / 测试端点 / 重试)。 */
87
87
  saveBtn: {
88
88
  padding: "6px 14px",
89
89
  borderRadius: "6px",
@@ -216,13 +216,164 @@ window.__ModuleLoader__.load({
216
216
  return data;
217
217
  }
218
218
 
219
- /** 主面板:配置 + 状态 + 一键测试 + 最近决策。 */
219
+ /**
220
+ * 自动保存的防抖时长:文本框停手多久之后落盘。
221
+ *
222
+ * 太短会把打字过程中的半截值写进 yml(每个字符一次请求),太长则
223
+ * 「改完立刻关掉面板」会丢改动。700ms 是这两者之间的常见折中。
224
+ */
225
+ const AUTOSAVE_DELAY_MS = 700;
226
+
227
+ /**
228
+ * 自定义 base URL 的问题描述,空串表示可用。
229
+ *
230
+ * 规则与服务端 `validateProviderPatch` 一致:本地先判,是为了让「打字打到一半
231
+ * 的非法地址」停在本地(显示红字),而不是每停一下就发一次注定 400 的请求。
232
+ *
233
+ * @param rawUrl - 用户填写的地址(未 trim)。
234
+ * @param provider - 当前 provider;只有 custom 时空地址才是「必填缺失」。
235
+ */
236
+ function customUrlProblem(rawUrl, provider) {
237
+ const url = (rawUrl ?? "").trim();
238
+ if (url.length === 0) {
239
+ return provider === "custom" ? "必填:请填写 system-one 风格服务的 base URL" : "";
240
+ }
241
+ if (/\s/.test(url)) return "不能包含空格或换行";
242
+ if (!/^https?:\/\//i.test(url)) return "必须以 http:// 或 https:// 开头";
243
+ try {
244
+ const parsed = new URL(url);
245
+ if (parsed.search.length > 0) return "不要带查询串(? 之后的部分)";
246
+ } catch {
247
+ return "不是合法地址(示例:https://api.example.com/v1)";
248
+ }
249
+ return "";
250
+ }
251
+
252
+ /**
253
+ * 待落盘的决策点/预算改动,没有改动时返回 null。
254
+ *
255
+ * 与 `/decisions` 的 PUT body 同形(`{ points?, budgetTokens? }`):
256
+ * 服务端对空 body 直接 400,所以「没改动就不发」必须由调用方保证。
257
+ */
258
+ function pendingDecisions(values, decisions) {
259
+ const changed = {};
260
+ for (const [id, mode] of Object.entries(values.pointsDraft ?? {})) {
261
+ const original = (decisions?.points ?? []).find((item) => item.id === id);
262
+ if (original !== undefined && original.mode !== mode) changed[id] = mode;
263
+ }
264
+ const limit = decisions?.budget?.limit ?? 0;
265
+ const budget =
266
+ values.budgetDraft !== null && values.budgetDraft !== undefined && values.budgetDraft !== limit
267
+ ? values.budgetDraft
268
+ : null;
269
+ const hasPoints = Object.keys(changed).length > 0;
270
+ if (hasPoints !== true && budget === null) return null;
271
+ const body = {};
272
+ if (hasPoints === true) body.points = changed;
273
+ if (budget !== null) body.budgetTokens = budget;
274
+ return body;
275
+ }
276
+
277
+ /**
278
+ * 由「当前值」拼出 `/config` 的 PUT patch。
279
+ *
280
+ * 与旧版「点保存」时提交的内容逐字一致,只是取值来源从渲染闭包换成了
281
+ * `valuesRef`(setState 是异步的,自动保存必须读到刚改完的值)。
282
+ *
283
+ * @param values - `{ draft, providerDraft, keyInputs, storageDraft }`。
284
+ * @param keyCommit - 用户明确提交密钥的 provider(失焦/回车),否则 null。
285
+ */
286
+ function configPatchOf(values, keyCommit) {
287
+ const draft = values.draft;
288
+ const provider = values.providerDraft;
289
+ const patch = {
290
+ enabled: draft.enabled === true,
291
+ mode: draft.mode,
292
+ // 思考模式总是提交:它是全局项(与 provider 无关),服务端还会再校验一次。
293
+ thinkMode: draft.thinkMode ?? "never",
294
+ // 模型字段只写当前 provider 自己那一格(model / systemoneModel /
295
+ // customModel)。混写会让「切过去再切回来」丢掉原值,也会把
296
+ // bamboonode 的模型名发给别的服务。
297
+ ...(provider.provider === "custom"
298
+ ? { customModel: (provider.customModel ?? "").trim() }
299
+ : provider.provider === "systemone"
300
+ ? { systemoneModel: (draft.systemoneModel ?? "").trim() }
301
+ : { model: (draft.model ?? "").trim() }),
302
+ provider: provider.provider,
303
+ customEndpointStyle: provider.customEndpointStyle ?? "auto",
304
+ // custom 的字段总是带上:用户可能先填地址再切 provider,
305
+ // 分开提交会让中间状态被当成最终配置。
306
+ customHeaders: (provider.customHeaders ?? "").trim(),
307
+ customApiKeyEnv: (provider.customApiKeyEnv ?? "").trim(),
308
+ };
309
+ // customBaseUrl 只在**合法**时提交。空串对内置 provider 是合法的
310
+ //(等于不覆盖),对 custom 则是必填缺失——那种情况整笔延后,见 flush()。
311
+ // 不合法的值一律不发:服务端会拒,而打字途中的每一次拒绝都是噪音。
312
+ const url = (provider.customBaseUrl ?? "").trim();
313
+ if (customUrlProblem(url, provider.provider) === "") patch.customBaseUrl = url;
314
+ // 保存位置:只在用户改过时才提交,避免每次保存都覆盖。
315
+ if (values.storageDraft !== null && values.storageDraft !== undefined) {
316
+ patch.keyStorage = values.storageDraft;
317
+ }
318
+ // 密钥:只有用户明确提交(失焦 / 回车)且长度够时才带上。
319
+ // 否则打字过程中每停一下都会把半截 key 写进凭据文件。
320
+ if (keyCommit !== null && keyCommit !== undefined) {
321
+ const raw = (values.keyInputs[keyCommit] ?? "").trim();
322
+ if (raw.length >= 10) {
323
+ if (keyCommit === "systemone") patch.systemoneApiKey = raw;
324
+ else if (keyCommit === "custom") patch.customApiKey = raw;
325
+ else patch.bamboonodeApiKey = raw;
326
+ }
327
+ }
328
+ return patch;
329
+ }
330
+
331
+ /**
332
+ * patch 是否真的与服务端当前状态不同。
333
+ *
334
+ * 自动保存会频繁调用:不做这个比较,用户改一个决策点也会重写一次
335
+ * config.yml(白改 mtime、白触发一次热加载)。比较对象是 status 回显,
336
+ * 也就是「服务端认为的值」——与旧版「保存按钮的可用条件」同一语义。
337
+ *
338
+ * @param patch - `configPatchOf` 拼出的 patch。
339
+ * @param view - `/status` 回显(缺字段时用安全默认)。
340
+ */
341
+ function patchDiffersFromServer(patch, view) {
342
+ const pairs = [
343
+ ["enabled", view.enabled === true],
344
+ ["mode", view.mode ?? "shadow"],
345
+ ["thinkMode", view.thinkMode ?? "never"],
346
+ ["provider", view.provider ?? "bamboonode"],
347
+ ["customBaseUrl", view.customBaseUrl ?? ""],
348
+ ["customEndpointStyle", view.customEndpointStyle ?? "auto"],
349
+ ["customModel", view.customModel ?? ""],
350
+ ["model", view.rawModel ?? ""],
351
+ ["systemoneModel", view.rawSystemoneModel ?? ""],
352
+ ["customHeaders", view.customHeaders ?? ""],
353
+ ["customApiKeyEnv", view.customApiKeyEnv ?? ""],
354
+ ["keyStorage", view.keyStorage ?? "credentials"],
355
+ ];
356
+ for (const [key, server] of pairs) {
357
+ if (Object.hasOwn(patch, key) && String(patch[key] ?? "") !== String(server ?? "")) return true;
358
+ }
359
+ // 密钥字段一出现就代表「有新 key 要写」:服务端只回传掩码,没法比较,
360
+ // 而且重复写同一个 key 是无害的(幂等覆盖)。
361
+ return ["systemoneApiKey", "bamboonodeApiKey", "customApiKey"].some((key) => Object.hasOwn(patch, key));
362
+ }
363
+
220
364
  /**
221
365
  * 配置页:仿照内置 dsh-rewind-plugin 的卡片布局——
222
- * 每行左侧是「标题 + 灰色说明」,右侧是开关;底部一个「保存」按钮。
366
+ * 每行左侧是「标题 + 灰色说明」,右侧是控件。
367
+ *
368
+ * **改完即保存,没有「保存」按钮**:
369
+ * - 开关 / 下拉 / 分段控件:点一下就落盘;
370
+ * - 文本框(地址、请求头、预算):停手 `AUTOSAVE_DELAY_MS` 后落盘,失焦立刻落盘;
371
+ * - API Key:失焦或回车才落盘(见 `commitKey`)。
372
+ * 落盘状态显示在底部一行(保存中 / 已保存 / 失败原因 + 重试),
373
+ * 取代了原来那个按钮。
223
374
  *
224
375
  * 数据全部走本插件自己的 `/api/decision-engine/*`,**不用** settings 服务
225
- *(旧插件死在 `settings.installSection` 上)。保存即改写 config.yml。
376
+ *(旧插件死在 `settings.installSection` 上)。落盘即改写 config.yml。
226
377
  *
227
378
  * @param props - 槽位属性;`view === "summary"` 时只渲染一行摘要文本。
228
379
  */
@@ -238,7 +389,6 @@ window.__ModuleLoader__.load({
238
389
  // 而不是在这里写一份硬编码默认:否则「status 已回来但 useEffect 还没跑」
239
390
  // 的那一帧会显示错的值(例如把 custom 显示成 bamboonode)。
240
391
  const [draftState, setDraft] = react.useState(null);
241
- const [dirty, setDirty] = react.useState(false);
242
392
  // provider 草稿:与主配置分开保存,因为 custom 的五个字段是「先填后切」,
243
393
  // 混进主 draft 会让「只切 provider」也顺手把没填完的 custom 值写进去。
244
394
  const [providerDraftState, setProviderDraft] = react.useState(null);
@@ -255,11 +405,35 @@ window.__ModuleLoader__.load({
255
405
  const [pointsDraft, setPointsDraft] = react.useState({});
256
406
  const [budgetDraft, setBudgetDraft] = react.useState(null);
257
407
 
258
- // 用 ref 保存「用户是否有未保存改动」,使 refresh 不因 dirty 变化而重建。
259
- // 否则保存时 setDirty(false) → refresh 换新引用 → useEffect 再跑一次,
260
- // 与 save 里的 setStatus 竞争,可能用旧值把刚保存的开关覆盖回去(表现为回弹)。
408
+ // 用 ref 保存「用户是否有未保存改动」,使 refresh 不因状态变化而重建。
409
+ // 否则落盘时 setDirty(false) → refresh 换新引用 → useEffect 再跑一次,
410
+ // 与 flush 里的 setStatus 竞争,可能用旧值把刚保存的开关覆盖回去(表现为回弹)。
261
411
  const dirtyRef = react.useRef(false);
262
412
  const savingRef = react.useRef(false);
413
+ // 落盘状态:null(无提示)| "saving" | "saved" | { error }。取代原来的保存按钮。
414
+ const [saveState, setSaveState] = react.useState(null);
415
+ /**
416
+ * 待落盘值的唯一真源。
417
+ *
418
+ * 不能用渲染闭包里的 `draft`:`setState` 是异步的,一次事件里改完立刻
419
+ * 发请求时闭包里还是旧值(会把改动存丢)。因此所有控件先同步写这里,
420
+ * 再 setState 触发重绘;flush 只读这里。
421
+ */
422
+ const valuesRef = react.useRef(null);
423
+ /** 每次改动自增:用来判断「响应回来时用户是否又改过」,避免旧快照覆盖新输入。 */
424
+ const editSeqRef = react.useRef(0);
425
+ /** 防抖计时器。 */
426
+ const timerRef = react.useRef(null);
427
+ /** 本次待落盘是否要连配置一起写(仅决策点改动时为 false)。 */
428
+ const pendingRef = react.useRef(null);
429
+ /**
430
+ * 明确提交密钥的 provider(失焦 / 回车)。
431
+ *
432
+ * 与普通改动分开:密钥不能随防抖自动提交,否则打字途中每停一下都会把
433
+ * 半截 key 写进凭据文件;这里记下「这一次是用户按下提交」,flush 时
434
+ * 才把它并进 patch,成功后清空。
435
+ */
436
+ const keyCommitRef = react.useRef(null);
263
437
 
264
438
  const refresh = react.useCallback(async () => {
265
439
  // 保存进行中不拉状态,避免用旧快照覆盖刚写入的结果。
@@ -363,7 +537,7 @@ window.__ModuleLoader__.load({
363
537
  const draft = draftState ?? {
364
538
  enabled: view.enabled === true,
365
539
  mode: view.mode ?? "shadow",
366
- // 思考模式也走 draft:改完点保存才落盘,与开关语义一致。
540
+ // 思考模式也走 draft:与其它控件一致,改完即自动落盘。
367
541
  thinkMode: view.thinkMode ?? "never",
368
542
  model: view.rawModel ?? "",
369
543
  systemoneModel: view.rawSystemoneModel ?? "",
@@ -401,148 +575,185 @@ window.__ModuleLoader__.load({
401
575
  customApiKeyEnv: next.customApiKeyEnv ?? "",
402
576
  });
403
577
  setProviderError(next.providerValid === false ? next.providerError ?? "配置不可用" : "");
404
- setDirty(false);
405
578
  dirtyRef.current = false;
406
579
  };
407
580
 
581
+ // 每次渲染把「当前值」同步进 ref:控件在事件里读它,flush 也读它。
582
+ // 放在渲染期赋值(而不是 useEffect)是必须的——useEffect 在提交之后才跑,
583
+ // 而事件处理器可能在它之前就读到 ref,那样拿到的还是上一次的值。
584
+ valuesRef.current = { draft, providerDraft, keyInputs, storageDraft, pointsDraft, budgetDraft };
585
+
408
586
  /**
409
- * provider 相关改动是否待保存。
587
+ * 自动保存引擎。
588
+ *
589
+ * 三个动作:
590
+ * - `scheduleSave(partial, immediate)`:控件改动后调用。先**同步**并入
591
+ * `valuesRef`,再按需立即(开关/下拉)或防抖(文本框)落盘。
592
+ * - `flush()`:真正发请求。串行、合并,按需分两次(`/config` + `/decisions`)。
593
+ * - `commitKey(provider)`:密钥的「明确提交」(失焦 / 回车)。
410
594
  *
411
- * 逐字段比较而不是维护一个独立的 dirty 标记:切到 custom 又切回来、
412
- * 或者填了字又删掉,都应当回到「无改动」而不是永远亮着保存按钮。
595
+ * 三条不变量(都是这个界面反复踩过的坑):
596
+ *
597
+ * 1. 同一时刻只有一个请求在飞(`savingRef`)。期间的改动只并入
598
+ * `valuesRef`,等这次回来再发——并发保存会让两次快照互相覆盖。
599
+ * 2. 响应回来时若用户又改过(`editSeqRef` 变了),**不覆盖草稿**,
600
+ * 只刷新服务端状态。否则「保存中的输入被旧快照冲掉」= 回弹。
601
+ * 3. 失败不丢输入、也不自动重试:原因写进状态行,等用户改或点重试。
602
+ * 自动重试一个注定 400 的请求只会刷屏。
413
603
  */
414
- const providerChanged = (() => {
415
- const server = {
416
- provider: view.provider ?? "bamboonode",
417
- customBaseUrl: view.customBaseUrl ?? "",
418
- customEndpointStyle: view.customEndpointStyle ?? "auto",
419
- customModel: view.customModel ?? "",
420
- customHeaders: view.customHeaders ?? "",
421
- customApiKeyEnv: view.customApiKeyEnv ?? "",
422
- };
423
- const storageChanged = storageDraft !== null && storageDraft !== (view.keyStorage ?? "credentials");
424
- const draftChanged = Object.keys(server).some(
425
- (key) => (providerDraft[key] ?? "") !== (server[key] ?? ""),
426
- );
427
- return draftChanged || storageChanged;
428
- })();
604
+ const flushRef = react.useRef(null);
429
605
 
430
- const save = react.useCallback(
431
- async (patch) => {
432
- setBusy("save");
433
- setMsg(null);
434
- savingRef.current = true;
435
- try {
436
- const next = await call("/config", {
437
- method: "PUT",
438
- headers: { "content-type": "application/json" },
439
- body: JSON.stringify(patch),
440
- });
441
- applyServerState(next);
442
- setMsg({ ok: true, text: "已保存" });
443
- if (Object.hasOwn(patch, "apiKey")) setKeyInput("");
444
- } catch (error) {
445
- // 校验失败(非法 URL / 缺必填)走这里:保留用户已填内容,
446
- // 只把原因显示出来——清空表单会让用户重填一遍。
447
- setMsg({ ok: false, text: error.message });
448
- setProviderError(error.message);
449
- } finally {
450
- savingRef.current = false;
451
- setBusy("");
452
- }
453
- },
454
- [],
455
- );
606
+ /** 同步记录一次改动,并安排落盘(`immediate` 用于离散控件)。 */
607
+ const scheduleSave = (partial, immediate) => {
608
+ valuesRef.current = { ...valuesRef.current, ...(partial ?? {}) };
609
+ editSeqRef.current += 1;
610
+ dirtyRef.current = true;
611
+ // 防抖窗口里就先把状态改成「待保存」:否则这一小段时间还挂着上一次的
612
+ // 「已保存」,用户会以为刚才那一下已经落盘了。
613
+ setSaveState("pending");
614
+ if (timerRef.current !== null) {
615
+ clearTimeout(timerRef.current);
616
+ timerRef.current = null;
617
+ }
618
+ if (immediate === true) {
619
+ void flushRef.current();
620
+ return;
621
+ }
622
+ // 文本框:停手 AUTOSAVE_DELAY_MS 之后再落盘,避免每敲一个字符一次请求。
623
+ timerRef.current = setTimeout(() => {
624
+ timerRef.current = null;
625
+ void flushRef.current();
626
+ }, AUTOSAVE_DELAY_MS);
627
+ };
456
628
 
457
- // 决策点/预算是否有待保存改动。必须在 saveDecisions 之前算:
458
- // useCallback 的依赖数组会在本次渲染立即求值,放到后面会踩 TDZ。
459
- const pointsChanged = Object.keys(pointsDraft).some((id) => {
460
- const original = (decisions?.points ?? []).find((item) => item.id === id);
461
- return original !== undefined && pointsDraft[id] !== original.mode;
462
- });
463
- const budgetChanged = budgetDraft !== null && budgetDraft !== (decisions?.budget?.limit ?? 0);
464
- const decisionsChanged = pointsChanged || budgetChanged;
629
+ /**
630
+ * 明确提交密钥(失焦 / 回车)。
631
+ *
632
+ * 密钥刻意**不**跟着防抖走:粘贴后停一下就把半截 key 写进凭据文件,
633
+ * 是比多点一次更糟的体验。这里只记「这一次是用户按下提交」。
634
+ */
635
+ const commitKey = (provider) => {
636
+ const raw = (valuesRef.current.keyInputs[provider] ?? "").trim();
637
+ // 留空 = 不修改(与旧版一致);太短的多半是没贴完,不落盘,给一句提示。
638
+ if (raw.length === 0) return;
639
+ if (raw.length < 10) {
640
+ setSaveState({ error: "未保存:密钥太短(至少 10 个字符)" });
641
+ return;
642
+ }
643
+ keyCommitRef.current = provider;
644
+ editSeqRef.current += 1;
645
+ dirtyRef.current = true;
646
+ if (timerRef.current !== null) {
647
+ clearTimeout(timerRef.current);
648
+ timerRef.current = null;
649
+ }
650
+ void flushRef.current();
651
+ };
465
652
 
466
653
  /**
467
- * 保存决策点与预算。
654
+ * 落盘一次。
468
655
  *
469
- * 走独立的 `/decisions` 而不是复用 `/config`:那两份配置的校验规则不同
470
- *(决策点要拒绝未实装的点、预算要 clamp),混在一起容易互相放宽。
656
+ * 决策点走独立的 `/decisions` 而不是复用 `/config`:那两份配置的校验规则
657
+ * 不同(决策点要拒绝未实装的点、预算要 clamp),混在一起容易互相放宽。
471
658
  */
472
- const saveDecisions = react.useCallback(async () => {
473
- const body = {};
474
- if (pointsChanged) body.points = pointsDraft;
475
- if (budgetChanged) body.budgetTokens = budgetDraft;
476
- if (Object.keys(body).length === 0) return true;
477
- const next = await call("/decisions", {
478
- method: "PUT",
479
- headers: { "content-type": "application/json" },
480
- body: JSON.stringify(body),
481
- });
482
- setDecisions(next);
483
- setPointsDraft({});
484
- setBudgetDraft(null);
485
- return true;
486
- }, [pointsChanged, budgetChanged, pointsDraft, budgetDraft]);
659
+ const flush = async () => {
660
+ if (savingRef.current === true) return;
661
+ if (dirtyRef.current !== true) return;
662
+ const values = valuesRef.current;
663
+ const seq = editSeqRef.current;
664
+ const keyCommit = keyCommitRef.current;
665
+ const decisionsBody = pendingDecisions(values, decisions);
666
+ // 打字途中的非法地址:整笔延后(服务端必然拒绝,那一次 400 只是噪音)。
667
+ // 决策点与地址无关,仍然照常落盘——否则改个决策点也会被地址卡住。
668
+ const urlProblem = customUrlProblem(
669
+ values.providerDraft.customBaseUrl,
670
+ values.providerDraft.provider,
671
+ );
672
+ const configDeferred = urlProblem.length > 0;
673
+ if (configDeferred === true && decisionsBody === null) {
674
+ setSaveState({ error: `配置未保存:${urlProblem}` });
675
+ return;
676
+ }
677
+
678
+ const patch = configPatchOf(values, keyCommit);
679
+ // 只要带了 key 就必须写配置:key 不能因为「其它字段没变」被漏掉。
680
+ const keyWritten = ["systemoneApiKey", "bamboonodeApiKey", "customApiKey"].some((key) =>
681
+ Object.hasOwn(patch, key),
682
+ );
683
+ const configNeeded =
684
+ configDeferred !== true && (keyWritten === true || patchDiffersFromServer(patch, view) === true);
487
685
 
488
- /** 一次点击提交两半:主配置 + 决策点。任一失败都要报错,不能静默半保存。 */
489
- const saveAll = react.useCallback(async () => {
490
- setBusy("save");
491
- setMsg(null);
492
686
  savingRef.current = true;
687
+ setSaveState("saving");
688
+ setMsg(null);
493
689
  setProviderError("");
690
+ /** 失败的到底是哪一半:只有配置那一半才该显示成「provider 配置不可用」。 */
691
+ let failing = "config";
494
692
  try {
495
- const patch = {
496
- enabled: draft.enabled === true,
497
- mode: draft.mode,
498
- // 思考模式总是提交:它是全局项(与 provider 无关),
499
- // 服务端还会再校验一次合法取值。
500
- thinkMode: draft.thinkMode ?? "never",
501
- // 模型字段只写当前 provider 自己那一格(model / systemoneModel /
502
- // customModel)。混写会让「切过去再切回来」丢掉原值,也会把
503
- // bamboonode 的模型名发给别的服务。
504
- ...(providerDraft.provider === "custom"
505
- ? { customModel: (providerDraft.customModel ?? "").trim() }
506
- : providerDraft.provider === "systemone"
507
- ? { systemoneModel: (draft.systemoneModel ?? "").trim() }
508
- : { model: (draft.model ?? "").trim() }),
509
- provider: providerDraft.provider,
510
- // custom 的字段**总是**一起提交:用户可能先填地址再切 provider,
511
- // 分两次保存会让中间状态被当成最终配置。值为空也照写,
512
- // 便于用户清空后立刻纠正(服务端只在 provider=custom 时校验必填)。
513
- customBaseUrl: (providerDraft.customBaseUrl ?? "").trim(),
514
- customEndpointStyle: providerDraft.customEndpointStyle ?? "auto",
515
- customHeaders: (providerDraft.customHeaders ?? "").trim(),
516
- customApiKeyEnv: (providerDraft.customApiKeyEnv ?? "").trim(),
517
- };
518
- // 只提交「当前 provider」那一格:切过去时不会顺手存下别的 provider 的 key。
519
- const activeKey = (keyInputs[providerDraft.provider] ?? "").trim();
520
- if (activeKey.length >= 10) {
521
- if (providerDraft.provider === "systemone") patch.systemoneApiKey = activeKey;
522
- else if (providerDraft.provider === "custom") patch.customApiKey = activeKey;
523
- else patch.bamboonodeApiKey = activeKey;
693
+ if (configNeeded === true) {
694
+ const next = await call("/config", {
695
+ method: "PUT",
696
+ headers: { "content-type": "application/json" },
697
+ body: JSON.stringify(patch),
698
+ });
699
+ // 用户又改过:只刷新服务端状态,草稿保留给下一次 flush。
700
+ if (seq === editSeqRef.current) applyServerState(next);
701
+ else setStatus(next);
524
702
  }
525
- // 保存位置:只在用户改过时才提交,避免每次保存都覆盖。
526
- if (storageDraft !== null) patch.keyStorage = storageDraft;
527
- const next = await call("/config", {
528
- method: "PUT",
529
- headers: { "content-type": "application/json" },
530
- body: JSON.stringify(patch),
531
- });
532
- applyServerState(next);
533
- if (Object.hasOwn(patch, "systemoneApiKey") || Object.hasOwn(patch, "bamboonodeApiKey") || Object.hasOwn(patch, "customApiKey")) {
534
- setKeyInputs({ ...keyInputs, [providerDraft.provider]: "" });
703
+ failing = "decisions";
704
+ if (decisionsBody !== null) {
705
+ const next = await call("/decisions", {
706
+ method: "PUT",
707
+ headers: { "content-type": "application/json" },
708
+ body: JSON.stringify(decisionsBody),
709
+ });
710
+ setDecisions(next);
711
+ // 期间又有改动时不清草稿:那些改动还没落盘,清掉就等于吞了。
712
+ if (seq === editSeqRef.current) {
713
+ setPointsDraft({});
714
+ setBudgetDraft(null);
715
+ }
716
+ }
717
+ if (seq !== editSeqRef.current) {
718
+ // 期间的改动由 finally 接着发,这里保持「保存中」。
719
+ setSaveState("saving");
720
+ } else if (configDeferred === true) {
721
+ // 决策点存了,配置那一半还欠着(等地址补全):**保持 dirty**,
722
+ // 否则地址改好之后没人会再发请求,改动就悄悄丢了。
723
+ dirtyRef.current = true;
724
+ setSaveState({ error: `配置未保存:${urlProblem}` });
725
+ } else {
726
+ dirtyRef.current = false;
727
+ if (keyWritten === true) {
728
+ keyCommitRef.current = null;
729
+ // 存完就清空输入框:与旧版「粘贴 → 保存 → 框变空」一致,
730
+ // 也让「已配置」的提示能立刻反映新值。
731
+ setKeyInputs({ ...values.keyInputs, [keyCommit]: "" });
732
+ }
733
+ setSaveState("saved");
535
734
  }
536
- await saveDecisions();
537
- setMsg({ ok: true, text: "已保存" });
538
735
  } catch (error) {
539
- setMsg({ ok: false, text: error.message });
540
- setProviderError(error.message);
736
+ const text = error instanceof Error ? error.message : String(error);
737
+ // 校验失败(非法 URL / 请求头格式)走这里:保留用户已填内容,
738
+ // 只把原因显示出来——清空表单会让用户重填一遍。
739
+ // dirty 保持 true:失败的那一半(可能是决策点)还没落盘,
740
+ // 重试按钮与后续编辑都要能把它带上。
741
+ dirtyRef.current = true;
742
+ setSaveState({ error: text });
743
+ if (failing === "config") setProviderError(text);
541
744
  } finally {
542
745
  savingRef.current = false;
543
- setBusy("");
746
+ // 保存期间用户又改过:立刻再发一次,不等防抖(他已经停手了)。
747
+ if (dirtyRef.current === true && seq !== editSeqRef.current) void flush();
544
748
  }
545
- }, [draft, keyInputs, storageDraft, providerDraft, saveDecisions]);
749
+ };
750
+ flushRef.current = flush;
751
+
752
+ /** 状态行的「重试」:把上一次失败的值原样再发一遍。 */
753
+ const retrySave = () => {
754
+ dirtyRef.current = true;
755
+ void flushRef.current();
756
+ };
546
757
 
547
758
  const runTest = react.useCallback(async () => {
548
759
  setBusy("test");
@@ -634,8 +845,15 @@ window.__ModuleLoader__.load({
634
845
  if (isCustom) setProviderDraft({ ...providerDraft, customModel: value });
635
846
  else if (isSystemOne) setDraft({ ...draft, systemoneModel: value });
636
847
  else setDraft({ ...draft, model: value });
637
- setDirty(true);
638
- dirtyRef.current = true;
848
+ // 下拉是离散控件:选完即存。
849
+ scheduleSave(
850
+ isCustom
851
+ ? { providerDraft: { ...providerDraft, customModel: value } }
852
+ : isSystemOne
853
+ ? { draft: { ...draft, systemoneModel: value } }
854
+ : { draft: { ...draft, model: value } },
855
+ true,
856
+ );
639
857
  };
640
858
 
641
859
  /**
@@ -651,23 +869,10 @@ window.__ModuleLoader__.load({
651
869
  : "";
652
870
 
653
871
  /**
654
- * 本地预校验:与服务端同规则,只是为了让用户在点保存之前就看到问题。
655
- * 服务端仍会独立校验(前端校验不可信),这里只求「早一点提示」。
872
+ * 本地预校验:与服务端同规则(见模块级 `customUrlProblem`),
873
+ * 只是为了让用户在落盘之前就看到问题。服务端仍会独立校验(前端校验不可信)。
656
874
  */
657
- const customUrlError = (() => {
658
- if (!isCustom) return "";
659
- const raw = (providerDraft.customBaseUrl ?? "").trim();
660
- if (raw.length === 0) return "必填:请填写 system-one 风格服务的 base URL";
661
- if (/\s/.test(raw)) return "不能包含空格或换行";
662
- if (!/^https?:\/\//i.test(raw)) return "必须以 http:// 或 https:// 开头";
663
- try {
664
- const url = new URL(raw);
665
- if (url.search.length > 0) return "不要带查询串(? 之后的部分)";
666
- } catch {
667
- return "不是合法地址(示例:https://api.example.com/v1)";
668
- }
669
- return "";
670
- })();
875
+ const customUrlError = isCustom ? customUrlProblem(providerDraft.customBaseUrl, "custom") : "";
671
876
 
672
877
  /** provider 提示文案:优先用服务端下发的说明,避免前端另写一份。 */
673
878
  const providerHint =
@@ -698,8 +903,10 @@ window.__ModuleLoader__.load({
698
903
  // 切换 provider 只改「当前用哪个」,不动其它 provider 已保存的值:
699
904
  // custom 的字段留在 providerDraft 里,切回来还在。
700
905
  setProviderError("");
701
- setDirty(true);
702
- dirtyRef.current = true;
906
+ // 下拉是离散控件:选完即存。切到 custom 但地址还没填时,
907
+ // flush 会整笔延后并提示(见 flush 里的 urlProblem),
908
+ // 地址补全后自动落盘,不需要再点一次。
909
+ scheduleSave({ providerDraft: { ...providerDraft, provider: next } }, true);
703
910
  },
704
911
  },
705
912
  ...(catalog.length === 0
@@ -732,8 +939,8 @@ window.__ModuleLoader__.load({
732
939
  toggle(draft.enabled === true, () => {
733
940
  const next = draft.enabled !== true;
734
941
  setDraft({ ...draft, enabled: next });
735
- setDirty(true);
736
- dirtyRef.current = true;
942
+ // 开关是离散控件:拨一下就落盘。
943
+ scheduleSave({ draft: { ...draft, enabled: next } }, true);
737
944
  }),
738
945
  "enabled",
739
946
  ),
@@ -744,8 +951,7 @@ window.__ModuleLoader__.load({
744
951
  toggle(draft.mode === "enforce", () => {
745
952
  const next = draft.mode === "enforce" ? "shadow" : "enforce";
746
953
  setDraft({ ...draft, mode: next });
747
- setDirty(true);
748
- dirtyRef.current = true;
954
+ scheduleSave({ draft: { ...draft, mode: next } }, true);
749
955
  }),
750
956
  "mode",
751
957
  ),
@@ -765,9 +971,10 @@ window.__ModuleLoader__.load({
765
971
  value: draft.thinkMode ?? "never",
766
972
  disabled: busy !== "",
767
973
  onChange: (event) => {
768
- setDraft({ ...draft, thinkMode: event.target.value });
769
- setDirty(true);
770
- dirtyRef.current = true;
974
+ const next = { ...draft, thinkMode: event.target.value };
975
+ setDraft(next);
976
+ // 下拉是离散控件:选完即存。
977
+ scheduleSave({ draft: next }, true);
771
978
  },
772
979
  },
773
980
  // 选项由服务端下发(thinkModes),前端不硬编码取值,避免漂移。
@@ -830,7 +1037,9 @@ window.__ModuleLoader__.load({
830
1037
  ),
831
1038
  "model",
832
1039
  ),
833
- // 4) API Key:每个 provider 一格,各自保存各自的 key
1040
+ // 4) API Key:每个 provider 一格,各自保存各自的 key。
1041
+ // 密钥**只**在失焦或回车时落盘(commitKey):打字途中自动保存会把
1042
+ // 半截 key 写进凭据文件,比多一步确认更危险。
834
1043
  row(
835
1044
  `API Key${keyOk ? `(已配置 ${activeKeyStatus?.hint ?? view.keyHint ?? ""})` : "(未配置)"}`,
836
1045
  activeKeyStatus !== null
@@ -840,17 +1049,24 @@ window.__ModuleLoader__.load({
840
1049
  // 混着说会让人去 .credentials.yaml 里找 key。
841
1050
  ? `存到插件配置 config.yml 的 ${activeKeyStatus.keyField || "显式 key 字段"}(明文)`
842
1051
  : `存到 .credentials.yaml 的 ${activeKeyStatus.refName}`
843
- : "留空则不修改;粘贴新 key 后点保存",
1052
+ : "留空则不修改;粘贴新 key 后失焦或回车即保存",
844
1053
  h("input", {
845
1054
  type: "password",
846
1055
  style: S.input,
847
1056
  placeholder: keyOk ? "sk-…(留空不改)" : "sk-…",
848
1057
  value: keyInputs[providerDraft.provider] ?? "",
849
- onChange: (event) =>
850
- setKeyInputs({ ...keyInputs, [providerDraft.provider]: event.target.value }),
1058
+ onChange: (event) => {
1059
+ const next = { ...keyInputs, [providerDraft.provider]: event.target.value };
1060
+ setKeyInputs(next);
1061
+ // 同步进 ref:commitKey 读的是它。回车可能紧贴最后一次按键,
1062
+ // 等重绘就来不及了。
1063
+ valuesRef.current = { ...valuesRef.current, keyInputs: next };
1064
+ },
1065
+ // 密钥只在「明确提交」时落盘:失焦或回车。
1066
+ onBlur: () => commitKey(providerDraft.provider),
851
1067
  onKeyDown: (event) => {
852
1068
  if (event.key !== "Enter") return;
853
- if ((keyInputs[providerDraft.provider] ?? "").trim().length >= 10) void saveAll();
1069
+ commitKey(providerDraft.provider);
854
1070
  },
855
1071
  }),
856
1072
  "key",
@@ -869,8 +1085,8 @@ window.__ModuleLoader__.load({
869
1085
  disabled: busy !== "",
870
1086
  onChange: (event) => {
871
1087
  setStorageDraft(event.target.value);
872
- setDirty(true);
873
- dirtyRef.current = true;
1088
+ // 下拉是离散控件:选完即存。
1089
+ scheduleSave({ storageDraft: event.target.value }, true);
874
1090
  },
875
1091
  },
876
1092
  h("option", { value: "credentials" }, ".credentials.yaml(默认)"),
@@ -898,11 +1114,13 @@ window.__ModuleLoader__.load({
898
1114
  placeholder: "https://api.example.com/v1",
899
1115
  value: providerDraft.customBaseUrl ?? "",
900
1116
  onChange: (event) => {
901
- setProviderDraft({ ...providerDraft, customBaseUrl: event.target.value });
1117
+ const next = { ...providerDraft, customBaseUrl: event.target.value };
1118
+ setProviderDraft(next);
902
1119
  setProviderError("");
903
- setDirty(true);
904
- dirtyRef.current = true;
1120
+ // 文本框:停手防抖落盘(打字途中的非法地址会被 flush 整笔延后)。
1121
+ scheduleSave({ providerDraft: next });
905
1122
  },
1123
+ onBlur: () => scheduleSave(null, true),
906
1124
  }),
907
1125
  "custom-url",
908
1126
  ),
@@ -915,9 +1133,10 @@ window.__ModuleLoader__.load({
915
1133
  style: { ...S.input, minWidth: "150px", flex: "none" },
916
1134
  value: providerDraft.customEndpointStyle ?? "auto",
917
1135
  onChange: (event) => {
918
- setProviderDraft({ ...providerDraft, customEndpointStyle: event.target.value });
919
- setDirty(true);
920
- dirtyRef.current = true;
1136
+ const next = { ...providerDraft, customEndpointStyle: event.target.value };
1137
+ setProviderDraft(next);
1138
+ // 下拉是离散控件:选完即存。
1139
+ scheduleSave({ providerDraft: next }, true);
921
1140
  },
922
1141
  },
923
1142
  h("option", { value: "auto" }, "auto(自动)"),
@@ -935,10 +1154,11 @@ window.__ModuleLoader__.load({
935
1154
  placeholder: "X-Tenant: team-a; X-Trace: dsh",
936
1155
  value: providerDraft.customHeaders ?? "",
937
1156
  onChange: (event) => {
938
- setProviderDraft({ ...providerDraft, customHeaders: event.target.value });
939
- setDirty(true);
940
- dirtyRef.current = true;
1157
+ const next = { ...providerDraft, customHeaders: event.target.value };
1158
+ setProviderDraft(next);
1159
+ scheduleSave({ providerDraft: next });
941
1160
  },
1161
+ onBlur: () => scheduleSave(null, true),
942
1162
  }),
943
1163
  "custom-headers",
944
1164
  ),
@@ -951,10 +1171,11 @@ window.__ModuleLoader__.load({
951
1171
  placeholder: "MY_DECISION_API_KEY",
952
1172
  value: providerDraft.customApiKeyEnv ?? "",
953
1173
  onChange: (event) => {
954
- setProviderDraft({ ...providerDraft, customApiKeyEnv: event.target.value });
955
- setDirty(true);
956
- dirtyRef.current = true;
1174
+ const next = { ...providerDraft, customApiKeyEnv: event.target.value };
1175
+ setProviderDraft(next);
1176
+ scheduleSave({ providerDraft: next });
957
1177
  },
1178
+ onBlur: () => scheduleSave(null, true),
958
1179
  }),
959
1180
  "custom-env",
960
1181
  ),
@@ -986,6 +1207,7 @@ window.__ModuleLoader__.load({
986
1207
  h("div", { style: S.sectionTitle }, "决策模型参与点"),
987
1208
  ...(decisions.points ?? []).map((point) => {
988
1209
  const current = Object.hasOwn(pointsDraft, point.id) ? pointsDraft[point.id] : point.mode;
1210
+ // 点完即存:这个角标只在「请求还在路上」的那一小会儿出现。
989
1211
  const changed = Object.hasOwn(pointsDraft, point.id) && pointsDraft[point.id] !== point.mode;
990
1212
  const st = point.stats ?? {};
991
1213
  // 「可用」与「已挂载」是两件事:点已实装(available),但宿主
@@ -1034,7 +1256,12 @@ window.__ModuleLoader__.load({
1034
1256
  type: "button",
1035
1257
  key: mode,
1036
1258
  disabled: busy !== "",
1037
- onClick: () => setPointsDraft({ ...pointsDraft, [point.id]: mode }),
1259
+ onClick: () => {
1260
+ const next = { ...pointsDraft, [point.id]: mode };
1261
+ setPointsDraft(next);
1262
+ // 三态分段是离散控件:点一下就落盘。
1263
+ scheduleSave({ pointsDraft: next }, true);
1264
+ },
1038
1265
  style: S.segItem(
1039
1266
  current === mode,
1040
1267
  mode === "enforce"
@@ -1048,7 +1275,7 @@ window.__ModuleLoader__.load({
1048
1275
  ),
1049
1276
  ),
1050
1277
  ),
1051
- changed ? h("span", { style: { ...S.badge, borderStyle: "solid", opacity: 0.9 } }, "待保存") : null,
1278
+ changed ? h("span", { style: { ...S.badge, borderStyle: "solid", opacity: 0.9 } }, "保存中") : null,
1052
1279
  );
1053
1280
  }),
1054
1281
  // 自身预算:决策模型自己也烧 token,不设上限会变成主要消耗方。
@@ -1073,8 +1300,12 @@ window.__ModuleLoader__.load({
1073
1300
  defaultValue: decisions.budget?.limit ?? 0,
1074
1301
  onChange: (event) => {
1075
1302
  const value = Number(event.target.value);
1076
- setBudgetDraft(Number.isFinite(value) ? Math.max(0, Math.floor(value)) : null);
1303
+ const next = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : null;
1304
+ setBudgetDraft(next);
1305
+ // 数字框是文本输入:停手防抖落盘。
1306
+ scheduleSave({ budgetDraft: next });
1077
1307
  },
1308
+ onBlur: () => scheduleSave(null, true),
1078
1309
  }),
1079
1310
  ),
1080
1311
  // 缓存命中:0 命中说明白名单外的调用每次都真发请求,是主要浪费。
@@ -1085,38 +1316,55 @@ window.__ModuleLoader__.load({
1085
1316
  `同类复用 ${decisions.cache?.softHits ?? 0} · 命中率 ${Math.round((decisions.cache?.hitRate ?? 0) * 100)}%`,
1086
1317
  ),
1087
1318
  ),
1088
- // 4) 保存按钮(同时提交开关、provider 与 key)
1319
+ // 4) 自动保存状态行:**没有「保存」按钮**,改动即落盘。
1320
+ // 这一行回答的是「刚才那一下到底存没存」:进行中 / 已存 /
1321
+ // 失败原因(附重试)。原来那个按钮的「保存中…」搬到这里。
1089
1322
  h(
1090
1323
  "div",
1091
- { style: { display: "flex", gap: "8px", margin: "14px 0 4px" }, key: "actions" },
1324
+ { style: { display: "flex", gap: "8px", alignItems: "center", margin: "14px 0 4px" }, key: "actions" },
1325
+ // 常驻说明:让第一次进来的人知道不需要找保存按钮。
1092
1326
  h(
1093
- "button",
1094
- {
1095
- type: "button",
1096
- // 保存按钮的可用条件必须包含 providerChanged 与 custom key:
1097
- // 少一项就会出现「改了 provider 但按钮是灰的」。
1098
- disabled:
1099
- busy !== "" ||
1100
- (dirty !== true &&
1101
- (keyInputs[providerDraft.provider] ?? "").trim().length < 10 &&
1102
- decisionsChanged !== true &&
1103
- providerChanged !== true),
1104
- onClick: () => void saveAll(),
1105
- style: {
1106
- ...S.saveBtn,
1107
- opacity:
1108
- busy !== "" ||
1109
- (dirty !== true &&
1110
- (keyInputs[providerDraft.provider] ?? "").trim().length < 10 &&
1111
- decisionsChanged !== true &&
1112
- providerChanged !== true)
1113
- ? 0.5
1114
- : 1,
1115
- cursor: busy !== "" ? "default" : "pointer",
1116
- },
1117
- },
1118
- busy === "save" ? "保存中…" : "保存",
1327
+ "span",
1328
+ { style: { ...S.meta, marginTop: 0, flex: 1, opacity: 0.55 } },
1329
+ saveState === "pending"
1330
+ ? "待保存…"
1331
+ : saveState === "saving"
1332
+ ? "保存中…"
1333
+ : saveState === "saved"
1334
+ ? "已保存(改动即生效,无需点保存)"
1335
+ : saveState !== null && typeof saveState === "object" && typeof saveState.error === "string"
1336
+ ? null
1337
+ : "改动即保存",
1119
1338
  ),
1339
+ // 失败:原因直接给出来,另配「重试」——不能让用户只看到「保存失败」
1340
+ // 而不知道该改什么、能不能再试一次。
1341
+ saveState !== null && typeof saveState === "object" && typeof saveState.error === "string"
1342
+ ? h(
1343
+ "span",
1344
+ {
1345
+ style: {
1346
+ ...S.meta,
1347
+ marginTop: 0,
1348
+ flex: 1,
1349
+ color: "#f87171",
1350
+ opacity: 1,
1351
+ },
1352
+ },
1353
+ `未保存:${saveState.error}`,
1354
+ )
1355
+ : null,
1356
+ saveState !== null && typeof saveState === "object" && typeof saveState.error === "string"
1357
+ ? h(
1358
+ "button",
1359
+ {
1360
+ type: "button",
1361
+ disabled: busy !== "",
1362
+ onClick: retrySave,
1363
+ style: { ...S.saveBtn, flexShrink: 0, padding: "6px 10px" },
1364
+ },
1365
+ "重试",
1366
+ )
1367
+ : null,
1120
1368
  h(
1121
1369
  "button",
1122
1370
  {
@@ -0,0 +1,86 @@
1
+ /**
2
+ * DSH 家目录解析 —— 与宿主 `@deepseek-ai/dsh-home-paths` 保持同一语义。
3
+ *
4
+ * 为什么自己实现而不直接依赖宿主那个包:
5
+ *
6
+ * `@deepseek-ai/dsh-home-paths` 是宿主的 peer 依赖,装上它本插件就无法在
7
+ * 没有 DSH node_modules 的裸仓库里被加载——而本项目刻意保持零运行时依赖
8
+ * (`lib/providers.js` 甚至能在裸 Node 下单测)。因此这里复刻它的**行为**,
9
+ * 而不是引入它的代码。
10
+ *
11
+ * 与宿主 `resolveDshHome()` 必须一致的三条规则:
12
+ *
13
+ * 1. **优先级**:显式配置 > `$DSH_HOME` > `~/.dsh`
14
+ * 2. **空白即未设置**:空串或纯空白的 `$DSH_HOME` 当作没有,
15
+ * 否则空值会把数据目录解析到当前工作目录(宿主的注释专门强调过这点)
16
+ * 3. **波浪号展开**:`~`、`~/`、`~\` 都按操作系统家目录展开。
17
+ * 其中 `~\` 是 Windows 形式,宿主为此专门加了这一条
18
+ *
19
+ * 另外补上宿主也做、而早期实现漏掉的一步:**规范化成绝对路径**
20
+ * (`path.resolve`)。相对路径(`DSH_HOME=./home`)若不规范化,实际落盘位置
21
+ * 会随进程工作目录漂移,表现为「同一个配置有时读得到有时读不到」。
22
+ *
23
+ * 跨平台说明:`~/.dsh` 里的 `.dsh` 只是**目录名**,不是路径分隔符,Windows
24
+ * 完全允许点开头的目录。真正与平台相关的是分隔符与家目录位置,这两件事都由
25
+ * `node:path` / `node:os` 处理,所以 Windows 上会得到
26
+ * `C:\Users\<name>\.dsh\...`。
27
+ *
28
+ * @module dsh-decision-engine/home-paths
29
+ */
30
+
31
+ import { homedir } from "node:os";
32
+ import { join, resolve } from "node:path";
33
+
34
+ /** 默认的 Harness 家目录名(宿主同名常量)。 */
35
+ export const DSH_HOME_DIR_NAME = ".dsh";
36
+
37
+ /** 覆盖默认家目录的环境变量(宿主同名常量)。 */
38
+ export const DSH_HOME_ENV = "DSH_HOME";
39
+
40
+ /**
41
+ * 展开 `~`、`~/`、`~\` 前缀。
42
+ *
43
+ * 只处理「单独的家目录」与「当前用户」两种形式;`~alice/...` 这类命名用户
44
+ * 形式原样返回——与宿主 `expandHomePath` 的行为一致,避免自作聪明地改坏
45
+ * 用户写的路径。
46
+ *
47
+ * @param path - 可能带波浪号前缀的路径。
48
+ * @returns 展开后的路径;没有可识别前缀时原样返回。
49
+ */
50
+ export function expandHomePath(path) {
51
+ const text = typeof path === "string" ? path : "";
52
+ if (text === "~") return homedir();
53
+ if (text.startsWith("~/") || text.startsWith("~\\")) return join(homedir(), text.slice(2));
54
+ return text;
55
+ }
56
+
57
+ /**
58
+ * 解析 DSH 家目录。
59
+ *
60
+ * @param options.configured - 显式配置的路径,优先级最高(当前插件没有此入口,
61
+ * 保留参数是为了与宿主签名一致,便于将来接入 profile 配置)。
62
+ * @param options.env - 环境变量表,默认 `process.env`;注入便于单测。
63
+ * @returns 规范化后的绝对路径。
64
+ */
65
+ export function resolveDshHome(options = {}) {
66
+ const env = options.env ?? process.env;
67
+ const raw = env?.[DSH_HOME_ENV];
68
+ const fromEnv = typeof raw === "string" && raw.trim().length > 0 ? raw : undefined;
69
+ const configured = typeof options.configured === "string" && options.configured.trim().length > 0
70
+ ? options.configured
71
+ : undefined;
72
+ const chosen = configured ?? fromEnv ?? join(homedir(), DSH_HOME_DIR_NAME);
73
+ // resolve() 同时完成「展开相对路径」与「消除 . / .. 」,
74
+ // 保证不同工作目录下得到同一个绝对路径。
75
+ return resolve(expandHomePath(chosen));
76
+ }
77
+
78
+ /**
79
+ * 把若干路径段拼到解析出的家目录上。
80
+ *
81
+ * @param segments - 追加的路径段;为空时返回家目录本身。
82
+ * @returns 绝对路径。
83
+ */
84
+ export function dshHomePath(...segments) {
85
+ return join(resolveDshHome(), ...segments);
86
+ }
package/lib/index.js CHANGED
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * 1. **零可选 API 依赖**。不调用 `settings.installSection`(该 API 在 DSH 中不存在),
7
7
  * 不依赖 `@deepseek-ai/dsh-credentials`、`launchEnvironmentOf`、`dshHomePath`。
8
- * 密钥走 config → 环境变量两级,路径走 `os.homedir()`。宿主 API 缺失不会炸成静默失效。
8
+ * 密钥走 config → 环境变量两级,路径走 `home-paths.js`(与宿主同一套规则)。
9
+ * 宿主 API 缺失不会炸成静默失效。
9
10
  *
10
11
  * 2. **endpoint 每次请求重新解析**。`classify()` 现读 `cfg` 并重新解析 provider,
11
12
  * 改配置立刻对下一次决策生效,无需重启(旧插件在 apply 期把 endpoint 固化进
@@ -23,8 +24,7 @@
23
24
 
24
25
  import { appendFile, mkdir, readFile } from "node:fs/promises";
25
26
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
26
- import { homedir } from "node:os";
27
- import { join } from "node:path";
27
+ import { dirname, join } from "node:path";
28
28
  import z from "@deepseek-ai/schemastery";
29
29
 
30
30
  import { DecisionCache } from "./decision-cache.js";
@@ -37,6 +37,7 @@ import { mountRoutePoint } from "./adapters/route.js";
37
37
  import { mountAgentPoints } from "./adapters/agent.js";
38
38
  import { mountCotTranslatePoint } from "./adapters/translate.js";
39
39
  import { applyConfigUpdates, parseConfigText } from "./config-file.js";
40
+ import { resolveDshHome } from "./home-paths.js";
40
41
  import { applyRefUpdates, listRefNames, pickRefValue } from "./credentials.js";
41
42
  import {
42
43
  DEFAULT_PROVIDER,
@@ -501,10 +502,9 @@ export function resolveKey(cfg, provider) {
501
502
  export function readCredentialFromDsh(ref) {
502
503
  const name = typeof ref === "string" ? ref.trim() : "";
503
504
  if (name.length === 0) return undefined;
504
- const candidates = [join(homedir(), ".dsh", ".credentials.yaml")];
505
- if (typeof process.env.DSH_HOME === "string" && process.env.DSH_HOME.length > 0) {
506
- candidates.unshift(join(process.env.DSH_HOME, ".credentials.yaml"));
507
- }
505
+ // 读侧与写侧必须用**同一套**规则,否则会出现"存了却读不回"。
506
+ // 两边都交给 resolveDshHome(),候选只剩一个路径。
507
+ const candidates = [join(resolveDshHome(), ".credentials.yaml")];
508
508
  for (const path of candidates) {
509
509
  let raw;
510
510
  try {
@@ -527,14 +527,11 @@ export function readCredentialFromDsh(ref) {
527
527
  * @returns 凭据文件绝对路径(不保证已存在)。
528
528
  */
529
529
  export function credentialFilePath() {
530
- // DSH_HOME 覆盖优先,其次 ~/.dsh。
531
- //
532
- // 注意:读取侧(readCredentialFromDsh)与写入侧必须用**同一套**候选,
533
- // 否则会出现"保存成功但读不回来"——写到了 A 文件、读的是 B 文件。
530
+ // 读取侧(readCredentialFromDsh)与写入侧必须用**同一套**规则,否则会
531
+ // 出现"保存成功但读不回来"——写到了 A 文件、读的是 B 文件。
534
532
  // 早先写入侧只认 ~/.dsh,就是在这一步把 key 写进真实家目录的。
535
- const home = typeof process.env.DSH_HOME === "string" ? process.env.DSH_HOME.trim() : "";
536
- if (home.length > 0) return join(home, ".credentials.yaml");
537
- return join(homedir(), ".dsh", ".credentials.yaml");
533
+ // 现在两侧都从 resolveDshHome() 派生,规则只有一份。
534
+ return join(resolveDshHome(), ".credentials.yaml");
538
535
  }
539
536
 
540
537
  /**
@@ -617,11 +614,10 @@ const LEGACY_CONFIG_FILE_NAMES = ["startlux.yml"];
617
614
  * 与插件源码分离——源码可以 clone 到任意位置,运行期数据只在 DSH 家目录下。
618
615
  */
619
616
  function defaultTelemetryDir() {
620
- // DSH_HOME 优先:它既是"DSH 家目录"的权威来源,也让测试能完全隔离,
621
- // 不必碰用户真实数据(HOME 在某些运行环境里不生效)。
622
- const home = typeof process.env.DSH_HOME === "string" ? process.env.DSH_HOME.trim() : "";
623
- const base = home.length > 0 ? home : join(homedir(), ".dsh");
624
- return join(base, DATA_DIR_NAME);
617
+ // 家目录解析统一走 home-paths.js:优先级、空白值处理、~ 展开与
618
+ // 绝对路径规范化都与宿主 @deepseek-ai/dsh-home-paths 一致。
619
+ // 早期各处手写一份 `DSH_HOME || ~/.dsh`,既重复又漏了规范化。
620
+ return join(resolveDshHome(), DATA_DIR_NAME);
625
621
  }
626
622
 
627
623
  /**
@@ -639,8 +635,7 @@ function defaultTelemetryDir() {
639
635
  * 找不到老配置。顺序即优先级:越新的旧位置越靠前。
640
636
  */
641
637
  function legacyConfigPaths() {
642
- const home = typeof process.env.DSH_HOME === "string" ? process.env.DSH_HOME.trim() : "";
643
- const base = home.length > 0 ? home : join(homedir(), ".dsh");
638
+ const base = resolveDshHome();
644
639
  return [
645
640
  // 上一代数据目录(本项目改名前的正式位置)
646
641
  join(base, "dsh-startlux-decision", "startlux.yml"),
@@ -1273,6 +1268,11 @@ export function apply(ctx, config) {
1273
1268
  */
1274
1269
  const persistConfig = async (updates) => {
1275
1270
  const { writeFile } = await import("node:fs/promises");
1271
+ // 先建目录:全新安装时数据目录还不存在,直接 writeFile 会 ENOENT。
1272
+ // 那会造成最糟的一种失败——凭据已经写进去了、配置却写不进去,
1273
+ // 用户只看到一条报错,而 key 其实「存了一半」。
1274
+ // 目录创建必须幂等(recursive),已有目录时是无操作。
1275
+ await mkdir(dirname(ownConfigPath), { recursive: true });
1276
1276
  let lines = [];
1277
1277
  try {
1278
1278
  lines = (await readFile(ownConfigPath, "utf8")).split("\n");
@@ -1345,17 +1345,31 @@ export function apply(ctx, config) {
1345
1345
  const names = Object.keys(credentialWrites);
1346
1346
  if (names.length === 0) return { ok: true, patch: ymlPatch };
1347
1347
 
1348
+ /** 已成功写入的项,带上写入前的值,供上层在后续步骤失败时回滚。 */
1349
+ const applied = [];
1348
1350
  for (const name of names) {
1351
+ // 先记下旧值:万一配置没写成功,要把凭据恢复原状,
1352
+ // 否则会出现「凭据存了、配置没存」的半成功状态。
1353
+ const previous = readCredentialFromDsh(name);
1349
1354
  const result = await writeCredentialToDsh(name, credentialWrites[name]);
1350
1355
  if (!result.ok) {
1356
+ // 本次之前已经写成功的,一并回滚,避免留下半套改动。
1357
+ for (const done of applied) {
1358
+ try {
1359
+ await writeCredentialToDsh(done.refName, done.previous ?? "");
1360
+ } catch {
1361
+ /* 回滚尽力而为,失败也不能再抛 */
1362
+ }
1363
+ }
1351
1364
  return {
1352
1365
  ok: false,
1353
1366
  patch: ymlPatch,
1354
1367
  error: `写入 ~/.dsh/.credentials.yaml 失败(${name}):${result.error}`,
1355
1368
  };
1356
1369
  }
1370
+ applied.push({ refName: name, previous });
1357
1371
  }
1358
- return { ok: true, patch: ymlPatch };
1372
+ return { ok: true, patch: ymlPatch, applied };
1359
1373
  };
1360
1374
 
1361
1375
  /** 一次决策的完整记录,写入 telemetry.jsonl 便于事后核对。 */
@@ -1830,7 +1844,28 @@ export function apply(ctx, config) {
1830
1844
  // key 已被写进指定位置的,不能同时再写进 yml(会变成两处明文)。
1831
1845
  const ymlPatch = { ...keyResult.patch };
1832
1846
 
1833
- await persistConfig(ymlPatch);
1847
+ try {
1848
+ await persistConfig(ymlPatch);
1849
+ } catch (error) {
1850
+ // 配置没写成功,就把已落地的凭据恢复原状:宁可整体失败,
1851
+ // 也不要留下「凭据存了、配置没存」的半套状态——那种状态在
1852
+ // 界面上看起来是保存失败,实际 key 已经悄悄换了。
1853
+ for (const done of keyResult.applied ?? []) {
1854
+ try {
1855
+ await writeCredentialToDsh(done.refName, done.previous ?? "");
1856
+ } catch {
1857
+ /* 回滚尽力而为 */
1858
+ }
1859
+ }
1860
+ return json(
1861
+ {
1862
+ error:
1863
+ `写入配置文件失败:${error instanceof Error ? error.message : String(error)}` +
1864
+ (keyResult.applied?.length > 0 ? "(已回滚本次写入的凭据)" : ""),
1865
+ },
1866
+ 500,
1867
+ );
1868
+ }
1834
1869
  return json({ ok: true, ...statusPayload() });
1835
1870
  } catch (error) {
1836
1871
  return json({ error: error instanceof Error ? error.message : String(error) }, 500);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-engine",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "dsh-decision-engine: a cheap judgement layer for DeepSeek Harness — all nine decision points (tool authorization, model routing, delegation, compaction, turn continuation, retrieval, skill pick, session title, COT translation) are implemented and individually switchable, and it works against any system-one style API (system one, bamboonode, or a custom base URL).",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",