dsh-workspace-api-key 0.2.11 → 0.2.13

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, once there is a key to put there** — 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. Until you save a search key, the official entry keeps pointing at the chat key, so upgrading never breaks `web_search`; the panel says so instead of failing silently.
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.11`, 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.13`, 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 # 175 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 设置(先有 key 才接管)** —— 启动时若已启用新版 Web 搜索 API,插件把官方 `web-deepseek-api` / `web-search-deepseek` 条目的 `apiKeyEnv` 指到 `DEEPSEEK_API_KEY_SEARCH`(只改这一个指向,永远不动 key 内容)。此后「设置 → 插件 → 网页搜索」里填的就是本面板这把搜索 key,既不会再顶掉聊天 key,也不存在第二处藏搜索 key 的地方。还没配搜索 key 时,官方条目继续指着聊天那把,所以升级不会让 `web_search` 直接失效,面板会如实提醒「搜索暂时和聊天共用一把 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.11`;直接从仓库装则用 `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key`(git 来源的插件会在安装时现场构建)。
82
+ 锁版本用 `dsh-workspace-api-key@0.2.13`;直接从仓库装则用 `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 # 175 条断言
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,102 @@ 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
+ * 「搜索没有自己的 key 时退回哪一把」——官方搜索页当前指向的 ref。
274
+ *
275
+ * 例外:如果那个 ref 是插件自己改上去的 `DEEPSEEK_API_KEY_SEARCH`,而它还是空的
276
+ * (用户还没配搜索默认 key),就退回聊天 ref——否则升级完、还没配搜索 key 时,
277
+ * 所有 web_search 都会因为「ref 是空的」直接失败。
278
+ */
279
+ function fallbackSearchRef(web) {
280
+ return web.searchKeyEnv === SEARCH_DEFAULT_REF ? DEFAULT_API_KEY_REF : web.searchKeyEnv
281
+ }
282
+
283
+ /**
284
+ * 把官方两套搜索插件的 `apiKeyEnv` 都指向同一个 ref。
285
+ *
286
+ * 这样「面板里保存的搜索默认 key」就是「官方网页搜索页里的那把 key」:官方页的输入框写同一个 ref,
287
+ * 两边不再各存一份。settings.update 只改 volatile 字段,失败就原样返回,绝不影响 key 本身。
288
+ */
289
+ async function bindOfficialSearchRef(ctx, ref) {
290
+ let settings
291
+ try {
292
+ settings = ctx.get('settings')
293
+ } catch {
294
+ settings = undefined
295
+ }
296
+ if (typeof settings?.update !== 'function' || typeof settings?.describe !== 'function') {
297
+ return { ok: false, reason: 'settings-unavailable', changed: [] }
298
+ }
299
+ const changed = []
300
+ for (const ns of [SEARCH_API_NS, LEGACY_SEARCH_NS]) {
301
+ try {
302
+ const row = (settings.describe() ?? []).find((item) => item?.ns === ns)
303
+ if (row === undefined) continue
304
+ if (row.value?.apiKeyEnv === ref) continue
305
+ await settings.update(ns, { apiKeyEnv: ref }, row.revision)
306
+ changed.push(ns)
307
+ } catch (error) {
308
+ return { ok: false, reason: error?.message ?? String(error), changed }
309
+ }
310
+ }
311
+ return { ok: true, changed }
312
+ }
313
+
314
+ /**
315
+ * 启动时把官方「网页搜索」页的 key 指向拉回一致。
316
+ *
317
+ * 官方页那个 Key 输入框写的永远是「当前 API 段的 apiKeyEnv 指向的 ref」。用户要求
318
+ * 「直接禁用官方页面里的搜索 key 设置」——所以插件要把它指到自己的 `DEEPSEEK_API_KEY_SEARCH`:
319
+ * 从那以后官方页填的就是插件这把搜索 key,既不会误改聊天 key,也不再存在第二把搜索 key。
320
+ *
321
+ * 但接管有个前提:搜索默认 key 已经存在。官方那条 ref 指向一把空 key 会让**所有搜索直接失败**,
322
+ * 所以这里分两种情况:
323
+ * - 官方指着搜索 ref、但那把 key 还没有 → 退回聊天那把,搜索继续可用(等用户配了再接管);
324
+ * - 官方指着聊天那把、而搜索 key 已经配好 → 接管过去。
325
+ * 只改 apiKeyEnv 指向,不动任何 key 的值;settings 不可写就静默跳过。
326
+ */
327
+ async function reconcileOfficialSearchRef(ctx) {
328
+ const web = readWebSettings(ctx)
329
+ if (web.apiChannel !== true) return
330
+ const official = officialSearchRef(web)
331
+ if (official !== SEARCH_DEFAULT_REF && official !== DEFAULT_API_KEY_REF) return
332
+ const owned = (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true
333
+ if (official === SEARCH_DEFAULT_REF && !owned) {
334
+ const bound = await bindOfficialSearchRef(ctx, DEFAULT_API_KEY_REF)
335
+ if (bound.ok !== true) log('restore official search ref failed: %s', bound.reason)
336
+ else if (bound.changed.length > 0) log('search default key is empty; official search key falls back to %s', DEFAULT_API_KEY_REF)
337
+ return
338
+ }
339
+ if (official === DEFAULT_API_KEY_REF && owned) {
340
+ const bound = await bindOfficialSearchRef(ctx, SEARCH_DEFAULT_REF)
341
+ if (bound.ok !== true) log('adopt official search ref failed: %s', bound.reason)
342
+ else if (bound.changed.length > 0) log('adopted official search key settings: %s', bound.changed.join(', '))
343
+ }
344
+ }
345
+
250
346
  /**
251
347
  * 「这个会话/目录的搜索请求会去读哪个 ref」——纯计算,不解析凭据。
252
348
  * 新版 API 通道:会话单独 key → 工作区勾了同步 ? (工作区 key / 系统默认): 搜索插件自己的 ref
@@ -261,9 +357,10 @@ async function searchRefFor(ctx, state, { sessionId, cwd, web, searchDefault })
261
357
  const hit = effectiveOverride(ctx, state, { sessionId, cwd })
262
358
  return hit.ref ?? DEFAULT_API_KEY_REF
263
359
  }
264
- // 没同步:插件自己的「搜索默认 key」优先,其次才是官方搜索页配的那把。
265
- const configured = searchDefault ?? (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true
266
- return configured === true ? SEARCH_DEFAULT_REF : w.searchKeyEnv
360
+ // 没同步:搜索默认 key(官方 ref 或插件自有的 ref)优先,其次才是官方搜索页配的那把。
361
+ const ownRef = searchDefaultRefOf(w)
362
+ const configured = searchDefault ?? (await describeKey(ctx, ownRef)).configured === true
363
+ return configured === true ? ownRef : fallbackSearchRef(w)
267
364
  }
268
365
 
269
366
  // ───────────────── 会话列表(宿主 sessionController + live 兜底) ─────────────────
@@ -789,7 +886,9 @@ async function searchWarnings(ctx, state, web) {
789
886
  ? (await describeKey(ctx, DEFAULT_API_KEY_REF)).configured === true
790
887
  : (await describeKey(ctx, web.apiKeyEnv)).configured === true
791
888
  const apiKeyPresent = web.apiLiteralKey !== undefined || (web.apiKeyEnv !== DEFAULT_API_KEY_REF && apiKeyConfigured)
792
- const searchDefaultConfigured = (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true
889
+ const searchDefaultRef = searchDefaultRefOf(web)
890
+ const searchDefaultConfigured = (await describeKey(ctx, searchDefaultRef)).configured === true
891
+ const officialRef = officialSearchRef(web)
793
892
 
794
893
  // 1) 配了新版搜索 API 的 key,但官方开关没开 → 那把 key 永远不会被读到。
795
894
  if (!web.apiChannel && apiKeyPresent) {
@@ -803,15 +902,28 @@ async function searchWarnings(ctx, state, web) {
803
902
  })
804
903
  }
805
904
 
806
- // 2) 开了新版 API,却没有可用的搜索 key → 搜索会直接报缺少凭据。
807
- if (web.apiChannel && web.apiLiteralKey === undefined && !apiKeyConfigured && !searchDefaultConfigured) {
808
- out.push({
809
- code: 'search-key-missing',
810
- level: 'bad',
811
- title: '新版 Web 搜索 API 已启用,但搜索 key 没有配置',
812
- detail: `搜索会用 ${web.apiKeyEnv} 去取 key,而它当前是空的:任何 web_search 都会失败。`
813
- + '到「设置 → 插件 → 网页搜索」里填一把,或把该 ref 指向已有的 key。',
814
- })
905
+ // 2) 开了新版 API,却没有搜索默认 key:
906
+ // 聊天 key 还在 → 搜索暂时沿用聊天那把(能跑,但计费混在一起,值得提醒);
907
+ // 聊天 key 也没了 → 搜索会直接报缺少凭据。
908
+ if (web.apiChannel && web.apiLiteralKey === undefined && !searchDefaultConfigured) {
909
+ const chatConfigured = (await describeKey(ctx, DEFAULT_API_KEY_REF)).configured === true
910
+ if (chatConfigured) {
911
+ out.push({
912
+ code: 'search-default-missing',
913
+ level: 'warn',
914
+ title: '搜索默认 key 还没配置:搜索暂时沿用聊天那把 key',
915
+ detail: `现在搜索读的是 ${DEFAULT_API_KEY_REF}(和聊天共用一把),所以搜索与聊天的花费算在一起。`
916
+ + `在本面板的「搜索默认 key」里保存一把后,插件会自动把官方配置指过去,两边就分开了。`,
917
+ })
918
+ } else {
919
+ out.push({
920
+ code: 'search-key-missing',
921
+ level: 'bad',
922
+ title: '新版 Web 搜索 API 已启用,但搜索 key 没有配置',
923
+ detail: `搜索会用 ${web.apiKeyEnv} 去取 key,而它当前是空的:任何 web_search 都会失败。`
924
+ + '到「设置 → 插件 → 网页搜索」里填一把,或在本面板的「搜索默认 key」里保存一把。',
925
+ })
926
+ }
815
927
  }
816
928
 
817
929
  // 2b) 面板里配了「搜索默认 key」,但新版 API 没开 → 这把 key 也不会被读到。
@@ -821,11 +933,23 @@ async function searchWarnings(ctx, state, web) {
821
933
  level: 'warn',
822
934
  title: '搜索默认 key 已配置,但「启用新版 Web 搜索 API」没打开',
823
935
  detail: '现在搜索走的是 DeepSeek Messages 通道,用的是聊天那把 key,'
824
- + `所以面板里的 ${SEARCH_DEFAULT_REF} 不会被使用。`
936
+ + `所以面板里的 ${searchDefaultRef} 不会被使用。`
825
937
  + '要让它生效,请到「设置 → 插件 → 网页搜索」打开那个开关;若暂时不想用新版 API,可以忽略本条。',
826
938
  })
827
939
  }
828
940
 
941
+ // 2c) 搜索默认 key 已保存,但官方网页搜索页还指向聊天那把 → 官方页看不到它,
942
+ // 一键同步就能让两处共用同一个 ref。
943
+ if (searchDefaultRef === SEARCH_DEFAULT_REF && searchDefaultConfigured) {
944
+ out.push({
945
+ code: 'search-official-shared',
946
+ level: 'warn',
947
+ title: '搜索默认 key 已保存,但官方「网页搜索」页仍指向聊天那把',
948
+ detail: `官方页现在绑的是 ${DEFAULT_API_KEY_REF}(与聊天共用),所以那里看不到 ${SEARCH_DEFAULT_REF} 这把。`
949
+ + '点面板上的「同步到官方设置」,官方页的 API Key 输入框就会改到同一个 ref,两处以后改的是同一把 key。',
950
+ })
951
+ }
952
+
829
953
  // 3) 勾了「同步搜索 API」的工作区里,既没有工作区 key,也没有系统默认 key。
830
954
  const defaultConfigured = (await describeKey(ctx, DEFAULT_API_KEY_REF)).configured === true
831
955
  const syncPaths = Object.keys(state.searchSync ?? {}).filter((key) => state.searchSync[key] === true)
@@ -1137,6 +1261,8 @@ export function apply(ctx) {
1137
1261
  // ── 1. 包装 credentials.resolve + 标记搜索/抓取通道 ─────────────────
1138
1262
  installWebScope(ctx)
1139
1263
  installResolver(ctx, state)
1264
+ // 「直接禁用官方页面里的搜索 key 设置」:把官方 apiKeyEnv 指到插件自有 ref(异步、失败不影响启动)。
1265
+ void reconcileOfficialSearchRef(ctx)
1140
1266
 
1141
1267
  // ── 2. REST API(loopback 可信)──────────────────────────────────────
1142
1268
  let webServer
@@ -1288,12 +1414,12 @@ function installResolver(ctx, state) {
1288
1414
  const fallback = await original(DEFAULT_API_KEY_REF)
1289
1415
  if (fallback?.value !== undefined && fallback.value !== '') return fallback
1290
1416
  }
1291
- // ③ 没同步:插件面板里配的「搜索默认 key」优先,其次才是官方搜索页
1292
- // 配的那把(默认与聊天共用 DEEPSEEK_API_KEY,所以更常见的路径是把
1293
- // 搜索 key 放在面板里,聊天 key 留在系统默认)。
1294
- const searchDefaultHit = await resolveKey(ctx, SEARCH_DEFAULT_REF)
1417
+ // ③ 没同步:用「搜索默认 key」。它要么就是官方页绑定的那个 ref(保存时已把
1418
+ // apiKeyEnv 指过来,两处同源),要么是插件自有的 ref(官方还指向聊天那把时)。
1419
+ const searchDefaultRef = searchDefaultRefOf(web)
1420
+ const searchDefaultHit = await resolveKey(ctx, searchDefaultRef)
1295
1421
  if (searchDefaultHit.value !== undefined && searchDefaultHit.value !== '') {
1296
- log(`search ref ${ref} → ${SEARCH_DEFAULT_REF} (search default)`)
1422
+ log(`search ref ${ref} → ${searchDefaultRef} (search default)`)
1297
1423
  return { value: searchDefaultHit.value, source: searchDefaultHit.source ?? 'plugin' }
1298
1424
  }
1299
1425
  return await original(ref)
@@ -1474,15 +1600,15 @@ async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, cl
1474
1600
  // 让用户勾了复选框之后马上能看到效果,而不用等到搜索失败才发现。
1475
1601
  const web = readWebSettings(ctx)
1476
1602
  const synced = state.searchSync?.[normPath(dir)] === true
1477
- const searchDefaultConfigured = web.apiChannel && (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true
1603
+ const searchDefaultConfigured = web.apiChannel && (await describeKey(ctx, searchDefaultRefOf(web))).configured === true
1478
1604
  const searchKeyRef = web.apiChannel
1479
1605
  ? synced
1480
1606
  ? configured
1481
1607
  ? ref
1482
1608
  : DEFAULT_API_KEY_REF
1483
1609
  : searchDefaultConfigured
1484
- ? SEARCH_DEFAULT_REF
1485
- : web.searchKeyEnv
1610
+ ? searchDefaultRefOf(web)
1611
+ : fallbackSearchRef(web)
1486
1612
  : web.searchKeyEnv
1487
1613
  const searchKeyMasked = web.apiChannel ? maskKey((await resolveKey(ctx, searchKeyRef))?.value) : undefined
1488
1614
  return {
@@ -1619,8 +1745,10 @@ async function snapshot(ctx, state) {
1619
1745
  const searchKeyRef = web.searchKeyEnv
1620
1746
  const searchKeyInfo = await describeKey(ctx, searchKeyRef)
1621
1747
  const searchKeyHit = await resolveKey(ctx, searchKeyRef)
1622
- const defaultSearchInfo = await describeKey(ctx, SEARCH_DEFAULT_REF)
1623
- const defaultSearchHit = await resolveKey(ctx, SEARCH_DEFAULT_REF)
1748
+ const searchDefaultRef = searchDefaultRefOf(web)
1749
+ const officialRef = officialSearchRef(web)
1750
+ const defaultSearchInfo = await describeKey(ctx, searchDefaultRef)
1751
+ const defaultSearchHit = await resolveKey(ctx, searchDefaultRef)
1624
1752
  const syncPaths = Object.keys(state.searchSync ?? {}).filter((key) => state.searchSync[key] === true)
1625
1753
  const warnings = await searchWarnings(ctx, state, web)
1626
1754
 
@@ -1675,9 +1803,16 @@ async function snapshot(ctx, state) {
1675
1803
  keySource: searchKeyInfo.source,
1676
1804
  keyWritable: searchKeyInfo.writable !== false,
1677
1805
  literalKey: web.searchLiteralKey !== undefined,
1678
- // 面板自己持有的「搜索默认 key」(优先于官方页配的那把)。
1679
- defaultRef: SEARCH_DEFAULT_REF,
1806
+ // 面板自己持有的「搜索默认 key」。它落在哪个 ref 取决于官方页:
1807
+ // 官方还指向聊天那把时用插件自有的 SEARCH_DEFAULT_REF,否则直接用官方那个 ref(两处同源)。
1808
+ defaultRef: searchDefaultRef,
1680
1809
  defaultConfigured: defaultSearchInfo.configured === true,
1810
+ // 官方网页搜索页绑定的 ref(它的 API Key 输入框写的就是这个 ref)。
1811
+ officialRef,
1812
+ officialShared: officialRef === DEFAULT_API_KEY_REF,
1813
+ // 插件自有 ref:官方仍共用聊天 key 时「同步到官方设置」会把它指过去。
1814
+ ownedRef: SEARCH_DEFAULT_REF,
1815
+ ownedConfigured: (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true,
1681
1816
  defaultKeyMasked: maskKey(defaultSearchHit?.value),
1682
1817
  defaultSource: defaultSearchInfo.source,
1683
1818
  defaultWritable: defaultSearchInfo.writable !== false,
@@ -1864,19 +1999,37 @@ async function handle(ctx, state, req, res) {
1864
1999
  if (pathname === `${API_BASE}/set` && method === 'POST') {
1865
2000
  const body = await readJson(req)
1866
2001
  const key = typeof body.key === 'string' ? body.key.trim() : ''
1867
- // 搜索默认 key:不是「某个目录/会话」的覆盖,由插件自己持有一个 ref。
2002
+ // 搜索默认 key:不是「某个目录/会话」的覆盖。
2003
+ // 它写在「官方网页搜索页绑定的那个 ref」上 —— 官方页的 API Key 输入框写的就是这个 ref,
2004
+ // 于是面板和官方页改的是同一把 key,不会各存一份。官方还指向聊天那把(DEEPSEEK_API_KEY)时,
2005
+ // 先把两套搜索插件的 apiKeyEnv 改到插件自有 ref,再写值。
1868
2006
  if (body.scope === 'search-default') {
2007
+ const web = readWebSettings(ctx)
2008
+ const official = officialSearchRef(web)
2009
+ const needsBind = official === DEFAULT_API_KEY_REF
2010
+ const target = needsBind ? SEARCH_DEFAULT_REF : official
2011
+ // 只要求同步(不改 key):用于「key 已经存过,但官方页还看不到」的一键修复。
2012
+ if (body.adopt === true) {
2013
+ const bound = await bindOfficialSearchRef(ctx, SEARCH_DEFAULT_REF)
2014
+ saveState(state)
2015
+ sendJson(res, 200, { ok: true, adopted: true, bound, ...(await snapshot(ctx, state)) })
2016
+ return
2017
+ }
1869
2018
  if (body.useDefault === true || key === '') {
1870
- await clearKey(ctx, SEARCH_DEFAULT_REF)
2019
+ await clearKey(ctx, target)
2020
+ if (needsBind) await clearKey(ctx, SEARCH_DEFAULT_REF)
2021
+ delete state.invalid[target]
1871
2022
  delete state.invalid[SEARCH_DEFAULT_REF]
1872
2023
  saveState(state)
1873
2024
  sendJson(res, 200, { ok: true, cleared: true, ...(await snapshot(ctx, state)) })
1874
2025
  return
1875
2026
  }
1876
- await storeKey(ctx, SEARCH_DEFAULT_REF, key)
1877
- delete state.invalid[SEARCH_DEFAULT_REF]
2027
+ let bound = { ok: true, changed: [], skipped: true }
2028
+ if (needsBind) bound = await bindOfficialSearchRef(ctx, SEARCH_DEFAULT_REF)
2029
+ await storeKey(ctx, target, key)
2030
+ delete state.invalid[target]
1878
2031
  saveState(state)
1879
- sendJson(res, 200, { ok: true, ...(await snapshot(ctx, state)) })
2032
+ sendJson(res, 200, { ok: true, bound, ...(await snapshot(ctx, state)) })
1880
2033
  return
1881
2034
  }
1882
2035
  const target = targetOf(body)
@@ -1918,7 +2071,7 @@ async function handle(ctx, state, req, res) {
1918
2071
  if (key === undefined) {
1919
2072
  if (sourceRef === undefined) {
1920
2073
  if (body.scope === 'search-default') {
1921
- sourceRef = SEARCH_DEFAULT_REF
2074
+ sourceRef = searchDefaultRefOf(web)
1922
2075
  } else if (body.scope === 'session') {
1923
2076
  const sessionId = typeof body.sessionId === 'string' && body.sessionId !== '' ? body.sessionId : undefined
1924
2077
  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.11",
3
+ "version": "0.2.13",
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",