dsh-workspace-api-key 0.2.17 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,10 +25,20 @@ session key → workspace key → system default
25
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.
26
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.
27
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.
28
+ - *(The four search features above apply to older hosts only. DSH >= `0.2.2-rc.2` replaced them with a `计费方式` (billing mode) model; the old fields no longer take effect. See "Host compatibility" below for the new behaviour.)*
28
29
  - **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.
29
30
  - **A way back from Settings** — Settings → API Key 分配 keeps an "open the panel" button and explains the overflow-menu and reload routes, so an unpinned sidebar icon is never a dead end.
30
31
  - **Language-aware UI** — the panel follows the web GUI language from `@deepseek-ai/dsh-client-locale`: English when the GUI is English, Chinese when it is Chinese. Answers produced by the host half (connection-test details, search warnings) are translated in the browser half, so the panel does not mix languages.
31
32
 
33
+ ## Host compatibility (0.3.0+)
34
+
35
+ The plugin switches on **host capability**, not on a parsed version: if the `web-deepseek-api` settings row contains `searchBilling` / `fetchBilling`, the host is the new one (DSH >= `0.2.2-rc.2`); otherwise the old behaviour applies.
36
+
37
+ - **New host**: the main panel keeps a single "搜索与获取" card that explains the official settings page owns the search key, shows the current search/fetch billing mode, and offers an "打开官方搜索设置" button. The old "搜索默认 key" card, the "同步搜索 API" switch, the effective-search-key hint, and all three search warnings are hidden — `apiKeyEnv` no longer takes effect on this host, so showing them would only invite misconfiguration. With "DeepSeek API Key" billing, search resolves `DEEPSEEK_API_KEY` and therefore follows the session → workspace → default cascade automatically; with "已登录的 DeepSeek 账号" billing it uses the account token, which the plugin cannot override, and the card says so.
38
+ - **Old host**: the 0.2.x behaviour is unchanged (search default key, `apiKeyEnv` takeover, per-workspace sync switch, search warnings).
39
+ - **Host version**: `hostVersion` in `/wsk-api/state` is best-effort — the plugin reads the `version` of a resolvable `@deepseek-ai/dsh` package.json; the desktop host is packed inside app.asar, so when it cannot be resolved the Settings card shows an unknown version. Capability detection, not the version string, drives behaviour.
40
+ - **Experimental "unified search key"** (Settings → API Key 分配 only, never in the main panel): when enabled, the plugin points the official `searchApiKeyEnv` / `fetchApiKeyEnv` at `DEEPSEEK_API_KEY_SEARCH` and stores that one key, so every session and workspace uses it for `web_search` / `web_fetch` (ahead of session/workspace keys); disabling clears both fields and returns to the official billing mode. It requires the new host — an old host answers the request with 400.
41
+
32
42
  ## How it works
33
43
 
34
44
  Host half (`lib/index.js`):
@@ -38,7 +48,7 @@ Host half (`lib/index.js`):
38
48
  - matches that directory against `workspaceRegistry.list()` after normalizing both sides (absolute, trailing separator removed, lower-cased on Windows);
39
49
  - derives a stable ref per level — `DEEPSEEK_API_KEY_WS_<first 16 hex chars of sha1(normalized path), upper-cased>` for a workspace and `DEEPSEEK_API_KEY_SS_<first 16 hex chars of sha1(session id), upper-cased>` for a session (a session id contains `-` and cannot be a ref name) — writes the key through the credentials service, and serves that ref's value;
40
50
  - rewrites only refs a provider actually reads: the default ref, every adapter's configured `apiKeyEnv`, and its own generated refs. Every other ref passes through untouched;
41
- - tells search credentials apart from chat credentials **by ref, not by wrapping the web seam**. The official new Search/Fetch API resolves its key under `web-deepseek-api.apiKeyEnv`, which this plugin points at its own `DEEPSEEK_API_KEY_SEARCH` at startup and whenever the panel saves a search key, so a search resolve is recognisable from the ref alone: chat never asks for it. Earlier 0.2.x releases wrapped `ctx.web.search` / `ctx.web.fetch` in an `AsyncLocalStorage` scope instead; that patch is gone from 0.2.15 on, because a wrapper around the web seam can leave `web_search` / `web_fetch` waiting until their 30/60 s tool budget expires (it hangs even for addresses that fail instantly, e.g. `http://127.0.0.1:1/`). The rule itself is unchanged: a session's own key first (always), then, if the workspace ticked `同步搜索 API`, the workspace key and then the system default, otherwise the panel's search default ref when it holds one, and only then the ref the search plugin configured. The legacy Messages-based search keeps the plain chat cascade, because that route *is* a messages request;
51
+ - tells search credentials apart from chat credentials **by ref, not by wrapping the web seam**. The official new Search/Fetch API resolves its key under `web-deepseek-api.apiKeyEnv`, which this plugin points at its own `DEEPSEEK_API_KEY_SEARCH` at startup and whenever the panel saves a search key, so a search resolve is recognisable from the ref alone: chat never asks for it. Earlier 0.2.x releases wrapped `ctx.web.search` / `ctx.web.fetch` in an `AsyncLocalStorage` scope instead; that patch is gone from 0.2.15 on, because a wrapper around the web seam can leave `web_search` / `web_fetch` waiting until their 30/60 s tool budget expires (it hangs even for addresses that fail instantly, e.g. `http://127.0.0.1:1/`). The rule itself is unchanged: a session's own key first (always), then, if the workspace ticked `同步搜索 API`, the workspace key and then the system default, otherwise the panel's search default ref when it holds one, and only then the ref the search plugin configured. The legacy Messages-based search keeps the plain chat cascade, because that route *is* a messages request. On the new billing model the plugin no longer writes `apiKeyEnv` (a dead field there): `api-key` billing resolves `DEEPSEEK_API_KEY`, so search follows the chat cascade; `account` billing uses the account token, which the plugin cannot override, so it only warns; the experimental unified key points `searchApiKeyEnv` at the plugin-owned `DEEPSEEK_API_KEY_SEARCH` and, recognised by ref, wins first;
42
52
  - builds the session list from the `sessionController` service (`list()`, unwrapping its `{items: […]}` envelope) and falls back to the live `sessions` service (`ctx.get('sessions').list()`), so the panel still has sessions when the controller is missing, throws, or returns an empty list. It hides the two kinds of session the sidebar hides: subagent sessions (`origin: 'subagent'`, or a `parentSession`) and *blank* sessions (created but never prompted — the sidebar shows only the one currently open). Without those two filters one workspace with a single real session listed thirteen;
43
53
  - reports its own version and a session probe (`available`, `shape`, `source`, `count`, `withoutCwd`, `subagents`, `samples`, `error`, plus sample workspace paths) in `/wsk-api/state`, so the panel can say *why* no sessions are listed: a stale host half (only a page reload instead of an app restart), a missing `sessionController`, a throwing `list()`, an unexpected envelope shape, or a `cwd` that matches no workspace path;
44
54
  - lists a directory that is not a registered workspace only if the sidebar would show it. It cannot decide that itself, because "only the currently open blank session counts" is knowledge the browser side holds (`retainedBy.mainView`), so it sends every such directory plus a `blank` flag per session and a `hiddenBlankDirs` list naming the ones whose sessions are all blank — the browser half applies the same verdict the sidebar does;
@@ -59,6 +69,7 @@ The browser half (`lib/client.js`) registers into the `sidebar.panellist` slot (
59
69
  | POST | `/wsk-api/test` | `{scope, path \| sessionId \| ref}` (or `{key}`) runs one real request and returns the verdict; `invalid` marks the flag, `ok` clears it |
60
70
  | POST | `/wsk-api/clear-flag` | `{ref}` or a target drops the invalid/quota flag |
61
71
  | POST | `/wsk-api/search-sync` | `{path, enabled}` turns a workspace's `同步搜索 API` switch on or off |
72
+ | POST | `/wsk-api/search-unified` | `{enabled, key?}` — experimental: pins search and fetch for every session/workspace to one key; enabling writes `searchApiKeyEnv` / `fetchApiKeyEnv`, disabling clears them |
62
73
 
63
74
  Only loopback callers are accepted (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`) with a same-origin or absent `Origin`. No response ever contains a plaintext key, only a mask.
64
75
 
@@ -81,7 +92,7 @@ The command forwards its arguments to pnpm inside the profile directory, so the
81
92
  pnpm add dsh-workspace-api-key # or: npm install dsh-workspace-api-key
82
93
  ```
83
94
 
84
- Pin a version with `dsh-workspace-api-key@0.2.17`, 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).
95
+ Pin a version with `dsh-workspace-api-key@0.3.0`, 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).
85
96
 
