dsh-workspace-api-key 0.2.7 → 0.2.8

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,9 @@ 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 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.
25
+ - **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.
26
+ - **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
27
 
25
28
  ## How it works
26
29
 
@@ -31,6 +34,7 @@ Host half (`lib/index.js`):
31
34
  - matches that directory against `workspaceRegistry.list()` after normalizing both sides (absolute, trailing separator removed, lower-cased on Windows);
32
35
  - 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
36
  - 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;
37
+ - 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, and otherwise the ref the search plugin configured. The legacy Messages-based search keeps the plain chat cascade, because that route *is* a messages request;
34
38
  - 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
39
  - 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
40
  - 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;
@@ -49,6 +53,7 @@ The browser half (`lib/client.js`) registers into the `sidebar.panellist` slot (
49
53
  | POST | `/wsk-api/set` | `{scope: 'workspace' \| 'session', path \| sessionId, title, key, provider?}` binds a key; `{useDefault: true}` or an empty key clears it |
50
54
  | 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
55
  | POST | `/wsk-api/clear-flag` | `{ref}` or a target drops the invalid/quota flag |
56
+ | POST | `/wsk-api/search-sync` | `{path, enabled}` turns a workspace's `同步搜索 API` switch on or off |
52
57
 
53
58
  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
59
 
@@ -56,13 +61,23 @@ Workspace targets may be directories that are not registered workspaces; such en
56
61
 
57
62
  ## Install
58
63
 
59
- Install through the market (设置 → 插件市场) once the plugin is listed, or manually:
64
+ Install through the market (设置 → 插件市场) once the plugin is listed, or from npm:
60
65
 
61
66
  ```sh
62
- # in the profile directory, e.g. ~/.dsh/profiles/desktop
63
- pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
67
+ # via the DSH CLI, into one profile (web, tui, ...). Desktop's own profile is
68
+ # managed by the Electron app, so install there from its UI.
69
+ dsh plugin --profile web add dsh-workspace-api-key
64
70
  ```
65
71
 
72
+ The command forwards its arguments to pnpm inside the profile directory, so the equivalent manual form is:
73
+
74
+ ```sh
75
+ # in the profile directory, e.g. ~/.dsh/profiles/web
76
+ pnpm add dsh-workspace-api-key # or: npm install dsh-workspace-api-key
77
+ ```
78
+
79
+ Pin a version with `dsh-workspace-api-key@0.2.8`, 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).
80
+
66
81
  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
82
 
68
83
  ```jsonc
@@ -72,11 +87,11 @@ Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `p
72
87
  ## Tests
73
88
 
74
89
  ```sh
75
- node test/host.test.mjs # 136 assertions
76
- node test/client.test.mjs # 145 assertions
90
+ node test/host.test.mjs # 157 assertions
91
+ node test/client.test.mjs # 162 assertions
77
92
  ```
78
93
 
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.
94
+ 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 two 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
95
 
81
96
  ## Requirements and limits
82
97
 
@@ -84,6 +99,7 @@ Neither suite needs a running DSH process. The host suite drives `apply()` again
84
99
  - 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
100
  - 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
101
  - 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.
102
+ - 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
103
  - 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
104
  - 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
105
  - Uninstalling the plugin leaves the generated `*_WS_*` / `*_SS_*` refs in the credentials file; remove them from the Models page.
package/README.zh.md CHANGED
@@ -21,6 +21,9 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
21
21
  - **不匹配提醒** —— 如果绑 key 时记下的是某个服务商(比如 `deepseek-official`),而该会话现在跑在另一个服务商上,点进这个会话时会提醒你,因为那次请求会拿着对方不认的 key 发出去。
22
22
  - **绝不悄悄换 key** —— 两种提醒都不会改动实际使用的 key,决定权始终在你手上;自动替换会直接破坏「按会话分离计费」这个初衷。
23
23
  - **一键回退** —— 清空会话条目回到工作区 key,清空工作区条目回到系统默认。
24
+ - **每个工作区一个搜索开关** —— 每个工作区行上都有「同步搜索 API」复选框。勾选后,该目录下会话发出的 `web_search` / `web_fetch` 请求改用它们聊天时用的那把 key(会话 → 工作区 → 系统默认);不勾选则继续用官方「设置 → 插件 → 网页搜索」里配置的那把 key。会话自己配了 key 时永远优先,所以「工作区同步、会话单独设置」花的是会话那把 key,而不是搜索默认 key。
25
+ - **在失败之前就提醒的搜索告警** —— 配了搜索 key 但没打开「启用新版 Web 搜索 API」,或者新版 API 打开了但它的 key ref 是空的,面板会显示提醒条,点进会话时的覆盖层也会用一句可直接照做的话说明;当生效的搜索通道不是新版 API 时,覆盖层不会拿搜索问题打扰你。
26
+ - **使用说明** —— 面板顶部有「使用说明」,推荐两把 key 分工:聊天默认用 `default`,搜索默认用 `search-default`,两份预算互不干扰;里面还画了层级,并列出了会触发提醒的几种情况。
24
27
 
25
28
  ## 实现方式
26
29
 
@@ -31,6 +34,7 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
31
34
  - 把该目录与 `workspaceRegistry.list()` 做规范化后比对(绝对化、去掉尾部分隔符、Windows 折大小写);
32
35
  - 由每一层派生出稳定的 ref —— 工作区是 `DEEPSEEK_API_KEY_WS_<sha1(规范化路径) 前 16 位十六进制大写>`,会话是 `DEEPSEEK_API_KEY_SS_<sha1(会话 id) 前 16 位十六进制大写>`(会话 id 含 `-`,不能直接当 ref 名)——通过凭据服务写入 key,并返回这个 ref 的值;
33
36
  - 只改写 provider 真正会读的 ref:默认 ref、每个适配器配置的 `apiKeyEnv`,以及插件自己生成的 ref;其它 ref 原样透传;
37
+ - 把「搜索请求」和「聊天请求」分开。官方新版 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。旧版基于 Messages 的搜索仍然走原来的聊天层级,因为那条路线本身就是一次 messages 请求;
34
38
  - 会话列表来自 `sessionController` 服务(`list()`,会拆开它 `{items: […]}` 的信封),控件缺失 / 抛错 / 列表为空时再退到宿主 live 会话服务(`ctx.get('sessions').list()`),所以那种情况下面板仍能列出会话;侧栏藏起来的两种会话这里也藏:子代理会话(`origin: 'subagent'` 或带 `parentSession`)和**空会话**(建了但一句话都没发过,侧栏只显示当前开着的那个)。两种都不过滤的话,一个只有 1 个真实会话的工作区会列出 13 个;
35
39
  - 不是注册工作区的目录,只有侧栏真的会显示它才会被列出来。这件事宿主判不了:「空会话里只有当前开着的那个算数」是浏览器端才知道的事(`retainedBy.mainView`),所以宿主把这种目录连同每个会话的 `blank` 标记一起发出去,并用 `hiddenBlankDirs` 点名「里面全是空会话」的目录,由浏览器半边套用与侧栏一致的判据;
36
40
  - `/wsk-api/state` 会带上插件自身版本与会话探针(`available`、`shape`、`source`、`count`、`withoutCwd`、`subagents`、`samples`、`error`,以及工作区路径样本),面板据此说明**为什么**没有列出会话:宿主半边是旧版本(只刷新了页面而没重启应用)、没有 `sessionController`、`list()` 抛错、信封形状不认识,还是会话的 `cwd` 与任何工作区路径都对不上;
@@ -49,6 +53,7 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
49
53
  | POST | `/wsk-api/set` | `{scope: 'workspace' \| 'session', path \| sessionId, title, key, provider?}` 绑定 key;`{useDefault: true}` 或空 key 为清除 |
