dsh-workspace-api-key 0.2.16 → 0.2.17

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
@@ -26,6 +26,8 @@ session key → workspace key → system default
26
26
  - **A search switch per workspace** — every workspace row carries a `同步搜索 API` checkbox. Ticked, the `web_search` / `web_fetch` calls made by sessions in that directory use the key they would use for chat (`session → workspace → system default`); unticked, they keep the key the official 设置 → 插件 → 网页搜索 page manages. A session with its own key always wins, so "sync on, session configured separately" spends the session key, not the search key.
27
27
  - **Search warnings that arrive before the failure** — when a search key is configured but `启用新版 Web 搜索 API` is off, or when the new API is on but its key ref is empty, the panel shows a warning banner and the entering-session overlay says it too, in one line you can act on. The overlay stays quiet about search problems when the search provider is not the one in use.
28
28
  - **A usage guide** — `使用说明` at the top of the panel recommends two keys: a chat default (`default`) and a search default (`search-default`), so the two budgets stay separate; it also draws the cascade and lists the situations that trigger a warning.
29
+ - **A way back from Settings** — Settings → API Key 分配 keeps an "open the panel" button and explains the overflow-menu and reload routes, so an unpinned sidebar icon is never a dead end.
30
+ - **Language-aware UI** — the panel follows the web GUI language from `@deepseek-ai/dsh-client-locale`: English when the GUI is English, Chinese when it is Chinese. Answers produced by the host half (connection-test details, search warnings) are translated in the browser half, so the panel does not mix languages.
29
31
 
30
32
  ## How it works
31
33
 
@@ -79,7 +81,7 @@ The command forwards its arguments to pnpm inside the profile directory, so the
79
81
  pnpm add dsh-workspace-api-key # or: npm install dsh-workspace-api-key
80
82
  ```
81
83
 
82
- Pin a version with `dsh-workspace-api-key@0.2.16`, 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).
84
+ Pin a version with `dsh-workspace-api-key@0.2.17`, or install straight from the repository with `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key` (a git-hosted plugin is built from source on install).
83
85
 
84
86
  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:
85
87
 
@@ -91,7 +93,7 @@ Then add `"dsh-workspace-api-key"` to `dsh.profile.bundles` in that profile's `p
91
93
 
92
94
  ```sh
93
95
  node test/host.test.mjs # 177 assertions
94
- node test/client.test.mjs # 173 assertions
96
+ node test/client.test.mjs # 180 assertions
95
97
  ```
96
98
 
97
99
  Neither suite needs a running DSH process. The host suite drives `apply()` against a mock context (fake `credentials`, `workspaceRegistry`, `agents`, `sessionController`, `sessions`, `webServer`, stubbed `fetch`) and covers ref derivation, the session → workspace → default cascade, workspace matching, redirecting a second adapter's `apiKeyEnv`, clearing back to the default, 401/402/429/5xx classification, the loopback and origin checks, state persistence, the session probe including the `{items}` envelope, the live-session fallback, and subagent and blank sessions being hidden, and the search scope with a stand-in `web` service and a stand-in settings service: the legacy route keeping the chat cascade, the new provider keeping its own key, the workspace switch taking over, a session key outranking both, the panel's own search default key being used and cleared, the three search warnings, and the search connection test. The client suite loads `lib/client.js` through a minimal `window.__ModuleLoader__` plus a small React runtime, renders the panel and the overlay, and asserts slot registration, the first render, session expand/collapse, per-session saving and testing, sessions recovered from the browser-side store when the host has none (with `displayTitle` winning and subagent sessions hidden), the ungrouped-directory card, the stale and mismatch prompts, the "go configure" hand-off, graceful degradation without a session list, back-navigation when `ctx.get('layout')` only becomes available after the plugin was applied, and the search UI: the switch on every workspace row, the `/wsk-api/search-sync` call it makes, the warning banner, and the usage dialog.
package/README.zh.md CHANGED
@@ -26,6 +26,8 @@ DSH 只从一个全局凭据 ref(默认 `DEEPSEEK_API_KEY`)解析 DeepSeek k
26
26
  - **每个工作区一个搜索开关** —— 每个工作区行上都有「同步搜索 API」复选框。勾选后,该目录下会话发出的 `web_search` / `web_fetch` 请求改用它们聊天时用的那把 key(会话 → 工作区 → 系统默认);不勾选则继续用官方「设置 → 插件 → 网页搜索」里配置的那把 key。会话自己配了 key 时永远优先,所以「工作区同步、会话单独设置」花的是会话那把 key,而不是搜索默认 key。
27
27
  - **在失败之前就提醒的搜索告警** —— 配了搜索 key 但没打开「启用新版 Web 搜索 API」,或者新版 API 打开了但它的 key ref 是空的,面板会显示提醒条,点进会话时的覆盖层也会用一句可直接照做的话说明;当生效的搜索通道不是新版 API 时,覆盖层不会拿搜索问题打扰你。
28
28
  - **使用说明** —— 面板顶部有「使用说明」,推荐两把 key 分工:聊天默认用 `default`,搜索默认用 `search-default`,两份预算互不干扰;里面还画了层级,并列出了会触发提醒的几种情况。
29
+ - **面板入口也能从设置里找回来** —— 「设置 → API Key 分配」里还有一个「打开 API Key 分配面板」按钮:侧栏图标被取消固定时,点它就能回到这里;那里也写明了「更多」菜单和刷新页面两条找回途径。
30
+ - **界面跟随 DSH 语言** —— 面板跟随 `@deepseek-ai/dsh-client-locale`:Web 界面是英文时显示英文,是中文时保持中文;宿主半边返回的提示(连接测试详情、搜索提醒)也在浏览器半边翻译,界面不会中英混排。
29
31
 
30
32
  ## 实现方式
31
33
 
@@ -79,7 +81,7 @@ dsh plugin --profile web add dsh-workspace-api-key
79
81
  pnpm add dsh-workspace-api-key # 或者:npm install dsh-workspace-api-key
80
82
  ```
81
83
 
82
- 锁版本用 `dsh-workspace-api-key@0.2.16`;直接从仓库装则用 `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key`(git 来源的插件会在安装时现场构建)。
84
+ 锁版本用 `dsh-workspace-api-key@0.2.17`;直接从仓库装则用 `dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key`(git 来源的插件会在安装时现场构建)。
83
85
 
84
86
  然后把 `"dsh-workspace-api-key"` 加进该 profile `package.json` 的 `dsh.profile.bundles`,重启 DSH。本地开发用 link 代替:
85
87
 
@@ -91,7 +93,7 @@ pnpm add dsh-workspace-api-key # 或者:npm install dsh-workspace-api
91
93
 
92
94
  ```sh
93
95
  node test/host.test.mjs # 177 条断言