86
97
  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:
87
98
 
@@ -92,19 +103,20 @@ Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `p
92
103
  ## Tests
93
104
 
94
105
  ```sh
95
- node test/host.test.mjs # 177 assertions
96
- node test/client.test.mjs # 180 assertions
106
+ node test/host.test.mjs # 196 assertions
107
+ node test/client.test.mjs # 180 assertions
108
+ node test/client-billing.test.mjs # 8 assertions (new billing model)
97
109
  ```
98
110
 
99
- 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.
111
+ Neither main 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. `client-billing.test.mjs` covers the new billing model — no legacy search UI, the official-settings entry, and the unified-key notice — and `client-i18n.test.mjs` checks the English dictionary (7 assertions).
100
112
 
101
113
  ## Requirements and limits
102
114
 
103
- - Verified against DSH Desktop 2.0.17 with `@deepseek-ai/dsh*` 0.2.0-rc.2 and `@deepseek-ai/cordis` 4.0.4. It has no runtime dependency and imports no `@deepseek-ai/*` package — only the host services (`credentials`, `workspaceRegistry`, `agents`, `sessionController`, `webServer`, `llm`, `settings`) and Node built-ins.
115
+ - Verified against DSH Desktop with `@deepseek-ai/dsh*` 0.2.2-rc.2 and `@deepseek-ai/cordis` 4.0.4. It has no runtime dependency and imports no `@deepseek-ai/*` package — only the host services (`credentials`, `workspaceRegistry`, `agents`, `sessionController`, `webServer`, `llm`, `settings`) and Node built-ins.
104
116
  - One key per level: a session has a single key whichever provider route reads it, and a key recorded for one provider is reported as a mismatch when the session runs on another. Model changes inside one provider are not treated as a mismatch, because a DeepSeek key is account-wide.
105
117
  - Sessions are matched to a workspace by their directory (`cwd`). The host's own `list()` skips stored sessions that never got a `cwd`, and the live-session fallback only knows sessions that are currently loaded, so the panel may show fewer sessions than the sidebar. Sessions visible only in the browser-side store carry no `cwd` of their own beyond the one the sidebar knows.
106
118
  - The connection test speaks the Anthropic Messages shape (`POST <baseURL>/messages` with `x-api-key` and `anthropic-version: 2023-06-01`), which is what the DeepSeek route uses. A provider that speaks a different protocol can still have keys bound per level, but the test may report a failure for a key that works.
107
- - Search support needs a DSH build that ships the official Search/Fetch API (`web-deepseek-api`, the 设置 → 插件 → 网页搜索 page with `启用新版 Web 搜索 API`). On a build without it the plugin simply never sees a search request: the switch stays visible, and the panel reports the legacy Messages channel instead of guessing.
119
+ - Search support needs a DSH build that ships the official Search/Fetch API (`web-deepseek-api`, the 设置 → 插件 → 网页搜索 page with `启用新版 Web 搜索 API`). On a build without it the plugin simply never sees a search request: the switch stays visible, and the panel reports the legacy Messages channel instead of guessing. On the new billing model, `account` billing uses the account token and cannot be overridden by the plugin — it can only be reported; choose "DeepSeek API Key" billing to follow session/workspace keys, or use the experimental unified search key in Settings.
108
120
  - Keys are stored through the credentials service in `$DSH_HOME/.credentials.yaml` under `refs:`, in plaintext, exactly like the default key. They are not written into session logs, the plugin's state file, or the plugin directory.
109
121
  - Matching is textual after normalization. If a session's `cwd` is a symlink or junction to a registered workspace path, it will not match and the level above is used.
110
122
  - Uninstalling the plugin leaves the generated `*_WS_*` / `*_SS_*` refs and `DEEPSEEK_API_KEY_SEARCH` in the credentials file; remove them from the Models page.
package/README.zh.md CHANGED
@@ -25,10 +25,20 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
25
25
  - **接管官方搜索 key 设置(先有 key 才接管)** —— 启动时若已启用新版 Web 搜索 API,插件把官方 `web-deepseek-api` / `web-search-deepseek` 条目的 `apiKeyEnv` 指到 `DEEPSEEK_API_KEY_SEARCH`(只改这一个指向,永远不动 key 内容)。此后「设置 → 插件 → 网页搜索」里填的就是本面板这把搜索 key,既不会再顶掉聊天 key,也不存在第二处藏搜索 key 的地方。还没配搜索 key 时,官方条目继续指着聊天那把,所以升级不会让 `web_search` 直接失效,面板会如实提醒「搜索暂时和聊天共用一把 key」。
26
26
  - **每个工作区一个搜索开关** —— 每个工作区行上都有「同步搜索 API」复选框。勾选后,该目录下会话发出的 `web_search` / `web_fetch` 请求改用它们聊天时用的那把 key(会话 → 工作区 → 系统默认);不勾选则继续用官方「设置 → 插件 → 网页搜索」里配置的那把 key。会话自己配了 key 时永远优先,所以「工作区同步、会话单独设置」花的是会话那把 key,而不是搜索默认 key。
27
27
  - **在失败之前就提醒的搜索告警** —— 配了搜索 key 但没打开「启用新版 Web 搜索 API」,或者新版 API 打开了但它的 key ref 是空的,面板会显示提醒条,点进会话时的覆盖层也会用一句可直接照做的话说明;当生效的搜索通道不是新版 API 时,覆盖层不会拿搜索问题打扰你。
28
+ - *(上面四条搜索能力只适用于旧版宿主。DSH >= `0.2.2-rc.2` 把搜索/获取改成了「计费方式」,旧字段不再生效;新版宿主上的行为见「宿主版本兼容」。)*
28
29
  - **使用说明** —— 面板顶部有「使用说明」,推荐两把 key 分工:聊天默认用 `default`,搜索默认用 `search-default`,两份预算互不干扰;里面还画了层级,并列出了会触发提醒的几种情况。
29
30
  - **面板入口也能从设置里找回来** —— 「设置 → API Key 分配」里还有一个「打开 API Key 分配面板」按钮:侧栏图标被取消固定时,点它就能回到这里;那里也写明了「更多」菜单和刷新页面两条找回途径。
30
31
  - **界面跟随 DSH 语言** —— 面板跟随 `@deepseek-ai/dsh-client-locale`:Web 界面是英文时显示英文,是中文时保持中文;宿主半边返回的提示(连接测试详情、搜索提醒)也在浏览器半边翻译,界面不会中英混排。
31
32
 
33
+ ## 宿主版本兼容(0.3.0 起)
34
+
35
+ 插件按**宿主能力**切换界面,不猜版本号:`web-deepseek-api` 的设置里出现 `searchBilling` / `fetchBilling` 字段,就按新版宿主(DSH >= `0.2.2-rc.2`)处理,否则按旧版处理。
36
+
37
+ - **新版宿主**:主面板只保留一张「搜索与获取」卡片,说明官方设置页已经接管搜索 key,显示当前的搜索/获取计费方式,并提供「打开官方搜索设置」按钮。旧版的「搜索默认 key」卡片、「同步搜索 API」开关、「搜索生效 key」提示和三类搜索告警全部隐藏——`apiKeyEnv` 在新版里已不再生效,继续展示只会让人误配。计费方式为「DeepSeek API Key」时,搜索解析的是 `DEEPSEEK_API_KEY`,因此会自动跟随上面讲的会话 → 工作区 → 系统默认级联;计费方式为「已登录的 DeepSeek 账号」时走账号令牌,插件无法覆盖,卡片会如实提示。
38
+ - **旧版宿主**:0.2.x 的行为原样保留(搜索默认 key、接管 `apiKeyEnv`、工作区同步开关、搜索告警)。
39
+ - **宿主版本**:`/wsk-api/state` 里的 `hostVersion` 是尽力而为读出来的——从能解析到的 `@deepseek-ai/dsh` 的 `package.json` 取 `version`;桌面版宿主打在 asar 里读不到时返回空,设置页显示「未知」。所以兼容性判断始终以设置字段为准,版本号只用于展示。
40
+ - **实验性「统一搜索 key」**(只在「设置 → API Key 分配」,不放进主面板):开启后插件把官方 `searchApiKeyEnv` / `fetchApiKeyEnv` 指向 `DEEPSEEK_API_KEY_SEARCH`,并把这把 key 存进凭据服务,于是所有会话、工作区的 `web_search` / `web_fetch` 都用它(优先于会话/工作区 key);关闭时清空这两个字段,回到官方计费方式。它需要新版宿主,旧版宿主调用会返回 400。
41
+
32
42
  ## 实现方式
33
43
 
34
44
  宿主半边(`lib/index.js`):
@@ -38,7 +48,7 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
38
48
  - 把该目录与 `workspaceRegistry.list()` 做规范化后比对(绝对化、去掉尾部分隔符、Windows 折大小写);
39
49
  - 由每一层派生出稳定的 ref —— 工作区是 `DEEPSEEK_API_KEY_WS_<sha1(规范化路径) 前 16 位十六进制大写>`,会话是 `DEEPSEEK_API_KEY_SS_<sha1(会话 id) 前 16 位十六进制大写>`(会话 id 含 `-`,不能直接当 ref 名)——通过凭据服务写入 key,并返回这个 ref 的值;
40
50
  - 只改写 provider 真正会读的 ref:默认 ref、每个适配器配置的 `apiKeyEnv`,以及插件自己生成的 ref;其它 ref 原样透传;
41
- - 把「搜索请求」和「聊天请求」分开——**按 ref 分辨,而不是包住 web 服务**。官方新版 Search/Fetch API 自己解析 key,用的是 `web-deepseek-api.apiKeyEnv` 指向的凭据;本插件在启动时(以及面板每次保存搜索 key 时)把它指到插件自有的 `DEEPSEEK_API_KEY_SEARCH`,于是只看 ref 就能认出「这是搜索在要 key」:聊天永远不会解析这个 ref。0.2.15 起删掉了原先「把 `ctx.web.search` / `ctx.web.fetch` 包进 `AsyncLocalStorage`」的做法——包住 web 服务可能让 `web_search` / `web_fetch` 一直挂到工具 30/60 秒预算耗尽(连 `http://127.0.0.1:1/` 这种必然立刻失败的地址也一样挂)。规则本身没变:先看会话自己的 key(永远最优先),再看该工作区有没有勾「同步搜索 API」,勾了就依次用工作区 key、系统默认 key;没勾时先用插件面板自己持有的搜索默认 ref(若配了),最后才用搜索插件配置的那个 ref。旧版基于 Messages 的搜索仍然走原来的聊天层级,因为那条路线本身就是一次 messages 请求;
51
+ - 把「搜索请求」和「聊天请求」分开——**按 ref 分辨,而不是包住 web 服务**。官方新版 Search/Fetch API 自己解析 key,用的是 `web-deepseek-api.apiKeyEnv` 指向的凭据;本插件在启动时(以及面板每次保存搜索 key 时)把它指到插件自有的 `DEEPSEEK_API_KEY_SEARCH`,于是只看 ref 就能认出「这是搜索在要 key」:聊天永远不会解析这个 ref。0.2.15 起删掉了原先「把 `ctx.web.search` / `ctx.web.fetch` 包进 `AsyncLocalStorage`」的做法——包住 web 服务可能让 `web_search` / `web_fetch` 一直挂到工具 30/60 秒预算耗尽(连 `http://127.0.0.1:1/` 这种必然立刻失败的地址也一样挂)。规则本身没变:先看会话自己的 key(永远最优先),再看该工作区有没有勾「同步搜索 API」,勾了就依次用工作区 key、系统默认 key;没勾时先用插件面板自己持有的搜索默认 ref(若配了),最后才用搜索插件配置的那个 ref。旧版基于 Messages 的搜索仍然走原来的聊天层级,因为那条路线本身就是一次 messages 请求。新版计费模型下不再写 `apiKeyEnv`(那是死字段):`api-key` 计费时搜索解析 `DEEPSEEK_API_KEY`,自然沿着聊天级联走;`account` 计费走账号令牌,插件覆盖不了,只做提示;实验性统一 key 则把 `searchApiKeyEnv` 指到插件自有的 `DEEPSEEK_API_KEY_SEARCH`,并按 ref 分辨、优先返回这把 key;
42
52
  - 会话列表来自 `sessionController` 服务(`list()`,会拆开它 `{items: […]}` 的信封),控件缺失 / 抛错 / 列表为空时再退到宿主 live 会话服务(`ctx.get('sessions').list()`),所以那种情况下面板仍能列出会话;侧栏藏起来的两种会话这里也藏:子代理会话(`origin: 'subagent'` 或带 `parentSession`)和**空会话**(建了但一句话都没发过,侧栏只显示当前开着的那个)。两种都不过滤的话,一个只有 1 个真实会话的工作区会列出 13 个;
43
53
  - 不是注册工作区的目录,只有侧栏真的会显示它才会被列出来。这件事宿主判不了:「空会话里只有当前开着的那个算数」是浏览器端才知道的事(`retainedBy.mainView`),所以宿主把这种目录连同每个会话的 `blank` 标记一起发出去,并用 `hiddenBlankDirs` 点名「里面全是空会话」的目录,由浏览器半边套用与侧栏一致的判据;
44
54
  - `/wsk-api/state` 会带上插件自身版本与会话探针(`available`、`shape`、`source`、`count`、`withoutCwd`、`subagents`、`samples`、`error`,以及工作区路径样本),面板据此说明**为什么**没有列出会话:宿主半边是旧版本(只刷新了页面而没重启应用)、没有 `sessionController`、`list()` 抛错、信封形状不认识,还是会话的 `cwd` 与任何工作区路径都对不上;
@@ -59,6 +69,7 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
59
69
  | POST | `/wsk-api/test` | `{scope, path \| sessionId \| ref}`(或 `{key}`)真发一次请求并返回判定;`invalid` 打标记,`ok` 清标记 |
60
70
  | POST | `/wsk-api/clear-flag` | `{ref}` 或一个目标,清掉失效/配额标记 |
61
71
  | POST | `/wsk-api/search-sync` | `{path, enabled}` —— 打开或关闭某个工作区的「同步搜索 API」 |
72
+ | POST | `/wsk-api/search-unified` | `{enabled, key?}` —— 实验性:把所有会话/工作区的搜索与获取固定到同一把 key;开启时写 `searchApiKeyEnv` / `fetchApiKeyEnv`,关闭时清空 |
62
73
 
63
74
  只接受本机回环调用(`127.0.0.1`、`::1`、`::ffff:127.0.0.1`),且 `Origin` 必须同源或缺失。任何响应都不会包含明文 key,只有掩码。
64
75
 
@@ -81,7 +92,7 @@ dsh plugin --profile web add dsh-workspace-api-key
81
92
  pnpm add dsh-workspace-api-key # 或者:npm install dsh-workspace-api-key
82
93
  ```
83
94
 
84
- 锁版本用 `dsh-workspace-api-key@0.2.17`;直接从仓库装则用 `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key`(git 来源的插件会在安装时现场构建)。
95
+ 锁版本用 `dsh-workspace-api-key@0.3.0`;直接从仓库装则用 `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key`(git 来源的插件会在安装时现场构建)。
85
96
 
86
97
  然后把 `"dsh-workspace-api-key"` 加进该 profile `package.json` 的 `dsh.profile.bundles`,重启 DSH。本地开发用 link 代替:
87
98
 
@@ -92,16 +103,17 @@ pnpm add dsh-workspace-api-key # 或者:npm install dsh-workspace-api
92
103
  ## 测试
93
104
 
94
105
  ```sh
95
- node test/host.test.mjs # 177 条断言
96
- node test/client.test.mjs # 180 条断言
106
+ node test/host.test.mjs # 196 条断言
107
+ node test/client.test.mjs # 180 条断言
108
+ node test/client-billing.test.mjs # 8 条断言(新版计费模型)
97
109
  ```
98
110
 
99
- 两个套件都不需要启动 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`、提醒条与「使用说明」弹窗。
111
+ 两个主套件都不需要启动 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`、提醒条与「使用说明」弹窗;`client-billing.test.mjs` 专门验证新版计费模型下面板不再出现旧搜索 UI、只给官方设置入口,以及统一搜索 key 的提示。i18n 套件(`client-i18n.test.mjs`,7 条断言)验证英文字典完整。
100
112
 
101
113
  ## 要求与限制
102
114
 
103
- - 已在 DSH Desktop 2.0.17(`@deepseek-ai/dsh*` 0.2.0-rc.2、`@deepseek-ai/cordis` 4.0.4)上验证。插件没有运行时依赖,也不 import 任何 `@deepseek-ai/*` 包,只用宿主服务(`credentials`、`workspaceRegistry`、`agents`、`sessionController`、`webServer`、`llm`、`settings`)与 Node 内置模块。
104
- - 搜索功能依赖自带官方 Search/Fetch API 的 DSH(`web-deepseek-api`,即「设置 → 插件 → 网页搜索」里那个「启用新版 Web 搜索 API」)。没有这个能力的版本上,插件根本收不到搜索请求:开关照旧显示,面板会如实报告当前是旧版 Messages 通道,而不是瞎猜。
115
+ - 已在 DSH Desktop(`@deepseek-ai/dsh*` 0.2.2-rc.2、`@deepseek-ai/cordis` 4.0.4)上验证。插件没有运行时依赖,也不 import 任何 `@deepseek-ai/*` 包,只用宿主服务(`credentials`、`workspaceRegistry`、`agents`、`sessionController`、`webServer`、`llm`、`settings`)与 Node 内置模块。
116
+ - 搜索功能依赖自带官方 Search/Fetch API 的 DSH(`web-deepseek-api`,即「设置 → 插件 → 网页搜索」里那个「启用新版 Web 搜索 API」)。没有这个能力的版本上,插件根本收不到搜索请求:开关照旧显示,面板会如实报告当前是旧版 Messages 通道,而不是瞎猜。新版宿主改用「计费方式」后,`account` 计费走账号令牌,插件无法覆盖,只能提示;需要按会话/工作区 key 计费时请把计费方式设为「DeepSeek API Key」,或使用设置页里的实验性「统一搜索 key」。
105
117
  - 每一层只有一把 key:一个会话无论被哪个 provider 路由读取,用的都是同一把 key;key 记下的服务商与会话当前服务商不一致时会被报为「不匹配」,但同一服务商内换模型不算不匹配,因为 DeepSeek key 是账号级的。
106
118
  - 会话按目录(`cwd`)归入工作区。宿主自己的 `list()` 会跳过从未存过 `cwd` 的历史会话,而 live 兜底只认识当前装载中的会话,所以面板列出的会话可能比侧栏少;只在浏览器端存储里可见的会话,除了侧栏知道的那个 `cwd` 之外没有别的路径信息。
107
119
  - 「测试连接」说的是 Anthropic Messages 协议(`POST <baseURL>/messages` 带 `x-api-key` 与 `anthropic-version: 2023-06-01`),也就是 DeepSeek 路由用的协议;换了别的协议的 provider 一样可以按层绑 key,只是测试可能把有效的 key 报成失败。
package/lib/client.js CHANGED
@@ -167,7 +167,37 @@ window.__ModuleLoader__.load({
167
167
  "再或者按 Ctrl+R 刷新页面 —— 面板固定状态只存在内存里,刷新就会恢复默认。": "or press Ctrl+R to reload the page — the pin state lives only in memory, so a reload restores the defaults.",
168
168
  "打开 API Key 分配面板": "Open the API Key Allocation panel",
169
169
  "已切到该面板:如果它还是没出现,说明面板正在加载,稍等一下。": "Switched to that panel: if it has not appeared yet, it is still loading — give it a moment.",
170
- "这里同时管着每个工作区 / 会话的 key 与搜索通道;面板右上角的「使用说明」有完整的优先级说明。": "This panel manages the key and search channel of every workspace and session; the \"How to use\" button in its header explains the full precedence order."
170
+ "这里同时管着每个工作区 / 会话的 key 与搜索通道;面板右上角的「使用说明」有完整的优先级说明。": "This panel manages the key and search channel of every workspace and session; the \"How to use\" button in its header explains the full precedence order.",
171
+ "搜索与获取": "Search & fetch",
172
+ "新版宿主用「计费方式」管理网页搜索 / 网页获取:key 由官方设置页决定,本面板不再接管,也不再显示旧的搜索 key。": "The newer host manages web search and fetch by billing mode: the key is decided on the official settings page, so this panel no longer takes over search keys or shows the old search key.",
173
+ "实验性「统一搜索 key」已开启:所有会话 / 工作区搜索都使用同一把 key。可在「设置 → API Key 分配」里更换或关闭。": "The experimental unified search key is on: every session and workspace searches with the same key. Change or disable it in Settings → API Key Allocation.",
174
+ "计费方式为「DeepSeek API Key」时,搜索会跟随当前会话 / 工作区的聊天 key;为「已登录的 DeepSeek 账号」时,搜索走账号令牌,插件无法覆盖。": "With billing set to \"DeepSeek API Key\", searches follow the current session/workspace chat key; with \"Signed-in DeepSeek account\", searches use the account token, which this plugin cannot override.",
175
+ "自动": "Automatic",
176
+ "已登录的 DeepSeek 账号": "Signed-in DeepSeek account",
177
+ "打开官方搜索设置": "Open official search settings",
178
+ "检测到的宿主版本:": "Detected host version: ",
179
+ "搜索 API(实验性)": "Search API (experimental)",
180
+ "新版宿主由官方「设置 → 插件 → 网页搜索」统一管理搜索/获取的计费方式。这里只保留一个实验开关:把所有会话的搜索强制指向插件持有的同一把 key,适合“一把搜索 key 走天下”。": "The newer host manages search/fetch billing on the official Settings → Plugins → Web Search page. Only one experimental switch remains here: force every session's searches to use a single key held by this plugin — handy for a single search key everywhere.",
181
+ "当前已启用:所有会话 / 工作区搜索都使用 DEEPSEEK_API_KEY_SEARCH 这一把 key,不再跟随各自的聊天 key。": "Currently on: every session and workspace searches with DEEPSEEK_API_KEY_SEARCH instead of its chat key.",
182
+ "粘贴新的搜索 key(sk-…)": "Paste a new search key (sk-…)",
183
+ "保存并继续使用": "Save and keep using",
184
+ "关闭统一搜索 key": "Disable unified search key",
185
+ "开启后,插件会把官方搜索配置的 key 指向自己持有的 ref,所有会话 / 工作区搜索都用这一把,不再跟随各自的聊天 key。关闭时会把官方配置清回默认计费方式。": "When enabled, the plugin points the official search key at its own ref, so every session and workspace searches with that single key instead of its chat key. Disabling clears the official setting back to the default billing mode.",
186
+ "粘贴搜索 key(sk-…)": "Paste a search key (sk-…)",
187
+ "开启": "Enable",
188
+ "已开启:所有会话/工作区共用这把搜索 key。": "Enabled: every session and workspace now shares this search key.",
189
+ "已关闭统一搜索 key,回到官方计费方式。": "Unified search key disabled; back to the official billing mode.",
190
+ "搜索 / 获取已交给官方设置": "Search & fetch are handled by the official settings",
191
+ "当前宿主的网页搜索与网页获取按「计费方式」选择用哪把 key,本插件不再接管搜索 key,也不显示旧的搜索配置。": "The current host picks the key for web search and fetch by billing mode; this plugin no longer takes over search keys or shows the old search configuration.",
192
+ "计费方式为「DeepSeek API Key」:搜索跟随当前会话 / 工作区的聊天 key,账单一起算。": "Billing \"DeepSeek API Key\": searches follow the current session/workspace chat key and share its bill.",
193
+ "计费方式为「已登录的 DeepSeek 账号」:搜索走账号令牌,插件无法覆盖。": "Billing \"Signed-in DeepSeek account\": searches use the account token, which this plugin cannot override.",
194
+ "「自动」:看当前会话走的是哪个模型路由,规则由宿主决定。": "\"Automatic\": follows the model route of the current session; the host decides the rules.",
195
+ "要改计费方式,点面板里的「打开官方搜索设置」,或手动到「设置 → 插件 → 网页搜索」。": "To change billing, press \"Open official search settings\" in the panel, or go to Settings → Plugins → Web Search manually.",
196
+ "实验性的「统一搜索 key」在「设置 → API Key 分配」页里,开启后所有会话搜索共用插件持有的一把 key。": "The experimental unified search key lives on the Settings → API Key Allocation page; when on, every session searches with one key held by the plugin.",
197
+ "聊天 key 的优先级": "Chat key precedence",
198
+ "会话单独配置的 key": "a key configured for the session",
199
+ "工作区专属 key": "a workspace-specific key",
200
+ "系统默认 key": "the system default key"
171
201
  }
172
202
 
173
203
  const EN_HOST = {
@@ -772,6 +802,61 @@ window.__ModuleLoader__.load({
772
802
 
773
803
  // ─────────────────────────────── 主页面 ───────────────────────────────
774
804
 
805
+ function billingText(value) {
806
+ switch (value) {
807
+ case 'account': return tr('已登录的 DeepSeek 账号')
808
+ case 'api-key': return tr('DeepSeek API Key')
809
+ default: return tr('自动')
810
+ }
811
+ }
812
+
813
+ /**
814
+ * 跳到官方「设置 → 插件 → 网页搜索」。
815
+ *
816
+ * 宿主没有给普通插件开放「把设置打开到某个 section」的 API(settings store 的
817
+ * openSection 只发给 settings.launcher),所以这里做尽力而为的点击导航:
818
+ * 打开设置 → 点「插件」→ 点「网页搜索」。任何一步失败,用户至少停在设置里。
819
+ */
820
+ function openOfficialSearchSettings() {
821
+ const findClickable = (root, pattern) => {
822
+ const nodes = root.querySelectorAll('button, a, [role="tab"], [role="button"], [role="menuitem"]')
823
+ for (const node of nodes) {
824
+ const text = (node.textContent ?? '').trim()
825
+ if (text !== '' && pattern.test(text)) {
826
+ node.click()
827
+ return true
828
+ }
829
+ }
830
+ return false
831
+ }
832
+ try {
833
+ if (document.querySelector('[data-shortcut-modal="settings"]') === null) {
834
+ const trigger = document.querySelector('button[aria-haspopup="dialog"]')
835
+ if (trigger !== null) trigger.click()
836
+ else window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', code: 'Comma', ctrlKey: true, bubbles: true }))
837
+ }
838
+ let tries = 0
839
+ const timer = setInterval(() => {
840
+ tries += 1
841
+ const modal = document.querySelector('[data-shortcut-modal="settings"]')
842
+ const text = modal === null ? '' : (modal.textContent ?? '')
843
+ if (modal !== null && /插件|Plugins?/i.test(text)) {
844
+ if (findClickable(modal, /^插件$|^Plugins?$/i)) {
845
+ clearInterval(timer)
846
+ setTimeout(() => {
847
+ const again = document.querySelector('[data-shortcut-modal="settings"]')
848
+ if (again !== null) findClickable(again, /网页搜索|Web search/i)
849
+ }, 150)
850
+ return
851
+ }
852
+ }
853
+ if (tries > 40) clearInterval(timer)
854
+ }, 50)
855
+ } catch {
856
+ /* 尽力而为:至少把设置打开 */
857
+ }
858
+ }
859
+
775
860
  /**
776
861
  * 「设置 → API Key 分配」这一栏:侧栏图标没了也能从这里回到面板。
777
862
  *
@@ -783,6 +868,40 @@ window.__ModuleLoader__.load({
783
868
  function SettingsSection() {
784
869
  useI18n()
785
870
  const [opened, setOpened] = useState(false)
871
+ const [state, setState] = useState(null)
872
+ const [unifiedKey, setUnifiedKey] = useState('')
873
+ const [unifiedBusy, setUnifiedBusy] = useState(false)
874
+ const [unifiedNotice, setUnifiedNotice] = useState(null)
875
+ useEffect(() => {
876
+ let alive = true
877
+ api('/state').then((next) => { if (alive) setState(next) }).catch(() => {})
878
+ return () => { alive = false }
879
+ }, [])
880
+ const search = state?.search ?? null
881
+ const billing = search?.mode === 'billing'
882
+ const unified = search?.unified === true
883
+ const saveUnified = async (enabled) => {
884
+ setUnifiedBusy(true)
885
+ setUnifiedNotice(null)
886
+ try {
887
+ const next = await api('/search-unified', {
888
+ method: 'POST',
889
+ body: JSON.stringify({ enabled, key: unifiedKey }),
890
+ })
891
+ setState(next)
892
+ setUnifiedKey('')
893
+ setUnifiedNotice({
894
+ kind: 'ok',
895
+ text: enabled
896
+ ? '已开启:所有会话/工作区共用这把搜索 key。'
897
+ : '已关闭统一搜索 key,回到官方计费方式。',
898
+ })
899
+ } catch (err) {
900
+ setUnifiedNotice({ kind: 'err', text: String(err?.message ?? err) })
901
+ } finally {
902
+ setUnifiedBusy(false)
903
+ }
904
+ }
786
905
  const openPanel = () => {
787
906
  try {
788
907
  const layout = host.ctx === null ? undefined : host.ctx.get('layout')
@@ -811,7 +930,64 @@ window.__ModuleLoader__.load({
811
930
  '已切到该面板:如果它还是没出现,说明面板正在加载,稍等一下。')
812
931
  : null,
813
932
  h('div', { className: 'wsk-hint', style: { marginTop: 10 } },
814
- '这里同时管着每个工作区 / 会话的 key 与搜索通道;面板右上角的「使用说明」有完整的优先级说明。')))
933
+ '这里同时管着每个工作区 / 会话的 key 与搜索通道;面板右上角的「使用说明」有完整的优先级说明。')),
934
+ billing
935
+ ? h('div', { className: 'wsk-card' },
936
+ h('h4', null, '搜索 API(实验性)'),
937
+ h('div', { className: 'wsk-hint' },
938
+ '新版宿主由官方「设置 → 插件 → 网页搜索」统一管理搜索/获取的计费方式。这里只保留一个实验开关:把所有会话的搜索强制指向插件持有的同一把 key,适合“一把搜索 key 走天下”。'),
939
+ unified
940
+ ? h('div', null,
941
+ h('div', { className: 'wsk-hint' },
942
+ '当前已启用:所有会话 / 工作区搜索都使用 DEEPSEEK_API_KEY_SEARCH 这一把 key,不再跟随各自的聊天 key。'),
943
+ h('div', { className: 'wsk-row', style: { marginTop: 8 } },
944
+ h('input', {
945
+ className: 'wsk-input',
946
+ type: 'password',
947
+ value: unifiedKey,
948
+ placeholder: '粘贴新的搜索 key(sk-…)',
949
+ onChange: (ev) => setUnifiedKey(ev.target.value),
950
+ }),
951
+ h('button', {
952
+ type: 'button', className: 'wsk-btn primary',
953
+ disabled: unifiedBusy || unifiedKey.trim() === '',
954
+ onClick: () => saveUnified(true),
955
+ }, '保存并继续使用'),
956
+ h('button', {
957
+ type: 'button', className: 'wsk-btn',
958
+ disabled: unifiedBusy,
959
+ onClick: () => saveUnified(false),
960
+ }, '关闭统一搜索 key')))
961
+ : h('div', null,
962
+ h('div', { className: 'wsk-hint' },
963
+ '开启后,插件会把官方搜索配置的 key 指向自己持有的 ref,所有会话 / 工作区搜索都用这一把,不再跟随各自的聊天 key。关闭时会把官方配置清回默认计费方式。'),
964
+ h('div', { className: 'wsk-row', style: { marginTop: 8 } },
965
+ h('input', {
966
+ className: 'wsk-input',
967
+ type: 'password',
968
+ value: unifiedKey,
969
+ placeholder: '粘贴搜索 key(sk-…)',
970
+ onChange: (ev) => setUnifiedKey(ev.target.value),
971
+ }),
972
+ h('button', {
973
+ type: 'button', className: 'wsk-btn primary',
974
+ disabled: unifiedBusy || unifiedKey.trim() === '',
975
+ onClick: () => saveUnified(true),
976
+ }, '开启'))),
977
+ unifiedNotice !== null
978
+ ? h('div', {
979
+ className: 'wsk-hint',
980
+ style: { marginTop: 8, color: unifiedNotice.kind === 'ok' ? undefined : '#e5484d' },
981
+ }, unifiedNotice.text)
982
+ : null,
983
+ h('button', {
984
+ type: 'button', className: 'wsk-btn', style: { marginTop: 8 },
985
+ onClick: openOfficialSearchSettings,
986
+ }, '打开官方搜索设置'))
987
+ : null,
988
+ billing && typeof state?.hostVersion === 'string' && state.hostVersion !== ''
989
+ ? h('div', { className: 'wsk-hint' }, tf(`检测到的宿主版本:${state.hostVersion}`, `Detected host version: ${state.hostVersion}`))
990
+ : null)
815
991
  }
816
992
 
817
993
  function WorkspaceKeyPage(props) {
@@ -1321,7 +1497,7 @@ window.__ModuleLoader__.load({
1321
1497
  }, verdictText(lastTest.verdict))
1322
1498
  : null),
1323
1499
  h('div', { className: 'wsk-path' }, prettyPath(ws.path)),
1324
- ws.kind === 'orphan'
1500
+ ws.kind === 'orphan' || searchInfo?.mode === 'billing'
1325
1501
  ? null
1326
1502
  : h('label', {
1327
1503
  key: 'sync',
@@ -1342,7 +1518,7 @@ window.__ModuleLoader__.load({
1342
1518
  : searchInfo?.defaultConfigured === true
1343
1519
  ? '搜索用搜索默认 key'
1344
1520
  : '搜索用搜索插件里的 key')),
1345
- ws.kind !== 'orphan' && searchInfo?.apiChannel === true && ws.searchKeyMasked !== undefined
1521
+ ws.kind !== 'orphan' && searchInfo?.mode !== 'billing' && searchInfo?.apiChannel === true && ws.searchKeyMasked !== undefined
1346
1522
  ? h('div', { key: 'sk', className: 'wsk-hint', style: { marginTop: 2 } },
1347
1523
  tf(`搜索生效 key:${ws.searchKeyMasked || '未配置'}`, `Effective search key: ${ws.searchKeyMasked || 'not configured'}`),
1348
1524
  ws.searchSync === true
@@ -1385,7 +1561,30 @@ window.__ModuleLoader__.load({
1385
1561
  : null)
1386
1562
  }
1387
1563
 
1388
- const renderHelp = () => h(Dialog, {
1564
+ const renderHelp = () => (searchInfo?.mode === 'billing'
1565
+ ? h(Dialog, {
1566
+ title: '使用说明',
1567
+ onDismiss: () => setHelp(false),
1568
+ body: h('div', { className: 'wsk-help' },
1569
+ h('h4', null, '搜索 / 获取已交给官方设置'),
1570
+ h('div', { className: 'wsk-hint' },
1571
+ '当前宿主的网页搜索与网页获取按「计费方式」选择用哪把 key,本插件不再接管搜索 key,也不显示旧的搜索配置。'),
1572
+ h('ul', null,
1573
+ h('li', null, '计费方式为「DeepSeek API Key」:搜索跟随当前会话 / 工作区的聊天 key,账单一起算。'),
1574
+ h('li', null, '计费方式为「已登录的 DeepSeek 账号」:搜索走账号令牌,插件无法覆盖。'),
1575
+ h('li', null, '「自动」:看当前会话走的是哪个模型路由,规则由宿主决定。')),
1576
+ h('div', { className: 'wsk-hint' },
1577
+ '要改计费方式,点面板里的「打开官方搜索设置」,或手动到「设置 → 插件 → 网页搜索」。'),
1578
+ h('div', { className: 'wsk-hint' },
1579
+ '实验性的「统一搜索 key」在「设置 → API Key 分配」页里,开启后所有会话搜索共用插件持有的一把 key。'),
1580
+ h('h4', null, '聊天 key 的优先级'),
1581
+ h('ol', null,
1582
+ h('li', null, '会话单独配置的 key'),
1583
+ h('li', null, '工作区专属 key'),
1584
+ h('li', null, '系统默认 key'))),
1585
+ actions: [{ label: '知道了', primary: true, onClick: () => setHelp(false) }],
1586
+ })
1587
+ : h(Dialog, {
1389
1588
  title: '使用说明',
1390
1589
  onDismiss: () => setHelp(false),
1391
1590
  body: h('div', { className: 'wsk-help' },
@@ -1430,7 +1629,7 @@ window.__ModuleLoader__.load({
1430
1629
  h('div', { className: 'wsk-hint' },
1431
1630
  '提醒只报告、不替你改 key。插件绝不会自动切换或替换你的 key,避免计费被悄悄改到别的账号上。')),
1432
1631
  actions: [{ label: '知道了', primary: true, onClick: () => setHelp(false) }],
1433
- })
1632
+ }))
1434
1633
 
1435
1634
  const renderPrompt = () => {
1436
1635
  if (prompt === null) return null
@@ -1572,7 +1771,28 @@ window.__ModuleLoader__.load({
1572
1771
  onClick: () => runTest(DEFAULT_KEY, '系统默认', { ref: defaultRef?.ref ?? DEFAULT_REF }),
1573
1772
  }, testing === DEFAULT_KEY ? '测试中…' : '测试连接')))),
1574
1773
 
1575
- searchInfo?.apiChannel === true
1774
+ searchInfo?.mode === 'billing'
1775
+ ? h('div', { className: 'wsk-card' },
1776
+ h('h4', null, '搜索与获取'),
1777
+ h('div', { className: 'wsk-hint' },
1778
+ '新版宿主用「计费方式」管理网页搜索 / 网页获取:key 由官方设置页决定,本面板不再接管,也不再显示旧的搜索 key。'),
1779
+ h('div', { className: 'wsk-hint' },
1780
+ tf(
1781
+ `当前计费方式:搜索「${billingText(searchInfo.billing?.search)}」,获取「${billingText(searchInfo.billing?.fetch)}」`,
1782
+ `Current billing: search "${billingText(searchInfo.billing?.search)}", fetch "${billingText(searchInfo.billing?.fetch)}"`)),
1783
+ searchInfo?.unified === true
1784
+ ? h('div', { className: 'wsk-hint' },
1785
+ '实验性「统一搜索 key」已开启:所有会话 / 工作区搜索都使用同一把 key。可在「设置 → API Key 分配」里更换或关闭。')
1786
+ : h('div', { className: 'wsk-hint' },
1787
+ '计费方式为「DeepSeek API Key」时,搜索会跟随当前会话 / 工作区的聊天 key;为「已登录的 DeepSeek 账号」时,搜索走账号令牌,插件无法覆盖。'),
1788
+ h('div', { className: 'wsk-row', style: { marginTop: 8 } },
1789
+ h('button', {
1790
+ type: 'button', className: 'wsk-btn primary',
1791
+ onClick: openOfficialSearchSettings,
1792
+ }, '打开官方搜索设置')))
1793
+ : null,
1794
+
1795
+ searchInfo?.mode !== 'billing' && searchInfo?.apiChannel === true
1576
1796
  ? h('div', { className: 'wsk-card' },
1577
1797
  h('h4', null, '搜索默认 key'),
1578
1798
  h('div', { className: 'wsk-row' },
package/lib/index.js CHANGED
@@ -32,6 +32,7 @@
32
32
 
33
33
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
34
34
  import { createHash } from 'node:crypto'
35
+ import { createRequire } from 'node:module'
35
36
  import { homedir } from 'node:os'
36
37
  import { join, dirname, basename, resolve as pathResolve } from 'node:path'
37
38
 
@@ -116,6 +117,11 @@ function isSearchRefFor(ctx, ref) {
116
117
  if (typeof ref !== 'string' || ref === '') return false
117
118
  const web = currentWeb(ctx)
118
119
  if (web.apiChannel !== true) return false
120
+ // 新版计费模型:搜索只认 `searchApiKeyEnv` 指向的自有 ref;聊天 ref(DEEPSEEK_API_KEY)
121
+ // 绝不能被当成搜索处理,否则「统一搜索 key」会把聊天也一起换掉。
122
+ if (web.mode === 'billing') {
123
+ return ref === SEARCH_DEFAULT_REF || (web.ownSearchRef !== undefined && ref === web.ownSearchRef)
124
+ }
119
125
  // 官方页还没被接管时,搜索用的是聊天那把 ref:这时分辨不了,按聊天处理(安全)。
120
126
  if (web.searchKeyEnv === DEFAULT_API_KEY_REF) return ref === SEARCH_DEFAULT_REF
121
127
  return ref === SEARCH_DEFAULT_REF || ref === web.searchKeyEnv || ref === web.legacyKeyEnv
@@ -142,6 +148,39 @@ const PLUGIN_VERSION = (() => {
142
148
  }
143
149
  })()
144
150
 
151
+ /**
152
+ * 宿主运行时版本(尽力而为,只用于界面展示与日志)。
153
+ *
154
+ * 行为判断**不**依赖它:桌面端宿主代码打在 app.asar 里,`@deepseek-ai/dsh`
155
+ * 不一定能从插件目录解析到;而新版搜索配置的 schema 特征(searchBilling /
156
+ * fetchBilling)与行为一一对应,所以 gating 用特性检测,版本号只是辅助信息。
157
+ */
158
+ const HOST_VERSION = (() => {
159
+ try {
160
+ const req = createRequire(import.meta.url)
161
+ let main
162
+ try {
163
+ main = req.resolve('@deepseek-ai/dsh')
164
+ } catch {
165
+ main = undefined
166
+ }
167
+ if (main === undefined) return undefined
168
+ // 从入口文件往上找最近的 package.json。
169
+ let dir = dirname(main)
170
+ for (let depth = 0; depth < 5; depth += 1) {
171
+ const candidate = join(dir, 'package.json')
172
+ if (existsSync(candidate)) {
173
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'))
174
+ if (typeof pkg?.version === 'string') return pkg.version
175
+ }
176
+ dir = dirname(dir)
177
+ }
178
+ } catch {
179
+ /* ignore */
180
+ }
181
+ return undefined
182
+ })()
183
+
145
184
  // ─────────────────────────── 路径 / 工作区键 ───────────────────────────
