dsh-web-search-plugin 0.4.1 → 0.5.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
@@ -12,6 +12,10 @@
12
12
  * writes once, credential written through the credentials domain) without
13
13
  * depending on that package's internal `CardForm`.
14
14
  *
15
+ * Adapted to DeepSeek Harness `0.2.x`: the card reads and writes its namespace
16
+ * through `ctx.configForms` (`ConfigForm`), replacing the removed
17
+ * `ctx.settingsScope` service.
18
+ *
15
19
  * @module dsh-web-search-plugin/client
16
20
  */
17
21
  window.__ModuleLoader__.load({
@@ -44,6 +48,42 @@ window.__ModuleLoader__.load({
44
48
  for (const entry of BUILTIN_REST_PROVIDERS) if (entry.keyField === field) return entry;
45
49
  return void 0;
46
50
  }
51
+ /**
52
+ * Where each provider issues its keys. The REST rows carry their own
53
+ * `officialUrl`; the three with dedicated branches are listed here so every
54
+ * engine gets the same "get an API key" affordance.
55
+ */
56
+ const KEY_CONSOLE_URLS = {
57
+ "deepseek-official": "https://platform.deepseek.com/",
58
+ tavily: "https://app.tavily.com/",
59
+ brave: "https://api-dashboard.search.brave.com/"
60
+ };
61
+ /** The console URL for the active engine, when it issues keys. */
62
+ function keyConsoleUrl(provider, restMeta) {
63
+ if (restMeta !== void 0) return restMeta.keyRequired ? restMeta.officialUrl : "";
64
+ return KEY_CONSOLE_URLS[provider] ?? "";
65
+ }
66
+ /**
67
+ * The "get an API key" link every key-issuing engine shows.
68
+ * @param t - the card's locale reader.
69
+ * @param provider - the active engine id.
70
+ * @param restMeta - the engine's metadata row, when it has one.
71
+ * @param keyed - Tavily only: whether it is in keyed mode.
72
+ * @returns the link element, or null when the engine issues no keys.
73
+ */
74
+ function keyConsoleLink(t, provider, restMeta, keyed) {
75
+ // Tavily's keyless mode needs no key, so it gets no console link.
76
+ if (provider === "tavily" && keyed !== true) return null;
77
+ const url = keyConsoleUrl(provider, restMeta);
78
+ if (url === "") return null;
79
+ return react.createElement("a", {
80
+ href: url,
81
+ target: "_blank",
82
+ rel: "noopener noreferrer",
83
+ className: "dshws_field_hint",
84
+ style: { color: "var(--dsw-alias-brand-primary,#3b82f6)", textDecoration: "none", padding: "8px 0", display: "inline-block" }
85
+ }, t("getKey") + " ↗");
86
+ }
47
87
  /** Credential reference Tavily keyed mode resolves when the section names none. */
48
88
  const DEFAULT_TAVILY_KEY_REF = "TAVILY_API_KEY";
49
89
  /** Credential reference Brave resolves when the section names none. */
@@ -162,6 +202,7 @@ window.__ModuleLoader__.load({
162
202
  invalidToast: "Fix the highlighted fields before saving.",
163
203
  readOnly: "This deployment stores settings read-only.",
164
204
  invalidNumber: "Enter a whole number of at least 1, or leave blank for the default.",
205
+ invalidCredentialRef: "Use an environment variable name: letters, digits, and underscores; the first character must be a letter or underscore.",
165
206
  quotaTitle: "Quota",
166
207
  quotaPlan: "Plan {plan}",
167
208
  quotaUsed: "Used {used} / {limit}",
@@ -258,6 +299,7 @@ window.__ModuleLoader__.load({
258
299
  invalidToast: "请先修正标红的字段再保存。",
259
300
  readOnly: "本部署的设置为只读。",
260
301
  invalidNumber: "请输入不小于 1 的整数;留空表示使用默认值。",
302
+ invalidCredentialRef: "请使用环境变量名:仅含字母、数字和下划线,且以字母或下划线开头。",
261
303
  quotaTitle: "额度",
262
304
  quotaPlan: "计划 {plan}",
263
305
  quotaUsed: "已用 {used} / {limit}",
@@ -311,6 +353,17 @@ window.__ModuleLoader__.load({
311
353
  }
312
354
  };
313
355
  }
356
+ function credentialField(field) {
357
+ return {
358
+ field,
359
+ format: (value) => typeof value === "string" ? value : "",
360
+ parse: (text) => {
361
+ const trimmed = text.trim();
362
+ if (trimmed === "") return { kind: "clear" };
363
+ return /^[A-Za-z_][A-Za-z0-9_]*$/u.test(trimmed) ? { kind: "set", value: trimmed } : void 0;
364
+ }
365
+ };
366
+ }
314
367
  /** A choice field spec: staged as its raw string. */
315
368
  function choiceField(field) {
316
369
  return {
@@ -330,7 +383,7 @@ window.__ModuleLoader__.load({
330
383
 
331
384
  /**
332
385
  * Staged form over the `dsh-web-search-plugin` settings namespace.
333
- * Write-only credential fields (`apiKey`, `braveApiKey`) have no section
386
+ * Write-only credential fields have no section
334
387
  * spec and are written through the credentials domain.
335
388
  */
336
389
  var SearchCardForm = class {
@@ -341,33 +394,33 @@ window.__ModuleLoader__.load({
341
394
  choiceField("provider"),
342
395
  choiceField("mode"),
343
396
  numberField("maxResults", { min: 1, max: MAX_RESULTS_CAP }),
344
- textField("apiKeyEnv"),
397
+ credentialField("apiKeyEnv"),
345
398
  textField("baseURL"),
346
399
  choiceField("searchDepth"),
347
400
  choiceField("topic"),
348
401
  booleanField("includeAnswer"),
349
- textField("braveApiKeyEnv"),
402
+ credentialField("braveApiKeyEnv"),
350
403
  textField("braveBaseURL"),
351
404
  textField("country"),
352
405
  textField("searchLang"),
353
406
  choiceField("freshness"),
354
407
  textField("proxy"),
355
- textField("deepseekApiKeyEnv"),
408
+ credentialField("deepseekApiKeyEnv"),
356
409
  textField("deepseekBaseURL"),
357
410
  textField("model"),
358
411
  textField("apiVersion"),
359
412
  numberField("maxTokens", { min: 1 }),
360
413
  numberField("maxUses", { min: 1 }),
361
- textField("serperApiKeyEnv"),
414
+ credentialField("serperApiKeyEnv"),
362
415
  textField("serperBaseURL"),
363
- textField("serpapiApiKeyEnv"),
416
+ credentialField("serpapiApiKeyEnv"),
364
417
  textField("serpapiBaseURL"),
365
- textField("exaApiKeyEnv"),
418
+ credentialField("exaApiKeyEnv"),
366
419
  textField("exaBaseURL"),
367
420
  textField("searxngBaseURL"),
368
- textField("scavioApiKeyEnv"),
421
+ credentialField("scavioApiKeyEnv"),
369
422
  textField("scavioBaseURL"),
370
- textField("firecrawlApiKeyEnv"),
423
+ credentialField("firecrawlApiKeyEnv"),
371
424
  textField("firecrawlBaseURL")
372
425
  ].map((spec) => [spec.field, spec]));
373
426
  this.staged = new Map();
@@ -404,7 +457,7 @@ window.__ModuleLoader__.load({
404
457
  available: snapshot.status === "ready",
405
458
  writable: snapshot.writable,
406
459
  dirty: plan.length > 0,
407
- invalid: plan.some((item) => item.run === void 0),
460
+ invalid: plan.some((item) => item.invalid === true),
408
461
  saving: this.saving,
409
462
  failed: this.failed,
410
463
  toastKey: this.toastKey
@@ -435,19 +488,19 @@ window.__ModuleLoader__.load({
435
488
  for (const [field, staged] of this.staged) {
436
489
  if (SECRET_FIELDS.has(field)) {
437
490
  const value = staged.text.trim();
438
- if (value !== "") plan.push({ field, run: () => this.writeKey(field, value) });
491
+ if (value !== "") plan.push({ field, credential: () => this.writeKey(field, value) });
439
492
  continue;
440
493
  }
441
494
  const spec = this.specs.get(field);
442
495
  if (staged.clear) {
443
- if (this.stored(field)) plan.push({ field, run: () => this.clear(field) });
496
+ if (this.stored(field)) plan.push({ field, op: { op: "unset", path: [field] } });
444
497
  continue;
445
498
  }
446
499
  if (staged.text === spec.format(this.sectionValue(field))) continue;
447
500
  const write = spec.parse(staged.text);
448
- if (write === void 0) plan.push({ field, run: void 0 });
449
- else if (write.kind === "clear") plan.push({ field, run: () => this.clear(field) });
450
- else plan.push({ field, run: () => this.store(field, write.value) });
501
+ if (write === void 0) plan.push({ field, invalid: true });
502
+ else if (write.kind === "clear") plan.push({ field, op: { op: "unset", path: [field] } });
503
+ else plan.push({ field, op: { op: "set", path: [field], value: write.value } });
451
504
  }
452
505
  return plan;
453
506
  }
@@ -463,22 +516,31 @@ window.__ModuleLoader__.load({
463
516
  }
464
517
  async save() {
465
518
  const plan = this.plan();
466
- const writes = plan.flatMap((item) => item.run === void 0 ? [] : [item.run]);
467
519
  if (this.saving) return;
468
520
  if (plan.length === 0) {
469
521
  this.showToast("nothing");
470
522
  return;
471
523
  }
472
- if (writes.length !== plan.length) {
524
+ if (plan.some((item) => item.invalid === true)) {
473
525
  this.showToast("invalid");
474
526
  return;
475
527
  }
528
+ const revision = this.snapshotOf().revision;
529
+ const credentials = plan.filter((item) => item.credential !== void 0);
530
+ const ops = plan.flatMap((item) => item.op === void 0 ? [] : [item.op]);
531
+ for (const item of credentials) {
532
+ // Override even an inherited literal key; unsetting could expose it again.
533
+ if (!ops.some((op) => op.path[0] === item.field)) ops.push({ op: "set", path: [item.field], value: "" });
534
+ }
476
535
  this.saving = true;
477
536
  this.failed = false;
478
537
  this.publish();
479
538
  let landed = true;
480
539
  try {
481
- for (const write of writes) landed = await write() && landed;
540
+ for (const item of credentials) {
541
+ if (!await item.credential()) { landed = false; break; }
542
+ }
543
+ if (landed && ops.length > 0) landed = await this.scope.mutate(ops, revision) === true;
482
544
  } catch (_saveFailure) {
483
545
  landed = false;
484
546
  }
@@ -486,19 +548,23 @@ window.__ModuleLoader__.load({
486
548
  this.saving = false;
487
549
  this.failed = !landed;
488
550
  this.showToast(landed ? "saved" : "failed");
551
+ if (landed) void usageStore.sync();
489
552
  }
490
553
  actions() {
491
554
  return {
492
555
  edit: (field, text) => {
556
+ if (this.saving) return;
493
557
  this.stage(field, { text, clear: false });
494
558
  },
495
559
  resetField: (field) => {
560
+ if (this.saving) return;
496
561
  this.stage(field, { text: this.baseText(field), clear: true });
497
562
  },
498
563
  save: () => {
499
564
  this.save();
500
565
  },
501
566
  discard: () => {
567
+ if (this.saving) return;
502
568
  if (this.staged.size === 0 && !this.failed) return;
503
569
  this.staged.clear();
504
570
  this.failed = false;
@@ -506,22 +572,6 @@ window.__ModuleLoader__.load({
506
572
  }
507
573
  };
508
574
  }
509
- async clear(field) {
510
- try {
511
- await this.scope.unset(field);
512
- return true;
513
- } catch (_clearFailure) {
514
- return false;
515
- }
516
- }
517
- async store(field, value) {
518
- try {
519
- await this.scope.set(field, value);
520
- return true;
521
- } catch (_storeFailure) {
522
- return false;
523
- }
524
- }
525
575
  stage(field, edit) {
526
576
  this.staged.set(field, edit);
527
577
  this.failed = false;
@@ -556,17 +606,21 @@ window.__ModuleLoader__.load({
556
606
  if (declared === "tavily" || declared === "brave" || declared === "deepseek-official" || builtinRest(declared) !== void 0) return declared;
557
607
  return "deepseek-official";
558
608
  }
609
+ credentialRef(field, fallback) {
610
+ const staged = this.staged.get(field);
611
+ const declared = staged === void 0
612
+ ? this.sectionValue(field)
613
+ : staged.clear ? this.snapshotOf().base?.[field] : staged.text.trim();
614
+ return typeof declared === "string" && declared.length > 0 ? declared : fallback;
615
+ }
559
616
  tavilyRef() {
560
- const declared = this.sectionValue("apiKeyEnv");
561
- return declared !== void 0 && declared.length > 0 ? declared : DEFAULT_TAVILY_KEY_REF;
617
+ return this.credentialRef("apiKeyEnv", DEFAULT_TAVILY_KEY_REF);
562
618
  }
563
619
  braveRef() {
564
- const declared = this.sectionValue("braveApiKeyEnv");
565
- return declared !== void 0 && declared.length > 0 ? declared : DEFAULT_BRAVE_KEY_REF;
620
+ return this.credentialRef("braveApiKeyEnv", DEFAULT_BRAVE_KEY_REF);
566
621
  }
567
622
  deepseekRef() {
568
- const declared = this.sectionValue("deepseekApiKeyEnv");
569
- return declared !== void 0 && declared.length > 0 ? declared : DEFAULT_DEEPSEEK_KEY_REF;
623
+ return this.credentialRef("deepseekApiKeyEnv", DEFAULT_DEEPSEEK_KEY_REF);
570
624
  }
571
625
  /** The credential reference the currently visible engine uses. */
572
626
  refOf() {
@@ -579,9 +633,8 @@ window.__ModuleLoader__.load({
579
633
  }
580
634
  /** Credential reference for a built-in REST provider's key env field. */
581
635
  restKeyRef(rest) {
582
- const declared = this.sectionValue(rest.keyEnvField);
583
- if (declared !== void 0 && declared.length > 0) return declared;
584
- return rest.id === "serper" ? "SERPER_API_KEY" : rest.id === "serpapi" ? "SERPAPI_API_KEY" : rest.id === "exa" ? "EXA_API_KEY" : rest.id === "scavio" ? "SCAVIO_API_KEY" : "FIRECRAWL_API_KEY";
636
+ const fallback = rest.id === "serper" ? "SERPER_API_KEY" : rest.id === "serpapi" ? "SERPAPI_API_KEY" : rest.id === "exa" ? "EXA_API_KEY" : rest.id === "scavio" ? "SCAVIO_API_KEY" : "FIRECRAWL_API_KEY";
637
+ return this.credentialRef(rest.keyEnvField, fallback);
585
638
  }
586
639
  async readCredential() {
587
640
  const ref = this.refOf();
@@ -621,9 +674,11 @@ window.__ModuleLoader__.load({
621
674
  try {
622
675
  const response = await this.remote.credentials.set(ref, value);
623
676
  if (!response.ok) return false;
624
- } catch (_credentialWriteFailure) {}
677
+ } catch (_credentialWriteFailure) {
678
+ return false;
679
+ }
625
680
  await this.readCredential();
626
- return this.credential.ref === ref ? this.credential.configured : true;
681
+ return true;
627
682
  }
628
683
  };
629
684
 
@@ -681,10 +736,10 @@ window.__ModuleLoader__.load({
681
736
  firecrawlApiKeyEnv: this.form.field("firecrawlApiKeyEnv"),
682
737
  firecrawlBaseURL: this.form.field("firecrawlBaseURL"),
683
738
  apiKeyConfigured: this.form.credential.configured,
684
- apiKeyWritable: this.form.credential.writable,
685
- toastKey: this.form.toastKey
686
- };
687
- }
739
+ apiKeyWritable: this.form.credential.writable,
740
+ toastKey: this.form.toastKey
741
+ };
742
+ }
688
743
  inject() {
689
744
  return {
690
745
  hooks: { pluginSearchCard: this.store },
@@ -701,7 +756,7 @@ window.__ModuleLoader__.load({
701
756
  react.createElement("span", { className: "dshws_badge" }, meta.t("overridden")),
702
757
  react.createElement("button", { type: "button", className: "dshws_reset", disabled: meta.disabled === true, onClick: meta.onReset }, meta.t("reset"))) : null),
703
758
  children,
704
- meta?.invalid ? react.createElement("p", { className: "dshws_field_error" }, meta.t("invalidNumber")) : hint ? react.createElement("p", { className: "dshws_field_hint" }, hint) : null);
759
+ meta?.invalid ? react.createElement("p", { className: "dshws_field_error" }, meta.t(meta.errorKey ?? "invalidNumber")) : hint ? react.createElement("p", { className: "dshws_field_hint" }, hint) : null);
705
760
  const pair = (left, right) => {
706
761
  if (!left && !right) return null;
707
762
  if (!left) return right;
@@ -838,8 +893,9 @@ window.__ModuleLoader__.load({
838
893
  return snap;
839
894
  },
840
895
  refresh: () => load(true),
896
+ sync: () => load(false),
841
897
  start() {
842
- void load(true);
898
+ void load(false);
843
899
  const timer = setInterval(() => {
844
900
  void load(false);
845
901
  }, USAGE_POLL_MS);
@@ -970,7 +1026,7 @@ window.__ModuleLoader__.load({
970
1026
  function WebSearchCard(props) {
971
1027
  const { t, state, actions } = props;
972
1028
  if (!state.available) return null;
973
- const disabled = !state.writable;
1029
+ const disabled = !state.writable || state.saving;
974
1030
  const provider = state.provider.text === "tavily" || state.provider.text === "brave" || state.provider.text === "deepseek-official" || builtinRest(state.provider.text) !== void 0 ? state.provider.text : "deepseek-official";
975
1031
  const brave = provider === "brave";
976
1032
  const deepseek = provider === "deepseek-official";
@@ -984,6 +1040,7 @@ window.__ModuleLoader__.load({
984
1040
  t,
985
1041
  overridden: state[field]?.overridden === true,
986
1042
  invalid: state[field]?.invalid === true,
1043
+ errorKey: field.endsWith("ApiKeyEnv") || field === "apiKeyEnv" ? "invalidCredentialRef" : "invalidNumber",
987
1044
  disabled,
988
1045
  onReset: () => actions.resetField(field)
989
1046
  });
@@ -1008,9 +1065,9 @@ window.__ModuleLoader__.load({
1008
1065
  !state.writable ? react.createElement("p", { className: "dshws_field_hint", style: { padding: "8px 0 0" } }, t("readOnly")) : null,
1009
1066
  pair(
1010
1067
  row("provider", t("provider"), null, selectInput({ id: "ws-provider", value: provider, disabled, onChange: (e) => actions.edit("provider", e.target.value), children: [
1068
+ react.createElement("option", { value: "deepseek-official", key: "deepseek-official" }, t("providerDeepseek")),
1011
1069
  react.createElement("option", { value: "tavily", key: "tavily" }, t("providerTavily")),
1012
1070
  react.createElement("option", { value: "brave", key: "brave" }, t("providerBrave")),
1013
- react.createElement("option", { value: "deepseek-official", key: "deepseek-official" }, t("providerDeepseek")),
1014
1071
  ...BUILTIN_REST_PROVIDERS.map((entry) => react.createElement("option", { value: entry.id, key: entry.id }, entry.name)),
1015
1072
  react.createElement("option", { value: "__ddg__", key: "ddg", disabled: true }, t("ddgDisabled"))
1016
1073
  ] }),
@@ -1021,16 +1078,17 @@ window.__ModuleLoader__.load({
1021
1078
  ] })) : null
1022
1079
  ),
1023
1080
  react.createElement(QuotaCard, { t, provider, keyed }),
1081
+ keyConsoleLink(t, provider, restMeta, keyed),
1024
1082
  deepseek ? pair(
1025
- fieldRow(t("apiKey"), keyHint, secretInput({ id: "ws-deepseek-key", value: state.deepseekApiKey.text, disabled: !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => actions.edit(DEEPSEEK_API_KEY_FIELD, e.target.value) })),
1083
+ fieldRow(t("apiKey"), keyHint, secretInput({ id: "ws-deepseek-key", value: state.deepseekApiKey.text, disabled: disabled || !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => actions.edit(DEEPSEEK_API_KEY_FIELD, e.target.value) })),
1026
1084
  row("deepseekApiKeyEnv", t("deepseekApiKeyEnv"), t("deepseekApiKeyEnvHint"), textInput({ id: "ws-deepseek-keyref", value: state.deepseekApiKeyEnv.text, disabled, onChange: (e) => actions.edit("deepseekApiKeyEnv", e.target.value) }))
1027
1085
  ) : null,
1028
1086
  brave ? pair(
1029
- fieldRow(t("apiKey"), keyHint, secretInput({ id: "ws-brave-key", value: state.braveApiKey.text, disabled: !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => actions.edit(BRAVE_API_KEY_FIELD, e.target.value) })),
1087
+ fieldRow(t("apiKey"), keyHint, secretInput({ id: "ws-brave-key", value: state.braveApiKey.text, disabled: disabled || !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => actions.edit(BRAVE_API_KEY_FIELD, e.target.value) })),
1030
1088
  row("braveApiKeyEnv", t("braveApiKeyEnv"), t("braveApiKeyEnvHint"), textInput({ id: "ws-brave-keyref", value: state.braveApiKeyEnv.text, disabled, onChange: (e) => actions.edit("braveApiKeyEnv", e.target.value) }))
1031
1089
  ) : null,
1032
1090
  (tavily && keyed) ? pair(
1033
- fieldRow(t("apiKey"), keyHint, secretInput({ id: "ws-tavily-key", value: state.apiKey.text, disabled: !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => actions.edit(TAVILY_API_KEY_FIELD, e.target.value) })),
1091
+ fieldRow(t("apiKey"), keyHint, secretInput({ id: "ws-tavily-key", value: state.apiKey.text, disabled: disabled || !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => actions.edit(TAVILY_API_KEY_FIELD, e.target.value) })),
1034
1092
  row("apiKeyEnv", t("apiKeyEnv"), t("apiKeyEnvHint"), textInput({ id: "ws-tavily-keyref", value: state.apiKeyEnv.text, disabled, onChange: (e) => actions.edit("apiKeyEnv", e.target.value) }))
1035
1093
  ) : null,
1036
1094
  deepseek ? row("deepseekBaseURL", t("deepseekBaseURL"), t("deepseekBaseURLHint"), textInput({ id: "ws-deepseek-base", value: state.deepseekBaseURL.text, disabled, onChange: (e) => actions.edit("deepseekBaseURL", e.target.value) })) : null,
@@ -1075,15 +1133,8 @@ window.__ModuleLoader__.load({
1075
1133
  brave ? row("proxy", t("proxy"), null, textInput({ id: "ws-proxy", value: state.proxy.text, disabled, placeholder: t("proxyPlaceholder"), onChange: (e) => actions.edit("proxy", e.target.value) })) : null,
1076
1134
  deepseek ? maxResultsRow : null,
1077
1135
  restMeta ? react.createElement("div", { className: "dshws_form" },
1078
- restMeta.keyRequired && restMeta.officialUrl ? react.createElement("a", {
1079
- href: restMeta.officialUrl,
1080
- target: "_blank",
1081
- rel: "noopener noreferrer",
1082
- className: "dshws_field_hint",
1083
- style: { color: "var(--dsw-alias-brand-primary,#3b82f6)", textDecoration: "none", padding: "8px 0", display: "inline-block" }
1084
- }, t("getKey") + " ↗") : null,
1085
1136
  restMeta.keyRequired ? pair(
1086
- fieldRow(t("apiKey"), keyHint, secretInput({ id: "ws-" + restMeta.id + "-key", value: state[restMeta.keyField]?.text ?? "", disabled: !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => actions.edit(restMeta.keyField, e.target.value) })),
1137
+ fieldRow(t("apiKey"), keyHint, secretInput({ id: "ws-" + restMeta.id + "-key", value: state[restMeta.keyField]?.text ?? "", disabled: disabled || !state.apiKeyWritable, placeholder: state.apiKeyConfigured ? t("apiKeySet") : t("apiKeyUnset"), onChange: (e) => actions.edit(restMeta.keyField, e.target.value) })),
1087
1138
  row(restMeta.keyEnvField, t("apiKeyEnv"), t("apiKeyEnvHint"), textInput({ id: "ws-" + restMeta.id + "-keyref", value: state[restMeta.keyEnvField]?.text ?? "", disabled, onChange: (e) => actions.edit(restMeta.keyEnvField, e.target.value) }))
1088
1139
  ) : null,
1089
1140
  row(restMeta.baseURLField, t("baseURL"), t("baseURLHint"), textInput({ id: "ws-" + restMeta.id + "-base", value: state[restMeta.baseURLField]?.text || restMeta.defaultBaseURL || "", disabled, onChange: (e) => actions.edit(restMeta.baseURLField, e.target.value) })),
@@ -1112,17 +1163,37 @@ window.__ModuleLoader__.load({
1112
1163
  }
1113
1164
 
1114
1165
  /** Section component: the "Web search" page body, driven by useSyncExternalStore. */
1115
- function makeSection(ctx, controller) {
1166
+ function makeSection(ctx) {
1116
1167
  return function WebSearchSection() {
1117
- const state = react.useSyncExternalStore(controller.store.subscribe, controller.store.getSnapshot);
1118
- const t = ctx.locale.bind(NS);
1119
- if (!state.available) return react.createElement("p", { style: { padding: "16px" } }, t("readOnly"));
1168
+ if (shared.controller === null) return null;
1120
1169
  return react.createElement("div", { style: { padding: "16px" } },
1121
- react.createElement("div", { style: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", borderRadius: "12px", padding: "4px 16px" } },
1122
- react.createElement(WebSearchCard, { t: t, state: state, actions: controller.form.actions() })));
1170
+ react.createElement(WebSearchConfigCard, { ctx: ctx }));
1123
1171
  };
1124
1172
  }
1125
1173
 
1174
+ /**
1175
+ * The settings card controller, or `null` when the Host does not serve
1176
+ * this namespace.
1177
+ * @type {{controller: SearchCardController | null}}
1178
+ */
1179
+ const shared = { controller: null };
1180
+
1181
+ /**
1182
+ * The configuration card reads the shared controller's store and draws
1183
+ * the bordered panel in the settings section.
1184
+ * @param props - `ctx` for locale binding.
1185
+ * @returns the card element, or null while the namespace is unserved.
1186
+ */
1187
+ function WebSearchConfigCard(props) {
1188
+ const controller = shared.controller;
1189
+ if (controller === null) return null;
1190
+ const state = react.useSyncExternalStore(controller.store.subscribe, controller.store.getSnapshot);
1191
+ const t = props.ctx.locale.bind(NS);
1192
+ if (!state.available) return react.createElement("p", { style: { padding: "16px" } }, t("readOnly"));
1193
+ return react.createElement("div", { style: { border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-3)", borderRadius: "12px", padding: "4px 16px" } },
1194
+ react.createElement(WebSearchCard, { t: t, state: state, actions: controller.form.actions() }));
1195
+ }
1196
+
1126
1197
  function syncSettingsNavIcon() {
1127
1198
  if (typeof document === "undefined" || typeof document.querySelectorAll !== "function") return;
1128
1199
  const lists = document.querySelectorAll("[class*='_navList']");
@@ -1149,26 +1220,43 @@ window.__ModuleLoader__.load({
1149
1220
  return () => observer.disconnect();
1150
1221
  }
1151
1222
 
1152
- /** Mount the top-level "Web search" settings section. */
1153
1223
  function apply(ctx) {
1154
1224
  const t = ctx.locale.bind(NS);
1155
1225
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-web-search-plugin: card dictionaries");
1156
- const controller = new SearchCardController(ctx.settingsScope.bind({ namespace: NS }), ctx.remote);
1157
- ctx.effect(() => ctx.remote.$on("credentials/reference-updated", (ref) => {
1158
- controller.form.refreshCredential(ref);
1159
- }), "dsh-web-search-plugin: credential invalidations");
1160
- ctx.effect(() => usageStore.start(), "dsh-web-search-plugin: usage poll");
1161
- ctx.effect(() => startSettingsNavIconSync(), "dsh-web-search-plugin: settings nav icon");
1162
- ctx.slots.inject("settings.section", () => ctx.slots.register({
1163
- name: "settings.section",
1164
- id: "web-search",
1165
- order: 10,
1166
- label: () => t("nav"),
1167
- locale: NS
1168
- }, makeSection(ctx, controller)));
1226
+
1227
+ // One controller for the settings section, created as soon as the Host
1228
+ // serves this namespace and torn down with it.
1229
+ ctx.effect(() => ctx.configForms.whileServed([NS], () => {
1230
+ shared.controller = new SearchCardController(ctx.configForms.get(NS), ctx.remote);
1231
+ const stopUsage = usageStore.start();
1232
+ const stopCredentials = ctx.remote.$on("credentials/reference-updated", (ref) => {
1233
+ shared.controller?.form.refreshCredential(ref);
1234
+ });
1235
+ const stopNavIcon = startSettingsNavIconSync();
1236
+ // The navigation entry belongs to the same lifetime as its Host form.
1237
+ let stopSection = () => {};
1238
+ const sectionFiber = ctx.slots.inject("settings.section", () => {
1239
+ stopSection = ctx.slots.register({
1240
+ name: "settings.section",
1241
+ id: "web-search",
1242
+ order: 10,
1243
+ label: () => t("nav"),
1244
+ locale: NS
1245
+ }, makeSection(ctx));
1246
+ return stopSection;
1247
+ });
1248
+ return () => {
1249
+ shared.controller = null;
1250
+ stopUsage();
1251
+ stopCredentials();
1252
+ stopNavIcon();
1253
+ stopSection();
1254
+ void sectionFiber.dispose();
1255
+ };
1256
+ }), "dsh-web-search-plugin: forms");
1169
1257
  }
1170
1258
 
1171
- const inject = ["slots", "locale", "remote", "remote.credentials", "settingsScope"];
1259
+ const inject = ["slots", "locale", "remote", "remote.credentials", "configForms"];
1172
1260
 
1173
1261
  exports.apply = apply;
1174
1262
  exports.inject = inject;