dsh-decision-engine 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  // 保存进行中不拉状态,避免用旧快照覆盖刚写入的结果。
@@ -347,6 +521,8 @@ window.__ModuleLoader__.load({
347
521
  providerError: "",
348
522
  providerCatalog: [],
349
523
  baseUrl: "",
524
+ // 探测出的可用路径:status 失败时留空(界面不显示那一行)。
525
+ detectedEndpoint: "",
350
526
  model: "",
351
527
  timeoutMs: 20000,
352
528
  keyConfigured: false,
@@ -363,7 +539,7 @@ window.__ModuleLoader__.load({
363
539
  const draft = draftState ?? {
364
540
  enabled: view.enabled === true,
365
541
  mode: view.mode ?? "shadow",
366
- // 思考模式也走 draft:改完点保存才落盘,与开关语义一致。
542
+ // 思考模式也走 draft:与其它控件一致,改完即自动落盘。
367
543
  thinkMode: view.thinkMode ?? "never",
368
544
  model: view.rawModel ?? "",
369
545
  systemoneModel: view.rawSystemoneModel ?? "",
@@ -401,148 +577,185 @@ window.__ModuleLoader__.load({
401
577
  customApiKeyEnv: next.customApiKeyEnv ?? "",
402
578
  });
403
579
  setProviderError(next.providerValid === false ? next.providerError ?? "配置不可用" : "");
404
- setDirty(false);
405
580
  dirtyRef.current = false;
406
581
  };
407
582
 
583
+ // 每次渲染把「当前值」同步进 ref:控件在事件里读它,flush 也读它。
584
+ // 放在渲染期赋值(而不是 useEffect)是必须的——useEffect 在提交之后才跑,
585
+ // 而事件处理器可能在它之前就读到 ref,那样拿到的还是上一次的值。
586
+ valuesRef.current = { draft, providerDraft, keyInputs, storageDraft, pointsDraft, budgetDraft };
587
+
408
588
  /**
409
- * provider 相关改动是否待保存。
589
+ * 自动保存引擎。
590
+ *
591
+ * 三个动作:
592
+ * - `scheduleSave(partial, immediate)`:控件改动后调用。先**同步**并入
593
+ * `valuesRef`,再按需立即(开关/下拉)或防抖(文本框)落盘。
594
+ * - `flush()`:真正发请求。串行、合并,按需分两次(`/config` + `/decisions`)。
595
+ * - `commitKey(provider)`:密钥的「明确提交」(失焦 / 回车)。
410
596
  *
411
- * 逐字段比较而不是维护一个独立的 dirty 标记:切到 custom 又切回来、
412
- * 或者填了字又删掉,都应当回到「无改动」而不是永远亮着保存按钮。
597
+ * 三条不变量(都是这个界面反复踩过的坑):
598
+ *
599
+ * 1. 同一时刻只有一个请求在飞(`savingRef`)。期间的改动只并入
600
+ * `valuesRef`,等这次回来再发——并发保存会让两次快照互相覆盖。
601
+ * 2. 响应回来时若用户又改过(`editSeqRef` 变了),**不覆盖草稿**,
602
+ * 只刷新服务端状态。否则「保存中的输入被旧快照冲掉」= 回弹。
603
+ * 3. 失败不丢输入、也不自动重试:原因写进状态行,等用户改或点重试。
604
+ * 自动重试一个注定 400 的请求只会刷屏。
413
605
  */
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
- })();
606
+ const flushRef = react.useRef(null);
429
607
 
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
- );
608
+ /** 同步记录一次改动,并安排落盘(`immediate` 用于离散控件)。 */
609
+ const scheduleSave = (partial, immediate) => {
610
+ valuesRef.current = { ...valuesRef.current, ...(partial ?? {}) };
611
+ editSeqRef.current += 1;
612
+ dirtyRef.current = true;
613
+ // 防抖窗口里就先把状态改成「待保存」:否则这一小段时间还挂着上一次的
614
+ // 「已保存」,用户会以为刚才那一下已经落盘了。
615
+ setSaveState("pending");
616
+ if (timerRef.current !== null) {
617
+ clearTimeout(timerRef.current);
618
+ timerRef.current = null;
619
+ }
620
+ if (immediate === true) {
621
+ void flushRef.current();
622
+ return;
623
+ }
624
+ // 文本框:停手 AUTOSAVE_DELAY_MS 之后再落盘,避免每敲一个字符一次请求。
625
+ timerRef.current = setTimeout(() => {
626
+ timerRef.current = null;
627
+ void flushRef.current();
628
+ }, AUTOSAVE_DELAY_MS);
629
+ };
456
630
 
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;
631
+ /**
632
+ * 明确提交密钥(失焦 / 回车)。
633
+ *
634
+ * 密钥刻意**不**跟着防抖走:粘贴后停一下就把半截 key 写进凭据文件,
635
+ * 是比多点一次更糟的体验。这里只记「这一次是用户按下提交」。
636
+ */
637
+ const commitKey = (provider) => {
638
+ const raw = (valuesRef.current.keyInputs[provider] ?? "").trim();
639
+ // 留空 = 不修改(与旧版一致);太短的多半是没贴完,不落盘,给一句提示。
640
+ if (raw.length === 0) return;
641
+ if (raw.length < 10) {
642
+ setSaveState({ error: "未保存:密钥太短(至少 10 个字符)" });
643
+ return;
644
+ }
645
+ keyCommitRef.current = provider;
646
+ editSeqRef.current += 1;
647
+ dirtyRef.current = true;
648
+ if (timerRef.current !== null) {
649
+ clearTimeout(timerRef.current);
650
+ timerRef.current = null;
651
+ }
652
+ void flushRef.current();
653
+ };
465
654
 
466
655
  /**
467
- * 保存决策点与预算。
656
+ * 落盘一次。
468
657
  *
469
- * 走独立的 `/decisions` 而不是复用 `/config`:那两份配置的校验规则不同
470
- *(决策点要拒绝未实装的点、预算要 clamp),混在一起容易互相放宽。
658
+ * 决策点走独立的 `/decisions` 而不是复用 `/config`:那两份配置的校验规则
659
+ * 不同(决策点要拒绝未实装的点、预算要 clamp),混在一起容易互相放宽。
471
660
  */
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]);
661
+ const flush = async () => {
662
+ if (savingRef.current === true) return;
663
+ if (dirtyRef.current !== true) return;
664
+ const values = valuesRef.current;
665
+ const seq = editSeqRef.current;
666
+ const keyCommit = keyCommitRef.current;
667
+ const decisionsBody = pendingDecisions(values, decisions);
668
+ // 打字途中的非法地址:整笔延后(服务端必然拒绝,那一次 400 只是噪音)。
669
+ // 决策点与地址无关,仍然照常落盘——否则改个决策点也会被地址卡住。
670
+ const urlProblem = customUrlProblem(
671
+ values.providerDraft.customBaseUrl,
672
+ values.providerDraft.provider,
673
+ );
674
+ const configDeferred = urlProblem.length > 0;
675
+ if (configDeferred === true && decisionsBody === null) {
676
+ setSaveState({ error: `配置未保存:${urlProblem}` });
677
+ return;
678
+ }
679
+
680
+ const patch = configPatchOf(values, keyCommit);
681
+ // 只要带了 key 就必须写配置:key 不能因为「其它字段没变」被漏掉。
682
+ const keyWritten = ["systemoneApiKey", "bamboonodeApiKey", "customApiKey"].some((key) =>
683
+ Object.hasOwn(patch, key),
684
+ );
685
+ const configNeeded =
686
+ configDeferred !== true && (keyWritten === true || patchDiffersFromServer(patch, view) === true);
487
687
 
488
- /** 一次点击提交两半:主配置 + 决策点。任一失败都要报错,不能静默半保存。 */
489
- const saveAll = react.useCallback(async () => {
490
- setBusy("save");
491
- setMsg(null);
492
688
  savingRef.current = true;
689
+ setSaveState("saving");
690
+ setMsg(null);
493
691
  setProviderError("");
692
+ /** 失败的到底是哪一半:只有配置那一半才该显示成「provider 配置不可用」。 */
693
+ let failing = "config";
494
694
  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;
695
+ if (configNeeded === true) {
696
+ const next = await call("/config", {
697
+ method: "PUT",
698
+ headers: { "content-type": "application/json" },
699
+ body: JSON.stringify(patch),
700
+ });
701
+ // 用户又改过:只刷新服务端状态,草稿保留给下一次 flush。
702
+ if (seq === editSeqRef.current) applyServerState(next);
703
+ else setStatus(next);
524
704
  }
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]: "" });
705
+ failing = "decisions";
706
+ if (decisionsBody !== null) {
707
+ const next = await call("/decisions", {
708
+ method: "PUT",
709
+ headers: { "content-type": "application/json" },
710
+ body: JSON.stringify(decisionsBody),
711
+ });
712
+ setDecisions(next);
713
+ // 期间又有改动时不清草稿:那些改动还没落盘,清掉就等于吞了。
714
+ if (seq === editSeqRef.current) {
715
+ setPointsDraft({});
716
+ setBudgetDraft(null);
717
+ }
718
+ }
719
+ if (seq !== editSeqRef.current) {
720
+ // 期间的改动由 finally 接着发,这里保持「保存中」。
721
+ setSaveState("saving");
722
+ } else if (configDeferred === true) {
723
+ // 决策点存了,配置那一半还欠着(等地址补全):**保持 dirty**,
724
+ // 否则地址改好之后没人会再发请求,改动就悄悄丢了。
725
+ dirtyRef.current = true;
726
+ setSaveState({ error: `配置未保存:${urlProblem}` });
727
+ } else {
728
+ dirtyRef.current = false;
729
+ if (keyWritten === true) {
730
+ keyCommitRef.current = null;
731
+ // 存完就清空输入框:与旧版「粘贴 → 保存 → 框变空」一致,
732
+ // 也让「已配置」的提示能立刻反映新值。
733
+ setKeyInputs({ ...values.keyInputs, [keyCommit]: "" });
734
+ }
735
+ setSaveState("saved");
535
736
  }