146
185
 
147
186
  /** 统一的路径比较用规范形:绝对化 + 分隔符统一 + Windows 折大小写。 */
@@ -354,6 +393,32 @@ async function bindOfficialSearchRef(ctx, ref) {
354
393
  return { ok: true, changed }
355
394
  }
356
395
 
396
+ /**
397
+ * 写新版搜索 API 的 schema 字段(实验性「统一搜索 key」用)。
398
+ *
399
+ * 只改 `searchApiKeyEnv` / `fetchApiKeyEnv` 这类声明过的字段,不碰任何 key 值;
400
+ * 空字符串表示「清掉自有 ref、回到官方计费方式」(官方 resolveTarget 用 nonEmpty 判断)。
401
+ */
402
+ async function updateSearchApiSettings(ctx, patch) {
403
+ let settings
404
+ try {
405
+ settings = ctx.get('settings')
406
+ } catch {
407
+ settings = undefined
408
+ }
409
+ if (typeof settings?.update !== 'function' || typeof settings?.describe !== 'function') {
410
+ return { ok: false, reason: 'settings-unavailable' }
411
+ }
412
+ try {
413
+ const row = (settings.describe() ?? []).find((item) => item?.ns === SEARCH_API_NS)
414
+ if (row === undefined) return { ok: false, reason: 'search-settings-missing' }
415
+ await settings.update(SEARCH_API_NS, patch, row.revision)
416
+ return { ok: true }
417
+ } catch (error) {
418
+ return { ok: false, reason: error?.message ?? String(error) }
419
+ }
420
+ }
421
+
357
422
  /**
358
423
  * 启动时把官方「网页搜索」页的 key 指向拉回一致。
359
424
  *
@@ -879,6 +944,14 @@ function webDefaults() {
879
944
  apiChannel: false,
880
945
  searchKeyEnv: DEFAULT_API_KEY_REF,
881
946
  searchLiteralKey: undefined,
947
+ // 搜索配置模型:'ref' = 旧版(apiKeyEnv 指针);'billing' = 0.2.2-rc.2 起的计费方式模型。
948
+ mode: 'ref',
949
+ searchBilling: undefined,
950
+ fetchBilling: undefined,
951
+ ownSearchRef: undefined,
952
+ ownFetchRef: undefined,
953
+ searchBaseURL: undefined,
954
+ fetchBaseURL: undefined,
882
955
  }
883
956
  }
884
957
 
@@ -903,6 +976,27 @@ function webConfigOf(rows) {
903
976
  : FALLBACK_FETCH_PROVIDER
904
977
  const apiLiteralKey = typeof api.apiKey === 'string' && api.apiKey !== '' ? api.apiKey : undefined
905
978
  const legacyLiteralKey = typeof legacy.apiKey === 'string' && legacy.apiKey !== '' ? legacy.apiKey : undefined
979
+
980
+ // 0.2.2-rc.2 起,搜索改由「计费方式」决定凭据:searchBilling / fetchBilling +
981
+ // (可选的)自有端点/ref。旧字段 apiKeyEnv 还能加载,但已经不再生效。
982
+ // 用「行里有没有 *_Billing」做特性检测,而不是解析宿主版本号:版本号在桌面端
983
+ // 拿不到(宿主代码打在 asar 里),而这个 schema 特征与行为是一一对应的。
984
+ const searchBilling = typeof api.searchBilling === 'string' && api.searchBilling !== '' ? api.searchBilling : undefined
985
+ const fetchBilling = typeof api.fetchBilling === 'string' && api.fetchBilling !== '' ? api.fetchBilling : undefined
986
+ const mode = searchBilling !== undefined || fetchBilling !== undefined ? 'billing' : 'ref'
987
+ const ownSearchRef = typeof api.searchApiKeyEnv === 'string' && api.searchApiKeyEnv !== ''
988
+ ? api.searchApiKeyEnv
989
+ : undefined
990
+ const ownFetchRef = typeof api.fetchApiKeyEnv === 'string' && api.fetchApiKeyEnv !== ''
991
+ ? api.fetchApiKeyEnv
992
+ : undefined
993
+ const searchBaseURL = typeof api.searchBaseURL === 'string' && api.searchBaseURL !== ''
994
+ ? api.searchBaseURL
995
+ : FALLBACK_SEARCH_BASE_URL
996
+ const fetchBaseURL = typeof api.fetchBaseURL === 'string' && api.fetchBaseURL !== ''
997
+ ? api.fetchBaseURL
998
+ : FALLBACK_SEARCH_BASE_URL
999
+
906
1000
  return {
907
1001
  apiKeyEnv,
908
1002
  legacyKeyEnv,
@@ -915,8 +1009,17 @@ function webConfigOf(rows) {
915
1009
  /** 新版 Search/Fetch API 是否被选中(官方开关)= provider 选择被改成了 API 版。 */
