dsh-workspace-api-key 0.2.6 → 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 +24 -7
- package/README.zh.md +24 -7
- package/lib/client.js +171 -12
- package/lib/index.js +503 -11
- package/package.json +1 -1
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,8 +34,10 @@ 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;
|
|
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;
|
|
36
41
|
- keeps its own bookkeeping (which level maps to which ref, invalid flags, the provider a key was recorded for) in `$DSH_HOME/storages/dsh-workspace-api-key.json`;
|
|
37
42
|
- observes failures through the `agent/request-error` waterfall without ever returning `{kind: 'retry'}` — it only records the verdict and calls `next()`.
|
|
38
43
|
|
|
@@ -48,20 +53,31 @@ The browser half (`lib/client.js`) registers into the `sidebar.panellist` slot (
|
|
|
48
53
|
| POST | `/wsk-api/set` | `{scope: 'workspace' \| 'session', path \| sessionId, title, key, provider?}` binds a key; `{useDefault: true}` or an empty key clears it |
|
|
49
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 |
|
|
50
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 |
|
|
51
57
|
|
|
52
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.
|
|
53
59
|
|
|
54
|
-
Workspace targets may be directories that are not registered workspaces; such entries appear as ungrouped directories
|
|
60
|
+
Workspace targets may be directories that are not registered workspaces; such entries appear as ungrouped directories, and only the ones the sidebar would actually show — a directory whose only sessions are brand-new and still empty (the sidebar shows just the currently open one) is not listed.
|
|
55
61
|
|
|
56
62
|
## Install
|
|
57
63
|
|
|
58
|
-
Install through the market (设置 → 插件市场) once the plugin is listed, or
|
|
64
|
+
Install through the market (设置 → 插件市场) once the plugin is listed, or from npm:
|
|
59
65
|
|
|
60
66
|
```sh
|
|
61
|
-
#
|
|
62
|
-
|
|
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
|
|
63
70
|
```
|
|
64
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
|
+
|
|
65
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:
|
|
66
82
|
|
|
67
83
|
```jsonc
|
|
@@ -71,11 +87,11 @@ Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `p
|
|
|
71
87
|
## Tests
|
|
72
88
|
|
|
73
89
|
```sh
|
|
74
|
-
node test/host.test.mjs #
|
|
75
|
-
node test/client.test.mjs #
|
|
90
|
+
node test/host.test.mjs # 157 assertions
|
|
91
|
+
node test/client.test.mjs # 162 assertions
|
|
76
92
|
```
|
|
77
93
|
|
|
78
|
-
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,
|
|
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.
|
|
79
95
|
|
|
80
96
|
## Requirements and limits
|
|
81
97
|
|
|
@@ -83,6 +99,7 @@ Neither suite needs a running DSH process. The host suite drives `apply()` again
|
|
|
83
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.
|
|
84
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.
|
|
85
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.
|
|
86
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.
|
|
87
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.
|
|
88
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,7 +34,9 @@ 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 个;
|
|
39
|
+
- 不是注册工作区的目录,只有侧栏真的会显示它才会被列出来。这件事宿主判不了:「空会话里只有当前开着的那个算数」是浏览器端才知道的事(`retainedBy.mainView`),所以宿主把这种目录连同每个会话的 `blank` 标记一起发出去,并用 `hiddenBlankDirs` 点名「里面全是空会话」的目录,由浏览器半边套用与侧栏一致的判据;
|
|
35
40
|
- `/wsk-api/state` 会带上插件自身版本与会话探针(`available`、`shape`、`source`、`count`、`withoutCwd`、`subagents`、`samples`、`error`,以及工作区路径样本),面板据此说明**为什么**没有列出会话:宿主半边是旧版本(只刷新了页面而没重启应用)、没有 `sessionController`、`list()` 抛错、信封形状不认识,还是会话的 `cwd` 与任何工作区路径都对不上;
|
|
36
41
|
- 自己的账本(哪一层对应哪个 ref、失效标记、key 是给哪个服务商记的)落在 `$DSH_HOME/storages/dsh-workspace-api-key.json`;
|
|
37
42
|
- 通过 `agent/request-error` waterfall 观察失败,但**从不**返回 `{kind: 'retry'}`——只记录判定结果并 `next()` 放行。
|
|
@@ -48,20 +53,31 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
|
|
|
48
53
|
| POST | `/wsk-api/set` | `{scope: 'workspace' \| 'session', path \| sessionId, title, key, provider?}` 绑定 key;`{useDefault: true}` 或空 key 为清除 |
|
|
49
54
|
| POST | `/wsk-api/test` | `{scope, path \| sessionId \| ref}`(或 `{key}`)真发一次请求并返回判定;`invalid` 打标记,`ok` 清标记 |
|
|
50
55
|
| POST | `/wsk-api/clear-flag` | `{ref}` 或一个目标,清掉失效/配额标记 |
|
|
56
|
+
| POST | `/wsk-api/search-sync` | `{path, enabled}` —— 打开或关闭某个工作区的「同步搜索 API」 |
|
|
51
57
|
|
|
52
58
|
只接受本机回环调用(`127.0.0.1`、`::1`、`::ffff:127.0.0.1`),且 `Origin` 必须同源或缺失。任何响应都不会包含明文 key,只有掩码。
|
|
53
59
|
|
|
54
|
-
|
|
60
|
+
工作区目标也接受不是注册工作区的目录;这类条目会作为「未分组目录」列在面板里,且只列侧栏真的会显示的目录——一个目录里若只有「刚建好、一句话都没发过」的空会话,侧栏不会显示它(空会话里只有当前打开的那个算数),面板也不会列。
|
|
55
61
|
|
|
56
62
|
## 安装
|
|
57
63
|
|
|
58
|
-
插件被市场收录后,可以直接在「设置 →
|
|
64
|
+
插件被市场收录后,可以直接在「设置 → 插件市场」里一键安装;也可以从 npm 装:
|
|
59
65
|
|
|
60
66
|
```sh
|
|
61
|
-
#
|
|
62
|
-
|
|
67
|
+
# 用 DSH 自带的 CLI 装进某个 profile(web、tui……)。Desktop 自己的 profile
|
|
68
|
+
# 由 Electron 应用独占管理,请在它的界面里安装。
|
|
69
|
+
dsh plugin --profile web add dsh-workspace-api-key
|
|
63
70
|
```
|
|
64
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
|
+
|
|
65
81
|
然后把 `"dsh-workspace-api-key"` 加进该 profile `package.json` 的 `dsh.profile.bundles`,重启 DSH。本地开发用 link 代替:
|
|
66
82
|
|
|
67
83
|
```jsonc
|
|
@@ -71,15 +87,16 @@ pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
|
|
|
71
87
|
## 测试
|
|
72
88
|
|
|
73
89
|
```sh
|
|
74
|
-
node test/host.test.mjs #
|
|
75
|
-
node test/client.test.mjs #
|
|
90
|
+
node test/host.test.mjs # 157 条断言
|
|
91
|
+
node test/client.test.mjs # 162 条断言
|
|
76
92
|
```
|
|
77
93
|
|
|
78
|
-
两个套件都不需要启动 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`、提醒条与「使用说明」弹窗。
|
|
79
95
|
|
|
80
96
|
## 要求与限制
|
|
81
97
|
|
|
82
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 通道,而不是瞎猜。
|
|
83
100
|
- 每一层只有一把 key:一个会话无论被哪个 provider 路由读取,用的都是同一把 key;key 记下的服务商与会话当前服务商不一致时会被报为「不匹配」,但同一服务商内换模型不算不匹配,因为 DeepSeek key 是账号级的。
|
|
84
101
|
- 会话按目录(`cwd`)归入工作区。宿主自己的 `list()` 会跳过从未存过 `cwd` 的历史会话,而 live 兜底只认识当前装载中的会话,所以面板列出的会话可能比侧栏少;只在浏览器端存储里可见的会话,除了侧栏知道的那个 `cwd` 之外没有别的路径信息。
|
|
85
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))
|
|
@@ -279,12 +309,12 @@ window.__ModuleLoader__.load({
|
|
|
279
309
|
if (entries.length === 0) {
|
|
280
310
|
// 没有浏览器端列表时也把子代理/空会话滤掉(宿主新版本已经过滤,这里兜旧宿主)。
|
|
281
311
|
const clean = (rows) => (Array.isArray(rows) ? rows : []).filter(isVisible)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
312
|
+
const workspaces = (state.workspaces ?? []).map((ws) => ({ ...ws, sessions: clean(ws.sessions) }))
|
|
313
|
+
// 未分组目录:过滤后一个会话都不剩的就不列(侧栏对空目录同样什么都不显示)。
|
|
314
|
+
const ungrouped = (state.ungrouped ?? [])
|
|
315
|
+
.map((group) => ({ ...group, sessions: clean(group.sessions) }))
|
|
316
|
+
.filter((group) => (group.sessions?.length ?? 0) > 0)
|
|
317
|
+
return { ...state, workspaces, ungrouped, sessionBlanks: blankSkipped }
|
|
288
318
|
}
|
|
289
319
|
|
|
290
320
|
const hostRows = new Map()
|
|
@@ -381,6 +411,8 @@ window.__ModuleLoader__.load({
|
|
|
381
411
|
const rows = fill(group.sessions)
|
|
382
412
|
for (const row of (extras.get(key) ?? [])) rows.push(row)
|
|
383
413
|
extras.delete(key)
|
|
414
|
+
// 过滤后没有会话的目录不再列(宿主新版本已经不产生这种组,这里兜旧宿主)。
|
|
415
|
+
if (rows.length === 0) continue
|
|
384
416
|
ungrouped.push({ ...group, sessions: sortByUpdated(rows) })
|
|
385
417
|
}
|
|
386
418
|
for (const [key, rows] of extras) {
|
|
@@ -482,6 +514,7 @@ window.__ModuleLoader__.load({
|
|
|
482
514
|
const [tests, setTests] = useState({})
|
|
483
515
|
const [notice, setNotice] = useState(null)
|
|
484
516
|
const [prompt, setPrompt] = useState(null)
|
|
517
|
+
const [help, setHelp] = useState(false)
|
|
485
518
|
const [expanded, setExpanded] = useState(() => new Set())
|
|
486
519
|
|
|
487
520
|
const alive = useRef(true)
|
|
@@ -651,6 +684,27 @@ window.__ModuleLoader__.load({
|
|
|
651
684
|
}
|
|
652
685
|
}
|
|
653
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
|
+
|
|
654
708
|
const clearFlag = async (id, body, title) => {
|
|
655
709
|
setBusy(true)
|
|
656
710
|
try {
|
|
@@ -675,6 +729,8 @@ window.__ModuleLoader__.load({
|
|
|
675
729
|
const ungrouped = view?.ungrouped ?? []
|
|
676
730
|
const defaultRef = view?.defaultRef
|
|
677
731
|
const providers = view?.providers ?? []
|
|
732
|
+
const searchInfo = view?.search
|
|
733
|
+
const searchWarnings = Array.isArray(searchInfo?.warnings) ? searchInfo.warnings : []
|
|
678
734
|
|
|
679
735
|
// ── 诊断 ──────────────────────────────────────────────────────────────
|
|
680
736
|
// 面板里「看不到可展开的会话」有两个完全不同的原因,修法也不同:
|
|
@@ -703,10 +759,21 @@ window.__ModuleLoader__.load({
|
|
|
703
759
|
const noCwd = probe?.withoutCwd > 0 ? ` · ${probe.withoutCwd} 个没有 cwd` : ''
|
|
704
760
|
const subs = probe?.subagents > 0 ? ` · 已跳过 ${probe.subagents} 个子代理会话` : ''
|
|
705
761
|
const blanks = (view?.sessionBlanks ?? 0) > 0 ? ` · 已跳过 ${view.sessionBlanks} 个空会话` : ''
|
|
762
|
+
// 宿主只标注「这个目录里只有还没说过话的空会话」,列不列由本文件按侧栏语义决定
|
|
763
|
+
// (空会话里只有当前打开的那一个算数)——所以这里说的是「宿主标了几个」。
|
|
764
|
+
const blankDirs = Array.isArray(state.hiddenBlankDirs) && state.hiddenBlankDirs.length > 0
|
|
765
|
+
? ` · ${state.hiddenBlankDirs.length} 个目录只有空会话(未分组里不列)`
|
|
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} 个目录` : '')
|
|
706
772
|
return `插件 v${hostVersion} · 状态存储 v${state.storeVersion ?? '—'}`
|
|
707
773
|
+ ` · 宿主会话列表:${sessions}${listError}${noCwd}${subs}`
|
|
708
|
-
+ ` · 浏览器会话列表:${clientCount} 个${blanks}`
|
|
774
|
+
+ ` · 浏览器会话列表:${clientCount} 个${blanks}${blankDirs}`
|
|
709
775
|
+ ` · 工作区 ${grouped.length} + 未分组 ${ungrouped.length} · 挂在目录下的会话 ${sessionTotal}`
|
|
776
|
+
+ searchDiag
|
|
710
777
|
}
|
|
711
778
|
|
|
712
779
|
const diagSamples = () => {
|
|
@@ -889,6 +956,30 @@ window.__ModuleLoader__.load({
|
|
|
889
956
|
}, verdictText(lastTest.verdict))
|
|
890
957
|
: null),
|
|
891
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,
|
|
892
983
|
reason !== undefined
|
|
893
984
|
? h('div', { className: 'wsk-hint', style: { color: 'var(--wsk-bad)', marginTop: 4 } }, reason)
|
|
894
985
|
: null),
|
|
@@ -925,6 +1016,41 @@ window.__ModuleLoader__.load({
|
|
|
925
1016
|
: null)
|
|
926
1017
|
}
|
|
927
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
|
+
|
|
928
1054
|
const renderPrompt = () => {
|
|
929
1055
|
if (prompt === null) return null
|
|
930
1056
|
if (prompt.kind === 'no-default') {
|
|
@@ -988,6 +1114,9 @@ window.__ModuleLoader__.load({
|
|
|
988
1114
|
h('span', { className: 'wsk-title' }, 'API Key 分配'),
|
|
989
1115
|
h('span', { className: 'wsk-sub' }, '按工作区 / 会话隔离计费'),
|
|
990
1116
|
h('span', { className: 'wsk-spacer' }),
|
|
1117
|
+
h('button', {
|
|
1118
|
+
type: 'button', className: 'wsk-btn', onClick: () => setHelp(true),
|
|
1119
|
+
}, '使用说明'),
|
|
991
1120
|
h('button', {
|
|
992
1121
|
type: 'button', className: 'wsk-btn', disabled: busy, onClick: refresh,
|
|
993
1122
|
}, refreshSvg(14), h('span', null, busy ? '刷新中…' : '刷新'))),
|
|
@@ -1008,6 +1137,17 @@ window.__ModuleLoader__.load({
|
|
|
1008
1137
|
'请「完全退出」DSH Desktop 再重新打开(关闭窗口 / 刷新页面都不够:宿主代码只在启动时加载)。'),
|
|
1009
1138
|
h('div', { className: 'wsk-hint' }, diagText()))
|
|
1010
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)),
|
|
1011
1151
|
state !== null && state.sessionListAvailable === false && clientCount === 0
|
|
1012
1152
|
? h('div', { className: 'wsk-msg info', style: { marginBottom: 12 } },
|
|
1013
1153
|
'当前 DSH 版本没有提供会话列表服务,只能按工作区配置 key。',
|
|
@@ -1063,7 +1203,8 @@ window.__ModuleLoader__.load({
|
|
|
1063
1203
|
? h('div', { className: 'wsk-card' },
|
|
1064
1204
|
h('h4', null, `未分组目录(${ungrouped.length})`),
|
|
1065
1205
|
h('div', { className: 'wsk-hint', style: { marginBottom: 6 } },
|
|
1066
|
-
'这些目录里的会话没有挂在工作区下(侧栏的「未分组」),同样可以单独配置。'
|
|
1206
|
+
'这些目录里的会话没有挂在工作区下(侧栏的「未分组」),同样可以单独配置。'
|
|
1207
|
+
+ '只有侧栏真的会显示的目录才列在这里(刚建好、还没发过消息的空会话不算)。'),
|
|
1067
1208
|
h('div', { className: 'wsk-list' }, ungrouped.map((ws) => renderWorkspace(ws))))
|
|
1068
1209
|
: null,
|
|
1069
1210
|
|
|
@@ -1080,7 +1221,8 @@ window.__ModuleLoader__.load({
|
|
|
1080
1221
|
state?.statePath ? h('div', null, `状态文件:${state.statePath}`) : null,
|
|
1081
1222
|
diagText() !== undefined ? h('div', { className: 'wsk-diag' }, diagText()) : null)),
|
|
1082
1223
|
|
|
1083
|
-
renderPrompt()
|
|
1224
|
+
renderPrompt(),
|
|
1225
|
+
help ? renderHelp() : null)
|
|
1084
1226
|
}
|
|
1085
1227
|
|
|
1086
1228
|
// ─────────────────────── 进入会话时的提醒(覆盖层) ───────────────────────
|
|
@@ -1124,7 +1266,11 @@ window.__ModuleLoader__.load({
|
|
|
1124
1266
|
+ (cwd === undefined ? '' : `&path=${encodeURIComponent(cwd)}`)
|
|
1125
1267
|
const res = await api(query)
|
|
1126
1268
|
if (disposed) return
|
|
1127
|
-
|
|
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)
|
|
1128
1274
|
} catch {
|
|
1129
1275
|
/* 宿主接口不可用时不打扰用户 */
|
|
1130
1276
|
}
|
|
@@ -1195,8 +1341,13 @@ window.__ModuleLoader__.load({
|
|
|
1195
1341
|
if (prompt === null) return null
|
|
1196
1342
|
const invalid = invalidReason(prompt.invalid)
|
|
1197
1343
|
const mismatch = mismatchText(prompt.mismatch)
|
|
1344
|
+
const searchIssue = searchIssueText(prompt.search)
|
|
1198
1345
|
return h(Dialog, {
|
|
1199
|
-
title: invalid !== undefined
|
|
1346
|
+
title: invalid !== undefined
|
|
1347
|
+
? '这个会话用的 API Key 已失效'
|
|
1348
|
+
: mismatch !== undefined
|
|
1349
|
+
? '这个会话的 API Key 可能不匹配'
|
|
1350
|
+
: '网页搜索的配置需要确认',
|
|
1200
1351
|
body: h('div', null,
|
|
1201
1352
|
kv('会话', prompt.title ?? String(prompt.sessionId ?? '').slice(0, 12)),
|
|
1202
1353
|
kv('模型', `${prompt.provider ?? '未知'} / ${prompt.model ?? '默认模型'}`),
|
|
@@ -1207,6 +1358,9 @@ window.__ModuleLoader__.load({
|
|
|
1207
1358
|
mismatch !== undefined
|
|
1208
1359
|
? h('div', { className: 'wsk-hint', style: { marginTop: 8, color: 'var(--wsk-warn)' } }, mismatch)
|
|
1209
1360
|
: null,
|
|
1361
|
+
searchIssue !== undefined
|
|
1362
|
+
? h('div', { className: 'wsk-hint', style: { marginTop: 8, color: 'var(--wsk-warn)' } }, searchIssue)
|
|
1363
|
+
: null,
|
|
1210
1364
|
h('div', { className: 'wsk-hint', style: { marginTop: 8 } },
|
|
1211
1365
|
'插件不会自动更换 key —— 用哪把 key 由你决定,避免计费被悄悄改到别的账号上。')),
|
|
1212
1366
|
onDismiss: () => setPrompt(null),
|
|
@@ -1215,7 +1369,12 @@ window.__ModuleLoader__.load({
|
|
|
1215
1369
|
...(invalid !== undefined
|
|
1216
1370
|
? [{ label: res2Upper(prompt), onClick: useUpperLevel }]
|
|
1217
1371
|
: []),
|
|
1218
|
-
{
|
|
1372
|
+
{
|
|
1373
|
+
label: invalid !== undefined
|
|
1374
|
+
? '稍后'
|
|
1375
|
+
: mismatch !== undefined ? '继续用当前 key' : '知道了',
|
|
1376
|
+
onClick: () => setPrompt(null),
|
|
1377
|
+
},
|
|
1219
1378
|
],
|
|
1220
1379
|
})
|
|
1221
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
|
790
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
|
+
}
|
|
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
|
|
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,10 +1425,28 @@ 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
|
}
|
|
1050
1434
|
|
|
1435
|
+
/**
|
|
1436
|
+
* 侧栏会不会真的显示这一行。
|
|
1437
|
+
* 侧栏对「还没发过消息的空会话」(sessionController 的 blank 标记)只显示当前打开的那一个,
|
|
1438
|
+
* 其余全部藏起来(另见 lib/client.js 同名判据与 @deepseek-ai/dsh-client-ui-workspace 的
|
|
1439
|
+
* sessionVisible)。空目录之所以要按这个判据过滤:见 snapshot() 里 ungrouped 的注释。
|
|
1440
|
+
*/
|
|
1441
|
+
function sidebarVisibleRow(row) {
|
|
1442
|
+
return row?.blank !== true
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/** 目录里至少有一个「侧栏会显示」的会话(没有会话的目录不算)。 */
|
|
1446
|
+
function hasVisibleSession(rows) {
|
|
1447
|
+
return Array.isArray(rows) && rows.some((row) => sidebarVisibleRow(row))
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1051
1450
|
async function snapshot(ctx, state) {
|
|
1052
1451
|
const credentials = ctx.get('credentials')
|
|
1053
1452
|
const defaultRefName = 'DEEPSEEK_API_KEY'
|
|
@@ -1105,10 +1504,18 @@ async function snapshot(ctx, state) {
|
|
|
1105
1504
|
}
|
|
1106
1505
|
|
|
1107
1506
|
// 未注册成工作区、但确实有会话的目录(侧栏的「未分组」):也能单独给会话配 key。
|
|
1507
|
+
// 注意:**不在这里过滤**。侧栏对一个目录显不显示,取决于「目录里有没有它真的显示出来的
|
|
1508
|
+
// 会话」,而「空会话只显示当前打开的那一个」这条规则只有浏览器端知道(会话 store 的
|
|
1509
|
+
// retainedBy.mainView)。宿主这边只把目录和 blank 标记原样发出去,由 lib/client.js 按
|
|
1510
|
+
// 同一个判据决定列不列 —— 以前面板会冒出侧栏上并不存在的「未分组目录」(用户实测:
|
|
1511
|
+
// E:\web 与插件自己的目录,各自只有一个还没说过话的空会话)。
|
|
1108
1512
|
const ungrouped = []
|
|
1513
|
+
const hiddenBlankDirs = []
|
|
1109
1514
|
for (const [norm, list] of byCwd) {
|
|
1110
1515
|
if (claimed.has(norm)) continue
|
|
1111
1516
|
const dir = typeof list[0]?.cwd === 'string' ? list[0].cwd : norm
|
|
1517
|
+
// 诊断用:这个目录里一个「真实会话」都没有(界面把它藏起来,但用户能看到原因)。
|
|
1518
|
+
if (!hasVisibleSession(list)) hiddenBlankDirs.push(dir)
|
|
1112
1519
|
ungrouped.push(await workspaceRow(ctx, state, { id: undefined, path: dir, title: basename(dir) || dir, kind: 'directory' }, list))
|
|
1113
1520
|
}
|
|
1114
1521
|
|
|
@@ -1134,6 +1541,14 @@ async function snapshot(ctx, state) {
|
|
|
1134
1541
|
})
|
|
1135
1542
|
}
|
|
1136
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
|
+
|
|
1137
1552
|
return {
|
|
1138
1553
|
ok: true,
|
|
1139
1554
|
pluginVersion: PLUGIN_VERSION,
|
|
@@ -1166,7 +1581,29 @@ async function snapshot(ctx, state) {
|
|
|
1166
1581
|
statePath: stateFilePath(),
|
|
1167
1582
|
workspaces,
|
|
1168
1583
|
ungrouped,
|
|
1584
|
+
// 诊断:这些未分组目录里只有空会话,界面按侧栏语义会把它藏起来(不直接从这里删,
|
|
1585
|
+
// 因为「当前打开的空会话」要显示,而这件事只有浏览器端知道)。
|
|
1586
|
+
hiddenBlankDirs,
|
|
1169
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
|
+
},
|
|
1170
1607
|
}
|
|
1171
1608
|
}
|
|
1172
1609
|
|
|
@@ -1240,6 +1677,7 @@ async function checkSession(ctx, state, params) {
|
|
|
1240
1677
|
const sessionEntry = sid !== undefined ? state.overrides[sessionRef(sid)] : undefined
|
|
1241
1678
|
const ws = locateWorkspace(ctx, cwd)
|
|
1242
1679
|
const wsRef = ws !== undefined ? derivedRef(ws.path) : undefined
|
|
1680
|
+
const searchState = await searchStateOf(ctx, state, { sessionId: sid, cwd })
|
|
1243
1681
|
return {
|
|
1244
1682
|
ok: true,
|
|
1245
1683
|
sessionId: sid ?? null,
|
|
@@ -1269,6 +1707,27 @@ async function checkSession(ctx, state, params) {
|
|
|
1269
1707
|
},
|
|
1270
1708
|
defaultRef: { ref: defaultRefName, configured: defaultInfo.configured === true },
|
|
1271
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,
|
|
1272
1731
|
}
|
|
1273
1732
|
}
|
|
1274
1733
|
|
|
@@ -1356,27 +1815,39 @@ async function handle(ctx, state, req, res) {
|
|
|
1356
1815
|
|
|
1357
1816
|
if (pathname === `${API_BASE}/test` && method === 'POST') {
|
|
1358
1817
|
const body = await readJson(req)
|
|
1818
|
+
const web = readWebSettings(ctx)
|
|
1819
|
+
const channel = body.channel === 'search' ? 'search' : 'chat'
|
|
1359
1820
|
let key = typeof body.key === 'string' && body.key.trim() !== '' ? body.key.trim() : undefined
|
|
1360
1821
|
let sourceRef = typeof body.ref === 'string' && body.ref !== '' ? body.ref : undefined
|
|
1361
1822
|
if (key === undefined) {
|
|
1362
1823
|
if (sourceRef === undefined) {
|
|
1363
1824
|
if (body.scope === 'session') {
|
|
1364
|
-
|
|
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 })
|
|
1365
1833
|
} else {
|
|
1366
1834
|
const dir = typeof body.path === 'string' ? body.path : undefined
|
|
1367
1835
|
const overrideRef = dir !== undefined && dir !== '' ? derivedRef(dir) : undefined
|
|
1368
1836
|
sourceRef = overrideRef !== undefined && state.overrides[overrideRef] !== undefined ? overrideRef : undefined
|
|
1369
1837
|
}
|
|
1370
1838
|
}
|
|
1371
|
-
if (sourceRef === undefined) sourceRef =
|
|
1839
|
+
if (sourceRef === undefined) sourceRef = DEFAULT_API_KEY_REF
|
|
1372
1840
|
const hit = await resolveKey(ctx, sourceRef)
|
|
1373
1841
|
key = hit?.value
|
|
1374
1842
|
}
|
|
1375
1843
|
if (typeof key !== 'string' || key === '') {
|
|
1376
|
-
|
|
1844
|
+
const hint = channel === 'search'
|
|
1845
|
+
? `(ref=${sourceRef ?? '未知'};新版 Web 搜索 API 已启用但该 ref 没有值,搜索会直接失败)`
|
|
1846
|
+
: `(ref=${sourceRef ?? '未知'})`
|
|
1847
|
+
sendJson(res, 400, { ok: false, error: `没有可测试的 key${hint}` })
|
|
1377
1848
|
return
|
|
1378
1849
|
}
|
|
1379
|
-
const result = await probeKey(ctx, key)
|
|
1850
|
+
const result = channel === 'search' ? await probeSearch(ctx, state, key, web) : await probeKey(ctx, key)
|
|
1380
1851
|
const isOverrideRef = sourceRef !== undefined && state.overrides[sourceRef] !== undefined
|
|
1381
1852
|
if (result.verdict === 'invalid') {
|
|
1382
1853
|
if (isOverrideRef) {
|
|
@@ -1401,6 +1872,27 @@ async function handle(ctx, state, req, res) {
|
|
|
1401
1872
|
return
|
|
1402
1873
|
}
|
|
1403
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
|
+
|
|
1404
1896
|
if (pathname === `${API_BASE}/clear-flag` && method === 'POST') {
|
|
1405
1897
|
const body = await readJson(req)
|
|
1406
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.
|
|
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",
|