@dsh-plus/web-search-services 0.1.19 → 0.2.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
@@ -54,6 +54,62 @@ window.__ModuleLoader__.load({
54
54
  ]
55
55
  });
56
56
  }
57
+ /**
58
+ * 勾选行:自绘圆角扁平开关(switch),对齐官方 WebUI 无原生 checkbox 的
59
+ * 设计语言(官方选择类控件全走胶囊/菜单,见 cardCss 的 -switch 规则)。
60
+ * 语义保持 checkbox(无障碍树与表单语义不变),视觉自绘。
61
+ */
62
+ function CheckRow(props) {
63
+ const p = props.prefix ?? "dshp";
64
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
65
+ className: `${p}-checkRow`,
66
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
67
+ id: props.id,
68
+ type: "checkbox",
69
+ role: "switch",
70
+ "aria-checked": props.checked,
71
+ checked: props.checked,
72
+ disabled: props.disabled === true,
73
+ onChange: (event) => props.onEdit(event.target.checked)
74
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
75
+ htmlFor: props.id,
76
+ children: props.label
77
+ })]
78
+ });
79
+ }
80
+ function SelectField(props) {
81
+ const p = props.prefix ?? "dshp";
82
+ const invalid = props.invalid === true;
83
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
84
+ className: `${p}-field`,
85
+ children: [
86
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
87
+ className: `${p}-head`,
88
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
89
+ className: `${p}-label`,
90
+ htmlFor: props.id,
91
+ children: props.label
92
+ })
93
+ }),
94
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
95
+ id: props.id,
96
+ className: `${p}-select${invalid ? ` ${p}-inputInvalid` : ""}`,
97
+ "aria-invalid": invalid || void 0,
98
+ value: props.value,
99
+ disabled: props.disabled === true,
100
+ onChange: (event) => props.onEdit(event.target.value),
101
+ children: props.options.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
102
+ value: option.value,
103
+ children: option.label
104
+ }, option.value))
105
+ }),
106
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
107
+ className: invalid ? `${p}-invalid` : `${p}-hint`,
108
+ children: invalid ? props.invalidLabel ?? "" : props.hint ?? ""
109
+ })
110
+ ]
111
+ });
112
+ }
57
113
  //#endregion
58
114
  //#region ../shared/src/client/card.tsx
59
115
  const IDLE_STATUS = {
@@ -152,6 +208,198 @@ window.__ModuleLoader__.load({
152
208
  }, reg.component));
153
209
  }
154
210
  //#endregion
211
+ //#region ../shared/src/client/credentials.tsx
212
+ /**
213
+ * 凭据面(共享件):官方 settings-controller 的 `remote.credentials` 命名空间
214
+ * (describe/set/unset/onChange),复用官方 credentials seam,不另开 HTTP 端点。
215
+ * **值只写不读**——describe 只回「已配置 / 来源 / 可写」,没有任何方法回传值。
216
+ *
217
+ * 凭据与 settings 草稿相互独立:不进命名空间、不参与脏判定,保存即落
218
+ * `$DSH_HOME/.credentials.yaml` 并热生效(宿主每次工具调用重新 resolve),
219
+ * 因此改口令不需要保存卡片、也不需要 /reload 或重启。
220
+ * @module @dsh-plus/shared/client/credentials
221
+ */
222
+ /**
223
+ * 把官方 `remote.credentials` 收成窄面:只补一个把宿主事件包成订阅的方法,
224
+ * 让状态层不必认识 `$on` 的事件名与载荷形状。
225
+ */
226
+ function credentialsFace(remote) {
227
+ const { credentials } = remote;
228
+ return {
229
+ describe: (refs) => credentials.describe(refs),
230
+ set: (ref, value) => credentials.set(ref, value),
231
+ unset: (ref) => credentials.unset(ref),
232
+ onChange: (listener) => remote.$on("credentials/reference-updated", (ref) => {
233
+ if (typeof ref === "string") listener(ref);
234
+ })
235
+ };
236
+ }
237
+ /** 未答(或应答缺项)时的视图:按「未配置、可写」呈现。 */
238
+ const ABSENT_VIEW = {
239
+ configured: false,
240
+ writable: true
241
+ };
242
+ /** 取某引用的视图;应答缺项回落为 {@link ABSENT_VIEW}。 */
243
+ function viewOf(views, ref) {
244
+ return views[ref] ?? ABSENT_VIEW;
245
+ }
246
+ /** 应答信封的错误文案(成功为空串)。 */
247
+ function failureOf(result) {
248
+ return result.ok ? "" : result.error.message ?? "";
249
+ }
250
+ function messageOf(error) {
251
+ return error instanceof Error ? error.message : String(error);
252
+ }
253
+ /**
254
+ * 凭据行状态:挂载即 describe 一次,写入后重新 describe;
255
+ * 外部改动经 `credentials/reference-updated` 触发同一路径刷新。
256
+ * @param face - 官方 credentials 远程面。
257
+ * @param refs - 本卡片关心的引用名(以换行拼接作为稳定依赖,避免数组身份抖动)。
258
+ */
259
+ function useCredentials(face, refs) {
260
+ const key = refs.join("\n");
261
+ const [views, setViews] = (0, react.useState)({});
262
+ const [busy, setBusy] = (0, react.useState)(false);
263
+ const [error, setError] = (0, react.useState)("");
264
+ const read = (0, react.useCallback)(async () => {
265
+ const list = key === "" ? [] : key.split("\n");
266
+ const result = await face.describe(list);
267
+ if (!result.ok) {
268
+ setError(failureOf(result));
269
+ return;
270
+ }
271
+ setError("");
272
+ setViews(result.value);
273
+ }, [face, key]);
274
+ (0, react.useEffect)(() => {
275
+ read();
276
+ }, [read]);
277
+ (0, react.useEffect)(() => face.onChange((ref) => {
278
+ if ((key === "" ? [] : key.split("\n")).includes(ref)) read();
279
+ }), [
280
+ face,
281
+ key,
282
+ read
283
+ ]);
284
+ const write = (0, react.useCallback)(async (run) => {
285
+ setBusy(true);
286
+ try {
287
+ const result = await run();
288
+ if (!result.ok) {
289
+ setError(failureOf(result));
290
+ return false;
291
+ }
292
+ setError("");
293
+ await read();
294
+ return true;
295
+ } catch (thrown) {
296
+ setError(messageOf(thrown));
297
+ return false;
298
+ } finally {
299
+ setBusy(false);
300
+ }
301
+ }, [read]);
302
+ return {
303
+ views,
304
+ busy,
305
+ error,
306
+ save: (0, react.useCallback)((ref, value) => write(() => face.set(ref, value)), [face, write]),
307
+ clear: (0, react.useCallback)((ref) => write(() => face.unset(ref)), [face, write])
308
+ };
309
+ }
310
+ /** 单行密钥:输入框 + 保存/清除,保存成功后清空输入(值不驻留 DOM)。 */
311
+ function SecretRow(props) {
312
+ const { prefix, row, view, busy, labels } = props;
313
+ const [text, setText] = (0, react.useState)("");
314
+ const locked = busy || !view.writable;
315
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
316
+ className: `${prefix}-field`,
317
+ children: [
318
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
319
+ prefix,
320
+ id: `${prefix}-secret-${row.ref}`,
321
+ label: row.label,
322
+ ...row.hint === void 0 ? {} : { hint: row.hint },
323
+ password: true,
324
+ value: text,
325
+ disabled: locked,
326
+ badge: {
327
+ text: view.configured ? labels.configured : labels.missing,
328
+ set: view.configured
329
+ },
330
+ onEdit: setText
331
+ }),
332
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
333
+ className: `${prefix}-sectionFooter`,
334
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
335
+ prefix,
336
+ variant: "primary",
337
+ label: labels.save,
338
+ disabled: locked || text === "",
339
+ onClick: () => {
340
+ props.onSave(row.ref, text).then((ok) => {
341
+ if (ok) setText("");
342
+ });
343
+ }
344
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
345
+ prefix,
346
+ variant: "ghost",
347
+ label: labels.clear,
348
+ disabled: locked || !view.configured,
349
+ onClick: () => {
350
+ props.onClear(row.ref);
351
+ }
352
+ })]
353
+ }),
354
+ view.writable ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
355
+ className: `${prefix}-warn`,
356
+ children: labels.shadowed
357
+ })
358
+ ]
359
+ });
360
+ }
361
+ /**
362
+ * 凭据小节:一行一个引用,值经官方 credentials 接口写入,不回显。
363
+ * 引用被进程环境遮蔽(`writable:false`)时禁用编辑并说明原因。
364
+ */
365
+ function CredentialsSection(props) {
366
+ const { prefix, rows, face, labels } = props;
367
+ const { views, busy, error, save, clear } = useCredentials(face, rows.map((row) => row.ref));
368
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
369
+ className: `${prefix}-section`,
370
+ children: [
371
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
372
+ className: `${prefix}-sectionHead`,
373
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
374
+ className: `${prefix}-sectionTitle`,
375
+ children: props.title
376
+ })
377
+ }),
378
+ props.description === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
379
+ className: `${prefix}-sectionDesc`,
380
+ children: props.description
381
+ }),
382
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
383
+ className: `${prefix}-sectionBody`,
384
+ children: rows.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SecretRow, {
385
+ prefix,
386
+ row,
387
+ labels,
388
+ face,
389
+ view: viewOf(views, row.ref),
390
+ busy,
391
+ onSave: save,
392
+ onClear: clear
393
+ }, row.ref))
394
+ }),
395
+ error === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
396
+ className: `${prefix}-warn`,
397
+ children: [labels.failed, error]
398
+ })
399
+ ]
400
+ });
401
+ }
402
+ //#endregion
155
403
  //#region ../shared/src/client/draft.ts
