dsh-workspace-api-key 0.2.10 → 0.2.12

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
@@ -21,7 +21,8 @@ session key → workspace key → system default
21
21
  - **Mismatch warning** — if the key you bound was recorded for one provider (say `deepseek-official`) and the session now runs on another, entering that session reports it, because that request would be sent with a key the provider does not accept.
22
22
  - **No silent substitution** — neither warning ever changes which key is used. The decision stays with you, which is the whole point: an automatically swapped key would defeat the billing separation.
23
23
  - **One-click revert** — clearing a session entry falls back to the workspace key, clearing a workspace entry falls back to the system default.
24
- - **A search default key of its own** — the `搜索默认 key` card binds a key to `DEEPSEEK_API_KEY_SEARCH`, so search can be billed apart from chat without editing the official configuration: a session's own key wins, then a synced workspace's chat cascade, then this key, then whatever the official search page stores.
24
+ - **A search default key of its own** — the `搜索默认 key` card binds a key to `DEEPSEEK_API_KEY_SEARCH`, so search can be billed apart from chat: a session's own key wins, then a synced workspace's chat cascade, then this key, then whatever the official search page stores.
25
+ - **Takes over the official search key field** — on startup, while the new Web Search API is on, the plugin points the official `web-deepseek-api` / `web-search-deepseek` entry at `DEEPSEEK_API_KEY_SEARCH` (only that `apiKeyEnv` pointer, never a key value). The `设置 → 插件 → 网页搜索` field then edits the same search key this panel does, so it can no longer overwrite your chat key, and there is no second place where a search key can hide.
25
26
  - **A search switch per workspace** — every workspace row carries a `同步搜索 API` checkbox. Ticked, the `web_search` / `web_fetch` calls made by sessions in that directory use the key they would use for chat (`session → workspace → system default`); unticked, they keep the key the official 设置 → 插件 → 网页搜索 page manages. A session with its own key always wins, so "sync on, session configured separately" spends the session key, not the search key.
26
27
  - **Search warnings that arrive before the failure** — when a search key is configured but `启用新版 Web 搜索 API` is off, or when the new API is on but its key ref is empty, the panel shows a warning banner and the entering-session overlay says it too, in one line you can act on. The overlay stays quiet about search problems when the search provider is not the one in use.
27
28
  - **A usage guide** — `使用说明` at the top of the panel recommends two keys: a chat default (`default`) and a search default (`search-default`), so the two budgets stay separate; it also draws the cascade and lists the situations that trigger a warning.
@@ -78,7 +79,7 @@ The command forwards its arguments to pnpm inside the profile directory, so the
78
79
  pnpm add dsh-workspace-api-key # or: npm install dsh-workspace-api-key
79
80
  ```
80
81
 
81
- Pin a version with `dsh-workspace-api-key@0.2.10`, or install straight from the repository with `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key` (a git-hosted plugin is built from source on install).
82
+ Pin a version with `dsh-workspace-api-key@0.2.12`, or install straight from the repository with `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key` (a git-hosted plugin is built from source on install).
82
83
 
83
84
  Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `package.json` and restart DSH. For local development, use a link instead:
84
85
 
@@ -89,8 +90,8 @@ Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `p
89
90
  ## Tests
90
91
 
91
92
  ```sh
92
- node test/host.test.mjs # 165 assertions
93
- node test/client.test.mjs # 170 assertions
93
+ node test/host.test.mjs # 173 assertions
94
+ node test/client.test.mjs # 173 assertions
94
95
  ```
95
96
 
96
97
  Neither suite needs a running DSH process. The host suite drives `apply()` against a mock context (fake `credentials`, `workspaceRegistry`, `agents`, `sessionController`, `sessions`, `webServer`, stubbed `fetch`) and covers ref derivation, the session → workspace → default cascade, workspace matching, redirecting a second adapter's `apiKeyEnv`, clearing back to the default, 401/402/429/5xx classification, the loopback and origin checks, state persistence, the session probe including the `{items}` envelope, the live-session fallback, and subagent and blank sessions being hidden, and the search scope with a stand-in `web` service and a stand-in settings service: the legacy route keeping the chat cascade, the new provider keeping its own key, the workspace switch taking over, a session key outranking both, the panel's own search default key being used and cleared, the three search warnings, and the search connection test. The client suite loads `lib/client.js` through a minimal `window.__ModuleLoader__` plus a small React runtime, renders the panel and the overlay, and asserts slot registration, the first render, session expand/collapse, per-session saving and testing, sessions recovered from the browser-side store when the host has none (with `displayTitle` winning and subagent sessions hidden), the ungrouped-directory card, the stale and mismatch prompts, the "go configure" hand-off, graceful degradation without a session list, back-navigation when `ctx.get('layout')` only becomes available after the plugin was applied, and the search UI: the switch on every workspace row, the `/wsk-api/search-sync` call it makes, the warning banner, and the usage dialog.
package/README.zh.md CHANGED
@@ -21,7 +21,8 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
21
21
  - **不匹配提醒** —— 如果绑 key 时记下的是某个服务商(比如 `deepseek-official`),而该会话现在跑在另一个服务商上,点进这个会话时会提醒你,因为那次请求会拿着对方不认的 key 发出去。
