@dsh-plus/web-search-services 0.1.8 → 0.1.9
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 +867 -0
- package/package.json +20 -4
- package/src/client/card.tsx +152 -0
- package/src/client/client.ts +51 -0
- package/src/client/draft.ts +92 -0
- package/src/client/i18n.ts +74 -0
- package/src/client/styles.ts +27 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@dsh-plus/web-search-services",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
|
+
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
10
|
+
//#region ../shared/src/client/card.tsx
|
|
11
|
+
const IDLE_STATUS = {
|
|
12
|
+
kind: "idle",
|
|
13
|
+
text: ""
|
|
14
|
+
};
|
|
15
|
+
/** 状态行 + 动作按钮(两种形态共用,类名随形态切换度量)。 */
|
|
16
|
+
function CardFoot(props) {
|
|
17
|
+
const p = props.prefix;
|
|
18
|
+
const section = props.variant === "section";
|
|
19
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
20
|
+
className: section ? `${p}-sectionFooter` : `${p}-footer`,
|
|
21
|
+
children: [props.status !== void 0 && props.status.kind !== "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
22
|
+
className: `${p}-status${props.status.kind === "error" ? ` ${p}-statusError` : ""}`,
|
|
23
|
+
role: "status",
|
|
24
|
+
children: props.status.text
|
|
25
|
+
}) : null, props.actions.map((action) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
26
|
+
type: "button",
|
|
27
|
+
className: `${p}-btn ${action.variant === "primary" ? `${p}-btnPrimary` : `${p}-btnGhost`}`,
|
|
28
|
+
disabled: action.disabled === true,
|
|
29
|
+
onClick: action.onClick,
|
|
30
|
+
children: action.label
|
|
31
|
+
}, action.key))]
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** 只读提示 + 字段区 + 页脚(两种形态共用)。 */
|
|
35
|
+
function CardBody(props) {
|
|
36
|
+
const p = props.prefix;
|
|
37
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
38
|
+
props.readOnlyNotice !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
39
|
+
className: `${p}-readOnly`,
|
|
40
|
+
role: "status",
|
|
41
|
+
children: props.readOnlyNotice
|
|
42
|
+
}) : null,
|
|
43
|
+
props.children,
|
|
44
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardFoot, { ...props })
|
|
45
|
+
] });
|
|
46
|
+
}
|
|
47
|
+
function CardChrome(props) {
|
|
48
|
+
const p = props.prefix;
|
|
49
|
+
const badge = props.statusBadge;
|
|
50
|
+
if (props.variant === "section") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
51
|
+
className: `${p}-section`,
|
|
52
|
+
"aria-label": props.title,
|
|
53
|
+
children: [
|
|
54
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
55
|
+
className: `${p}-sectionHead`,
|
|
56
|
+
children: [
|
|
57
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
58
|
+
className: `${p}-sectionTitle`,
|
|
59
|
+
children: props.title
|
|
60
|
+
}),
|
|
61
|
+
badge !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
62
|
+
className: `${p}-sectionBadge ${badge.on ? `${p}-statusOn` : `${p}-statusOff`}`,
|
|
63
|
+
children: badge.text
|
|
64
|
+
}) : null,
|
|
65
|
+
props.dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
66
|
+
className: `${p}-pending`,
|
|
67
|
+
children: props.dirtyLabel
|
|
68
|
+
}) : null
|
|
69
|
+
]
|
|
70
|
+
}),
|
|
71
|
+
props.description === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
72
|
+
className: `${p}-sectionDesc`,
|
|
73
|
+
children: props.description
|
|
74
|
+
}),
|
|
75
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
76
|
+
className: `${p}-sectionBody`,
|
|
77
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardBody, { ...props })
|
|
78
|
+
})
|
|
79
|
+
]
|
|
80
|
+
});
|
|
81
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
82
|
+
className: `${p}-card${props.open ? ` ${p}-cardOpen` : ""}`,
|
|
83
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
84
|
+
type: "button",
|
|
85
|
+
className: `${p}-header`,
|
|
86
|
+
"aria-expanded": props.open,
|
|
87
|
+
"aria-label": props.title,
|
|
88
|
+
onClick: () => props.onToggle(!props.open),
|
|
89
|
+
children: [
|
|
90
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
91
|
+
className: `${p}-headText`,
|
|
92
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
93
|
+
className: `${p}-name`,
|
|
94
|
+
children: props.title
|
|
95
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
96
|
+
className: `${p}-description`,
|
|
97
|
+
children: props.description
|
|
98
|
+
})]
|
|
99
|
+
}),
|
|
100
|
+
badge !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
101
|
+
className: `${p}-statusBadge ${badge.on ? `${p}-statusOn` : `${p}-statusOff`}`,
|
|
102
|
+
children: badge.text
|
|
103
|
+
}) : null,
|
|
104
|
+
props.dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
105
|
+
className: `${p}-pending`,
|
|
106
|
+
children: props.dirtyLabel
|
|
107
|
+
}) : null,
|
|
108
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { className: `${p}-chevron${props.open ? ` ${p}-chevronOpen` : ""}` })
|
|
109
|
+
]
|
|
110
|
+
}), props.open ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
111
|
+
className: `${p}-body`,
|
|
112
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardBody, { ...props })
|
|
113
|
+
}) : null]
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** 取值未到达时的占位:与 CardChrome 同形态,避免宿主页里闪一次卡片外壳。 */
|
|
117
|
+
function CardLoading(props) {
|
|
118
|
+
if (props.variant === "section") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
|
|
119
|
+
className: `${props.prefix}-section`,
|
|
120
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
121
|
+
className: `${props.prefix}-readOnly`,
|
|
122
|
+
role: "status",
|
|
123
|
+
children: props.text
|
|
124
|
+
})
|
|
125
|
+
});
|
|
126
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
|
|
127
|
+
className: `${props.prefix}-card`,
|
|
128
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
129
|
+
className: `${props.prefix}-readOnly`,
|
|
130
|
+
role: "status",
|
|
131
|
+
children: props.text
|
|
132
|
+
})
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region ../shared/src/client/config-slots.ts
|
|
137
|
+
/** dsh-plus 聚合 bundle 的包名(各插件 row 均经其补丁层插入 profile)。 */
|
|
138
|
+
const DSH_PLUS_BUNDLE = "@dsh-plus/bundle-main";
|
|
139
|
+
/**
|
|
140
|
+
* 视图 → 外壳形态:插件页 page 视图渲染在官方页面容器内(宿主已画标题、图标、
|
|
141
|
+
* 面包屑与页面级内边距),再套卡片会形成卡片套卡片并压缩移动端可用宽度,
|
|
142
|
+
* 故用无边框分节;summary 与 legacy 槽位保持卡片形态。
|
|
143
|
+
*/
|
|
144
|
+
function cardVariantFor(view) {
|
|
145
|
+
return view === "page" ? "section" : "card";
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* 把一张配置卡片同时注册进 legacy 与新版三个槽位。
|
|
149
|
+
* 任一槽位在当前 dsh 版本未声明时对应 inject 挂起等待,版本不命中即 no-op。
|
|
150
|
+
*/
|
|
151
|
+
function injectPluginConfigCard(slots, reg) {
|
|
152
|
+
const base = {
|
|
153
|
+
locale: reg.ns,
|
|
154
|
+
inject: reg.inject
|
|
155
|
+
};
|
|
156
|
+
slots.inject("settings.plugin.item", () => slots.register({
|
|
157
|
+
name: "settings.plugin.item",
|
|
158
|
+
key: reg.ns,
|
|
159
|
+
...base
|
|
160
|
+
}, reg.component));
|
|
161
|
+
slots.inject("plugins.row.config", () => slots.register({
|
|
162
|
+
name: "plugins.row.config",
|
|
163
|
+
key: `${DSH_PLUS_BUNDLE}#${reg.rowId}`,
|
|
164
|
+
...base
|
|
165
|
+
}, reg.component));
|
|
166
|
+
slots.inject("plugins.bundle.config", () => slots.register({
|
|
167
|
+
name: "plugins.bundle.config",
|
|
168
|
+
key: reg.pkg,
|
|
169
|
+
...base
|
|
170
|
+
}, reg.component));
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region ../shared/src/client/draft.ts
|
|
174
|
+
/**
|
|
175
|
+
* 单命名空间 staged 草稿(简单配置卡片共用):读 scope 快照、播种草稿、
|
|
176
|
+
* 脏判定、保存(revision fencing + 重新装载)与状态行。
|
|
177
|
+
*
|
|
178
|
+
* 语义与官方 staged 表单一致:Host 更新不覆盖在途编辑,只有保存才写回;
|
|
179
|
+
* 保存成功后按 Host 解析值重置草稿。`from`/`patch` 参与 effect 与 useMemo
|
|
180
|
+
* 依赖,必须是模块级稳定函数(不要内联箭头函数)。
|
|
181
|
+
* @module @dsh-plus/shared/client/draft
|
|
182
|
+
*/
|
|
183
|
+
function useNamespaceDraft(options) {
|
|
184
|
+
const { scope, api, from, patch, saveFailedLabel } = options;
|
|
185
|
+
const snapshot = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
|
|
186
|
+
const value = snapshot.value;
|
|
187
|
+
const [draft, setDraftState] = (0, react.useState)(null);
|
|
188
|
+
const [saving, setSaving] = (0, react.useState)(false);
|
|
189
|
+
const [status, setStatus] = (0, react.useState)(IDLE_STATUS);
|
|
190
|
+
(0, react.useEffect)(() => {
|
|
191
|
+
if (value === void 0 || draft !== null) return;
|
|
192
|
+
setDraftState(from(value));
|
|
193
|
+
}, [
|
|
194
|
+
value,
|
|
195
|
+
draft,
|
|
196
|
+
from
|
|
197
|
+
]);
|
|
198
|
+
const dirty = (0, react.useMemo)(() => value !== void 0 && draft !== null && JSON.stringify(patch(draft)) !== JSON.stringify(patch(from(value))), [
|
|
199
|
+
value,
|
|
200
|
+
draft,
|
|
201
|
+
from,
|
|
202
|
+
patch
|
|
203
|
+
]);
|
|
204
|
+
const setDraft = (next) => {
|
|
205
|
+
setDraftState(next);
|
|
206
|
+
setStatus(IDLE_STATUS);
|
|
207
|
+
};
|
|
208
|
+
const edit = (part) => {
|
|
209
|
+
if (draft === null) return;
|
|
210
|
+
setDraft({
|
|
211
|
+
...draft,
|
|
212
|
+
...part
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
const reset = () => {
|
|
216
|
+
if (value === void 0) return;
|
|
217
|
+
setDraft(from(value));
|
|
218
|
+
};
|
|
219
|
+
const save = () => {
|
|
220
|
+
if (draft === null) return;
|
|
221
|
+
setSaving(true);
|
|
222
|
+
const revision = scope.getSnapshot().revision;
|
|
223
|
+
api.update(patch(draft), revision).then(async () => {
|
|
224
|
+
await scope.load();
|
|
225
|
+
const next = scope.getSnapshot().value;
|
|
226
|
+
if (next !== void 0) setDraftState(from(next));
|
|
227
|
+
setStatus(IDLE_STATUS);
|
|
228
|
+
}).catch((error) => {
|
|
229
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
230
|
+
setStatus({
|
|
231
|
+
kind: "error",
|
|
232
|
+
text: `${saveFailedLabel}${message}`
|
|
233
|
+
});
|
|
234
|
+
}).finally(() => setSaving(false));
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
value,
|
|
238
|
+
draft,
|
|
239
|
+
dirty,
|
|
240
|
+
saving,
|
|
241
|
+
status,
|
|
242
|
+
disabled: !snapshot.writable,
|
|
243
|
+
setDraft,
|
|
244
|
+
edit,
|
|
245
|
+
reset,
|
|
246
|
+
save
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region ../shared/src/client/fields.tsx
|
|
251
|
+
function TextField(props) {
|
|
252
|
+
const p = props.prefix ?? "dshp";
|
|
253
|
+
const invalid = props.invalid === true;
|
|
254
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
255
|
+
className: `${p}-field`,
|
|
256
|
+
children: [
|
|
257
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
258
|
+
className: `${p}-head`,
|
|
259
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
|
|
260
|
+
className: `${p}-label`,
|
|
261
|
+
htmlFor: props.id,
|
|
262
|
+
children: props.label
|
|
263
|
+
}), props.badge !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
264
|
+
className: `${p}-badge ${props.badge.set ? `${p}-badgeSet` : `${p}-badgeUnset`}`,
|
|
265
|
+
children: props.badge.text
|
|
266
|
+
}) : null]
|
|
267
|
+
}),
|
|
268
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
269
|
+
id: props.id,
|
|
270
|
+
className: `${p}-input${invalid ? ` ${p}-inputInvalid` : ""}`,
|
|
271
|
+
type: props.password === true ? "password" : "text",
|
|
272
|
+
inputMode: props.numeric === true ? "numeric" : void 0,
|
|
273
|
+
autoComplete: props.password === true ? "off" : void 0,
|
|
274
|
+
"aria-invalid": invalid || void 0,
|
|
275
|
+
value: props.value,
|
|
276
|
+
disabled: props.disabled === true,
|
|
277
|
+
onChange: (event) => props.onEdit(event.target.value)
|
|
278
|
+
}),
|
|
279
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
280
|
+
className: invalid ? `${p}-invalid` : `${p}-hint`,
|
|
281
|
+
children: invalid ? props.invalidLabel ?? "" : props.hint ?? ""
|
|
282
|
+
})
|
|
283
|
+
]
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region ../shared/src/client/i18n.ts
|
|
288
|
+
/** 卡片通用文案(save/discard/unsaved/loading 等四插件共有键)。 */
|
|
289
|
+
const commonZh = {
|
|
290
|
+
save: "保存",
|
|
291
|
+
saving: "保存中…",
|
|
292
|
+
discard: "放弃",
|
|
293
|
+
unsaved: "未保存",
|
|
294
|
+
loading: "加载中…",
|
|
295
|
+
unavailable: "配置服务不可用。",
|
|
296
|
+
readOnly: "当前部署无 settings provider,配置为只读;请编辑 settings.yaml。",
|
|
297
|
+
invalidNumber: "请输入正整数。",
|
|
298
|
+
collapse: "收起",
|
|
299
|
+
expand: "展开",
|
|
300
|
+
enabledOn: "已启用",
|
|
301
|
+
enabledOff: "未启用",
|
|
302
|
+
saveFailed: "保存失败,请检查填写内容。",
|
|
303
|
+
confirm: "确认",
|
|
304
|
+
cancel: "取消"
|
|
305
|
+
};
|
|
306
|
+
const commonEn = {
|
|
307
|
+
save: "Save",
|
|
308
|
+
saving: "Saving…",
|
|
309
|
+
discard: "Discard",
|
|
310
|
+
unsaved: "Unsaved",
|
|
311
|
+
loading: "Loading…",
|
|
312
|
+
unavailable: "Settings service unavailable.",
|
|
313
|
+
readOnly: "No settings provider in this deployment; config is read-only. Edit settings.yaml.",
|
|
314
|
+
invalidNumber: "Enter a positive integer.",
|
|
315
|
+
collapse: "Collapse",
|
|
316
|
+
expand: "Expand",
|
|
317
|
+
enabledOn: "On",
|
|
318
|
+
enabledOff: "Off",
|
|
319
|
+
saveFailed: "Save failed; check the fields.",
|
|
320
|
+
confirm: "Confirm",
|
|
321
|
+
cancel: "Cancel"
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* own 覆盖同键合并(返回新对象,入参不可变)。
|
|
325
|
+
* 返回类型保留两侧键集合,调用方据此派生 DictKey。
|
|
326
|
+
*/
|
|
327
|
+
function mergeDict(base, own) {
|
|
328
|
+
return {
|
|
329
|
+
...base,
|
|
330
|
+
...own
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
//#endregion
|
|
334
|
+
//#region ../shared/src/client/scope.ts
|
|
335
|
+
/**
|
|
336
|
+
* remote.settings 直连实现的命名空间 scope(官方配置页 tab 同款模式)。
|
|
337
|
+
* 读:describe 取命名空间视图(脱敏 value/revision + 顶层 writable);
|
|
338
|
+
* 刷新:remote `settings/document-updated`(按命名空间过滤)与
|
|
339
|
+
* `connection/reset`;generation 防旧读覆盖新发布。任何页面 origin
|
|
340
|
+
* (loopback 或 tailnet 信任域名)下行为一致。
|
|
341
|
+
* effect 归属调用方 fiber(label 需含插件名以便诊断)。
|
|
342
|
+
*/
|
|
343
|
+
function createSettingsScope(c, ns, label) {
|
|
344
|
+
const remote = c.get("remote");
|
|
345
|
+
let state = {
|
|
346
|
+
status: "loading",
|
|
347
|
+
value: void 0,
|
|
348
|
+
revision: void 0,
|
|
349
|
+
writable: false
|
|
350
|
+
};
|
|
351
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
352
|
+
let generation = 0;
|
|
353
|
+
const publish = (next) => {
|
|
354
|
+
state = next;
|
|
355
|
+
for (const listener of [...listeners]) listener();
|
|
356
|
+
};
|
|
357
|
+
const load = async () => {
|
|
358
|
+
const gen = ++generation;
|
|
359
|
+
let view;
|
|
360
|
+
try {
|
|
361
|
+
const response = await remote.settings.describe();
|
|
362
|
+
if (!response.ok) return;
|
|
363
|
+
view = response.value;
|
|
364
|
+
} catch {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (gen !== generation) return;
|
|
368
|
+
const namespace = view.namespaces.find((candidate) => candidate.ns === ns);
|
|
369
|
+
if (namespace === void 0) {
|
|
370
|
+
publish({
|
|
371
|
+
status: "unavailable",
|
|
372
|
+
value: void 0,
|
|
373
|
+
revision: void 0,
|
|
374
|
+
writable: view.writable
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
publish({
|
|
379
|
+
status: "ready",
|
|
380
|
+
value: namespace.value,
|
|
381
|
+
revision: namespace.revision,
|
|
382
|
+
writable: view.writable
|
|
383
|
+
});
|
|
384
|
+
};
|
|
385
|
+
const refresh = (namespace) => {
|
|
386
|
+
if (namespace !== void 0 && namespace !== ns) return;
|
|
387
|
+
load();
|
|
388
|
+
};
|
|
389
|
+
const disposers = [remote.$on("settings/document-updated", refresh), c.on("connection/reset", () => {
|
|
390
|
+
load();
|
|
391
|
+
})];
|
|
392
|
+
c.effect(() => () => {
|
|
393
|
+
for (const dispose of disposers) dispose();
|
|
394
|
+
}, label);
|
|
395
|
+
load();
|
|
396
|
+
return {
|
|
397
|
+
getSnapshot: () => state,
|
|
398
|
+
subscribe: (listener) => {
|
|
399
|
+
listeners.add(listener);
|
|
400
|
+
return () => {
|
|
401
|
+
listeners.delete(listener);
|
|
402
|
+
};
|
|
403
|
+
},
|
|
404
|
+
load
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
/** 以 remote.settings 构造命名空间绑定的 api 面。 */
|
|
408
|
+
function createNamespaceApi(remote, ns) {
|
|
409
|
+
return {
|
|
410
|
+
update: async (patch, expectedRevision) => {
|
|
411
|
+
const response = await remote.update(ns, patch, expectedRevision);
|
|
412
|
+
if (!response.ok) throw new Error(response.error.message ?? "settings update failed");
|
|
413
|
+
},
|
|
414
|
+
describe: async () => {
|
|
415
|
+
const response = await remote.describe();
|
|
416
|
+
if (!response.ok) throw new Error(response.error.message ?? "settings describe failed");
|
|
417
|
+
return response.value;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
//#endregion
|
|
422
|
+
//#region ../shared/src/client/styles.ts
|
|
423
|
+
/**
|
|
424
|
+
* 配置卡片样式工厂(各插件 client 的公共收编版)。
|
|
425
|
+
* 视觉规则与迁移前逐字一致(类名前缀换为各插件自身,DOM 类名不变),
|
|
426
|
+
* 另补移动端与可达性增强(767px 断点 + pointer:coarse,对齐 ui-mobile-fit 约定):
|
|
427
|
+
* - 窄屏 input/textarea 16px(防 iOS 聚焦缩放)、可点目标 ≥44px;
|
|
428
|
+
* - footer 按钮等宽换行、状态文字独占一行;
|
|
429
|
+
* - 展开态 body 底部 sticky 保存栏(长表单滚动中始终可达)。
|
|
430
|
+
* - 分节形态(-section,插件页 page 视图内嵌宿主页面):无边框/背景、横向零
|
|
431
|
+
* 内边距,页脚不 sticky——官方插件页已提供页面级内边距,再叠一层卡片会
|
|
432
|
+
* 缩窄移动端可用宽度(卡片套卡片)。
|
|
433
|
+
* 沿用官方 data-plugin / data-plugin-css 约定(HMR 据此卸载),不覆盖上游选择器。
|
|
434
|
+
* @module @dsh-plus/shared/client/styles
|
|
435
|
+
*/
|
|
436
|
+
/** 生成全套卡片 CSS;prefix 为插件类名前缀(如 'dne'),extra 为插件特有规则。 */
|
|
437
|
+
function cardCss(prefix, extra = "") {
|
|
438
|
+
return `
|
|
439
|
+
.${prefix}-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}
|
|
440
|
+
.${prefix}-card:hover{border-color:var(--dsw-alias-label-dimmed)}
|
|
441
|
+
.${prefix}-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
|
|
442
|
+
.${prefix}-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
|
|
443
|
+
.${prefix}-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
444
|
+
.${prefix}-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
|
|
445
|
+
.${prefix}-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
|
|
446
|
+
.${prefix}-description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
|
|
447
|
+
.${prefix}-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s;display:inline-flex}
|
|
448
|
+
.${prefix}-chevronOpen{transform:rotate(180deg)}
|
|
449
|
+
.${prefix}-pending{white-space:nowrap;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}
|
|
450
|
+
.${prefix}-statusBadge{white-space:nowrap;border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}
|
|
451
|
+
.${prefix}-statusOn{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
|
|
452
|
+
.${prefix}-statusOff{color:var(--dsw-alias-label-tertiary)}
|
|
453
|
+
.${prefix}-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
|
|
454
|
+
.${prefix}-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}
|
|
455
|
+
.${prefix}-field+.${prefix}-field{border-top:1px solid var(--dsw-alias-border-l2)}
|
|
456
|
+
.${prefix}-head{align-items:center;gap:8px;display:flex}
|
|
457
|
+
.${prefix}-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}
|
|
458
|
+
.${prefix}-badge{white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}
|
|
459
|
+
.${prefix}-badgeSet{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
|
|
460
|
+
.${prefix}-badgeUnset{color:var(--dsw-alias-label-tertiary)}
|
|
461
|
+
.${prefix}-input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;transition:border-color .18s,background .18s}
|
|
462
|
+
.${prefix}-input:hover:not(:disabled):not(:focus-visible){border-color:var(--dsw-alias-label-dimmed)}
|
|
463
|
+
.${prefix}-input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}
|
|
464
|
+
.${prefix}-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
|
|
465
|
+
.${prefix}-inputInvalid{border-color:var(--dsw-alias-label-error)}
|
|
466
|
+
.${prefix}-select{appearance:none;background:var(--dsw-alias-bg-module-platform);border:none;cursor:pointer;background-image:linear-gradient(45deg,transparent 50%,var(--dsw-alias-label-secondary) 50%),linear-gradient(135deg,var(--dsw-alias-label-secondary) 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px;background-repeat:no-repeat;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;height:34px;box-sizing:border-box;border-radius:8px;padding:0 30px 0 12px;transition:background-color .18s}
|
|
467
|
+
.${prefix}-select:hover:not(:disabled){background-color:var(--dsw-alias-interactive-bg-hover)}
|
|
468
|
+
.${prefix}-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}
|
|
469
|
+
.${prefix}-invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}
|
|
470
|
+
.${prefix}-checkRow{align-items:center;gap:10px;display:flex;padding:6px 0}
|
|
471
|
+
.${prefix}-checkRow label{color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;cursor:pointer;flex:1;min-width:0}
|
|
472
|
+
/* 自绘圆角扁平开关(官方 UI 无可见原生 checkbox,选择类全走胶囊/菜单) */
|
|
473
|
+
.${prefix}-checkRow input{appearance:none;-webkit-appearance:none;position:relative;box-sizing:border-box;background:var(--dsw-alias-bg-module-platform);border:none;border-radius:999px;width:34px;height:20px;flex:none;cursor:pointer;transition:background .18s;margin:0}
|
|
474
|
+
.${prefix}-checkRow input::after{content:"";position:absolute;top:2px;left:2px;background:var(--dsw-alias-label-primary);border-radius:999px;width:16px;height:16px;transition:left .18s cubic-bezier(.4,0,.2,1)}
|
|
475
|
+
.${prefix}-checkRow input:checked{background:var(--dsw-alias-brand-primary)}
|
|
476
|
+
.${prefix}-checkRow input:checked::after{left:16px;background:var(--dsw-alias-bg-base)}
|
|
477
|
+
.${prefix}-checkRow input:disabled{opacity:.4;cursor:default}
|
|
478
|
+
.${prefix}-checkRow input:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
479
|
+
/* 宿主内嵌分节形态(插件页 page 视图):官方设置页同构——无边框/背景,
|
|
480
|
+
横向零内边距(宽度交给宿主页面容器),小节标题 13px/600,页脚非 sticky。 */
|
|
481
|
+
.${prefix}-section{box-sizing:border-box;flex-direction:column;width:100%;min-width:0;padding:16px 0;display:flex}
|
|
482
|
+
.${prefix}-sectionHead{align-items:center;gap:8px;min-width:0;display:flex}
|
|
483
|
+
.${prefix}-sectionTitle{min-width:0;color:var(--dsw-alias-label-primary);flex:1;margin:0;font-size:13px;font-weight:600;line-height:1.5}
|
|
484
|
+
.${prefix}-sectionBadge{white-space:nowrap;border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}
|
|
485
|
+
.${prefix}-sectionDesc{color:var(--dsw-alias-label-tertiary);margin:4px 0 0;font-size:12px;line-height:1.5}
|
|
486
|
+
.${prefix}-sectionBody{flex-direction:column;min-width:0;display:flex}
|
|
487
|
+
.${prefix}-sectionFooter{align-items:center;gap:8px;padding-top:16px;display:flex;flex-wrap:wrap}
|
|
488
|
+
.${prefix}-groupLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;padding:12px 0 2px;margin:0}
|
|
489
|
+
.${prefix}-readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}
|
|
490
|
+
.${prefix}-warn{color:var(--dsw-alias-label-error);margin:12px 0 0;font-size:12px;line-height:1.5}
|
|
491
|
+
.${prefix}-footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex;flex-wrap:wrap}
|
|
492
|
+
.${prefix}-status{min-width:0;color:var(--dsw-alias-label-secondary);flex:1;margin:0;font-size:12px;line-height:1.5}
|
|
493
|
+
.${prefix}-statusError{color:var(--dsw-alias-label-error)}
|
|
494
|
+
.${prefix}-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}
|
|
495
|
+
.${prefix}-btnGhost{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}
|
|
496
|
+
.${prefix}-btnGhost:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}
|
|
497
|
+
.${prefix}-btnPrimary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
|
|
498
|
+
.${prefix}-btn:disabled{opacity:.4;cursor:default}
|
|
499
|
+
.${prefix}-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
|
|
500
|
+
@media (max-width:767px){
|
|
501
|
+
.${prefix}-body{margin:0 10px}
|
|
502
|
+
.${prefix}-header{padding:12px 12px}
|
|
503
|
+
.${prefix}-input,.${prefix}-select{font-size:16px;height:40px}
|
|
504
|
+
.${prefix}-checkRow{min-height:44px;padding:8px 0}
|
|
505
|
+
.${prefix}-footer{position:sticky;bottom:0;background:var(--dsw-alias-bg-layer-2);margin:0 -10px;padding:10px 10px 6px;border-top:1px solid var(--dsw-alias-border-l2);border-bottom-left-radius:12px;border-bottom-right-radius:12px}
|
|
506
|
+
.${prefix}-status{flex-basis:100%;flex:none}
|
|
507
|
+
.${prefix}-btn{min-height:44px;flex:1;min-width:96px}
|
|
508
|
+
.${prefix}-statusBadge{display:none}
|
|
509
|
+
.${prefix}-sectionBadge{display:inline-block}
|
|
510
|
+
.${prefix}-sectionFooter{position:static}
|
|
511
|
+
}
|
|
512
|
+
@media (pointer:coarse){
|
|
513
|
+
.${prefix}-btn{min-height:44px}
|
|
514
|
+
.${prefix}-checkRow{min-height:44px}
|
|
515
|
+
.${prefix}-input,.${prefix}-select{height:40px}
|
|
516
|
+
}
|
|
517
|
+
${extra}
|
|
518
|
+
`;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* 幂等注入样式标签(官方 data-plugin-css 约定);返回新标签,
|
|
522
|
+
* 已存在或环境无 document 时返回 null(调用方 effect 卸载用)。
|
|
523
|
+
*/
|
|
524
|
+
function injectCardStyle(pluginId, css) {
|
|
525
|
+
if (typeof document === "undefined") return null;
|
|
526
|
+
const tagId = `${pluginId}/card.css`;
|
|
527
|
+
if (document.querySelector(`style[data-plugin-css=${JSON.stringify(tagId)}]`) !== null) return null;
|
|
528
|
+
const tag = document.createElement("style");
|
|
529
|
+
tag.dataset.plugin = pluginId;
|
|
530
|
+
tag.dataset.pluginCss = tagId;
|
|
531
|
+
tag.textContent = css;
|
|
532
|
+
document.head.appendChild(tag);
|
|
533
|
+
return tag;
|
|
534
|
+
}
|
|
535
|
+
//#endregion
|
|
536
|
+
//#region src/ns.ts
|
|
537
|
+
/**
|
|
538
|
+
* settings 命名空间字面量(纯常量,零依赖)。
|
|
539
|
+
* 服务端以字面量注册 settings section(用户层配置落 $DSH_HOME/settings.yaml,
|
|
540
|
+
* 热生效);与行级 cordis Config 共用同一份 schemastery schema(config.ts)。
|
|
541
|
+
* @module web-search-services/ns
|
|
542
|
+
*/
|
|
543
|
+
const SETTINGS_NS = "dsh-plus-web-search-services";
|
|
544
|
+
//#endregion
|
|
545
|
+
//#region src/client/draft.ts
|
|
546
|
+
/** 可选后端(与 config.ts 的 PRIORITY_ENTRY union 同源顺序)。 */
|
|
547
|
+
const SEARCH_BACKENDS = [
|
|
548
|
+
"tavily",
|
|
549
|
+
"exa",
|
|
550
|
+
"openai-chat"
|
|
551
|
+
];
|
|
552
|
+
/** 单次搜索子进程超时下限(与 config.ts 的 .min(1000) 一致)。 */
|
|
553
|
+
const TIMEOUT_MIN_MS = 1e3;
|
|
554
|
+
function draftFromValue(value) {
|
|
555
|
+
return {
|
|
556
|
+
scriptPath: value.scriptPath,
|
|
557
|
+
envFile: value.envFile,
|
|
558
|
+
python: value.python,
|
|
559
|
+
priorityText: value.priority.join("\n"),
|
|
560
|
+
timeoutMsText: String(value.timeoutMs)
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
/** 优先级文本 → 后端序列;顺带返回无法识别的行(供 UI 提示)。 */
|
|
564
|
+
function parsePriority(text) {
|
|
565
|
+
const backends = [];
|
|
566
|
+
const unknown = [];
|
|
567
|
+
const duplicated = [];
|
|
568
|
+
for (const raw of text.split("\n")) {
|
|
569
|
+
const line = raw.trim();
|
|
570
|
+
if (line === "") continue;
|
|
571
|
+
const match = SEARCH_BACKENDS.find((backend) => backend === line);
|
|
572
|
+
if (match === void 0) {
|
|
573
|
+
unknown.push(line);
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (backends.includes(match)) {
|
|
577
|
+
duplicated.push(line);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
backends.push(match);
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
backends,
|
|
584
|
+
unknown,
|
|
585
|
+
duplicated
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/** 优先级是否可用:至少一个后端、无未知项、无重复项。 */
|
|
589
|
+
function priorityTextOk(text) {
|
|
590
|
+
const { backends, unknown, duplicated } = parsePriority(text);
|
|
591
|
+
return backends.length > 0 && unknown.length === 0 && duplicated.length === 0;
|
|
592
|
+
}
|
|
593
|
+
/** 超时是否可用:≥1000 的整数(对齐 schema 的 .natural().min(1000))。 */
|
|
594
|
+
function timeoutTextOk(text) {
|
|
595
|
+
if (!/^\d+$/.test(text.trim())) return false;
|
|
596
|
+
return Number(text) >= TIMEOUT_MIN_MS;
|
|
597
|
+
}
|
|
598
|
+
/** 草稿 → 提交形状(调用方保证已通过校验)。 */
|
|
599
|
+
function toPatch(draft) {
|
|
600
|
+
return {
|
|
601
|
+
scriptPath: draft.scriptPath,
|
|
602
|
+
envFile: draft.envFile,
|
|
603
|
+
python: draft.python,
|
|
604
|
+
priority: parsePriority(draft.priorityText).backends,
|
|
605
|
+
timeoutMs: Number(draft.timeoutMsText)
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/client/card.tsx
|
|
610
|
+
/**
|
|
611
|
+
* 「搜索后端聚合」配置卡片:经 injectPluginConfigCard 注册(legacy
|
|
612
|
+
* settings.plugin.item 与插件页 plugins.row.config / plugins.bundle.config,
|
|
613
|
+
* view 分发 summary/page)。
|
|
614
|
+
* 字段:scriptPath / envFile / python / priority(每行一个后端)/ timeoutMs。
|
|
615
|
+
* 密钥不走卡片:keys 段的 role(secret) 字段只允许 profile 行级注入,用户层
|
|
616
|
+
* 写入会被脱敏遮蔽——卡片改为把密钥来源(env 文件)与覆盖方式写在页面上。
|
|
617
|
+
* 外壳与控件走 @dsh-plus/shared/client 套件(CardChrome/TextField/
|
|
618
|
+
* useNamespaceDraft);纯校验逻辑在同目录 draft.ts(node --test 可直测)。
|
|
619
|
+
* @module web-search-services/client/card
|
|
620
|
+
*/
|
|
621
|
+
function WebSearchServicesCard(props) {
|
|
622
|
+
const { t, scope, api } = props;
|
|
623
|
+
const { draft, dirty, saving, status, disabled, edit, reset, save } = useNamespaceDraft({
|
|
624
|
+
scope,
|
|
625
|
+
api,
|
|
626
|
+
from: draftFromValue,
|
|
627
|
+
patch: toPatch,
|
|
628
|
+
saveFailedLabel: t("saveFailed")
|
|
629
|
+
});
|
|
630
|
+
const [open, setOpen] = (0, react.useState)(props.view === "page");
|
|
631
|
+
if (props.view === "summary") return t("summaryLine");
|
|
632
|
+
const variant = cardVariantFor(props.view);
|
|
633
|
+
if (draft === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardLoading, {
|
|
634
|
+
prefix: "wsv",
|
|
635
|
+
variant,
|
|
636
|
+
text: t("loading")
|
|
637
|
+
});
|
|
638
|
+
const priorityInvalid = !priorityTextOk(draft.priorityText);
|
|
639
|
+
const timeoutInvalid = !timeoutTextOk(draft.timeoutMsText);
|
|
640
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(CardChrome, {
|
|
641
|
+
prefix: "wsv",
|
|
642
|
+
title: t("title"),
|
|
643
|
+
description: t("description"),
|
|
644
|
+
open,
|
|
645
|
+
onToggle: setOpen,
|
|
646
|
+
variant,
|
|
647
|
+
dirty,
|
|
648
|
+
dirtyLabel: t("unsaved"),
|
|
649
|
+
readOnlyNotice: disabled ? t("readOnly") : void 0,
|
|
650
|
+
status,
|
|
651
|
+
actions: [{
|
|
652
|
+
key: "discard",
|
|
653
|
+
label: t("discard"),
|
|
654
|
+
disabled: !dirty || saving,
|
|
655
|
+
onClick: reset
|
|
656
|
+
}, {
|
|
657
|
+
key: "save",
|
|
658
|
+
label: t(saving ? "saving" : "save"),
|
|
659
|
+
variant: "primary",
|
|
660
|
+
disabled: !dirty || priorityInvalid || timeoutInvalid || saving || disabled,
|
|
661
|
+
onClick: save
|
|
662
|
+
}],
|
|
663
|
+
children: [
|
|
664
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
|
|
665
|
+
prefix: "wsv",
|
|
666
|
+
id: "wsv-script",
|
|
667
|
+
label: t("scriptPath"),
|
|
668
|
+
hint: t("scriptPathHint"),
|
|
669
|
+
value: draft.scriptPath,
|
|
670
|
+
disabled,
|
|
671
|
+
onEdit: (v) => edit({ scriptPath: v })
|
|
672
|
+
}),
|
|
673
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
|
|
674
|
+
prefix: "wsv",
|
|
675
|
+
id: "wsv-envfile",
|
|
676
|
+
label: t("envFile"),
|
|
677
|
+
hint: t("envFileHint"),
|
|
678
|
+
value: draft.envFile,
|
|
679
|
+
disabled,
|
|
680
|
+
onEdit: (v) => edit({ envFile: v })
|
|
681
|
+
}),
|
|
682
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
|
|
683
|
+
prefix: "wsv",
|
|
684
|
+
id: "wsv-python",
|
|
685
|
+
label: t("python"),
|
|
686
|
+
hint: t("pythonHint"),
|
|
687
|
+
value: draft.python,
|
|
688
|
+
disabled,
|
|
689
|
+
onEdit: (v) => edit({ python: v })
|
|
690
|
+
}),
|
|
691
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
692
|
+
className: "wsv-field",
|
|
693
|
+
children: [
|
|
694
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
695
|
+
className: "wsv-head",
|
|
696
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
|
|
697
|
+
className: "wsv-label",
|
|
698
|
+
htmlFor: "wsv-priority",
|
|
699
|
+
children: t("priority")
|
|
700
|
+
})
|
|
701
|
+
}),
|
|
702
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
|
|
703
|
+
id: "wsv-priority",
|
|
704
|
+
className: "wsv-priority",
|
|
705
|
+
rows: 3,
|
|
706
|
+
value: draft.priorityText,
|
|
707
|
+
disabled,
|
|
708
|
+
onChange: (event) => edit({ priorityText: event.target.value })
|
|
709
|
+
}),
|
|
710
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
711
|
+
className: priorityInvalid ? "wsv-invalid" : "wsv-hint",
|
|
712
|
+
children: priorityInvalid ? t("invalidPriority") : t("priorityHint")
|
|
713
|
+
})
|
|
714
|
+
]
|
|
715
|
+
}),
|
|
716
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
|
|
717
|
+
prefix: "wsv",
|
|
718
|
+
id: "wsv-timeout",
|
|
719
|
+
label: t("timeoutMs"),
|
|
720
|
+
hint: t("timeoutMsHint"),
|
|
721
|
+
value: draft.timeoutMsText,
|
|
722
|
+
numeric: true,
|
|
723
|
+
disabled,
|
|
724
|
+
invalid: timeoutInvalid,
|
|
725
|
+
invalidLabel: t("invalidTimeout"),
|
|
726
|
+
onEdit: (v) => edit({ timeoutMsText: v })
|
|
727
|
+
}),
|
|
728
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
729
|
+
className: "wsv-keys",
|
|
730
|
+
children: [
|
|
731
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
732
|
+
className: "wsv-keysTitle",
|
|
733
|
+
children: t("keysGroup")
|
|
734
|
+
}),
|
|
735
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
736
|
+
className: "wsv-hint",
|
|
737
|
+
children: t("keysHint")
|
|
738
|
+
}),
|
|
739
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
740
|
+
className: "wsv-hint",
|
|
741
|
+
children: t("keysUserLayerHint")
|
|
742
|
+
})
|
|
743
|
+
]
|
|
744
|
+
}),
|
|
745
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
746
|
+
className: "wsv-hint",
|
|
747
|
+
children: t("reloadHint")
|
|
748
|
+
})
|
|
749
|
+
]
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
//#endregion
|
|
753
|
+
//#region src/client/i18n.ts
|
|
754
|
+
/**
|
|
755
|
+
* 配置卡片文案(zh/en)。经 ctx.locale.register 注册、bind 取用。
|
|
756
|
+
* 公共键(save/discard/unsaved 等)来自 shared 的 common 字典,本文件只维护业务键。
|
|
757
|
+
* @module web-search-services/client/i18n
|
|
758
|
+
*/
|
|
759
|
+
const NS = "dsh-plus-web-search-services";
|
|
760
|
+
const ownZh = {
|
|
761
|
+
title: "搜索后端聚合",
|
|
762
|
+
description: "把 search-services skill 接成 dsh 的 web_search 后端:按优先级尝试 Tavily / Exa / OpenAI-compatible,失败自动回退。",
|
|
763
|
+
summaryLine: "web_search 免费后端聚合:优先级链 + 失败自动回退(密钥读 skill 的 env 文件)。",
|
|
764
|
+
scriptPath: "search.py 路径",
|
|
765
|
+
scriptPathHint: "留空 = 使用随包内置脚本;支持 ~ 展开。",
|
|
766
|
+
envFile: "密钥 env 文件",
|
|
767
|
+
envFileHint: "默认 ~/.config/search-services/env(skill 的单事实源);留空表示不读该文件。支持 ~ 展开。",
|
|
768
|
+
python: "python 解释器",
|
|
769
|
+
pythonHint: "可含参数(如 py -3);留空 = 自动发现(桌面捆绑运行时 → PATH → 平台兜底名)。",
|
|
770
|
+
priority: "后端优先级",
|
|
771
|
+
priorityHint: "每行一个,靠前先试,失败自动回退到下一个。可选值:tavily、exa、openai-chat;重复或未知值会被拒绝保存。",
|
|
772
|
+
timeoutMs: "单次搜索超时(毫秒)",
|
|
773
|
+
timeoutMsHint: "应低于 dsh-tool-web 的 searchTimeoutMs;下限 1000,默认 55000。",
|
|
774
|
+
invalidPriority: "请填写至少一个有效后端,且不要重复",
|
|
775
|
+
invalidTimeout: "请输入不小于 1000 的整数",
|
|
776
|
+
keysGroup: "密钥与覆盖项(本卡片不编辑)",
|
|
777
|
+
keysHint: "密钥主来源是上面的 env 文件,保持 skill 单事实源:TAVILY_API_KEY、EXA_API_KEY、SEARCH_OPENAI_API_KEY,以及可选的 SEARCH_OPENAI_BASE_URL、SEARCH_OPENAI_MODEL。",
|
|
778
|
+
keysUserLayerHint: "schema 里的 keys 段是行级覆盖逃生舱(role(secret) 字段只允许 profile 行级注入);写入 settings.yaml 用户层会被脱敏遮蔽,因此配置卡片不提供编辑。需要覆盖时请改 env 文件,或在 profile 的 cordis.patch.yml 行级注入。",
|
|
779
|
+
reloadHint: "改动热生效(下一次 web_search 调用即用新配置);scriptPath / python 变化无需重启。"
|
|
780
|
+
};
|
|
781
|
+
const ownEn = {
|
|
782
|
+
title: "Search backend aggregation",
|
|
783
|
+
description: "Bridges the search-services skill into dsh as a web_search backend: tries Tavily / Exa / OpenAI-compatible in priority order with automatic fallback.",
|
|
784
|
+
summaryLine: "web_search backend aggregation: priority chain with automatic fallback (keys from the skill env file).",
|
|
785
|
+
scriptPath: "search.py path",
|
|
786
|
+
scriptPathHint: "Empty uses the bundled copy; ~ is expanded.",
|
|
787
|
+
envFile: "Credential env file",
|
|
788
|
+
envFileHint: "Defaults to ~/.config/search-services/env (the skill’s single source); empty skips the file. ~ is expanded.",
|
|
789
|
+
python: "Python interpreter",
|
|
790
|
+
pythonHint: "May include arguments (e.g. py -3); empty auto-detects (desktop runtime → PATH → platform default).",
|
|
791
|
+
priority: "Backend priority",
|
|
792
|
+
priorityHint: "One per line, earlier first, with automatic fallback on failure. Allowed: tavily, exa, openai-chat; duplicates or unknown values block saving.",
|
|
793
|
+
timeoutMs: "Per-search timeout (ms)",
|
|
794
|
+
timeoutMsHint: "Keep it below dsh-tool-web’s searchTimeoutMs; minimum 1000, default 55000.",
|
|
795
|
+
invalidPriority: "Enter at least one valid backend without duplicates",
|
|
796
|
+
invalidTimeout: "Enter an integer of at least 1000",
|
|
797
|
+
keysGroup: "Credentials and overrides (not edited here)",
|
|
798
|
+
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.",
|
|
799
|
+
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.yaml 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.",
|
|
800
|
+
reloadHint: "Changes apply hot (the next web_search call uses them); scriptPath / python need no restart."
|
|
801
|
+
};
|
|
802
|
+
const zh = mergeDict(commonZh, ownZh);
|
|
803
|
+
const en = mergeDict(commonEn, ownEn);
|
|
804
|
+
//#endregion
|
|
805
|
+
//#region src/client/styles.ts
|
|
806
|
+
/**
|
|
807
|
+
* 配置卡片样式:基础规则走 @dsh-plus/shared/client 的 cardCss('wsv'),
|
|
808
|
+
* 本文件只补插件特有规则(优先级多行文本域、密钥说明块)。
|
|
809
|
+
* 沿用官方 data-plugin / data-plugin-css 约定(HMR 据此卸载)。
|
|
810
|
+
* @module web-search-services/client/styles
|
|
811
|
+
*/
|
|
812
|
+
const PLUGIN_ID = "@dsh-plus/web-search-services";
|
|
813
|
+
const cardCssAll = cardCss("wsv", `
|
|
814
|
+
.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}
|
|
815
|
+
.wsv-priority:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}
|
|
816
|
+
.wsv-priority:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
|
|
817
|
+
.wsv-keys{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;margin:12px 0 0;padding:10px 12px}
|
|
818
|
+
.wsv-keysTitle{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;font-weight:600}
|
|
819
|
+
@media (max-width:767px){
|
|
820
|
+
.wsv-priority{font-size:16px}
|
|
821
|
+
}
|
|
822
|
+
`);
|
|
823
|
+
/** 幂等注入样式标签;返回标签(已存在或环境无 document 时为 null)。 */
|
|
824
|
+
function injectStyle() {
|
|
825
|
+
return injectCardStyle(PLUGIN_ID, cardCssAll);
|
|
826
|
+
}
|
|
827
|
+
//#endregion
|
|
828
|
+
//#region src/client/client.ts
|
|
829
|
+
const name = "dsh-plus-web-search-services";
|
|
830
|
+
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
831
|
+
const inject = [
|
|
832
|
+
"slots",
|
|
833
|
+
"locale",
|
|
834
|
+
"remote",
|
|
835
|
+
"remote.settings"
|
|
836
|
+
];
|
|
837
|
+
function apply(ctx) {
|
|
838
|
+
const c = ctx;
|
|
839
|
+
const tag = injectStyle();
|
|
840
|
+
c.effect(() => () => {
|
|
841
|
+
tag?.remove();
|
|
842
|
+
}, "web-search-services: style");
|
|
843
|
+
c.effect(() => c.locale.register(NS, {
|
|
844
|
+
zh,
|
|
845
|
+
en
|
|
846
|
+
}), "web-search-services: locale");
|
|
847
|
+
const scope = createSettingsScope(c, SETTINGS_NS, "web-search-services: settings scope");
|
|
848
|
+
const api = createNamespaceApi(c.get("remote").settings, SETTINGS_NS);
|
|
849
|
+
injectPluginConfigCard(c.slots, {
|
|
850
|
+
ns: SETTINGS_NS,
|
|
851
|
+
rowId: "dsh-plus-web-search-services",
|
|
852
|
+
pkg: "@dsh-plus/web-search-services",
|
|
853
|
+
component: WebSearchServicesCard,
|
|
854
|
+
inject: () => ({
|
|
855
|
+
t: c.locale.bind(NS),
|
|
856
|
+
scope,
|
|
857
|
+
api
|
|
858
|
+
})
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
//#endregion
|
|
862
|
+
exports.apply = apply;
|
|
863
|
+
exports.inject = inject;
|
|
864
|
+
exports.name = name;
|
|
865
|
+
return module.exports;
|
|
866
|
+
}
|
|
867
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/web-search-services",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "dsh-plus service plugin: web_search 免费后端聚合——经官方 ctx.web provider 扩展点桥接 search-services skill(Tavily/Exa/OpenAI Chat 免费额度,优先级+自动回退+key 配置),官方 web_search 工具与输出格式零修改",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"types": "./lib/index.d.ts",
|
|
11
11
|
"default": "./lib/index.js"
|
|
12
12
|
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"default": "./lib/client.js"
|
|
15
|
+
},
|
|
13
16
|
"./src/*": "./src/*",
|
|
14
17
|
"./package.json": "./package.json"
|
|
15
18
|
},
|
|
@@ -18,8 +21,18 @@
|
|
|
18
21
|
"src",
|
|
19
22
|
"scripts"
|
|
20
23
|
],
|
|
24
|
+
"dsh": {
|
|
25
|
+
"client": {
|
|
26
|
+
"inject": [
|
|
27
|
+
"@deepseek-ai/dsh-client-locale",
|
|
28
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
29
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
30
|
+
],
|
|
31
|
+
"platform": "web"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
21
34
|
"dependencies": {
|
|
22
|
-
"@dsh-plus/shared": "0.1.
|
|
35
|
+
"@dsh-plus/shared": "0.1.31"
|
|
23
36
|
},
|
|
24
37
|
"peerDependencies": {
|
|
25
38
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
@@ -33,12 +46,15 @@
|
|
|
33
46
|
"@deepseek-ai/dsh-home-paths": "0.2.0-rc.1",
|
|
34
47
|
"@deepseek-ai/dsh-settings": "0.2.0-rc.1",
|
|
35
48
|
"@deepseek-ai/dsh-web": "0.2.0-rc.1",
|
|
36
|
-
"@deepseek-ai/schemastery": "3.18.4"
|
|
49
|
+
"@deepseek-ai/schemastery": "3.18.4",
|
|
50
|
+
"@types/react": "~18.3.31",
|
|
51
|
+
"react": "^18.2.0"
|
|
37
52
|
},
|
|
38
53
|
"publishConfig": {
|
|
39
54
|
"access": "public"
|
|
40
55
|
},
|
|
41
56
|
"scripts": {
|
|
42
|
-
"build": "tsdown
|
|
57
|
+
"build": "tsdown",
|
|
58
|
+
"watch": "tsdown --watch"
|
|
43
59
|
}
|
|
44
60
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「搜索后端聚合」配置卡片:经 injectPluginConfigCard 注册(legacy
|
|
3
|
+
* settings.plugin.item 与插件页 plugins.row.config / plugins.bundle.config,
|
|
4
|
+
* view 分发 summary/page)。
|
|
5
|
+
* 字段:scriptPath / envFile / python / priority(每行一个后端)/ timeoutMs。
|
|
6
|
+
* 密钥不走卡片:keys 段的 role(secret) 字段只允许 profile 行级注入,用户层
|
|
7
|
+
* 写入会被脱敏遮蔽——卡片改为把密钥来源(env 文件)与覆盖方式写在页面上。
|
|
8
|
+
* 外壳与控件走 @dsh-plus/shared/client 套件(CardChrome/TextField/
|
|
9
|
+
* useNamespaceDraft);纯校验逻辑在同目录 draft.ts(node --test 可直测)。
|
|
10
|
+
* @module web-search-services/client/card
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
CardChrome,
|
|
15
|
+
CardLoading,
|
|
16
|
+
cardVariantFor,
|
|
17
|
+
type NamespaceSettingsApi,
|
|
18
|
+
type PluginConfigViewProps,
|
|
19
|
+
type Scope,
|
|
20
|
+
TextField,
|
|
21
|
+
useNamespaceDraft,
|
|
22
|
+
} from '@dsh-plus/shared/client'
|
|
23
|
+
import { type ReactElement, useState } from 'react'
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
type ConfigValue,
|
|
27
|
+
type Draft,
|
|
28
|
+
draftFromValue,
|
|
29
|
+
priorityTextOk,
|
|
30
|
+
timeoutTextOk,
|
|
31
|
+
toPatch,
|
|
32
|
+
} from './draft.ts'
|
|
33
|
+
import type { Translate } from './i18n.ts'
|
|
34
|
+
|
|
35
|
+
export interface CardProps extends PluginConfigViewProps {
|
|
36
|
+
t: Translate
|
|
37
|
+
scope: Scope
|
|
38
|
+
api: NamespaceSettingsApi
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function WebSearchServicesCard(props: CardProps): ReactElement | string | null {
|
|
42
|
+
const { t, scope, api } = props
|
|
43
|
+
const { draft, dirty, saving, status, disabled, edit, reset, save } = useNamespaceDraft<
|
|
44
|
+
ConfigValue,
|
|
45
|
+
Draft
|
|
46
|
+
>({ scope, api, from: draftFromValue, patch: toPatch, saveFailedLabel: t('saveFailed') })
|
|
47
|
+
// 插件页 page 视图为表单落地页,默认展开;旧槽位无 view,保持折叠。
|
|
48
|
+
const [open, setOpen] = useState(props.view === 'page')
|
|
49
|
+
|
|
50
|
+
// 插件页 summary 视图只出一行简介(hooks 已全部落定,可安全提前返回)。
|
|
51
|
+
if (props.view === 'summary') return t('summaryLine')
|
|
52
|
+
|
|
53
|
+
// 插件页 page 视图内嵌宿主页面容器(已带页面级内边距与标题),用无边框分节。
|
|
54
|
+
const variant = cardVariantFor(props.view)
|
|
55
|
+
if (draft === null) return <CardLoading prefix="wsv" variant={variant} text={t('loading')} />
|
|
56
|
+
|
|
57
|
+
const priorityInvalid = !priorityTextOk(draft.priorityText)
|
|
58
|
+
const timeoutInvalid = !timeoutTextOk(draft.timeoutMsText)
|
|
59
|
+
return (
|
|
60
|
+
<CardChrome
|
|
61
|
+
prefix="wsv"
|
|
62
|
+
title={t('title')}
|
|
63
|
+
description={t('description')}
|
|
64
|
+
open={open}
|
|
65
|
+
onToggle={setOpen}
|
|
66
|
+
variant={variant}
|
|
67
|
+
dirty={dirty}
|
|
68
|
+
dirtyLabel={t('unsaved')}
|
|
69
|
+
readOnlyNotice={disabled ? t('readOnly') : undefined}
|
|
70
|
+
status={status}
|
|
71
|
+
actions={[
|
|
72
|
+
{
|
|
73
|
+
key: 'discard',
|
|
74
|
+
label: t('discard'),
|
|
75
|
+
disabled: !dirty || saving,
|
|
76
|
+
onClick: reset,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: 'save',
|
|
80
|
+
label: t(saving ? 'saving' : 'save'),
|
|
81
|
+
variant: 'primary',
|
|
82
|
+
disabled: !dirty || priorityInvalid || timeoutInvalid || saving || disabled,
|
|
83
|
+
onClick: save,
|
|
84
|
+
},
|
|
85
|
+
]}
|
|
86
|
+
>
|
|
87
|
+
<TextField
|
|
88
|
+
prefix="wsv"
|
|
89
|
+
id="wsv-script"
|
|
90
|
+
label={t('scriptPath')}
|
|
91
|
+
hint={t('scriptPathHint')}
|
|
92
|
+
value={draft.scriptPath}
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
onEdit={(v) => edit({ scriptPath: v })}
|
|
95
|
+
/>
|
|
96
|
+
<TextField
|
|
97
|
+
prefix="wsv"
|
|
98
|
+
id="wsv-envfile"
|
|
99
|
+
label={t('envFile')}
|
|
100
|
+
hint={t('envFileHint')}
|
|
101
|
+
value={draft.envFile}
|
|
102
|
+
disabled={disabled}
|
|
103
|
+
onEdit={(v) => edit({ envFile: v })}
|
|
104
|
+
/>
|
|
105
|
+
<TextField
|
|
106
|
+
prefix="wsv"
|
|
107
|
+
id="wsv-python"
|
|
108
|
+
label={t('python')}
|
|
109
|
+
hint={t('pythonHint')}
|
|
110
|
+
value={draft.python}
|
|
111
|
+
disabled={disabled}
|
|
112
|
+
onEdit={(v) => edit({ python: v })}
|
|
113
|
+
/>
|
|
114
|
+
<div className="wsv-field">
|
|
115
|
+
<div className="wsv-head">
|
|
116
|
+
<label className="wsv-label" htmlFor="wsv-priority">
|
|
117
|
+
{t('priority')}
|
|
118
|
+
</label>
|
|
119
|
+
</div>
|
|
120
|
+
<textarea
|
|
121
|
+
id="wsv-priority"
|
|
122
|
+
className="wsv-priority"
|
|
123
|
+
rows={3}
|
|
124
|
+
value={draft.priorityText}
|
|
125
|
+
disabled={disabled}
|
|
126
|
+
onChange={(event) => edit({ priorityText: event.target.value })}
|
|
127
|
+
/>
|
|
128
|
+
<p className={priorityInvalid ? 'wsv-invalid' : 'wsv-hint'}>
|
|
129
|
+
{priorityInvalid ? t('invalidPriority') : t('priorityHint')}
|
|
130
|
+
</p>
|
|
131
|
+
</div>
|
|
132
|
+
<TextField
|
|
133
|
+
prefix="wsv"
|
|
134
|
+
id="wsv-timeout"
|
|
135
|
+
label={t('timeoutMs')}
|
|
136
|
+
hint={t('timeoutMsHint')}
|
|
137
|
+
value={draft.timeoutMsText}
|
|
138
|
+
numeric
|
|
139
|
+
disabled={disabled}
|
|
140
|
+
invalid={timeoutInvalid}
|
|
141
|
+
invalidLabel={t('invalidTimeout')}
|
|
142
|
+
onEdit={(v) => edit({ timeoutMsText: v })}
|
|
143
|
+
/>
|
|
144
|
+
<div className="wsv-keys">
|
|
145
|
+
<p className="wsv-keysTitle">{t('keysGroup')}</p>
|
|
146
|
+
<p className="wsv-hint">{t('keysHint')}</p>
|
|
147
|
+
<p className="wsv-hint">{t('keysUserLayerHint')}</p>
|
|
148
|
+
</div>
|
|
149
|
+
<p className="wsv-hint">{t('reloadHint')}</p>
|
|
150
|
+
</CardChrome>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半入口:注册 locale 字典 + 注册「搜索后端聚合」卡片
|
|
3
|
+
* (injectPluginConfigCard 三槽位:legacy settings.plugin.item 与插件页
|
|
4
|
+
* plugins.row.config / plugins.bundle.config)。
|
|
5
|
+
* 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
|
|
6
|
+
* (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
|
|
7
|
+
* 配置读写经 ctx.remote.settings 直连(scope/api 来自 @dsh-plus/shared/client);
|
|
8
|
+
* 密钥不经卡片(role(secret) 只允许行级注入),见 card.tsx 的说明块。
|
|
9
|
+
* @module web-search-services/client
|
|
10
|
+
*/
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import {
|
|
13
|
+
createNamespaceApi,
|
|
14
|
+
createSettingsScope,
|
|
15
|
+
injectPluginConfigCard,
|
|
16
|
+
type PluginClientContext,
|
|
17
|
+
} from '@dsh-plus/shared/client'
|
|
18
|
+
|
|
19
|
+
import { SETTINGS_NS } from '../ns.ts'
|
|
20
|
+
import { WebSearchServicesCard } from './card.tsx'
|
|
21
|
+
import { type DictKey, en, NS, zh } from './i18n.ts'
|
|
22
|
+
import { injectStyle } from './styles.ts'
|
|
23
|
+
|
|
24
|
+
export const name = 'dsh-plus-web-search-services'
|
|
25
|
+
|
|
26
|
+
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
27
|
+
export const inject = ['slots', 'locale', 'remote', 'remote.settings'] as const
|
|
28
|
+
|
|
29
|
+
/** 宿主窄面收编在 @dsh-plus/shared/client;TKey 传入本包 DictKey 使键名受检。 */
|
|
30
|
+
type ClientContext = PluginClientContext<DictKey>
|
|
31
|
+
|
|
32
|
+
export function apply(ctx: Context): void {
|
|
33
|
+
const c = ctx as unknown as ClientContext
|
|
34
|
+
const tag = injectStyle()
|
|
35
|
+
c.effect(
|
|
36
|
+
() => () => {
|
|
37
|
+
tag?.remove()
|
|
38
|
+
},
|
|
39
|
+
'web-search-services: style',
|
|
40
|
+
)
|
|
41
|
+
c.effect(() => c.locale.register(NS, { zh, en }), 'web-search-services: locale')
|
|
42
|
+
const scope = createSettingsScope(c, SETTINGS_NS, 'web-search-services: settings scope')
|
|
43
|
+
const api = createNamespaceApi(c.get('remote').settings, SETTINGS_NS)
|
|
44
|
+
injectPluginConfigCard(c.slots, {
|
|
45
|
+
ns: SETTINGS_NS,
|
|
46
|
+
rowId: 'dsh-plus-web-search-services',
|
|
47
|
+
pkg: '@dsh-plus/web-search-services',
|
|
48
|
+
component: WebSearchServicesCard,
|
|
49
|
+
inject: () => ({ t: c.locale.bind(NS), scope, api }),
|
|
50
|
+
})
|
|
51
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置卡片草稿的纯逻辑(无 React/JSX,供 node --test 直接导入):
|
|
3
|
+
* 解析值 → 草稿、草稿 → 提交形状,以及优先级列表与超时的校验。
|
|
4
|
+
* @module web-search-services/client/draft
|
|
5
|
+
*/
|
|
6
|
+
import type { SearchBackend } from '../config.ts'
|
|
7
|
+
|
|
8
|
+
/** 可选后端(与 config.ts 的 PRIORITY_ENTRY union 同源顺序)。 */
|
|
9
|
+
export const SEARCH_BACKENDS: readonly SearchBackend[] = ['tavily', 'exa', 'openai-chat']
|
|
10
|
+
|
|
11
|
+
/** 默认后端顺序(与 config.ts 的 DEFAULT_PRIORITY 一致)。 */
|
|
12
|
+
export const DEFAULT_PRIORITY: readonly SearchBackend[] = ['tavily', 'exa', 'openai-chat']
|
|
13
|
+
|
|
14
|
+
/** 单次搜索子进程超时下限(与 config.ts 的 .min(1000) 一致)。 */
|
|
15
|
+
export const TIMEOUT_MIN_MS = 1000
|
|
16
|
+
|
|
17
|
+
/** settings 命名空间的解析值(keys 内 secret 字段已被 describe 层遮蔽,卡片不消费)。 */
|
|
18
|
+
export interface ConfigValue {
|
|
19
|
+
scriptPath: string
|
|
20
|
+
envFile: string
|
|
21
|
+
python: string
|
|
22
|
+
priority: SearchBackend[]
|
|
23
|
+
timeoutMs: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 编辑草稿:字符串字段原样,数组与数值以文本承载(保存时校验并折算)。 */
|
|
27
|
+
export interface Draft {
|
|
28
|
+
scriptPath: string
|
|
29
|
+
envFile: string
|
|
30
|
+
python: string
|
|
31
|
+
priorityText: string
|
|
32
|
+
timeoutMsText: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function draftFromValue(value: ConfigValue): Draft {
|
|
36
|
+
return {
|
|
37
|
+
scriptPath: value.scriptPath,
|
|
38
|
+
envFile: value.envFile,
|
|
39
|
+
python: value.python,
|
|
40
|
+
priorityText: value.priority.join('\n'),
|
|
41
|
+
timeoutMsText: String(value.timeoutMs),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 优先级文本 → 后端序列;顺带返回无法识别的行(供 UI 提示)。 */
|
|
46
|
+
export function parsePriority(text: string): {
|
|
47
|
+
backends: SearchBackend[]
|
|
48
|
+
unknown: string[]
|
|
49
|
+
duplicated: string[]
|
|
50
|
+
} {
|
|
51
|
+
const backends: SearchBackend[] = []
|
|
52
|
+
const unknown: string[] = []
|
|
53
|
+
const duplicated: string[] = []
|
|
54
|
+
for (const raw of text.split('\n')) {
|
|
55
|
+
const line = raw.trim()
|
|
56
|
+
if (line === '') continue
|
|
57
|
+
const match = SEARCH_BACKENDS.find((backend) => backend === line)
|
|
58
|
+
if (match === undefined) {
|
|
59
|
+
unknown.push(line)
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (backends.includes(match)) {
|
|
63
|
+
duplicated.push(line)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
backends.push(match)
|
|
67
|
+
}
|
|
68
|
+
return { backends, unknown, duplicated }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 优先级是否可用:至少一个后端、无未知项、无重复项。 */
|
|
72
|
+
export function priorityTextOk(text: string): boolean {
|
|
73
|
+
const { backends, unknown, duplicated } = parsePriority(text)
|
|
74
|
+
return backends.length > 0 && unknown.length === 0 && duplicated.length === 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 超时是否可用:≥1000 的整数(对齐 schema 的 .natural().min(1000))。 */
|
|
78
|
+
export function timeoutTextOk(text: string): boolean {
|
|
79
|
+
if (!/^\d+$/.test(text.trim())) return false
|
|
80
|
+
return Number(text) >= TIMEOUT_MIN_MS
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 草稿 → 提交形状(调用方保证已通过校验)。 */
|
|
84
|
+
export function toPatch(draft: Draft): Record<string, unknown> {
|
|
85
|
+
return {
|
|
86
|
+
scriptPath: draft.scriptPath,
|
|
87
|
+
envFile: draft.envFile,
|
|
88
|
+
python: draft.python,
|
|
89
|
+
priority: parsePriority(draft.priorityText).backends,
|
|
90
|
+
timeoutMs: Number(draft.timeoutMsText),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置卡片文案(zh/en)。经 ctx.locale.register 注册、bind 取用。
|
|
3
|
+
* 公共键(save/discard/unsaved 等)来自 shared 的 common 字典,本文件只维护业务键。
|
|
4
|
+
* @module web-search-services/client/i18n
|
|
5
|
+
*/
|
|
6
|
+
import { commonEn, commonZh, mergeDict } from '@dsh-plus/shared/client'
|
|
7
|
+
|
|
8
|
+
export const NS = 'dsh-plus-web-search-services'
|
|
9
|
+
|
|
10
|
+
const ownZh = {
|
|
11
|
+
title: '搜索后端聚合',
|
|
12
|
+
description:
|
|
13
|
+
'把 search-services skill 接成 dsh 的 web_search 后端:按优先级尝试 Tavily / Exa / OpenAI-compatible,失败自动回退。',
|
|
14
|
+
summaryLine: 'web_search 免费后端聚合:优先级链 + 失败自动回退(密钥读 skill 的 env 文件)。',
|
|
15
|
+
scriptPath: 'search.py 路径',
|
|
16
|
+
scriptPathHint: '留空 = 使用随包内置脚本;支持 ~ 展开。',
|
|
17
|
+
envFile: '密钥 env 文件',
|
|
18
|
+
envFileHint:
|
|
19
|
+
'默认 ~/.config/search-services/env(skill 的单事实源);留空表示不读该文件。支持 ~ 展开。',
|
|
20
|
+
python: 'python 解释器',
|
|
21
|
+
pythonHint: '可含参数(如 py -3);留空 = 自动发现(桌面捆绑运行时 → PATH → 平台兜底名)。',
|
|
22
|
+
priority: '后端优先级',
|
|
23
|
+
priorityHint:
|
|
24
|
+
'每行一个,靠前先试,失败自动回退到下一个。可选值:tavily、exa、openai-chat;重复或未知值会被拒绝保存。',
|
|
25
|
+
timeoutMs: '单次搜索超时(毫秒)',
|
|
26
|
+
timeoutMsHint: '应低于 dsh-tool-web 的 searchTimeoutMs;下限 1000,默认 55000。',
|
|
27
|
+
invalidPriority: '请填写至少一个有效后端,且不要重复',
|
|
28
|
+
invalidTimeout: '请输入不小于 1000 的整数',
|
|
29
|
+
keysGroup: '密钥与覆盖项(本卡片不编辑)',
|
|
30
|
+
keysHint:
|
|
31
|
+
'密钥主来源是上面的 env 文件,保持 skill 单事实源:TAVILY_API_KEY、EXA_API_KEY、SEARCH_OPENAI_API_KEY,以及可选的 SEARCH_OPENAI_BASE_URL、SEARCH_OPENAI_MODEL。',
|
|
32
|
+
keysUserLayerHint:
|
|
33
|
+
'schema 里的 keys 段是行级覆盖逃生舱(role(secret) 字段只允许 profile 行级注入);写入 settings.yaml 用户层会被脱敏遮蔽,因此配置卡片不提供编辑。需要覆盖时请改 env 文件,或在 profile 的 cordis.patch.yml 行级注入。',
|
|
34
|
+
reloadHint: '改动热生效(下一次 web_search 调用即用新配置);scriptPath / python 变化无需重启。',
|
|
35
|
+
} as const
|
|
36
|
+
|
|
37
|
+
const ownEn = {
|
|
38
|
+
title: 'Search backend aggregation',
|
|
39
|
+
description:
|
|
40
|
+
'Bridges the search-services skill into dsh as a web_search backend: tries Tavily / Exa / OpenAI-compatible in priority order with automatic fallback.',
|
|
41
|
+
summaryLine:
|
|
42
|
+
'web_search backend aggregation: priority chain with automatic fallback (keys from the skill env file).',
|
|
43
|
+
scriptPath: 'search.py path',
|
|
44
|
+
scriptPathHint: 'Empty uses the bundled copy; ~ is expanded.',
|
|
45
|
+
envFile: 'Credential env file',
|
|
46
|
+
envFileHint:
|
|
47
|
+
'Defaults to ~/.config/search-services/env (the skill’s single source); empty skips the file. ~ is expanded.',
|
|
48
|
+
python: 'Python interpreter',
|
|
49
|
+
pythonHint:
|
|
50
|
+
'May include arguments (e.g. py -3); empty auto-detects (desktop runtime → PATH → platform default).',
|
|
51
|
+
priority: 'Backend priority',
|
|
52
|
+
priorityHint:
|
|
53
|
+
'One per line, earlier first, with automatic fallback on failure. Allowed: tavily, exa, openai-chat; duplicates or unknown values block saving.',
|
|
54
|
+
timeoutMs: 'Per-search timeout (ms)',
|
|
55
|
+
timeoutMsHint: 'Keep it below dsh-tool-web’s searchTimeoutMs; minimum 1000, default 55000.',
|
|
56
|
+
invalidPriority: 'Enter at least one valid backend without duplicates',
|
|
57
|
+
invalidTimeout: 'Enter an integer of at least 1000',
|
|
58
|
+
keysGroup: 'Credentials and overrides (not edited here)',
|
|
59
|
+
keysHint:
|
|
60
|
+
'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.',
|
|
61
|
+
keysUserLayerHint:
|
|
62
|
+
'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.yaml 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.',
|
|
63
|
+
reloadHint:
|
|
64
|
+
'Changes apply hot (the next web_search call uses them); scriptPath / python need no restart.',
|
|
65
|
+
} as const
|
|
66
|
+
|
|
67
|
+
export type DictKey = keyof typeof commonZh | keyof typeof ownZh
|
|
68
|
+
|
|
69
|
+
/** 卡片共用的翻译函数类型(slot 注入的 bind 结果按此消费)。 */
|
|
70
|
+
export type Translate = (key: DictKey) => string
|
|
71
|
+
|
|
72
|
+
// 标注为 Record<DictKey, string>:en 缺任一键即编译期报错(中英强制对齐)。
|
|
73
|
+
export const zh: Record<DictKey, string> = mergeDict(commonZh, ownZh)
|
|
74
|
+
export const en: Record<DictKey, string> = mergeDict(commonEn, ownEn)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置卡片样式:基础规则走 @dsh-plus/shared/client 的 cardCss('wsv'),
|
|
3
|
+
* 本文件只补插件特有规则(优先级多行文本域、密钥说明块)。
|
|
4
|
+
* 沿用官方 data-plugin / data-plugin-css 约定(HMR 据此卸载)。
|
|
5
|
+
* @module web-search-services/client/styles
|
|
6
|
+
*/
|
|
7
|
+
import { cardCss, injectCardStyle } from '@dsh-plus/shared/client'
|
|
8
|
+
|
|
9
|
+
export const PLUGIN_ID = '@dsh-plus/web-search-services'
|
|
10
|
+
|
|
11
|
+
const extraCss = `
|
|
12
|
+
.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}
|
|
13
|
+
.wsv-priority:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}
|
|
14
|
+
.wsv-priority:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
|
|
15
|
+
.wsv-keys{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;margin:12px 0 0;padding:10px 12px}
|
|
16
|
+
.wsv-keysTitle{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;font-weight:600}
|
|
17
|
+
@media (max-width:767px){
|
|
18
|
+
.wsv-priority{font-size:16px}
|
|
19
|
+
}
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
export const cardCssAll = cardCss('wsv', extraCss)
|
|
23
|
+
|
|
24
|
+
/** 幂等注入样式标签;返回标签(已存在或环境无 document 时为 null)。 */
|
|
25
|
+
export function injectStyle(): HTMLStyleElement | null {
|
|
26
|
+
return injectCardStyle(PLUGIN_ID, cardCssAll)
|
|
27
|
+
}
|