156
404
  /**
157
405
  * 单命名空间 staged 草稿(简单配置卡片共用):读 scope 快照、播种草稿、
@@ -468,83 +716,766 @@ ${extra}
468
716
  */
469
717
  const SETTINGS_NS = "dsh-plus-web-search-services";
470
718
  //#endregion
471
- //#region src/client/draft.ts
472
- /** 可选后端(与 config.ts 的 PRIORITY_ENTRY union 同源顺序)。 */
473
- const SEARCH_BACKENDS = [
474
- "tavily",
475
- "exa",
476
- "openai-chat"
477
- ];
478
- /** 单次搜索子进程超时下限(与 config.ts 的 .min(1000) 一致)。 */
479
- const TIMEOUT_MIN_MS = 1e3;
480
- function draftFromValue(value) {
481
- return {
482
- scriptPath: value.scriptPath,
483
- envFile: value.envFile,
484
- python: value.python,
485
- priorityText: value.priority.join("\n"),
486
- timeoutMsText: String(value.timeoutMs)
487
- };
719
+ //#region ../web-providers-core/src/card-ui.tsx
720
+ /**
721
+ * 条目链编辑器(搜索/抓取两个插件共用):字段、默认值与校验全部由 spec/kit 决定,
722
+ * 本组件只认「条目列表 + 字段描述符」,不认业务,因此新增后端字段时这里不用改。
723
+ *
724
+ * 控件形态来自 `./spec.ts`;校验提示是代号(`IssueCode`),文案由插件经 `labels.issue`
725
+ * 翻译;样式类名前缀由插件经 `prefix` 传入(各插件自己的 CSS 命名空间)。
726
+ * @module @dsh-plus/web-providers-core/card-ui
727
+ */
728
+ /** 取出某条目下标的问题(键去掉前缀)。 */
729
+ function issuesFor(issues, index) {
730
+ const prefix = `${index}.`;
731
+ return Object.fromEntries(Object.entries(issues).filter(([key]) => key.startsWith(prefix)).map(([key, code]) => [key.slice(prefix.length), code]));
488
732
  }
489
- /** 优先级文本 → 后端序列;顺带返回无法识别的行(供 UI 提示)。 */
490
- function parsePriority(text) {
491
- const backends = [];
492
- const unknown = [];
493
- const duplicated = [];
494
- for (const raw of text.split("\n")) {
495
- const line = raw.trim();
496
- if (line === "") continue;
497
- const match = SEARCH_BACKENDS.find((backend) => backend === line);
498
- if (match === void 0) {
499
- unknown.push(line);
500
- continue;
501
- }
502
- if (backends.includes(match)) {
503
- duplicated.push(line);
504
- continue;
733
+ /** 单个条目字段:按 spec 的 kind 分发控件。 */
734
+ function EntryField(props) {
735
+ const { prefix, labels, idPrefix, field, value, issue, disabled } = props;
736
+ const invalid = issue !== void 0;
737
+ const invalidLabel = issue === void 0 ? void 0 : labels.issue(issue);
738
+ const id = `${idPrefix}-${field.key}`;
739
+ const label = field.key;
740
+ if (field.kind === "boolean") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckRow, {
741
+ prefix,
742
+ id,
743
+ label,
744
+ checked: value === true,
745
+ disabled,
746
+ onEdit: (checked) => props.onEdit(checked)
747
+ });
748
+ const text = typeof value === "string" ? value : "";
749
+ if (field.kind === "select") {
750
+ const options = (field.options ?? []).map((option) => ({
751
+ value: option,
752
+ label: option === "" ? labels.unsetOption : option
753
+ }));
754
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SelectField, {
755
+ prefix,
756
+ id,
757
+ label,
758
+ value: text,
759
+ options,
760
+ disabled,
761
+ invalid,
762
+ ...invalidLabel === void 0 ? {} : { invalidLabel },
763
+ onEdit: (next) => props.onEdit(next)
764
+ });
765
+ }
766
+ if (field.kind === "libraries" || field.kind === "headers") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
767
+ className: `${prefix}-field`,
768
+ children: [
769
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
770
+ className: `${prefix}-head`,
771
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
772
+ className: `${prefix}-label`,
773
+ htmlFor: id,
774
+ children: label
775
+ })
776
+ }),
777
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
778
+ id,
779
+ className: `${prefix}-json${invalid ? ` ${prefix}-inputInvalid` : ""}`,
780
+ rows: field.kind === "libraries" ? 4 : 3,
781
+ value: text,
782
+ disabled,
783
+ onChange: (event) => props.onEdit(event.target.value)
784
+ }),
785
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
786
+ className: invalid ? `${prefix}-invalid` : `${prefix}-hint`,
787
+ children: invalid ? invalidLabel ?? "" : field.kind === "libraries" ? labels.librariesHint : labels.headersHint
788
+ })
789
+ ]
790
+ });
791
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
792
+ prefix,
793
+ id,
794
+ label,
795
+ ...field.kind === "csv" ? { hint: labels.csvHint } : {},
796
+ value: text,
797
+ numeric: field.kind === "number",
798
+ disabled,
799
+ invalid,
800
+ ...invalidLabel === void 0 ? {} : { invalidLabel },
801
+ onEdit: (next) => props.onEdit(next)
802
+ });
803
+ }
804
+ /** 单个条目:头部(序号/类型/条目名/链内开关/顺序与删除)+ 字段区。 */
805
+ function EntryBlock(props) {
806
+ const { prefix, labels, index, entry, issues, total, disabled } = props;
807
+ const spec = props.kit.specOf(entry.type);
808
+ const idIssue = issues.id;
809
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
810
+ className: `${prefix}-entry`,
811
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
812
+ className: `${prefix}-entryHead`,
813
+ children: [
814
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
815
+ className: `${prefix}-entryIndex`,
816
+ children: index + 1
817
+ }),
818
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
819
+ className: `${prefix}-badge ${prefix}-badgeSet`,
820
+ children: entry.type
821
+ }),
822
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
823
+ className: `${prefix}-entryActions`,
824
+ children: [
825
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
826
+ prefix,
827
+ variant: "ghost",
828
+ label: labels.moveUp,
829
+ disabled: disabled || index === 0,
830
+ onClick: () => props.onMove(-1)
831
+ }),
832
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
833
+ prefix,
834
+ variant: "ghost",
835
+ label: labels.moveDown,
836
+ disabled: disabled || index === total - 1,
837
+ onClick: () => props.onMove(1)
838
+ }),
839
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
840
+ prefix,
841
+ variant: "ghost",
842
+ label: labels.remove,
843
+ disabled,
844
+ onClick: props.onRemove
845
+ })
846
+ ]
847
+ })
848
+ ]
849
+ }), spec === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
850
+ className: `${prefix}-invalid`,
851
+ children: labels.unknownType
852
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
853
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
854
+ prefix,
855
+ id: `${prefix}-entry-${index}-id`,
856
+ label: labels.entryId,
857
+ hint: labels.entryIdHint,
858
+ value: entry.id,
859
+ disabled,
860
+ invalid: idIssue !== void 0,
861
+ ...idIssue === void 0 ? {} : { invalidLabel: labels.issue(idIssue) },
862
+ onEdit: (next) => props.onEdit({ id: next })
863
+ }),
864
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckRow, {
865
+ prefix,
866
+ id: `${prefix}-entry-${index}-enabled`,
867
+ label: labels.entryEnabled,
868
+ checked: entry.enabled,
869
+ disabled,
870
+ onEdit: (checked) => props.onEdit({ enabled: checked })
871
+ }),
872
+ spec.fields.map((field) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EntryField, {
873
+ prefix,
874
+ labels,
875
+ idPrefix: `${prefix}-entry-${index}`,
876
+ field,
877
+ value: entry.fields[field.key] ?? "",
878
+ issue: issues[field.key],
879
+ disabled,
880
+ onEdit: (value) => props.onField(field.key, value)
881
+ }, field.key))
882
+ ] })]
883
+ });
884
+ }
885
+ /** 后端链编辑器:条目块列表 + 新增条目。 */
886
+ function EntryEditor(props) {
887
+ const { prefix, kit, labels, draft, disabled, onChange } = props;
888
+ const issues = kit.issuesOf(draft);
889
+ const [addType, setAddType] = (0, react.useState)(kit.entryTypes[0] ?? "");
890
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
891
+ className: `${prefix}-section`,
892
+ children: [
893
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
894
+ className: `${prefix}-sectionHead`,
895
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
896
+ className: `${prefix}-sectionTitle`,
897
+ children: labels.title
898
+ })
899
+ }),
900
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
901
+ className: `${prefix}-sectionDesc`,
902
+ children: labels.description
903
+ }),
904
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
905
+ className: `${prefix}-sectionBody`,
906
+ children: [draft.entries.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
907
+ className: `${prefix}-warn`,
908
+ children: labels.empty
909
+ }) : null, draft.entries.map((entry, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EntryBlock, {
910
+ prefix,
911
+ kit,
912
+ labels,
913
+ index,
914
+ entry,
915
+ total: draft.entries.length,
916
+ issues: issuesFor(issues, index),
917
+ disabled,
918
+ onEdit: (patch) => {
919
+ const entries = draft.entries.map((item, position) => position === index ? {
920
+ ...item,
921
+ ...patch
922
+ } : item);
923
+ onChange({
924
+ ...draft,
925
+ entries
926
+ });
927
+ },
928
+ onField: (key, value) => {
929
+ const entries = draft.entries.map((item, position) => position === index ? {
930
+ ...item,
931
+ fields: {
932
+ ...item.fields,
933
+ [key]: value
934
+ }
935
+ } : item);
936
+ onChange({
937
+ ...draft,
938
+ entries
939
+ });
940
+ },
941
+ onMove: (delta) => onChange(kit.moveEntry(draft, index, delta)),
942
+ onRemove: () => onChange(kit.removeEntry(draft, index))
943
+ }, entry.key))]
944
+ }),
945
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
946
+ className: `${prefix}-sectionFooter`,
947
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
948
+ id: `${prefix}-add-type`,
949
+ className: `${prefix}-select`,
950
+ value: addType,
951
+ disabled,
952
+ onChange: (event) => setAddType(event.target.value),
953
+ children: kit.entryTypes.map((type) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
954
+ value: type,
955
+ children: type
956
+ }, type))
957
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
958
+ prefix,
959
+ label: labels.addEntry,
960
+ disabled: disabled || addType === "",
961
+ onClick: () => onChange(kit.addEntry(draft, addType))
962
+ })]
963
+ })
964
+ ]
965
+ });
966
+ }
967
+ //#endregion
968
+ //#region ../web-providers-core/src/card.ts
969
+ /** 解析值 → 草稿文本:csv 走逗号拼接,JSON 类字段走格式化 JSON(畸形值原样露出)。 */
970
+ function asText(value, field) {
971
+ if (field.kind === "csv") return Array.isArray(value) ? value.join(", ") : String(value ?? "");
972
+ if (field.kind === "libraries" || field.kind === "headers") return JSON.stringify(value ?? (field.kind === "libraries" ? [] : {}), null, 2);
973
+ return String(value ?? "");
974
+ }
975
+ function numberIssue(value, field) {
976
+ if (!/^-?\d+$/.test(value.trim())) return "int";
977
+ const parsed = Number(value);
978
+ if (parsed < (field.min ?? 0)) return "below-min";
979
+ if (field.max !== void 0 && parsed > field.max) return "above-max";
980
+ }
981
+ function jsonIssue(value, kind) {
982
+ let parsed;
983
+ try {
984
+ parsed = JSON.parse(value === "" ? kind === "libraries" ? "[]" : "{}" : value);
985
+ } catch {
986
+ return "json";
987
+ }
988
+ if (kind === "libraries") {
989
+ if (!Array.isArray(parsed)) return "json-array";
990
+ for (const item of parsed) {
991
+ const name = item?.name;
992
+ if (typeof item !== "object" || item === null || typeof name !== "string") return "library-shape";
505
993
  }
506
- backends.push(match);
994
+ return;
507
995
  }
508
- return {
509
- backends,
510
- unknown,
511
- duplicated
512
- };
996
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return "json-object";
997
+ return Object.values(parsed).every((item) => typeof item === "string") ? void 0 : "json-strings";
513
998
  }