536
- await saveDecisions();
537
- setMsg({ ok: true, text: "已保存" });
538
737
  } catch (error) {
539
- setMsg({ ok: false, text: error.message });
540
- setProviderError(error.message);
738
+ const text = error instanceof Error ? error.message : String(error);
739
+ // 校验失败(非法 URL / 请求头格式)走这里:保留用户已填内容,
740
+ // 只把原因显示出来——清空表单会让用户重填一遍。
741
+ // dirty 保持 true:失败的那一半(可能是决策点)还没落盘,
742
+ // 重试按钮与后续编辑都要能把它带上。
743
+ dirtyRef.current = true;
744
+ setSaveState({ error: text });
745
+ if (failing === "config") setProviderError(text);
541
746
  } finally {
542
747
  savingRef.current = false;
543
- setBusy("");
748
+ // 保存期间用户又改过:立刻再发一次,不等防抖(他已经停手了)。
749
+ if (dirtyRef.current === true && seq !== editSeqRef.current) void flush();
544
750
  }
545
- }, [draft, keyInputs, storageDraft, providerDraft, saveDecisions]);
751
+ };
752
+ flushRef.current = flush;
753
+
754
+ /** 状态行的「重试」:把上一次失败的值原样再发一遍。 */
755
+ const retrySave = () => {
756
+ dirtyRef.current = true;
757
+ void flushRef.current();
758
+ };
546
759
 