94
- node test/client.test.mjs # 173 条断言
96
+ node test/client.test.mjs # 180 条断言
95
97
  ```
96
98
 
97
99
  两个套件都不需要启动 DSH。宿主套件用一个假 ctx(假的 `credentials`、`workspaceRegistry`、`agents`、`sessionController`、`sessions`、`webServer`,以及打桩的 `fetch`)驱动 `apply()`,覆盖 ref 派生、会话→工作区→默认的级联、工作区匹配、把第二个适配器的 `apiKeyEnv` 一起重定向、清回默认、401/402/429/5xx 的判定、回环与来源校验、状态落盘,以及会话探针(含 `{items}` 信封与 live 兜底)。浏览器套件通过一个极小的 `window.__ModuleLoader__` 与自制 React 运行时加载 `lib/client.js`,渲染出面板与覆盖层,断言槽注册、首屏渲染、会话折叠/展开、会话级保存与测试、宿主列不出会话时从浏览器端存储恢复会话(标题取 `displayTitle`、隐藏子代理会话与空会话、浏览器端只认识一部分会话时不挤掉宿主行)、会话列表服务晚于插件提供时的重试、未分组目录卡片、失效与不匹配提醒、「去配置」的交接、没有会话列表时的降级,以及 `ctx.get('layout')` 在插件 apply 之后才可用时返回键仍然有效。宿主套件还带一个假的 `web` 服务与假的设置服务,验证搜索作用域:旧通道沿用聊天层级、新版通道用它自己的 key、工作区开关接管、会话 key 压过两者、面板自己的搜索默认 key 生效与清除、三类搜索提醒,以及搜索连接测试。浏览器套件覆盖搜索相关界面:每个工作区行的「同步搜索 API」复选框、它发出的 `/wsk-api/search-sync`、提醒条与「使用说明」弹窗。
package/lib/client.js CHANGED
@@ -5,9 +5,276 @@ window.__ModuleLoader__.load({
5
5
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
6
6
 
7
7
  const React = require('react')
8
- const h = React.createElement
8
+ const rawCreateElement = React.createElement
9
9
  const { useState, useEffect, useCallback, useRef } = React
10
10
 
11
+ // ─────────────────────────── localization ───────────────────────────
12
+ // The web GUI's language is owned by @deepseek-ai/dsh-client-locale. This
13
+ // plugin keeps one English catalog keyed by the Chinese source copy: when
14
+ // the active language is Chinese the catalog is bypassed and every string
15
+ // is used exactly as written, otherwise rendered text is translated.
16
+ // Messages produced by the host half (which has no locale of its own) are
17
+ // covered by EN_HOST and the pattern rules below.
18
+ const EN = {
19
+ "勾选后:该目录下会话的搜索/抓取请求改用聊天用的那把 key(会话单独配置仍然优先)。不勾则用「设置 → 插件 → 网页搜索」里配置的 key。": "When checked: search/fetch calls from sessions in this directory use the chat key (session-level keys still win). Unchecked, they use the key from Settings → Plugins → Web Search.",
20
+ "连接正常": "Connected",
21
+ "key 无效": "Invalid key",
22
+ "余额不足": "Out of balance",
23
+ "被限流": "Rate limited",
24
+ "无法连接": "Unreachable",
25
+ "测试失败": "Test failed",
26
+ "鉴权失败": "Authentication failed",
27
+ "本会话专属": "Session key",
28
+ "工作区专属": "Workspace key",
29
+ "系统默认": "System default",
30
+ "未记录服务商": "Provider not recorded",
31
+ "未知": "Unknown",
32
+ "你打开了「启用新版 Web 搜索 API」,但搜索 key 还没有配置:这个会话里的搜索会直接失败。": "\"New Web Search API\" is enabled, but no search key is configured: searches in this session will fail.",
33
+ "你配置了搜索专用的 API Key,但「启用新版 Web 搜索 API」没有打开,所以那把 key 现在不会被使用。": "A search-only API key is configured, but \"New Web Search API\" is off, so that key is not being used.",
34
+ "(空会话)": "(empty session)",
35
+ "(没有目录)": "(no directory)",
36
+ "模型未知": "Model unknown",
37
+ "搜索默认 key 已清除:搜索改用官方搜索页里配的那把 key。": "Search default key cleared: searches use the key configured on the official search page.",
38
+ "官方「网页搜索」页仍指向原来的 ref,两处暂时不是同一把。": "The official \"Web Search\" page still points at the previous ref, so the two are temporarily different keys.",
39
+ "官方「网页搜索」页的 API Key 输入框也指向这个 ref:以后两边改的是同一把 key。": "The API Key field on the official \"Web Search\" page now points at this ref too: both places edit the same key.",
40
+ "已同步:该目录下会话的搜索请求改用聊天用的那把 key(会话单独配置仍然优先)。": "Synced: searches from sessions in this directory use the chat key (a session-level key still wins).",
41
+ "已取消同步:该目录的搜索请求改用「设置 → 插件 → 网页搜索」里配置的 key。": "Sync off: searches in this directory use the key from Settings → Plugins → Web Search.",
42
+ "旧版(未重启应用)": "older version (app not restarted)",
43
+ "不可用": "Unavailable",
44
+ "(live 兜底)": "(live fallback)",
45
+ "(不指定)": "(not specified)",
46
+ "改用系统默认": "Use system default",
47
+ "为该工作区指定一把专属 key;留空并保存即继续使用系统默认。": "Set a dedicated key for this workspace; leave empty and save to keep using the system default.",
48
+ "sk-…(留空 = 使用上层设置)": "sk-… (empty = use the level above)",
49
+ "这把 key 属于哪个服务商(用于进入会话时判断是否匹配)": "Which provider this key belongs to (used to detect mismatches when entering a session)",
50
+ "保存中…": "Saving…",
51
+ "保存": "Save",
52
+ "用这把 key 测试": "Test this key",
53
+ "取消": "Cancel",
54
+ "凭据引用:保存时由宿主按这个会话/目录的 ID 生成,保存后这里会显示出来": "Credential ref: generated by the host from this session/directory ID on save; it appears here afterwards.",
55
+ "key 已失效": "Key invalid",
56
+ "服务商不匹配": "Provider mismatch",
57
+ " · 未配置 key": " · no key configured",
58
+ "清除标记": "Clear flag",
59
+ "测试中…": "Testing…",
60
+ "测试连接": "Test connection",
61
+ "收起": "Collapse",
62
+ "配置": "Configure",
63
+ "折叠会话": "Collapse sessions",
64
+ "展开会话": "Expand sessions",
65
+ "专属 key": "Dedicated key",
66
+ "未分组目录": "Ungrouped directory",
67
+ "目录已不存在": "Directory no longer exists",
68
+ "勾选后:该目录下会话的搜索/抓取请求改用聊天用的那把 key": "When checked: search/fetch calls from sessions in this directory use the chat key",
69
+ "(会话单独配置仍然优先)。不勾则用「设置 → 插件 → 网页搜索」里配置的 key。": "(session-level keys still win). Unchecked, they use the key from Settings → Plugins → Web Search.",
70
+ "同步搜索 API": "Sync search API",
71
+ "搜索用这里的 key": "Searches use this key",
72
+ "搜索用搜索默认 key": "Searches use the search default key",
73
+ "搜索用搜索插件里的 key": "Searches use the search plugin's key",
74
+ "(搜索插件)": "(search plugin)",
75
+ "使用说明": "Usage guide",
76
+ "推荐用法(两把 key,各管一件事)": "Recommended setup (two keys, one job each)",
77
+ "先到「设置 → 模型」配一把系统默认 key,例如就叫 ": "First, set a system default key in Settings → Models, named e.g. ",
78
+ "。没有单独配置的地方都用它聊天。": ". Everywhere without its own key uses it for chat.",
79
+ "再回到本页的「搜索默认 key」,放一把搜索专用的 key,例如 ": "Then, back on this page, put a search-only key in \"Search default key\", e.g. ",
80
+ "。搜索请求默认用它,和聊天那把分开计费。": ". Searches use it by default, billed separately from the chat key.",
81
+ "保存时插件会把官方「设置 → 插件 → 网页搜索」里的 API Key 指向同一个 ref,": "On save the plugin points the official Settings → Plugins → Web Search API Key at the same ref,",
82
+ "所以那个页面显示、修改的都是这把 key,两边不会各存一份。": "so that page shows and edits this key; there is no second copy.",
83
+ "平时不用再管:聊天按工作区/会话的 key 走,搜索走搜索默认 key,两边账单互不影响。": "Nothing else to do: chat uses workspace/session keys, search uses the search default key, and the bills don't affect each other.",
84
+ "想按工作区核算搜索花费": "Track search spend per workspace",
85
+ "在该工作区行勾上「同步搜索 API」:这个目录下会话的搜索就改用聊天那套层级 key": "Tick \"Sync search API\" on that workspace: searches from its sessions use the chat key cascade",
86
+ "(会话 key → 工作区 key → 系统默认),不再用搜索默认 key。": "(session key → workspace key → system default) instead of the search default key.",
87
+ "不勾的目录继续用搜索默认 key。两种方式可以同时存在,一个工作区一个样。": "Unticked directories keep using the search default key. Both styles can coexist, one per workspace.",
88
+ "搜索 key 的优先级(从高到低)": "Search key precedence (highest first)",
89
+ "会话单独配置的 key —— 该会话的聊天与搜索都用它,永远最优先": "A session's own key — used for both chat and search in that session, always first",
90
+ "工作区勾了「同步搜索 API」—— 该工作区下会话的搜索改用:工作区 key → 系统默认 key": "Workspace with \"Sync search API\" on — its sessions search with: workspace key → system default key",
91
+ "搜索默认 key —— 没勾同步时,搜索用它": "Search default key — used by searches when sync is off",
92
+ "官方「网页搜索」页里配的那把 —— 面板没配搜索默认 key 时的兜底": "The key on the official \"Web Search\" page — fallback when this panel has no search default key",
93
+ "(它默认就是系统默认 key ": "(by default that is the system default key ",
94
+ ",与聊天共用)": ", shared with chat)",
95
+ "官方「设置 → 插件 → 网页搜索」那个 Key 框怎么办": "What about the Key field in Settings → Plugins → Web Search?",
96
+ "官方那个输入框写的永远是「当前 API 段的 ": "That field always writes the ref named by the current API section's ",
97
+ " 指向的 ref」,默认就是聊天那把(": ", which defaults to the chat key (",
98
+ "本插件启动后会把它指到插件自己的搜索 ref(": "On startup this plugin points it at its own search ref (",
99
+ "等于把官方那个 key 设置接管过来:从今往后官方页里填的也是这把搜索 key,": "This takes over the official key setting: from now on that page edits this search key,",
100
+ "不会再把聊天 key 顶掉,也不会出现「两把搜索 key 各管一半」。": "so it can no longer overwrite the chat key or split search between two keys.",
101
+ "所以日常只需要在本页配搜索 key,官方那一页可以不管;本插件只改 ": "So normally you only configure the search key here and can leave the official page alone; the plugin only changes ",
102
+ " 这个指向,永远不动你的 key 内容。": " that pointer and never touches your key values.",
103
+ "会被提醒的几种情况": "Situations that trigger a warning",
104
+ "配了搜索 key,但没打开「启用新版 Web 搜索 API」→ 那把 key 其实不会被用到,面板顶部会提示。": "A search key is set but \"New Web Search API\" is off → that key is unused; the panel shows a notice.",
105
+ "打开了新版搜索 API,但既没有搜索默认 key、官方那把也是空的 → 任何搜索都会失败,面板顶部会提示。": "The new search API is on but neither the search default key nor the official key is set → every search fails; the panel shows a notice.",
106
+ "勾了同步,但该工作区既没有专属 key、系统默认 key 也没配 → 会提示先去配一把。": "Sync is on but the workspace has no key and no system default key is set → a notice asks you to set one first.",
107
+ "搜索默认 key 存好了,但官方页还指着聊天那把 → 会提示点「同步到官方设置」,一键对齐。": "The search default key is saved but the official page still points at the chat key → a notice offers \"Sync to official settings\" to align them.",
108
+ "原则": "Principles",
109
+ "提醒只报告、不替你改 key。插件绝不会自动切换或替换你的 key,避免计费被悄悄改到别的账号上。": "Warnings only report; they never change your key. The plugin never switches or replaces keys automatically, so billing cannot silently move to another account.",
110
+ "知道了": "Got it",
111
+ "尚未配置系统默认 API Key": "No system default API key configured",
112
+ "当前没有任何可用的 DEEPSEEK_API_KEY。可以到「设置 → 模型」里配置系统默认 key,": "There is no usable DEEPSEEK_API_KEY. Set a system default key in Settings → Models,",
113
+ "也可以直接为某个工作区写一把专属 key。": "or write a dedicated key for a workspace below.",
114
+ "可以重新配置这把 key,或让该工作区改用系统默认设置。": "Reconfigure this key, or let the workspace fall back to the system default.",
115
+ "重新配置": "Reconfigure",
116
+ "改用系统设置": "Use system settings",
117
+ "稍后": "Later",
118
+ "返回会话": "Back to conversation",
119
+ "返回": "Back",
120
+ "API Key 分配": "API Keys",
121
+ "按工作区 / 会话隔离计费": "Per-workspace and per-session billing",
122
+ "刷新中…": "Refreshing…",
123
+ "刷新": "Refresh",
124
+ "请确认 DSH Web 服务正在运行、且本插件的宿主半边已加载。": "Make sure the DSH Web service is running and this plugin's host half is loaded.",
125
+ "宿主端插件还是旧版本:所以看不到会话列表,也没有按会话配置。": "The host half is still an older version: no session list and no per-session configuration.",
126
+ "请「完全退出」DSH Desktop 再重新打开(关闭窗口 / 刷新页面都不够:宿主代码只在启动时加载)。": "Fully quit DSH and reopen it (closing the window or refreshing the page is not enough: host code loads at startup).",
127
+ "当前 DSH 版本没有提供会话列表服务,只能按工作区配置 key。": "This DSH version provides no session list service; only workspace keys can be configured.",
128
+ "宿主报告 0 个会话,浏览器端也没有拿到会话列表,所以没有可展开的列表。": "The host reports 0 sessions and the browser has no session list either, so there is nothing to expand.",
129
+ "只有「会话记录里带 cwd」的会话才能挂到工作区/未分组目录下。": "Only sessions whose log records a cwd can be attached to a workspace or ungrouped directory.",
130
+ "拿到会话了,但没有一个能对上工作区/目录(路径不匹配)。": "Sessions were found, but none matches a workspace/directory (path mismatch).",
131
+ "系统默认设置": "System default",
132
+ "未配置": "Not configured",
133
+ "被环境变量遮蔽(只读)": "Shadowed by an environment variable (read-only)",
134
+ "没有单独配置的工作区/会话,一律使用这把 key。": "Workspaces and sessions without their own key use this one.",
135
+ "搜索默认 key": "Search default key",
136
+ "已配置": "Configured",
137
+ "搜索请求默认用它,和聊天那把 key 分开计费。": "Searches use it by default, billed separately from chat.",
138
+ "工作区勾了「同步搜索 API」时改用该工作区的聊天层级;会话单独配置永远最优先。": "With \"Sync search API\" on for a workspace, searches use its chat cascade; a session's own key always wins.",
139
+ "官方「网页搜索」页现在仍指着聊天那把 key(DEEPSEEK_API_KEY)——保存一次搜索 key 后,插件会把它接过来。": "The official \"Web Search\" page still points at the chat key (DEEPSEEK_API_KEY); after you save a search key once, the plugin takes it over.",
140
+ "你之前保存过搜索默认 key,但官方页还指向聊天那把。": "You saved a search default key earlier, but the official page still points at the chat key.",
141
+ "同步到官方设置": "Sync to official settings",
142
+ "sk-…(留空 = 用官方搜索页里配的那把)": "sk-… (empty = use the key from the official search page)",
143
+ "搜索默认": "Search default",
144
+ "清除": "Clear",
145
+ "展开工作区可以看到它下面的会话,并单独给某个会话指定 key(会话 > 工作区 > 系统默认)。": "Expand a workspace to see its sessions and give one its own key (session > workspace > system default).",
146
+ "还没有工作区。先在侧栏「工作区」里新建一个。": "No workspaces yet. Create one in the Workspaces section of the sidebar first.",
147
+ "这些目录里的会话没有挂在工作区下(侧栏的「未分组」),同样可以单独配置。": "Sessions in these directories are not under a workspace (the sidebar's \"Ungrouped\"); they can be configured the same way.",
148
+ "只有侧栏真的会显示的目录才列在这里(刚建好、还没发过消息的空会话不算)。": "Only directories the sidebar actually shows are listed (brand-new empty sessions with no message do not count).",
149
+ "这些目录已经不是注册的工作区了,对应的 key 仍留在凭据库里。可改用系统默认以清理。": "These directories are no longer registered workspaces; their keys remain in the credential store. Switch them to the system default to clean up.",
150
+ "专属 key 存进 DSH 凭据库中的独立引用,因此换 key 不写进会话日志、也不影响历史回放。": "Dedicated keys are stored as separate refs in the DSH credential store, so changing a key never enters session logs or affects replay.",
151
+ "这个会话用的 API Key 已失效": "The API key for this session is invalid",
152
+ "这个会话的 API Key 可能不匹配": "The API key for this session may not match",
153
+ "网页搜索的配置需要确认": "Web search configuration needs attention",
154
+ "会话": "Session",
155
+ "模型": "Model",
156
+ "生效 key": "Effective key",
157
+ "插件不会自动更换 key —— 用哪把 key 由你决定,避免计费被悄悄改到别的账号上。": "The plugin never swaps keys automatically — you decide which key is used, so billing cannot silently move to another account.",
158
+ "去配置": "Configure",
159
+ "继续用当前 key": "Keep current key",
160
+ "清除本会话配置": "Clear this session's setting",
161
+ "API Key 提醒": "API Key notice",
162
+ "面板入口": "Panel entry",
163
+ "左侧栏的「API Key 分配」图标可以像其他面板一样取消固定。取消之后它还在侧栏底部的「更多」菜单里,并不是被卸载。": "The \"API Key Allocation\" icon in the left sidebar can be unpinned like any other panel. Unpinning only moves it into the sidebar's More menu; the panel itself is not removed.",
164
+ "如果找不到它,可以:": "If you cannot find it, you can:",
165
+ "点下面的按钮,直接打开面板;": "press the button below to open the panel directly;",
166
+ "或者点侧栏底部的「更多」菜单,未固定的面板都列在那里;": "or open the More menu at the sidebar's bottom, where unpinned panels are listed;",
167
+ "再或者按 Ctrl+R 刷新页面 —— 面板固定状态只存在内存里,刷新就会恢复默认。": "or press Ctrl+R to reload the page — the pin state lives only in memory, so a reload restores the defaults.",
168
+ "打开 API Key 分配面板": "Open the API Key Allocation panel",
169
+ "已切到该面板:如果它还是没出现,说明面板正在加载,稍等一下。": "Switched to that panel: if it has not appeared yet, it is still loading — give it a moment.",
170
+ "这里同时管着每个工作区 / 会话的 key 与搜索通道;面板右上角的「使用说明」有完整的优先级说明。": "This panel manages the key and search channel of every workspace and session; the \"How to use\" button in its header explains the full precedence order."
171
+ }
172
+
173
+ const EN_HOST = {
174
+ "现在搜索走的是 DeepSeek Messages 通道,用的是聊天那把 key。": "Searches currently use the DeepSeek Messages channel and the chat key.",
175
+ "这些工作区勾了「同步搜索 API」,但既没有工作区专属 key,系统默认 key 也没配置:": "These workspaces have \"Sync search API\" on, but neither a workspace key nor a system default key is configured:",
176
+ "搜索会用空 key 发出去。给这些工作区各配一把,或先配好系统默认 key。": "searches would go out with an empty key. Give each of them a key, or configure the system default first.",
177
+ "当前搜索走旧的 Messages 通道,它本来就用聊天通道的 key(会话 → 工作区 → 系统默认),": "Searches use the legacy Messages channel, which already uses the chat key (session → workspace → system default),",
178
+ "所以这个开关只影响「启用新版 Web 搜索 API」之后的行为。": "so this switch only affects behavior after \"New Web Search API\" is enabled.",
179
+ "要让它生效,请到「设置 → 插件 → 网页搜索」打开那个开关;若暂时不想用新版 API,可以忽略本条。": "To enable it, turn on the switch in Settings → Plugins → Web Search; ignore this notice if you do not want the new API for now.",
180
+ "点面板上的「同步到官方设置」,官方页的 API Key 输入框就会改到同一个 ref,两处以后改的是同一把 key。": "Click \"Sync to official settings\" in the panel and the official API Key field moves to the same ref; both then edit the same key.",
181
+ "在本面板的「搜索默认 key」里保存一把后,插件会自动把官方配置指过去,两边就分开了。": "Save one under \"Search default key\" in this panel and the plugin points the official setting at it, separating the two.",
182
+ "连接正常,key 可用": "Connected; key works",
183
+ "key 无效(401/403)": "Invalid key (401/403)",
184
+ "被限流(429),但 key 本身可能有效": "Rate limited (429), but the key itself may be valid",
185
+ "余额/配额不足(402)": "Balance/quota exhausted (402)",
186
+ "请求参数被拒": "request rejected",
187
+ "credentials 服务不可用": "credentials service unavailable",
188
+ "credentials 服务不可用,无法写入 API key": "credentials service unavailable; cannot store the API key",
189
+ "面板请求超时(8 秒):DSH 的某个服务没有响应": "Panel request timed out (8s): a DSH service did not respond",
190
+ "缺少 sessionId": "missing sessionId",
191
+ "缺少 path": "missing path",
192
+ "无法为该会话生成凭据引用": "cannot derive a credential ref for this session",
193
+ "无法为该路径生成凭据引用": "cannot derive a credential ref for this path",
194
+ "无法规范化该路径": "cannot normalize this path",
195
+ "搜索 key 已配置,但「启用新版 Web 搜索 API」没打开": "Search key is configured, but \"New Web Search API\" is off",
196
+ "搜索默认 key 还没配置:搜索暂时沿用聊天那把 key": "No search default key yet: searches currently share the chat key",
197
+ "新版 Web 搜索 API 已启用,但搜索 key 没有配置": "New Web Search API is enabled, but no search key is configured",
198
+ "搜索默认 key 已配置,但「启用新版 Web 搜索 API」没打开": "Search default key is configured, but \"New Web Search API\" is off",
199
+ "搜索默认 key 已保存,但官方「网页搜索」页仍指向聊天那把": "Search default key is saved, but the official \"Web Search\" page still points at the chat key",
200
+ "「同步搜索 API」现在没有区别": "\"Sync search API\" currently makes no difference"
201
+ }
202
+
203
+ const EN_HOST_RULES = [
204
+ [/^官方页现在绑的是 (.+)(与聊天共用),所以那里看不到 (.+) 这把。$/, "The official page binds $1 (shared with chat), so it cannot show $2."],
205
+ [/^所以面板里的 (.+) 不会被使用。$/, "so $1 in the panel is not used."],
206
+ [/^现在搜索读的是 (.+)(和聊天共用一把),所以搜索与聊天的花费算在一起。$/, "Searches read $1 (shared with chat), so search and chat spending are counted together."],
207
+ [/^鉴权通过(HTTP 400:(.+))$/, "Authenticated (HTTP 400: $1)"],
208
+ [/^请求超时((\d+)ms):(.+)$/, "Request timed out ($1ms): $2"],
209
+ [/^(404:端点 (.+) 不存在,检查 baseURL)$/, "(404: endpoint $1 not found; check baseURL)"],
210
+ [/^(404:端点 (.+) 不存在,检查「Search \/ Fetch API 基础地址」)$/, "(404: endpoint $1 not found; check the Search / Fetch API base URL)"],
211
+ [/^搜索通道正常,返回 (\d+) 条结果((.+))$/, "Search channel OK, $1 result(s) ($2)"],
212
+ [/^搜索通道正常((.+))$/, "Search channel OK ($1)"]
213
+ ]
214
+
215
+ const i18n = { english: false, listeners: new Set() }
216
+
217
+ function refreshI18n(ctx) {
218
+ let english = false
219
+ try {
220
+ const locale = ctx?.get?.('locale')
221
+ const snapshot = locale?.getSnapshot?.() ?? locale?.getLocale?.()
222
+ const active = snapshot?.active
223
+ english = typeof active === 'string' && !active.toLowerCase().startsWith('zh')
224
+ } catch {
225
+ english = false
226
+ }
227
+ i18n.english = english
228
+ }
229
+
230
+ function notifyI18n() {
231
+ for (const listener of [...i18n.listeners]) {
232
+ try { listener() } catch { /* a broken listener must not break the switch */ }
233
+ }
234
+ }
235
+
236
+ function tr(value) {
237
+ if (typeof value !== 'string' || i18n.english !== true) return value
238
+ const hit = EN[value] ?? EN_HOST[value]
239
+ if (hit !== undefined) return hit
240
+ for (const [pattern, replacement] of EN_HOST_RULES) {
241
+ if (pattern.test(value)) return value.replace(pattern, replacement)
242
+ }
243
+ return value
244
+ }
245
+
246
+ function tf(zh, en) {
247
+ return i18n.english === true ? en : zh
248
+ }
249
+
250
+ function trChild(value) {
251
+ if (typeof value === 'string') return tr(value)
252
+ if (Array.isArray(value)) return value.map(trChild)
253
+ return value
254
+ }
255
+
256
+ const TRANSLATED_PROPS = ['title', 'placeholder', 'aria-label', 'alt', 'label']
257
+ function h(type, props, ...children) {
258
+ let next = props
259
+ if (props !== null && typeof props === 'object') {
260
+ next = { ...props }
261
+ for (const key of TRANSLATED_PROPS) {
262
+ if (typeof next[key] === 'string') next[key] = tr(next[key])
263
+ }
264
+ }
265
+ return rawCreateElement(type, next, ...children.map(trChild))
266
+ }
267
+
268
+ function useI18n() {
269
+ const [, setTick] = useState(0)
270
+ useEffect(() => {
271
+ if (host.ctx !== null) refreshI18n(host.ctx)
272
+ const listener = () => setTick((tick) => tick + 1)
273
+ i18n.listeners.add(listener)
274
+ return () => { i18n.listeners.delete(listener) }
275
+ }, [])
276
+ }
277
+
11
278
  const API_BASE = '/wsk-api'
12
279
  const STYLE_ID = 'wsk-styles'
13
280
  const PANEL_ID = 'workspace-api-key'
@@ -144,15 +411,15 @@ window.__ModuleLoader__.load({
144
411
  try {
145
412
  res = await window.fetch(`${API_BASE}${path}`, init)
146
413
  } catch (err) {
147
- throw new Error(`无法访问插件宿主接口(${String(err?.message ?? err)})`)
414
+ throw new Error(tf(`无法访问插件宿主接口(${String(err?.message ?? err)})`, `Could not reach the plugin host API (${String(err?.message ?? err)})`))
148
415
  }
149
416
  let body = null
150
417
  try {
151
418
  body = await res.json()
152
419
  } catch {
153
- throw new Error(`宿主接口返回了非 JSON 响应(HTTP ${res.status})`)
420
+ throw new Error(tf(`宿主接口返回了非 JSON 响应(HTTP ${res.status})`, `The host API returned a non-JSON response (HTTP ${res.status})`))
154
421
  }
155
- if (body === null || body === undefined) throw new Error(`宿主接口返回空响应(HTTP ${res.status})`)
422
+ if (body === null || body === undefined) throw new Error(tf(`宿主接口返回空响应(HTTP ${res.status})`, `The host API returned an empty response (HTTP ${res.status})`))
156
423
  if (res.ok !== true || body.ok === false) throw new Error(body.error ?? `HTTP ${res.status}`)
157
424
  return body
158
425
  }
@@ -164,27 +431,27 @@ window.__ModuleLoader__.load({
164
431
 
165
432
  function verdictText(verdict) {
166
433
  switch (verdict) {
167
- case 'ok': return '连接正常'
168
- case 'invalid': return 'key 无效'
169
- case 'quota': return '余额不足'
170
- case 'rate-limit': return '被限流'
171
- case 'unreachable': return '无法连接'
172
- default: return '测试失败'
434
+ case 'ok': return tr('连接正常')
435
+ case 'invalid': return tr('key 无效')
436
+ case 'quota': return tr('余额不足')
437
+ case 'rate-limit': return tr('被限流')
438
+ case 'unreachable': return tr('无法连接')
439
+ default: return tr('测试失败')
173
440
  }
174
441
  }
175
442
 
176
443
  function invalidReason(flag) {
177
444
  if (flag === null || flag === undefined) return undefined
178
445
  if (flag.status !== 'invalid') return undefined
179
- return flag.message ?? '鉴权失败'
446
+ return tr(flag.message) ?? tr('鉴权失败')
180
447
  }
181
448
 
182
449
  // 生效层级:会话级 > 工作区级 > 系统默认
183
450
  function levelText(level) {
184
451
  switch (level) {
185
- case 'session': return '本会话专属'
186
- case 'workspace': return '工作区专属'
187
- default: return '系统默认'
452
+ case 'session': return tr('本会话专属')
453
+ case 'workspace': return tr('工作区专属')
454
+ default: return tr('系统默认')
188
455
  }
189
456
  }
190
457
 
@@ -194,9 +461,9 @@ window.__ModuleLoader__.load({
194
461
 
195
462
  function mismatchText(mismatch) {
196
463
  if (mismatch === null || mismatch === undefined) return undefined
197
- const key = mismatch.keyProvider ?? '未记录服务商'
198
- const session = mismatch.sessionProvider ?? '未知'
199
- return `这把 key 是为「${key}」准备的,当前会话用的是「${session}」`
464
+ const key = mismatch.keyProvider ?? tr('未记录服务商')
465
+ const session = mismatch.sessionProvider ?? tr('未知')
466
+ return tf(`这把 key 是为「${key}」准备的,当前会话用的是「${session}」`, `This key is for "${key}", but the current session uses "${session}"`)
200
467
  }
201
468
 
202
469
  /**
@@ -207,19 +474,19 @@ window.__ModuleLoader__.load({
207
474
  if (search === null || search === undefined) return undefined
208
475
  if (search.apiChannel === true) {
209
476
  if (search.keyConfigured === false) {
210
- return '你打开了「启用新版 Web 搜索 API」,但搜索 key 还没有配置:这个会话里的搜索会直接失败。'
477
+ return tr('你打开了「启用新版 Web 搜索 API」,但搜索 key 还没有配置:这个会话里的搜索会直接失败。')
211
478
  }
212
479
  return undefined
213
480
  }
214
481
  if (search.leftoverKey === true) {
215
- return '你配置了搜索专用的 API Key,但「启用新版 Web 搜索 API」没有打开,所以那把 key 现在不会被使用。'
482
+ return tr('你配置了搜索专用的 API Key,但「启用新版 Web 搜索 API」没有打开,所以那把 key 现在不会被使用。')
216
483
  }
217
484
  return undefined
218
485
  }
219
486
 
220
487
  function sessionLabel(session) {
221
488
  const title = typeof session.title === 'string' && session.title !== '' ? session.title : undefined
222
- return title ?? (session.blank === true ? '(空会话)' : String(session.sessionId).slice(0, 12))
489
+ return title ?? (session.blank === true ? tr('(空会话)') : String(session.sessionId).slice(0, 12))
223
490
  }
224
491
 
225
492
  // ─────────────────── 会话树:宿主行 + 浏览器端列表 ───────────────────
@@ -431,8 +698,8 @@ window.__ModuleLoader__.load({
431
698
  }
432
699
 
433
700
  function modelText(session) {
434
- if (typeof session.provider !== 'string' || session.provider === '') return '模型未知'
435
- return `${session.provider} / ${session.model ?? '默认模型'}`
701
+ if (typeof session.provider !== 'string' || session.provider === '') return tr('模型未知')
702
+ return tf(`${session.provider} / ${session.model ?? '默认模型'}`, `${session.provider} / ${session.model ?? 'default model'}`)
436
703
  }
437
704
 
438
705
  // ─────────────────────────────── 图标 ───────────────────────────────
@@ -505,7 +772,50 @@ window.__ModuleLoader__.load({
505
772
 
506
773
  // ─────────────────────────────── 主页面 ───────────────────────────────
507
774
 
775
+ /**
776
+ * 「设置 → API Key 分配」这一栏:侧栏图标没了也能从这里回到面板。
777
+ *
778
+ * 侧栏的面板图标是 DSH 的「面板固定」状态(`@deepseek-ai/dsh-client-ui-sidebar`
779
+ * 的 panel-pins)。取消固定只把图标挪进侧栏底部的「更多」菜单,面板本身没被卸载;
780
+ * 那套固定状态只保存在内存里,刷新页面就恢复默认,而且没有对外 API,
781
+ * 插件没法替用户把图标钉回去 —— 所以这里提供一个不会消失的入口。
782
+ */
783
+ function SettingsSection() {
784
+ useI18n()
785
+ const [opened, setOpened] = useState(false)
786
+ const openPanel = () => {
787
+ try {
788
+ const layout = host.ctx === null ? undefined : host.ctx.get('layout')
789
+ if (layout === undefined || layout === null) return
790
+ if (typeof layout.selectPanel !== 'function') return
791
+ layout.selectPanel(PANEL_ID)
792
+ setOpened(true)
793
+ } catch {
794
+ /* layout 不可用时保持说明文字,用户还能走「更多」菜单 */
795
+ }
796
+ }
797
+ return h('div', { className: 'wsk-body' },
798
+ h('div', { className: 'wsk-card' },
799
+ h('h4', null, '面板入口'),
800
+ h('div', { className: 'wsk-hint' },
801
+ '左侧栏的「API Key 分配」图标可以像其他面板一样取消固定。取消之后它还在侧栏底部的「更多」菜单里,并不是被卸载。'),
802
+ h('div', { className: 'wsk-hint' }, '如果找不到它,可以:'),
803
+ h('ul', { className: 'wsk-hint', style: { margin: '6px 0 10px 18px' } },
804
+ h('li', null, '点下面的按钮,直接打开面板;'),
805
+ h('li', null, '或者点侧栏底部的「更多」菜单,未固定的面板都列在那里;'),
806
+ h('li', null, '再或者按 Ctrl+R 刷新页面 —— 面板固定状态只存在内存里,刷新就会恢复默认。')),
807
+ h('button', { type: 'button', className: 'wsk-btn primary', onClick: openPanel },
808
+ '打开 API Key 分配面板'),
809
+ opened
810
+ ? h('div', { className: 'wsk-hint', style: { marginTop: 8 } },
811
+ '已切到该面板:如果它还是没出现,说明面板正在加载,稍等一下。')
812
+ : null,
813
+ h('div', { className: 'wsk-hint', style: { marginTop: 10 } },
814
+ '这里同时管着每个工作区 / 会话的 key 与搜索通道;面板右上角的「使用说明」有完整的优先级说明。')))
815
+ }
816
+
508
817
  function WorkspaceKeyPage(props) {
818
+ useI18n()
509
819
  const [state, setState] = useState(null)
510
820
  const [error, setError] = useState(null)
511
821
  const [busy, setBusy] = useState(false)
@@ -646,12 +956,12 @@ window.__ModuleLoader__.load({
646
956
  setNotice({
647
957
  kind: 'ok',
648
958
  text: trimmed === ''
649
- ? `${target.title}:已改用${scope === 'session' ? '上层' : '系统默认'}设置`
959
+ ? tf(`${target.title}:已改用${scope === 'session' ? '上层' : '系统默认'}设置`, `${target.title}: switched to the ${scope === 'session' ? 'level above' : 'system default'}`)
650
960
  : target.kind === 'session'
651
- ? `${target.title}:已保存本会话专属 key`
961
+ ? tf(`${target.title}:已保存本会话专属 key`, `${target.title}: saved a key for this session`)
652
962
  : wasCleared
653
- ? `${target.title}:已改用系统默认设置`
654
- : `${target.title}:已保存专属 key`,
963
+ ? tf(`${target.title}:已改用系统默认设置`, `${target.title}: switched back to the system default`)
964
+ : tf(`${target.title}:已保存专属 key`, `${target.title}: saved a dedicated key`),
655
965
  })
656
966
  }
657
967
  return true
@@ -682,14 +992,14 @@ window.__ModuleLoader__.load({
682
992
  } else if (bound?.ok === false) {
683
993
  setNotice({
684
994
  kind: 'warn',
685
- text: `key 已保存在 ${next?.search?.defaultRef ?? '搜索默认 ref'},但没能改官方设置(${bound.reason}):`
686
- + '官方「网页搜索」页仍指向原来的 ref,两处暂时不是同一把。',
995
+ text: tf(`key 已保存在 ${next?.search?.defaultRef ?? '搜索默认 ref'},但没能改官方设置(${bound.reason}):`, `Key saved to ${next?.search?.defaultRef ?? 'the search default ref'}, but the official setting could not be changed (${bound.reason}):`)
996
+ + tf('官方「网页搜索」页仍指向原来的 ref,两处暂时不是同一把。', 'The official "Web Search" page still points at the previous ref, so the two are temporarily different keys.'),
687
997
  })
688
998
  } else {
689
999
  setNotice({
690
1000
  kind: 'ok',
691
- text: `搜索默认 key 已保存到 ${next?.search?.defaultRef ?? '搜索默认 ref'},`
692
- + '官方「网页搜索」页的 API Key 输入框也指向这个 ref:以后两边改的是同一把 key。',
1001
+ text: tf(`搜索默认 key 已保存到 ${next?.search?.defaultRef ?? '搜索默认 ref'},`, `Search default key saved to ${next?.search?.defaultRef ?? 'the search default ref'}, `)
1002
+ + tf('官方「网页搜索」页的 API Key 输入框也指向这个 ref:以后两边改的是同一把 key。', 'The API Key field on the official "Web Search" page now points at this ref too: both places edit the same key.'),
693
1003
  })
694
1004
  }
695
1005
  } catch (err) {
@@ -707,8 +1017,8 @@ window.__ModuleLoader__.load({
707
1017
  applyState(next)
708
1018
  const bound = next?.bound
709
1019
  setNotice(bound?.ok === false
710
- ? { kind: 'warn', text: `没能改官方设置:${bound.reason}` }
711
- : { kind: 'ok', text: `已同步:官方「网页搜索」页现在绑定 ${next?.search?.officialRef ?? next?.search?.defaultRef ?? '同一个 ref'},与面板改的是同一把 key。` })
1020
+ ? { kind: 'warn', text: tf(`没能改官方设置:${bound.reason}`, `Could not change the official setting: ${bound.reason}`) }
1021
+ : { kind: 'ok', text: tf(`已同步:官方「网页搜索」页现在绑定 ${next?.search?.officialRef ?? next?.search?.defaultRef ?? '同一个 ref'},与面板改的是同一把 key。`, `Synced: the official "Web Search" page now binds ${next?.search?.officialRef ?? next?.search?.defaultRef ?? 'the same ref'} — the same key this panel edits.`) })
712
1022
  } catch (err) {
713
1023
  if (alive.current) setNotice({ kind: 'bad', text: `${err?.message ?? err}` })
714
1024
  } finally {
@@ -766,7 +1076,7 @@ window.__ModuleLoader__.load({
766
1076
  applyState(await api('/clear-flag', { method: 'POST', body: JSON.stringify(body) }))
767
1077
  if (alive.current) {
768
1078
  setTests((prev) => ({ ...prev, [id]: undefined }))
769
- setNotice({ kind: 'info', text: `${title}:已清除失效标记` })
1079
+ setNotice({ kind: 'info', text: tf(`${title}:已清除失效标记`, `${title}: invalid flag cleared`) })
770
1080
  }
771
1081
  } catch (err) {
772
1082
  if (alive.current) setNotice({ kind: 'bad', text: String(err?.message ?? err) })
@@ -803,31 +1113,31 @@ window.__ModuleLoader__.load({
803
1113
  if (state === null) return undefined
804
1114
  const hostVersion = typeof state.pluginVersion === 'string'
805
1115
  ? state.pluginVersion
806
- : '旧版(未重启应用)'
1116
+ : tr('旧版(未重启应用)')
807
1117
  const sessions = probe === undefined
808
- ? '未知'
1118
+ ? tr('未知')
809
1119
  : probe.available !== true
810
- ? '不可用'
811
- : `${probe.count} 个${probe.shape !== undefined ? `(${probe.shape})` : ''}`
812
- + (probe.source === 'sessions' ? '(live 兜底)' : '')
813
- const listError = probe?.error !== undefined ? ` · list 报错:${probe.error}` : ''
814
- const noCwd = probe?.withoutCwd > 0 ? ` · ${probe.withoutCwd} 个没有 cwd` : ''
815
- const subs = probe?.subagents > 0 ? ` · 已跳过 ${probe.subagents} 个子代理会话` : ''
816
- const blanks = (view?.sessionBlanks ?? 0) > 0 ? ` · 已跳过 ${view.sessionBlanks} 个空会话` : ''
1120
+ ? tr('不可用')
1121
+ : tf(`${probe.count} 个${probe.shape !== undefined ? `(${probe.shape})` : ''}`, `${probe.count} session(s)${probe.shape !== undefined ? ` (${probe.shape})` : ''}`)
1122
+ + (probe.source === 'sessions' ? tf('(live 兜底)', ' (live fallback)') : '')
1123
+ const listError = probe?.error !== undefined ? tf(` · list 报错:${probe.error}`, ` · list() error: ${tr(probe.error)}`) : ''
1124
+ const noCwd = probe?.withoutCwd > 0 ? tf(` · ${probe.withoutCwd} 个没有 cwd`, ` · ${probe.withoutCwd} without cwd`) : ''
1125
+ const subs = probe?.subagents > 0 ? tf(` · 已跳过 ${probe.subagents} 个子代理会话`, ` · skipped ${probe.subagents} subagent session(s)`) : ''
1126
+ const blanks = (view?.sessionBlanks ?? 0) > 0 ? tf(` · 已跳过 ${view.sessionBlanks} 个空会话`, ` · skipped ${view.sessionBlanks} empty session(s)`) : ''
817
1127
  // 宿主只标注「这个目录里只有还没说过话的空会话」,列不列由本文件按侧栏语义决定
818
1128
  // (空会话里只有当前打开的那一个算数)——所以这里说的是「宿主标了几个」。
819
1129
  const blankDirs = Array.isArray(state.hiddenBlankDirs) && state.hiddenBlankDirs.length > 0
820
- ? ` · ${state.hiddenBlankDirs.length} 个目录只有空会话(未分组里不列)`
1130
+ ? tf(` · ${state.hiddenBlankDirs.length} 个目录只有空会话(未分组里不列)`, ` · ${state.hiddenBlankDirs.length} directories contain only empty sessions (not listed under Ungrouped)`)
821
1131
  : ''
822
1132
  const searchDiag = searchInfo === undefined
823
1133
  ? ''
824
- : ` · 搜索通道:${searchInfo.apiChannel === true ? '新版 API' : 'Messages'}`
825
- + `(${searchInfo.keyRef ?? '—'}${searchInfo.keyMasked ? ` · ${searchInfo.keyMasked}` : ' · 未配置'})`
826
- + (searchInfo.syncCount > 0 ? ` · 已同步 ${searchInfo.syncCount} 个目录` : '')
827
- return `插件 v${hostVersion} · 状态存储 v${state.storeVersion ?? '—'}`
828
- + ` · 宿主会话列表:${sessions}${listError}${noCwd}${subs}`
829
- + ` · 浏览器会话列表:${clientCount} 个${blanks}${blankDirs}`
830
- + ` · 工作区 ${grouped.length} + 未分组 ${ungrouped.length} · 挂在目录下的会话 ${sessionTotal}`
1134
+ : tf(` · 搜索通道:${searchInfo.apiChannel === true ? '新版 API' : 'Messages'}`, ` · search channel: ${searchInfo.apiChannel === true ? 'new API' : 'Messages'}`)
1135
+ + tf(`(${searchInfo.keyRef ?? '—'}${searchInfo.keyMasked ? ` · ${searchInfo.keyMasked}` : ' · 未配置'})`, ` (${searchInfo.keyRef ?? '—'}${searchInfo.keyMasked ? ` · ${searchInfo.keyMasked}` : ' · not configured'})`)
1136
+ + (searchInfo.syncCount > 0 ? tf(` · 已同步 ${searchInfo.syncCount} 个目录`, ` · synced ${searchInfo.syncCount} director${searchInfo.syncCount === 1 ? 'y' : 'ies'}`) : '')
1137
+ return tf(`插件 v${hostVersion} · 状态存储 v${state.storeVersion ?? '—'}`, `plugin v${hostVersion} · state store v${state.storeVersion ?? '—'}`)
1138
+ + tf(` · 宿主会话列表:${sessions}${listError}${noCwd}${subs}`, ` · host sessions: ${sessions}${listError}${noCwd}${subs}`)
1139
+ + tf(` · 浏览器会话列表:${clientCount} 个${blanks}${blankDirs}`, ` · browser sessions: ${clientCount}${blanks}${blankDirs}`)
1140
+ + tf(` · 工作区 ${grouped.length} + 未分组 ${ungrouped.length} · 挂在目录下的会话 ${sessionTotal}`, ` · workspaces ${grouped.length} + ungrouped ${ungrouped.length} · sessions attached to directories ${sessionTotal}`)
831
1141
  + searchDiag
832
1142
  }
833
1143
 
@@ -837,8 +1147,8 @@ window.__ModuleLoader__.load({
837
1147
  const paths = Array.isArray(state.workspacePathSample) ? state.workspacePathSample : []
838
1148
  if (cwds.length === 0 && paths.length === 0) return null
839
1149
  return h('div', { className: 'wsk-hint', style: { marginTop: 4 } },
840
- cwds.length > 0 ? h('div', null, `会话 cwd 样本:${cwds.join(' | ')}`) : null,
841
- paths.length > 0 ? h('div', null, `工作区路径样本:${paths.join(' | ')}`) : null)
1150
+ cwds.length > 0 ? h('div', null, tf(`会话 cwd 样本:${cwds.join(' | ')}`, `Session cwd samples: ${cwds.join(' | ')}`)) : null,
1151
+ paths.length > 0 ? h('div', null, tf(`工作区路径样本:${paths.join(' | ')}`, `Workspace path samples: ${paths.join(' | ')}`)) : null)
842
1152
  }
843
1153
 
844
1154
  const providerOptions = (current, fallback) => {
@@ -854,14 +1164,14 @@ window.__ModuleLoader__.load({
854
1164
  const isSession = target.kind === 'session'
855
1165
  const current = isSession ? target.level : target.configured === true ? 'workspace' : 'default'
856
1166
  const fallbackLabel = isSession
857
- ? `清除本会话配置(改回${target.workspaceConfigured === true ? '工作区' : '系统默认'})`
1167
+ ? tf(`清除本会话配置(改回${target.workspaceConfigured === true ? '工作区' : '系统默认'})`, `Clear this session's setting (back to ${target.workspaceConfigured === true ? 'workspace' : 'system default'})`)
858
1168
  : '改用系统默认'