514
- /** 优先级是否可用:至少一个后端、无未知项、无重复项。 */
515
- function priorityTextOk(text) {
516
- const { backends, unknown, duplicated } = parsePriority(text);
517
- return backends.length > 0 && unknown.length === 0 && duplicated.length === 0;
999
+ function fieldIssue(field, value) {
1000
+ if (field.kind === "boolean") return void 0;
1001
+ if (typeof value !== "string") return "json";
1002
+ if (field.required === true && value.trim() === "") return "required";
1003
+ switch (field.kind) {
1004
+ case "number": return numberIssue(value, field);
1005
+ case "select":
1006
+ if (field.options === void 0) return void 0;
1007
+ return field.options.includes(value) ? void 0 : "option";
1008
+ case "libraries":
1009
+ case "headers": return jsonIssue(value, field.kind);
1010
+ default: return;
1011
+ }
518
1012
  }
519
- /** 超时是否可用:≥1000 的整数(对齐 schema 的 .natural().min(1000))。 */
520
- function timeoutTextOk(text) {
521
- if (!/^\d+$/.test(text.trim())) return false;
522
- return Number(text) >= TIMEOUT_MIN_MS;
1013
+ function fieldValue(field, value) {
1014
+ if (field.kind === "boolean") return value === true;
1015
+ const text = typeof value === "string" ? value : "";
1016
+ switch (field.kind) {
1017
+ case "number": return Number(text);
1018
+ case "csv": return text.split(",").map((item) => item.trim()).filter((item) => item !== "");
1019
+ case "libraries": return JSON.parse(text === "" ? "[]" : text);
1020
+ case "headers": return JSON.parse(text === "" ? "{}" : text);
1021
+ default: return text;
1022
+ }
523
1023
  }
524
- /** 草稿 → 提交形状(调用方保证已通过校验)。 */
525
- function toPatch(draft) {
1024
+ /** 用一份条目描述符建卡片工具箱。 */
1025
+ function createCardKit(specs) {
1026
+ const specOf = (type) => specs.find((spec) => spec.type === type);
1027
+ let nextKey = 0;
1028
+ const newKey = () => {
1029
+ nextKey += 1;
1030
+ return `entry-${nextKey}`;
1031
+ };
1032
+ const entryFromDefaults = (type, id, spec) => {
1033
+ const fields = {};
1034
+ for (const field of spec.fields) {
1035
+ const raw = spec.defaults[field.key];
1036
+ fields[field.key] = field.kind === "boolean" ? raw === true : asText(raw, field);
1037
+ }
1038
+ return {
1039
+ key: newKey(),
1040
+ type,
1041
+ id,
1042
+ enabled: true,
1043
+ fields
1044
+ };
1045
+ };
1046
+ const entryIssues = (entry) => {
1047
+ const issues = {};
1048
+ if (entry.id.trim() === "") issues.id = "id-empty";
1049
+ const spec = specOf(entry.type);
1050
+ if (spec === void 0) {
1051
+ issues.type = "type-unknown";
1052
+ return issues;
1053
+ }
1054
+ for (const field of spec.fields) {
1055
+ const issue = fieldIssue(field, entry.fields[field.key] ?? "");
1056
+ if (issue !== void 0) issues[field.key] = issue;
1057
+ }
1058
+ return issues;
1059
+ };
1060
+ const issuesOf = (draft) => {
1061
+ const issues = {};
1062
+ const seen = /* @__PURE__ */ new Set();
1063
+ draft.entries.forEach((entry, index) => {
1064
+ for (const [key, code] of Object.entries(entryIssues(entry))) issues[`${index}.${key}`] = code;
1065
+ if (seen.has(entry.id)) issues[`${index}.id`] = "id-duplicate";
1066
+ seen.add(entry.id);
1067
+ });
1068
+ return issues;
1069
+ };
526
1070
  return {
527
- scriptPath: draft.scriptPath,
528
- envFile: draft.envFile,
529
- python: draft.python,
530
- priority: parsePriority(draft.priorityText).backends,
531
- timeoutMs: Number(draft.timeoutMsText)
1071
+ entryTypes: specs.map((spec) => spec.type),
1072
+ specOf,
1073
+ defaultsSeed: (type) => {
1074
+ const spec = specOf(type);
1075
+ return spec === void 0 ? void 0 : entryFromDefaults(type, type, spec);
1076
+ },
1077
+ draftFromValue: (value) => ({
1078
+ enabled: value.enabled,
1079
+ exposeEntries: value.exposeEntries,
1080
+ proxy: value.proxy,
1081
+ entries: value.entries.map((entry) => {
1082
+ const type = String(entry.type ?? "");
1083
+ const fields = {};
1084
+ for (const field of specOf(type)?.fields ?? []) {
1085
+ const raw = entry[field.key];
1086
+ fields[field.key] = field.kind === "boolean" ? raw === true : asText(raw, field);
1087
+ }
1088
+ return {
1089
+ key: newKey(),
1090
+ type,
1091
+ id: String(entry.id ?? ""),
1092
+ enabled: entry.enabled !== false,
1093
+ fields
1094
+ };
1095
+ })
1096
+ }),
1097
+ addEntry: (draft, type) => {
1098
+ const seed = specOf(type);
1099
+ return seed === void 0 ? draft : {
1100
+ ...draft,
1101
+ entries: [...draft.entries, entryFromDefaults(type, type, seed)]
1102
+ };
1103
+ },
1104
+ removeEntry: (draft, index) => ({
1105
+ ...draft,
1106
+ entries: draft.entries.filter((_, position) => position !== index)
1107
+ }),
1108
+ moveEntry: (draft, index, delta) => {
1109
+ const target = index + delta;
1110
+ const moved = draft.entries[index];
1111
+ const displaced = draft.entries[target];
1112
+ if (moved === void 0 || displaced === void 0) return draft;
1113
+ const entries = [...draft.entries];
1114
+ entries[index] = displaced;
1115
+ entries[target] = moved;
1116
+ return {
1117
+ ...draft,
1118
+ entries
1119
+ };
1120
+ },
1121
+ entryIssues,
1122
+ issuesOf,
1123
+ draftValid: (draft) => Object.keys(issuesOf(draft)).length === 0,
1124
+ toPatch: (draft) => ({
1125
+ enabled: draft.enabled,
1126
+ exposeEntries: draft.exposeEntries,
1127
+ proxy: draft.proxy,
1128
+ entries: draft.entries.map((entry) => {
1129
+ const out = {
1130
+ type: entry.type,
1131
+ id: entry.id.trim(),
1132
+ enabled: entry.enabled
1133
+ };
1134
+ for (const field of specOf(entry.type)?.fields ?? []) out[field.key] = fieldValue(field, entry.fields[field.key] ?? "");
1135
+ return out;
1136
+ })
1137
+ }),
1138
+ refsOf: (draft) => {
1139
+ const refs = [];
1140
+ for (const entry of draft.entries) for (const field of specOf(entry.type)?.fields ?? []) {
1141
+ if (field.refs !== true) continue;
1142
+ const value = entry.fields[field.key];
1143
+ if (typeof value !== "string") continue;
1144
+ for (const ref of value.split(",")) {
1145
+ const name = ref.trim();
1146
+ if (name !== "" && !refs.includes(name)) refs.push(name);
1147
+ }
1148
+ }
1149
+ return refs;
1150
+ }
532
1151
  };
533
1152
  }
534
1153
  //#endregion
1154
+ //#region src/client/draft.ts
1155
+ /**
1156
+ * 搜索插件的卡片逻辑绑定:字段清单/默认值/校验全部来自
1157
+ * `@dsh-plus/web-providers-core`(与后端 schema 同源),本文件只把搜索那份 spec 绑上。
1158
+ * @module web-search-services/client/draft
1159
+ */
1160
+ const SEARCH_KIT = createCardKit([
1161
+ {
1162
+ type: "tavily",
1163
+ defaults: {
1164
+ enabled: true,
1165
+ baseURL: "https://api.tavily.com",
1166
+ apiKeyRefs: ["TAVILY_API_KEY"],
1167
+ searchDepth: "basic",
1168
+ topic: "general",
1169
+ timeRange: "",
1170
+ includeAnswer: "",
1171
+ includeDomains: [],
1172
+ excludeDomains: [],
1173
+ maxResults: 0,
1174
+ timeoutMs: 2e4
1175
+ },
1176
+ fields: [
1177
+ {
1178
+ key: "baseURL",
1179
+ kind: "text"
1180
+ },
1181
+ {
1182
+ key: "apiKeyRefs",
1183
+ kind: "csv",
1184
+ refs: true
1185
+ },
1186
+ {
1187
+ key: "searchDepth",
1188
+ kind: "select",
1189
+ options: [
1190
+ "basic",
1191
+ "advanced",
1192
+ "fast",
1193
+ "ultra-fast"
1194
+ ]
1195
+ },
1196
+ {
1197
+ key: "topic",
1198
+ kind: "select",
1199
+ options: [
1200
+ "general",
1201
+ "news",
1202
+ "finance"
1203
+ ]
1204
+ },
1205
+ {
1206
+ key: "timeRange",
1207
+ kind: "select",
1208
+ options: [
1209
+ "",
1210
+ "day",
1211
+ "week",
1212
+ "month",
1213
+ "year"
1214
+ ],
1215
+ allowEmpty: true
1216
+ },
1217
+ {
1218
+ key: "includeAnswer",
1219
+ kind: "select",
1220
+ options: [
1221
+ "",
1222
+ "basic",
1223
+ "advanced"
1224
+ ],
1225
+ allowEmpty: true
1226
+ },
1227
+ {
1228
+ key: "includeDomains",
1229
+ kind: "csv"
1230
+ },
1231
+ {
1232
+ key: "excludeDomains",
1233
+ kind: "csv"
1234
+ },
1235
+ {
1236
+ key: "maxResults",
1237
+ kind: "number",
1238
+ max: 20
1239
+ },
1240
+ {
1241
+ key: "timeoutMs",
1242
+ kind: "number",
1243
+ min: 1e3
1244
+ }
1245
+ ]
1246
+ },
1247
+ {
1248
+ type: "exa",
1249
+ defaults: {
1250
+ enabled: true,
1251
+ baseURL: "https://api.exa.ai",
1252
+ apiKeyRef: "EXA_API_KEY",
1253
+ searchType: "auto",
1254
+ category: "",
1255
+ includeDomains: [],
1256
+ excludeDomains: [],
1257
+ highlights: true,
1258
+ summary: false,
1259
+ textMaxChars: 0,
1260
+ numResults: 0,
1261
+ timeoutMs: 2e4
1262
+ },
1263
+ fields: [
1264
+ {
1265
+ key: "baseURL",
1266
+ kind: "text"
1267
+ },
1268
+ {
1269
+ key: "apiKeyRef",
1270
+ kind: "text",
1271
+ refs: true
1272
+ },
1273
+ {
1274
+ key: "searchType",
1275
+ kind: "select",
1276
+ options: [
1277
+ "auto",
1278
+ "fast",
1279
+ "instant",
1280
+ "deep-lite",
1281
+ "deep",
1282
+ "deep-reasoning"
1283
+ ]
1284
+ },
1285
+ {
1286
+ key: "category",
1287
+ kind: "text"
1288
+ },
1289
+ {
1290
+ key: "includeDomains",
1291
+ kind: "csv"
1292
+ },
1293
+ {
1294
+ key: "excludeDomains",
1295
+ kind: "csv"
1296
+ },
1297
+ {
1298
+ key: "highlights",
1299
+ kind: "boolean"
1300
+ },
1301
+ {
1302
+ key: "summary",
1303
+ kind: "boolean"
1304
+ },
1305
+ {
1306
+ key: "textMaxChars",
1307
+ kind: "number"
1308
+ },
1309
+ {
1310
+ key: "numResults",
1311
+ kind: "number",
1312
+ max: 25
1313
+ },
1314
+ {
1315
+ key: "timeoutMs",
1316
+ kind: "number",
1317
+ min: 1e3
1318
+ }
1319
+ ]
1320
+ },
1321
+ {
1322
+ type: "openai-chat",
1323
+ defaults: {
1324
+ enabled: true,
1325
+ baseURL: "https://api.openai.com/v1",
1326
+ apiKeyRef: "SEARCH_OPENAI_API_KEY",
1327
+ model: "",
1328
+ answerFormat: "markdown",
1329
+ platform: "",
1330
+ systemPrompt: "",
1331
+ thinkingEffort: "",
1332
+ thinkingField: "reasoning_effort",
1333
+ maxTokens: 0,
1334
+ temperature: -1,
1335
+ timeoutMs: 12e4,
1336
+ maxElapsedMs: 0
1337
+ },
1338
+ fields: [
1339
+ {
1340
+ key: "baseURL",
1341
+ kind: "text"
1342
+ },
1343
+ {
1344
+ key: "apiKeyRef",
1345
+ kind: "text",
1346
+ refs: true
1347
+ },
1348
+ {
1349
+ key: "model",
1350
+ kind: "text",
1351
+ required: true
1352
+ },
1353
+ {
1354
+ key: "answerFormat",
1355
+ kind: "select",
1356
+ options: [
1357
+ "markdown",
1358
+ "text",
1359
+ "json"
1360
+ ]
1361
+ },
1362
+ {
1363
+ key: "platform",
1364
+ kind: "text"
1365
+ },
1366
+ {
1367
+ key: "systemPrompt",
1368
+ kind: "text"
1369
+ },
1370
+ {
1371
+ key: "thinkingEffort",
1372
+ kind: "select",
1373
+ options: [
1374
+ "",
1375
+ "low",
1376
+ "medium",
1377
+ "high"
1378
+ ],
1379
+ allowEmpty: true
1380
+ },
1381
+ {
1382
+ key: "thinkingField",
1383
+ kind: "text"
1384
+ },
1385
+ {
1386
+ key: "maxTokens",
1387
+ kind: "number"
1388
+ },
1389
+ {
1390
+ key: "temperature",
1391
+ kind: "number",
1392
+ min: -1,
1393
+ max: 2
1394
+ },
1395
+ {
1396
+ key: "timeoutMs",
1397
+ kind: "number",
1398
+ min: 1e3
1399
+ },
1400
+ {
1401
+ key: "maxElapsedMs",
1402
+ kind: "number"
1403
+ }
1404
+ ]
1405
+ },
1406
+ {
1407
+ type: "context7",
1408
+ defaults: {
1409
+ enabled: true,
1410
+ baseURL: "https://context7.com/api",
1411
+ apiKeyRef: "CONTEXT7_API_KEY",
1412
+ libraries: [],
1413
+ fast: false,
1414
+ responseType: "json",
1415
+ maxContentChars: 4e3,
1416
+ timeoutMs: 2e4
1417
+ },
1418
+ fields: [
1419
+ {
1420
+ key: "baseURL",
1421
+ kind: "text"
1422
+ },
1423
+ {
1424
+ key: "apiKeyRef",
1425
+ kind: "text",
1426
+ refs: true
1427
+ },
1428
+ {
1429
+ key: "libraries",
1430
+ kind: "libraries"
1431
+ },
1432
+ {
1433
+ key: "fast",
1434
+ kind: "boolean"
1435
+ },
1436
+ {
1437
+ key: "responseType",
1438
+ kind: "select",
1439
+ options: ["json", "text"]
1440
+ },
1441
+ {
1442
+ key: "maxContentChars",
1443
+ kind: "number",
1444
+ min: 200
1445
+ },
1446
+ {
1447
+ key: "timeoutMs",
1448
+ kind: "number",
1449
+ min: 1e3
1450
+ }
1451
+ ]
1452
+ }
1453
+ ]);
1454
+ const kit = SEARCH_KIT;
1455
+ kit.entryTypes;
1456
+ kit.specOf;
1457
+ const draftFromValue = kit.draftFromValue;
1458
+ kit.addEntry;
1459
+ kit.removeEntry;
1460
+ kit.moveEntry;
1461
+ kit.entryIssues;
1462
+ kit.issuesOf;
1463
+ const draftValid = kit.draftValid;
1464
+ const toPatch = kit.toPatch;
1465
+ const refsOf = kit.refsOf;
1466
+ //#endregion
535
1467
  //#region src/client/card.tsx
536
1468
  /**
537
1469
  * 「搜索后端聚合」配置卡片:经 injectPluginConfigCard 注册(插件页
538
1470
  * plugins.row.config / plugins.bundle.config,view 分发 summary/page)。
539
- * 字段:scriptPath / envFile / python / priority(每行一个后端)/ timeoutMs。
540
- * 密钥不走卡片:keys 段的 role(secret) 字段只允许 profile 行级注入,用户层
541
- * 写入会被脱敏遮蔽——卡片改为把密钥来源(env 文件)与覆盖方式写在页面上。
542
- * 外壳与控件走 @dsh-plus/shared/client 套件(CardChrome/TextField/
543
- * useNamespaceDraft);纯校验逻辑在同目录 draft.ts(node --test 可直测)。
1471
+ *
1472
+ * 结构:总开关 / 暴露条目 / 代理 → 后端链(条目块)→ 密钥(官方 credentials)。
1473
+ * 字段与默认值来自 `@dsh-plus/web-providers-core/spec`,校验逻辑来自其 `card` 工具箱,
1474
+ * 本文件只负责布局与文案。
544
1475
  * @module web-search-services/client/card
545
1476
  */
546
1477
  function WebSearchServicesCard(props) {
547
- const { t, scope, api } = props;
1478
+ const { t, scope, api, credentials } = props;
548
1479
  const { draft, dirty, saving, status, disabled, edit, reset, save } = useNamespaceDraft({
549
1480
  scope,
550
1481
  api,
@@ -557,8 +1488,60 @@ ${extra}
557
1488
  prefix: "wsv",
558
1489
  text: t("loading")
559
1490
  });
560
- const priorityInvalid = !priorityTextOk(draft.priorityText);
561
- const timeoutInvalid = !timeoutTextOk(draft.timeoutMsText);
1491
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardBody, {
1492
+ t,
1493
+ draft,
1494
+ status,
1495
+ disabled,
1496
+ dirty,
1497
+ saving,
1498
+ credentials,
1499
+ onEdit: edit,
1500
+ onReset: reset,
1501
+ onSave: save
1502
+ });
1503
+ }
1504
+ /** 校验代号 → 文案键。 */
1505
+ const ISSUE_KEYS = {
1506
+ "id-empty": "issueIdEmpty",
1507
+ required: "issueRequired",
1508
+ "id-duplicate": "issueIdDuplicate",
1509
+ "type-unknown": "issueTypeUnknown",
1510
+ int: "issueInt",
1511
+ "below-min": "issueBelowMin",
1512
+ "above-max": "issueAboveMax",
1513
+ option: "issueOption",
1514
+ json: "issueJson",
1515
+ "json-array": "issueJsonArray",
1516
+ "json-object": "issueJsonObject",
1517
+ "json-strings": "issueJsonStrings",
1518
+ "library-shape": "issueLibraryShape"
1519
+ };
1520
+ /** 条目编辑器的已翻译文案(字段标签用 spec 的技术名,不做翻译)。 */
1521
+ function editorLabels(t) {
1522
+ return {
1523
+ title: t("entriesTitle"),
1524
+ description: t("entriesHint"),
1525
+ addEntry: t("addEntry"),
1526
+ empty: t("entriesEmpty"),
1527
+ unknownType: t("unknownType"),
1528
+ entryId: t("entryId"),
1529
+ entryIdHint: t("entryIdHint"),
1530
+ entryEnabled: t("entryEnabled"),
1531
+ moveUp: t("moveUp"),
1532
+ moveDown: t("moveDown"),
1533
+ remove: t("remove"),
1534
+ unsetOption: t("unsetOption"),
1535
+ csvHint: t("csvHint"),
1536
+ librariesHint: t("librariesHint"),
1537
+ headersHint: t("headersHint"),
1538
+ issue: (code) => t(ISSUE_KEYS[code])
1539
+ };
1540
+ }
1541
+ function CardBody(props) {
1542
+ const { t, draft, status, disabled, dirty, saving, credentials } = props;
1543
+ const refs = (0, react.useMemo)(() => refsOf(draft), [draft]);
1544
+ const valid = draftValid(draft);
562
1545
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(CardChrome, {
563
1546
  prefix: "wsv",
564
1547
  title: t("title"),
@@ -571,95 +1554,73 @@ ${extra}
571
1554
  key: "discard",
572
1555
  label: t("discard"),
573
1556
  disabled: !dirty || saving,
574
- onClick: reset
1557
+ onClick: props.onReset
575
1558
  }, {
576
1559
  key: "save",
577
1560
  label: t(saving ? "saving" : "save"),
578
1561
  variant: "primary",
579
- disabled: !dirty || priorityInvalid || timeoutInvalid || saving || disabled,
580
- onClick: save
1562
+ disabled: !dirty || !valid || saving || disabled,
1563
+ onClick: props.onSave
581
1564
  }],
582
1565
  children: [
583
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
1566
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckRow, {
584
1567
  prefix: "wsv",
585
- id: "wsv-script",
586
- label: t("scriptPath"),
587
- hint: t("scriptPathHint"),
588
- value: draft.scriptPath,
1568
+ id: "wsv-enabled",
1569
+ label: t("enabled"),
1570
+ checked: draft.enabled,
589
1571
  disabled,
590
- onEdit: (v) => edit({ scriptPath: v })
1572
+ onEdit: (checked) => props.onEdit({ enabled: checked })
591
1573
  }),
592
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
1574
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckRow, {
593
1575
  prefix: "wsv",
594
- id: "wsv-envfile",
595
- label: t("envFile"),
596
- hint: t("envFileHint"),
597
- value: draft.envFile,
1576
+ id: "wsv-expose",
1577
+ label: t("exposeEntries"),
1578
+ checked: draft.exposeEntries,
598
1579
  disabled,
599
- onEdit: (v) => edit({ envFile: v })
1580
+ onEdit: (checked) => props.onEdit({ exposeEntries: checked })
1581
+ }),
1582
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1583
+ className: "wsv-hint",
1584
+ children: t("exposeEntriesHint")
600
1585
  }),
