dsh-workspace-api-key 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,6 +21,10 @@ 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.
25
+ - **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
+ - **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
+ - **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.
24
28
 
25
29
  ## How it works
26
30
 
@@ -31,6 +35,7 @@ Host half (`lib/index.js`):
31
35
  - matches that directory against `workspaceRegistry.list()` after normalizing both sides (absolute, trailing separator removed, lower-cased on Windows);
32
36
  - 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;
33
37
  - 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;
38
+ - tells search credentials apart from chat credentials. The official new Search/Fetch API (`web.searchProvider === 'deepseek-search-api'`) resolves its key itself, and it defaults to the same `DEEPSEEK_API_KEY` the chat uses, so the ref alone cannot say why a key is being asked for. The host half therefore wraps `ctx.web.search` / `ctx.web.fetch` in an `AsyncLocalStorage` scope and applies the search rule only when `credentials.resolve` runs inside it — a session's own key first (always), then, if that workspace ticked `同步搜索 API`, the workspace key and then the system default, otherwise its own search default ref when the panel 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;
34
39
  - 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;
35
40
  - 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;
36
41
  - 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;
@@ -47,8 +52,10 @@ The browser half (`lib/client.js`) registers into the `sidebar.panellist` slot (
47
52
  | GET | `/wsk-api/resolve` | which level and ref the current session resolves to right now |
48
53
  | GET | `/wsk-api/check` | `?sessionId=…&path=…` — read-only verdict for one session: effective level, key, stale flag, provider mismatch, `needsAttention` |
49
54
  | POST | `/wsk-api/set` | `{scope: 'workspace' \| 'session', path \| sessionId, title, key, provider?}` binds a key; `{useDefault: true}` or an empty key clears it |
55
+ | POST | `/wsk-api/set` (search) | `{scope: 'search-default', key}` binds the panel's search default key; `{useDefault: true}` clears it |
50
56
  | 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 |
51
57
  | POST | `/wsk-api/clear-flag` | `{ref}` or a target drops the invalid/quota flag |
58
+ | POST | `/wsk-api/search-sync` | `{path, enabled}` turns a workspace's `同步搜索 API` switch on or off |
52
59
 
53
60
  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.
54
61
 
@@ -56,13 +63,23 @@ Workspace targets may be directories that are not registered workspaces; such en
56
63
 
57
64
  ## Install
58
65
 
59
- Install through the market (设置 → 插件市场) once the plugin is listed, or manually:
66
+ Install through the market (设置 → 插件市场) once the plugin is listed, or from npm:
60
67
 
61
68
  ```sh
62
- # in the profile directory, e.g. ~/.dsh/profiles/desktop
63
- pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
69
+ # via the DSH CLI, into one profile (web, tui, ...). Desktop's own profile is
70
+ # managed by the Electron app, so install there from its UI.
71
+ dsh plugin --profile web add dsh-workspace-api-key
64
72
  ```
65
73
 
74
+ The command forwards its arguments to pnpm inside the profile directory, so the equivalent manual form is:
75
+
76
+ ```sh
77
+ # in the profile directory, e.g. ~/.dsh/profiles/web
78
+ pnpm add dsh-workspace-api-key # or: npm install dsh-workspace-api-key
79
+ ```
80
+
81
+ Pin a version with `dsh-workspace-api-key@0.2.9`, 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
+
66
83
  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:
67
84
 
68
85
  ```jsonc
@@ -72,11 +89,11 @@ Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `p
72
89
  ## Tests
73
90
 
74
91
  ```sh
75
- node test/host.test.mjs # 136 assertions
76
- node test/client.test.mjs # 145 assertions
92
+ node test/host.test.mjs # 165 assertions
93
+ node test/client.test.mjs # 170 assertions
77
94
  ```
78
95
 
79
- 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, and the session probe including the `{items}` envelope, the live-session fallback, and subagent and blank sessions being hidden. 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, and back-navigation when `ctx.get('layout')` only becomes available after the plugin was applied.
96
+ 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.
80
97
 
81
98
  ## Requirements and limits
82
99
 
@@ -84,9 +101,10 @@ Neither suite needs a running DSH process. The host suite drives `apply()` again
84
101
  - 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.
85
102
  - 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.
86
103
  - 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.
104
+ - 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.
87
105
  - 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.
88
106
  - 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.
89
- - Uninstalling the plugin leaves the generated `*_WS_*` / `*_SS_*` refs in the credentials file; remove them from the Models page.
107
+ - Uninstalling the plugin leaves the generated `*_WS_*` / `*_SS_*` refs and `DEEPSEEK_API_KEY_SEARCH` in the credentials file; remove them from the Models page.
90
108
  - If the panel lists workspaces but no expandable sessions, read the diagnostics line at the bottom of the panel. A red "the host half is still an older version" banner means the DSH process was never restarted: the host half is loaded once at startup, so closing the window or reloading the page is not enough — fully quit DSH Desktop and open it again. Otherwise the diagnostics line shows how many sessions the host sees (and from which source: `sessionController`, its live-session fallback, or the browser store), plus sample session `cwd` values next to sample workspace paths when none of them match.
91
109
 
92
110
  ## License
package/README.zh.md CHANGED
@@ -21,6 +21,10 @@ 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,最后才是官方搜索页里配的那把。
25
+ - **每个工作区一个搜索开关** —— 每个工作区行上都有「同步搜索 API」复选框。勾选后,该目录下会话发出的 `web_search` / `web_fetch` 请求改用它们聊天时用的那把 key(会话 → 工作区 → 系统默认);不勾选则继续用官方「设置 → 插件 → 网页搜索」里配置的那把 key。会话自己配了 key 时永远优先,所以「工作区同步、会话单独设置」花的是会话那把 key,而不是搜索默认 key。
26
+ - **在失败之前就提醒的搜索告警** —— 配了搜索 key 但没打开「启用新版 Web 搜索 API」,或者新版 API 打开了但它的 key ref 是空的,面板会显示提醒条,点进会话时的覆盖层也会用一句可直接照做的话说明;当生效的搜索通道不是新版 API 时,覆盖层不会拿搜索问题打扰你。
27
+ - **使用说明** —— 面板顶部有「使用说明」,推荐两把 key 分工:聊天默认用 `default`,搜索默认用 `search-default`,两份预算互不干扰;里面还画了层级,并列出了会触发提醒的几种情况。
24
28
 
25
29
  ## 实现方式
26
30
 
@@ -31,6 +35,7 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
31
35
  - 把该目录与 `workspaceRegistry.list()` 做规范化后比对(绝对化、去掉尾部分隔符、Windows 折大小写);
32
36
  - 由每一层派生出稳定的 ref —— 工作区是 `DEEPSEEK_API_KEY_WS_<sha1(规范化路径) 前 16 位十六进制大写>`,会话是 `DEEPSEEK_API_KEY_SS_<sha1(会话 id) 前 16 位十六进制大写>`(会话 id 含 `-`,不能直接当 ref 名)——通过凭据服务写入 key,并返回这个 ref 的值;
33
37
  - 只改写 provider 真正会读的 ref:默认 ref、每个适配器配置的 `apiKeyEnv`,以及插件自己生成的 ref;其它 ref 原样透传;
38
+ - 把「搜索请求」和「聊天请求」分开。官方新版 Search/Fetch API(`web.searchProvider === 'deepseek-search-api'`)自己解析 key,而它默认读的又是聊天用的 `DEEPSEEK_API_KEY`,所以光看 ref 分不出这次要 key 是为了什么。宿主半边因此把 `ctx.web.search` / `ctx.web.fetch` 包进一个 `AsyncLocalStorage` 作用域,只有当 `credentials.resolve` 在这个作用域里被调用时才套用搜索规则:先看会话自己的 key(永远最优先),再看该工作区有没有勾「同步搜索 API」,勾了就依次用工作区 key、系统默认 key;没勾时先用插件面板自己持有的搜索默认 ref(若配了),最后才用搜索插件配置的那个 ref。旧版基于 Messages 的搜索仍然走原来的聊天层级,因为那条路线本身就是一次 messages 请求;
34
39
  - 会话列表来自 `sessionController` 服务(`list()`,会拆开它 `{items: […]}` 的信封),控件缺失 / 抛错 / 列表为空时再退到宿主 live 会话服务(`ctx.get('sessions').list()`),所以那种情况下面板仍能列出会话;侧栏藏起来的两种会话这里也藏:子代理会话(`origin: 'subagent'` 或带 `parentSession`)和**空会话**(建了但一句话都没发过,侧栏只显示当前开着的那个)。两种都不过滤的话,一个只有 1 个真实会话的工作区会列出 13 个;
35
40
  - 不是注册工作区的目录,只有侧栏真的会显示它才会被列出来。这件事宿主判不了:「空会话里只有当前开着的那个算数」是浏览器端才知道的事(`retainedBy.mainView`),所以宿主把这种目录连同每个会话的 `blank` 标记一起发出去,并用 `hiddenBlankDirs` 点名「里面全是空会话」的目录,由浏览器半边套用与侧栏一致的判据;
36
41
  - `/wsk-api/state` 会带上插件自身版本与会话探针(`available`、`shape`、`source`、`count`、`withoutCwd`、`subagents`、`samples`、`error`,以及工作区路径样本),面板据此说明**为什么**没有列出会话:宿主半边是旧版本(只刷新了页面而没重启应用)、没有 `sessionController`、`list()` 抛错、信封形状不认识,还是会话的 `cwd` 与任何工作区路径都对不上;
@@ -46,9 +51,11 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
46
51
  | GET | `/wsk-api/state` | 默认 ref 状态、工作区及其会话、覆盖项、失效标记 |
47
52
  | GET | `/wsk-api/resolve` | 当前会话此刻解析到哪一层、哪个 ref |
48
53
  | GET | `/wsk-api/check` | `?sessionId=…&path=…` 只读判定:生效层级、key、失效标记、服务商不匹配、`needsAttention` |
54
+ | POST | `/wsk-api/set`(搜索) | `{scope: 'search-default', key}` 绑定面板自己的搜索默认 key;`{useDefault: true}` 清除 |
49
55
  | POST | `/wsk-api/set` | `{scope: 'workspace' \| 'session', path \| sessionId, title, key, provider?}` 绑定 key;`{useDefault: true}` 或空 key 为清除 |
50
56
  | POST | `/wsk-api/test` | `{scope, path \| sessionId \| ref}`(或 `{key}`)真发一次请求并返回判定;`invalid` 打标记,`ok` 清标记 |
51
57
  | POST | `/wsk-api/clear-flag` | `{ref}` 或一个目标,清掉失效/配额标记 |
58
+ | POST | `/wsk-api/search-sync` | `{path, enabled}` —— 打开或关闭某个工作区的「同步搜索 API」 |
52
59
 
53
60
  只接受本机回环调用(`127.0.0.1`、`::1`、`::ffff:127.0.0.1`),且 `Origin` 必须同源或缺失。任何响应都不会包含明文 key,只有掩码。
54
61
 
@@ -56,13 +63,23 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
56
63
 
57
64
  ## 安装
58
65
 
59
- 插件被市场收录后,可以直接在「设置 → 插件市场」里一键安装;也可以手动装:
66
+ 插件被市场收录后,可以直接在「设置 → 插件市场」里一键安装;也可以从 npm 装:
60
67
 
61
68
  ```sh
62
- # 在 profile 目录里,例如 ~/.dsh/profiles/desktop
63
- pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
69
+ # 用 DSH 自带的 CLI 装进某个 profile(web、tui……)。Desktop 自己的 profile
70
+ # 由 Electron 应用独占管理,请在它的界面里安装。
71
+ dsh plugin --profile web add dsh-workspace-api-key
64
72
  ```
65
73
 
74
+ 这条命令会把参数原样转交给 profile 目录里的 pnpm,等价的手写形式是:
75
+
76
+ ```sh
77
+ # 在 profile 目录里,例如 ~/.dsh/profiles/web
78
+ pnpm add dsh-workspace-api-key # 或者:npm install dsh-workspace-api-key
79
+ ```
80
+
81
+ 锁版本用 `dsh-workspace-api-key@0.2.9`;直接从仓库装则用 `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key`(git 来源的插件会在安装时现场构建)。
82
+
66
83
  然后把 `"dsh-workspace-api-key"` 加进该 profile `package.json` 的 `dsh.profile.bundles`,重启 DSH。本地开发用 link 代替:
67
84
 
68
85
  ```jsonc
@@ -72,15 +89,16 @@ pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
72
89
  ## 测试
73
90
 
74
91
  ```sh
75
- node test/host.test.mjs # 136 条断言
76
- node test/client.test.mjs # 145 条断言
92
+ node test/host.test.mjs # 165 条断言
93
+ node test/client.test.mjs # 170 条断言
77
94
  ```
78
95
 
79
- 两个套件都不需要启动 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 之后才可用时返回键仍然有效。
96
+ 两个套件都不需要启动 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`、提醒条与「使用说明」弹窗。
80
97
 
81
98
  ## 要求与限制
82
99
 
83
100
  - 已在 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 内置模块。
101
+ - 搜索功能依赖自带官方 Search/Fetch API 的 DSH(`web-deepseek-api`,即「设置 → 插件 → 网页搜索」里那个「启用新版 Web 搜索 API」)。没有这个能力的版本上,插件根本收不到搜索请求:开关照旧显示,面板会如实报告当前是旧版 Messages 通道,而不是瞎猜。
84
102
  - 每一层只有一把 key:一个会话无论被哪个 provider 路由读取,用的都是同一把 key;key 记下的服务商与会话当前服务商不一致时会被报为「不匹配」,但同一服务商内换模型不算不匹配,因为 DeepSeek key 是账号级的。
85
103
  - 会话按目录(`cwd`)归入工作区。宿主自己的 `list()` 会跳过从未存过 `cwd` 的历史会话,而 live 兜底只认识当前装载中的会话,所以面板列出的会话可能比侧栏少;只在浏览器端存储里可见的会话,除了侧栏知道的那个 `cwd` 之外没有别的路径信息。
86
104
  - 「测试连接」说的是 Anthropic Messages 协议(`POST <baseURL>/messages` 带 `x-api-key` 与 `anthropic-version: 2023-06-01`),也就是 DeepSeek 路由用的协议;换了别的协议的 provider 一样可以按层绑 key,只是测试可能把有效的 key 报成失败。
package/lib/client.js CHANGED
@@ -110,6 +110,18 @@ window.__ModuleLoader__.load({
110
110
  .wsk-dialog .wsk-btn:hover:not(:disabled) { background: color-mix(in srgb, currentColor 8%, transparent); }
111
111
  .wsk-kv { display: flex; gap: 6px; font-size: 12px; line-height: 1.7; }
112
112
  .wsk-kv-key { color: color-mix(in srgb, currentColor 60%, transparent); flex: 0 0 auto; min-width: 74px; }
113
+ .wsk-check { display: flex; align-items: center; gap: 6px; font-size: 11px;
114
+ color: var(--wsk-dim); margin-top: 5px; cursor: pointer; user-select: none; }
115
+ .wsk-check input { margin: 0; accent-color: #2563eb; }
116
+ .wsk-check.on { color: var(--wsk-fg, inherit); }
117
+ .wsk-check .wsk-check-note { color: var(--wsk-faint); }
118
+ .wsk-help { display: flex; flex-direction: column; gap: 10px; max-height: min(60vh, 520px); overflow: auto; }
119
+ .wsk-help h4 { margin: 0; font-size: 13px; }
120
+ .wsk-help ol, .wsk-help ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
121
+ .wsk-help li { font-size: 12px; line-height: 1.7; }
122
+ .wsk-help code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
123
+ font-size: 11px; padding: 1px 4px; border-radius: 4px;
124
+ background: color-mix(in srgb, currentColor 10%, transparent); }
113
125
  @media (prefers-color-scheme: dark) {
114
126
  .wsk-dialog { --wsk-dialog-bg: #1f2124; --wsk-dialog-fg: #f2f2f2; }
115
127
  }
@@ -187,6 +199,24 @@ window.__ModuleLoader__.load({
187
199
  return `这把 key 是为「${key}」准备的,当前会话用的是「${session}」`
188
200
  }
189
201
 
202
+ /**
203
+ * 进入会话时要提醒的「搜索配置问题」。
204
+ * 这两类问题都不会立刻报错,只会在用户下一次搜索时炸掉,所以必须在入口提醒。
205
+ */
206
+ function searchIssueText(search) {
207
+ if (search === null || search === undefined) return undefined
208
+ if (search.apiChannel === true) {
209
+ if (search.keyConfigured === false) {
210
+ return '你打开了「启用新版 Web 搜索 API」,但搜索 key 还没有配置:这个会话里的搜索会直接失败。'
211
+ }
212
+ return undefined
213
+ }
214
+ if (search.leftoverKey === true) {
215
+ return '你配置了搜索专用的 API Key,但「启用新版 Web 搜索 API」没有打开,所以那把 key 现在不会被使用。'
216
+ }
217
+ return undefined
218
+ }
219
+
190
220
  function sessionLabel(session) {
191
221
  const title = typeof session.title === 'string' && session.title !== '' ? session.title : undefined
192
222
  return title ?? (session.blank === true ? '(空会话)' : String(session.sessionId).slice(0, 12))
@@ -484,6 +514,9 @@ window.__ModuleLoader__.load({
484
514
  const [tests, setTests] = useState({})
485
515
  const [notice, setNotice] = useState(null)
486
516
  const [prompt, setPrompt] = useState(null)
517
+ const [help, setHelp] = useState(false)
518
+ const [searchOpen, setSearchOpen] = useState(false)
519
+ const [searchDraft, setSearchDraft] = useState('')
487
520
  const [expanded, setExpanded] = useState(() => new Set())
488
521
 
489
522
  const alive = useRef(true)
@@ -630,6 +663,32 @@ window.__ModuleLoader__.load({
630
663
  }
631
664
  }
632
665
 
666
+ // 搜索默认 key:由插件自己持有一个 ref(DEEPSEEK_API_KEY_SEARCH),
667
+ // 搜索请求在没有会话/工作区层级 key 时优先用它,从而和聊天 key 分开计费。
668
+ const saveSearchDefault = async (key) => {
669
+ const trimmed = typeof key === 'string' ? key.trim() : ''
670
+ setBusy(true)
671
+ try {
672
+ const body = { scope: 'search-default' }
673
+ if (trimmed === '') body.useDefault = true
674
+ else body.key = trimmed
675
+ const next = await api('/set', { method: 'POST', body: JSON.stringify(body) })
676
+ applyState(next)
677
+ setSearchDraft('')
678
+ if (trimmed === '') setSearchOpen(false)
679
+ setNotice({
680
+ kind: 'ok',
681
+ text: trimmed === ''
682
+ ? '搜索默认 key 已清除:搜索改用官方搜索页里配的那把 key。'
683
+ : '搜索默认 key 已保存:搜索请求优先用它(工作区同步与会话单独配置仍然优先)。',
684
+ })
685
+ } catch (err) {
686
+ if (alive.current) setNotice({ kind: 'bad', text: `${err?.message ?? err}` })
687
+ } finally {
688
+ if (alive.current) setBusy(false)
689
+ }
690
+ }
691
+
633
692
  const runTest = async (id, title, body, key) => {
634
693
  setTesting(id)
635
694
  setNotice(null)
@@ -653,6 +712,27 @@ window.__ModuleLoader__.load({
653
712
  }
654
713
  }
655
714
 
715
+ // 「同步搜索 API」:勾上之后,该目录下所有会话的搜索/抓取请求改用本插件的
716
+ // 层级 key(会话 → 工作区 → 系统默认);不勾则用官方搜索插件里配的那把 key。
717
+ const toggleSearchSync = async (path, enabled) => {
718
+ setBusy(true)
719
+ try {
720
+ applyState(await api('/search-sync', { method: 'POST', body: JSON.stringify({ path, enabled }) }))
721
+ if (alive.current) {
722
+ setNotice({
723
+ kind: 'ok',
724
+ text: enabled
725
+ ? '已同步:该目录下会话的搜索请求改用聊天用的那把 key(会话单独配置仍然优先)。'
726
+ : '已取消同步:该目录的搜索请求改用「设置 → 插件 → 网页搜索」里配置的 key。',
727
+ })
728
+ }
729
+ } catch (err) {
730
+ if (alive.current) setNotice({ kind: 'bad', text: String(err?.message ?? err) })
731
+ } finally {
732
+ if (alive.current) setBusy(false)
733
+ }
734
+ }
735
+
656
736
  const clearFlag = async (id, body, title) => {
657
737
  setBusy(true)
658
738
  try {
@@ -677,6 +757,8 @@ window.__ModuleLoader__.load({
677
757
  const ungrouped = view?.ungrouped ?? []
678
758
  const defaultRef = view?.defaultRef
679
759
  const providers = view?.providers ?? []
760
+ const searchInfo = view?.search
761
+ const searchWarnings = Array.isArray(searchInfo?.warnings) ? searchInfo.warnings : []
680
762
 
681
763
  // ── 诊断 ──────────────────────────────────────────────────────────────
682
764
  // 面板里「看不到可展开的会话」有两个完全不同的原因,修法也不同:
@@ -710,10 +792,16 @@ window.__ModuleLoader__.load({
710
792
  const blankDirs = Array.isArray(state.hiddenBlankDirs) && state.hiddenBlankDirs.length > 0
711
793
  ? ` · ${state.hiddenBlankDirs.length} 个目录只有空会话(未分组里不列)`
712
794
  : ''
795
+ const searchDiag = searchInfo === undefined
796
+ ? ''
797
+ : ` · 搜索通道:${searchInfo.apiChannel === true ? '新版 API' : 'Messages'}`
798
+ + `(${searchInfo.keyRef ?? '—'}${searchInfo.keyMasked ? ` · ${searchInfo.keyMasked}` : ' · 未配置'})`
799
+ + (searchInfo.syncCount > 0 ? ` · 已同步 ${searchInfo.syncCount} 个目录` : '')
713
800
  return `插件 v${hostVersion} · 状态存储 v${state.storeVersion ?? '—'}`
714
801
  + ` · 宿主会话列表:${sessions}${listError}${noCwd}${subs}`
715
802
  + ` · 浏览器会话列表:${clientCount} 个${blanks}${blankDirs}`
716
803
  + ` · 工作区 ${grouped.length} + 未分组 ${ungrouped.length} · 挂在目录下的会话 ${sessionTotal}`
804
+ + searchDiag
717
805
  }
718
806
 
719
807
  const diagSamples = () => {
@@ -896,6 +984,34 @@ window.__ModuleLoader__.load({
896
984
  }, verdictText(lastTest.verdict))
897
985
  : null),
898
986
  h('div', { className: 'wsk-path' }, prettyPath(ws.path)),
987
+ ws.kind === 'orphan'
988
+ ? null
989
+ : h('label', {
990
+ key: 'sync',
991
+ className: `wsk-check${ws.searchSync === true ? ' on' : ''}`,
992
+ title: '勾选后:该目录下会话的搜索/抓取请求改用聊天用的那把 key'
993
+ + '(会话单独配置仍然优先)。不勾则用「设置 → 插件 → 网页搜索」里配置的 key。',
994
+ },
995
+ h('input', {
996
+ type: 'checkbox',
997
+ checked: ws.searchSync === true,
998
+ disabled: busy,
999
+ onChange: (ev) => toggleSearchSync(ws.path, ev.target.checked),
1000
+ }),
1001
+ h('span', null, '同步搜索 API'),
1002
+ h('span', { className: 'wsk-check-note' },
1003
+ ws.searchSync === true
1004
+ ? '搜索用这里的 key'
1005
+ : searchInfo?.defaultConfigured === true
1006
+ ? '搜索用搜索默认 key'
1007
+ : '搜索用搜索插件里的 key')),
1008
+ ws.kind !== 'orphan' && searchInfo?.apiChannel === true && ws.searchKeyMasked !== undefined
1009
+ ? h('div', { key: 'sk', className: 'wsk-hint', style: { marginTop: 2 } },
1010
+ `搜索生效 key:${ws.searchKeyMasked || '未配置'}`,
1011
+ ws.searchSync === true
1012
+ ? `(${ws.configured === true ? '本目录专属' : '系统默认'})`
1013
+ : '(搜索插件)')
1014
+ : null,
899
1015
  reason !== undefined
900
1016
  ? h('div', { className: 'wsk-hint', style: { color: 'var(--wsk-bad)', marginTop: 4 } }, reason)
901
1017
  : null),
@@ -932,6 +1048,40 @@ window.__ModuleLoader__.load({
932
1048
  : null)
933
1049
  }
934
1050
 
1051
+ const renderHelp = () => h(Dialog, {
1052
+ title: '使用说明',
1053
+ onDismiss: () => setHelp(false),
1054
+ body: h('div', { className: 'wsk-help' },
1055
+ h('h4', null, '推荐用法(两把 key,各管一件事)'),
1056
+ h('ol', null,
1057
+ h('li', null, '先到「设置 → 模型」配一把系统默认 key,例如就叫 ', h('code', null, 'default'),
1058
+ '。没有单独配置的地方都用它聊天。'),
1059
+ h('li', null, '再回到本页的「搜索默认 key」,放一把搜索专用的 key,例如 ', h('code', null, 'search-default'),
1060
+ '。搜索请求默认用它,和聊天那把分开计费。'),
1061
+ h('li', null, '平时不用再管:聊天按工作区/会话的 key 走,搜索走搜索默认 key,两边账单互不影响。')),
1062
+ h('h4', null, '想按工作区核算搜索花费'),
1063
+ h('ul', null,
1064
+ h('li', null, '在该工作区行勾上「同步搜索 API」:这个目录下会话的搜索就改用聊天那套层级 key',
1065
+ '(会话 key → 工作区 key → 系统默认),不再用搜索默认 key。'),
1066
+ h('li', null, '不勾的目录继续用搜索默认 key。两种方式可以同时存在,一个工作区一个样。')),
1067
+ h('h4', null, '搜索 key 的优先级(从高到低)'),
1068
+ h('ol', null,
1069
+ h('li', null, '会话单独配置的 key —— 该会话的聊天与搜索都用它,永远最优先'),
1070
+ h('li', null, '工作区勾了「同步搜索 API」—— 该工作区下会话的搜索改用:工作区 key → 系统默认 key'),
1071
+ h('li', null, '搜索默认 key —— 没勾同步时,搜索用它'),
1072
+ h('li', null, '官方「网页搜索」页里配的那把 —— 面板没配搜索默认 key 时的兜底',
1073
+ '(它默认就是系统默认 key ', h('code', null, 'DEEPSEEK_API_KEY'), ',与聊天共用)')),
1074
+ h('h4', null, '会被提醒的几种情况'),
1075
+ h('ul', null,
1076
+ h('li', null, '配了搜索 key,但没打开「启用新版 Web 搜索 API」→ 那把 key 其实不会被用到,面板顶部会提示。'),
1077
+ h('li', null, '打开了新版搜索 API,但既没有搜索默认 key、官方那把也是空的 → 任何搜索都会失败,面板顶部会提示。'),
1078
+ h('li', null, '勾了同步,但该工作区既没有专属 key、系统默认 key 也没配 → 会提示先去配一把。')),
1079
+ h('h4', null, '原则'),
1080
+ h('div', { className: 'wsk-hint' },
1081
+ '提醒只报告、不替你改 key。插件绝不会自动切换或替换你的 key,避免计费被悄悄改到别的账号上。')),
1082
+ actions: [{ label: '知道了', primary: true, onClick: () => setHelp(false) }],
1083
+ })
1084
+
935
1085
  const renderPrompt = () => {
936
1086
  if (prompt === null) return null
937
1087
  if (prompt.kind === 'no-default') {
@@ -995,6 +1145,9 @@ window.__ModuleLoader__.load({
995
1145
  h('span', { className: 'wsk-title' }, 'API Key 分配'),
996
1146
  h('span', { className: 'wsk-sub' }, '按工作区 / 会话隔离计费'),
997
1147
  h('span', { className: 'wsk-spacer' }),
1148
+ h('button', {
1149
+ type: 'button', className: 'wsk-btn', onClick: () => setHelp(true),
1150
+ }, '使用说明'),
998
1151
  h('button', {
999
1152
  type: 'button', className: 'wsk-btn', disabled: busy, onClick: refresh,
1000
1153
  }, refreshSvg(14), h('span', null, busy ? '刷新中…' : '刷新'))),
@@ -1015,6 +1168,17 @@ window.__ModuleLoader__.load({
1015
1168
  '请「完全退出」DSH Desktop 再重新打开(关闭窗口 / 刷新页面都不够:宿主代码只在启动时加载)。'),
1016
1169
  h('div', { className: 'wsk-hint' }, diagText()))
1017
1170
  : null,
1171
+ // 搜索配置的提醒(宿主算好、界面只负责展示):都是「不提醒的话,用户
1172
+ // 要等搜索失败才会发现」的问题,所以放在最上面。
1173
+ searchWarnings.map((warning, index) => h('div', {
1174
+ key: `warn:${warning.code ?? index}`,
1175
+ className: `wsk-msg ${warning.level === 'bad' ? 'bad' : warning.level === 'warn' ? 'warn' : 'info'}`,
1176
+ style: { marginBottom: 12 },
1177
+ },
1178
+ warning.title,
1179
+ warning.detail !== undefined
1180
+ ? h('div', { className: 'wsk-hint' }, warning.detail)
1181
+ : null)),
1018
1182
  state !== null && state.sessionListAvailable === false && clientCount === 0
1019
1183
  ? h('div', { className: 'wsk-msg info', style: { marginBottom: 12 } },
1020
1184
  '当前 DSH 版本没有提供会话列表服务,只能按工作区配置 key。',
@@ -1058,6 +1222,62 @@ window.__ModuleLoader__.load({
1058
1222
  onClick: () => runTest(DEFAULT_KEY, '系统默认', { ref: defaultRef?.ref ?? DEFAULT_REF }),
1059
1223
  }, testing === DEFAULT_KEY ? '测试中…' : '测试连接')))),
1060
1224
 
1225
+ searchInfo?.apiChannel === true
1226
+ ? h('div', { className: 'wsk-card' },
1227
+ h('h4', null, '搜索默认 key'),
1228
+ h('div', { className: 'wsk-row' },
1229
+ h('div', { className: 'wsk-row-main' },
1230
+ h('div', { className: 'wsk-name' },
1231
+ h('span', { key: 'r' }, searchInfo.defaultRef ?? 'DEEPSEEK_API_KEY_SEARCH'),
1232
+ searchInfo.defaultConfigured === true
1233
+ ? h('span', { key: 's', className: 'wsk-badge ok' }, '已配置')
1234
+ : h('span', { key: 's', className: 'wsk-badge' }, '未配置')),
1235
+ h('div', { className: 'wsk-hint' },
1236
+ '搜索请求默认用它,和聊天那把 key 分开计费。'
1237
+ + '工作区勾了「同步搜索 API」时改用该工作区的聊天层级;会话单独配置永远最优先。',
1238
+ searchInfo.defaultConfigured === true && searchInfo.defaultKeyMasked
1239
+ ? ` 当前值:${searchInfo.defaultKeyMasked}`
1240
+ : '')),
1241
+ h('div', { className: 'wsk-actions' },
1242
+ h('button', {
1243
+ type: 'button', className: 'wsk-btn', disabled: busy,
1244
+ onClick: () => setSearchOpen((open) => !open),
1245
+ }, searchOpen ? '收起' : '配置'))),
1246
+ searchOpen
1247
+ ? h('div', { className: 'wsk-editor' },
1248
+ h('input', {
1249
+ className: 'wsk-input',
1250
+ type: 'text',
1251
+ autoComplete: 'off',
1252
+ spellCheck: false,
1253
+ placeholder: 'sk-…(留空 = 用官方搜索页里配的那把)',
1254
+ value: searchDraft,
1255
+ onChange: (ev) => setSearchDraft(ev.target.value),
1256
+ onKeyDown: (ev) => { if (ev.key === 'Enter') saveSearchDefault(searchDraft) },
1257
+ }),
1258
+ h('div', { className: 'wsk-dialog-actions' },
1259
+ h('button', {
1260
+ type: 'button', className: 'wsk-btn primary', disabled: busy,
1261
+ onClick: () => saveSearchDefault(searchDraft),
1262
+ }, busy ? '保存中…' : '保存'),
1263
+ h('button', {
1264
+ type: 'button', className: 'wsk-btn',
1265
+ disabled: busy || searchDraft.trim() === '',
1266
+ onClick: () => runTest('__search_default__', '搜索默认', { scope: 'search-default', channel: 'search' }, searchDraft),
1267
+ }, testing === '__search_default__' ? '测试中…' : '用这把 key 测试'),
1268
+ searchInfo.defaultConfigured === true
1269
+ ? h('button', {
1270
+ type: 'button', className: 'wsk-btn', disabled: busy,
1271
+ onClick: () => saveSearchDefault(''),
1272
+ }, '清除')
1273
+ : null,
1274
+ h('button', {
1275
+ type: 'button', className: 'wsk-btn',
1276
+ onClick: () => setSearchOpen(false),
1277
+ }, '收起')))
1278
+ : null)
1279
+ : null,
1280
+
1061
1281
  h('div', { className: 'wsk-card', style: { background: 'transparent', border: 0, padding: 0 } },
1062
1282
  h('h4', { style: { marginBottom: 6 } }, `工作区(${grouped.length})`),
1063
1283
  h('div', { className: 'wsk-hint', style: { marginBottom: 6 } },
@@ -1088,7 +1308,8 @@ window.__ModuleLoader__.load({
1088
1308
  state?.statePath ? h('div', null, `状态文件:${state.statePath}`) : null,
1089
1309
  diagText() !== undefined ? h('div', { className: 'wsk-diag' }, diagText()) : null)),
1090
1310
 
1091
- renderPrompt())
1311
+ renderPrompt(),
1312
+ help ? renderHelp() : null)
1092
1313
  }
1093
1314
 
1094
1315
  // ─────────────────────── 进入会话时的提醒(覆盖层) ───────────────────────
@@ -1132,7 +1353,11 @@ window.__ModuleLoader__.load({
1132
1353
  + (cwd === undefined ? '' : `&path=${encodeURIComponent(cwd)}`)
1133
1354
  const res = await api(query)
1134
1355
  if (disposed) return
1135
- if (res.needsAttention === true) setPrompt(res)
1356
+ // 搜索配置问题也要提醒(否则用户只会在下一次搜索失败时才发现)。
1357
+ const searchIssue = res.search?.apiChannel === true
1358
+ ? res.search?.keyConfigured === false
1359
+ : res.search?.leftoverKey === true
1360
+ if (res.needsAttention === true || searchIssue === true) setPrompt(res)
1136
1361
  } catch {
1137
1362
  /* 宿主接口不可用时不打扰用户 */
1138
1363
  }
@@ -1203,8 +1428,13 @@ window.__ModuleLoader__.load({
1203
1428
  if (prompt === null) return null
1204
1429
  const invalid = invalidReason(prompt.invalid)
1205
1430
  const mismatch = mismatchText(prompt.mismatch)
1431
+ const searchIssue = searchIssueText(prompt.search)
1206
1432
  return h(Dialog, {
1207
- title: invalid !== undefined ? '这个会话用的 API Key 已失效' : '这个会话的 API Key 可能不匹配',
1433
+ title: invalid !== undefined
1434
+ ? '这个会话用的 API Key 已失效'
1435
+ : mismatch !== undefined
1436
+ ? '这个会话的 API Key 可能不匹配'
1437
+ : '网页搜索的配置需要确认',
1208
1438
  body: h('div', null,
1209
1439
  kv('会话', prompt.title ?? String(prompt.sessionId ?? '').slice(0, 12)),
1210
1440
  kv('模型', `${prompt.provider ?? '未知'} / ${prompt.model ?? '默认模型'}`),
@@ -1215,6 +1445,9 @@ window.__ModuleLoader__.load({
1215
1445
  mismatch !== undefined
1216
1446
  ? h('div', { className: 'wsk-hint', style: { marginTop: 8, color: 'var(--wsk-warn)' } }, mismatch)
1217
1447
  : null,
1448
+ searchIssue !== undefined
1449
+ ? h('div', { className: 'wsk-hint', style: { marginTop: 8, color: 'var(--wsk-warn)' } }, searchIssue)
1450
+ : null,
1218
1451
  h('div', { className: 'wsk-hint', style: { marginTop: 8 } },
1219
1452
  '插件不会自动更换 key —— 用哪把 key 由你决定,避免计费被悄悄改到别的账号上。')),
1220
1453
  onDismiss: () => setPrompt(null),
@@ -1223,7 +1456,12 @@ window.__ModuleLoader__.load({
1223
1456
  ...(invalid !== undefined
1224
1457
  ? [{ label: res2Upper(prompt), onClick: useUpperLevel }]
1225
1458
  : []),
1226
- { label: invalid !== undefined ? '稍后' : '继续用当前 key', onClick: () => setPrompt(null) },
1459
+ {
1460
+ label: invalid !== undefined
1461
+ ? '稍后'
1462
+ : mismatch !== undefined ? '继续用当前 key' : '知道了',
1463
+ onClick: () => setPrompt(null),
1464
+ },
1227
1465
  ],
1228
1466
  })
1229
1467
  }
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 { AsyncLocalStorage } from 'node:async_hooks'
35
36
  import { homedir } from 'node:os'
36
37
  import { join, dirname, basename, resolve as pathResolve } from 'node:path'
37
38
 
@@ -50,6 +51,35 @@ const API_BASE = '/wsk-api'
50
51
  const TIMEOUT_MS = 20000
51
52
  const DEBUG = process.env.WSK_DEBUG === '1'
52
53
 
54
+ const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY'
55
+ /** 新版 Search / Fetch API(`deepseek-search-api` / `deepseek-fetch-api`)的配置命名空间。 */
56
+ const SEARCH_API_NS = 'web-deepseek-api'
57
+ /** 旧的「通过 Messages 搜索」provider 的配置命名空间。 */
58
+ const LEGACY_SEARCH_NS = 'web-search-deepseek'
59
+ /** 承载 `searchProvider` / `fetchProvider` 选择的命名空间。 */
60
+ const WEB_NS = 'web'
61
+ const FALLBACK_SEARCH_BASE_URL = 'https://api.deepseek.com/v1/tools'
62
+ const FALLBACK_SEARCH_PROVIDER = 'deepseek-official'
63
+ const FALLBACK_FETCH_PROVIDER = 'http'
64
+ const SEARCH_PROVIDER_ID = 'deepseek-search-api'
65
+ const SEARCH_FETCH_PROVIDER_ID = 'deepseek-fetch-api'
66
+ /**
67
+ * 插件自己持有的「搜索默认 key」。官方搜索页的密钥框写的是它那套 API 的
68
+ * `apiKeyEnv`(默认 `DEEPSEEK_API_KEY`,与聊天共用),所以想让搜索单独计费,
69
+ * 就得另立一把 key。这把 key 由面板管理、存在这个 ref 下,搜索时优先于官方页
70
+ * 配的那把——这样「聊天一把、搜索一把」不用去改官方配置。
71
+ */
72
+ const SEARCH_DEFAULT_REF = 'DEEPSEEK_API_KEY_SEARCH'
73
+
74
+ /**
75
+ * 搜索通道标记:包住 `ctx.web.search` / `ctx.web.fetch`,在它们的异步调用链里
76
+ * 打一个 AsyncLocalStorage 标记。`credentials.resolve` 的 wrapper 据此知道
77
+ * 「这次取 key 是搜索/抓取在要,还是聊天在要」——两者用的是同一把
78
+ * `DEEPSEEK_API_KEY` 时,除此之外没有别的可靠区分方式(resolve 只收到 ref)。
79
+ */
80
+ const searchScope = new AsyncLocalStorage()
81
+ const WEB_SCOPE_MARK = Symbol.for('dsh-workspace-api-key/web-scope')
82
+
53
83
  // 幂等标记:同一进程里可能被 apply 多次(热重载),只装一层 wrapper。
54
84
  const INSTALL_KEY = Symbol.for('dsh-workspace-api-key/installed')
55
85
 
@@ -202,6 +232,40 @@ function effectiveOverride(ctx, state, { sessionId, cwd }) {
202
232
  return { level: 'default', ref: undefined, entry: undefined }
203
233
  }
204
234
 
235
+ /** 该会话是否有「单独设置」的 key(搜索通道里它永远优先)。 */
236
+ function sessionOverrideRef(state, sessionId) {
237
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
238
+ const ref = sessionRef(sessionId)
239
+ if (ref === undefined) return undefined
240
+ return state.overrides[ref] === undefined ? undefined : ref
241
+ }
242
+
243
+ /** 该目录是否勾了「同步搜索 API」。 */
244
+ function workspaceSynced(state, cwd) {
245
+ const norm = normPath(cwd)
246
+ if (norm === undefined) return false
247
+ return state.searchSync?.[norm] === true
248
+ }
249
+
250
+ /**
251
+ * 「这个会话/目录的搜索请求会去读哪个 ref」——纯计算,不解析凭据。
252
+ * 新版 API 通道:会话单独 key → 工作区勾了同步 ? (工作区 key / 系统默认): 搜索插件自己的 ref
253
+ * 旧 Messages 通道:搜索就在用聊天 key,固定返回搜索 ref。
254
+ */
255
+ async function searchRefFor(ctx, state, { sessionId, cwd, web, searchDefault }) {
256
+ const w = web ?? readWebSettings(ctx)
257
+ if (!w.apiChannel) return w.searchKeyEnv
258
+ const own = sessionOverrideRef(state, sessionId)
259
+ if (own !== undefined) return own
260
+ if (workspaceSynced(state, cwd)) {
261
+ const hit = effectiveOverride(ctx, state, { sessionId, cwd })
262
+ return hit.ref ?? DEFAULT_API_KEY_REF
263
+ }
264
+ // 没同步:插件自己的「搜索默认 key」优先,其次才是官方搜索页配的那把。
265
+ const configured = searchDefault ?? (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true
266
+ return configured === true ? SEARCH_DEFAULT_REF : w.searchKeyEnv
267
+ }
268
+
205
269
  // ───────────────── 会话列表(宿主 sessionController + live 兜底) ─────────────────
206
270
 
207
271
  /** 宿主是否挂载了会话列表 API(仅诊断用;界面看 sessionProbe.available)。 */
@@ -433,11 +497,13 @@ function stateFilePath() {
433
497
  return join(home, 'storages', 'dsh-workspace-api-key.json')
434
498
  }
435
499
 
436
- const STATE_VERSION = 2
500
+ const STATE_VERSION = 3
437
501
 
438
502
  /**
439
503
  * v1 的覆盖项没有 scope/sessionId/provider 字段,读进来时补齐:
440
504
  * 有 sessionId 的算会话级,其余算工作区级。
505
+ * v3 新增 `searchSync`(规范路径 → true):该工作区的搜索/抓取请求改用本插件
506
+ * 的层级 key(会话 → 工作区 → 系统默认),而不是官方搜索插件里配置的那把。
441
507
  */
442
508
  function normalizeState(parsed) {
443
509
  const overrides = {}
@@ -453,17 +519,27 @@ function normalizeState(parsed) {
453
519
  }
454
520
  }
455
521
  const invalid = typeof parsed?.invalid === 'object' && parsed.invalid !== null ? parsed.invalid : {}
456
- return { version: STATE_VERSION, overrides, invalid }
522
+ const searchSync = {}
523
+ const rawSync = typeof parsed?.searchSync === 'object' && parsed.searchSync !== null ? parsed.searchSync : {}
524
+ for (const [key, value] of Object.entries(rawSync)) {
525
+ const norm = normPath(key)
526
+ if (norm !== undefined && value === true) searchSync[norm] = true
527
+ }
528
+ return { version: STATE_VERSION, overrides, invalid, searchSync }
529
+ }
530
+
531
+ function emptyState() {
532
+ return { version: STATE_VERSION, overrides: {}, invalid: {}, searchSync: {} }
457
533
  }
458
534
 
459
535
  function loadState() {
460
536
  const path = stateFilePath()
461
537
  try {
462
- if (!existsSync(path)) return { version: STATE_VERSION, overrides: {}, invalid: {} }
538
+ if (!existsSync(path)) return emptyState()
463
539
  return normalizeState(JSON.parse(readFileSync(path, 'utf8')))
464
540
  } catch (err) {
465
541
  console.warn('[workspace-api-key] load state failed:', String(err?.message ?? err))
466
- return { version: STATE_VERSION, overrides: {}, invalid: {} }
542
+ return emptyState()
467
543
  }
468
544
  }
469
545
 
@@ -563,7 +639,7 @@ function maskKey(value) {
563
639
  * 保证显式配置过的 ref 一定被拦。
564
640
  */
565
641
  function interceptableRefs(ctx, state) {
566
- const refs = new Set(['DEEPSEEK_API_KEY'])
642
+ const refs = new Set(['DEEPSEEK_API_KEY', SEARCH_DEFAULT_REF])
567
643
  try {
568
644
  const llm = ctx.get('llm')
569
645
  const adapters = llm?.adapters
@@ -586,9 +662,164 @@ function interceptableRefs(ctx, state) {
586
662
  for (const entry of Object.values(state.overrides)) {
587
663
  if (typeof entry?.ref === 'string' && entry.ref !== '') refs.add(entry.ref)
588
664
  }
665
+ // 搜索/抓取通道的 ref 也要拦:用户可能把「新版 Web 搜索 API」的 key 指到
666
+ // 独立 ref(例如 DEEPSEEK_SEARCH_API_KEY)。不拦的话,勾了「同步搜索 API」
667
+ // 也会静默不生效——面板显示已同步、实际还在用系统默认 key。
668
+ try {
669
+ const web = readWebSettings(ctx)
670
+ for (const ref of [web.apiKeyEnv, web.legacyKeyEnv]) {
671
+ if (typeof ref === 'string' && ref !== '') refs.add(ref)
672
+ }
673
+ } catch {
674
+ /* settings 不可用时只保留已知 ref */
675
+ }
589
676
  return refs
590
677
  }
591
678
 
679
+ // ───────────────────────── 搜索 / 抓取通道配置 ─────────────────────────
680
+
681
+ /**
682
+ * 读官方「插件 → 网页搜索」页写的三处配置(全部走 `ctx.get('settings').get(ns)`,
683
+ * 与 dsh-client-ui-settings-web-search 的 web-search-card-controller 同源)。
684
+ *
685
+ * - `web.searchProvider` / `web.fetchProvider`:开关把它写成
686
+ * `deepseek-search-api` + `deepseek-fetch-api`(新版)或 `deepseek-official` + `http`(旧的 Messages 版)。
687
+ * - `web-deepseek-api.*`:新版 Search/Fetch API 的 key 引用、基础地址、每次结果数。
688
+ * - `web-search-deepseek.*`:旧版 Messages 搜索的 key 引用。
689
+ *
690
+ * 官方页的密钥输入框写的是「当前选中那套 API 的 apiKeyEnv 指向的凭据」,所以
691
+ * 我们只要读 apiKeyEnv,就等于知道用户配的搜索 key 在哪个 ref 上。
692
+ */
693
+ function readWebSettings(ctx) {
694
+ let settings
695
+ try {
696
+ settings = ctx.get('settings')
697
+ } catch {
698
+ settings = undefined
699
+ }
700
+ const read = (ns) => {
701
+ try {
702
+ const value = settings?.get?.(ns)
703
+ return typeof value === 'object' && value !== null ? value : {}
704
+ } catch {
705
+ return {}
706
+ }
707
+ }
708
+ const api = read(SEARCH_API_NS)
709
+ const legacy = read(LEGACY_SEARCH_NS)
710
+ const web = read(WEB_NS)
711
+
712
+ const apiKeyEnv = typeof api.apiKeyEnv === 'string' && api.apiKeyEnv !== '' ? api.apiKeyEnv : DEFAULT_API_KEY_REF
713
+ const legacyKeyEnv = typeof legacy.apiKeyEnv === 'string' && legacy.apiKeyEnv !== '' ? legacy.apiKeyEnv : DEFAULT_API_KEY_REF
714
+ const baseURL = typeof api.baseURL === 'string' && api.baseURL !== '' ? api.baseURL : FALLBACK_SEARCH_BASE_URL
715
+ const searchProvider = typeof web.searchProvider === 'string' && web.searchProvider !== ''
716
+ ? web.searchProvider
717
+ : FALLBACK_SEARCH_PROVIDER
718
+ const fetchProvider = typeof web.fetchProvider === 'string' && web.fetchProvider !== ''
719
+ ? web.fetchProvider
720
+ : FALLBACK_FETCH_PROVIDER
721
+ const apiLiteralKey = typeof api.apiKey === 'string' && api.apiKey !== '' ? api.apiKey : undefined
722
+ const legacyLiteralKey = typeof legacy.apiKey === 'string' && legacy.apiKey !== '' ? legacy.apiKey : undefined
723
+ return {
724
+ apiKeyEnv,
725
+ legacyKeyEnv,
726
+ apiLiteralKey,
727
+ legacyLiteralKey,
728
+ baseURL,
729
+ searchProvider,
730
+ fetchProvider,
731
+ count: typeof api.count === 'number' ? api.count : 10,
732
+ /** 新版 Search/Fetch API 是否被选中(官方开关)= provider 选择被改成了 API 版。 */
733
+ apiChannel: searchProvider === SEARCH_PROVIDER_ID || fetchProvider === SEARCH_FETCH_PROVIDER_ID,
734
+ /** 搜索请求真正会用的那套配置的 key 引用。 */
735
+ searchKeyEnv: searchProvider === SEARCH_PROVIDER_ID ? apiKeyEnv : legacyKeyEnv,
736
+ searchLiteralKey: searchProvider === SEARCH_PROVIDER_ID ? apiLiteralKey : legacyLiteralKey,
737
+ }
738
+ }
739
+
740
+ /**
741
+ * 面板顶部要展示的「该提醒但容易忽略」的搜索配置问题。
742
+ *
743
+ * 只报告,不改任何配置——每一条都能自己解释清楚「为什么」和「怎么修」。
744
+ */
745
+ async function searchWarnings(ctx, state, web) {
746
+ const out = []
747
+ const apiKeyConfigured = web.apiKeyEnv === DEFAULT_API_KEY_REF
748
+ ? (await describeKey(ctx, DEFAULT_API_KEY_REF)).configured === true
749
+ : (await describeKey(ctx, web.apiKeyEnv)).configured === true
750
+ const apiKeyPresent = web.apiLiteralKey !== undefined || (web.apiKeyEnv !== DEFAULT_API_KEY_REF && apiKeyConfigured)
751
+ const searchDefaultConfigured = (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true
752
+
753
+ // 1) 配了新版搜索 API 的 key,但官方开关没开 → 那把 key 永远不会被读到。
754
+ if (!web.apiChannel && apiKeyPresent) {
755
+ out.push({
756
+ code: 'search-key-idle',
757
+ level: 'warn',
758
+ title: '搜索 key 已配置,但「启用新版 Web 搜索 API」没打开',
759
+ detail: `当前搜索走的是 DeepSeek Messages 通道(provider: ${web.searchProvider}),`
760
+ + `所以 ${web.apiKeyEnv} 这把 key 不会被使用。要让它生效,请到「设置 → 插件 → 网页搜索」打开那个开关;`
761
+ + '若暂时不想用新版 API,可以忽略本条。',
762
+ })
763
+ }
764
+
765
+ // 2) 开了新版 API,却没有可用的搜索 key → 搜索会直接报缺少凭据。
766
+ if (web.apiChannel && web.apiLiteralKey === undefined && !apiKeyConfigured && !searchDefaultConfigured) {
767
+ out.push({
768
+ code: 'search-key-missing',
769
+ level: 'bad',
770
+ title: '新版 Web 搜索 API 已启用,但搜索 key 没有配置',
771
+ detail: `搜索会用 ${web.apiKeyEnv} 去取 key,而它当前是空的:任何 web_search 都会失败。`
772
+ + '到「设置 → 插件 → 网页搜索」里填一把,或把该 ref 指向已有的 key。',
773
+ })
774
+ }
775
+
776
+ // 2b) 面板里配了「搜索默认 key」,但新版 API 没开 → 这把 key 也不会被读到。
777
+ if (!web.apiChannel && searchDefaultConfigured) {
778
+ out.push({
779
+ code: 'search-default-idle',
780
+ level: 'warn',
781
+ title: '搜索默认 key 已配置,但「启用新版 Web 搜索 API」没打开',
782
+ detail: '现在搜索走的是 DeepSeek Messages 通道,用的是聊天那把 key,'
783
+ + `所以面板里的 ${SEARCH_DEFAULT_REF} 不会被使用。`
784
+ + '要让它生效,请到「设置 → 插件 → 网页搜索」打开那个开关;若暂时不想用新版 API,可以忽略本条。',
785
+ })
786
+ }
787
+
788
+ // 3) 勾了「同步搜索 API」的工作区里,既没有工作区 key,也没有系统默认 key。
789
+ const defaultConfigured = (await describeKey(ctx, DEFAULT_API_KEY_REF)).configured === true
790
+ const syncPaths = Object.keys(state.searchSync ?? {}).filter((key) => state.searchSync[key] === true)
791
+ const dryWorkspaces = []
792
+ for (const norm of syncPaths) {
793
+ const wsRef = derivedRef(norm)
794
+ const hasWorkspaceKey = wsRef !== undefined && state.overrides[wsRef] !== undefined
795
+ if (!hasWorkspaceKey && !defaultConfigured) dryWorkspaces.push(norm)
796
+ }
797
+ if (dryWorkspaces.length > 0) {
798
+ out.push({
799
+ code: 'search-sync-no-key',
800
+ level: 'warn',
801
+ title: `${dryWorkspaces.length} 个已同步的工作区没有可用的 key`,
802
+ detail: '这些工作区勾了「同步搜索 API」,但既没有工作区专属 key,系统默认 key 也没配置:'
803
+ + '搜索会用空 key 发出去。给这些工作区各配一把,或先配好系统默认 key。',
804
+ paths: dryWorkspaces,
805
+ })
806
+ }
807
+
808
+ // 4) 勾了同步,但当前还是旧的 Messages 通道 → 同步开关对搜索没有区别
809
+ // (旧通道本来就解析聊天用的 ref,层级 key 一直生效)。
810
+ if (!web.apiChannel && syncPaths.length > 0) {
811
+ out.push({
812
+ code: 'search-sync-legacy',
813
+ level: 'info',
814
+ title: '「同步搜索 API」现在没有区别',
815
+ detail: '当前搜索走旧的 Messages 通道,它本来就用聊天通道的 key(会话 → 工作区 → 系统默认),'
816
+ + '所以这个开关只影响「启用新版 Web 搜索 API」之后的行为。',
817
+ })
818
+ }
819
+
820
+ return out
821
+ }
822
+
592
823
  // ─────────────────────────── 失效判定 ───────────────────────────
593
824
 
594
825
  function classifyFailure(failure) {
@@ -786,12 +1017,84 @@ async function probeKey(ctx, key) {
786
1017
  return { ok: false, verdict: 'error', status: res.status, latencyMs, detail: `${sanitizeDetail(raw) ?? `HTTP ${res.status}`}${hint}` }
787
1018
  }
788
1019
 
789
- // ─────────────────────────── 插件主体 ───────────────────────────
1020
+ /**
1021
+ * 验证「新版 Web 搜索 API」这条通道:`POST {baseURL}/search`,最小请求。
1022
+ * 与搜索插件自己的调用形状一致(`{ query, count, need_content }`),所以 200 就
1023
+ * 说明「key 有效 + 端点正确 + 开关真的生效」三件事同时成立。
1024
+ */
1025
+ async function probeSearch(ctx, state, key, web) {
1026
+ const base = String(web?.baseURL ?? FALLBACK_SEARCH_BASE_URL).replace(/\/+$/u, '')
1027
+ const endpoint = `${base}/search`
1028
+ const started = Date.now()
1029
+ let res
1030
+ try {
1031
+ res = await fetch(endpoint, {
1032
+ method: 'POST',
1033
+ redirect: 'error',
1034
+ headers: {
1035
+ 'content-type': 'application/json',
1036
+ accept: 'application/json',
1037
+ authorization: `Bearer ${key}`,
1038
+ },
1039
+ body: JSON.stringify({ query: 'deepseek', count: 1, need_content: false }),
1040
+ signal: AbortSignal.timeout(TIMEOUT_MS),
1041
+ })
1042
+ } catch (err) {
1043
+ const message = String(err?.message ?? err)
1044
+ const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError'
1045
+ return {
1046
+ ok: false,
1047
+ verdict: 'unreachable',
1048
+ status: 0,
1049
+ latencyMs: Date.now() - started,
1050
+ detail: timedOut ? `请求超时(${TIMEOUT_MS}ms):${endpoint}` : `${message}(${endpoint})`,
1051
+ }
1052
+ }
790
1053
 
1054
+ const latencyMs = Date.now() - started
1055
+ const raw = await res.text().catch(() => '')
1056
+ if (res.ok) {
1057
+ let count
1058
+ try {
1059
+ const parsed = JSON.parse(raw)
1060
+ count = Array.isArray(parsed?.results) ? parsed.results.length : undefined
1061
+ } catch {
1062
+ /* 非 JSON 也算成功 */
1063
+ }
1064
+ return {
1065
+ ok: true,
1066
+ verdict: 'ok',
1067
+ status: res.status,
1068
+ latencyMs,
1069
+ detail: `搜索通道正常${count === undefined ? '' : `,返回 ${count} 条结果`}(${endpoint})`,
1070
+ }
1071
+ }
1072
+ if (res.status === 401 || res.status === 403) {
1073
+ return { ok: false, verdict: 'invalid', status: res.status, latencyMs, detail: sanitizeDetail(raw) ?? 'key 无效(401/403)' }
1074
+ }
1075
+ if (res.status === 402) {
1076
+ return { ok: false, verdict: 'quota', status: res.status, latencyMs, detail: sanitizeDetail(raw) ?? '余额/配额不足(402)' }
1077
+ }
1078
+ if (res.status === 429) {
1079
+ return { ok: false, verdict: 'rate-limit', status: res.status, latencyMs, detail: sanitizeDetail(raw) ?? '被限流(429),但 key 本身可能有效' }
1080
+ }
1081
+ if (res.status === 400) {
1082
+ return { ok: true, verdict: 'ok', status: res.status, latencyMs, detail: `鉴权通过(HTTP 400:${sanitizeDetail(raw) ?? '请求参数被拒'})` }
1083
+ }
1084
+ const hint = res.status === 404
1085
+ ? `(404:端点 ${endpoint} 不存在,检查「Search / Fetch API 基础地址」)`
1086
+ : web?.apiChannel === false
1087
+ ? `(${endpoint};当前搜索通道是 Messages 版,新版 API 可能还没启用)`
1088
+ : `(${endpoint})`
1089
+ return { ok: false, verdict: 'error', status: res.status, latencyMs, detail: `${sanitizeDetail(raw) ?? `HTTP ${res.status}`}${hint}` }
1090
+ }
1091
+
1092
+ // ─────────────────────────── 插件主体 ───────────────────────────
791
1093
  export function apply(ctx) {
792
1094
  const state = loadState()
793
1095
 
794
- // ── 1. 包装 credentials.resolve ──────────────────────────────────────
1096
+ // ── 1. 包装 credentials.resolve + 标记搜索/抓取通道 ─────────────────
1097
+ installWebScope(ctx)
795
1098
  installResolver(ctx, state)
796
1099
 
797
1100
  // ── 2. REST API(loopback 可信)──────────────────────────────────────
@@ -842,6 +1145,54 @@ export function apply(ctx) {
842
1145
  )
843
1146
  }
844
1147
 
1148
+ /**
1149
+ * 装上搜索/抓取通道标记:把 `ctx.web.search` / `ctx.web.fetch` 包一层
1150
+ * AsyncLocalStorage,让 resolve wrapper 知道「这次取 key 的是搜索,不是聊天」。
1151
+ * 装不上就静默跳过——不影响任何既有行为(只是无法区分通道)。
1152
+ */
1153
+ function installWebScope(ctx) {
1154
+ if (globalThis[WEB_SCOPE_MARK] !== undefined) {
1155
+ log('web scope already installed; skipping')
1156
+ return
1157
+ }
1158
+ let web
1159
+ try {
1160
+ web = ctx.get('web')
1161
+ } catch {
1162
+ web = undefined
1163
+ }
1164
+ if (web === undefined) return
1165
+
1166
+ const originals = {}
1167
+ for (const method of ['search', 'fetch']) {
1168
+ if (typeof web[method] !== 'function') continue
1169
+ const original = web[method].bind(web)
1170
+ originals[method] = original
1171
+ try {
1172
+ web[method] = (...args) => searchScope.run(true, () => original(...args))
1173
+ } catch {
1174
+ delete originals[method]
1175
+ }
1176
+ }
1177
+ if (Object.keys(originals).length === 0) return
1178
+
1179
+ globalThis[WEB_SCOPE_MARK] = { web, originals }
1180
+ ctx.effect(
1181
+ () => () => {
1182
+ for (const [method, original] of Object.entries(originals)) {
1183
+ try {
1184
+ web[method] = original
1185
+ } catch {
1186
+ /* ignore */
1187
+ }
1188
+ }
1189
+ delete globalThis[WEB_SCOPE_MARK]
1190
+ },
1191
+ 'workspace-api-key: restore web.search/fetch',
1192
+ )
1193
+ log(`web scope wrapped: ${Object.keys(originals).join(',')}`)
1194
+ }
1195
+
845
1196
  /**
846
1197
  * 装上 resolve wrapper。装不上也不能影响宿主:任何异常都吞掉并退回原行为。
847
1198
  */
@@ -866,8 +1217,50 @@ function installResolver(ctx, state) {
866
1217
  const wrapped = async (ref /*, ...rest */) => {
867
1218
  try {
868
1219
  if (!interceptableRefs(ctx, state).has(ref)) return await original(ref)
1220
+ const sessionId = activeSessionId(ctx)
1221
+ const cwd = activeCwd(ctx)
1222
+
1223
+ // 搜索 / 抓取通道:官方「新版 Web 搜索 API」用的是独立的一套 key 配置,
1224
+ // 语义与聊天不同,因此单独处理(旧 Messages 通道仍走下面的聊天层级)。
1225
+ if (searchScope.getStore() === true) {
1226
+ const web = readWebSettings(ctx)
1227
+ if (web.apiChannel) {
1228
+ // ① 会话单独设置的 key 永远优先——哪怕官方搜索插件里配了别的 key。
1229
+ const sessionKey = sessionOverrideRef(state, sessionId)
1230
+ if (sessionKey !== undefined) {
1231
+ const value = await original(sessionKey)
1232
+ if (value?.value !== undefined && value.value !== '') {
1233
+ log(`search ref ${ref} → ${sessionKey} (session)`)
1234
+ return value
1235
+ }
1236
+ }
1237
+ // ② 工作区勾了「同步搜索 API」:按会话 → 工作区 → 系统默认的层级。
1238
+ if (workspaceSynced(state, cwd)) {
1239
+ const hit = effectiveOverride(ctx, state, { sessionId, cwd })
1240
+ if (hit.ref !== undefined && hit.level !== 'default') {
1241
+ const value = await original(hit.ref)
1242
+ if (value?.value !== undefined && value.value !== '') {
1243
+ log(`search ref ${ref} → ${hit.ref} (${hit.level}, synced)`)
1244
+ return value
1245
+ }
1246
+ }
1247
+ const fallback = await original(DEFAULT_API_KEY_REF)
1248
+ if (fallback?.value !== undefined && fallback.value !== '') return fallback
1249
+ }
1250
+ // ③ 没同步:插件面板里配的「搜索默认 key」优先,其次才是官方搜索页
1251
+ // 配的那把(默认与聊天共用 DEEPSEEK_API_KEY,所以更常见的路径是把
1252
+ // 搜索 key 放在面板里,聊天 key 留在系统默认)。
1253
+ const searchDefaultHit = await resolveKey(ctx, SEARCH_DEFAULT_REF)
1254
+ if (searchDefaultHit.value !== undefined && searchDefaultHit.value !== '') {
1255
+ log(`search ref ${ref} → ${SEARCH_DEFAULT_REF} (search default)`)
1256
+ return { value: searchDefaultHit.value, source: searchDefaultHit.source ?? 'plugin' }
1257
+ }
1258
+ return await original(ref)
1259
+ }
1260
+ }
1261
+
869
1262
  // 1) 会话级覆盖优先,2) 再工作区级,3) 都没有就用系统默认(原 ref)。
870
- const hit = effectiveOverride(ctx, state, { sessionId: activeSessionId(ctx), cwd: activeCwd(ctx) })
1263
+ const hit = effectiveOverride(ctx, state, { sessionId, cwd })
871
1264
  if (hit.ref !== undefined && hit.level !== 'default') {
872
1265
  const value = await original(hit.ref)
873
1266
  if (value?.value !== undefined && value.value !== '') {
@@ -1016,6 +1409,10 @@ async function sessionRow(ctx, state, row, fallbackCwd) {
1016
1409
  effectiveTitle: eff.entry?.title,
1017
1410
  invalid: eff.ref !== undefined ? state.invalid[eff.ref] ?? null : null,
1018
1411
  mismatch: mismatchOf(eff.entry, selection),
1412
+ // 搜索通道:该会话所在目录是否勾了「同步搜索 API」,以及本会话是否另有
1413
+ // 单独的 key(单独设置永远优先于工作区同步与官方搜索插件里的 key)。
1414
+ searchSync: workspaceSynced(state, cwd),
1415
+ sessionSearchKey: sessionOverrideRef(state, sessionId) !== undefined,
1019
1416
  }
1020
1417
  }
1021
1418
 
@@ -1031,6 +1428,22 @@ async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, cl
1031
1428
  }
1032
1429
  sessions.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
1033
1430
  if (claimed !== undefined) claimed.add(normPath(dir))
1431
+ // 搜索通道:只有新版 Search/Fetch API 才有「同步 / 不同步」的区别(旧 Messages
1432
+ // 通道本来就用聊天 key)。这里给出「该工作区的搜索请求实际会用哪把 key」,
1433
+ // 让用户勾了复选框之后马上能看到效果,而不用等到搜索失败才发现。
1434
+ const web = readWebSettings(ctx)
1435
+ const synced = state.searchSync?.[normPath(dir)] === true
1436
+ const searchDefaultConfigured = web.apiChannel && (await describeKey(ctx, SEARCH_DEFAULT_REF)).configured === true
1437
+ const searchKeyRef = web.apiChannel
1438
+ ? synced
1439
+ ? configured
1440
+ ? ref
1441
+ : DEFAULT_API_KEY_REF
1442
+ : searchDefaultConfigured
1443
+ ? SEARCH_DEFAULT_REF
1444
+ : web.searchKeyEnv
1445
+ : web.searchKeyEnv
1446
+ const searchKeyMasked = web.apiChannel ? maskKey((await resolveKey(ctx, searchKeyRef))?.value) : undefined
1034
1447
  return {
1035
1448
  id,
1036
1449
  path: dir,
@@ -1044,6 +1457,9 @@ async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, cl
1044
1457
  overrideProvider: configured ? state.overrides[ref]?.provider : undefined,
1045
1458
  invalid: ref !== undefined ? state.invalid[ref] ?? null : null,
1046
1459
  exists: typeof dir === 'string' ? existsSync(dir) : false,
1460
+ searchSync: synced,
1461
+ searchKeyRef,
1462
+ searchKeyMasked,
1047
1463
  sessions,
1048
1464
  }
1049
1465
  }
@@ -1157,6 +1573,16 @@ async function snapshot(ctx, state) {
1157
1573
  })
1158
1574
  }
1159
1575
 
1576
+ // ── 搜索 / 抓取通道总览 ──────────────────────────────────────────────
1577
+ const web = readWebSettings(ctx)
1578
+ const searchKeyRef = web.searchKeyEnv
1579
+ const searchKeyInfo = await describeKey(ctx, searchKeyRef)
1580
+ const searchKeyHit = await resolveKey(ctx, searchKeyRef)
1581
+ const defaultSearchInfo = await describeKey(ctx, SEARCH_DEFAULT_REF)
1582
+ const defaultSearchHit = await resolveKey(ctx, SEARCH_DEFAULT_REF)
1583
+ const syncPaths = Object.keys(state.searchSync ?? {}).filter((key) => state.searchSync[key] === true)
1584
+ const warnings = await searchWarnings(ctx, state, web)
1585
+
1160
1586
  return {
1161
1587
  ok: true,
1162
1588
  pluginVersion: PLUGIN_VERSION,
@@ -1193,6 +1619,31 @@ async function snapshot(ctx, state) {
1193
1619
  // 因为「当前打开的空会话」要显示,而这件事只有浏览器端知道)。
1194
1620
  hiddenBlankDirs,
1195
1621
  sessionOverrides,
1622
+ // 搜索 / 抓取通道:官方「设置 → 插件 → 网页搜索」里的配置 + 本插件怎么参与。
1623
+ // 面板据此显示通道、生效 key 与提醒;不暴露任何明文。
1624
+ search: {
1625
+ channel: web.apiChannel ? 'api' : 'messages',
1626
+ apiChannel: web.apiChannel,
1627
+ searchProvider: web.searchProvider,
1628
+ fetchProvider: web.fetchProvider,
1629
+ baseURL: web.baseURL,
1630
+ count: web.count,
1631
+ keyRef: searchKeyRef,
1632
+ keyRefConfigured: searchKeyInfo.configured === true,
1633
+ keyMasked: maskKey(searchKeyHit?.value),
1634
+ keySource: searchKeyInfo.source,
1635
+ keyWritable: searchKeyInfo.writable !== false,
1636
+ literalKey: web.searchLiteralKey !== undefined,
1637
+ // 面板自己持有的「搜索默认 key」(优先于官方页配的那把)。
1638
+ defaultRef: SEARCH_DEFAULT_REF,
1639
+ defaultConfigured: defaultSearchInfo.configured === true,
1640
+ defaultKeyMasked: maskKey(defaultSearchHit?.value),
1641
+ defaultSource: defaultSearchInfo.source,
1642
+ defaultWritable: defaultSearchInfo.writable !== false,
1643
+ syncCount: syncPaths.length,
1644
+ syncPaths,
1645
+ warnings,
1646
+ },
1196
1647
  }
1197
1648
  }
1198
1649
 
@@ -1266,6 +1717,7 @@ async function checkSession(ctx, state, params) {
1266
1717
  const sessionEntry = sid !== undefined ? state.overrides[sessionRef(sid)] : undefined
1267
1718
  const ws = locateWorkspace(ctx, cwd)
1268
1719
  const wsRef = ws !== undefined ? derivedRef(ws.path) : undefined
1720
+ const searchState = await searchStateOf(ctx, state, { sessionId: sid, cwd })
1269
1721
  return {
1270
1722
  ok: true,
1271
1723
  sessionId: sid ?? null,
@@ -1295,6 +1747,27 @@ async function checkSession(ctx, state, params) {
1295
1747
  },
1296
1748
  defaultRef: { ref: defaultRefName, configured: defaultInfo.configured === true },
1297
1749
  providers: providerList(ctx),
1750
+ // 搜索通道:进会话时若搜索配置有问题,界面要能立刻提醒(而不是等到搜索失败)。
1751
+ search: searchState,
1752
+ }
1753
+ }
1754
+
1755
+ /** 进会话提醒用的搜索通道摘要(不含明文 key)。 */
1756
+ async function searchStateOf(ctx, state, { sessionId, cwd }) {
1757
+ const web = readWebSettings(ctx)
1758
+ const sync = workspaceSynced(state, cwd)
1759
+ if (!web.apiChannel) {
1760
+ const idle = web.apiLiteralKey !== undefined
1761
+ || (web.apiKeyEnv !== DEFAULT_API_KEY_REF && (await describeKey(ctx, web.apiKeyEnv)).configured === true)
1762
+ return { channel: 'messages', apiChannel: false, sync, leftoverKey: idle }
1763
+ }
1764
+ return {
1765
+ channel: 'api',
1766
+ apiChannel: true,
1767
+ sync,
1768
+ ref: await searchRefFor(ctx, state, { sessionId, cwd, web }),
1769
+ sessionOwnKey: sessionOverrideRef(state, sessionId) !== undefined,
1770
+ keyConfigured: (await describeKey(ctx, await searchRefFor(ctx, state, { sessionId, cwd, web }))).configured === true,
1298
1771
  }
1299
1772
  }
1300
1773
 
@@ -1349,12 +1822,27 @@ async function handle(ctx, state, req, res) {
1349
1822
 
1350
1823
  if (pathname === `${API_BASE}/set` && method === 'POST') {
1351
1824
  const body = await readJson(req)
1825
+ const key = typeof body.key === 'string' ? body.key.trim() : ''
1826
+ // 搜索默认 key:不是「某个目录/会话」的覆盖,由插件自己持有一个 ref。
1827
+ if (body.scope === 'search-default') {
1828
+ if (body.useDefault === true || key === '') {
1829
+ await clearKey(ctx, SEARCH_DEFAULT_REF)
1830
+ delete state.invalid[SEARCH_DEFAULT_REF]
1831
+ saveState(state)
1832
+ sendJson(res, 200, { ok: true, cleared: true, ...(await snapshot(ctx, state)) })
1833
+ return
1834
+ }
1835
+ await storeKey(ctx, SEARCH_DEFAULT_REF, key)
1836
+ delete state.invalid[SEARCH_DEFAULT_REF]
1837
+ saveState(state)
1838
+ sendJson(res, 200, { ok: true, ...(await snapshot(ctx, state)) })
1839
+ return
1840
+ }
1352
1841
  const target = targetOf(body)
1353
1842
  if (target.error !== undefined) {
1354
1843
  sendJson(res, 400, { ok: false, error: target.error })
1355
1844
  return
1356
1845
  }
1357
- const key = typeof body.key === 'string' ? body.key.trim() : ''
1358
1846
  if (body.useDefault === true || key === '') {
1359
1847
  await clearKey(ctx, target.ref)
1360
1848
  delete state.overrides[target.ref]
@@ -1382,27 +1870,41 @@ async function handle(ctx, state, req, res) {
1382
1870
 
1383
1871
  if (pathname === `${API_BASE}/test` && method === 'POST') {
1384
1872
  const body = await readJson(req)
1873
+ const web = readWebSettings(ctx)
1874
+ const channel = body.channel === 'search' ? 'search' : 'chat'
1385
1875
  let key = typeof body.key === 'string' && body.key.trim() !== '' ? body.key.trim() : undefined
1386
1876
  let sourceRef = typeof body.ref === 'string' && body.ref !== '' ? body.ref : undefined
1387
1877
  if (key === undefined) {
1388
1878
  if (sourceRef === undefined) {
1389
- if (body.scope === 'session') {
1390
- sourceRef = typeof body.sessionId === 'string' && body.sessionId !== '' ? sessionRef(body.sessionId) : undefined
1879
+ if (body.scope === 'search-default') {
1880
+ sourceRef = SEARCH_DEFAULT_REF
1881
+ } else if (body.scope === 'session') {
1882
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId !== '' ? body.sessionId : undefined
1883
+ const cwd = typeof body.path === 'string' && body.path !== '' ? body.path : activeCwd(ctx)
1884
+ sourceRef = channel === 'search'
1885
+ ? await searchRefFor(ctx, state, { sessionId, cwd, web })
1886
+ : (sessionId === undefined ? undefined : sessionRef(sessionId))
1887
+ } else if (channel === 'search') {
1888
+ const dir = typeof body.path === 'string' && body.path !== '' ? body.path : undefined
1889
+ sourceRef = await searchRefFor(ctx, state, { cwd: dir, web })
1391
1890
  } else {
1392
1891
  const dir = typeof body.path === 'string' ? body.path : undefined
1393
1892
  const overrideRef = dir !== undefined && dir !== '' ? derivedRef(dir) : undefined
1394
1893
  sourceRef = overrideRef !== undefined && state.overrides[overrideRef] !== undefined ? overrideRef : undefined
1395
1894
  }
1396
1895
  }
1397
- if (sourceRef === undefined) sourceRef = 'DEEPSEEK_API_KEY'
1896
+ if (sourceRef === undefined) sourceRef = DEFAULT_API_KEY_REF
1398
1897
  const hit = await resolveKey(ctx, sourceRef)
1399
1898
  key = hit?.value
1400
1899
  }
1401
1900
  if (typeof key !== 'string' || key === '') {
1402
- sendJson(res, 400, { ok: false, error: `没有可测试的 key(ref=${sourceRef ?? '未知'})` })
1901
+ const hint = channel === 'search'
1902
+ ? `(ref=${sourceRef ?? '未知'};新版 Web 搜索 API 已启用但该 ref 没有值,搜索会直接失败)`
1903
+ : `(ref=${sourceRef ?? '未知'})`
1904
+ sendJson(res, 400, { ok: false, error: `没有可测试的 key${hint}` })
1403
1905
  return
1404
1906
  }
1405
- const result = await probeKey(ctx, key)
1907
+ const result = channel === 'search' ? await probeSearch(ctx, state, key, web) : await probeKey(ctx, key)
1406
1908
  const isOverrideRef = sourceRef !== undefined && state.overrides[sourceRef] !== undefined
1407
1909
  if (result.verdict === 'invalid') {
1408
1910
  if (isOverrideRef) {
@@ -1427,6 +1929,27 @@ async function handle(ctx, state, req, res) {
1427
1929
  return
1428
1930
  }
1429
1931
 
1932
+ if (pathname === `${API_BASE}/search-sync` && method === 'POST') {
1933
+ const body = await readJson(req)
1934
+ const dir = typeof body.path === 'string' ? body.path : undefined
1935
+ if (dir === undefined || dir === '') {
1936
+ sendJson(res, 400, { ok: false, error: '缺少 path' })
1937
+ return
1938
+ }
1939
+ const norm = normPath(dir)
1940
+ if (norm === undefined) {
1941
+ sendJson(res, 400, { ok: false, error: '无法规范化该路径' })
1942
+ return
1943
+ }
1944
+ const enabled = body.enabled !== false
1945
+ if (enabled) state.searchSync[norm] = true
1946
+ else delete state.searchSync[norm]
1947
+ saveState(state)
1948
+ log(`search sync ${enabled ? 'on' : 'off'}: ${dir}`)
1949
+ sendJson(res, 200, { ok: true, enabled, ...(await snapshot(ctx, state)) })
1950
+ return
1951
+ }
1952
+
1430
1953
  if (pathname === `${API_BASE}/clear-flag` && method === 'POST') {
1431
1954
  const body = await readJson(req)
1432
1955
  let ref = typeof body.ref === 'string' && body.ref !== '' ? body.ref : undefined
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workspace-api-key",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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",