22
22
  - **绝不悄悄换 key** —— 两种提醒都不会改动实际使用的 key,决定权始终在你手上;自动替换会直接破坏「按会话分离计费」这个初衷。
23
23
  - **一键回退** —— 清空会话条目回到工作区 key,清空工作区条目回到系统默认。
24
- - **搜索默认 key 单独一把** —— 「搜索默认 key」卡片把这把 key 绑到 `DEEPSEEK_API_KEY_SEARCH`,于是搜索可以和聊天分开计费,而且不用去改官方配置:会话自己的 key 最优先,其次勾了同步的工作区走聊天层级,再次是这把搜索默认 key,最后才是官方搜索页里配的那把。
24
+ - **搜索默认 key 单独一把** —— 「搜索默认 key」卡片把这把 key 绑到 `DEEPSEEK_API_KEY_SEARCH`,于是搜索可以和聊天分开计费:会话自己的 key 最优先,其次勾了同步的工作区走聊天层级,再次是这把搜索默认 key,最后才是官方搜索页里配的那把。
25
+ - **接管官方搜索 key 设置** —— 启动时若已启用新版 Web 搜索 API,插件把官方 `web-deepseek-api` / `web-search-deepseek` 条目的 `apiKeyEnv` 指到 `DEEPSEEK_API_KEY_SEARCH`(只改这一个指向,永远不动 key 内容)。此后「设置 → 插件 → 网页搜索」里填的就是本面板这把搜索 key,既不会再顶掉聊天 key,也不存在第二处藏搜索 key 的地方。
25
26
  - **每个工作区一个搜索开关** —— 每个工作区行上都有「同步搜索 API」复选框。勾选后,该目录下会话发出的 `web_search` / `web_fetch` 请求改用它们聊天时用的那把 key(会话 → 工作区 → 系统默认);不勾选则继续用官方「设置 → 插件 → 网页搜索」里配置的那把 key。会话自己配了 key 时永远优先,所以「工作区同步、会话单独设置」花的是会话那把 key,而不是搜索默认 key。
26
27
  - **在失败之前就提醒的搜索告警** —— 配了搜索 key 但没打开「启用新版 Web 搜索 API」,或者新版 API 打开了但它的 key ref 是空的,面板会显示提醒条,点进会话时的覆盖层也会用一句可直接照做的话说明;当生效的搜索通道不是新版 API 时,覆盖层不会拿搜索问题打扰你。
27
28
  - **使用说明** —— 面板顶部有「使用说明」,推荐两把 key 分工:聊天默认用 `default`,搜索默认用 `search-default`,两份预算互不干扰;里面还画了层级,并列出了会触发提醒的几种情况。
@@ -78,7 +79,7 @@ dsh plugin --profile web add dsh-workspace-api-key
78
79
  pnpm add dsh-workspace-api-key # 或者:npm install dsh-workspace-api-key
79
80
  ```
80
81
 
81
- 锁版本用 `dsh-workspace-api-key@0.2.10`;直接从仓库装则用 `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key`(git 来源的插件会在安装时现场构建)。
82
+ 锁版本用 `dsh-workspace-api-key@0.2.12`;直接从仓库装则用 `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key`(git 来源的插件会在安装时现场构建)。
82
83
 
83
84
  然后把 `"dsh-workspace-api-key"` 加进该 profile `package.json` 的 `dsh.profile.bundles`,重启 DSH。本地开发用 link 代替:
84
85
 
@@ -89,8 +90,8 @@ pnpm add dsh-workspace-api-key # 或者:npm install dsh-workspace-api
89
90
  ## 测试
90
91
 
91
92
  ```sh