916
1010
  apiChannel: searchProvider === SEARCH_PROVIDER_ID || fetchProvider === SEARCH_FETCH_PROVIDER_ID,
917
1011
  /** 搜索请求真正会用的那套配置的 key 引用。 */
918
- searchKeyEnv: searchProvider === SEARCH_PROVIDER_ID ? apiKeyEnv : legacyKeyEnv,
1012
+ searchKeyEnv: searchProvider === SEARCH_PROVIDER_ID
1013
+ ? (mode === 'billing' ? ownSearchRef ?? DEFAULT_API_KEY_REF : apiKeyEnv)
1014
+ : legacyKeyEnv,
919
1015
  searchLiteralKey: searchProvider === SEARCH_PROVIDER_ID ? apiLiteralKey : legacyLiteralKey,
1016
+ mode,
1017
+ searchBilling,
1018
+ fetchBilling,
1019
+ ownSearchRef,
1020
+ ownFetchRef,
1021
+ searchBaseURL,
1022
+ fetchBaseURL,
920
1023
  }
921
1024
  }
922
1025
 
@@ -927,6 +1030,30 @@ function webConfigOf(rows) {
927
1030
  */
928
1031
  async function searchWarnings(ctx, state, web) {
929
1032
  const out = []
1033
+
1034
+ // 新版计费模型:搜索不再读 apiKeyEnv 指针,旧提醒(同步/搜索默认 key)全部失去意义。
1035
+ // 这里只保留两条与新版真正相关的提示,避免面板给出过时或错误的引导。
1036
+ if (web.mode === 'billing') {
1037
+ if (web.ownSearchRef === SEARCH_DEFAULT_REF) {
1038
+ out.push({
1039
+ code: 'search-unified',
1040
+ level: 'info',
1041
+ title: '实验性:所有会话的搜索共用同一把 key',
1042
+ detail: `搜索/获取被指向插件自有的 ${SEARCH_DEFAULT_REF},官方「计费方式」暂时不生效。`
1043
+ + '要恢复按会话/工作区计费,到「设置 → API Key 分配」里关掉这项实验功能。',
1044
+ })
1045
+ } else if (web.searchBilling === 'account') {
1046
+ out.push({
1047
+ code: 'search-billing-account',
1048
+ level: 'warn',
1049
+ title: '搜索当前按「已登录的 DeepSeek 账号」计费',
1050
+ detail: '计费方式设为 account 时,搜索只使用账号令牌,本插件配置的 API key 不会参与搜索。'
1051
+ + '要让搜索跟随会话/工作区的 key,请到「设置 → 插件 → 网页搜索」把计费方式改成「DeepSeek API Key」。',
1052
+ })
1053
+ }
1054
+ return out
1055
+ }
1056
+
930
1057
  const apiKeyConfigured = web.apiKeyEnv === DEFAULT_API_KEY_REF
931
1058
  ? (await describeKey(ctx, DEFAULT_API_KEY_REF)).configured === true
932
1059
  : (await describeKey(ctx, web.apiKeyEnv)).configured === true
@@ -1321,7 +1448,9 @@ export function apply(ctx) {
1321
1448
  }
1322
1449
  installResolver(ctx, state)
1323
1450
  // 「直接禁用官方页面里的搜索 key 设置」:把官方 apiKeyEnv 指到插件自有 ref(异步、失败不影响启动)。
1324
- void reconcileOfficialSearchRef(ctx)
1451
+ // 0.2.2-rc.2 起官方改用计费方式(searchBilling/fetchBilling),apiKeyEnv 已不再生效:
1452
+ // 这时再写它只会制造「看起来配了、其实没用」的无效配置,所以只在旧模型下接管。
1453
+ if (currentWeb(ctx).mode !== 'billing') void reconcileOfficialSearchRef(ctx)
1325
1454
 
1326
1455
  // ── 2. REST API(loopback 可信)──────────────────────────────────────
1327
1456
  let webServer
@@ -1404,6 +1533,15 @@ function installResolver(ctx, state) {
1404
1533
  if (isSearchRefFor(ctx, ref)) {
1405
1534
  const web = currentWeb(ctx)
1406
1535
  if (web.apiChannel) {
1536
+ // 实验性「统一搜索 key」:搜索/获取固定用插件自有的那一把,优先于会话/工作区配置。
1537
+ // 只在计费模型下生效:旧模型里 searchApiKeyEnv 就是「搜索默认 key」,语义不同。
1538
+ if (web.mode === 'billing' && web.ownSearchRef === SEARCH_DEFAULT_REF) {
1539
+ const forced = await resolveKey(ctx, SEARCH_DEFAULT_REF)
1540
+ if (forced.value !== undefined && forced.value !== '') {
1541
+ log(`search ref ${ref} → ${SEARCH_DEFAULT_REF} (unified)`)
1542
+ return { value: forced.value, source: forced.source ?? 'plugin' }
1543
+ }
1544
+ }
1407
1545
  // ① 会话单独设置的 key 永远优先——哪怕官方搜索插件里配了别的 key。
1408
1546
  const sessionKey = sessionOverrideRef(state, sessionId)
1409
1547
  if (sessionKey !== undefined) {
@@ -1783,6 +1921,7 @@ async function snapshot(ctx, state) {
1783
1921
  return {
1784
1922
  ok: true,
1785
1923
  pluginVersion: PLUGIN_VERSION,
1924
+ hostVersion: HOST_VERSION,
1786
1925
  storeVersion: STATE_VERSION,
1787
1926
  // 面板卡顿排查用:会话探针(宿主 list 一次要扫所有会话)与整行的耗时。
1788
1927
  timings: { probeMs, rowsMs: Math.max(0, totalMs - probeMs), totalMs },
@@ -1821,6 +1960,13 @@ async function snapshot(ctx, state) {
1821
1960
  // 搜索 / 抓取通道:官方「设置 → 插件 → 网页搜索」里的配置 + 本插件怎么参与。
1822
1961
  // 面板据此显示通道、生效 key 与提醒;不暴露任何明文。
1823
1962
  search: {
1963
+ // 'billing' = 0.2.2-rc.2 起的计费方式模型;'ref' = 旧版 apiKeyEnv 指针模型。
1964
+ mode: web.mode ?? 'ref',
1965
+ billing: { search: web.searchBilling, fetch: web.fetchBilling },
1966
+ ownSearchRef: web.ownSearchRef,
1967
+ ownFetchRef: web.ownFetchRef,
1968
+ // 实验性「统一搜索 key」:把官方 searchApiKeyEnv 指向插件自有 ref。
1969
+ unified: web.mode === 'billing' && web.searchKeyEnv === SEARCH_DEFAULT_REF,
1824
1970
  channel: web.apiChannel ? 'api' : 'messages',
1825
1971
  apiChannel: web.apiChannel,
1826
1972
  searchProvider: web.searchProvider,
@@ -2168,6 +2314,43 @@ async function handle(ctx, state, req, res) {
2168
2314
  return
2169
2315
  }
2170
2316
 
2317
+ if (pathname === `${API_BASE}/search-unified` && method === 'POST') {
2318
+ const body = await readJson(req)
2319
+ const web = refreshWebCache(ctx)
2320
+ if (web.mode !== 'billing') {
2321
+ sendJson(res, 400, { ok: false, error: '当前宿主还是旧版搜索配置,不支持该实验功能' })
2322
+ return
2323
+ }
2324
+ const enabled = body.enabled !== false
2325
+ if (enabled) {
2326
+ const key = typeof body.key === 'string' ? body.key.trim() : ''
2327
+ if (key === '') {
2328
+ sendJson(res, 400, { ok: false, error: '请先填写一把搜索 key' })
2329
+ return
2330
+ }
2331
+ await storeKey(ctx, SEARCH_DEFAULT_REF, key)
2332
+ const updated = await updateSearchApiSettings(ctx, {
2333
+ searchApiKeyEnv: SEARCH_DEFAULT_REF,
2334
+ fetchApiKeyEnv: SEARCH_DEFAULT_REF,
2335
+ })
2336
+ if (updated.ok !== true) {
2337
+ sendJson(res, 500, { ok: false, error: `写入官方搜索配置失败:${updated.reason}` })
2338
+ return
2339
+ }
2340
+ } else {
2341
+ const updated = await updateSearchApiSettings(ctx, { searchApiKeyEnv: '', fetchApiKeyEnv: '' })
2342
+ if (updated.ok !== true) {
2343
+ sendJson(res, 500, { ok: false, error: `写入官方搜索配置失败:${updated.reason}` })
2344
+ return
2345
+ }
2346
+ }
2347
+ webCache = undefined
2348
+ refreshWebCache(ctx)
2349
+ log(`unified search key ${enabled ? 'on' : 'off'}`)
2350
+ sendJson(res, 200, { ok: true, enabled, ...(await snapshot(ctx, state)) })
2351
+ return
2352
+ }
2353
+
2171
2354
  if (pathname === `${API_BASE}/search-sync` && method === 'POST') {
2172
2355
  const body = await readJson(req)
2173
2356
  const dir = typeof body.path === 'string' ? body.path : undefined
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workspace-api-key",
3
- "version": "0.2.17",
3
+ "version": "0.3.0",
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",
@@ -50,6 +50,6 @@
50
50
  "lib/client.js"
51
51
  ],
52
52
  "scripts": {
53
- "test": "node test/host.test.mjs && node test/client.test.mjs && node test/client-i18n.test.mjs"
53
+ "test": "node test/host.test.mjs && node test/client.test.mjs && node test/client-i18n.test.mjs && node test/client-billing.test.mjs"
54
54
  }
55
55
  }