859
1169
  return h('div', { className: 'wsk-editor' },
860
1170
  h('div', { className: 'wsk-hint' },
861
1171
  isSession
862
- ? `当前生效:${levelText(current)}${target.keyMasked ? `(${target.keyMasked})` : ''}。`
1172
+ ? tf(`当前生效:${levelText(current)}${target.keyMasked ? `(${target.keyMasked})` : ''}。`, `Currently effective: ${levelText(current)}${target.keyMasked ? ` (${target.keyMasked})` : ''}.`)
863
1173
  : target.configured === true
864
- ? `该工作区当前使用专属 key${target.keyMasked ? `(${target.keyMasked})` : ''}。留空并保存即恢复使用系统默认。`
1174
+ ? tf(`该工作区当前使用专属 key${target.keyMasked ? `(${target.keyMasked})` : ''}。留空并保存即恢复使用系统默认。`, `This workspace uses a dedicated key${target.keyMasked ? ` (${target.keyMasked})` : ''}. Leave empty and save to fall back to the system default.`)
865
1175
  : '为该工作区指定一把专属 key;留空并保存即继续使用系统默认。'),
866
1176
  h('input', {
867
1177
  className: 'wsk-input',
@@ -901,7 +1211,7 @@ window.__ModuleLoader__.load({
901
1211
  }, '取消')),
902
1212
  h('div', { className: 'wsk-hint' }, target.ref === undefined || target.ref === null
903
1213
  ? '凭据引用:保存时由宿主按这个会话/目录的 ID 生成,保存后这里会显示出来'
904
- : `凭据引用:${target.ref}`))
1214
+ : tf(`凭据引用:${target.ref}`, `Credential ref: ${target.ref}`)))
905
1215
  }