92
- node test/host.test.mjs # 165 条断言
93
- node test/client.test.mjs # 170 条断言
93
+ node test/host.test.mjs # 173 条断言
94
+ node test/client.test.mjs # 173 条断言
94
95
  ```
95
96
 
96
97
  两个套件都不需要启动 DSH。宿主套件用一个假 ctx(假的 `credentials`、`workspaceRegistry`、`agents`、`sessionController`、`sessions`、`webServer`,以及打桩的 `fetch`)驱动 `apply()`,覆盖 ref 派生、会话→工作区→默认的级联、工作区匹配、把第二个适配器的 `apiKeyEnv` 一起重定向、清回默认、401/402/429/5xx 的判定、回环与来源校验、状态落盘,以及会话探针(含 `{items}` 信封与 live 兜底)。浏览器套件通过一个极小的 `window.__ModuleLoader__` 与自制 React 运行时加载 `lib/client.js`,渲染出面板与覆盖层,断言槽注册、首屏渲染、会话折叠/展开、会话级保存与测试、宿主列不出会话时从浏览器端存储恢复会话(标题取 `displayTitle`、隐藏子代理会话与空会话、浏览器端只认识一部分会话时不挤掉宿主行)、会话列表服务晚于插件提供时的重试、未分组目录卡片、失效与不匹配提醒、「去配置」的交接、没有会话列表时的降级,以及 `ctx.get('layout')` 在插件 apply 之后才可用时返回键仍然有效。宿主套件还带一个假的 `web` 服务与假的设置服务,验证搜索作用域:旧通道沿用聊天层级、新版通道用它自己的 key、工作区开关接管、会话 key 压过两者、面板自己的搜索默认 key 生效与清除、三类搜索提醒,以及搜索连接测试。浏览器套件覆盖搜索相关界面:每个工作区行的「同步搜索 API」复选框、它发出的 `/wsk-api/search-sync`、提醒条与「使用说明」弹窗。
package/lib/client.js CHANGED
@@ -676,12 +676,39 @@ window.__ModuleLoader__.load({
676
676
  applyState(next)
677
677
  setSearchDraft('')
678
678
  if (trimmed === '') setSearchOpen(false)
679
- setNotice({
680
- kind: 'ok',
681
- text: trimmed === ''
682
- ? '搜索默认 key 已清除:搜索改用官方搜索页里配的那把 key。'
683
- : '搜索默认 key 已保存:搜索请求优先用它(工作区同步与会话单独配置仍然优先)。',
684
- })
679
+ const bound = next?.bound
680
+ if (trimmed === '') {
681
+ setNotice({ kind: 'ok', text: '搜索默认 key 已清除:搜索改用官方搜索页里配的那把 key。' })
682
+ } else if (bound?.ok === false) {
683
+ setNotice({
684
+ kind: 'warn',
685
+ text: `key 已保存在 ${next?.search?.defaultRef ?? '搜索默认 ref'},但没能改官方设置(${bound.reason}):`
686
+ + '官方「网页搜索」页仍指向原来的 ref,两处暂时不是同一把。',
687
+ })
688
+ } else {
689
+ setNotice({
690
+ kind: 'ok',
691
+ text: `搜索默认 key 已保存到 ${next?.search?.defaultRef ?? '搜索默认 ref'},`
692
+ + '官方「网页搜索」页的 API Key 输入框也指向这个 ref:以后两边改的是同一把 key。',
693
+ })
694
+ }
695
+ } catch (err) {
696
+ if (alive.current) setNotice({ kind: 'bad', text: `${err?.message ?? err}` })
697
+ } finally {
698
+ if (alive.current) setBusy(false)
699
+ }
700
+ }
701
+
702
+ // 只把官方网页搜索页的 ref 指到插件自有 ref(key 已经在库里,只是官方页看不到)。
703
+ const syncSearchDefault = async () => {
704
+ setBusy(true)
705
+ try {
706
+ const next = await api('/set', { method: 'POST', body: JSON.stringify({ scope: 'search-default', adopt: true }) })
707
+ applyState(next)
708
+ const bound = next?.bound
709
+ setNotice(bound?.ok === false
710
+ ? { kind: 'warn', text: `没能改官方设置:${bound.reason}` }
711
+ : { kind: 'ok', text: `已同步:官方「网页搜索」页现在绑定 ${next?.search?.officialRef ?? next?.search?.defaultRef ?? '同一个 ref'},与面板改的是同一把 key。` })
685
712
  } catch (err) {
686
713
  if (alive.current) setNotice({ kind: 'bad', text: `${err?.message ?? err}` })
687
714
  } finally {
@@ -1057,7 +1084,9 @@ window.__ModuleLoader__.load({
1057
1084
  h('li', null, '先到「设置 → 模型」配一把系统默认 key,例如就叫 ', h('code', null, 'default'),
1058
1085
  '。没有单独配置的地方都用它聊天。'),
1059
1086
  h('li', null, '再回到本页的「搜索默认 key」,放一把搜索专用的 key,例如 ', h('code', null, 'search-default'),
1060
- '。搜索请求默认用它,和聊天那把分开计费。'),
1087
+ '。搜索请求默认用它,和聊天那把分开计费。',
1088
+ '保存时插件会把官方「设置 → 插件 → 网页搜索」里的 API Key 指向同一个 ref,',
1089
+ '所以那个页面显示、修改的都是这把 key,两边不会各存一份。'),
1061
1090
  h('li', null, '平时不用再管:聊天按工作区/会话的 key 走,搜索走搜索默认 key,两边账单互不影响。')),
1062
1091
  h('h4', null, '想按工作区核算搜索花费'),
1063
1092
  h('ul', null,
@@ -1071,11 +1100,22 @@ window.__ModuleLoader__.load({
1071
1100
  h('li', null, '搜索默认 key —— 没勾同步时,搜索用它'),
1072
1101
  h('li', null, '官方「网页搜索」页里配的那把 —— 面板没配搜索默认 key 时的兜底',
1073
1102
  '(它默认就是系统默认 key ', h('code', null, 'DEEPSEEK_API_KEY'), ',与聊天共用)')),
1103
+ h('h4', null, '官方「设置 → 插件 → 网页搜索」那个 Key 框怎么办'),
1104
+ h('div', { className: 'wsk-hint' },
1105
+ '官方那个输入框写的永远是「当前 API 段的 ', h('code', null, 'apiKeyEnv'),
1106
+ ' 指向的 ref」,默认就是聊天那把(', h('code', null, 'DEEPSEEK_API_KEY'), ')。',
1107
+ '本插件启动后会把它指到插件自己的搜索 ref(', h('code', null, 'DEEPSEEK_API_KEY_SEARCH'), '),',
1108
+ '等于把官方那个 key 设置接管过来:从今往后官方页里填的也是这把搜索 key,',
1109
+ '不会再把聊天 key 顶掉,也不会出现「两把搜索 key 各管一半」。'),
1110
+ h('div', { className: 'wsk-hint' },
1111
+ '所以日常只需要在本页配搜索 key,官方那一页可以不管;本插件只改 ',
1112
+ h('code', null, 'apiKeyEnv'), ' 这个指向,永远不动你的 key 内容。'),
1074
1113
  h('h4', null, '会被提醒的几种情况'),
1075
1114
  h('ul', null,
1076
1115
  h('li', null, '配了搜索 key,但没打开「启用新版 Web 搜索 API」→ 那把 key 其实不会被用到,面板顶部会提示。'),
1077
1116
  h('li', null, '打开了新版搜索 API,但既没有搜索默认 key、官方那把也是空的 → 任何搜索都会失败,面板顶部会提示。'),
1078
- h('li', null, '勾了同步,但该工作区既没有专属 key、系统默认 key 也没配 → 会提示先去配一把。')),
1117
+ h('li', null, '勾了同步,但该工作区既没有专属 key、系统默认 key 也没配 → 会提示先去配一把。'),
1118
+ h('li', null, '搜索默认 key 存好了,但官方页还指着聊天那把 → 会提示点「同步到官方设置」,一键对齐。')),
1079
1119
  h('h4', null, '原则'),
1080
1120
  h('div', { className: 'wsk-hint' },
1081
1121
  '提醒只报告、不替你改 key。插件绝不会自动切换或替换你的 key,避免计费被悄悄改到别的账号上。')),
@@ -1234,10 +1274,22 @@ window.__ModuleLoader__.load({
1234
1274
  : h('span', { key: 's', className: 'wsk-badge' }, '未配置')),
1235
1275
  h('div', { className: 'wsk-hint' },
1236
1276
  '搜索请求默认用它,和聊天那把 key 分开计费。'
1237
- + '工作区勾了「同步搜索 API」时改用该工作区的聊天层级;会话单独配置永远最优先。',
1277
+ + '工作区勾了「同步搜索 API」时改用该工作区的聊天层级;会话单独配置永远最优先。'),
1278
+ h('div', { className: 'wsk-hint' },
1238
1279
  searchInfo.defaultConfigured === true && searchInfo.defaultKeyMasked
1239
- ? ` 当前值:${searchInfo.defaultKeyMasked}`
1240
- : '')),
1280
+ ? `当前值:${searchInfo.defaultKeyMasked}。`
1281
+ : '',
1282
+ searchInfo.officialShared === true
1283
+ ? '官方「网页搜索」页现在仍指着聊天那把 key(DEEPSEEK_API_KEY)——保存一次搜索 key 后,插件会把它接过来。'
1284
+ : `官方「网页搜索」页已由本插件接管:那里填的也是 ${searchInfo.officialRef ?? 'DEEPSEEK_API_KEY_SEARCH'} 这一把,不会再误改聊天 key。`),
1285
+ searchInfo.officialShared === true && searchInfo.ownedConfigured === true
1286
+ ? h('div', { className: 'wsk-hint' },
1287
+ '你之前保存过搜索默认 key,但官方页还指向聊天那把。',
1288
+ h('button', {
1289
+ type: 'button', className: 'wsk-btn', style: { marginLeft: 6 }, disabled: busy,
1290
+ onClick: () => syncSearchDefault(),
1291
+ }, '同步到官方设置'))
1292
+ : null),
1241
1293
  h('div', { className: 'wsk-actions' },
1242
1294
  h('button', {
1243
1295
  type: 'button', className: 'wsk-btn', disabled: busy,
package/lib/index.js CHANGED
@@ -247,6 +247,78 @@ function workspaceSynced(state, cwd) {
247
247
  return state.searchSync?.[norm] === true
248
248
  }
249
249
 
250
+ /**
251
+ * 官方「设置 → 插件 → 网页搜索」页那个 API Key 输入框绑定到的 ref。
252
+ *
253
+ * 官方页写的就是 provider 配置里的 `apiKeyEnv`(没写时是 `DEEPSEEK_API_KEY`),
254
+ * 所以想让「面板里的搜索默认 key」与「官方页里那把」是同一个值,就得让 `apiKeyEnv` 指过来。
255
+ */
256
+ function officialSearchRef(web) {
257
+ return web.apiChannel ? web.apiKeyEnv : web.legacyKeyEnv
258
+ }
259
+
260
+ /**
261
+ * 面板「搜索默认 key」实际落在哪个 ref。
262
+ *
263
+ * 官方还指向聊天那把(`DEEPSEEK_API_KEY`)时,用插件自有的 `DEEPSEEK_API_KEY_SEARCH` 顶替——
264
+ * 否则保存搜索 key 会把聊天 key 覆盖掉。官方指向别处(含已被本插件改过)时,就直接用官方那个 ref,
265
+ * 于是官方页和面板改的是同一把 key。
266
+ */
267
+ function searchDefaultRefOf(web) {
268
+ const official = officialSearchRef(web)
269
+ return official === DEFAULT_API_KEY_REF ? SEARCH_DEFAULT_REF : official
270
+ }
271
+
272
+ /**
273
+ * 把官方两套搜索插件的 `apiKeyEnv` 都指向同一个 ref。
274
+ *
275
+ * 这样「面板里保存的搜索默认 key」就是「官方网页搜索页里的那把 key」:官方页的输入框写同一个 ref,
276
+ * 两边不再各存一份。settings.update 只改 volatile 字段,失败就原样返回,绝不影响 key 本身。
277
+ */
278
+ async function bindOfficialSearchRef(ctx, ref) {
279
+ let settings
280
+ try {
281
+ settings = ctx.get('settings')
282
+ } catch {
283
+ settings = undefined
284
+ }
285
+ if (typeof settings?.update !== 'function' || typeof settings?.describe !== 'function') {
286
+ return { ok: false, reason: 'settings-unavailable', changed: [] }
287
+ }
288
+ const changed = []
289
+ for (const ns of [SEARCH_API_NS, LEGACY_SEARCH_NS]) {
290
+ try {
291
+ const row = (settings.describe() ?? []).find((item) => item?.ns === ns)
292
+ if (row === undefined) continue
293
+ if (row.value?.apiKeyEnv === ref) continue
294
+ await settings.update(ns, { apiKeyEnv: ref }, row.revision)
295
+ changed.push(ns)
296
+ } catch (error) {
297
+ return { ok: false, reason: error?.message ?? String(error), changed }
298
+ }
299
+ }
300
+ return { ok: true, changed }
301
+ }
302
+
303
+ /**
304
+ * 启动时接管官方「网页搜索」页的 key 设置。
305
+ *
306
+ * 官方页那个 Key 输入框写的永远是「当前 API 段的 apiKeyEnv 指向的 ref」。用户要求
307
+ * 「直接禁用官方页面里的搜索 key 设置」——所以这里把它指到插件自有的 `DEEPSEEK_API_KEY_SEARCH`:
308
+ * 从那以后官方页填的就是插件这把搜索 key,既不会误改聊天 key,也不再存在第二把搜索 key。
309
+ *
310
+ * 只在「已启用新版 API 且官方仍指着聊天那把」时做一次;settings 不可写就静默跳过
311
+ * (面板里另有手动按钮兜底),绝不因为改配置失败而影响任何 key。
312
+ */
313
+ async function adoptOfficialSearchRef(ctx) {
314
+ const web = readWebSettings(ctx)
315
+ if (web.apiChannel !== true) return
316
+ if (officialSearchRef(web) !== DEFAULT_API_KEY_REF) return
317
+ const bound = await bindOfficialSearchRef(ctx, SEARCH_DEFAULT_REF)
318
+ if (bound.ok !== true) log('adopt official search ref failed: %s', bound.reason)
319
+ else if (bound.changed.length > 0) log('adopted official search key settings: %s', bound.changed.join(', '))
320
+ }
321
+
250
322
  /**
251
323
  * 「这个会话/目录的搜索请求会去读哪个 ref」——纯计算,不解析凭据。
252
324
  * 新版 API 通道:会话单独 key → 工作区勾了同步 ? (工作区 key / 系统默认): 搜索插件自己的 ref
@@ -261,9 +333,10 @@ async function searchRefFor(ctx, state, { sessionId, cwd, web, searchDefault })
261
333
  const hit = effectiveOverride(ctx, state, { sessionId, cwd })
262
334
  return hit.ref ?? DEFAULT_API_KEY_REF
263
335
  }
264
- // 没同步:插件自己的「搜索默认 key」优先,其次才是官方搜索页配的那把。
265
- const configured = searchDefault ?? (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true
266
- return configured === true ? SEARCH_DEFAULT_REF : w.searchKeyEnv
336
+ // 没同步:搜索默认 key(官方 ref 或插件自有的 ref)优先,其次才是官方搜索页配的那把。
337
+ const ownRef = searchDefaultRefOf(w)
338
+ const configured = searchDefault ?? (await describeKey(ctx, ownRef)).configured === true
339
+ return configured === true ? ownRef : w.searchKeyEnv
267
340
  }
268
341
 
269
342
  // ───────────────── 会话列表(宿主 sessionController + live 兜底) ─────────────────
@@ -700,6 +773,10 @@ function readWebSettings(ctx) {
700
773
  // settings 服务的真实形状是 describe() 返回「按 profile 条目 id 键控」的行(每行带 ns 与活值),
701
774
  // 没有 get(ns):早期版本曾按 get 读取,结果永远拿到 undefined,搜索面板会整个消失。
702
775
  let rows = []
776
+ // 重入保护:describe() 会 interpolate 各条目的配置表达式,若某个表达式又要凭据,
777
+ // 就会再次走到这里。读不成就算了,绝不能递归。
778
+ if (readingWebSettings === true) return webDefaults()
779
+ readingWebSettings = true
703
780
  try {
704
781
  if (typeof settings?.describe === 'function') {
705
782
  rows = settings.describe() ?? []
@@ -710,7 +787,34 @@ function readWebSettings(ctx) {
710
787
  }
711
788
  } catch {
712
789
  rows = []
790
+ } finally {
791
+ readingWebSettings = false
792
+ }
793
+ return webConfigOf(rows)
794
+ }
795
+
796
+ /** readWebSettings 期间的重入标记。 */
797
+ let readingWebSettings = false
798
+
799
+ /** 读不到官方设置时的默认形状:旧 Messages 通道、默认 ref。 */
800
+ function webDefaults() {
801
+ return {
802
+ apiKeyEnv: DEFAULT_API_KEY_REF,
803
+ legacyKeyEnv: DEFAULT_API_KEY_REF,
804
+ apiLiteralKey: undefined,
805
+ legacyLiteralKey: undefined,
806
+ baseURL: FALLBACK_SEARCH_BASE_URL,
807
+ searchProvider: FALLBACK_SEARCH_PROVIDER,
808
+ fetchProvider: FALLBACK_FETCH_PROVIDER,
809
+ count: 10,
810
+ apiChannel: false,
811
+ searchKeyEnv: DEFAULT_API_KEY_REF,
812
+ searchLiteralKey: undefined,
713
813
  }
814
+ }
815
+
816
+ /** 把 describe() 的行投影成本插件关心的字段。 */
817
+ function webConfigOf(rows) {
714
818
  const read = (ns) => {
715
819
  const value = rows.find((row) => row?.ns === ns)?.value
716
820
  return typeof value === 'object' && value !== null ? value : {}
@@ -758,7 +862,9 @@ async function searchWarnings(ctx, state, web) {
758
862
  ? (await describeKey(ctx, DEFAULT_API_KEY_REF)).configured === true
759
863
  : (await describeKey(ctx, web.apiKeyEnv)).configured === true
760
864
  const apiKeyPresent = web.apiLiteralKey !== undefined || (web.apiKeyEnv !== DEFAULT_API_KEY_REF && apiKeyConfigured)
761
- const searchDefaultConfigured = (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true
865
+ const searchDefaultRef = searchDefaultRefOf(web)
866
+ const searchDefaultConfigured = (await describeKey(ctx, searchDefaultRef)).configured === true
867
+ const officialRef = officialSearchRef(web)
762
868
 
763
869
  // 1) 配了新版搜索 API 的 key,但官方开关没开 → 那把 key 永远不会被读到。
764
870
  if (!web.apiChannel && apiKeyPresent) {
@@ -779,7 +885,7 @@ async function searchWarnings(ctx, state, web) {
779
885
  level: 'bad',
780
886
  title: '新版 Web 搜索 API 已启用,但搜索 key 没有配置',
781
887
  detail: `搜索会用 ${web.apiKeyEnv} 去取 key,而它当前是空的:任何 web_search 都会失败。`
782
- + '到「设置 → 插件 → 网页搜索」里填一把,或把该 ref 指向已有的 key。',
888
+ + '到「设置 → 插件 → 网页搜索」里填一把,或在本面板的「搜索默认 key」里保存一把。',
783
889
  })
784
890
  }
785
891
 
@@ -790,11 +896,23 @@ async function searchWarnings(ctx, state, web) {
790
896
  level: 'warn',
791
897
  title: '搜索默认 key 已配置,但「启用新版 Web 搜索 API」没打开',
792
898
  detail: '现在搜索走的是 DeepSeek Messages 通道,用的是聊天那把 key,'
793
- + `所以面板里的 ${SEARCH_DEFAULT_REF} 不会被使用。`
899
+ + `所以面板里的 ${searchDefaultRef} 不会被使用。`
794
900
  + '要让它生效,请到「设置 → 插件 → 网页搜索」打开那个开关;若暂时不想用新版 API,可以忽略本条。',
795
901
  })
796
902
  }
797
903
 
904
+ // 2c) 搜索默认 key 已保存,但官方网页搜索页还指向聊天那把 → 官方页看不到它,
905
+ // 一键同步就能让两处共用同一个 ref。
906
+ if (searchDefaultRef === SEARCH_DEFAULT_REF && searchDefaultConfigured) {
907
+ out.push({
908
+ code: 'search-official-shared',
909
+ level: 'warn',
910
+ title: '搜索默认 key 已保存,但官方「网页搜索」页仍指向聊天那把',
911
+ detail: `官方页现在绑的是 ${DEFAULT_API_KEY_REF}(与聊天共用),所以那里看不到 ${SEARCH_DEFAULT_REF} 这把。`
912
+ + '点面板上的「同步到官方设置」,官方页的 API Key 输入框就会改到同一个 ref,两处以后改的是同一把 key。',
913
+ })
914
+ }
915
+
798
916
  // 3) 勾了「同步搜索 API」的工作区里,既没有工作区 key,也没有系统默认 key。
799
917
  const defaultConfigured = (await describeKey(ctx, DEFAULT_API_KEY_REF)).configured === true
800
918
  const syncPaths = Object.keys(state.searchSync ?? {}).filter((key) => state.searchSync[key] === true)
@@ -1106,6 +1224,8 @@ export function apply(ctx) {
1106
1224
  // ── 1. 包装 credentials.resolve + 标记搜索/抓取通道 ─────────────────
1107
1225
  installWebScope(ctx)
1108
1226
  installResolver(ctx, state)
1227
+ // 「直接禁用官方页面里的搜索 key 设置」:把官方 apiKeyEnv 指到插件自有 ref(异步、失败不影响启动)。
1228
+ void adoptOfficialSearchRef(ctx)
1109
1229
 
1110
1230
  // ── 2. REST API(loopback 可信)──────────────────────────────────────
1111
1231
  let webServer
@@ -1257,12 +1377,12 @@ function installResolver(ctx, state) {
1257
1377
  const fallback = await original(DEFAULT_API_KEY_REF)
1258
1378
  if (fallback?.value !== undefined && fallback.value !== '') return fallback
1259
1379
  }
1260
- // ③ 没同步:插件面板里配的「搜索默认 key」优先,其次才是官方搜索页
1261
- // 配的那把(默认与聊天共用 DEEPSEEK_API_KEY,所以更常见的路径是把
1262
- // 搜索 key 放在面板里,聊天 key 留在系统默认)。
1263
- const searchDefaultHit = await resolveKey(ctx, SEARCH_DEFAULT_REF)
1380
+ // ③ 没同步:用「搜索默认 key」。它要么就是官方页绑定的那个 ref(保存时已把
1381
+ // apiKeyEnv 指过来,两处同源),要么是插件自有的 ref(官方还指向聊天那把时)。
1382
+ const searchDefaultRef = searchDefaultRefOf(web)
1383
+ const searchDefaultHit = await resolveKey(ctx, searchDefaultRef)
1264
1384
  if (searchDefaultHit.value !== undefined && searchDefaultHit.value !== '') {
1265
- log(`search ref ${ref} → ${SEARCH_DEFAULT_REF} (search default)`)
1385
+ log(`search ref ${ref} → ${searchDefaultRef} (search default)`)
1266
1386
  return { value: searchDefaultHit.value, source: searchDefaultHit.source ?? 'plugin' }
1267
1387
  }
1268
1388
  return await original(ref)
@@ -1443,14 +1563,14 @@ async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, cl
1443
1563
  // 让用户勾了复选框之后马上能看到效果,而不用等到搜索失败才发现。
1444
1564
  const web = readWebSettings(ctx)
1445
1565
  const synced = state.searchSync?.[normPath(dir)] === true
1446
- const searchDefaultConfigured = web.apiChannel && (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true
1566
+ const searchDefaultConfigured = web.apiChannel && (await describeKey(ctx, searchDefaultRefOf(web))).configured === true
1447
1567
  const searchKeyRef = web.apiChannel
1448
1568
  ? synced
1449
1569
  ? configured
1450
1570
  ? ref
1451
1571
  : DEFAULT_API_KEY_REF
1452
1572
  : searchDefaultConfigured
1453
- ? SEARCH_DEFAULT_REF
1573
+ ? searchDefaultRefOf(web)
1454
1574
  : web.searchKeyEnv
1455
1575
  : web.searchKeyEnv
1456
1576
  const searchKeyMasked = web.apiChannel ? maskKey((await resolveKey(ctx, searchKeyRef))?.value) : undefined
@@ -1588,8 +1708,10 @@ async function snapshot(ctx, state) {
1588
1708
  const searchKeyRef = web.searchKeyEnv
1589
1709
  const searchKeyInfo = await describeKey(ctx, searchKeyRef)
1590
1710
  const searchKeyHit = await resolveKey(ctx, searchKeyRef)
1591
- const defaultSearchInfo = await describeKey(ctx, SEARCH_DEFAULT_REF)
1592
- const defaultSearchHit = await resolveKey(ctx, SEARCH_DEFAULT_REF)
1711
+ const searchDefaultRef = searchDefaultRefOf(web)
1712
+ const officialRef = officialSearchRef(web)
1713
+ const defaultSearchInfo = await describeKey(ctx, searchDefaultRef)
1714
+ const defaultSearchHit = await resolveKey(ctx, searchDefaultRef)
1593
1715
  const syncPaths = Object.keys(state.searchSync ?? {}).filter((key) => state.searchSync[key] === true)
1594
1716
  const warnings = await searchWarnings(ctx, state, web)
1595
1717
 
@@ -1644,9 +1766,16 @@ async function snapshot(ctx, state) {
1644
1766
  keySource: searchKeyInfo.source,
1645
1767
  keyWritable: searchKeyInfo.writable !== false,
1646
1768
  literalKey: web.searchLiteralKey !== undefined,
1647
- // 面板自己持有的「搜索默认 key」(优先于官方页配的那把)。
1648
- defaultRef: SEARCH_DEFAULT_REF,
1769
+ // 面板自己持有的「搜索默认 key」。它落在哪个 ref 取决于官方页:
1770
+ // 官方还指向聊天那把时用插件自有的 SEARCH_DEFAULT_REF,否则直接用官方那个 ref(两处同源)。
1771
+ defaultRef: searchDefaultRef,
1649
1772
  defaultConfigured: defaultSearchInfo.configured === true,
1773
+ // 官方网页搜索页绑定的 ref(它的 API Key 输入框写的就是这个 ref)。
1774
+ officialRef,
1775
+ officialShared: officialRef === DEFAULT_API_KEY_REF,
1776
+ // 插件自有 ref:官方仍共用聊天 key 时「同步到官方设置」会把它指过去。
1777
+ ownedRef: SEARCH_DEFAULT_REF,
1778
+ ownedConfigured: (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true,
1650
1779
  defaultKeyMasked: maskKey(defaultSearchHit?.value),
1651
1780
  defaultSource: defaultSearchInfo.source,
1652
1781
  defaultWritable: defaultSearchInfo.writable !== false,
@@ -1833,19 +1962,37 @@ async function handle(ctx, state, req, res) {
1833
1962
  if (pathname === `${API_BASE}/set` && method === 'POST') {
1834
1963
  const body = await readJson(req)
1835
1964
  const key = typeof body.key === 'string' ? body.key.trim() : ''
1836
- // 搜索默认 key:不是「某个目录/会话」的覆盖,由插件自己持有一个 ref。
1965
+ // 搜索默认 key:不是「某个目录/会话」的覆盖。
1966
+ // 它写在「官方网页搜索页绑定的那个 ref」上 —— 官方页的 API Key 输入框写的就是这个 ref,
1967
+ // 于是面板和官方页改的是同一把 key,不会各存一份。官方还指向聊天那把(DEEPSEEK_API_KEY)时,
1968
+ // 先把两套搜索插件的 apiKeyEnv 改到插件自有 ref,再写值。
1837
1969
  if (body.scope === 'search-default') {
1970
+ const web = readWebSettings(ctx)
1971
+ const official = officialSearchRef(web)
1972
+ const needsBind = official === DEFAULT_API_KEY_REF
1973
+ const target = needsBind ? SEARCH_DEFAULT_REF : official
1974
+ // 只要求同步(不改 key):用于「key 已经存过,但官方页还看不到」的一键修复。
1975
+ if (body.adopt === true) {
1976
+ const bound = await bindOfficialSearchRef(ctx, SEARCH_DEFAULT_REF)
1977
+ saveState(state)
1978
+ sendJson(res, 200, { ok: true, adopted: true, bound, ...(await snapshot(ctx, state)) })
1979
+ return
1980
+ }
1838
1981
  if (body.useDefault === true || key === '') {
1839
- await clearKey(ctx, SEARCH_DEFAULT_REF)
1982
+ await clearKey(ctx, target)
1983
+ if (needsBind) await clearKey(ctx, SEARCH_DEFAULT_REF)
1984
+ delete state.invalid[target]
1840
1985
  delete state.invalid[SEARCH_DEFAULT_REF]
1841
1986
  saveState(state)
1842
1987
  sendJson(res, 200, { ok: true, cleared: true, ...(await snapshot(ctx, state)) })
1843
1988
  return
1844
1989
  }
1845
- await storeKey(ctx, SEARCH_DEFAULT_REF, key)
1846
- delete state.invalid[SEARCH_DEFAULT_REF]
1990
+ let bound = { ok: true, changed: [], skipped: true }
1991
+ if (needsBind) bound = await bindOfficialSearchRef(ctx, SEARCH_DEFAULT_REF)
1992
+ await storeKey(ctx, target, key)
1993
+ delete state.invalid[target]
1847
1994
  saveState(state)
1848
- sendJson(res, 200, { ok: true, ...(await snapshot(ctx, state)) })
1995
+ sendJson(res, 200, { ok: true, bound, ...(await snapshot(ctx, state)) })
1849
1996
  return
1850
1997
  }
1851
1998
  const target = targetOf(body)
@@ -1887,7 +2034,7 @@ async function handle(ctx, state, req, res) {
1887
2034
  if (key === undefined) {
1888
2035
  if (sourceRef === undefined) {
1889
2036
  if (body.scope === 'search-default') {
1890
- sourceRef = SEARCH_DEFAULT_REF
2037
+ sourceRef = searchDefaultRefOf(web)
1891
2038
  } else if (body.scope === 'session') {
1892
2039
  const sessionId = typeof body.sessionId === 'string' && body.sessionId !== '' ? body.sessionId : undefined
1893
2040
  const cwd = typeof body.path === 'string' && body.path !== '' ? body.path : activeCwd(ctx)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workspace-api-key",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "A DeepSeek Harness plugin that binds a separate DeepSeek API key to each workspace, or to a single session, so the spend of one key stays with the sessions you choose.",
5
5
  "type": "module",
6
6
  "license": "MIT",