dsh-workspace-api-key 0.2.16 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -32,6 +32,7 @@
32
32
 
33
33
  import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
34
34
  import { createHash } from 'node:crypto'
35
+ import { createRequire } from 'node:module'
35
36
  import { homedir } from 'node:os'
36
37
  import { join, dirname, basename, resolve as pathResolve } from 'node:path'
37
38
 
@@ -116,6 +117,11 @@ function isSearchRefFor(ctx, ref) {
116
117
  if (typeof ref !== 'string' || ref === '') return false
117
118
  const web = currentWeb(ctx)
118
119
  if (web.apiChannel !== true) return false
120
+ // 新版计费模型:搜索只认 `searchApiKeyEnv` 指向的自有 ref;聊天 ref(DEEPSEEK_API_KEY)
121
+ // 绝不能被当成搜索处理,否则「统一搜索 key」会把聊天也一起换掉。
122
+ if (web.mode === 'billing') {
123
+ return ref === SEARCH_DEFAULT_REF || (web.ownSearchRef !== undefined && ref === web.ownSearchRef)
124
+ }
119
125
  // 官方页还没被接管时,搜索用的是聊天那把 ref:这时分辨不了,按聊天处理(安全)。
120
126
  if (web.searchKeyEnv === DEFAULT_API_KEY_REF) return ref === SEARCH_DEFAULT_REF
121
127
  return ref === SEARCH_DEFAULT_REF || ref === web.searchKeyEnv || ref === web.legacyKeyEnv
@@ -142,6 +148,39 @@ const PLUGIN_VERSION = (() => {
142
148
  }
143
149
  })()
144
150
 
151
+ /**
152
+ * 宿主运行时版本(尽力而为,只用于界面展示与日志)。
153
+ *
154
+ * 行为判断**不**依赖它:桌面端宿主代码打在 app.asar 里,`@deepseek-ai/dsh`
155
+ * 不一定能从插件目录解析到;而新版搜索配置的 schema 特征(searchBilling /
156
+ * fetchBilling)与行为一一对应,所以 gating 用特性检测,版本号只是辅助信息。
157
+ */
158
+ const HOST_VERSION = (() => {
159
+ try {
160
+ const req = createRequire(import.meta.url)
161
+ let main
162
+ try {
163
+ main = req.resolve('@deepseek-ai/dsh')
164
+ } catch {
165
+ main = undefined
166
+ }
167
+ if (main === undefined) return undefined
168
+ // 从入口文件往上找最近的 package.json。
169
+ let dir = dirname(main)
170
+ for (let depth = 0; depth < 5; depth += 1) {
171
+ const candidate = join(dir, 'package.json')
172
+ if (existsSync(candidate)) {
173
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'))
174
+ if (typeof pkg?.version === 'string') return pkg.version
175
+ }
176
+ dir = dirname(dir)
177
+ }
178
+ } catch {
179
+ /* ignore */
180
+ }
181
+ return undefined
182
+ })()
183
+
145
184
  // ─────────────────────────── 路径 / 工作区键 ───────────────────────────
146
185
 
147
186
  /** 统一的路径比较用规范形:绝对化 + 分隔符统一 + Windows 折大小写。 */
@@ -243,11 +282,11 @@ function workspaceIndex(ctx) {
243
282
  }
244
283
 
245
284
  /** cwd → 工作区描述;没有匹配工作区时回退成「以该目录为单位」。 */