601
1586
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
602
1587
  prefix: "wsv",
603
- id: "wsv-python",
604
- label: t("python"),
605
- hint: t("pythonHint"),
606
- value: draft.python,
1588
+ id: "wsv-proxy",
1589
+ label: t("proxy"),
1590
+ hint: t("proxyHint"),
1591
+ value: draft.proxy,
607
1592
  disabled,
608
- onEdit: (v) => edit({ python: v })
1593
+ onEdit: (value) => props.onEdit({ proxy: value })
609
1594
  }),
610
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
611
- className: "wsv-field",
612
- children: [
613
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
614
- className: "wsv-head",
615
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
616
- className: "wsv-label",
617
- htmlFor: "wsv-priority",
618
- children: t("priority")
619
- })
620
- }),
621
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
622
- id: "wsv-priority",
623
- className: "wsv-priority",
624
- rows: 3,
625
- value: draft.priorityText,
626
- disabled,
627
- onChange: (event) => edit({ priorityText: event.target.value })
628
- }),
629
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
630
- className: priorityInvalid ? "wsv-invalid" : "wsv-hint",
631
- children: priorityInvalid ? t("invalidPriority") : t("priorityHint")
632
- })
633
- ]
1595
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1596
+ className: "wsv-hint",
1597
+ children: t("enabledHint")
634
1598
  }),