547
760
  const runTest = react.useCallback(async () => {
548
761
  setBusy("test");
@@ -634,8 +847,15 @@ window.__ModuleLoader__.load({
634
847
  if (isCustom) setProviderDraft({ ...providerDraft, customModel: value });
635
848
  else if (isSystemOne) setDraft({ ...draft, systemoneModel: value });
636
849
  else setDraft({ ...draft, model: value });
637
- setDirty(true);
638
- dirtyRef.current = true;
850
+ // 下拉是离散控件:选完即存。
851
+ scheduleSave(
852
+ isCustom
853
+ ? { providerDraft: { ...providerDraft, customModel: value } }
854
+ : isSystemOne
855
+ ? { draft: { ...draft, systemoneModel: value } }
856
+ : { draft: { ...draft, model: value } },
857
+ true,
858
+ );
639
859
  };
640
860
 
641
861
  /**
@@ -651,23 +871,10 @@ window.__ModuleLoader__.load({
651
871
  : "";
652
872
 
653
873
  /**
654
- * 本地预校验:与服务端同规则,只是为了让用户在点保存之前就看到问题。
655
- * 服务端仍会独立校验(前端校验不可信),这里只求「早一点提示」。
874
+ * 本地预校验:与服务端同规则(见模块级 `customUrlProblem`),
875
+ * 只是为了让用户在落盘之前就看到问题。服务端仍会独立校验(前端校验不可信)。
656
876
  */
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
- })();
877
+ const customUrlError = isCustom ? customUrlProblem(providerDraft.customBaseUrl, "custom") : "";
671
878
 
672
879
  /** provider 提示文案:优先用服务端下发的说明,避免前端另写一份。 */
673
880
  const providerHint =
@@ -698,8 +905,10 @@ window.__ModuleLoader__.load({
698
905
  // 切换 provider 只改「当前用哪个」,不动其它 provider 已保存的值:
699
906
  // custom 的字段留在 providerDraft 里,切回来还在。
700
907
  setProviderError("");
701
- setDirty(true);
702
- dirtyRef.current = true;
908
+ // 下拉是离散控件:选完即存。切到 custom 但地址还没填时,
909
+ // flush 会整笔延后并提示(见 flush 里的 urlProblem),
910
+ // 地址补全后自动落盘,不需要再点一次。
911
+ scheduleSave({ providerDraft: { ...providerDraft, provider: next } }, true);
703
912
  },
704
913
  },
705
914
  ...(catalog.length === 0
@@ -717,11 +926,24 @@ window.__ModuleLoader__.load({
717
926
  ? "由上方 base URL + 端点风格拼接;已带 /decisions 或 /systemone 时原样使用"
718
927
  : "内置预设的默认地址(可用 config.yml 的 baseUrl 覆盖 bamboonode)",
719
928
  h(
720
- "span",
721
- { style: { ...S.v, fontSize: "11px", opacity: 0.75, maxWidth: "300px" } },
722
- view.providerValid === false
723
- ? "配置不可用"
724
- : view.baseUrl || "(服务端未返回)",
929
+ "div",
930
+ { style: { display: "flex", flexDirection: "column", gap: "3px", maxWidth: "320px" } },
931
+ h(
932
+ "span",
933
+ { style: { ...S.v, fontSize: "11px", opacity: 0.75, wordBreak: "break-all" } },
934
+ view.providerValid === false
935
+ ? "配置不可用"
936
+ : view.baseUrl || "(服务端未返回)",
937
+ ),
938
+ // 探测出的实际可用路径:路径后缀由服务商决定(实测 SiliconFlow
939
+ // 只通 /systemone),必须让用户看见「最后打的是哪条」。
940
+ typeof view.detectedEndpoint === "string" && view.detectedEndpoint.length > 0
941
+ ? h(
942
+ "span",
943
+ { style: { ...S.v, fontSize: "11px", opacity: 0.85, color: "#7dd3a0" } },
944
+ `已验证可用:${view.detectedEndpoint}`,
945
+ )
946
+ : null,
725
947
  ),
726
948
  "endpoint",
727
949
  ),
@@ -732,8 +954,8 @@ window.__ModuleLoader__.load({
732
954
  toggle(draft.enabled === true, () => {
733
955
  const next = draft.enabled !== true;
734
956
  setDraft({ ...draft, enabled: next });
735
- setDirty(true);
736
- dirtyRef.current = true;
957
+ // 开关是离散控件:拨一下就落盘。
958
+ scheduleSave({ draft: { ...draft, enabled: next } }, true);
737
959
  }),
738
960
  "enabled",
739
961
  ),
@@ -744,8 +966,7 @@ window.__ModuleLoader__.load({
744
966
  toggle(draft.mode === "enforce", () => {
745
967
  const next = draft.mode === "enforce" ? "shadow" : "enforce";
746
968
  setDraft({ ...draft, mode: next });
747
- setDirty(true);
748
- dirtyRef.current = true;
969
+ scheduleSave({ draft: { ...draft, mode: next } }, true);
749
970
  }),
750
971
  "mode",
751
972
  ),
@@ -765,9 +986,10 @@ window.__ModuleLoader__.load({
765
986
  value: draft.thinkMode ?? "never",
766
987
  disabled: busy !== "",
767
988
  onChange: (event) => {
768
- setDraft({ ...draft, thinkMode: event.target.value });
769
- setDirty(true);
770
- dirtyRef.current = true;
989
+ const next = { ...draft, thinkMode: event.target.value };
990
+ setDraft(next);
991
+ // 下拉是离散控件:选完即存。
992
+ scheduleSave({ draft: next }, true);
771
993
  },
772
994
  },
773
995
  // 选项由服务端下发(thinkModes),前端不硬编码取值,避免漂移。
@@ -830,7 +1052,9 @@ window.__ModuleLoader__.load({
830
1052
  ),
831
1053
  "model",
832
1054
  ),
833
- // 4) API Key:每个 provider 一格,各自保存各自的 key
1055
+ // 4) API Key:每个 provider 一格,各自保存各自的 key。
1056
+ // 密钥**只**在失焦或回车时落盘(commitKey):打字途中自动保存会把
1057
+ // 半截 key 写进凭据文件,比多一步确认更危险。
834
1058
  row(
835
1059
  `API Key${keyOk ? `(已配置 ${activeKeyStatus?.hint ?? view.keyHint ?? ""})` : "(未配置)"}`,
836
1060
  activeKeyStatus !== null
@@ -840,17 +1064,24 @@ window.__ModuleLoader__.load({
840
1064
  // 混着说会让人去 .credentials.yaml 里找 key。
841
1065
  ? `存到插件配置 config.yml 的 ${activeKeyStatus.keyField || "显式 key 字段"}(明文)`
842
1066
  : `存到 .credentials.yaml 的 ${activeKeyStatus.refName}`
843
- : "留空则不修改;粘贴新 key 后点保存",
1067
+ : "留空则不修改;粘贴新 key 后失焦或回车即保存",
844
1068
  h("input", {
845
1069
  type: "password",
846
1070
  style: S.input,
847
1071
  placeholder: keyOk ? "sk-…(留空不改)" : "sk-…",
848
1072
  value: keyInputs[providerDraft.provider] ?? "",
849
- onChange: (event) =>
850
- setKeyInputs({ ...keyInputs, [providerDraft.provider]: event.target.value }),
1073
+ onChange: (event) => {
1074
+ const next = { ...keyInputs, [providerDraft.provider]: event.target.value };
1075
+ setKeyInputs(next);
1076
+ // 同步进 ref:commitKey 读的是它。回车可能紧贴最后一次按键,
1077
+ // 等重绘就来不及了。
1078
+ valuesRef.current = { ...valuesRef.current, keyInputs: next };
1079
+ },
1080
+ // 密钥只在「明确提交」时落盘:失焦或回车。
1081
+ onBlur: () => commitKey(providerDraft.provider),
851
1082
  onKeyDown: (event) => {
852
1083
  if (event.key !== "Enter") return;
853
- if ((keyInputs[providerDraft.provider] ?? "").trim().length >= 10) void saveAll();
1084
+ commitKey(providerDraft.provider);
854
1085
  },
855
1086
  }),
856
1087
  "key",
@@ -869,8 +1100,8 @@ window.__ModuleLoader__.load({
869
1100
  disabled: busy !== "",
870
1101
  onChange: (event) => {
871
1102
  setStorageDraft(event.target.value);
872
- setDirty(true);
873
- dirtyRef.current = true;
1103
+ // 下拉是离散控件:选完即存。
1104
+ scheduleSave({ storageDraft: event.target.value }, true);
874
1105
  },
875
1106
  },
876
1107
  h("option", { value: "credentials" }, ".credentials.yaml(默认)"),
@@ -898,29 +1129,33 @@ window.__ModuleLoader__.load({
898
1129
  placeholder: "https://api.example.com/v1",
899
1130
  value: providerDraft.customBaseUrl ?? "",
900
1131
  onChange: (event) => {
901
- setProviderDraft({ ...providerDraft, customBaseUrl: event.target.value });
1132
+ const next = { ...providerDraft, customBaseUrl: event.target.value };
1133
+ setProviderDraft(next);
902
1134
  setProviderError("");
903
- setDirty(true);
904
- dirtyRef.current = true;
1135
+ // 文本框:停手防抖落盘(打字途中的非法地址会被 flush 整笔延后)。
1136
+ scheduleSave({ providerDraft: next });
905
1137
  },
1138
+ onBlur: () => scheduleSave(null, true),
906
1139
  }),
907
1140
  "custom-url",
908
1141
  ),
909
1142
  row(
910
1143
  "端点风格",
911
- "auto:地址已带 /decisions 或 /systemone 就原样用,否则按 /decisions 拼;也可强制指定",
1144
+ "路径后缀由服务商决定(实测 SiliconFlow 只通 /systemone,bamboonode 两条都通)。" +
1145
+ "这里是「首选」:若首选路径返回 404,插件会自动改用另一条再试一次",
912
1146
  h(
913
1147
  "select",
914
1148
  {
915
1149
  style: { ...S.input, minWidth: "150px", flex: "none" },
916
1150
  value: providerDraft.customEndpointStyle ?? "auto",
917
1151
  onChange: (event) => {
918
- setProviderDraft({ ...providerDraft, customEndpointStyle: event.target.value });
919
- setDirty(true);
920
- dirtyRef.current = true;
1152
+ const next = { ...providerDraft, customEndpointStyle: event.target.value };
1153
+ setProviderDraft(next);
1154
+ // 下拉是离散控件:选完即存。
1155
+ scheduleSave({ providerDraft: next }, true);
921
1156
  },
922
1157
  },
923
- h("option", { value: "auto" }, "auto(自动)"),
1158
+ h("option", { value: "auto" }, "auto(先 /decisions)"),
924
1159
  h("option", { value: "decisions" }, "decisions"),
925
1160
  h("option", { value: "systemone" }, "systemone"),
926
1161
  ),
@@ -935,10 +1170,11 @@ window.__ModuleLoader__.load({
935
1170
  placeholder: "X-Tenant: team-a; X-Trace: dsh",
936
1171
  value: providerDraft.customHeaders ?? "",
937
1172
  onChange: (event) => {
938
- setProviderDraft({ ...providerDraft, customHeaders: event.target.value });
939
- setDirty(true);
940
- dirtyRef.current = true;
1173
+ const next = { ...providerDraft, customHeaders: event.target.value };
1174
+ setProviderDraft(next);
1175
+ scheduleSave({ providerDraft: next });
941
1176
  },
1177
+ onBlur: () => scheduleSave(null, true),
942
1178
  }),
943
1179
  "custom-headers",
944
1180
  ),
@@ -951,10 +1187,11 @@ window.__ModuleLoader__.load({
951
1187
  placeholder: "MY_DECISION_API_KEY",
952
1188
  value: providerDraft.customApiKeyEnv ?? "",
953
1189
  onChange: (event) => {
954
- setProviderDraft({ ...providerDraft, customApiKeyEnv: event.target.value });
955
- setDirty(true);
956
- dirtyRef.current = true;
1190
+ const next = { ...providerDraft, customApiKeyEnv: event.target.value };
1191
+ setProviderDraft(next);
1192
+ scheduleSave({ providerDraft: next });
957
1193
  },
1194
+ onBlur: () => scheduleSave(null, true),
958
1195
  }),
959
1196
  "custom-env",
960
1197
  ),
@@ -986,6 +1223,7 @@ window.__ModuleLoader__.load({
986
1223
  h("div", { style: S.sectionTitle }, "决策模型参与点"),
987
1224
  ...(decisions.points ?? []).map((point) => {
988
1225
  const current = Object.hasOwn(pointsDraft, point.id) ? pointsDraft[point.id] : point.mode;
1226
+ // 点完即存:这个角标只在「请求还在路上」的那一小会儿出现。
989
1227
  const changed = Object.hasOwn(pointsDraft, point.id) && pointsDraft[point.id] !== point.mode;
990
1228
  const st = point.stats ?? {};
991
1229
  // 「可用」与「已挂载」是两件事:点已实装(available),但宿主
@@ -1034,7 +1272,12 @@ window.__ModuleLoader__.load({
1034
1272
  type: "button",
1035
1273
  key: mode,
1036
1274
  disabled: busy !== "",
1037
- onClick: () => setPointsDraft({ ...pointsDraft, [point.id]: mode }),
1275
+ onClick: () => {
1276
+ const next = { ...pointsDraft, [point.id]: mode };
1277
+ setPointsDraft(next);
1278
+ // 三态分段是离散控件:点一下就落盘。
1279
+ scheduleSave({ pointsDraft: next }, true);
1280
+ },
1038
1281
  style: S.segItem(
1039
1282
  current === mode,
1040
1283
  mode === "enforce"
@@ -1048,7 +1291,7 @@ window.__ModuleLoader__.load({
1048
1291
  ),
1049
1292
  ),
1050
1293
  ),
1051
- changed ? h("span", { style: { ...S.badge, borderStyle: "solid", opacity: 0.9 } }, "待保存") : null,
1294
+ changed ? h("span", { style: { ...S.badge, borderStyle: "solid", opacity: 0.9 } }, "保存中") : null,
1052
1295
  );
1053
1296
  }),
1054
1297
  // 自身预算:决策模型自己也烧 token,不设上限会变成主要消耗方。
@@ -1073,8 +1316,12 @@ window.__ModuleLoader__.load({
1073
1316
  defaultValue: decisions.budget?.limit ?? 0,
1074
1317
  onChange: (event) => {
1075
1318
  const value = Number(event.target.value);
1076
- setBudgetDraft(Number.isFinite(value) ? Math.max(0, Math.floor(value)) : null);
1319
+ const next = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : null;
1320
+ setBudgetDraft(next);
1321
+ // 数字框是文本输入:停手防抖落盘。
1322
+ scheduleSave({ budgetDraft: next });
1077
1323
  },
1324
+ onBlur: () => scheduleSave(null, true),
1078
1325
  }),
1079
1326
  ),
1080
1327
  // 缓存命中:0 命中说明白名单外的调用每次都真发请求,是主要浪费。
@@ -1085,38 +1332,55 @@ window.__ModuleLoader__.load({
1085
1332
  `同类复用 ${decisions.cache?.softHits ?? 0} · 命中率 ${Math.round((decisions.cache?.hitRate ?? 0) * 100)}%`,
1086
1333
  ),
1087
1334
  ),
1088
- // 4) 保存按钮(同时提交开关、provider 与 key)
1335
+ // 4) 自动保存状态行:**没有「保存」按钮**,改动即落盘。
1336
+ // 这一行回答的是「刚才那一下到底存没存」:进行中 / 已存 /
1337
+ // 失败原因(附重试)。原来那个按钮的「保存中…」搬到这里。
1089
1338
  h(
1090
1339
  "div",
1091
- { style: { display: "flex", gap: "8px", margin: "14px 0 4px" }, key: "actions" },
1340
+ { style: { display: "flex", gap: "8px", alignItems: "center", margin: "14px 0 4px" }, key: "actions" },
1341
+ // 常驻说明:让第一次进来的人知道不需要找保存按钮。
1092
1342
  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" ? "保存中…" : "保存",
1343
+ "span",
1344
+ { style: { ...S.meta, marginTop: 0, flex: 1, opacity: 0.55 } },
1345
+ saveState === "pending"
1346
+ ? "待保存…"
1347
+ : saveState === "saving"
1348
+ ? "保存中…"
1349
+ : saveState === "saved"
1350
+ ? "已保存(改动即生效,无需点保存)"
1351
+ : saveState !== null && typeof saveState === "object" && typeof saveState.error === "string"
1352
+ ? null
1353
+ : "改动即保存",
1119
1354
  ),
1355
+ // 失败:原因直接给出来,另配「重试」——不能让用户只看到「保存失败」
1356
+ // 而不知道该改什么、能不能再试一次。
1357
+ saveState !== null && typeof saveState === "object" && typeof saveState.error === "string"
1358
+ ? h(
1359
+ "span",
1360
+ {
1361
+ style: {
1362
+ ...S.meta,
1363
+ marginTop: 0,
1364
+ flex: 1,
1365
+ color: "#f87171",
1366
+ opacity: 1,
1367
+ },
1368
+ },
1369
+ `未保存:${saveState.error}`,
1370
+ )
1371
+ : null,
1372
+ saveState !== null && typeof saveState === "object" && typeof saveState.error === "string"
1373
+ ? h(
1374
+ "button",
1375
+ {
1376
+ type: "button",
1377
+ disabled: busy !== "",
1378
+ onClick: retrySave,
1379
+ style: { ...S.saveBtn, flexShrink: 0, padding: "6px 10px" },
1380
+ },
1381
+ "重试",
1382
+ )
1383
+ : null,
1120
1384
  h(
1121
1385
  "button",
1122
1386
  {