906
1216
 
907
1217
  const renderSession = (ws, session) => {
@@ -932,8 +1242,8 @@ window.__ModuleLoader__.load({
932
1242
  : null),
933
1243
  h('div', { className: 'wsk-path' },
934
1244
  `${modelText(session)}` +
935
- (session.effectiveKeyMasked ? ` · 生效 key ${session.effectiveKeyMasked}` : ' · 未配置 key') +
936
- (hasOwn && session.keyMasked ? ` · 本会话 ${session.keyMasked}` : '')),
1245
+ (session.effectiveKeyMasked ? tf(` · 生效 key ${session.effectiveKeyMasked}`, ` · effective key ${session.effectiveKeyMasked}`) : ' · 未配置 key') +
1246
+ (hasOwn && session.keyMasked ? tf(` · 本会话 ${session.keyMasked}`, ` · this session ${session.keyMasked}`) : '')),
937
1247
  reason !== undefined
938
1248
  ? h('div', { className: 'wsk-hint', style: { color: 'var(--wsk-bad)', marginTop: 3 } }, reason)
939
1249
  : null,
@@ -992,7 +1302,7 @@ window.__ModuleLoader__.load({
992
1302
  key: 'tgl', type: 'button', className: 'wsk-toggle',
993
1303
  title: open ? '折叠会话' : '展开会话',
994
1304
  onClick: () => toggle(ws.path),
995
- }, caretSvg(12, open), h('span', null, `会话 ${sessions.length}`))
1305
+ }, caretSvg(12, open), h('span', null, tf(`会话 ${sessions.length}`, `Sessions ${sessions.length}`)))
996
1306
  : null,