635
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
1599
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EntryEditor, {
636
1600
  prefix: "wsv",
637
- id: "wsv-timeout",
638
- label: t("timeoutMs"),
639
- hint: t("timeoutMsHint"),
640
- value: draft.timeoutMsText,
641
- numeric: true,
1601
+ kit: SEARCH_KIT,
1602
+ labels: editorLabels(t),
1603
+ draft,
642
1604
  disabled,
643
- invalid: timeoutInvalid,
644
- invalidLabel: t("invalidTimeout"),
645
- onEdit: (v) => edit({ timeoutMsText: v })
1605
+ onChange: (next) => props.onEdit(next)
646
1606
  }),
647
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
648
- className: "wsv-keys",
649
- children: [
650
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
651
- className: "wsv-keysTitle",
652
- children: t("keysGroup")
653
- }),
654
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
655
- className: "wsv-hint",
656
- children: t("keysHint")
657
- }),
658
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
659
- className: "wsv-hint",
660
- children: t("keysUserLayerHint")
661
- })
662
- ]
1607
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CredentialsSection, {
1608
+ prefix: "wsv",
1609
+ title: t("secretsTitle"),
1610
+ description: t("secretsBody"),
1611
+ rows: refs.map((ref) => ({
1612
+ ref,
1613
+ label: ref
1614
+ })),
1615
+ face: credentials,
1616
+ labels: {
1617
+ save: t("save"),
1618
+ clear: t("secretClear"),
1619
+ configured: t("secretConfigured"),
1620
+ missing: t("secretMissing"),
1621
+ shadowed: t("secretShadowed"),
1622
+ failed: t("secretFailed")
1623
+ }
663
1624
  }),
