dsh-decision-engine 0.6.0 → 0.7.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/lib/client.js CHANGED
@@ -250,9 +250,9 @@ window.__ModuleLoader__.load({
250
250
  }
251
251
 
252
252
  /**
253
- * 待落盘的决策点/预算改动,没有改动时返回 null。
253
+ * 待落盘的决策点改动,没有改动时返回 null。
254
254
  *
255
- * 与 `/decisions` 的 PUT body 同形(`{ points?, budgetTokens? }`):
255
+ * 与 `/decisions` 的 PUT body 同形(`{ points? }`):
256
256
  * 服务端对空 body 直接 400,所以「没改动就不发」必须由调用方保证。
257
257
  */
258
258
  function pendingDecisions(values, decisions) {
@@ -261,17 +261,45 @@ window.__ModuleLoader__.load({
261
261
  const original = (decisions?.points ?? []).find((item) => item.id === id);
262
262
  if (original !== undefined && original.mode !== mode) changed[id] = mode;
263
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;
264
+ if (Object.keys(changed).length === 0) return null;
265
+ return { points: changed };
266
+ }
267
+
268
+ /**
269
+ * 当前 provider 的字段名与模型策略。
270
+ *
271
+ * **全部由服务端下发**(`providerCatalog` 的 `modelField` / `keyField` /
272
+ * `requiresModel` / `knownModels`),前端不抄一份。理由与 provider 下拉
273
+ * 那条一致:名字只有 providers.js 的预设表知道,前端再写一份就是
274
+ * 「界面显示存到 A、实际写进 B」这种最难自查的偏差。
275
+ *
276
+ * @param providerId - 当前 provider 的 id。
277
+ * @param view - `/status` 回显。
278
+ * @returns catalog 条目;拿不到(状态未加载)时返回 null。
279
+ */
280
+ function providerFieldsOf(providerId, view) {
281
+ const item = (view?.providerCatalog ?? []).find((row) => row.id === providerId);
282
+ // 字段名是写回配置的前提,少了它宁可不写——见 flush() 里的延后处理。
283
+ if (item === undefined || typeof item.modelField !== "string" || typeof item.keyField !== "string") {
284
+ return null;
285
+ }
286
+ return item;
287
+ }
288
+
289
+ /**
290
+ * 各模型字段的原值,键就是 config.yml 里的字段名。
291
+ *
292
+ * 服务端下发 `rawModels`(按字段名);老服务端只有三个散字段,
293
+ * 一并并入,于是「回显」与「比较有没有改动」都只有一种写法,
294
+ * 不会因为少了一格而漏判改动(漏判 = 改了不保存 = 静默)。
295
+ */
296
+ function rawModelsOf(view) {
297
+ return {
298
+ model: view?.rawModel ?? "",
299
+ systemoneModel: view?.rawSystemoneModel ?? "",
300
+ customModel: view?.customModel ?? "",
301
+ ...(view?.rawModels ?? {}),
302
+ };
275
303
  }
276
304
 
277
305
  /**
@@ -282,8 +310,10 @@ window.__ModuleLoader__.load({
282
310
  *
283
311
  * @param values - `{ draft, providerDraft, keyInputs, storageDraft }`。
284
312
  * @param keyCommit - 用户明确提交密钥的 provider(失焦/回车),否则 null。
313
+ * @param modelFields - 当前 provider 的字段(providerFieldsOf 结果,非 null)。
314
+ * @param keyFields - 提交密钥的那个 provider 的字段;本次没提交时为 null。
285
315
  */
286
- function configPatchOf(values, keyCommit) {
316
+ function configPatchOf(values, keyCommit, modelFields, keyFields) {
287
317
  const draft = values.draft;
288
318
  const provider = values.providerDraft;
289
319
  const patch = {
@@ -291,14 +321,10 @@ window.__ModuleLoader__.load({
291
321
  mode: draft.mode,
292
322
  // 思考模式总是提交:它是全局项(与 provider 无关),服务端还会再校验一次。
293
323
  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() }),
324
+ // 模型只写当前 provider 自己那一格(字段名叫什么由 fields 给)。
325
+ // 混写会让「切过去再切回来」丢掉原值,也会把 bamboonode 的模型名
326
+ // 发给别的服务。
327
+ [modelFields.modelField]: (draft[modelFields.modelField] ?? "").trim(),
302
328
  provider: provider.provider,
303
329
  customEndpointStyle: provider.customEndpointStyle ?? "auto",
304
330
  // custom 的字段总是带上:用户可能先填地址再切 provider,
@@ -317,13 +343,14 @@ window.__ModuleLoader__.load({
317
343
  }
318
344
  // 密钥:只有用户明确提交(失焦 / 回车)且长度够时才带上。
319
345
  // 否则打字过程中每停一下都会把半截 key 写进凭据文件。
320
- if (keyCommit !== null && keyCommit !== undefined) {
346
+ //
347
+ // 写到哪一格按**提交时的 provider**(keyCommit)而不是当前选中的 provider:
348
+ // 提交与落盘之间可能隔了几百毫秒(防抖、上一次保存还在飞),期间用户
349
+ // 完全可以切走。按当前选中项写,就会把硅基流动的 key 存进 bamboonode 的
350
+ // 格子——正是「存了 key 却说没配 key」的来源。
351
+ if (keyCommit !== null && keyCommit !== undefined && keyFields !== null) {
321
352
  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
- }
353
+ if (raw.length >= 10) patch[keyFields.keyField] = raw;
327
354
  }
328
355
  return patch;
329
356
  }
@@ -338,7 +365,7 @@ window.__ModuleLoader__.load({
338
365
  * @param patch - `configPatchOf` 拼出的 patch。
339
366
  * @param view - `/status` 回显(缺字段时用安全默认)。
340
367
  */
341
- function patchDiffersFromServer(patch, view) {
368
+ function patchDiffersFromServer(patch, view, fields) {
342
369
  const pairs = [
343
370
  ["enabled", view.enabled === true],
344
371
  ["mode", view.mode ?? "shadow"],
@@ -346,19 +373,20 @@ window.__ModuleLoader__.load({
346
373
  ["provider", view.provider ?? "bamboonode"],
347
374
  ["customBaseUrl", view.customBaseUrl ?? ""],
348
375
  ["customEndpointStyle", view.customEndpointStyle ?? "auto"],
349
- ["customModel", view.customModel ?? ""],
350
- ["model", view.rawModel ?? ""],
351
- ["systemoneModel", view.rawSystemoneModel ?? ""],
352
376
  ["customHeaders", view.customHeaders ?? ""],
353
377
  ["customApiKeyEnv", view.customApiKeyEnv ?? ""],
354
378
  ["keyStorage", view.keyStorage ?? "credentials"],
379
+ // 模型字段逐格比较:键就是 config.yml 里的字段名(rawModels 下发)。
380
+ // 用一张表而不是逐个 provider 写死,新增预设才不用改这里——漏一格
381
+ // 的后果是「改了模型却不触发保存」。
382
+ ...Object.entries(rawModelsOf(view)),
355
383
  ];
356
384
  for (const [key, server] of pairs) {
357
385
  if (Object.hasOwn(patch, key) && String(patch[key] ?? "") !== String(server ?? "")) return true;
358
386
  }
359
387
  // 密钥字段一出现就代表「有新 key 要写」:服务端只回传掩码,没法比较,
360
- // 而且重复写同一个 key 是无害的(幂等覆盖)。
361
- return ["systemoneApiKey", "bamboonodeApiKey", "customApiKey"].some((key) => Object.hasOwn(patch, key));
388
+ // 而且重复写同一个 key 是无害的(幂等覆盖)。字段名由服务端下发。
389
+ return Object.hasOwn(patch, fields.keyField);
362
390
  }
363
391
 
364
392
  /**
@@ -367,7 +395,7 @@ window.__ModuleLoader__.load({
367
395
  *
368
396
  * **改完即保存,没有「保存」按钮**:
369
397
  * - 开关 / 下拉 / 分段控件:点一下就落盘;
370
- * - 文本框(地址、请求头、预算):停手 `AUTOSAVE_DELAY_MS` 后落盘,失焦立刻落盘;
398
+ * - 文本框(地址、请求头):停手 `AUTOSAVE_DELAY_MS` 后落盘,失焦立刻落盘;
371
399
  * - API Key:失焦或回车才落盘(见 `commitKey`)。
372
400
  * 落盘状态显示在底部一行(保存中 / 已保存 / 失败原因 + 重试),
373
401
  * 取代了原来那个按钮。
@@ -379,9 +407,10 @@ window.__ModuleLoader__.load({
379
407
  */
380
408
  function StartluxPage(props) {
381
409
  const [status, setStatus] = react.useState(null);
382
- // 密钥输入按 provider 分槽:切到别的 provider 时,这一格不该带过去,
383
- // 否则容易把 A 的 key 存到 B 名下。
384
- const [keyInputs, setKeyInputs] = react.useState({ systemone: "", bamboonode: "", custom: "" });
410
+ // 密钥输入按 provider 分槽(键 = provider id):切到别的 provider 时,
411
+ // 这一格不该带过去,否则容易把 A 的 key 存到 B 名下。槽位不预置名单——
412
+ // 读的时候一律 `?? ""`,新增 provider 不必再改这里。
413
+ const [keyInputs, setKeyInputs] = react.useState({});
385
414
  const [busy, setBusy] = react.useState("");
386
415
  const [msg, setMsg] = react.useState(null);
387
416
  const [loading, setLoading] = react.useState(true);
@@ -403,7 +432,6 @@ window.__ModuleLoader__.load({
403
432
  const [decisions, setDecisions] = react.useState(null);
404
433
  // 待保存的模式改动(id → mode);null 表示该行未改动,沿用服务端值。
405
434
  const [pointsDraft, setPointsDraft] = react.useState({});
406
- const [budgetDraft, setBudgetDraft] = react.useState(null);
407
435
 
408
436
  // 用 ref 保存「用户是否有未保存改动」,使 refresh 不因状态变化而重建。
409
437
  // 否则落盘时 setDirty(false) → refresh 换新引用 → useEffect 再跑一次,
@@ -446,16 +474,18 @@ window.__ModuleLoader__.load({
446
474
  enabled: next.enabled === true,
447
475
  mode: next.mode ?? "shadow",
448
476
  thinkMode: next.thinkMode ?? "never",
449
- model: next.rawModel ?? "",
450
- systemoneModel: next.rawSystemoneModel ?? "",
477
+ // 模型各 provider 一格:键就是 config.yml 里的字段名(rawModels
478
+ // 下发),因此新增预设不必再改前端。切 provider 也不会把 A 的
479
+ // 模型名带进 B 那一格。
480
+ ...rawModelsOf(next),
451
481
  });
452
482
  // provider 回显:保存后重新打开必须看到自己选的那一项与 custom 值。
453
- // 全部来自服务端 status,前端不缓存、不推断。
483
+ // 全部来自服务端 status,前端不缓存、不推断。模型不在这里——
484
+ // 它按字段名放在 draft(见 rawModelsOf)。
454
485
  setProviderDraft({
455
486
  provider: next.provider ?? "bamboonode",
456
487
  customBaseUrl: next.customBaseUrl ?? "",
457
488
  customEndpointStyle: next.customEndpointStyle ?? "auto",
458
- customModel: next.customModel ?? "",
459
489
  customHeaders: next.customHeaders ?? "",
460
490
  customApiKeyEnv: next.customApiKeyEnv ?? "",
461
491
  });
@@ -489,7 +519,7 @@ window.__ModuleLoader__.load({
489
519
  }
490
520
  }, []);
491
521
 
492
- /** 拉决策点总览(含每个点的实测 token / 延迟 / 缓存与自身预算)。 */
522
+ /** 拉决策点总览(含每个点的实测 token / 延迟 / 缓存)。 */
493
523
  const loadDecisions = react.useCallback(async () => {
494
524
  try {
495
525
  const result = await call("/decisions");
@@ -521,6 +551,8 @@ window.__ModuleLoader__.load({
521
551
  providerError: "",
522
552
  providerCatalog: [],
523
553
  baseUrl: "",
554
+ // 探测出的可用路径:status 失败时留空(界面不显示那一行)。
555
+ detectedEndpoint: "",
524
556
  model: "",
525
557
  timeoutMs: 20000,
526
558
  keyConfigured: false,
@@ -539,14 +571,16 @@ window.__ModuleLoader__.load({
539
571
  mode: view.mode ?? "shadow",
540
572
  // 思考模式也走 draft:与其它控件一致,改完即自动落盘。
541
573
  thinkMode: view.thinkMode ?? "never",
542
- model: view.rawModel ?? "",
543
- systemoneModel: view.rawSystemoneModel ?? "",
574
+ // 模型一格一个 provider,键 = config.yml 字段名(见 rawModelsOf)。
575
+ ...rawModelsOf(view),
544
576
  };
577
+ // provider 草稿:与主配置分开保存,因为 custom 的字段是「先填后切」,
578
+ // 混进主 draft 会让「只切 provider」也顺手把没填完的自定义地址写进去。
579
+ // 模型不在这里——它跟模型行走,与 provider 切换同步刷新。
545
580
  const providerDraft = providerDraftState ?? {
546
581
  provider: view.provider ?? "bamboonode",
547
582
  customBaseUrl: view.customBaseUrl ?? "",
548
583
  customEndpointStyle: view.customEndpointStyle ?? "auto",
549
- customModel: view.customModel ?? "",
550
584
  customHeaders: view.customHeaders ?? "",
551
585
  customApiKeyEnv: view.customApiKeyEnv ?? "",
552
586
  };
@@ -563,14 +597,15 @@ window.__ModuleLoader__.load({
563
597
  enabled: next.enabled === true,
564
598
  mode: next.mode ?? "shadow",
565
599
  thinkMode: next.thinkMode ?? "never",
566
- model: next.rawModel ?? "",
567
- systemoneModel: next.rawSystemoneModel ?? "",
600
+ // 模型各 provider 一格:键就是 config.yml 里的字段名(rawModels
601
+ // 下发),因此新增预设不必再改前端。切 provider 也不会把 A 的
602
+ // 模型名带进 B 那一格。
603
+ ...rawModelsOf(next),
568
604
  });
569
605
  setProviderDraft({
570
606
  provider: next.provider ?? "bamboonode",
571
607
  customBaseUrl: next.customBaseUrl ?? "",
572
608
  customEndpointStyle: next.customEndpointStyle ?? "auto",
573
- customModel: next.customModel ?? "",
574
609
  customHeaders: next.customHeaders ?? "",
575
610
  customApiKeyEnv: next.customApiKeyEnv ?? "",
576
611
  });
@@ -581,7 +616,7 @@ window.__ModuleLoader__.load({
581
616
  // 每次渲染把「当前值」同步进 ref:控件在事件里读它,flush 也读它。
582
617
  // 放在渲染期赋值(而不是 useEffect)是必须的——useEffect 在提交之后才跑,
583
618
  // 而事件处理器可能在它之前就读到 ref,那样拿到的还是上一次的值。
584
- valuesRef.current = { draft, providerDraft, keyInputs, storageDraft, pointsDraft, budgetDraft };
619
+ valuesRef.current = { draft, providerDraft, keyInputs, storageDraft, pointsDraft };
585
620
 
586
621
  /**
587
622
  * 自动保存引擎。
@@ -654,7 +689,7 @@ window.__ModuleLoader__.load({
654
689
  * 落盘一次。
655
690
  *
656
691
  * 决策点走独立的 `/decisions` 而不是复用 `/config`:那两份配置的校验规则
657
- * 不同(决策点要拒绝未实装的点、预算要 clamp),混在一起容易互相放宽。
692
+ * 不同(决策点要拒绝未实装的点),混在一起容易互相放宽。
658
693
  */
659
694
  const flush = async () => {
660
695
  if (savingRef.current === true) return;
@@ -669,19 +704,34 @@ window.__ModuleLoader__.load({
669
704
  values.providerDraft.customBaseUrl,
670
705
  values.providerDraft.provider,
671
706
  );
672
- const configDeferred = urlProblem.length > 0;
707
+ // 服务端还没给出字段映射(配置页刚打开、status 尚未回来)。此时无法
708
+ // 知道「模型与 key 该写进 config.yml 的哪一格」,猜一个名字就是把
709
+ // siliconflow 的 key 写进 bamboonode 的格子——宁可延后并说明原因,
710
+ // 也不能猜着写。状态回来后 dirty 仍为 true,改动会自动补发。
711
+ const modelFields = providerFieldsOf(values.providerDraft.provider, view);
712
+ // 密钥按提交时的 provider 取字段(见 configPatchOf 里的说明)。
713
+ const keyFields =
714
+ keyCommit === null || keyCommit === undefined
715
+ ? null
716
+ : providerFieldsOf(keyCommit, view);
717
+ const mapProblem =
718
+ modelFields === null || (keyCommit != null && keyFields === null)
719
+ ? "配置尚未就绪(服务端未返回 provider 字段映射),请稍后重试"
720
+ : "";
721
+ const problem = urlProblem.length > 0 ? urlProblem : mapProblem;
722
+ const configDeferred = problem.length > 0;
673
723
  if (configDeferred === true && decisionsBody === null) {
674
- setSaveState({ error: `配置未保存:${urlProblem}` });
724
+ setSaveState({ error: `配置未保存:${problem}` });
675
725
  return;
676
726
  }
677
727
 
678
- const patch = configPatchOf(values, keyCommit);
728
+ const patch =
729
+ modelFields === null ? {} : configPatchOf(values, keyCommit, modelFields, keyFields);
679
730
  // 只要带了 key 就必须写配置:key 不能因为「其它字段没变」被漏掉。
680
- const keyWritten = ["systemoneApiKey", "bamboonodeApiKey", "customApiKey"].some((key) =>
681
- Object.hasOwn(patch, key),
682
- );
731
+ const keyWritten = keyFields !== null && Object.hasOwn(patch, keyFields.keyField);
683
732
  const configNeeded =
684
- configDeferred !== true && (keyWritten === true || patchDiffersFromServer(patch, view) === true);
733
+ configDeferred !== true
734
+ && (keyWritten === true || patchDiffersFromServer(patch, view, modelFields) === true);
685
735
 
686
736
  savingRef.current = true;
687
737
  setSaveState("saving");
@@ -711,17 +761,16 @@ window.__ModuleLoader__.load({
711
761
  // 期间又有改动时不清草稿:那些改动还没落盘,清掉就等于吞了。
712
762
  if (seq === editSeqRef.current) {
713
763
  setPointsDraft({});
714
- setBudgetDraft(null);
715
764
  }
716
765
  }
717
766
  if (seq !== editSeqRef.current) {
718
767
  // 期间的改动由 finally 接着发,这里保持「保存中」。
719
768
  setSaveState("saving");
720
769
  } else if (configDeferred === true) {
721
- // 决策点存了,配置那一半还欠着(等地址补全):**保持 dirty**,
722
- // 否则地址改好之后没人会再发请求,改动就悄悄丢了。
770
+ // 决策点存了,配置那一半还欠着(等地址补全或配置就绪):**保持 dirty**,
771
+ // 否则原因消失之后没人会再发请求,改动就悄悄丢了。
723
772
  dirtyRef.current = true;
724
- setSaveState({ error: `配置未保存:${urlProblem}` });
773
+ setSaveState({ error: `配置未保存:${problem}` });
725
774
  } else {
726
775
  dirtyRef.current = false;
727
776
  if (keyWritten === true) {
@@ -827,33 +876,26 @@ window.__ModuleLoader__.load({
827
876
  const isCustom = providerDraft.provider === "custom";
828
877
 
829
878
  /**
830
- * 模型字段按 provider 分派。
879
+ * 模型字段按 provider 分派:字段名叫什么由服务端下发(`modelField`),
880
+ * 前端不再逐个 provider 写 if/else——否则新增预设就得同步改前端,
881
+ * 漏改就是「A 的模型名写进了 B 的格子」。
831
882
  *
832
- * bamboonode -> model (老版本唯一的模型键)
833
- * systemone -> systemoneModel
834
- * custom -> customModel
883
+ * bamboonode -> model (老版本唯一的模型键)
884
+ * systemone -> systemoneModel
885
+ * siliconflow -> siliconflowModel
886
+ * siliconflow-intl -> siliconflowIntlModel
887
+ * custom -> customModel
835
888
  *
836
- * 三者分开存,切换 provider 才不会把 A 的模型名发给 B。
889
+ * 各格分开存,切换 provider 才不会把 A 的模型名发给 B。
837
890
  */
838
- const isSystemOne = providerDraft.provider === "systemone";
839
- const modelValue = isCustom
840
- ? providerDraft.customModel ?? ""
841
- : isSystemOne
842
- ? draft.systemoneModel ?? ""
843
- : draft.model ?? "";
891
+ const activeFields = providerFieldsOf(providerDraft.provider, view);
892
+ const modelField = activeFields?.modelField ?? "model";
893
+ const modelValue = draft[modelField] ?? "";
844
894
  const setModelValue = (value) => {
845
- if (isCustom) setProviderDraft({ ...providerDraft, customModel: value });
846
- else if (isSystemOne) setDraft({ ...draft, systemoneModel: value });
847
- else setDraft({ ...draft, model: value });
895
+ const next = { ...draft, [modelField]: value };
896
+ setDraft(next);
848
897
  // 下拉是离散控件:选完即存。
849
- scheduleSave(
850
- isCustom
851
- ? { providerDraft: { ...providerDraft, customModel: value } }
852
- : isSystemOne
853
- ? { draft: { ...draft, systemoneModel: value } }
854
- : { draft: { ...draft, model: value } },
855
- true,
856
- );
898
+ scheduleSave({ draft: next }, true);
857
899
  };
858
900
 
859
901
  /**
@@ -924,11 +966,24 @@ window.__ModuleLoader__.load({
924
966
  ? "由上方 base URL + 端点风格拼接;已带 /decisions 或 /systemone 时原样使用"
925
967
  : "内置预设的默认地址(可用 config.yml 的 baseUrl 覆盖 bamboonode)",
926
968
  h(
927
- "span",
928
- { style: { ...S.v, fontSize: "11px", opacity: 0.75, maxWidth: "300px" } },
929
- view.providerValid === false
930
- ? "配置不可用"
931
- : view.baseUrl || "(服务端未返回)",
969
+ "div",
970
+ { style: { display: "flex", flexDirection: "column", gap: "3px", maxWidth: "320px" } },
971
+ h(
972
+ "span",
973
+ { style: { ...S.v, fontSize: "11px", opacity: 0.75, wordBreak: "break-all" } },
974
+ view.providerValid === false
975
+ ? "配置不可用"
976
+ : view.baseUrl || "(服务端未返回)",
977
+ ),
978
+ // 探测出的实际可用路径:路径后缀由服务商决定(实测 SiliconFlow
979
+ // 只通 /systemone),必须让用户看见「最后打的是哪条」。
980
+ typeof view.detectedEndpoint === "string" && view.detectedEndpoint.length > 0
981
+ ? h(
982
+ "span",
983
+ { style: { ...S.v, fontSize: "11px", opacity: 0.85, color: "#7dd3a0" } },
984
+ `已验证可用:${view.detectedEndpoint}`,
985
+ )
986
+ : null,
932
987
  ),
933
988
  "endpoint",
934
989
  ),
@@ -995,18 +1050,25 @@ window.__ModuleLoader__.load({
995
1050
  ),
996
1051
  "thinkMode",
997
1052
  ),
998
- // 3) 模型:下拉选择,含「自动(服务端默认)」
999
- // custom 用自己那一格(customModel),内置预设共用 model——
1000
- // 分开存才能做到「切 provider 不覆盖其它 provider 已保存的模型」。
1053
+ // 3) 模型:下拉选择,含「留空」项
1054
+ // 每个 provider 用自己那一格(modelField 由服务端下发),分开存才能做到
1055
+ // 「切 provider 不覆盖其它 provider 已保存的模型」。
1056
+ //
1057
+ // 「留空」的含义由该端点的脾气决定:多数端点接受省略 model(服务端选
1058
+ // 默认);而 SiliconFlow 的 /systemone 把 model 列为必填(实测省略回
1059
+ // 400 `Model field is required.`),留空时插件改用预设默认 Kev-4B。
1060
+ // 文案必须说清楚,否则用户会以为「留空 = 让服务端挑」。
1001
1061
  row(
1002
1062
  "模型",
1003
- modelsState === "ok"
1004
- ? "从服务商实时获取;选「自动」则由服务端决定,换名也不会失效"
1005
- : modelsState === "loading"
1006
- ? "正在获取模型列表…"
1007
- : modelsState === "error"
1008
- ? "获取失败,可手填或留空用服务端默认"
1009
- : "未获取到列表,可手填或留空用服务端默认",
1063
+ (activeFields?.requiresModel ?? false) === true
1064
+ ? `该端点 model 必填;留空 = 用预设默认 ${activeFields.defaultModel}(可改填 SemIf)`
1065
+ : modelsState === "ok"
1066
+ ? "从服务商实时获取;选「自动」则由服务端决定,换名也不会失效"
1067
+ : modelsState === "loading"
1068
+ ? "正在获取模型列表…"
1069
+ : modelsState === "error"
1070
+ ? "获取失败,可手填或留空用服务端默认"
1071
+ : "未获取到列表,可手填或留空用服务端默认",
1010
1072
  h(
1011
1073
  "div",
1012
1074
  { style: { display: "flex", gap: "6px", alignItems: "center", flex: 1, minWidth: 0 } },
@@ -1017,7 +1079,9 @@ window.__ModuleLoader__.load({
1017
1079
  value: modelValue ?? "",
1018
1080
  onChange: (event) => setModelValue(event.target.value),
1019
1081
  },
1020
- h("option", { value: "" }, "自动(服务端默认)"),
1082
+ (activeFields?.requiresModel ?? false) === true
1083
+ ? h("option", { value: "" }, `默认(${activeFields.defaultModel})`)
1084
+ : h("option", { value: "" }, "自动(服务端默认)"),
1021
1085
  // 当前值不在列表里时也保留,避免下拉把已配置的值吞掉。
1022
1086
  ...((modelValue ?? "") === "" || models.some((item) => item.id === modelValue)
1023
1087
  ? []
@@ -1126,7 +1190,8 @@ window.__ModuleLoader__.load({
1126
1190
  ),
1127
1191
  row(
1128
1192
  "端点风格",
1129
- "auto:地址已带 /decisions 或 /systemone 就原样用,否则按 /decisions 拼;也可强制指定",
1193
+ "路径后缀由服务商决定(实测 SiliconFlow 只通 /systemone,bamboonode 两条都通)。" +
1194
+ "这里是「首选」:若首选路径返回 404,插件会自动改用另一条再试一次",
1130
1195
  h(
1131
1196
  "select",
1132
1197
  {
@@ -1139,7 +1204,7 @@ window.__ModuleLoader__.load({
1139
1204
  scheduleSave({ providerDraft: next }, true);
1140
1205
  },
1141
1206
  },
1142
- h("option", { value: "auto" }, "auto(自动)"),
1207
+ h("option", { value: "auto" }, "auto(先 /decisions)"),
1143
1208
  h("option", { value: "decisions" }, "decisions"),
1144
1209
  h("option", { value: "systemone" }, "systemone"),
1145
1210
  ),
@@ -1278,36 +1343,6 @@ window.__ModuleLoader__.load({
1278
1343
  changed ? h("span", { style: { ...S.badge, borderStyle: "solid", opacity: 0.9 } }, "保存中") : null,
1279
1344
  );
1280
1345
  }),
1281
- // 自身预算:决策模型自己也烧 token,不设上限会变成主要消耗方。
1282
- h(
1283
- "div",
1284
- { style: { ...S.rowLine, marginTop: "10px" }, key: "budget" },
1285
- h(
1286
- "div",
1287
- { style: S.rowText },
1288
- h("div", { style: S.rowTitle }, "自身 token 预算"),
1289
- h(
1290
- "div",
1291
- { style: S.rowHint },
1292
- `每小时上限,超出后自动退回启发式并放行(当前已用 ${decisions.budget?.used ?? 0})`,
1293
- ),
1294
- ),
1295
- h("input", {
1296
- type: "number",
1297
- min: 0,
1298
- step: 10000,
1299
- style: { ...S.input, maxWidth: "130px", flex: "none" },
1300
- defaultValue: decisions.budget?.limit ?? 0,
1301
- onChange: (event) => {
1302
- const value = Number(event.target.value);
1303
- const next = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : null;
1304
- setBudgetDraft(next);
1305
- // 数字框是文本输入:停手防抖落盘。
1306
- scheduleSave({ budgetDraft: next });
1307
- },
1308
- onBlur: () => scheduleSave(null, true),
1309
- }),
1310
- ),
1311
1346
  // 缓存命中:0 命中说明白名单外的调用每次都真发请求,是主要浪费。
1312
1347
  h(
1313
1348
  "div",
@@ -97,7 +97,7 @@ export class DecisionCache {
97
97
  * @param getLimit - 返回当前容量的取值函数;<=0 表示禁用缓存。
98
98
  * @param getTtlMs - 返回当前存活时间的取值函数。
99
99
  * 两者都接收函数而非定值:配置页改了缓存参数应当立刻生效,
100
- * 不该要求重启(与预算、端点、密钥的处理方式一致)。
100
+ * 不该要求重启(与端点、密钥的处理方式一致)。
101
101
  */
102
102
  constructor(getLimit, getTtlMs) {
103
103
  this.getLimit = getLimit;