@darlingc/dsh-freesearch 0.5.4 → 0.5.5

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/README.md CHANGED
@@ -36,7 +36,7 @@ dsh 默认的搜索 provider 依赖 DeepSeek 官方 API key(`DEEPSEEK_API_KEY`
36
36
  - **统一引擎回退** —— 任何引擎失败(付费/免费,缺 key/401/限流/网络)自动轮流尝试下一个引擎:首选引擎 → 其他引擎(exa/tavily/keenable 无 key 也会尝试,因为它们自带 keyless 免费额度)→ 剩余免费引擎,搜索永不直接失败;结果顶部注明实际生效的引擎(如 `Note: perplexity unavailable or failed, using exa.`)
37
37
  - **时间过滤** —— `advanced_search` 工具支持 `timeRange`:固定档、自定义相对值、绝对日期三种形式(详见下方逻辑说明)
38
38
  - **系统提示词注入** —— agent 知道当前用哪个引擎、哪些需要 key
39
- - **版本号 + 检查更新** —— 设置卡片显示当前版本(v0.4.12),"检查更新"按钮直连 npm registry 对比最新版,有新版本时提示并可一键跳转
39
+ - **版本号 + 检查更新** —— 设置卡片显示当前版本(v0.5.5),"检查更新"按钮直连 npm registry 对比最新版,有新版本时提示并可一键跳转
40
40
  - **结果缓存** —— 相同查询(含引擎/时间过滤参数)5 分钟内命中缓存(LRU 50 条),防免费引擎限流、省付费额度;时长可在设置页 0-5 分钟自由配置(0 关闭)
41
41
  - **免费标注** —— 设置页中免费引擎带绿色 `FREE` 徽章,付费引擎带橙色 `API KEY` 徽章
42
42
  - **网页抓取(web_fetch)** —— 让 agent 抓取网页内容(官方 `dsh-web-fetch-http` provider,纯 JS,零额外依赖)
@@ -122,6 +122,29 @@ dsh web
122
122
 
123
123
  > ⚠️ 依赖:CI 的 `npm stage publish` 需要 GitHub 仓库配置 `NPM_TOKEN` secret(npmjs.com → Access Tokens → Publish 类型 token)。且该包需先在 npm registry 上存在(首次发布无法 stage,需先在网页/本地完成一次正式发布)。
124
124
 
125
+ #### 发布到 GitHub Packages(本地,可选,不走 CI/CD)
126
+
127
+ 除 npmjs 外,也可把本包发布到 **GitHub Packages**(`npm.pkg.github.com`,归属 `DarlingC` 组织)。仓库内置了专用 npm 配置 `.npmrc-github`,它**不会自动加载**,因此不会影响常规安装或上方 npmjs Staged Publishing 流程。
128
+
129
+ ```sh
130
+ # 1) 生成带 write:packages 权限的 GitHub PAT(Settings → Developer settings → Personal access tokens)
131
+ export GITHUB_TOKEN=ghp_xxx
132
+
133
+ # 2) 用专用配置发布到 GitHub Packages(版本号须 >= 已存在版本)
134
+ npm publish --registry=https://npm.pkg.github.com/ --userconfig=.npmrc-github
135
+ ```
136
+
137
+ 从 GitHub Packages 安装:
138
+
139
+ ```sh
140
+ export GITHUB_TOKEN=ghp_xxx # 需 read:packages 权限
141
+ dsh plugin --profile web add @darlingc/dsh-freesearch --registry=https://npm.pkg.github.com/
142
+ # 或在用户/项目 .npmrc 里加一行让该 scope 走 GitHub Packages:
143
+ # @darlingc:registry=https://npm.pkg.github.com/
144
+ ```
145
+
146
+ > 📌 说明:GitHub Packages 适合「仓库内私有/组织内发布」场景;对外推荐走 npmjs(上方 Staged Publishing)。两套 registry 互不影响,tag push 自动建的 GitHub Release(见 `.github/workflows/publish.yml`)与 GitHub Packages 发布相互独立。
147
+
125
148
  ### 使用
126
149
 
127
150
  #### 网页设置(推荐)
@@ -130,6 +153,7 @@ dsh web
130
153
 
131
154
  - **Search engine**:下拉框切换引擎,保存即生效
132
155
  - **API keys**:为 Exa / Tavily / Keenable / Perplexity / DeepSeek 填写 key(密码框,保存后只显示"已配置")
156
+ - **推荐**:付费引擎 key 建议写入 harness 凭据中心 `~/.dsh/.credentials.yaml`(如 `DEEPSEEK_API_KEY: sk-...`,与官方 LLM provider 一致,一处管理所有 key)。插件读取优先级:凭据中心 > 设置页 > 环境变量,设置页填的 key 仅作为遗留兼容。
133
157
  - **Test engine**:直测当前引擎可用性(不走回退链,付费引擎无 key 会明确报错)
134
158
  - **Use Bing default**:把当前搜索引擎切回稳定的免费 Bing;`Discard` 只撤销尚未保存的编辑
135
159
  - **Platform search**:勾选启用 GitHub / V2EX / Bilibili 平台搜索(`platform_search` 工具按此过滤)
@@ -307,7 +331,7 @@ This plugin provides multiple free search engines with automatic fallback, compl
307
331
  - **Unified Engine Fallback** — Any engine failure (paid or free, missing key, 401, rate limit, network error) automatically tries the next engine: the configured engine first, then other engines (exa/tavily/keenable are tried even without a key because they have built-in keyless quota), then the remaining free engines (Bing/AnySearch etc.) — with a note attached to the results naming the engine that actually served them (e.g. `Note: perplexity unavailable or failed, using exa.`). Search never fails outright.
308
332
  - **Time Filtering** — The `advanced_search` tool supports `timeRange`: fixed tiers, custom relative values, or an absolute date (details below)
309
333
  - **System Prompt Injection** — The agent is aware of the currently active engine and which engines require API keys
310
- - **Version + Update Check** — The settings card shows the current version (v0.4.12), and a "Check update" button queries the npm registry to compare against the latest release, prompting a one-click jump when a newer version exists
334
+ - **Version + Update Check** — The settings card shows the current version (v0.5.5), and a "Check update" button queries the npm registry to compare against the latest release, prompting a one-click jump when a newer version exists
311
335
  - **Result Caching** — Identical queries (same engine / time-filter args) hit an LRU cache (50 entries) for up to 5 minutes, protecting free engines from rate-limiting and saving paid quota; the TTL is configurable from 0-5 minutes in the settings UI (0 disables caching)
312
336
  - **Visual Badges** — Free engines feature a green `FREE` badge, while paid engines show an orange `API KEY` badge in the settings UI
313
337
  - **Webpage Fetching (`web_fetch`)** — Allows the agent to read full webpage contents (official `dsh-web-fetch-http` provider, pure JS, zero extra dependencies)
@@ -391,6 +415,7 @@ After installation, navigate to **Settings → Plugins → Configurable** tab
391
415
 
392
416
  - **Search engine**: Select an engine from the dropdown; changes take effect immediately upon saving.
393
417
  - **API keys**: Enter keys for Exa / Tavily / Keenable / Perplexity / DeepSeek (password fields; displayed as "configured" once saved).
418
+ - **Recommended**: store paid-engine keys in the harness credential center `~/.dsh/.credentials.yaml` (e.g. `DEEPSEEK_API_KEY: sk-...`, same as the official LLM providers — one place for all keys). Resolution order: credentials center > settings page > environment variable; the settings-page fields remain for backward compatibility.
394
419
  - **Test engine**: Tests the selected engine directly (no fallback chain; paid engines without a key report an explicit error).
395
420
  - **Use Bing default**: stage a switch back to the stable free Bing engine; `Discard` only cancels unsaved edits
396
421
  - **Platform search**: check platforms (GitHub / V2EX / Bilibili / Reddit / HN / Stack Overflow / Wikipedia / npm) to enable them for the `platform_search` tool (disabled platforms are skipped).
package/cordis.patch.yml CHANGED
@@ -16,6 +16,9 @@
16
16
  provider: bing
17
17
  bingMarket: zh-CN
18
18
  # 自动接管默认搜索:让 harness 的 web_search 直接用本插件的 provider(内部含全部引擎)
19
+ # 注意 patch 语义是"整行替换 config"(DSH 0.1.2+):必须保留 fetchProvider,否则
20
+ # web-fetch-http 会被另一层重新注册,与 base bundle 的注册造成 duplicate loader entry。
19
21
  - id: web
20
22
  config:
21
23
  searchProvider: ddg
24
+ fetchProvider: http
package/lib/client.js CHANGED
@@ -30,8 +30,15 @@ window.__ModuleLoader__.load({
30
30
  ".dshfs-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500}",
31
31
  ".dshfs-select{border:1px solid var(--dsw-alias-border-l2);font:inherit;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border-radius:6px;padding:6px 8px;font-size:13px;transition:border-color .13s,box-shadow .13s;width:100%}",
32
32
  ".dshfs-select:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}",
33
+ // 下拉选项列表配色锁死:不随皮肤变量变化(皮肤只影响 select 框体本身)
34
+ // color-scheme 让浏览器原生下拉按主题渲染;option 显式固定底色/文字色兜底
35
+ ".dshfs-select{color-scheme:light dark}",
36
+ ".dshfs-select option,.dshfs-select optgroup{background-color:#ffffff;color:#1f2328}",
37
+ "@media (prefers-color-scheme:dark){.dshfs-select{color-scheme:dark}.dshfs-select option,.dshfs-select optgroup{background-color:#1e1f24;color:#e8e8ea}}",
33
38
  ".dshfs-input{border:1px solid var(--dsw-alias-border-l2);font:inherit;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border-radius:6px;padding:6px 8px;font-size:13px;transition:border-color .13s,box-shadow .13s;width:100%}",
34
39
  ".dshfs-ttl{width:88px}",
40
+ ".dshfs-fieldRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
41
+ ".dshfs-keyStorage{width:auto;min-width:180px}",
35
42
  ".dshfs-input:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}",
36
43
  ".dshfs-input:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}",
37
44
  ".dshfs-input:disabled{opacity:.6;cursor:default}",
@@ -84,7 +91,12 @@ window.__ModuleLoader__.load({
84
91
  perplexityPh: (c) => c ? "Perplexity API 密钥(已配置)" : "Perplexity API 密钥(pplx-...)",
85
92
  deepseekPh: (c) => c ? "DeepSeek API 密钥(已配置)" : "DeepSeek API 密钥(sk-...)",
86
93
  geminiPh: (c) => c ? "Gemini API 密钥(已配置)" : "Gemini API 密钥(免费层,联网搜索)",
87
- keysHint: "密钥保存在 settings.yaml(界面脱敏显示)。为某引擎填写密钥后即可使用。",
94
+ keysHint: "密钥读取优先级:.credentials.yaml 凭据中心 > 这里 > 环境变量。推荐把 key 写进凭据中心(与官方 LLM 一致,一处管理)。",
95
+ keyStorage: "Key 存储位置",
96
+ keyStorageCred: "凭据中心(推荐)",
97
+ keyStorageSettings: "设置页(兼容)",
98
+ keyStorageCredHint: (c) => `保存后写入 ~/.dsh/.credentials.yaml(最高优先级)。当前已配置:${["exa", "tavily", "keenable", "perplexity", "deepseek", "gemini"].filter((k) => c[k]).map((k) => k.toUpperCase()).join(", ") || "无"}`,
99
+ keyStorageSettingsHint: "保存后写入 settings.yaml(向后兼容路径,优先级低于凭据中心)。",
88
100
  platformSearch: "平台搜索(platform_search 工具)",
89
101
  platformHint: "为 agent 的 platform_search 工具启用平台。禁用的平台会被跳过。",
90
102
  cacheTtl: "结果缓存时长(分钟)",
@@ -126,7 +138,12 @@ window.__ModuleLoader__.load({
126
138
  perplexityPh: (c) => c ? "Perplexity API key (configured)" : "Perplexity API key (pplx-...)",
127
139
  deepseekPh: (c) => c ? "DeepSeek API key (configured)" : "DeepSeek API key (sk-...)",
128
140
  geminiPh: (c) => c ? "Gemini API key (configured)" : "Gemini API key (free tier, web search)",
129
- keysHint: "Keys are stored in settings.yaml (redacted in the UI). Enter a key for an engine to use it.",
141
+ keysHint: "Key resolution: .credentials.yaml credential center > here > environment variables. Recommended: store keys in the credential center (same as official LLM providers, one place for all).",
142
+ keyStorage: "Key storage",
143
+ keyStorageCred: "Credential center (recommended)",
144
+ keyStorageSettings: "Settings page (legacy)",
145
+ keyStorageCredHint: (c) => `Saved to ~/.dsh/.credentials.yaml (highest priority). Currently configured: ${["exa", "tavily", "keenable", "perplexity", "deepseek", "gemini"].filter((k) => c[k]).map((k) => k.toUpperCase()).join(", ") || "none"}`,
146
+ keyStorageSettingsHint: "Saved to settings.yaml (backward-compatible path; lower priority than the credential center).",
130
147
  platformSearch: "Platform search (platform_search tool)",
131
148
  platformHint: "Enable platforms for the agent's platform_search tool. Disabled platforms are skipped.",
132
149
  cacheTtl: "Result cache TTL (minutes)",
@@ -157,7 +174,7 @@ window.__ModuleLoader__.load({
157
174
  };
158
175
  const tt = (lang) => I18N[lang === "en" ? "en" : "zh"];
159
176
  // 当前插件版本(与 lib/index.js 的 PLUGIN_VERSION、package.json 保持一致)
160
- const PLUGIN_VERSION = "0.5.4";
177
+ const PLUGIN_VERSION = "0.5.5";
161
178
  const ENGINES = [
162
179
  { id: "ddg", label: "DuckDuckGo · HTML", badge: "FREE", link: "https://duckduckgo.com" },
163
180
  { id: "ddg-lite", label: "DuckDuckGo · Lite", badge: "FREE", link: "https://duckduckgo.com" },
@@ -208,7 +225,54 @@ window.__ModuleLoader__.load({
208
225
  return response.json();
209
226
  }
210
227
 
228
+ async function bridgeCredentialsStatus() {
229
+ const response = await fetch(`${BRIDGE_PREFIX}/credentials-status`, {
230
+ method: "POST",
231
+ headers: { "content-type": "application/json" },
232
+ body: "{}",
233
+ });
234
+ return response.json();
235
+ }
236
+
237
+ async function bridgeCredentialsSet(key, value) {
238
+ const response = await fetch(`${BRIDGE_PREFIX}/credentials-set`, {
239
+ method: "POST",
240
+ headers: { "content-type": "application/json" },
241
+ body: JSON.stringify({ key, value }),
242
+ });
243
+ return response.json();
244
+ }
245
+
246
+ async function bridgeCredentialsUnset(key) {
247
+ const response = await fetch(`${BRIDGE_PREFIX}/credentials-unset`, {
248
+ method: "POST",
249
+ headers: { "content-type": "application/json" },
250
+ body: JSON.stringify({ key }),
251
+ });
252
+ return response.json();
253
+ }
254
+
211
255
  function FreeSearchCard(props) {
256
+ // 检测应用主题深浅(读 body 的 --dsw-alias-bg-base 变量亮度),用于锁定原生下拉配色
257
+ const isDarkScheme = react.useMemo(() => {
258
+ try {
259
+ const root = document.body || document.documentElement;
260
+ const bg = getComputedStyle(root).getPropertyValue("--dsw-alias-bg-base").trim();
261
+ const m = bg.match(/(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)/);
262
+ if (m) {
263
+ const l = 0.299 * Number(m[1]) + 0.587 * Number(m[2]) + 0.114 * Number(m[3]);
264
+ return l < 128;
265
+ }
266
+ if (/^#([0-9a-f]{3,8})/i.test(bg)) {
267
+ const hex = bg.slice(1);
268
+ const h = hex.length <= 4 ? hex.replace(/./g, (c) => c + c) : hex;
269
+ const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
270
+ return 0.299 * r + 0.587 * g + 0.114 * b < 128;
271
+ }
272
+ } catch {}
273
+ return typeof matchMedia === "function" ? matchMedia("(prefers-color-scheme: dark)").matches : false;
274
+ }, []);
275
+ const selectColorScheme = isDarkScheme ? "dark" : "light";
212
276
  const [open, setOpen] = react.useState(false);
213
277
  const [state, setState] = react.useState({ status: "loading" });
214
278
  const [provider, setProvider] = react.useState("bing");
@@ -221,6 +285,10 @@ window.__ModuleLoader__.load({
221
285
  const [platforms, setPlatforms] = react.useState(["github", "v2ex", "bilibili", "reddit", "hn", "stackoverflow", "wikipedia", "npm"]);
222
286
  const [cacheTtl, setCacheTtl] = react.useState(5);
223
287
  const [keysConfigured, setKeysConfigured] = react.useState({});
288
+ // key 存储位置:credentials(凭据中心,默认)| settings(设置页,兼容旧行为)
289
+ const [keyStorage, setKeyStorage] = react.useState("credentials");
290
+ // 凭据中心里已配置的 key(describe 不返回,需单独查)
291
+ const [credConfigured, setCredConfigured] = react.useState({});
224
292
  const [lang, setLang] = react.useState("zh");
225
293
  const [dirty, setDirty] = react.useState(false);
226
294
  const [saving, setSaving] = react.useState(false);
@@ -262,7 +330,21 @@ window.__ModuleLoader__.load({
262
330
  }
263
331
  }
264
332
  setKeysConfigured(configured);
333
+ // key 存储位置(默认凭据中心)
334
+ setKeyStorage(v.keyStorage === "settings" ? "settings" : "credentials");
265
335
  setState({ status: "ready", writable: result.value.writable });
336
+ // 查询凭据中心里各 key 的配置状态
337
+ try {
338
+ const cred = await bridgeCredentialsStatus();
339
+ if (cred.ok) {
340
+ const cc = {};
341
+ const map = { exaApiKey: "exa", tavilyApiKey: "tavily", keenableApiKey: "keenable", perplexityApiKey: "perplexity", deepseekApiKey: "deepseek", geminiApiKey: "gemini" };
342
+ for (const [k, v] of Object.entries(cred.value.configured ?? {})) {
343
+ if (map[k]) cc[map[k]] = v;
344
+ }
345
+ setCredConfigured(cc);
346
+ }
347
+ } catch {}
266
348
  } else {
267
349
  setState({ status: "unavailable" });
268
350
  }
@@ -288,18 +370,36 @@ window.__ModuleLoader__.load({
288
370
  setSaving(true);
289
371
  setFailed(false);
290
372
  try {
373
+ // key 存储分流:凭据中心(默认)走 credentials-set;设置页走 settings mutate(兼容)
374
+ const keyFields = [
375
+ ["exaApiKey", exaKey],
376
+ ["tavilyApiKey", tavilyKey],
377
+ ["keenableApiKey", keenableKey],
378
+ ["perplexityApiKey", perplexityKey],
379
+ ["deepseekApiKey", deepseekKey],
380
+ ["geminiApiKey", geminiKey],
381
+ ];
382
+ let credFailed = false;
383
+ if (keyStorage === "credentials") {
384
+ for (const [field, value] of keyFields) {
385
+ if (!value.trim()) continue;
386
+ const r = await bridgeCredentialsSet(field, value.trim());
387
+ if (!r.ok) credFailed = true;
388
+ }
389
+ }
291
390
  const ops = [{ op: "set", path: ["provider"], value: provider }];
292
391
  ops.push({ op: "set", path: ["lang"], value: lang });
293
- if (exaKey.trim()) ops.push({ op: "set", path: ["exaApiKey"], value: exaKey.trim() });
294
- if (tavilyKey.trim()) ops.push({ op: "set", path: ["tavilyApiKey"], value: tavilyKey.trim() });
295
- if (keenableKey.trim()) ops.push({ op: "set", path: ["keenableApiKey"], value: keenableKey.trim() });
296
- if (perplexityKey.trim()) ops.push({ op: "set", path: ["perplexityApiKey"], value: perplexityKey.trim() });
297
- if (deepseekKey.trim()) ops.push({ op: "set", path: ["deepseekApiKey"], value: deepseekKey.trim() });
298
- if (geminiKey.trim()) ops.push({ op: "set", path: ["geminiApiKey"], value: geminiKey.trim() });
392
+ ops.push({ op: "set", path: ["keyStorage"], value: keyStorage });
393
+ if (keyStorage !== "credentials") {
394
+ // settings 模式:key 仍写 settings.yaml(旧行为)
395
+ for (const [field, value] of keyFields) {
396
+ if (value.trim()) ops.push({ op: "set", path: [field], value: value.trim() });
397
+ }
398
+ }
299
399
  ops.push({ op: "set", path: ["platforms"], value: platforms });
300
400
  ops.push({ op: "set", path: ["cacheTtl"], value: Math.min(Math.max(Number(cacheTtl) ?? 5, 0), 5) });
301
401
  const result = await bridgeMutate({ ns: NS, ops });
302
- if (result.ok) {
402
+ if (result.ok && !credFailed) {
303
403
  setDirty(false);
304
404
  setProvider(result.value.value.provider ?? provider);
305
405
  setFailed(false);
@@ -452,6 +552,7 @@ react_jsx_runtime.jsx("div", {
452
552
  react_jsx_runtime.jsx("select", {
453
553
  className: "dshfs-select",
454
554
  value: provider,
555
+ style: { colorScheme: selectColorScheme },
455
556
  disabled: !ready || saving,
456
557
  onChange: (e) => select(e.target.value),
457
558
  children: ENGINES.map((engine) =>
@@ -559,6 +660,34 @@ react_jsx_runtime.jsx("div", {
559
660
  className: "dshfs-hint",
560
661
  children: t.keysHint,
561
662
  }),
663
+ react_jsx_runtime.jsx("div", {
664
+ className: "dshfs-fieldRow",
665
+ children: [
666
+ react_jsx_runtime.jsx("label", {
667
+ className: "dshfs-label",
668
+ children: t.keyStorage,
669
+ }),
670
+ react_jsx_runtime.jsx("select", {
671
+ className: "dshfs-select dshfs-keyStorage",
672
+ value: keyStorage,
673
+ style: { colorScheme: selectColorScheme },
674
+ disabled: !ready || saving,
675
+ onChange: (e) => {
676
+ setKeyStorage(e.target.value);
677
+ setDirty(true);
678
+ setFailed(false);
679
+ },
680
+ children: [
681
+ react_jsx_runtime.jsx("option", { value: "credentials", children: t.keyStorageCred }),
682
+ react_jsx_runtime.jsx("option", { value: "settings", children: t.keyStorageSettings }),
683
+ ],
684
+ }),
685
+ ],
686
+ }),
687
+ react_jsx_runtime.jsx("p", {
688
+ className: "dshfs-hint",
689
+ children: keyStorage === "credentials" ? t.keyStorageCredHint(credConfigured) : t.keyStorageSettingsHint,
690
+ }),
562
691
  ],
563
692
  }),
564
693
  react_jsx_runtime.jsx("div", {
package/lib/index.js CHANGED
@@ -28,7 +28,7 @@ const GEMINI_MODEL = "gemini-2.5-flash";
28
28
  const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`;
29
29
 
30
30
  // 当前插件版本(发布时与 package.json 同步)
31
- const PLUGIN_VERSION = "0.5.4";
31
+ const PLUGIN_VERSION = "0.5.5";
32
32
  // 检查更新的 npm registry 元数据地址(@darlingc/dsh-freesearch 是 npmjs 上的公开包)
33
33
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/@darlingc%2Fdsh-freesearch/latest";
34
34
  const PLUGIN_NPM_URL = "https://www.npmjs.com/package/@darlingc/dsh-freesearch";
@@ -1185,7 +1185,7 @@ function toView(descriptor) {
1185
1185
  };
1186
1186
  }
1187
1187
 
1188
- function makeBridgeRoutes(settings, search, testEngine) {
1188
+ function makeBridgeRoutes(settings, search, testEngine, getCredentials) {
1189
1189
  const allowlisted = () =>
1190
1190
  settings
1191
1191
  .describe({ redactSecrets: true })
@@ -1338,6 +1338,52 @@ function makeBridgeRoutes(settings, search, testEngine) {
1338
1338
  }
1339
1339
  return { ok: true, value: toView(descriptor) };
1340
1340
  },
1341
+ // 凭据中心:查询各引擎 key 的配置状态(value 不返回,只返回是否已配置)
1342
+ async credentialsStatus() {
1343
+ const credentials = getCredentials();
1344
+ if (!credentials) return { ok: false, code: "credentials-unavailable", message: "credentials service is not available" };
1345
+ const configured = {};
1346
+ for (const [settingsKey, ref] of Object.entries(KEY_REF_MAP)) {
1347
+ try {
1348
+ const info = await credentials.describe(ref);
1349
+ configured[settingsKey] = info !== undefined && info.configured === true;
1350
+ } catch {
1351
+ configured[settingsKey] = false;
1352
+ }
1353
+ }
1354
+ return { ok: true, value: { configured, available: true } };
1355
+ },
1356
+ // 凭据中心:写入一个引擎 key(ref 白名单限定)
1357
+ async credentialsSet(request) {
1358
+ const credentials = getCredentials();
1359
+ if (!credentials) return { ok: false, code: "credentials-unavailable", message: "credentials service is not available" };
1360
+ const { key, value } = request ?? {};
1361
+ const ref = KEY_REF_MAP[key];
1362
+ if (!ref) return { ok: false, code: "credentials-rejected", message: `unknown credential key "${key}"` };
1363
+ if (typeof value !== "string" || value.trim().length === 0) {
1364
+ return { ok: false, code: "credentials-rejected", message: "value is required" };
1365
+ }
1366
+ try {
1367
+ await credentials.set(ref, value.trim());
1368
+ return { ok: true, value: { ref, set: true } };
1369
+ } catch (error) {
1370
+ return { ok: false, code: "credentials-write-failed", message: error instanceof Error ? error.message : String(error) };
1371
+ }
1372
+ },
1373
+ // 凭据中心:删除一个引擎 key
1374
+ async credentialsUnset(request) {
1375
+ const credentials = getCredentials();
1376
+ if (!credentials) return { ok: false, code: "credentials-unavailable", message: "credentials service is not available" };
1377
+ const { key } = request ?? {};
1378
+ const ref = KEY_REF_MAP[key];
1379
+ if (!ref) return { ok: false, code: "credentials-rejected", message: `unknown credential key "${key}"` };
1380
+ try {
1381
+ await credentials.unset(ref);
1382
+ return { ok: true, value: { ref, set: false } };
1383
+ } catch (error) {
1384
+ return { ok: false, code: "credentials-write-failed", message: error instanceof Error ? error.message : String(error) };
1385
+ }
1386
+ },
1341
1387
  };
1342
1388
 
1343
1389
  const guard = (req, res) => {
@@ -1374,6 +1420,40 @@ function makeBridgeRoutes(settings, search, testEngine) {
1374
1420
  writeJson(res, 200, await handlers.mutate(body));
1375
1421
  },
1376
1422
  },
1423
+ {
1424
+ kind: "exact",
1425
+ path: `${BRIDGE_PREFIX}/credentials-status`,
1426
+ handler: async (req, res) => {
1427
+ if (!guard(req, res)) return;
1428
+ writeJson(res, 200, await handlers.credentialsStatus());
1429
+ },
1430
+ },
1431
+ {
1432
+ kind: "exact",
1433
+ path: `${BRIDGE_PREFIX}/credentials-set`,
1434
+ handler: async (req, res) => {
1435
+ if (!guard(req, res)) return;
1436
+ const body = await readJsonBody(req);
1437
+ if (body === undefined) {
1438
+ writeJson(res, 400, { ok: false, code: "credentials-rejected", message: "malformed JSON body" });
1439
+ return;
1440
+ }
1441
+ writeJson(res, 200, await handlers.credentialsSet(body));
1442
+ },
1443
+ },
1444
+ {
1445
+ kind: "exact",
1446
+ path: `${BRIDGE_PREFIX}/credentials-unset`,
1447
+ handler: async (req, res) => {
1448
+ if (!guard(req, res)) return;
1449
+ const body = await readJsonBody(req);
1450
+ if (body === undefined) {
1451
+ writeJson(res, 400, { ok: false, code: "credentials-rejected", message: "malformed JSON body" });
1452
+ return;
1453
+ }
1454
+ writeJson(res, 200, await handlers.credentialsUnset(body));
1455
+ },
1456
+ },
1377
1457
  {
1378
1458
  kind: "exact",
1379
1459
  path: `${BRIDGE_PREFIX}/check-update`,
@@ -1410,10 +1490,21 @@ function makeBridgeRoutes(settings, search, testEngine) {
1410
1490
  const name = "web-search-free";
1411
1491
  const inject = ["web"];
1412
1492
 
1493
+ // 引擎 key 与凭据中心 ref 的映射(白名单:只允许这些 ref 被 UI 读写凭据中心)
1494
+ const KEY_REF_MAP = {
1495
+ exaApiKey: "EXA_API_KEY",
1496
+ tavilyApiKey: "TAVILY_API_KEY",
1497
+ keenableApiKey: "KEENABLE_API_KEY",
1498
+ perplexityApiKey: "PERPLEXITY_API_KEY",
1499
+ deepseekApiKey: "DEEPSEEK_API_KEY",
1500
+ geminiApiKey: "GEMINI_API_KEY",
1501
+ };
1502
+
1413
1503
  const Config = z.object({
1414
1504
  provider: z.string().default("bing"),
1415
1505
  cache: z.boolean().default(true), // 单 query 结果缓存开关(防限流/省额度)
1416
1506
  cacheTtl: z.number().default(5), // 缓存时长(分钟),0-5 可配置(使用处再 clamp)
1507
+ keyStorage: z.string().default("credentials"), // key 存储位置:credentials(凭据中心)| settings(设置页)
1417
1508
  lang: z.string().default("zh"),
1418
1509
  region: z.string(),
1419
1510
  bingMarket: z.string().default("zh-CN"),
@@ -1430,7 +1521,8 @@ const Config = z.object({
1430
1521
  function apply(ctx, config) {
1431
1522
  let current = () => config ?? {};
1432
1523
  const logger = ctx.logger;
1433
- const credentials = ctx.get("credentials");
1524
+ // credentials 服务可能晚于本插件挂载(跨 bundle 顺序),运行期动态获取而不是 apply 时缓存
1525
+ const getCredentials = () => ctx.get("credentials");
1434
1526
 
1435
1527
  // 系统提示词动态刷新:设置变更时重新生成,避免显示旧引擎
1436
1528
  let refreshPrompt = null;
@@ -1438,16 +1530,17 @@ function apply(ctx, config) {
1438
1530
  // 单 query 结果缓存(provider.search 内闭包持有):LRU 50 条 / TTL 可配置
1439
1531
  const searchCache = new Map(); // key -> { value, expiresAt }
1440
1532
 
1441
- // key 优先级:settings 的 free-search.<x>ApiKey > 环境变量/credentials
1533
+ // key 优先级:credentials(.credentials.yaml 凭据中心,官方推荐)> settings 的 free-search.<x>ApiKey(遗留兼容)> 环境变量
1442
1534
  const resolveApiKey = async (envName, settingsKey) => {
1443
- const cfg = current();
1444
- if (settingsKey && cfg[settingsKey]) return cfg[settingsKey];
1535
+ const credentials = getCredentials();
1445
1536
  if (credentials) {
1446
1537
  try {
1447
1538
  const resolved = await credentials.resolve(envName);
1448
1539
  if (resolved?.value) return resolved.value;
1449
1540
  } catch {}
1450
1541
  }
1542
+ const cfg = current();
1543
+ if (settingsKey && cfg[settingsKey]) return cfg[settingsKey];
1451
1544
  return process.env[envName] ?? "";
1452
1545
  };
1453
1546
 
@@ -1655,7 +1748,8 @@ function apply(ctx, config) {
1655
1748
  const disposers = makeBridgeRoutes(
1656
1749
  sctx.settings,
1657
1750
  (request) => provider.search(request, undefined),
1658
- (engine, query, timeRange) => runEngineTest(engine, query, timeRange)
1751
+ (engine, query, timeRange) => runEngineTest(engine, query, timeRange),
1752
+ getCredentials
1659
1753
  ).map((route) => sctx.webServer.register(route));
1660
1754
  return () => {
1661
1755
  for (const dispose of disposers) dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@darlingc/dsh-freesearch",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Free web search for DeepSeek Harness: 11 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable keyless + Gemini grounding) + time filtering + platform search + web_fetch, with web settings UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",
42
- "url": "https://github.com/DarlingC/dsh-freesearch.git"
42
+ "url": "git+https://github.com/DarlingC/dsh-freesearch.git"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=20"
@@ -72,6 +72,9 @@
72
72
  "@deepseek-ai/dsh-client-runtime"
73
73
  ],
74
74
  "platform": "web"
75
+ },
76
+ "engines": {
77
+ "dsh": ">=0.1.1-rc.1"
75
78
  }
76
79
  }
77
80
  }