664
1625
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
665
1626
  className: "wsv-hint",
@@ -671,52 +1632,106 @@ ${extra}
671
1632
  //#endregion
672
1633
  //#region src/client/i18n.ts
673
1634
  /**
674
- * 配置卡片文案(zh/en)。经 ctx.locale.register 注册、bind 取用。
1635
+ * 配置卡片文案(zh/en):经 ctx.locale.register 注册、bind 取用。
675
1636
  * 公共键(save/discard/unsaved 等)来自 shared 的 common 字典,本文件只维护业务键。
676
1637
  * @module web-search-services/client/i18n
677
1638
  */
678
1639
  const NS = "dsh-plus-web-search-services";
679
1640
  const ownZh = {
680
1641
  title: "搜索后端聚合",
681
- description: "把 search-services skill 接成 dsh 的 web_search 后端:按优先级尝试 Tavily / Exa / OpenAI-compatible,失败自动回退。",
682
- summaryLine: "web_search 免费后端聚合:优先级链 + 失败自动回退(密钥读 skill 的 env 文件)。",
683
- scriptPath: "search.py 路径",
684
- scriptPathHint: "留空 = 使用随包内置脚本;支持 ~ 展开。",
685
- envFile: "密钥 env 文件",
686
- envFileHint: "默认 ~/.config/search-services/env(skill 的单事实源);留空表示不读该文件。支持 ~ 展开。",
687
- python: "python 解释器",
688
- pythonHint: "可含参数(如 py -3);留空 = 自动发现(桌面捆绑运行时 → PATH → 平台兜底名)。",
689
- priority: "后端优先级",
690
- priorityHint: "每行一个,靠前先试,失败自动回退到下一个。可选值:tavily、exa、openai-chat;重复或未知值会被拒绝保存。",
691
- timeoutMs: "单次搜索超时(毫秒)",
692
- timeoutMsHint: "应低于 dsh-tool-web 的 searchTimeoutMs;下限 1000,默认 55000。",
693
- invalidPriority: "请填写至少一个有效后端,且不要重复",
694
- invalidTimeout: "请输入不小于 1000 的整数",
695
- keysGroup: "密钥与覆盖项(本卡片不编辑)",
696
- keysHint: "密钥主来源是上面的 env 文件,保持 skill 单事实源:TAVILY_API_KEY、EXA_API_KEY、SEARCH_OPENAI_API_KEY,以及可选的 SEARCH_OPENAI_BASE_URL、SEARCH_OPENAI_MODEL。",
697
- keysUserLayerHint: "schema 里的 keys 段是行级覆盖逃生舱(role(secret) 字段只允许 profile 行级注入);写入 settings 用户层会被脱敏遮蔽,因此配置卡片不提供编辑。需要覆盖时请改 env 文件,或在 profile 的 cordis.patch.yml 行级注入。",
698
- reloadHint: "改动热生效(下一次 web_search 调用即用新配置);scriptPath / python 变化无需重启。"
1642
+ description: "把 web_search 接到多个搜索后端:按条目顺序尝试,失败自动回退到下一个;官方工具、渲染与 Sources 格式零修改。",
1643
+ summaryLine: "web_search 多后端聚合:顺序尝试 + 失败自动回退(密钥走官方 credentials)。",
1644
+ enabled: "启用本插件",
1645
+ enabledHint: "关闭后 web_search 不再选用本插件注册的 provider;官方工具与提示词不变。",
1646
+ exposeEntries: "暴露每条目 provider",
1647
+ exposeEntriesHint: "额外注册 dsh-plus-search-<条目名>,便于在 profile 里用 searchProvider 钉住单个后端;改动条目列表后需 /reload 生效。",
1648
+ proxy: "HTTP(S) 代理",
1649
+ proxyHint: "全部后端共用(Tavily/Exa/OpenAI/Context7 的基址都走这里);留空 = 读环境变量 HTTP_PROXY/HTTPS_PROXY/NO_PROXY。",
1650
+ entriesTitle: "后端链",
1651
+ entriesHint: "顺序即优先级:靠前的先试,失败自动回退到下一个。条目内的密钥引用在下方「密钥」小节填写。",
1652
+ addEntry: "新增条目",
1653
+ entryId: "条目名",
1654
+ entryIdHint: "链内标识,同时是 provider id 的后缀(dsh-plus-search-<条目名>),需唯一。",
1655
+ entryEnabled: "参与链",
1656
+ moveUp: "上移",
1657
+ moveDown: "下移",
1658
+ remove: "删除",
1659
+ type: "类型",
1660
+ unsetOption: "(不设置)",
1661
+ csvHint: "多项用逗号分隔,留空表示不限。",
1662
+ librariesHint: "JSON 数组:[{\"name\":\"react\",\"id\":\"/facebook/react\",\"keywords\":[\"react\"]}](id 可留空,运行时按名称解析)。",
1663
+ headersHint: "JSON 对象:{\"Accept-Language\":\"zh-CN\"}。",
1664
+ entriesEmpty: "至少保留一个条目,否则本 provider 不可用。",
1665
+ unknownType: "未知条目类型(来自更早版本的配置),保存前请删除或改类型。",
1666
+ issueIdEmpty: "条目名不能为空。",
1667
+ issueRequired: "必填:留空会让该条目永不可用。",
1668
+ issueIdDuplicate: "条目名重复。",
1669
+ issueTypeUnknown: "未知条目类型。",
1670
+ issueInt: "需为整数。",
1671
+ issueBelowMin: "低于允许的最小值(见后端 schema)。",
1672
+ issueAboveMax: "高于允许的最大值(见后端 schema)。",
1673
+ issueOption: "取值不在候选内。",
1674
+ issueJson: "需为合法 JSON。",
1675
+ issueJsonArray: "需为 JSON 数组。",
1676
+ issueJsonObject: "需为 JSON 对象。",
1677
+ issueJsonStrings: "值需为字符串。",
1678
+ issueLibraryShape: "每项需含 name 字段(可选 id 与 keywords)。",
1679
+ secretsTitle: "密钥",
1680
+ secretsBody: "值经官方 credentials 接口写入 $DSH_HOME/.credentials.yaml,卡片不回显;引用名由上面的条目决定。也可用环境变量提供同名值。",
1681
+ secretConfigured: "已配置",
1682
+ secretMissing: "未配置",
1683
+ secretShadowed: "该引用被进程环境(或只读来源)提供,卡片不能改写。",
1684
+ secretFailed: "凭据写入失败:",
1685
+ secretClear: "清除",
1686
+ reloadHint: "条目增删后需 /reload 才会重新注册 provider;超时、密钥、顺序等字段改动下一次调用即生效。"
699
1687
  };