246
- function locateWorkspace(ctx, cwd) {
285
+ function locateWorkspace(ctx, cwd, index) {
247
286
  if (cwd === undefined) return undefined
248
287
  const norm = normPath(cwd)
249
288
  if (norm === undefined) return undefined
250
- const hit = workspaceIndex(ctx).get(norm)
289
+ const hit = (index ?? workspaceIndex(ctx)).get(norm)
251
290
  if (hit !== undefined) return { ...hit, kind: 'workspace' }
252
291
  return { id: undefined, path: cwd, title: basename(cwd) || cwd, kind: 'directory' }
253
292
  }
@@ -256,7 +295,7 @@ function locateWorkspace(ctx, cwd) {
256
295
  * 给定会话/目录,算出当前生效的覆盖层级(纯函数,不读凭据):
257
296
  * session → workspace → default。
258
297
  */
259
- function effectiveOverride(ctx, state, { sessionId, cwd }) {
298
+ function effectiveOverride(ctx, state, { sessionId, cwd }, index) {
260
299
  if (sessionId !== undefined) {
261
300
  const sref = sessionRef(sessionId)
262
301
  if (sref !== undefined && state.overrides[sref] !== undefined) {
@@ -264,7 +303,9 @@ function effectiveOverride(ctx, state, { sessionId, cwd }) {
264
303
  }
265
304
  }
266
305
  if (cwd !== undefined) {
267
- const ws = locateWorkspace(ctx, cwd)
306
+ // index 是可选的「工作区索引」快照:面板一次要算几十个会话,每行都去
307
+ // registry.list() 一遍会让整个事件循环卡住(0.2.16 实测 /state 要 50 秒)。
308
+ const ws = locateWorkspace(ctx, cwd, index)
268
309
  const wref = ws !== undefined ? derivedRef(ws.path) : undefined
269
310
  if (wref !== undefined && state.overrides[wref] !== undefined) {
270
311
  return { level: 'workspace', ref: wref, entry: state.overrides[wref], workspace: ws }
@@ -352,6 +393,32 @@ async function bindOfficialSearchRef(ctx, ref) {
352
393
  return { ok: true, changed }
353
394
  }
354
395
 
396
+ /**
397
+ * 写新版搜索 API 的 schema 字段(实验性「统一搜索 key」用)。
398
+ *
399
+ * 只改 `searchApiKeyEnv` / `fetchApiKeyEnv` 这类声明过的字段,不碰任何 key 值;
400
+ * 空字符串表示「清掉自有 ref、回到官方计费方式」(官方 resolveTarget 用 nonEmpty 判断)。
401
+ */
402
+ async function updateSearchApiSettings(ctx, patch) {
403
+ let settings
404
+ try {
405
+ settings = ctx.get('settings')
406
+ } catch {
407
+ settings = undefined
408
+ }
409
+ if (typeof settings?.update !== 'function' || typeof settings?.describe !== 'function') {
410
+ return { ok: false, reason: 'settings-unavailable' }
411
+ }
412
+ try {
413
+ const row = (settings.describe() ?? []).find((item) => item?.ns === SEARCH_API_NS)
414
+ if (row === undefined) return { ok: false, reason: 'search-settings-missing' }
415
+ await settings.update(SEARCH_API_NS, patch, row.revision)
416
+ return { ok: true }
417
+ } catch (error) {
418
+ return { ok: false, reason: error?.message ?? String(error) }
419
+ }
420
+ }
421
+
355
422
  /**
356
423
  * 启动时把官方「网页搜索」页的 key 指向拉回一致。
357
424
  *
@@ -390,7 +457,7 @@ async function reconcileOfficialSearchRef(ctx) {
390
457
  * 旧 Messages 通道:搜索就在用聊天 key,固定返回搜索 ref。
391
458
  */
392
459
  async function searchRefFor(ctx, state, { sessionId, cwd, web, searchDefault }) {
393
- const w = web ?? readWebSettings(ctx)
460
+ const w = web ?? currentWeb(ctx)
394
461
  if (!w.apiChannel) return w.searchKeyEnv
395
462
  const own = sessionOverrideRef(state, sessionId)
396
463
  if (own !== undefined) return own
@@ -803,8 +870,10 @@ function interceptableRefs(ctx, state) {
803
870
  // 搜索/抓取通道的 ref 也要拦:用户可能把「新版 Web 搜索 API」的 key 指到
804
871
  // 独立 ref(例如 DEEPSEEK_SEARCH_API_KEY)。不拦的话,勾了「同步搜索 API」
805
872
  // 也会静默不生效——面板显示已同步、实际还在用系统默认 key。
873
+ // 注意这里走 currentWeb(缓存)而不是 readWebSettings:本函数在 credentials.resolve
874
+ // 的热路径上被调用,每次都 settings.describe() 会把聊天通道一并拖慢。
806
875
  try {
807
- const web = readWebSettings(ctx)
876
+ const web = currentWeb(ctx)
808
877
  for (const ref of [web.apiKeyEnv, web.legacyKeyEnv]) {
809
878
  if (typeof ref === 'string' && ref !== '') refs.add(ref)
810
879
  }
@@ -875,6 +944,14 @@ function webDefaults() {
875
944
  apiChannel: false,
876
945
  searchKeyEnv: DEFAULT_API_KEY_REF,
877
946
  searchLiteralKey: undefined,
947
+ // 搜索配置模型:'ref' = 旧版(apiKeyEnv 指针);'billing' = 0.2.2-rc.2 起的计费方式模型。
948
+ mode: 'ref',
949
+ searchBilling: undefined,
950
+ fetchBilling: undefined,
951
+ ownSearchRef: undefined,
952
+ ownFetchRef: undefined,
953
+ searchBaseURL: undefined,
954
+ fetchBaseURL: undefined,
878
955
  }
879
956
  }
880
957
 
@@ -899,6 +976,27 @@ function webConfigOf(rows) {
899
976
  : FALLBACK_FETCH_PROVIDER
900
977
  const apiLiteralKey = typeof api.apiKey === 'string' && api.apiKey !== '' ? api.apiKey : undefined
901
978
  const legacyLiteralKey = typeof legacy.apiKey === 'string' && legacy.apiKey !== '' ? legacy.apiKey : undefined
979
+
980
+ // 0.2.2-rc.2 起,搜索改由「计费方式」决定凭据:searchBilling / fetchBilling +
981
+ // (可选的)自有端点/ref。旧字段 apiKeyEnv 还能加载,但已经不再生效。
982
+ // 用「行里有没有 *_Billing」做特性检测,而不是解析宿主版本号:版本号在桌面端
983
+ // 拿不到(宿主代码打在 asar 里),而这个 schema 特征与行为是一一对应的。
984
+ const searchBilling = typeof api.searchBilling === 'string' && api.searchBilling !== '' ? api.searchBilling : undefined
985
+ const fetchBilling = typeof api.fetchBilling === 'string' && api.fetchBilling !== '' ? api.fetchBilling : undefined
986
+ const mode = searchBilling !== undefined || fetchBilling !== undefined ? 'billing' : 'ref'
987
+ const ownSearchRef = typeof api.searchApiKeyEnv === 'string' && api.searchApiKeyEnv !== ''
988
+ ? api.searchApiKeyEnv
989
+ : undefined
990
+ const ownFetchRef = typeof api.fetchApiKeyEnv === 'string' && api.fetchApiKeyEnv !== ''
991
+ ? api.fetchApiKeyEnv
992
+ : undefined
993
+ const searchBaseURL = typeof api.searchBaseURL === 'string' && api.searchBaseURL !== ''
994
+ ? api.searchBaseURL
995
+ : FALLBACK_SEARCH_BASE_URL
996
+ const fetchBaseURL = typeof api.fetchBaseURL === 'string' && api.fetchBaseURL !== ''
997
+ ? api.fetchBaseURL
998
+ : FALLBACK_SEARCH_BASE_URL
999
+
902
1000
  return {
903
1001
  apiKeyEnv,
904
1002
  legacyKeyEnv,
@@ -911,8 +1009,17 @@ function webConfigOf(rows) {
911
1009
  /** 新版 Search/Fetch API 是否被选中(官方开关)= provider 选择被改成了 API 版。 */
912
1010
  apiChannel: searchProvider === SEARCH_PROVIDER_ID || fetchProvider === SEARCH_FETCH_PROVIDER_ID,
913
1011
  /** 搜索请求真正会用的那套配置的 key 引用。 */
914
- searchKeyEnv: searchProvider === SEARCH_PROVIDER_ID ? apiKeyEnv : legacyKeyEnv,
1012
+ searchKeyEnv: searchProvider === SEARCH_PROVIDER_ID
1013
+ ? (mode === 'billing' ? ownSearchRef ?? DEFAULT_API_KEY_REF : apiKeyEnv)
1014
+ : legacyKeyEnv,
915
1015
  searchLiteralKey: searchProvider === SEARCH_PROVIDER_ID ? apiLiteralKey : legacyLiteralKey,
1016
+ mode,
1017
+ searchBilling,
1018
+ fetchBilling,
1019
+ ownSearchRef,
1020
+ ownFetchRef,
1021
+ searchBaseURL,
1022
+ fetchBaseURL,
916
1023
  }
917
1024
  }
918
1025
 
@@ -923,6 +1030,30 @@ function webConfigOf(rows) {
923
1030
  */
924
1031
  async function searchWarnings(ctx, state, web) {
925
1032
  const out = []
1033
+
1034
+ // 新版计费模型:搜索不再读 apiKeyEnv 指针,旧提醒(同步/搜索默认 key)全部失去意义。
1035
+ // 这里只保留两条与新版真正相关的提示,避免面板给出过时或错误的引导。
1036
+ if (web.mode === 'billing') {
1037
+ if (web.ownSearchRef === SEARCH_DEFAULT_REF) {
1038
+ out.push({
1039
+ code: 'search-unified',
1040
+ level: 'info',
1041
+ title: '实验性:所有会话的搜索共用同一把 key',
1042
+ detail: `搜索/获取被指向插件自有的 ${SEARCH_DEFAULT_REF},官方「计费方式」暂时不生效。`
1043
+ + '要恢复按会话/工作区计费,到「设置 → API Key 分配」里关掉这项实验功能。',
1044
+ })
1045
+ } else if (web.searchBilling === 'account') {
1046
+ out.push({
1047
+ code: 'search-billing-account',
1048
+ level: 'warn',
1049
+ title: '搜索当前按「已登录的 DeepSeek 账号」计费',
1050
+ detail: '计费方式设为 account 时,搜索只使用账号令牌,本插件配置的 API key 不会参与搜索。'
1051
+ + '要让搜索跟随会话/工作区的 key,请到「设置 → 插件 → 网页搜索」把计费方式改成「DeepSeek API Key」。',
1052
+ })
1053
+ }
1054
+ return out
1055
+ }
1056
+
926
1057
  const apiKeyConfigured = web.apiKeyEnv === DEFAULT_API_KEY_REF
927
1058
  ? (await describeKey(ctx, DEFAULT_API_KEY_REF)).configured === true
928
1059
  : (await describeKey(ctx, web.apiKeyEnv)).configured === true
@@ -1317,7 +1448,9 @@ export function apply(ctx) {
1317
1448
  }
1318
1449
  installResolver(ctx, state)
1319
1450
  // 「直接禁用官方页面里的搜索 key 设置」:把官方 apiKeyEnv 指到插件自有 ref(异步、失败不影响启动)。
1320
- void reconcileOfficialSearchRef(ctx)
1451
+ // 0.2.2-rc.2 起官方改用计费方式(searchBilling/fetchBilling),apiKeyEnv 已不再生效:
1452
+ // 这时再写它只会制造「看起来配了、其实没用」的无效配置,所以只在旧模型下接管。
1453
+ if (currentWeb(ctx).mode !== 'billing') void reconcileOfficialSearchRef(ctx)
1321
1454
 
1322
1455
  // ── 2. REST API(loopback 可信)──────────────────────────────────────
1323
1456
  let webServer
@@ -1400,6 +1533,15 @@ function installResolver(ctx, state) {
1400
1533
  if (isSearchRefFor(ctx, ref)) {
1401
1534
  const web = currentWeb(ctx)
1402
1535
  if (web.apiChannel) {
1536
+ // 实验性「统一搜索 key」:搜索/获取固定用插件自有的那一把,优先于会话/工作区配置。
1537
+ // 只在计费模型下生效:旧模型里 searchApiKeyEnv 就是「搜索默认 key」,语义不同。
1538
+ if (web.mode === 'billing' && web.ownSearchRef === SEARCH_DEFAULT_REF) {
1539
+ const forced = await resolveKey(ctx, SEARCH_DEFAULT_REF)
1540
+ if (forced.value !== undefined && forced.value !== '') {
1541
+ log(`search ref ${ref} → ${SEARCH_DEFAULT_REF} (unified)`)
1542
+ return { value: forced.value, source: forced.source ?? 'plugin' }
1543
+ }
1544
+ }
1403
1545
  // ① 会话单独设置的 key 永远优先——哪怕官方搜索插件里配了别的 key。
1404
1546
  const sessionKey = sessionOverrideRef(state, sessionId)
1405
1547
  if (sessionKey !== undefined) {
@@ -1551,7 +1693,7 @@ async function readJson(req) {
1551
1693
  }
1552
1694
 
1553
1695
  /** 一个会话的展示 + 生效情况(供面板与会话进入提醒使用)。 */
1554
- async function sessionRow(ctx, state, row, fallbackCwd) {
1696
+ async function sessionRow(ctx, state, row, fallbackCwd, index) {
1555
1697
  const sessionId = typeof row?.sessionId === 'string' && row.sessionId !== '' ? row.sessionId : undefined
1556
1698
  if (sessionId === undefined) return undefined
1557
1699
  const selection = modelSelectionOf(row)
@@ -1559,7 +1701,7 @@ async function sessionRow(ctx, state, row, fallbackCwd) {
1559
1701
  const ref = sessionRef(sessionId)
1560
1702
  const entry = ref !== undefined ? state.overrides[ref] : undefined
1561
1703
  const directHit = entry !== undefined ? await resolveKey(ctx, ref) : undefined
1562
- const eff = effectiveOverride(ctx, state, { sessionId, cwd })
1704
+ const eff = effectiveOverride(ctx, state, { sessionId, cwd }, index)
1563
1705
  const effHit = eff.ref !== undefined ? await resolveKey(ctx, eff.ref) : undefined
1564
1706
  return {
1565
1707
  sessionId,
@@ -1591,14 +1733,19 @@ async function sessionRow(ctx, state, row, fallbackCwd) {
1591
1733
  }
1592
1734
  }
1593
1735
 
1594
- async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, claimed) {
1736
+ async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, claimed, env) {
1737
+ // 每个工作区行都要的一份上下文(工作区索引、官方搜索配置、搜索默认 key 是否已配)。
1738
+ // 由 snapshot() 统一算一次传进来:panel 打开一次要渲染 20+ 行,每行各算一遍
1739
+ // settings.describe() + registry.list() 会把事件循环卡住(0.2.16 实测 /state ≈ 50 s)。
1740
+ const index = env?.index
1741
+ const web = env?.web ?? currentWeb(ctx)
1595
1742
  const ref = derivedRef(dir)
1596
1743
  const configured = ref !== undefined && state.overrides[ref] !== undefined
1597
1744
  const desc = configured ? await describeKey(ctx, ref) : undefined
1598
1745
  const hit = configured ? await resolveKey(ctx, ref) : undefined
1599
1746
  const sessions = []
1600
1747
  for (const row of rows) {
1601
- const item = await sessionRow(ctx, state, row, dir)
1748
+ const item = await sessionRow(ctx, state, row, dir, index)
1602
1749
  if (item !== undefined) sessions.push(item)
1603
1750
  }
1604
1751
  sessions.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
@@ -1606,9 +1753,9 @@ async function workspaceRow(ctx, state, { id, path: dir, title, kind }, rows, cl
1606
1753
  // 搜索通道:只有新版 Search/Fetch API 才有「同步 / 不同步」的区别(旧 Messages
1607
1754
  // 通道本来就用聊天 key)。这里给出「该工作区的搜索请求实际会用哪把 key」,
1608
1755
  // 让用户勾了复选框之后马上能看到效果,而不用等到搜索失败才发现。
1609
- const web = readWebSettings(ctx)
1610
1756
  const synced = state.searchSync?.[normPath(dir)] === true
1611
- const searchDefaultConfigured = web.apiChannel && (await describeKey(ctx, searchDefaultRefOf(web))).configured === true
1757
+ const searchDefaultConfigured = env?.searchDefaultConfigured
1758
+ ?? (web.apiChannel && (await describeKey(ctx, searchDefaultRefOf(web))).configured === true)
1612
1759
  const searchKeyRef = web.apiChannel
1613
1760
  ? synced
1614
1761
  ? configured
@@ -1655,13 +1802,21 @@ function hasVisibleSession(rows) {
1655
1802
  }
1656
1803
 
1657
1804
  async function snapshot(ctx, state) {
1805
+ const startedAt = Date.now()
1658
1806
  // 官方「网页搜索」配置:每次面板刷新都重读一次(缓存只在请求入口更新,解析路径只读)。
1659
- refreshWebCache(ctx)
1807
+ // 整个 snapshot 只读这一次:下面每行都用 currentWeb(ctx) 拿缓存,不再各自 describe。
1808
+ const web = refreshWebCache(ctx)
1809
+ // 工作区索引也一样:一次 snapshot 只调一次 registry.list(),行渲染复用同一份快照。
1810
+ const index = workspaceIndex(ctx)
1660
1811
  const credentials = ctx.get('credentials')
1661
1812
  const defaultRefName = 'DEEPSEEK_API_KEY'
1662
1813
  const defaultInfo = await describeKey(ctx, defaultRefName)
1663
1814
  const defaultHit = await resolveKey(ctx, defaultRefName)
1664
1815
  const probe = await sessionProbe(ctx)
1816
+ const probeMs = Date.now() - startedAt
1817
+ const searchDefaultConfigured = web.apiChannel
1818
+ && (await describeKey(ctx, searchDefaultRefOf(web))).configured === true
1819
+ const rowEnv = { index, web, searchDefaultConfigured }
1665
1820
  const rows = probe.rows
1666
1821
  const byCwd = new Map()
1667
1822
  for (const row of rows) {
@@ -1685,7 +1840,7 @@ async function snapshot(ctx, state) {
1685
1840
  }
1686
1841
  if (typeof dir !== 'string' || dir === '') continue
1687
1842
  const norm = normPath(dir)
1688
- workspaces.push(await workspaceRow(ctx, state, { id, path: dir, title, kind: 'workspace' }, byCwd.get(norm) ?? [], claimed))
1843
+ workspaces.push(await workspaceRow(ctx, state, { id, path: dir, title, kind: 'workspace' }, byCwd.get(norm) ?? [], claimed, rowEnv))
1689
1844
  }
1690
1845
 
1691
1846
  // 有覆盖记录、但对应工作区已不在注册表里的(被删掉的工作区)也列出来,方便清理。
@@ -1725,7 +1880,7 @@ async function snapshot(ctx, state) {
1725
1880
  const dir = typeof list[0]?.cwd === 'string' ? list[0].cwd : norm
1726
1881
  // 诊断用:这个目录里一个「真实会话」都没有(界面把它藏起来,但用户能看到原因)。
1727
1882
  if (!hasVisibleSession(list)) hiddenBlankDirs.push(dir)
1728
- ungrouped.push(await workspaceRow(ctx, state, { id: undefined, path: dir, title: basename(dir) || dir, kind: 'directory' }, list))
1883
+ ungrouped.push(await workspaceRow(ctx, state, { id: undefined, path: dir, title: basename(dir) || dir, kind: 'directory' }, list, undefined, rowEnv))
1729
1884
  }
1730
1885
 
1731
1886
  // 会话级覆盖总览(含已不在列表里的会话,便于清理)。
@@ -1751,8 +1906,7 @@ async function snapshot(ctx, state) {
1751
1906
  }
1752
1907
 
1753
1908
  // ── 搜索 / 抓取通道总览 ──────────────────────────────────────────────
1754
- // 每次面板刷新都重读一次:开关改过之后,缓存必须跟着变。
1755
- const web = refreshWebCache(ctx)
1909
+ // 上面的 refreshWebCache() 已经把官方配置读到 web 里了,这里直接复用。
1756
1910
  const searchKeyRef = web.searchKeyEnv
1757
1911
  const searchKeyInfo = await describeKey(ctx, searchKeyRef)
1758
1912
  const searchKeyHit = await resolveKey(ctx, searchKeyRef)
@@ -1763,10 +1917,14 @@ async function snapshot(ctx, state) {
1763
1917
  const syncPaths = Object.keys(state.searchSync ?? {}).filter((key) => state.searchSync[key] === true)
1764
1918
  const warnings = await searchWarnings(ctx, state, web)
1765
1919
 
1920
+ const totalMs = Date.now() - startedAt
1766
1921
  return {
1767
1922
  ok: true,
1768
1923
  pluginVersion: PLUGIN_VERSION,
1924
+ hostVersion: HOST_VERSION,
1769
1925
  storeVersion: STATE_VERSION,
1926
+ // 面板卡顿排查用:会话探针(宿主 list 一次要扫所有会话)与整行的耗时。
1927
+ timings: { probeMs, rowsMs: Math.max(0, totalMs - probeMs), totalMs },
1770
1928
  sessionProbe: {
1771
1929
  available: probe.available,
1772
1930
  count: probe.count,
@@ -1788,7 +1946,7 @@ async function snapshot(ctx, state) {
1788
1946
  },
1789
1947
  providers: providerList(ctx),
1790
1948
  interceptableRefs: [...interceptableRefs(ctx, state)],
1791
- workspaceCount: workspaceIndex(ctx).size,
1949
+ workspaceCount: index.size,
1792
1950
  credentialsAvailable: credentials !== undefined,
1793
1951
  sessionListAvailable: probe.available,
1794
1952
  sessionControllerAvailable: sessionListAvailable(ctx),
@@ -1802,6 +1960,13 @@ async function snapshot(ctx, state) {
1802
1960
  // 搜索 / 抓取通道:官方「设置 → 插件 → 网页搜索」里的配置 + 本插件怎么参与。
1803
1961
  // 面板据此显示通道、生效 key 与提醒;不暴露任何明文。
1804
1962
  search: {
1963
+ // 'billing' = 0.2.2-rc.2 起的计费方式模型;'ref' = 旧版 apiKeyEnv 指针模型。
1964
+ mode: web.mode ?? 'ref',
1965
+ billing: { search: web.searchBilling, fetch: web.fetchBilling },
1966
+ ownSearchRef: web.ownSearchRef,
1967
+ ownFetchRef: web.ownFetchRef,
1968
+ // 实验性「统一搜索 key」:把官方 searchApiKeyEnv 指向插件自有 ref。
1969
+ unified: web.mode === 'billing' && web.searchKeyEnv === SEARCH_DEFAULT_REF,
1805
1970
  channel: web.apiChannel ? 'api' : 'messages',
1806
1971
  apiChannel: web.apiChannel,
1807
1972
  searchProvider: web.searchProvider,
@@ -1941,7 +2106,8 @@ async function checkSession(ctx, state, params) {
1941
2106
 
1942
2107
  /** 进会话提醒用的搜索通道摘要(不含明文 key)。 */
1943
2108
  async function searchStateOf(ctx, state, { sessionId, cwd }) {
1944
- const web = readWebSettings(ctx)
2109
+ // 面板/会话入口不是热路径,但也没必要每次 describe:缓存由请求入口与设置事件维护。
2110
+ const web = currentWeb(ctx)
1945
2111
  const sync = workspaceSynced(state, cwd)
1946
2112
  if (!web.apiChannel) {
1947
2113
  const idle = web.apiLiteralKey !== undefined
@@ -2029,7 +2195,7 @@ async function handle(ctx, state, req, res) {
2029
2195
  // 于是面板和官方页改的是同一把 key,不会各存一份。官方还指向聊天那把(DEEPSEEK_API_KEY)时,
2030
2196
  // 先把两套搜索插件的 apiKeyEnv 改到插件自有 ref,再写值。
2031
2197
  if (body.scope === 'search-default') {
2032
- const web = readWebSettings(ctx)
2198
+ const web = refreshWebCache(ctx)
2033
2199
  const official = officialSearchRef(web)
2034
2200
  const needsBind = official === DEFAULT_API_KEY_REF
2035
2201
  const target = needsBind ? SEARCH_DEFAULT_REF : official
@@ -2089,7 +2255,7 @@ async function handle(ctx, state, req, res) {
2089
2255
 
2090
2256
  if (pathname === `${API_BASE}/test` && method === 'POST') {
2091
2257
  const body = await readJson(req)
2092
- const web = readWebSettings(ctx)
2258
+ const web = refreshWebCache(ctx)
2093
2259
  const channel = body.channel === 'search' ? 'search' : 'chat'
2094
2260
  let key = typeof body.key === 'string' && body.key.trim() !== '' ? body.key.trim() : undefined
2095
2261
  let sourceRef = typeof body.ref === 'string' && body.ref !== '' ? body.ref : undefined
@@ -2148,6 +2314,43 @@ async function handle(ctx, state, req, res) {
2148
2314
  return
2149
2315
  }
2150
2316
 
2317
+ if (pathname === `${API_BASE}/search-unified` && method === 'POST') {
2318
+ const body = await readJson(req)
2319
+ const web = refreshWebCache(ctx)
2320
+ if (web.mode !== 'billing') {
2321
+ sendJson(res, 400, { ok: false, error: '当前宿主还是旧版搜索配置,不支持该实验功能' })
2322
+ return
2323
+ }
2324
+ const enabled = body.enabled !== false
2325
+ if (enabled) {
2326
+ const key = typeof body.key === 'string' ? body.key.trim() : ''
2327
+ if (key === '') {
2328
+ sendJson(res, 400, { ok: false, error: '请先填写一把搜索 key' })
2329
+ return
2330
+ }
2331
+ await storeKey(ctx, SEARCH_DEFAULT_REF, key)
2332
+ const updated = await updateSearchApiSettings(ctx, {
2333
+ searchApiKeyEnv: SEARCH_DEFAULT_REF,
2334
+ fetchApiKeyEnv: SEARCH_DEFAULT_REF,
2335
+ })
2336
+ if (updated.ok !== true) {
2337
+ sendJson(res, 500, { ok: false, error: `写入官方搜索配置失败:${updated.reason}` })
2338
+ return
2339
+ }
2340
+ } else {
2341
+ const updated = await updateSearchApiSettings(ctx, { searchApiKeyEnv: '', fetchApiKeyEnv: '' })
2342
+ if (updated.ok !== true) {
2343
+ sendJson(res, 500, { ok: false, error: `写入官方搜索配置失败:${updated.reason}` })
2344
+ return
2345
+ }
2346
+ }
2347
+ webCache = undefined
2348
+ refreshWebCache(ctx)
2349
+ log(`unified search key ${enabled ? 'on' : 'off'}`)
2350
+ sendJson(res, 200, { ok: true, enabled, ...(await snapshot(ctx, state)) })
2351
+ return
2352
+ }
2353
+
2151
2354
  if (pathname === `${API_BASE}/search-sync` && method === 'POST') {
2152
2355
  const body = await readJson(req)
2153
2356
  const dir = typeof body.path === 'string' ? body.path : undefined
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-workspace-api-key",
3
- "version": "0.2.16",
3
+ "version": "0.3.0",
4
4
  "description": "A DeepSeek Harness plugin that binds a separate DeepSeek API key to each workspace, or to a single session, so the spend of one key stays with the sessions you choose.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,6 +50,6 @@
50
50
  "lib/client.js"
51
51
  ],
52
52
  "scripts": {
53
- "test": "node test/host.test.mjs && node test/client.test.mjs"
53
+ "test": "node test/host.test.mjs && node test/client.test.mjs && node test/client-i18n.test.mjs && node test/client-billing.test.mjs"
54
54
  }
55
55
  }