50
54
  | POST | `/wsk-api/test` | `{scope, path \| sessionId \| ref}`(或 `{key}`)真发一次请求并返回判定;`invalid` 打标记,`ok` 清标记 |
51
55
  | POST | `/wsk-api/clear-flag` | `{ref}` 或一个目标,清掉失效/配额标记 |
56
+ | POST | `/wsk-api/search-sync` | `{path, enabled}` —— 打开或关闭某个工作区的「同步搜索 API」 |
52
57
 
53
58
  只接受本机回环调用(`127.0.0.1`、`::1`、`::ffff:127.0.0.1`),且 `Origin` 必须同源或缺失。任何响应都不会包含明文 key,只有掩码。
54
59
 
@@ -56,13 +61,23 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
56
61
 
57
62
  ## 安装
58
63
 
59
- 插件被市场收录后,可以直接在「设置 → 插件市场」里一键安装;也可以手动装:
64
+ 插件被市场收录后,可以直接在「设置 → 插件市场」里一键安装;也可以从 npm 装:
60
65
 
61
66
  ```sh
62
- # 在 profile 目录里,例如 ~/.dsh/profiles/desktop
63
- pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
67
+ # 用 DSH 自带的 CLI 装进某个 profile(web、tui……)。Desktop 自己的 profile
68
+ # 由 Electron 应用独占管理,请在它的界面里安装。
69
+ dsh plugin --profile web add dsh-workspace-api-key
64
70
  ```
65
71
 
72
+ 这条命令会把参数原样转交给 profile 目录里的 pnpm,等价的手写形式是:
73
+
74
+ ```sh
75
+ # 在 profile 目录里,例如 ~/.dsh/profiles/web
76
+ pnpm add dsh-workspace-api-key # 或者:npm install dsh-workspace-api-key
77
+ ```
78
+
79
+ 锁版本用 `dsh-workspace-api-key@0.2.8`;直接从仓库装则用 `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key`(git 来源的插件会在安装时现场构建)。
80
+
66
81
  然后把 `"dsh-workspace-api-key"` 加进该 profile `package.json` 的 `dsh.profile.bundles`,重启 DSH。本地开发用 link 代替:
67
82
 
68
83
  ```jsonc
@@ -72,15 +87,16 @@ pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
72
87
  ## 测试
73
88
 
74
89
  ```sh