700
1688
  const ownEn = {
701
1689
  title: "Search backend aggregation",
702
- description: "Bridges the search-services skill into dsh as a web_search backend: tries Tavily / Exa / OpenAI-compatible in priority order with automatic fallback.",
703
- summaryLine: "web_search backend aggregation: priority chain with automatic fallback (keys from the skill env file).",
704
- scriptPath: "search.py path",
705
- scriptPathHint: "Empty uses the bundled copy; ~ is expanded.",
706
- envFile: "Credential env file",
707
- envFileHint: "Defaults to ~/.config/search-services/env (the skill’s single source); empty skips the file. ~ is expanded.",
708
- python: "Python interpreter",
709
- pythonHint: "May include arguments (e.g. py -3); empty auto-detects (desktop runtime → PATH → platform default).",
710
- priority: "Backend priority",
711
- priorityHint: "One per line, earlier first, with automatic fallback on failure. Allowed: tavily, exa, openai-chat; duplicates or unknown values block saving.",
712
- timeoutMs: "Per-search timeout (ms)",
713
- timeoutMsHint: "Keep it below dsh-tool-web’s searchTimeoutMs; minimum 1000, default 55000.",
714
- invalidPriority: "Enter at least one valid backend without duplicates",
715
- invalidTimeout: "Enter an integer of at least 1000",
716
- keysGroup: "Credentials and overrides (not edited here)",
717
- keysHint: "Credentials come from the env file above, keeping the skill as the single source: TAVILY_API_KEY, EXA_API_KEY, SEARCH_OPENAI_API_KEY, plus optional SEARCH_OPENAI_BASE_URL and SEARCH_OPENAI_MODEL.",
718
- keysUserLayerHint: "The keys section in the schema is a line-level escape hatch (role(secret) fields accept profile-line injection only); values written to the settings user layer are masked, so this card offers no editor. To override, edit the env file or inject at the profile row in cordis.patch.yml.",
719
- reloadHint: "Changes apply hot (the next web_search call uses them); scriptPath / python need no restart."
1690
+ description: "Routes web_search to several search backends: entries are tried in order with automatic fallback. Official tool, rendering and Sources formatting are untouched.",
1691
+ summaryLine: "web_search backend chain: ordered attempts with automatic fallback (keys via official credentials).",
1692
+ enabled: "Enable this plugin",
1693
+ enabledHint: "When off, web_search no longer selects the providers registered here; the official tool and prompt are unchanged.",
1694
+ exposeEntries: "Expose per-entry providers",
1695
+ exposeEntriesHint: "Also registers dsh-plus-search-<entry id> so a profile can pin one backend via searchProvider; changing the entry list needs /reload.",
1696
+ proxy: "HTTP(S) proxy",
1697
+ proxyHint: "Shared by every backend (Tavily/Exa/OpenAI/Context7 base URLs); empty reads HTTP_PROXY/HTTPS_PROXY/NO_PROXY.",
1698
+ entriesTitle: "Backend chain",
1699
+ entriesHint: "Order is priority: the first entry is tried first and failures fall through to the next. Credential references are edited in the Secrets section below.",
1700
+ addEntry: "Add entry",
1701
+ entryId: "Entry name",
1702
+ entryIdHint: "Identifies the entry inside the chain and suffixes the provider id (dsh-plus-search-<entry>); must be unique.",
1703
+ entryEnabled: "In chain",
1704
+ moveUp: "Move up",
1705
+ moveDown: "Move down",
1706
+ remove: "Remove",
1707
+ type: "Type",
1708
+ unsetOption: "(unset)",
1709
+ csvHint: "Separate multiple values with commas; empty means no restriction.",
1710
+ librariesHint: "JSON array: [{\"name\":\"react\",\"id\":\"/facebook/react\",\"keywords\":[\"react\"]}] (id optional; resolved by name at runtime).",
1711
+ headersHint: "JSON object: {\"Accept-Language\":\"zh-CN\"}.",
1712
+ entriesEmpty: "Keep at least one entry, otherwise this provider is unavailable.",
1713
+ unknownType: "Unknown entry type (from an older configuration); remove it or change its type before saving.",
1714
+ issueIdEmpty: "Entry name is required.",
1715
+ issueRequired: "Required: leaving it empty makes the entry permanently unusable.",
1716
+ issueIdDuplicate: "Entry name is duplicated.",
1717
+ issueTypeUnknown: "Unknown entry type.",
1718
+ issueInt: "Enter an integer.",
1719
+ issueBelowMin: "Below the minimum allowed by the backend schema.",
1720
+ issueAboveMax: "Above the maximum allowed by the backend schema.",
1721
+ issueOption: "Value is not one of the options.",
1722
+ issueJson: "Enter valid JSON.",
1723
+ issueJsonArray: "Enter a JSON array.",
1724
+ issueJsonObject: "Enter a JSON object.",
1725
+ issueJsonStrings: "Values must be strings.",
1726
+ issueLibraryShape: "Each item needs a name (optional id and keywords).",
1727
+ secretsTitle: "Credentials",
1728
+ secretsBody: "Values are written through the official credentials API into $DSH_HOME/.credentials.yaml and are never echoed back; references come from the entries above. Environment variables with the same name also work.",
1729
+ secretConfigured: "Configured",
1730
+ secretMissing: "Not configured",
1731
+ secretShadowed: "This reference comes from the process environment (or a read-only source); the card cannot change it.",
1732
+ secretFailed: "Credential write failed: ",
1733
+ secretClear: "Clear",
1734
+ reloadHint: "Adding or removing entries needs /reload to re-register providers; timeouts, keys and order take effect on the next call."
720
1735
  };