997
1307
  h('span', { key: 'n' }, title),
998
1308
  reason !== undefined
@@ -1034,9 +1344,9 @@ window.__ModuleLoader__.load({
1034
1344
  : '搜索用搜索插件里的 key')),
1035
1345
  ws.kind !== 'orphan' && searchInfo?.apiChannel === true && ws.searchKeyMasked !== undefined
1036
1346
  ? h('div', { key: 'sk', className: 'wsk-hint', style: { marginTop: 2 } },
1037
- `搜索生效 key:${ws.searchKeyMasked || '未配置'}`,
1347
+ tf(`搜索生效 key:${ws.searchKeyMasked || '未配置'}`, `Effective search key: ${ws.searchKeyMasked || 'not configured'}`),
1038
1348
  ws.searchSync === true
1039
- ? `(${ws.configured === true ? '本目录专属' : '系统默认'})`
1349
+ ? tf(`(${ws.configured === true ? '本目录专属' : '系统默认'})`, ` (${ws.configured === true ? 'this directory' : 'system default'})`)
1040
1350
  : '(搜索插件)')
1041
1351
  : null,
1042
1352
  reason !== undefined
@@ -1136,10 +1446,10 @@ window.__ModuleLoader__.load({
1136
1446
  const ws = prompt.ws
1137
1447
  const reason = invalidReason(ws.invalid)
1138
1448
  return h(Dialog, {
1139
- title: `${ws.title} 的 API Key 已失效`,
1449
+ title: tf(`${ws.title} 的 API Key 已失效`, `${ws.title}: API key invalid`),
1140
1450
  body: h('div', null,
1141
1451
  h('div', null, reason),
1142
- h('div', { className: 'wsk-hint', style: { marginTop: 6 } }, `工作区:${prettyPath(ws.path)}`),
1452
+ h('div', { className: 'wsk-hint', style: { marginTop: 6 } }, tf(`工作区:${prettyPath(ws.path)}`, `Workspace: ${prettyPath(ws.path)}`)),
1143
1453
  h('div', { className: 'wsk-hint' }, '可以重新配置这把 key,或让该工作区改用系统默认设置。')),
1144
1454
  onDismiss: () => setPrompt(null),
1145
1455
  actions: [
@@ -1198,7 +1508,7 @@ window.__ModuleLoader__.load({
1198
1508
  : null,
1199
1509
  error !== null
1200
1510
  ? h('div', { className: 'wsk-msg bad', style: { marginBottom: 12 } },
1201
- `读取失败:${error}`,
1511
+ tf(`读取失败:${error}`, `Failed to load: ${tr(error)}`),
1202
1512
  h('div', { className: 'wsk-hint' }, '请确认 DSH Web 服务正在运行、且本插件的宿主半边已加载。'))
1203
1513
  : null,
1204
1514
  hostIsStale
@@ -1247,14 +1557,14 @@ window.__ModuleLoader__.load({
1247
1557
  h('span', { key: 'r' }, defaultRef?.ref ?? DEFAULT_REF),
1248
1558
  defaultRef?.configured === true
1249
1559
  ? h('span', { key: 's', className: 'wsk-badge ok' },
1250
- `已配置${defaultRef.source ? ` · ${defaultRef.source}` : ''}`)
1560
+ tf(`已配置${defaultRef.source ? ` · ${defaultRef.source}` : ''}`, `Configured${defaultRef.source ? ` · ${defaultRef.source}` : ''}`))
1251
1561
  : h('span', { key: 's', className: 'wsk-badge bad' }, '未配置'),
1252
1562
  defaultRef?.writable === false
1253
1563
  ? h('span', { key: 'w', className: 'wsk-badge warn' }, '被环境变量遮蔽(只读)')
1254
1564
  : null),
1255
1565
  h('div', { className: 'wsk-hint' },
1256
1566
  '没有单独配置的工作区/会话,一律使用这把 key。',
1257
- defaultRef?.keyMasked ? ` 当前值:${defaultRef.keyMasked}` : '')),
1567
+ defaultRef?.keyMasked ? tf(` 当前值:${defaultRef.keyMasked}`, ` current value: ${defaultRef.keyMasked}`) : '')),
1258
1568
  h('div', { className: 'wsk-actions' },
1259
1569
  h('button', {
1260
1570
  type: 'button', className: 'wsk-btn',
@@ -1277,11 +1587,11 @@ window.__ModuleLoader__.load({
1277
1587
  + '工作区勾了「同步搜索 API」时改用该工作区的聊天层级;会话单独配置永远最优先。'),
1278
1588
  h('div', { className: 'wsk-hint' },
1279
1589
  searchInfo.defaultConfigured === true && searchInfo.defaultKeyMasked
1280
- ? `当前值:${searchInfo.defaultKeyMasked}。`
1590
+ ? tf(`当前值:${searchInfo.defaultKeyMasked}。`, `Current value: ${searchInfo.defaultKeyMasked}.`)
1281
1591
  : '',
1282
1592
  searchInfo.officialShared === true
1283
1593
  ? '官方「网页搜索」页现在仍指着聊天那把 key(DEEPSEEK_API_KEY)——保存一次搜索 key 后,插件会把它接过来。'
1284
- : `官方「网页搜索」页已由本插件接管:那里填的也是 ${searchInfo.officialRef ?? 'DEEPSEEK_API_KEY_SEARCH'} 这一把,不会再误改聊天 key。`),
1594
+ : tf(`官方「网页搜索」页已由本插件接管:那里填的也是 ${searchInfo.officialRef ?? 'DEEPSEEK_API_KEY_SEARCH'} 这一把,不会再误改聊天 key。`, `The official "Web Search" page is managed by this plugin: it edits ${searchInfo.officialRef ?? 'DEEPSEEK_API_KEY_SEARCH'}, so it can no longer overwrite the chat key.`)),
1285
1595
  searchInfo.officialShared === true && searchInfo.ownedConfigured === true
1286
1596
  ? h('div', { className: 'wsk-hint' },
1287
1597
  '你之前保存过搜索默认 key,但官方页还指向聊天那把。',
@@ -1331,7 +1641,7 @@ window.__ModuleLoader__.load({
1331
1641
  : null,
1332
1642
 
1333
1643
  h('div', { className: 'wsk-card', style: { background: 'transparent', border: 0, padding: 0 } },
1334
- h('h4', { style: { marginBottom: 6 } }, `工作区(${grouped.length})`),
1644
+ h('h4', { style: { marginBottom: 6 } }, tf(`工作区(${grouped.length})`, `Workspaces (${grouped.length})`)),
1335
1645
  h('div', { className: 'wsk-hint', style: { marginBottom: 6 } },
1336
1646
  '展开工作区可以看到它下面的会话,并单独给某个会话指定 key(会话 > 工作区 > 系统默认)。'),
1337
1647
  grouped.length === 0
@@ -1340,7 +1650,7 @@ window.__ModuleLoader__.load({
1340
1650
 
1341
1651
  ungrouped.length > 0
1342
1652
  ? h('div', { className: 'wsk-card' },
1343
- h('h4', null, `未分组目录(${ungrouped.length})`),
1653
+ h('h4', null, tf(`未分组目录(${ungrouped.length})`, `Ungrouped directories (${ungrouped.length})`)),
1344
1654
  h('div', { className: 'wsk-hint', style: { marginBottom: 6 } },
1345
1655
  '这些目录里的会话没有挂在工作区下(侧栏的「未分组」),同样可以单独配置。'
1346
1656
  + '只有侧栏真的会显示的目录才列在这里(刚建好、还没发过消息的空会话不算)。'),
@@ -1349,7 +1659,7 @@ window.__ModuleLoader__.load({
1349
1659
 
1350
1660
  orphans.length > 0
1351
1661
  ? h('div', { className: 'wsk-card' },
1352
- h('h4', null, `已失效的配置(${orphans.length})`),
1662
+ h('h4', null, tf(`已失效的配置(${orphans.length})`, `Stale settings (${orphans.length})`)),
1353
1663
  h('div', { className: 'wsk-hint', style: { marginBottom: 6 } },
1354
1664
  '这些目录已经不是注册的工作区了,对应的 key 仍留在凭据库里。可改用系统默认以清理。'),
1355
1665
  h('div', { className: 'wsk-list' }, orphans.map((ws) => renderWorkspace(ws))))
@@ -1357,7 +1667,7 @@ window.__ModuleLoader__.load({
1357
1667
 
1358
1668
  h('div', { className: 'wsk-hint' },
1359
1669
  '专属 key 存进 DSH 凭据库中的独立引用,因此换 key 不写进会话日志、也不影响历史回放。',
1360
- state?.statePath ? h('div', null, `状态文件:${state.statePath}`) : null,
1670
+ state?.statePath ? h('div', null, tf(`状态文件:${state.statePath}`, `State file: ${state.statePath}`)) : null,
1361
1671
  diagText() !== undefined ? h('div', { className: 'wsk-diag' }, diagText()) : null)),
1362
1672
 
1363
1673
  renderPrompt(),
@@ -1368,6 +1678,7 @@ window.__ModuleLoader__.load({
1368
1678
 
1369
1679
  function createOverlayWatcher(ctx) {
1370
1680
  return function ApiKeyWatch() {
1681
+ useI18n()
1371
1682
  const [prompt, setPrompt] = useState(null)
1372
1683
  const asked = useRef(new Set())
1373
1684
 
@@ -1489,7 +1800,7 @@ window.__ModuleLoader__.load({
1489
1800
  : '网页搜索的配置需要确认',
1490
1801
  body: h('div', null,
1491
1802
  kv('会话', prompt.title ?? String(prompt.sessionId ?? '').slice(0, 12)),
1492
- kv('模型', `${prompt.provider ?? '未知'} / ${prompt.model ?? '默认模型'}`),
1803
+ kv('模型', tf(`${prompt.provider ?? '未知'} / ${prompt.model ?? '默认模型'}`, `${prompt.provider ?? 'Unknown'} / ${prompt.model ?? 'default model'}`)),
1493
1804
  kv('生效 key', `${levelText(prompt.level)}${prompt.keyMasked ? ` · ${prompt.keyMasked}` : ''}`),
1494
1805
  invalid !== undefined
1495
1806
  ? h('div', { className: 'wsk-hint', style: { marginTop: 8, color: 'var(--wsk-bad)' } }, invalid)
@@ -1520,13 +1831,21 @@ window.__ModuleLoader__.load({
1520
1831
  }
1521
1832
 
1522
1833
  function res2Upper(prompt) {
1523
- return prompt.level === 'session' ? '清除本会话配置' : '改用系统默认'
1834
+ return prompt.level === 'session' ? tr('清除本会话配置') : tr('改用系统默认')
1524
1835
  }
1525
1836
 
1526
1837
  // ─────────────────────────────── 插件入口 ───────────────────────────────
1527
1838
 
1528
1839
  function apply(ctx) {
1529
1840
  ensureStyles()
1841
+ refreshI18n(ctx)
1842
+ const locale = ctx.get('locale')
1843
+ if (locale !== undefined && locale !== null && typeof locale.subscribe === 'function') {
1844
+ ctx.effect(() => locale.subscribe(() => {
1845
+ refreshI18n(ctx)
1846
+ notifyI18n()
1847
+ }), 'workspace-api-key: locale subscription')
1848
+ }
1530
1849
  const slots = ctx.get('slots')
1531
1850
  if (slots === undefined || slots === null) return
1532
1851
  host.ctx = ctx
@@ -1549,7 +1868,7 @@ window.__ModuleLoader__.load({
1549
1868
  name: 'sidebar.panellist',
1550
1869
  id: PANEL_ID,
1551
1870
  order: 1,
1552
- label: () => 'API Key 分配',
1871
+ label: () => tr('API Key 分配'),
1553
1872
  }, KeyIcon)), 'workspace-api-key: sidebar row')
1554
1873
 
1555
1874
  ctx.effect(() => slots.inject('main', () =>
@@ -1559,12 +1878,20 @@ window.__ModuleLoader__.load({
1559
1878
  inject: () => ({ back, sessions: ctx.get('sessions') }),
1560
1879
  }, WorkspaceKeyPage)), 'workspace-api-key: main page')
1561
1880
 
1881
+ ctx.effect(() => slots.inject('settings.section', () =>
1882
+ slots.register({
1883
+ name: 'settings.section',
1884
+ id: PANEL_ID,
1885
+ order: 60,
1886
+ label: () => tr('API Key 分配'),
1887
+ }, SettingsSection)), 'workspace-api-key: settings section')
1888
+
1562
1889
  ctx.effect(() => slots.inject('shell.overlay', () =>
1563
1890
  slots.register({
1564
1891
  name: 'shell.overlay',
1565
1892
  id: OVERLAY_ID,
1566
1893
  order: 60,
1567
- label: 'API Key 提醒',
1894
+ label: tr('API Key 提醒'),
1568
1895
  }, createOverlayWatcher(ctx))), 'workspace-api-key: session entry watcher')
1569
1896
  }
1570
1897
 
package/lib/index.js CHANGED
@@ -243,11 +243,11 @@ function workspaceIndex(ctx) {
243
243
  }
244
244
 
245
245
  /** cwd → 工作区描述;没有匹配工作区时回退成「以该目录为单位」。 */
246
- function locateWorkspace(ctx, cwd) {
246
+ function locateWorkspace(ctx, cwd, index) {
247
247
  if (cwd === undefined) return undefined
248
248
  const norm = normPath(cwd)
249
249
  if (norm === undefined) return undefined
250
- const hit = workspaceIndex(ctx).get(norm)
250
+ const hit = (index ?? workspaceIndex(ctx)).get(norm)
251
251
  if (hit !== undefined) return { ...hit, kind: 'workspace' }
252
252
  return { id: undefined, path: cwd, title: basename(cwd) || cwd, kind: 'directory' }
253
253
  }
@@ -256,7 +256,7 @@ function locateWorkspace(ctx, cwd) {
256
256
  * 给定会话/目录,算出当前生效的覆盖层级(纯函数,不读凭据):
257
257
  * session → workspace → default。
258
258
  */
259
- function effectiveOverride(ctx, state, { sessionId, cwd }) {
259
+ function effectiveOverride(ctx, state, { sessionId, cwd }, index) {
260
260
  if (sessionId !== undefined) {
261
261
  const sref = sessionRef(sessionId)
262
262
  if (sref !== undefined && state.overrides[sref] !== undefined) {
@@ -264,7 +264,9 @@ function effectiveOverride(ctx, state, { sessionId, cwd }) {
264
264
  }
265
265
  }
266
266
  if (cwd !== undefined) {
267
- const ws = locateWorkspace(ctx, cwd)
267
+ // index 是可选的「工作区索引」快照:面板一次要算几十个会话,每行都去
268
+ // registry.list() 一遍会让整个事件循环卡住(0.2.16 实测 /state 要 50 秒)。
269
+ const ws = locateWorkspace(ctx, cwd, index)
268
270
  const wref = ws !== undefined ? derivedRef(ws.path) : undefined
269
271
  if (wref !== undefined && state.overrides[wref] !== undefined) {
270
272
  return { level: 'workspace', ref: wref, entry: state.overrides[wref], workspace: ws }
@@ -390,7 +392,7 @@ async function reconcileOfficialSearchRef(ctx) {
390
392
  * 旧 Messages 通道:搜索就在用聊天 key,固定返回搜索 ref。
391
393
  */
392
394
  async function searchRefFor(ctx, state, { sessionId, cwd, web, searchDefault }) {
393
- const w = web ?? readWebSettings(ctx)
395
+ const w = web ?? currentWeb(ctx)
394
396
  if (!w.apiChannel) return w.searchKeyEnv
395
397
  const own = sessionOverrideRef(state, sessionId)
396
398
  if (own !== undefined) return own
@@ -803,8 +805,10 @@ function interceptableRefs(ctx, state) {
803
805
  // 搜索/抓取通道的 ref 也要拦:用户可能把「新版 Web 搜索 API」的 key 指到
804
806
  // 独立 ref(例如 DEEPSEEK_SEARCH_API_KEY)。不拦的话,勾了「同步搜索 API」
805
807
  // 也会静默不生效——面板显示已同步、实际还在用系统默认 key。
808
+ // 注意这里走 currentWeb(缓存)而不是 readWebSettings:本函数在 credentials.resolve
809
+ // 的热路径上被调用,每次都 settings.describe() 会把聊天通道一并拖慢。
806
810
  try {
807
- const web = readWebSettings(ctx)
811
+ const web = currentWeb(ctx)
808
812
  for (const ref of [web.apiKeyEnv, web.legacyKeyEnv]) {
809
813
  if (typeof ref === 'string' && ref !== '') refs.add(ref)
810
814
  }
@@ -1551,7 +1555,7 @@ async function readJson(req) {
1551
1555
  }
1552
1556
 
1553
1557
  /** 一个会话的展示 + 生效情况(供面板与会话进入提醒使用)。 */
1554
- async function sessionRow(ctx, state, row, fallbackCwd) {
1558
+ async function sessionRow(ctx, state, row, fallbackCwd, index) {
1555
1559
  const sessionId = typeof row?.sessionId === 'string' && row.sessionId !== '' ? row.sessionId : undefined
1556
1560
  if (sessionId === undefined) return undefined
1557
1561
  const selection = modelSelectionOf(row)
@@ -1559,7 +1563,7 @@ async function sessionRow(ctx, state, row, fallbackCwd) {
1559
1563
  const ref = sessionRef(sessionId)
1560
1564
  const entry = ref !== undefined ? state.overrides[ref] : undefined
1561
1565
  const directHit = entry !== undefined ? await resolveKey(ctx, ref) : undefined
1562
- const eff = effectiveOverride(ctx, state, { sessionId, cwd })
1566
+ const eff = effectiveOverride(ctx, state, { sessionId, cwd }, index)
1563
1567
  const effHit = eff.ref !== undefined ? await resolveKey(ctx, eff.ref) : undefined
1564
1568
  return {
1565
1569
  sessionId,
@@ -1591,14 +1595,19 @@ async function sessionRow(ctx, state, row, fallbackCwd) {
1591
1595
  }
1592
1596
  }
1593
1597
 
1594
- async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, claimed) {
1598
+ async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, claimed, env) {
1599
+ // 每个工作区行都要的一份上下文(工作区索引、官方搜索配置、搜索默认 key 是否已配)。
1600
+ // 由 snapshot() 统一算一次传进来:panel 打开一次要渲染 20+ 行,每行各算一遍
1601
+ // settings.describe() + registry.list() 会把事件循环卡住(0.2.16 实测 /state ≈ 50 s)。
1602
+ const index = env?.index
1603
+ const web = env?.web ?? currentWeb(ctx)
1595
1604
  const ref = derivedRef(dir)
1596
1605
  const configured = ref !== undefined && state.overrides[ref] !== undefined
1597
1606
  const desc = configured ? await describeKey(ctx, ref) : undefined
1598
1607
  const hit = configured ? await resolveKey(ctx, ref) : undefined
1599
1608
  const sessions = []
1600
1609
  for (const row of rows) {
1601
- const item = await sessionRow(ctx, state, row, dir)
1610
+ const item = await sessionRow(ctx, state, row, dir, index)
1602
1611
  if (item !== undefined) sessions.push(item)
1603
1612
  }
1604
1613
  sessions.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
@@ -1606,9 +1615,9 @@ async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, cl
1606
1615
  // 搜索通道:只有新版 Search/Fetch API 才有「同步 / 不同步」的区别(旧 Messages
1607
1616
  // 通道本来就用聊天 key)。这里给出「该工作区的搜索请求实际会用哪把 key」,
1608
1617
  // 让用户勾了复选框之后马上能看到效果,而不用等到搜索失败才发现。
1609
- const web = readWebSettings(ctx)
1610
1618
  const synced = state.searchSync?.[normPath(dir)] === true
1611
- const searchDefaultConfigured = web.apiChannel && (await describeKey(ctx, searchDefaultRefOf(web))).configured === true
1619
+ const searchDefaultConfigured = env?.searchDefaultConfigured
1620
+ ?? (web.apiChannel && (await describeKey(ctx, searchDefaultRefOf(web))).configured === true)
1612
1621
  const searchKeyRef = web.apiChannel
1613
1622
  ? synced
1614
1623
  ? configured
@@ -1655,13 +1664,21 @@ function hasVisibleSession(rows) {
1655
1664
  }
1656
1665
 
1657
1666
  async function snapshot(ctx, state) {
1667
+ const startedAt = Date.now()
1658
1668
  // 官方「网页搜索」配置:每次面板刷新都重读一次(缓存只在请求入口更新,解析路径只读)。
1659
- refreshWebCache(ctx)
1669
+ // 整个 snapshot 只读这一次:下面每行都用 currentWeb(ctx) 拿缓存,不再各自 describe。
1670
+ const web = refreshWebCache(ctx)
1671
+ // 工作区索引也一样:一次 snapshot 只调一次 registry.list(),行渲染复用同一份快照。
1672
+ const index = workspaceIndex(ctx)
1660
1673
  const credentials = ctx.get('credentials')
1661
1674
  const defaultRefName = 'DEEPSEEK_API_KEY'
1662
1675
  const defaultInfo = await describeKey(ctx, defaultRefName)
1663
1676
  const defaultHit = await resolveKey(ctx, defaultRefName)
1664
1677
  const probe = await sessionProbe(ctx)
1678
+ const probeMs = Date.now() - startedAt
1679
+ const searchDefaultConfigured = web.apiChannel
1680
+ && (await describeKey(ctx, searchDefaultRefOf(web))).configured === true
1681
+ const rowEnv = { index, web, searchDefaultConfigured }
1665
1682
  const rows = probe.rows
1666
1683
  const byCwd = new Map()
1667
1684
  for (const row of rows) {
@@ -1685,7 +1702,7 @@ async function snapshot(ctx, state) {
1685
1702
  }
1686
1703
  if (typeof dir !== 'string' || dir === '') continue
1687
1704
  const norm = normPath(dir)
1688
- workspaces.push(await workspaceRow(ctx, state, { id, path: dir, title, kind: 'workspace' }, byCwd.get(norm) ?? [], claimed))
1705
+ workspaces.push(await workspaceRow(ctx, state, { id, path: dir, title, kind: 'workspace' }, byCwd.get(norm) ?? [], claimed, rowEnv))
1689
1706
  }
1690
1707
 
1691
1708
  // 有覆盖记录、但对应工作区已不在注册表里的(被删掉的工作区)也列出来,方便清理。
@@ -1725,7 +1742,7 @@ async function snapshot(ctx, state) {
1725
1742
  const dir = typeof list[0]?.cwd === 'string' ? list[0].cwd : norm
1726
1743
  // 诊断用:这个目录里一个「真实会话」都没有(界面把它藏起来,但用户能看到原因)。
1727
1744
  if (!hasVisibleSession(list)) hiddenBlankDirs.push(dir)
1728
- ungrouped.push(await workspaceRow(ctx, state, { id: undefined, path: dir, title: basename(dir) || dir, kind: 'directory' }, list))
1745
+ ungrouped.push(await workspaceRow(ctx, state, { id: undefined, path: dir, title: basename(dir) || dir, kind: 'directory' }, list, undefined, rowEnv))
1729
1746
  }
1730
1747
 
1731
1748
  // 会话级覆盖总览(含已不在列表里的会话,便于清理)。
@@ -1751,8 +1768,7 @@ async function snapshot(ctx, state) {
1751
1768
  }
1752
1769
 
1753
1770
  // ── 搜索 / 抓取通道总览 ──────────────────────────────────────────────
1754
- // 每次面板刷新都重读一次:开关改过之后,缓存必须跟着变。
1755
- const web = refreshWebCache(ctx)
1771
+ // 上面的 refreshWebCache() 已经把官方配置读到 web 里了,这里直接复用。
1756
1772
  const searchKeyRef = web.searchKeyEnv
1757
1773
  const searchKeyInfo = await describeKey(ctx, searchKeyRef)
1758
1774
  const searchKeyHit = await resolveKey(ctx, searchKeyRef)
@@ -1763,10 +1779,13 @@ async function snapshot(ctx, state) {
1763
1779
  const syncPaths = Object.keys(state.searchSync ?? {}).filter((key) => state.searchSync[key] === true)
1764
1780
  const warnings = await searchWarnings(ctx, state, web)
1765
1781
 
1782
+ const totalMs = Date.now() - startedAt
1766
1783
  return {
1767
1784
  ok: true,
1768
1785
  pluginVersion: PLUGIN_VERSION,
1769
1786
  storeVersion: STATE_VERSION,
1787
+ // 面板卡顿排查用:会话探针(宿主 list 一次要扫所有会话)与整行的耗时。
1788
+ timings: { probeMs, rowsMs: Math.max(0, totalMs - probeMs), totalMs },
1770
1789
  sessionProbe: {
1771
1790
  available: probe.available,
1772
1791
  count: probe.count,
@@ -1788,7 +1807,7 @@ async function snapshot(ctx, state) {
1788
1807
  },
1789
1808
  providers: providerList(ctx),
1790
1809
  interceptableRefs: [...interceptableRefs(ctx, state)],
1791
- workspaceCount: workspaceIndex(ctx).size,
1810
+ workspaceCount: index.size,
1792
1811
  credentialsAvailable: credentials !== undefined,
1793
1812
  sessionListAvailable: probe.available,
1794
1813
  sessionControllerAvailable: sessionListAvailable(ctx),
@@ -1941,7 +1960,8 @@ async function checkSession(ctx, state, params) {
1941
1960
 
1942
1961
  /** 进会话提醒用的搜索通道摘要(不含明文 key)。 */
1943
1962
  async function searchStateOf(ctx, state, { sessionId, cwd }) {
1944
- const web = readWebSettings(ctx)
1963
+ // 面板/会话入口不是热路径,但也没必要每次 describe:缓存由请求入口与设置事件维护。
1964
+ const web = currentWeb(ctx)
1945
1965
  const sync = workspaceSynced(state, cwd)
1946
1966
  if (!web.apiChannel) {
1947
1967
  const idle = web.apiLiteralKey !== undefined
@@ -2029,7 +2049,7 @@ async function handle(ctx, state, req, res) {
2029
2049
  // 于是面板和官方页改的是同一把 key,不会各存一份。官方还指向聊天那把(DEEPSEEK_API_KEY)时,
2030
2050
  // 先把两套搜索插件的 apiKeyEnv 改到插件自有 ref,再写值。
2031
2051
  if (body.scope === 'search-default') {
2032
- const web = readWebSettings(ctx)
2052
+ const web = refreshWebCache(ctx)
2033
2053
  const official = officialSearchRef(web)
2034
2054
  const needsBind = official === DEFAULT_API_KEY_REF
2035
2055
  const target = needsBind ? SEARCH_DEFAULT_REF : official
@@ -2089,7 +2109,7 @@ async function handle(ctx, state, req, res) {
2089
2109
 
2090
2110
  if (pathname === `${API_BASE}/test` && method === 'POST') {
2091
2111
  const body = await readJson(req)
2092
- const web = readWebSettings(ctx)
2112
+ const web = refreshWebCache(ctx)
2093
2113
  const channel = body.channel === 'search' ? 'search' : 'chat'
2094
2114
  let key = typeof body.key === 'string' && body.key.trim() !== '' ? body.key.trim() : undefined
2095
2115
  let sourceRef = 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.16",
3
+ "version": "0.2.17",
4
4
  "description": "A DeepSeek Harness plugin that binds a separate DeepSeek API key to each workspace, or to a single session, so the spend of one key stays with the sessions you choose.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,6 +50,6 @@
50
50
  "lib/client.js"
51
51
  ],
52
52
  "scripts": {
53
- "test": "node test/host.test.mjs && node test/client.test.mjs"
53
+ "test": "node test/host.test.mjs && node test/client.test.mjs && node test/client-i18n.test.mjs"
54
54
  }
55
55
  }