75
- node test/host.test.mjs # 136 条断言
76
- node test/client.test.mjs # 145 条断言
90
+ node test/host.test.mjs # 157 条断言
91
+ node test/client.test.mjs # 162 条断言
77
92
  ```
78
93
 
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 之后才可用时返回键仍然有效。
94
+ 两个套件都不需要启动 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 压过两者、两类搜索提醒,以及搜索连接测试。浏览器套件覆盖搜索相关界面:每个工作区行的「同步搜索 API」复选框、它发出的 `/wsk-api/search-sync`、提醒条与「使用说明」弹窗。
80
95
 
81
96
  ## 要求与限制
82
97
 
83
98
  - 已在 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 内置模块。
99
+ - 搜索功能依赖自带官方 Search/Fetch API 的 DSH(`web-deepseek-api`,即「设置 → 插件 → 网页搜索」里那个「启用新版 Web 搜索 API」)。没有这个能力的版本上,插件根本收不到搜索请求:开关照旧显示,面板会如实报告当前是旧版 Messages 通道,而不是瞎猜。
84
100
  - 每一层只有一把 key:一个会话无论被哪个 provider 路由读取,用的都是同一把 key;key 记下的服务商与会话当前服务商不一致时会被报为「不匹配」,但同一服务商内换模型不算不匹配,因为 DeepSeek key 是账号级的。
85
101
  - 会话按目录(`cwd`)归入工作区。宿主自己的 `list()` 会跳过从未存过 `cwd` 的历史会话,而 live 兜底只认识当前装载中的会话,所以面板列出的会话可能比侧栏少;只在浏览器端存储里可见的会话,除了侧栏知道的那个 `cwd` 之外没有别的路径信息。
86
102
  - 「测试连接」说的是 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,7 @@ 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)
487
518
  const [expanded, setExpanded] = useState(() => new Set())
488
519
 
489
520
  const alive = useRef(true)
@@ -653,6 +684,27 @@ window.__ModuleLoader__.load({
653
684
  }
654
685
  }
655
686
 
687
+ // 「同步搜索 API」:勾上之后,该目录下所有会话的搜索/抓取请求改用本插件的
688
+ // 层级 key(会话 → 工作区 → 系统默认);不勾则用官方搜索插件里配的那把 key。
689
+ const toggleSearchSync = async (path, enabled) => {
690
+ setBusy(true)
691
+ try {
692
+ applyState(await api('/search-sync', { method: 'POST', body: JSON.stringify({ path, enabled }) }))
693
+ if (alive.current) {
694
+ setNotice({
695
+ kind: 'ok',
696
+ text: enabled
697
+ ? '已同步:该目录下会话的搜索请求改用聊天用的那把 key(会话单独配置仍然优先)。'
698
+ : '已取消同步:该目录的搜索请求改用「设置 → 插件 → 网页搜索」里配置的 key。',
699
+ })
700
+ }
701
+ } catch (err) {
702
+ if (alive.current) setNotice({ kind: 'bad', text: String(err?.message ?? err) })
703
+ } finally {
704
+ if (alive.current) setBusy(false)
705
+ }
706
+ }
707
+
656
708
  const clearFlag = async (id, body, title) => {
657
709
  setBusy(true)
658
710
  try {
@@ -677,6 +729,8 @@ window.__ModuleLoader__.load({
677
729
  const ungrouped = view?.ungrouped ?? []
678
730
  const defaultRef = view?.defaultRef
679
731
  const providers = view?.providers ?? []
732
+ const searchInfo = view?.search
733
+ const searchWarnings = Array.isArray(searchInfo?.warnings) ? searchInfo.warnings : []
680
734
 
681
735
  // ── 诊断 ──────────────────────────────────────────────────────────────
682
736
  // 面板里「看不到可展开的会话」有两个完全不同的原因,修法也不同:
@@ -710,10 +764,16 @@ window.__ModuleLoader__.load({
710
764
  const blankDirs = Array.isArray(state.hiddenBlankDirs) && state.hiddenBlankDirs.length > 0
711
765
  ? ` · ${state.hiddenBlankDirs.length} 个目录只有空会话(未分组里不列)`
712
766
  : ''
767
+ const searchDiag = searchInfo === undefined
768
+ ? ''
769
+ : ` · 搜索通道:${searchInfo.apiChannel === true ? '新版 API' : 'Messages'}`
770
+ + `(${searchInfo.keyRef ?? '—'}${searchInfo.keyMasked ? ` · ${searchInfo.keyMasked}` : ' · 未配置'})`
771
+ + (searchInfo.syncCount > 0 ? ` · 已同步 ${searchInfo.syncCount} 个目录` : '')
713
772
  return `插件 v${hostVersion} · 状态存储 v${state.storeVersion ?? '—'}`
714
773
  + ` · 宿主会话列表:${sessions}${listError}${noCwd}${subs}`
715
774
  + ` · 浏览器会话列表:${clientCount} 个${blanks}${blankDirs}`
716
775
  + ` · 工作区 ${grouped.length} + 未分组 ${ungrouped.length} · 挂在目录下的会话 ${sessionTotal}`
776
+ + searchDiag
717
777
  }
718
778
 
719
779
  const diagSamples = () => {
@@ -896,6 +956,30 @@ window.__ModuleLoader__.load({
896
956
  }, verdictText(lastTest.verdict))
897
957
  : null),
898
958
  h('div', { className: 'wsk-path' }, prettyPath(ws.path)),
959
+ ws.kind === 'orphan'
960
+ ? null
961
+ : h('label', {
962
+ key: 'sync',
963
+ className: `wsk-check${ws.searchSync === true ? ' on' : ''}`,
964
+ title: '勾选后:该目录下会话的搜索/抓取请求改用聊天用的那把 key'
965
+ + '(会话单独配置仍然优先)。不勾则用「设置 → 插件 → 网页搜索」里配置的 key。',
966
+ },
967
+ h('input', {
968
+ type: 'checkbox',
969
+ checked: ws.searchSync === true,
970
+ disabled: busy,
971
+ onChange: (ev) => toggleSearchSync(ws.path, ev.target.checked),
972
+ }),
973
+ h('span', null, '同步搜索 API'),
974
+ h('span', { className: 'wsk-check-note' },
975
+ ws.searchSync === true ? '搜索用这里的 key' : '搜索用搜索插件里的 key')),
976
+ ws.kind !== 'orphan' && searchInfo?.apiChannel === true && ws.searchKeyMasked !== undefined
977
+ ? h('div', { key: 'sk', className: 'wsk-hint', style: { marginTop: 2 } },
978
+ `搜索生效 key:${ws.searchKeyMasked || '未配置'}`,
979
+ ws.searchSync === true
980
+ ? `(${ws.configured === true ? '本目录专属' : '系统默认'})`
981
+ : '(搜索插件)')
982
+ : null,
899
983
  reason !== undefined
900
984
  ? h('div', { className: 'wsk-hint', style: { color: 'var(--wsk-bad)', marginTop: 4 } }, reason)
901
985
  : null),
@@ -932,6 +1016,41 @@ window.__ModuleLoader__.load({
932
1016
  : null)
933
1017
  }
934
1018
 
1019
+ const renderHelp = () => h(Dialog, {
1020
+ title: '使用说明',
1021
+ onDismiss: () => setHelp(false),
1022
+ body: h('div', { className: 'wsk-help' },
1023
+ h('h4', null, '推荐用法(两把 key,各管一件事)'),
1024
+ h('ol', null,
1025
+ h('li', null, '先到「设置 → 模型」配一把系统默认 key,例如就叫 ', h('code', null, 'default'),
1026
+ '。没有单独配置的会话都会用它聊天。'),
1027
+ h('li', null, '再到「设置 → 插件 → 网页搜索」打开「启用新版 Web 搜索 API」,',
1028
+ '并在那里的 API Key 里配一把搜索专用的 key,例如 ', h('code', null, 'search-default'), '。'),
1029
+ h('li', null, '平时不用再管:聊天用工作区/会话的 key,搜索用 ', h('code', null, 'search-default'),
1030
+ ',两边计费互不影响。')),
1031
+ h('h4', null, '层级(从高到低)'),
1032
+ h('ul', null,
1033
+ h('li', null, '会话单独配置 → 覆盖该会话的一切(聊天与搜索都用它)'),
1034
+ h('li', null, '工作区专属 key → 该工作区下所有没单独配置的会话'),
1035
+ h('li', null, '系统默认 key → 兜底'),
1036
+ h('li', null, '搜索默认 key → 只有在工作区没勾「同步搜索 API」、且该会话没有单独配置时才用')),
1037
+ h('h4', null, '「同步搜索 API」是什么'),
1038
+ h('ul', null,
1039
+ h('li', null, '勾选:该目录下会话的搜索/抓取请求改用上面那套层级 key(和聊天完全一致)。'),
1040
+ h('li', null, '不勾:搜索用「搜索插件里配置的 key」(例如 ', h('code', null, 'search-default'), ')。'),
1041
+ h('li', null, '会话单独配置的 key 永远优先——勾不勾都一样。'),
1042
+ h('li', null, '这个开关只对「新版 Web 搜索 API」有效;旧的 Messages 搜索本来就用聊天 key。')),
1043
+ h('h4', null, '会被提醒的几种情况'),
1044
+ h('ul', null,
1045
+ h('li', null, '配了搜索 key,但没打开「启用新版 Web 搜索 API」→ 那把 key 其实不会被用到,面板顶部会提示。'),
1046
+ h('li', null, '打开了新版搜索 API,但搜索 key 是空的 → 任何搜索都会失败,面板顶部会提示。'),
1047
+ h('li', null, '勾了同步,但该工作区既没有专属 key、系统默认 key 也没配 → 会提示先去配一把。')),
1048
+ h('h4', null, '原则'),
1049
+ h('div', { className: 'wsk-hint' },
1050
+ '提醒只报告、不替你改 key。插件绝不会自动切换或替换你的 key,避免计费被悄悄改到别的账号上。')),
1051
+ actions: [{ label: '知道了', primary: true, onClick: () => setHelp(false) }],
1052
+ })
1053
+
935
1054
  const renderPrompt = () => {
936
1055
  if (prompt === null) return null
937
1056
  if (prompt.kind === 'no-default') {
@@ -995,6 +1114,9 @@ window.__ModuleLoader__.load({
995
1114
  h('span', { className: 'wsk-title' }, 'API Key 分配'),
996
1115
  h('span', { className: 'wsk-sub' }, '按工作区 / 会话隔离计费'),
997
1116
  h('span', { className: 'wsk-spacer' }),
1117
+ h('button', {
1118
+ type: 'button', className: 'wsk-btn', onClick: () => setHelp(true),
1119
+ }, '使用说明'),
998
1120
  h('button', {
999
1121
  type: 'button', className: 'wsk-btn', disabled: busy, onClick: refresh,
1000
1122
  }, refreshSvg(14), h('span', null, busy ? '刷新中…' : '刷新'))),
@@ -1015,6 +1137,17 @@ window.__ModuleLoader__.load({
1015
1137
  '请「完全退出」DSH Desktop 再重新打开(关闭窗口 / 刷新页面都不够:宿主代码只在启动时加载)。'),
1016
1138
  h('div', { className: 'wsk-hint' }, diagText()))
1017
1139
  : null,
1140
+ // 搜索配置的提醒(宿主算好、界面只负责展示):都是「不提醒的话,用户
1141
+ // 要等搜索失败才会发现」的问题,所以放在最上面。
1142
+ searchWarnings.map((warning, index) => h('div', {
1143
+ key: `warn:${warning.code ?? index}`,
1144
+ className: `wsk-msg ${warning.level === 'bad' ? 'bad' : warning.level === 'warn' ? 'warn' : 'info'}`,
1145
+ style: { marginBottom: 12 },
1146
+ },
1147
+ warning.title,
1148
+ warning.detail !== undefined
1149
+ ? h('div', { className: 'wsk-hint' }, warning.detail)
1150
+ : null)),
1018
1151
  state !== null && state.sessionListAvailable === false && clientCount === 0
1019
1152
  ? h('div', { className: 'wsk-msg info', style: { marginBottom: 12 } },
1020
1153
  '当前 DSH 版本没有提供会话列表服务,只能按工作区配置 key。',
@@ -1088,7 +1221,8 @@ window.__ModuleLoader__.load({
1088
1221
  state?.statePath ? h('div', null, `状态文件:${state.statePath}`) : null,
1089
1222
  diagText() !== undefined ? h('div', { className: 'wsk-diag' }, diagText()) : null)),
1090
1223
 
1091
- renderPrompt())
1224
+ renderPrompt(),
1225
+ help ? renderHelp() : null)
1092
1226
  }
1093
1227
 
1094
1228
  // ─────────────────────── 进入会话时的提醒(覆盖层) ───────────────────────
@@ -1132,7 +1266,11 @@ window.__ModuleLoader__.load({
1132
1266
  + (cwd === undefined ? '' : `&path=${encodeURIComponent(cwd)}`)
1133
1267
  const res = await api(query)
1134
1268
  if (disposed) return
1135
- if (res.needsAttention === true) setPrompt(res)
1269
+ // 搜索配置问题也要提醒(否则用户只会在下一次搜索失败时才发现)。
1270
+ const searchIssue = res.search?.apiChannel === true
1271
+ ? res.search?.keyConfigured === false
1272
+ : res.search?.leftoverKey === true
1273
+ if (res.needsAttention === true || searchIssue === true) setPrompt(res)
1136
1274
  } catch {
1137
1275
  /* 宿主接口不可用时不打扰用户 */
1138
1276
  }
@@ -1203,8 +1341,13 @@ window.__ModuleLoader__.load({
1203
1341
  if (prompt === null) return null
1204
1342
  const invalid = invalidReason(prompt.invalid)
1205
1343
  const mismatch = mismatchText(prompt.mismatch)
1344
+ const searchIssue = searchIssueText(prompt.search)
1206
1345
  return h(Dialog, {
1207
- title: invalid !== undefined ? '这个会话用的 API Key 已失效' : '这个会话的 API Key 可能不匹配',
1346
+ title: invalid !== undefined
1347
+ ? '这个会话用的 API Key 已失效'
1348
+ : mismatch !== undefined
1349
+ ? '这个会话的 API Key 可能不匹配'
1350
+ : '网页搜索的配置需要确认',
1208
1351
  body: h('div', null,
1209
1352
  kv('会话', prompt.title ?? String(prompt.sessionId ?? '').slice(0, 12)),
1210
1353
  kv('模型', `${prompt.provider ?? '未知'} / ${prompt.model ?? '默认模型'}`),
@@ -1215,6 +1358,9 @@ window.__ModuleLoader__.load({
1215
1358
  mismatch !== undefined
1216
1359
  ? h('div', { className: 'wsk-hint', style: { marginTop: 8, color: 'var(--wsk-warn)' } }, mismatch)
1217
1360
  : null,
1361
+ searchIssue !== undefined
1362
+ ? h('div', { className: 'wsk-hint', style: { marginTop: 8, color: 'var(--wsk-warn)' } }, searchIssue)
1363
+ : null,
1218
1364
  h('div', { className: 'wsk-hint', style: { marginTop: 8 } },
1219
1365
  '插件不会自动更换 key —— 用哪把 key 由你决定,避免计费被悄悄改到别的账号上。')),
1220
1366
  onDismiss: () => setPrompt(null),
@@ -1223,7 +1369,12 @@ window.__ModuleLoader__.load({
1223
1369
  ...(invalid !== undefined
1224
1370
  ? [{ label: res2Upper(prompt), onClick: useUpperLevel }]
1225
1371
  : []),
1226
- { label: invalid !== undefined ? '稍后' : '继续用当前 key', onClick: () => setPrompt(null) },
1372
+ {
1373
+ label: invalid !== undefined
1374
+ ? '稍后'
1375
+ : mismatch !== undefined ? '继续用当前 key' : '知道了',
1376
+ onClick: () => setPrompt(null),
1377
+ },
1227
1378
  ],
1228
1379
  })
1229
1380
  }
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,28 @@ 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
+ /**
68
+ * 搜索通道标记:包住 `ctx.web.search` / `ctx.web.fetch`,在它们的异步调用链里
69
+ * 打一个 AsyncLocalStorage 标记。`credentials.resolve` 的 wrapper 据此知道
70
+ * 「这次取 key 是搜索/抓取在要,还是聊天在要」——两者用的是同一把
71
+ * `DEEPSEEK_API_KEY` 时,除此之外没有别的可靠区分方式(resolve 只收到 ref)。
72
+ */
73
+ const searchScope = new AsyncLocalStorage()
74
+ const WEB_SCOPE_MARK = Symbol.for('dsh-workspace-api-key/web-scope')
75
+
53
76
  // 幂等标记:同一进程里可能被 apply 多次(热重载),只装一层 wrapper。
54
77
  const INSTALL_KEY = Symbol.for('dsh-workspace-api-key/installed')
55
78
 
@@ -202,6 +225,38 @@ function effectiveOverride(ctx, state, { sessionId, cwd }) {
202
225
  return { level: 'default', ref: undefined, entry: undefined }
203
226
  }
204
227
 
228
+ /** 该会话是否有「单独设置」的 key(搜索通道里它永远优先)。 */
229
+ function sessionOverrideRef(state, sessionId) {
230
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
231
+ const ref = sessionRef(sessionId)
232
+ if (ref === undefined) return undefined
233
+ return state.overrides[ref] === undefined ? undefined : ref
234
+ }
235
+
236
+ /** 该目录是否勾了「同步搜索 API」。 */
237
+ function workspaceSynced(state, cwd) {
238
+ const norm = normPath(cwd)
239
+ if (norm === undefined) return false
240
+ return state.searchSync?.[norm] === true
241
+ }
242
+
243
+ /**
244
+ * 「这个会话/目录的搜索请求会去读哪个 ref」——纯计算,不解析凭据。
245
+ * 新版 API 通道:会话单独 key → 工作区勾了同步 ? (工作区 key / 系统默认): 搜索插件自己的 ref
246
+ * 旧 Messages 通道:搜索就在用聊天 key,固定返回搜索 ref。
247
+ */
248
+ function searchRefFor(ctx, state, { sessionId, cwd, web }) {
249
+ const w = web ?? readWebSettings(ctx)
250
+ if (!w.apiChannel) return w.searchKeyEnv
251
+ const own = sessionOverrideRef(state, sessionId)
252
+ if (own !== undefined) return own
253
+ if (workspaceSynced(state, cwd)) {
254
+ const hit = effectiveOverride(ctx, state, { sessionId, cwd })
255
+ return hit.ref ?? DEFAULT_API_KEY_REF
256
+ }
257
+ return w.searchKeyEnv
258
+ }
259
+
205
260
  // ───────────────── 会话列表(宿主 sessionController + live 兜底) ─────────────────
206
261
 
207
262
  /** 宿主是否挂载了会话列表 API(仅诊断用;界面看 sessionProbe.available)。 */
@@ -433,11 +488,13 @@ function stateFilePath() {
433
488
  return join(home, 'storages', 'dsh-workspace-api-key.json')
434
489
  }
435
490
 
436
- const STATE_VERSION = 2
491
+ const STATE_VERSION = 3
437
492
 
438
493
  /**
439
494
  * v1 的覆盖项没有 scope/sessionId/provider 字段,读进来时补齐:
440
495
  * 有 sessionId 的算会话级,其余算工作区级。
496
+ * v3 新增 `searchSync`(规范路径 → true):该工作区的搜索/抓取请求改用本插件
497
+ * 的层级 key(会话 → 工作区 → 系统默认),而不是官方搜索插件里配置的那把。
441
498
  */
442
499
  function normalizeState(parsed) {
443
500
  const overrides = {}
@@ -453,17 +510,27 @@ function normalizeState(parsed) {
453
510
  }
454
511
  }
455
512
  const invalid = typeof parsed?.invalid === 'object' && parsed.invalid !== null ? parsed.invalid : {}
456
- return { version: STATE_VERSION, overrides, invalid }
513
+ const searchSync = {}
514
+ const rawSync = typeof parsed?.searchSync === 'object' && parsed.searchSync !== null ? parsed.searchSync : {}
515
+ for (const [key, value] of Object.entries(rawSync)) {
516
+ const norm = normPath(key)
517
+ if (norm !== undefined && value === true) searchSync[norm] = true
518
+ }
519
+ return { version: STATE_VERSION, overrides, invalid, searchSync }
520
+ }
521
+
522
+ function emptyState() {
523
+ return { version: STATE_VERSION, overrides: {}, invalid: {}, searchSync: {} }
457
524
  }
458
525
 
459
526
  function loadState() {
460
527
  const path = stateFilePath()
461
528
  try {
462
- if (!existsSync(path)) return { version: STATE_VERSION, overrides: {}, invalid: {} }
529
+ if (!existsSync(path)) return emptyState()
463
530
  return normalizeState(JSON.parse(readFileSync(path, 'utf8')))
464
531
  } catch (err) {
465
532
  console.warn('[workspace-api-key] load state failed:', String(err?.message ?? err))
466
- return { version: STATE_VERSION, overrides: {}, invalid: {} }
533
+ return emptyState()
467
534
  }
468
535
  }
469
536
 
@@ -586,9 +653,151 @@ function interceptableRefs(ctx, state) {
586
653
  for (const entry of Object.values(state.overrides)) {
587
654
  if (typeof entry?.ref === 'string' && entry.ref !== '') refs.add(entry.ref)
588
655
  }
656
+ // 搜索/抓取通道的 ref 也要拦:用户可能把「新版 Web 搜索 API」的 key 指到
657
+ // 独立 ref(例如 DEEPSEEK_SEARCH_API_KEY)。不拦的话,勾了「同步搜索 API」
658
+ // 也会静默不生效——面板显示已同步、实际还在用系统默认 key。
659
+ try {
660
+ const web = readWebSettings(ctx)
661
+ for (const ref of [web.apiKeyEnv, web.legacyKeyEnv]) {
662
+ if (typeof ref === 'string' && ref !== '') refs.add(ref)
663
+ }
664
+ } catch {
665
+ /* settings 不可用时只保留已知 ref */
666
+ }
589
667
  return refs
590
668
  }
591
669
 
670
+ // ───────────────────────── 搜索 / 抓取通道配置 ─────────────────────────
671
+
672
+ /**
673
+ * 读官方「插件 → 网页搜索」页写的三处配置(全部走 `ctx.get('settings').get(ns)`,
674
+ * 与 dsh-client-ui-settings-web-search 的 web-search-card-controller 同源)。
675
+ *
676
+ * - `web.searchProvider` / `web.fetchProvider`:开关把它写成
677
+ * `deepseek-search-api` + `deepseek-fetch-api`(新版)或 `deepseek-official` + `http`(旧的 Messages 版)。
678
+ * - `web-deepseek-api.*`:新版 Search/Fetch API 的 key 引用、基础地址、每次结果数。
679
+ * - `web-search-deepseek.*`:旧版 Messages 搜索的 key 引用。
680
+ *
681
+ * 官方页的密钥输入框写的是「当前选中那套 API 的 apiKeyEnv 指向的凭据」,所以
682
+ * 我们只要读 apiKeyEnv,就等于知道用户配的搜索 key 在哪个 ref 上。
683
+ */
684
+ function readWebSettings(ctx) {
685
+ let settings
686
+ try {
687
+ settings = ctx.get('settings')
688
+ } catch {
689
+ settings = undefined
690
+ }
691
+ const read = (ns) => {
692
+ try {
693
+ const value = settings?.get?.(ns)
694
+ return typeof value === 'object' && value !== null ? value : {}
695
+ } catch {
696
+ return {}
697
+ }
698
+ }
699
+ const api = read(SEARCH_API_NS)
700
+ const legacy = read(LEGACY_SEARCH_NS)
701
+ const web = read(WEB_NS)
702
+
703
+ const apiKeyEnv = typeof api.apiKeyEnv === 'string' && api.apiKeyEnv !== '' ? api.apiKeyEnv : DEFAULT_API_KEY_REF
704
+ const legacyKeyEnv = typeof legacy.apiKeyEnv === 'string' && legacy.apiKeyEnv !== '' ? legacy.apiKeyEnv : DEFAULT_API_KEY_REF
705
+ const baseURL = typeof api.baseURL === 'string' && api.baseURL !== '' ? api.baseURL : FALLBACK_SEARCH_BASE_URL
706
+ const searchProvider = typeof web.searchProvider === 'string' && web.searchProvider !== ''
707
+ ? web.searchProvider
708
+ : FALLBACK_SEARCH_PROVIDER
709
+ const fetchProvider = typeof web.fetchProvider === 'string' && web.fetchProvider !== ''
710
+ ? web.fetchProvider
711
+ : FALLBACK_FETCH_PROVIDER
712
+ const apiLiteralKey = typeof api.apiKey === 'string' && api.apiKey !== '' ? api.apiKey : undefined
713
+ const legacyLiteralKey = typeof legacy.apiKey === 'string' && legacy.apiKey !== '' ? legacy.apiKey : undefined
714
+ return {
715
+ apiKeyEnv,
716
+ legacyKeyEnv,
717
+ apiLiteralKey,
718
+ legacyLiteralKey,
719
+ baseURL,
720
+ searchProvider,
721
+ fetchProvider,
722
+ count: typeof api.count === 'number' ? api.count : 10,
723
+ /** 新版 Search/Fetch API 是否被选中(官方开关)= provider 选择被改成了 API 版。 */
724
+ apiChannel: searchProvider === SEARCH_PROVIDER_ID || fetchProvider === SEARCH_FETCH_PROVIDER_ID,
725
+ /** 搜索请求真正会用的那套配置的 key 引用。 */
726
+ searchKeyEnv: searchProvider === SEARCH_PROVIDER_ID ? apiKeyEnv : legacyKeyEnv,
727
+ searchLiteralKey: searchProvider === SEARCH_PROVIDER_ID ? apiLiteralKey : legacyLiteralKey,
728
+ }
729
+ }
730
+
731
+ /**
732
+ * 面板顶部要展示的「该提醒但容易忽略」的搜索配置问题。
733
+ *
734
+ * 只报告,不改任何配置——每一条都能自己解释清楚「为什么」和「怎么修」。
735
+ */
736
+ async function searchWarnings(ctx, state, web) {
737
+ const out = []
738
+ const apiKeyConfigured = web.apiKeyEnv === DEFAULT_API_KEY_REF
739
+ ? (await describeKey(ctx, DEFAULT_API_KEY_REF)).configured === true
740
+ : (await describeKey(ctx, web.apiKeyEnv)).configured === true
741
+ const apiKeyPresent = web.apiLiteralKey !== undefined || (web.apiKeyEnv !== DEFAULT_API_KEY_REF && apiKeyConfigured)
742
+
743
+ // 1) 配了新版搜索 API 的 key,但官方开关没开 → 那把 key 永远不会被读到。
744
+ if (!web.apiChannel && apiKeyPresent) {
745
+ out.push({
746
+ code: 'search-key-idle',
747
+ level: 'warn',
748
+ title: '搜索 key 已配置,但「启用新版 Web 搜索 API」没打开',
749
+ detail: `当前搜索走的是 DeepSeek Messages 通道(provider: ${web.searchProvider}),`
750
+ + `所以 ${web.apiKeyEnv} 这把 key 不会被使用。要让它生效,请到「设置 → 插件 → 网页搜索」打开那个开关;`
751
+ + '若暂时不想用新版 API,可以忽略本条。',
752
+ })
753
+ }
754
+
755
+ // 2) 开了新版 API,却没有可用的搜索 key → 搜索会直接报缺少凭据。
756
+ if (web.apiChannel && web.apiLiteralKey === undefined && !apiKeyConfigured) {
757
+ out.push({
758
+ code: 'search-key-missing',
759
+ level: 'bad',
760
+ title: '新版 Web 搜索 API 已启用,但搜索 key 没有配置',
761
+ detail: `搜索会用 ${web.apiKeyEnv} 去取 key,而它当前是空的:任何 web_search 都会失败。`
762
+ + '到「设置 → 插件 → 网页搜索」里填一把,或把该 ref 指向已有的 key。',
763
+ })
764
+ }
765
+
766
+ // 3) 勾了「同步搜索 API」的工作区里,既没有工作区 key,也没有系统默认 key。
767
+ const defaultConfigured = (await describeKey(ctx, DEFAULT_API_KEY_REF)).configured === true
768
+ const syncPaths = Object.keys(state.searchSync ?? {}).filter((key) => state.searchSync[key] === true)
769
+ const dryWorkspaces = []
770
+ for (const norm of syncPaths) {
771
+ const wsRef = derivedRef(norm)
772
+ const hasWorkspaceKey = wsRef !== undefined && state.overrides[wsRef] !== undefined
773
+ if (!hasWorkspaceKey && !defaultConfigured) dryWorkspaces.push(norm)
774
+ }
775
+ if (dryWorkspaces.length > 0) {
776
+ out.push({
777
+ code: 'search-sync-no-key',
778
+ level: 'warn',
779
+ title: `${dryWorkspaces.length} 个已同步的工作区没有可用的 key`,
780
+ detail: '这些工作区勾了「同步搜索 API」,但既没有工作区专属 key,系统默认 key 也没配置:'
781
+ + '搜索会用空 key 发出去。给这些工作区各配一把,或先配好系统默认 key。',
782
+ paths: dryWorkspaces,
783
+ })
784
+ }
785
+
786
+ // 4) 勾了同步,但当前还是旧的 Messages 通道 → 同步开关对搜索没有区别
787
+ // (旧通道本来就解析聊天用的 ref,层级 key 一直生效)。
788
+ if (!web.apiChannel && syncPaths.length > 0) {
789
+ out.push({
790
+ code: 'search-sync-legacy',
791
+ level: 'info',
792
+ title: '「同步搜索 API」现在没有区别',
793
+ detail: '当前搜索走旧的 Messages 通道,它本来就用聊天通道的 key(会话 → 工作区 → 系统默认),'
794
+ + '所以这个开关只影响「启用新版 Web 搜索 API」之后的行为。',
795
+ })
796
+ }
797
+
798
+ return out
799
+ }
800
+
592
801
  // ─────────────────────────── 失效判定 ───────────────────────────
593
802
 
594
803
  function classifyFailure(failure) {
@@ -786,12 +995,84 @@ async function probeKey(ctx, key) {
786
995
  return { ok: false, verdict: 'error', status: res.status, latencyMs, detail: `${sanitizeDetail(raw) ?? `HTTP ${res.status}`}${hint}` }
787
996
  }
788
997
 
789
- // ─────────────────────────── 插件主体 ───────────────────────────
998
+ /**
999
+ * 验证「新版 Web 搜索 API」这条通道:`POST {baseURL}/search`,最小请求。
1000
+ * 与搜索插件自己的调用形状一致(`{ query, count, need_content }`),所以 200 就
1001
+ * 说明「key 有效 + 端点正确 + 开关真的生效」三件事同时成立。
1002
+ */
1003
+ async function probeSearch(ctx, state, key, web) {
1004
+ const base = String(web?.baseURL ?? FALLBACK_SEARCH_BASE_URL).replace(/\/+$/u, '')
1005
+ const endpoint = `${base}/search`
1006
+ const started = Date.now()
1007
+ let res
1008
+ try {
1009
+ res = await fetch(endpoint, {
1010
+ method: 'POST',
1011
+ redirect: 'error',
1012
+ headers: {
1013
+ 'content-type': 'application/json',
1014
+ accept: 'application/json',
1015
+ authorization: `Bearer ${key}`,
1016
+ },
1017
+ body: JSON.stringify({ query: 'deepseek', count: 1, need_content: false }),
1018
+ signal: AbortSignal.timeout(TIMEOUT_MS),
1019
+ })
1020
+ } catch (err) {
1021
+ const message = String(err?.message ?? err)
1022
+ const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError'
1023
+ return {
1024
+ ok: false,
1025
+ verdict: 'unreachable',
1026
+ status: 0,
1027
+ latencyMs: Date.now() - started,
1028
+ detail: timedOut ? `请求超时(${TIMEOUT_MS}ms):${endpoint}` : `${message}(${endpoint})`,
1029
+ }
1030
+ }
1031
+
1032
+ const latencyMs = Date.now() - started
1033
+ const raw = await res.text().catch(() => '')
1034
+ if (res.ok) {
1035
+ let count
1036
+ try {
1037
+ const parsed = JSON.parse(raw)
1038
+ count = Array.isArray(parsed?.results) ? parsed.results.length : undefined
1039
+ } catch {
1040
+ /* 非 JSON 也算成功 */
1041
+ }
1042
+ return {
1043
+ ok: true,
1044
+ verdict: 'ok',
1045
+ status: res.status,
1046
+ latencyMs,
1047
+ detail: `搜索通道正常${count === undefined ? '' : `,返回 ${count} 条结果`}(${endpoint})`,
1048
+ }
1049
+ }
1050
+ if (res.status === 401 || res.status === 403) {
1051
+ return { ok: false, verdict: 'invalid', status: res.status, latencyMs, detail: sanitizeDetail(raw) ?? 'key 无效(401/403)' }
1052
+ }
1053
+ if (res.status === 402) {
1054
+ return { ok: false, verdict: 'quota', status: res.status, latencyMs, detail: sanitizeDetail(raw) ?? '余额/配额不足(402)' }
1055
+ }
1056
+ if (res.status === 429) {
1057
+ return { ok: false, verdict: 'rate-limit', status: res.status, latencyMs, detail: sanitizeDetail(raw) ?? '被限流(429),但 key 本身可能有效' }
1058
+ }
1059
+ if (res.status === 400) {
1060
+ return { ok: true, verdict: 'ok', status: res.status, latencyMs, detail: `鉴权通过(HTTP 400:${sanitizeDetail(raw) ?? '请求参数被拒'})` }
1061
+ }
1062
+ const hint = res.status === 404
1063
+ ? `(404:端点 ${endpoint} 不存在,检查「Search / Fetch API 基础地址」)`
1064
+ : web?.apiChannel === false
1065
+ ? `(${endpoint};当前搜索通道是 Messages 版,新版 API 可能还没启用)`
1066
+ : `(${endpoint})`
1067
+ return { ok: false, verdict: 'error', status: res.status, latencyMs, detail: `${sanitizeDetail(raw) ?? `HTTP ${res.status}`}${hint}` }
1068
+ }
790
1069
 
1070
+ // ─────────────────────────── 插件主体 ───────────────────────────
791
1071
  export function apply(ctx) {
792
1072
  const state = loadState()
793
1073
 
794
- // ── 1. 包装 credentials.resolve ──────────────────────────────────────
1074
+ // ── 1. 包装 credentials.resolve + 标记搜索/抓取通道 ─────────────────
1075
+ installWebScope(ctx)
795
1076
  installResolver(ctx, state)
796
1077
 
797
1078
  // ── 2. REST API(loopback 可信)──────────────────────────────────────
@@ -842,6 +1123,54 @@ export function apply(ctx) {
842
1123
  )
843
1124
  }
844
1125
 
1126
+ /**
1127
+ * 装上搜索/抓取通道标记:把 `ctx.web.search` / `ctx.web.fetch` 包一层
1128
+ * AsyncLocalStorage,让 resolve wrapper 知道「这次取 key 的是搜索,不是聊天」。
1129
+ * 装不上就静默跳过——不影响任何既有行为(只是无法区分通道)。
1130
+ */
1131
+ function installWebScope(ctx) {
1132
+ if (globalThis[WEB_SCOPE_MARK] !== undefined) {
1133
+ log('web scope already installed; skipping')
1134
+ return
1135
+ }
1136
+ let web
1137
+ try {
1138
+ web = ctx.get('web')
1139
+ } catch {
1140
+ web = undefined
1141
+ }
1142
+ if (web === undefined) return
1143
+
1144
+ const originals = {}
1145
+ for (const method of ['search', 'fetch']) {
1146
+ if (typeof web[method] !== 'function') continue
1147
+ const original = web[method].bind(web)
1148
+ originals[method] = original
1149
+ try {
1150
+ web[method] = (...args) => searchScope.run(true, () => original(...args))
1151
+ } catch {
1152
+ delete originals[method]
1153
+ }
1154
+ }
1155
+ if (Object.keys(originals).length === 0) return
1156
+
1157
+ globalThis[WEB_SCOPE_MARK] = { web, originals }
1158
+ ctx.effect(
1159
+ () => () => {
1160
+ for (const [method, original] of Object.entries(originals)) {
1161
+ try {
1162
+ web[method] = original
1163
+ } catch {
1164
+ /* ignore */
1165
+ }
1166
+ }
1167
+ delete globalThis[WEB_SCOPE_MARK]
1168
+ },
1169
+ 'workspace-api-key: restore web.search/fetch',
1170
+ )
1171
+ log(`web scope wrapped: ${Object.keys(originals).join(',')}`)
1172
+ }
1173
+
845
1174
  /**
846
1175
  * 装上 resolve wrapper。装不上也不能影响宿主:任何异常都吞掉并退回原行为。
847
1176
  */
@@ -866,8 +1195,43 @@ function installResolver(ctx, state) {
866
1195
  const wrapped = async (ref /*, ...rest */) => {
867
1196
  try {
868
1197
  if (!interceptableRefs(ctx, state).has(ref)) return await original(ref)
1198
+ const sessionId = activeSessionId(ctx)
1199
+ const cwd = activeCwd(ctx)
1200
+
1201
+ // 搜索 / 抓取通道:官方「新版 Web 搜索 API」用的是独立的一套 key 配置,
1202
+ // 语义与聊天不同,因此单独处理(旧 Messages 通道仍走下面的聊天层级)。
1203
+ if (searchScope.getStore() === true) {
1204
+ const web = readWebSettings(ctx)
1205
+ if (web.apiChannel) {
1206
+ // ① 会话单独设置的 key 永远优先——哪怕官方搜索插件里配了别的 key。
1207
+ const sessionKey = sessionOverrideRef(state, sessionId)
1208
+ if (sessionKey !== undefined) {
1209
+ const value = await original(sessionKey)
1210
+ if (value?.value !== undefined && value.value !== '') {
1211
+ log(`search ref ${ref} → ${sessionKey} (session)`)
1212
+ return value
1213
+ }
1214
+ }
1215
+ // ② 工作区勾了「同步搜索 API」:按会话 → 工作区 → 系统默认的层级。
1216
+ if (workspaceSynced(state, cwd)) {
1217
+ const hit = effectiveOverride(ctx, state, { sessionId, cwd })
1218
+ if (hit.ref !== undefined && hit.level !== 'default') {
1219
+ const value = await original(hit.ref)
1220
+ if (value?.value !== undefined && value.value !== '') {
1221
+ log(`search ref ${ref} → ${hit.ref} (${hit.level}, synced)`)
1222
+ return value
1223
+ }
1224
+ }
1225
+ const fallback = await original(DEFAULT_API_KEY_REF)
1226
+ if (fallback?.value !== undefined && fallback.value !== '') return fallback
1227
+ }
1228
+ // ③ 没同步:用官方搜索插件自己配的那把 key(原样解析)。
1229
+ return await original(ref)
1230
+ }
1231
+ }
1232
+
869
1233
  // 1) 会话级覆盖优先,2) 再工作区级,3) 都没有就用系统默认(原 ref)。
870
- const hit = effectiveOverride(ctx, state, { sessionId: activeSessionId(ctx), cwd: activeCwd(ctx) })
1234
+ const hit = effectiveOverride(ctx, state, { sessionId, cwd })
871
1235
  if (hit.ref !== undefined && hit.level !== 'default') {
872
1236
  const value = await original(hit.ref)
873
1237
  if (value?.value !== undefined && value.value !== '') {
@@ -1016,6 +1380,10 @@ async function sessionRow(ctx, state, row, fallbackCwd) {
1016
1380
  effectiveTitle: eff.entry?.title,
1017
1381
  invalid: eff.ref !== undefined ? state.invalid[eff.ref] ?? null : null,
1018
1382
  mismatch: mismatchOf(eff.entry, selection),
1383
+ // 搜索通道:该会话所在目录是否勾了「同步搜索 API」,以及本会话是否另有
1384
+ // 单独的 key(单独设置永远优先于工作区同步与官方搜索插件里的 key)。
1385
+ searchSync: workspaceSynced(state, cwd),
1386
+ sessionSearchKey: sessionOverrideRef(state, sessionId) !== undefined,
1019
1387
  }
1020
1388
  }
1021
1389
 
@@ -1031,6 +1399,19 @@ async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, cl
1031
1399
  }
1032
1400
  sessions.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
1033
1401
  if (claimed !== undefined) claimed.add(normPath(dir))
1402
+ // 搜索通道:只有新版 Search/Fetch API 才有「同步 / 不同步」的区别(旧 Messages
1403
+ // 通道本来就用聊天 key)。这里给出「该工作区的搜索请求实际会用哪把 key」,
1404
+ // 让用户勾了复选框之后马上能看到效果,而不用等到搜索失败才发现。
1405
+ const web = readWebSettings(ctx)
1406
+ const synced = state.searchSync?.[normPath(dir)] === true
1407
+ const searchKeyRef = web.apiChannel
1408
+ ? synced
1409
+ ? configured
1410
+ ? ref
1411
+ : DEFAULT_API_KEY_REF
1412
+ : web.searchKeyEnv
1413
+ : web.searchKeyEnv
1414
+ const searchKeyMasked = web.apiChannel ? maskKey((await resolveKey(ctx, searchKeyRef))?.value) : undefined
1034
1415
  return {
1035
1416
  id,
1036
1417
  path: dir,
@@ -1044,6 +1425,9 @@ async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, cl
1044
1425
  overrideProvider: configured ? state.overrides[ref]?.provider : undefined,
1045
1426
  invalid: ref !== undefined ? state.invalid[ref] ?? null : null,
1046
1427
  exists: typeof dir === 'string' ? existsSync(dir) : false,
1428
+ searchSync: synced,
1429
+ searchKeyRef,
1430
+ searchKeyMasked,
1047
1431
  sessions,
1048
1432
  }
1049
1433
  }
@@ -1157,6 +1541,14 @@ async function snapshot(ctx, state) {
1157
1541
  })
1158
1542
  }
1159
1543
 
1544
+ // ── 搜索 / 抓取通道总览 ──────────────────────────────────────────────
1545
+ const web = readWebSettings(ctx)
1546
+ const searchKeyRef = web.searchKeyEnv
1547
+ const searchKeyInfo = await describeKey(ctx, searchKeyRef)
1548
+ const searchKeyHit = await resolveKey(ctx, searchKeyRef)
1549
+ const syncPaths = Object.keys(state.searchSync ?? {}).filter((key) => state.searchSync[key] === true)
1550
+ const warnings = await searchWarnings(ctx, state, web)
1551
+
1160
1552
  return {
1161
1553
  ok: true,
1162
1554
  pluginVersion: PLUGIN_VERSION,
@@ -1193,6 +1585,25 @@ async function snapshot(ctx, state) {
1193
1585
  // 因为「当前打开的空会话」要显示,而这件事只有浏览器端知道)。
1194
1586
  hiddenBlankDirs,
1195
1587
  sessionOverrides,
1588
+ // 搜索 / 抓取通道:官方「设置 → 插件 → 网页搜索」里的配置 + 本插件怎么参与。
1589
+ // 面板据此显示通道、生效 key 与提醒;不暴露任何明文。
1590
+ search: {
1591
+ channel: web.apiChannel ? 'api' : 'messages',
1592
+ apiChannel: web.apiChannel,
1593
+ searchProvider: web.searchProvider,
1594
+ fetchProvider: web.fetchProvider,
1595
+ baseURL: web.baseURL,
1596
+ count: web.count,
1597
+ keyRef: searchKeyRef,
1598
+ keyRefConfigured: searchKeyInfo.configured === true,
1599
+ keyMasked: maskKey(searchKeyHit?.value),
1600
+ keySource: searchKeyInfo.source,
1601
+ keyWritable: searchKeyInfo.writable !== false,
1602
+ literalKey: web.searchLiteralKey !== undefined,
1603
+ syncCount: syncPaths.length,
1604
+ syncPaths,
1605
+ warnings,
1606
+ },
1196
1607
  }
1197
1608
  }
1198
1609
 
@@ -1266,6 +1677,7 @@ async function checkSession(ctx, state, params) {
1266
1677
  const sessionEntry = sid !== undefined ? state.overrides[sessionRef(sid)] : undefined
1267
1678
  const ws = locateWorkspace(ctx, cwd)
1268
1679
  const wsRef = ws !== undefined ? derivedRef(ws.path) : undefined
1680
+ const searchState = await searchStateOf(ctx, state, { sessionId: sid, cwd })
1269
1681
  return {
1270
1682
  ok: true,
1271
1683
  sessionId: sid ?? null,
@@ -1295,6 +1707,27 @@ async function checkSession(ctx, state, params) {
1295
1707
  },
1296
1708
  defaultRef: { ref: defaultRefName, configured: defaultInfo.configured === true },
1297
1709
  providers: providerList(ctx),
1710
+ // 搜索通道:进会话时若搜索配置有问题,界面要能立刻提醒(而不是等到搜索失败)。
1711
+ search: searchState,
1712
+ }
1713
+ }
1714
+
1715
+ /** 进会话提醒用的搜索通道摘要(不含明文 key)。 */
1716
+ async function searchStateOf(ctx, state, { sessionId, cwd }) {
1717
+ const web = readWebSettings(ctx)
1718
+ const sync = workspaceSynced(state, cwd)
1719
+ if (!web.apiChannel) {
1720
+ const idle = web.apiLiteralKey !== undefined
1721
+ || (web.apiKeyEnv !== DEFAULT_API_KEY_REF && (await describeKey(ctx, web.apiKeyEnv)).configured === true)
1722
+ return { channel: 'messages', apiChannel: false, sync, leftoverKey: idle }
1723
+ }
1724
+ return {
1725
+ channel: 'api',
1726
+ apiChannel: true,
1727
+ sync,
1728
+ ref: searchRefFor(ctx, state, { sessionId, cwd, web }),
1729
+ sessionOwnKey: sessionOverrideRef(state, sessionId) !== undefined,
1730
+ keyConfigured: (await describeKey(ctx, searchRefFor(ctx, state, { sessionId, cwd, web }))).configured === true,
1298
1731
  }
1299
1732
  }
1300
1733
 
@@ -1382,27 +1815,39 @@ async function handle(ctx, state, req, res) {
1382
1815
 
1383
1816
  if (pathname === `${API_BASE}/test` && method === 'POST') {
1384
1817
  const body = await readJson(req)
1818
+ const web = readWebSettings(ctx)
1819
+ const channel = body.channel === 'search' ? 'search' : 'chat'
1385
1820
  let key = typeof body.key === 'string' && body.key.trim() !== '' ? body.key.trim() : undefined
1386
1821
  let sourceRef = typeof body.ref === 'string' && body.ref !== '' ? body.ref : undefined
1387
1822
  if (key === undefined) {
1388
1823
  if (sourceRef === undefined) {
1389
1824
  if (body.scope === 'session') {
1390
- sourceRef = typeof body.sessionId === 'string' && body.sessionId !== '' ? sessionRef(body.sessionId) : undefined
1825
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId !== '' ? body.sessionId : undefined
1826
+ const cwd = typeof body.path === 'string' && body.path !== '' ? body.path : activeCwd(ctx)
1827
+ sourceRef = channel === 'search'
1828
+ ? searchRefFor(ctx, state, { sessionId, cwd, web })
1829
+ : (sessionId === undefined ? undefined : sessionRef(sessionId))
1830
+ } else if (channel === 'search') {
1831
+ const dir = typeof body.path === 'string' && body.path !== '' ? body.path : undefined
1832
+ sourceRef = searchRefFor(ctx, state, { cwd: dir, web })
1391
1833
  } else {
1392
1834
  const dir = typeof body.path === 'string' ? body.path : undefined
1393
1835
  const overrideRef = dir !== undefined && dir !== '' ? derivedRef(dir) : undefined
1394
1836
  sourceRef = overrideRef !== undefined && state.overrides[overrideRef] !== undefined ? overrideRef : undefined
1395
1837
  }
1396
1838
  }
1397
- if (sourceRef === undefined) sourceRef = 'DEEPSEEK_API_KEY'
1839
+ if (sourceRef === undefined) sourceRef = DEFAULT_API_KEY_REF
1398
1840
  const hit = await resolveKey(ctx, sourceRef)
1399
1841
  key = hit?.value
1400
1842
  }
1401
1843
  if (typeof key !== 'string' || key === '') {
1402
- sendJson(res, 400, { ok: false, error: `没有可测试的 key(ref=${sourceRef ?? '未知'})` })
1844
+ const hint = channel === 'search'
1845
+ ? `(ref=${sourceRef ?? '未知'};新版 Web 搜索 API 已启用但该 ref 没有值,搜索会直接失败)`
1846
+ : `(ref=${sourceRef ?? '未知'})`
1847
+ sendJson(res, 400, { ok: false, error: `没有可测试的 key${hint}` })
1403
1848
  return
1404
1849
  }
1405
- const result = await probeKey(ctx, key)
1850
+ const result = channel === 'search' ? await probeSearch(ctx, state, key, web) : await probeKey(ctx, key)
1406
1851
  const isOverrideRef = sourceRef !== undefined && state.overrides[sourceRef] !== undefined
1407
1852
  if (result.verdict === 'invalid') {
1408
1853
  if (isOverrideRef) {
@@ -1427,6 +1872,27 @@ async function handle(ctx, state, req, res) {
1427
1872
  return
1428
1873
  }
1429
1874
 
1875
+ if (pathname === `${API_BASE}/search-sync` && method === 'POST') {
1876
+ const body = await readJson(req)
1877
+ const dir = typeof body.path === 'string' ? body.path : undefined
1878
+ if (dir === undefined || dir === '') {
1879
+ sendJson(res, 400, { ok: false, error: '缺少 path' })
1880
+ return
1881
+ }
1882
+ const norm = normPath(dir)
1883
+ if (norm === undefined) {
1884
+ sendJson(res, 400, { ok: false, error: '无法规范化该路径' })
1885
+ return
1886
+ }
1887
+ const enabled = body.enabled !== false
1888
+ if (enabled) state.searchSync[norm] = true
1889
+ else delete state.searchSync[norm]
1890
+ saveState(state)
1891
+ log(`search sync ${enabled ? 'on' : 'off'}: ${dir}`)
1892
+ sendJson(res, 200, { ok: true, enabled, ...(await snapshot(ctx, state)) })
1893
+ return
1894
+ }
1895
+
1430
1896
  if (pathname === `${API_BASE}/clear-flag` && method === 'POST') {
1431
1897
  const body = await readJson(req)
1432
1898
  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.8",
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",