721
1736
  const zh = mergeDict(commonZh, ownZh);
722
1737
  const en = mergeDict(commonEn, ownEn);
@@ -724,19 +1739,22 @@ ${extra}
724
1739
  //#region src/client/styles.ts
725
1740
  /**
726
1741
  * 配置卡片样式:基础规则走 @dsh-plus/shared/client 的 cardCss('wsv'),
727
- * 本文件只补插件特有规则(优先级多行文本域、密钥说明块)。
1742
+ * 本文件只补插件特有规则(条目块、JSON 文本域、条目操作按钮组)。
728
1743
  * 沿用官方 data-plugin / data-plugin-css 约定(HMR 据此卸载)。
729
1744
  * @module web-search-services/client/styles
730
1745
  */
731
1746
  const PLUGIN_ID = "@dsh-plus/web-search-services";
732
1747
  const cardCssAll = cardCss("wsv", `
733
- .wsv-priority{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:8px 12px;font-size:13px;line-height:1.6;min-height:72px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
734
- .wsv-priority:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}
735
- .wsv-priority:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
736
- .wsv-keys{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;margin:12px 0 0;padding:10px 12px}
737
- .wsv-keysTitle{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;font-weight:600}
1748
+ .wsv-entry{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;margin:12px 0 0;padding:4px 12px 12px}
1749
+ .wsv-entryHead{align-items:center;gap:8px;display:flex;flex-wrap:wrap;padding:12px 0 4px}
1750
+ .wsv-entryIndex{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}
1751
+ .wsv-entryActions{margin-left:auto;gap:6px;display:flex;flex-wrap:wrap}
1752
+ .wsv-json{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:8px 12px;font-size:13px;line-height:1.6;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
1753
+ .wsv-json:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}
1754
+ .wsv-json:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
738
1755
  @media (max-width:767px){
739
- .wsv-priority{font-size:16px}
1756
+ .wsv-json{font-size:16px}
1757
+ .wsv-entryActions{width:100%;margin-left:0}
740
1758
  }
741
1759
  `);
742
1760
  /** 幂等注入样式标签;返回标签(已存在或环境无 document 时为 null)。 */
@@ -746,12 +1764,17 @@ ${extra}
746
1764
  //#endregion
747
1765
  //#region src/client/client.ts
748
1766
  const name = "dsh-plus-web-search-services";
749
- /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
1767
+ /**
1768
+ * 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject
1769
+ * 管包加载顺序)。`remote.credentials` 为硬依赖:卡片是密钥的唯一配置面,
1770
+ * 命名空间缺席时整张卡片都不该挂(官方设置页同样如此声明)。
1771
+ */
750
1772
  const inject = [
751
1773
  "slots",
752
1774
  "locale",
753
1775
  "remote",
754
- "remote.settings"
1776
+ "remote.settings",
1777
+ "remote.credentials"
755
1778
  ];
756
1779
  function apply(ctx) {
757
1780
  const c = ctx;
@@ -765,6 +1788,7 @@ ${extra}
765
1788
  }), "web-search-services: locale");
766
1789
  const scope = createSettingsScope(c, SETTINGS_NS, "web-search-services: settings scope");
767
1790
  const api = createNamespaceApi(c.get("remote").settings, SETTINGS_NS);
1791
+ const credentials = credentialsFace(c.get("remote"));
768
1792
  injectPluginConfigCard(c.slots, {
769
1793
  ns: SETTINGS_NS,
770
1794
  rowId: "dsh-plus-web-search-services",
@@ -773,7 +1797,8 @@ ${extra}
773
1797
  inject: () => ({
774
1798
  t: c.locale.bind(NS),
775
1799
  scope,
776
- api
1800
+ api,
1801
+ credentials
777
1802
  })
778
1803
  });
779
1804
  }