dsh-workspace-api-key 0.0.0-stage → 0.2.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/client.js ADDED
@@ -0,0 +1,934 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-workspace-api-key',
3
+ factory: (require) => {
4
+ const exports = {}
5
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
6
+
7
+ const React = require('react')
8
+ const h = React.createElement
9
+ const { useState, useEffect, useCallback, useRef } = React
10
+
11
+ const API_BASE = '/wsk-api'
12
+ const STYLE_ID = 'wsk-styles'
13
+ const PANEL_ID = 'workspace-api-key'
14
+ const OVERLAY_ID = 'workspace-api-key-watch'
15
+ const DEFAULT_KEY = '__default__'
16
+ const DEFAULT_REF = 'DEEPSEEK_API_KEY'
17
+
18
+ // 覆盖层(提醒)与主面板之间的桥:同一个模块实例里的两个组件。
19
+ // focus —— 覆盖层点「去配置」时,把要编辑的会话交给面板
20
+ // refresh —— 面板挂载后在这里登记自己的刷新函数
21
+ const bridge = { focus: null, refresh: null }
22
+
23
+ // ─────────────────────────────── 样式 ───────────────────────────────
24
+
25
+ const CSS = `
26
+ .wsk { --wsk-border: color-mix(in srgb, currentColor 16%, transparent);
27
+ --wsk-dim: color-mix(in srgb, currentColor 58%, transparent);
28
+ --wsk-faint: color-mix(in srgb, currentColor 42%, transparent);
29
+ --wsk-bg: color-mix(in srgb, currentColor 5%, transparent);
30
+ --wsk-ok: #16a34a; --wsk-warn: #d97706; --wsk-bad: #dc2626;
31
+ display: flex; flex-direction: column; height: 100%; min-height: 0;
32
+ font-size: 13px; color: inherit; }
33
+ .wsk * { box-sizing: border-box; }
34
+ .wsk-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px;
35
+ border-bottom: 1px solid var(--wsk-border); flex-wrap: wrap; }
36
+ .wsk-back { display: inline-flex; align-items: center; gap: 4px; border: 0; background: transparent;
37
+ color: inherit; cursor: pointer; padding: 4px 8px; border-radius: 6px; font: inherit; }
38
+ .wsk-back:hover { background: var(--wsk-bg); }
39
+ .wsk-title { font-weight: 600; font-size: 14px; }
40
+ .wsk-sub { color: var(--wsk-dim); font-size: 12px; }
41
+ .wsk-spacer { flex: 1 1 auto; }
42
+ .wsk-btn { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--wsk-border);
43
+ background: transparent; color: inherit; cursor: pointer; padding: 4px 10px;
44
+ border-radius: 6px; font: inherit; font-size: 12px; white-space: nowrap; }
45
+ .wsk-btn:hover:not(:disabled) { background: var(--wsk-bg); }
46
+ .wsk-btn:disabled { opacity: .45; cursor: default; }
47
+ .wsk-btn.primary { border-color: transparent; background: #2563eb; color: #fff; }
48
+ .wsk-btn.primary:hover:not(:disabled) { background: #1d4ed8; }
49
+ .wsk-btn.danger { color: var(--wsk-bad); }
50
+ .wsk-body { flex: 1 1 auto; overflow: auto; padding: 14px; max-width: 980px; width: 100%; }
51
+ .wsk-card { border: 1px solid var(--wsk-border); border-radius: 10px; padding: 12px 14px;
52
+ margin-bottom: 14px; background: var(--wsk-bg); }
53
+ .wsk-card h4 { margin: 0 0 8px; font-size: 13px; font-weight: 600; }
54
+ .wsk-row { display: flex; align-items: center; gap: 10px; padding: 10px 4px; }
55
+ .wsk-list .wsk-row + .wsk-row { border-top: 1px solid var(--wsk-border); }
56
+ .wsk-row-main { flex: 1 1 auto; min-width: 0; }
57
+ .wsk-name { font-weight: 500; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
58
+ .wsk-path { color: var(--wsk-faint); font-size: 11px; margin-top: 2px; word-break: break-all; }
59
+ .wsk-badge { display: inline-flex; align-items: center; gap: 3px; padding: 1px 7px; border-radius: 999px;
60
+ font-size: 11px; border: 1px solid var(--wsk-border); color: var(--wsk-dim); white-space: nowrap; }
61
+ .wsk-badge.ok { color: var(--wsk-ok); border-color: color-mix(in srgb, var(--wsk-ok) 40%, transparent); }
62
+ .wsk-badge.bad { color: var(--wsk-bad); border-color: color-mix(in srgb, var(--wsk-bad) 40%, transparent); }
63
+ .wsk-badge.warn { color: var(--wsk-warn); border-color: color-mix(in srgb, var(--wsk-warn) 40%, transparent); }
64
+ .wsk-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
65
+ .wsk-editor { padding: 10px 12px; border: 1px solid var(--wsk-border); border-radius: 8px;
66
+ margin: 4px 0 10px; display: flex; flex-direction: column; gap: 8px; }
67
+ .wsk-input { width: 100%; padding: 7px 9px; border: 1px solid var(--wsk-border); border-radius: 6px;
68
+ background: transparent; color: inherit; font: inherit; font-size: 12px; }
69
+ .wsk-input:focus { outline: 2px solid color-mix(in srgb, #2563eb 45%, transparent); outline-offset: -1px; }
70
+ .wsk-hint { color: var(--wsk-dim); font-size: 11px; line-height: 1.6; }
71
+ .wsk-msg { padding: 7px 10px; border-radius: 6px; font-size: 12px; line-height: 1.5; }
72
+ .wsk-msg.ok { background: color-mix(in srgb, var(--wsk-ok) 12%, transparent); color: var(--wsk-ok); }
73
+ .wsk-msg.bad { background: color-mix(in srgb, var(--wsk-bad) 12%, transparent); color: var(--wsk-bad); }
74
+ .wsk-msg.warn { background: color-mix(in srgb, var(--wsk-warn) 14%, transparent); color: var(--wsk-warn); }
75
+ .wsk-msg.info { background: var(--wsk-bg); color: var(--wsk-dim); }
76
+ .wsk-dialog-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
77
+ .wsk-toggle { display: inline-flex; align-items: center; gap: 3px; border: 0; background: transparent;
78
+ color: var(--wsk-dim); cursor: pointer; padding: 2px 5px; border-radius: 5px; font: inherit; font-size: 11px; }
79
+ .wsk-toggle:hover { background: var(--wsk-bg); }
80
+ .wsk-sessions { margin: 0 0 8px 10px; padding-left: 10px; border-left: 1px solid var(--wsk-border);
81
+ display: flex; flex-direction: column; }
82
+ .wsk-sess { display: flex; align-items: center; gap: 8px; padding: 7px 2px; }
83
+ .wsk-sess + .wsk-sess { border-top: 1px solid color-mix(in srgb, currentColor 8%, transparent); }
84
+ .wsk-sess-main { flex: 1 1 auto; min-width: 0; }
85
+ .wsk-sess-title { font-size: 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
86
+ .wsk-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }
87
+ .wsk-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex;
88
+ align-items: center; justify-content: center; z-index: 9999; }
89
+ .wsk-dialog { width: min(560px, calc(100vw - 40px)); border-radius: 12px; padding: 18px 20px;
90
+ background: var(--wsk-dialog-bg, #ffffff); color: var(--wsk-dialog-fg, #111111);
91
+ box-shadow: 0 18px 50px rgba(0,0,0,.35); display: flex; flex-direction: column; gap: 12px;
92
+ font-size: 13px; }
93
+ .wsk-dialog h3 { margin: 0; font-size: 15px; }
94
+ .wsk-dialog .wsk-hint { color: color-mix(in srgb, currentColor 65%, transparent); }
95
+ .wsk-dialog .wsk-btn { border-color: color-mix(in srgb, currentColor 25%, transparent); }
96
+ .wsk-dialog .wsk-btn:hover:not(:disabled) { background: color-mix(in srgb, currentColor 8%, transparent); }
97
+ .wsk-kv { display: flex; gap: 6px; font-size: 12px; line-height: 1.7; }
98
+ .wsk-kv-key { color: color-mix(in srgb, currentColor 60%, transparent); flex: 0 0 auto; min-width: 74px; }
99
+ @media (prefers-color-scheme: dark) {
100
+ .wsk-dialog { --wsk-dialog-bg: #1f2124; --wsk-dialog-fg: #f2f2f2; }
101
+ }
102
+ `
103
+
104
+ function ensureStyles() {
105
+ if (typeof document === 'undefined') return
106
+ if (document.getElementById(STYLE_ID) !== null) return
107
+ const style = document.createElement('style')
108
+ style.id = STYLE_ID
109
+ style.textContent = CSS
110
+ document.head.appendChild(style)
111
+ }
112
+
113
+ // ─────────────────────────────── 工具 ───────────────────────────────
114
+
115
+ async function api(path, options) {
116
+ const init = { headers: { 'content-type': 'application/json' }, ...options }
117
+ let res
118
+ try {
119
+ res = await window.fetch(`${API_BASE}${path}`, init)
120
+ } catch (err) {
121
+ throw new Error(`无法访问插件宿主接口(${String(err?.message ?? err)})`)
122
+ }
123
+ let body = null
124
+ try {
125
+ body = await res.json()
126
+ } catch {
127
+ throw new Error(`宿主接口返回了非 JSON 响应(HTTP ${res.status})`)
128
+ }
129
+ if (body === null || body === undefined) throw new Error(`宿主接口返回空响应(HTTP ${res.status})`)
130
+ if (res.ok !== true || body.ok === false) throw new Error(body.error ?? `HTTP ${res.status}`)
131
+ return body
132
+ }
133
+
134
+ function prettyPath(p) {
135
+ if (typeof p !== 'string' || p === '') return ''
136
+ return p.replace(/^[A-Za-z]:\\Users\\[^\\]+\\/i, '~\\')
137
+ }
138
+
139
+ function verdictText(verdict) {
140
+ switch (verdict) {
141
+ case 'ok': return '连接正常'
142
+ case 'invalid': return 'key 无效'
143
+ case 'quota': return '余额不足'
144
+ case 'rate-limit': return '被限流'
145
+ case 'unreachable': return '无法连接'
146
+ default: return '测试失败'
147
+ }
148
+ }
149
+
150
+ function invalidReason(flag) {
151
+ if (flag === null || flag === undefined) return undefined
152
+ if (flag.status !== 'invalid') return undefined
153
+ return flag.message ?? '鉴权失败'
154
+ }
155
+
156
+ // 生效层级:会话级 > 工作区级 > 系统默认
157
+ function levelText(level) {
158
+ switch (level) {
159
+ case 'session': return '本会话专属'
160
+ case 'workspace': return '工作区专属'
161
+ default: return '系统默认'
162
+ }
163
+ }
164
+
165
+ function levelClass(level) {
166
+ return level === 'session' || level === 'workspace' ? 'wsk-badge ok' : 'wsk-badge'
167
+ }
168
+
169
+ function mismatchText(mismatch) {
170
+ if (mismatch === null || mismatch === undefined) return undefined
171
+ const key = mismatch.keyProvider ?? '未记录服务商'
172
+ const session = mismatch.sessionProvider ?? '未知'
173
+ return `这把 key 是为「${key}」准备的,当前会话用的是「${session}」`
174
+ }
175
+
176
+ function sessionLabel(session) {
177
+ const title = typeof session.title === 'string' && session.title !== '' ? session.title : undefined
178
+ return title ?? (session.blank === true ? '(空会话)' : String(session.sessionId).slice(0, 12))
179
+ }
180
+
181
+ function modelText(session) {
182
+ if (typeof session.provider !== 'string' || session.provider === '') return '模型未知'
183
+ return `${session.provider} / ${session.model ?? '默认模型'}`
184
+ }
185
+
186
+ // ─────────────────────────────── 图标 ───────────────────────────────
187
+
188
+ function keySvg(size) {
189
+ return h('svg', {
190
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
191
+ stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round',
192
+ 'aria-hidden': 'true', focusable: 'false',
193
+ },
194
+ h('circle', { key: 'c', cx: 8, cy: 12, r: 3.4 }),
195
+ h('path', { key: 'a', d: 'M11.4 12H20.5' }),
196
+ h('path', { key: 'b', d: 'M17.8 12v3' }),
197
+ h('path', { key: 'd', d: 'M14.8 12v2.2' }))
198
+ }
199
+
200
+ function KeyIcon(props) { return keySvg(props?.size ?? 16) }
201
+
202
+ function chevronSvg(size) {
203
+ return h('svg', {
204
+ width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
205
+ stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
206
+ 'aria-hidden': 'true', focusable: 'false',
207
+ }, h('path', { d: 'M10 3.5 5.5 8l4.5 4.5' }))
208
+ }
209
+
210
+ function refreshSvg(size) {
211
+ return h('svg', {
212
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
213
+ stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
214
+ 'aria-hidden': 'true', focusable: 'false',
215
+ }, h('path', { key: 'a', d: 'M21 12a9 9 0 1 1-2.64-6.36' }), h('path', { key: 'b', d: 'M21 4v5h-5' }))
216
+ }
217
+
218
+ function caretSvg(size, open) {
219
+ return h('svg', {
220
+ width: size, height: size, viewBox: '0 0 16 16', fill: 'none',
221
+ stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round',
222
+ 'aria-hidden': 'true', focusable: 'false',
223
+ }, open ? h('path', { d: 'M3.5 6 8 10.5 12.5 6' }) : h('path', { d: 'M6 3.5 10.5 8 6 12.5' }))
224
+ }
225
+
226
+ // ─────────────────────────────── 弹窗 ───────────────────────────────
227
+
228
+ function Dialog(props) {
229
+ return h('div', {
230
+ className: 'wsk-overlay',
231
+ onClick: (ev) => { if (ev.target === ev.currentTarget) props.onDismiss?.() },
232
+ },
233
+ h('div', { className: 'wsk-dialog', role: 'dialog', 'aria-modal': 'true' },
234
+ h('h3', null, props.title),
235
+ typeof props.body === 'string'
236
+ ? h('div', { className: 'wsk-hint' }, props.body)
237
+ : props.body,
238
+ h('div', { className: 'wsk-dialog-actions' },
239
+ (props.actions ?? []).map((action, index) =>
240
+ h('button', {
241
+ key: index,
242
+ type: 'button',
243
+ className: `wsk-btn${action.primary === true ? ' primary' : ''}`,
244
+ onClick: action.onClick,
245
+ }, action.label)))))
246
+ }
247
+
248
+ function kv(key, value) {
249
+ return h('div', { className: 'wsk-kv', key },
250
+ h('span', { className: 'wsk-kv-key' }, key),
251
+ h('span', null, value))
252
+ }
253
+
254
+ // ─────────────────────────────── 主页面 ───────────────────────────────
255
+
256
+ function WorkspaceKeyPage(props) {
257
+ const [state, setState] = useState(null)
258
+ const [error, setError] = useState(null)
259
+ const [busy, setBusy] = useState(false)
260
+ const [editing, setEditing] = useState(null)
261
+ const [testing, setTesting] = useState(null)
262
+ const [tests, setTests] = useState({})
263
+ const [notice, setNotice] = useState(null)
264
+ const [prompt, setPrompt] = useState(null)
265
+ const [expanded, setExpanded] = useState(() => new Set())
266
+
267
+ const alive = useRef(true)
268
+ const prompted = useRef(false)
269
+
270
+ useEffect(() => {
271
+ ensureStyles()
272
+ alive.current = true
273
+ return () => { alive.current = false }
274
+ }, [])
275
+
276
+ const refresh = useCallback(async () => {
277
+ setBusy(true)
278
+ try {
279
+ const next = await api('/state')
280
+ if (typeof globalThis !== 'undefined' && globalThis.__WSK_TRACE__ === true) {
281
+ console.log('[wsk] got /state alive=' + alive.current + ' workspaces=' + (next.workspaces ?? []).length)
282
+ }
283
+ if (!alive.current) return
284
+ setState(next)
285
+ setError(null)
286
+ // 覆盖层点「去配置」时留下的焦点:展开对应工作区并直接打开会话编辑器。
287
+ if (bridge.focus !== null) {
288
+ const focus = bridge.focus
289
+ bridge.focus = null
290
+ if (typeof focus.path === 'string' && focus.path !== '') {
291
+ setExpanded((prev) => new Set(prev).add(focus.path))
292
+ }
293
+ setEditing({
294
+ kind: 'session',
295
+ path: focus.path,
296
+ sessionId: focus.sessionId,
297
+ title: focus.title,
298
+ provider: focus.provider,
299
+ model: focus.model,
300
+ ref: focus.ref,
301
+ key: '',
302
+ })
303
+ }
304
+ if (!prompted.current) {
305
+ const stale = (next.workspaces ?? []).find(
306
+ (ws) => ws.configured === true && invalidReason(ws.invalid) !== undefined)
307
+ if (stale !== undefined) {
308
+ prompted.current = true
309
+ setPrompt({ kind: 'stale', ws: stale })
310
+ } else if (next.defaultRef?.configured !== true) {
311
+ prompted.current = true
312
+ setPrompt({ kind: 'no-default' })
313
+ }
314
+ }
315
+ } catch (err) {
316
+ if (alive.current) setError(String(err?.message ?? err))
317
+ } finally {
318
+ if (alive.current) setBusy(false)
319
+ }
320
+ }, [])
321
+
322
+ useEffect(() => { refresh() }, [refresh])
323
+
324
+ // 让覆盖层能立刻刷新面板(点「去配置」时面板可能已经挂载着)。
325
+ useEffect(() => {
326
+ bridge.refresh = refresh
327
+ return () => { if (bridge.refresh === refresh) bridge.refresh = null }
328
+ }, [refresh])
329
+
330
+ // 「返回」:惰性取 layout,避免在 apply 阶段就把 undefined 缓存下来。
331
+ const close = () => { props?.back?.() }
332
+
333
+ const applyState = (next) => { if (alive.current) setState(next) }
334
+
335
+ const toggle = (key) => setExpanded((prev) => {
336
+ const next = new Set(prev)
337
+ if (next.has(key)) next.delete(key)
338
+ else next.add(key)
339
+ return next
340
+ })
341
+
342
+ const save = async (target, key, { quiet = false } = {}) => {
343
+ const trimmed = typeof key === 'string' ? key.trim() : ''
344
+ const scope = target.kind === 'session' ? 'session' : 'workspace'
345
+ const wasCleared = target.kind === 'session' ? false : target.configured !== true
346
+ setBusy(true)
347
+ try {
348
+ const body = {
349
+ scope,
350
+ title: target.title,
351
+ }
352
+ if (scope === 'session') body.sessionId = target.sessionId
353
+ else body.path = target.path
354
+ if (typeof target.provider === 'string' && target.provider !== '') body.provider = target.provider
355
+ if (typeof target.model === 'string' && target.model !== '') body.model = target.model
356
+ if (trimmed === '') body.useDefault = true
357
+ else body.key = trimmed
358
+ const next = await api('/set', { method: 'POST', body: JSON.stringify(body) })
359
+ applyState(next)
360
+ setEditing(null)
361
+ if (!quiet) {
362
+ setNotice({
363
+ kind: 'ok',
364
+ text: trimmed === ''
365
+ ? `${target.title}:已改用${scope === 'session' ? '上层' : '系统默认'}设置`
366
+ : target.kind === 'session'
367
+ ? `${target.title}:已保存本会话专属 key`
368
+ : wasCleared
369
+ ? `${target.title}:已改用系统默认设置`
370
+ : `${target.title}:已保存专属 key`,
371
+ })
372
+ }
373
+ return true
374
+ } catch (err) {
375
+ if (alive.current) setNotice({ kind: 'bad', text: String(err?.message ?? err) })
376
+ return false
377
+ } finally {
378
+ if (alive.current) setBusy(false)
379
+ }
380
+ }
381
+
382
+ const runTest = async (id, title, body, key) => {
383
+ setTesting(id)
384
+ setNotice(null)
385
+ try {
386
+ const payload = { ...body }
387
+ if (typeof key === 'string' && key.trim() !== '') payload.key = key.trim()
388
+ const res = await api('/test', { method: 'POST', body: JSON.stringify(payload) })
389
+ if (!alive.current) return
390
+ setTests((prev) => ({ ...prev, [id]: res.test }))
391
+ setNotice({
392
+ kind: res.test?.ok === true ? 'ok' : 'bad',
393
+ text: `${title}:${verdictText(res.test?.verdict)}` +
394
+ (res.test?.detail ? ` — ${res.test.detail}` : '') +
395
+ (res.test?.latencyMs ? `(${res.test.latencyMs}ms)` : ''),
396
+ })
397
+ applyState(await api('/state'))
398
+ } catch (err) {
399
+ if (alive.current) setNotice({ kind: 'bad', text: String(err?.message ?? err) })
400
+ } finally {
401
+ if (alive.current) setTesting(null)
402
+ }
403
+ }
404
+
405
+ const clearFlag = async (id, body, title) => {
406
+ setBusy(true)
407
+ try {
408
+ applyState(await api('/clear-flag', { method: 'POST', body: JSON.stringify(body) }))
409
+ if (alive.current) {
410
+ setTests((prev) => ({ ...prev, [id]: undefined }))
411
+ setNotice({ kind: 'info', text: `${title}:已清除失效标记` })
412
+ }
413
+ } catch (err) {
414
+ if (alive.current) setNotice({ kind: 'bad', text: String(err?.message ?? err) })
415
+ } finally {
416
+ if (alive.current) setBusy(false)
417
+ }
418
+ }
419
+
420
+ const workspaces = state?.workspaces ?? []
421
+ const grouped = workspaces.filter((ws) => ws.kind === 'workspace')
422
+ const orphans = workspaces.filter((ws) => ws.kind !== 'workspace')
423
+ const ungrouped = state?.ungrouped ?? []
424
+ const defaultRef = state?.defaultRef
425
+ const providers = state?.providers ?? []
426
+
427
+ const providerOptions = (current, fallback) => {
428
+ const list = [...providers]
429
+ for (const candidate of [current, fallback]) {
430
+ if (typeof candidate === 'string' && candidate !== '' && !list.includes(candidate)) list.push(candidate)
431
+ }
432
+ return [h('option', { key: '__none', value: '' }, '(不指定)')].concat(
433
+ list.map((name) => h('option', { key: name, value: name }, name)))
434
+ }
435
+
436
+ const renderEditor = (target) => {
437
+ const isSession = target.kind === 'session'
438
+ const current = isSession ? target.level : target.configured === true ? 'workspace' : 'default'
439
+ const fallbackLabel = isSession
440
+ ? `清除本会话配置(改回${target.workspaceConfigured === true ? '工作区' : '系统默认'})`
441
+ : '改用系统默认'
442
+ return h('div', { className: 'wsk-editor' },
443
+ h('div', { className: 'wsk-hint' },
444
+ isSession
445
+ ? `当前生效:${levelText(current)}${target.keyMasked ? `(${target.keyMasked})` : ''}。`
446
+ : target.configured === true
447
+ ? `该工作区当前使用专属 key${target.keyMasked ? `(${target.keyMasked})` : ''}。留空并保存即恢复使用系统默认。`
448
+ : '为该工作区指定一把专属 key;留空并保存即继续使用系统默认。'),
449
+ h('input', {
450
+ className: 'wsk-input',
451
+ type: 'text',
452
+ autoComplete: 'off',
453
+ spellCheck: false,
454
+ placeholder: 'sk-…(留空 = 使用上层设置)',
455
+ value: target.key,
456
+ onChange: (ev) => setEditing({ ...target, key: ev.target.value }),
457
+ onKeyDown: (ev) => { if (ev.key === 'Enter') save(target, target.key) },
458
+ }),
459
+ h('div', null,
460
+ h('div', { className: 'wsk-hint' }, '这把 key 属于哪个服务商(用于进入会话时判断是否匹配)'),
461
+ h('select', {
462
+ className: 'wsk-input',
463
+ value: typeof target.provider === 'string' ? target.provider : '',
464
+ onChange: (ev) => setEditing({ ...target, provider: ev.target.value }),
465
+ }, providerOptions(target.provider, isSession ? target.sessionProvider : undefined))),
466
+ h('div', { className: 'wsk-dialog-actions' },
467
+ h('button', {
468
+ type: 'button', className: 'wsk-btn primary', disabled: busy,
469
+ onClick: () => save(target, target.key),
470
+ }, busy ? '保存中…' : '保存'),
471
+ h('button', {
472
+ type: 'button', className: 'wsk-btn', disabled: busy || target.key.trim() === '',
473
+ onClick: () => runTest(target.id, target.title, target.testBody, target.key),
474
+ }, '用这把 key 测试'),
475
+ target.configured === true || (isSession && target.level !== 'default')
476
+ ? h('button', {
477
+ type: 'button', className: 'wsk-btn', disabled: busy,
478
+ onClick: () => save({ ...target, key: '' }, ''),
479
+ }, fallbackLabel)
480
+ : null,
481
+ h('button', {
482
+ type: 'button', className: 'wsk-btn',
483
+ onClick: () => setEditing(null),
484
+ }, '取消')),
485
+ h('div', { className: 'wsk-hint' }, `凭据引用:${target.ref ?? '—'}`))
486
+ }
487
+
488
+ const renderSession = (ws, session) => {
489
+ const id = `ss:${session.sessionId}`
490
+ const reason = invalidReason(session.invalid)
491
+ const mismatch = mismatchText(session.mismatch)
492
+ const lastTest = tests[id]
493
+ const isEditing = editing !== null && editing.kind === 'session' && editing.sessionId === session.sessionId
494
+ const isTesting = testing === id
495
+ const hasOwn = session.configured === true
496
+ return h('div', { key: id },
497
+ h('div', { className: 'wsk-sess' },
498
+ h('div', { className: 'wsk-sess-main' },
499
+ h('div', { className: 'wsk-sess-title' },
500
+ h('span', { key: 't' }, sessionLabel(session)),
501
+ h('span', { key: 'l', className: levelClass(session.level) }, levelText(session.level)),
502
+ reason !== undefined
503
+ ? h('span', { key: 'i', className: 'wsk-badge bad', title: reason }, 'key 已失效')
504
+ : null,
505
+ session.mismatch !== null && session.mismatch !== undefined
506
+ ? h('span', { key: 'm', className: 'wsk-badge warn', title: mismatch }, '服务商不匹配')
507
+ : null,
508
+ lastTest !== undefined && lastTest !== null
509
+ ? h('span', {
510
+ key: 'tt',
511
+ className: `wsk-badge ${lastTest.ok === true ? 'ok' : 'bad'}`,
512
+ }, verdictText(lastTest.verdict))
513
+ : null),
514
+ h('div', { className: 'wsk-path' },
515
+ `${modelText(session)}` +
516
+ (session.effectiveKeyMasked ? ` · 生效 key ${session.effectiveKeyMasked}` : ' · 未配置 key') +
517
+ (hasOwn && session.keyMasked ? ` · 本会话 ${session.keyMasked}` : '')),
518
+ reason !== undefined
519
+ ? h('div', { className: 'wsk-hint', style: { color: 'var(--wsk-bad)', marginTop: 3 } }, reason)
520
+ : null,
521
+ mismatch !== undefined
522
+ ? h('div', { className: 'wsk-hint', style: { color: 'var(--wsk-warn)', marginTop: 3 } }, mismatch)
523
+ : null),
524
+ h('div', { className: 'wsk-actions' },
525
+ reason !== undefined
526
+ ? h('button', {
527
+ key: 'clear', type: 'button', className: 'wsk-btn', disabled: busy,
528
+ onClick: () => clearFlag(id, { ref: session.effectiveRef }, sessionLabel(session)),
529
+ }, '清除标记')
530
+ : null,
531
+ h('button', {
532
+ key: 'test', type: 'button', className: 'wsk-btn', disabled: isTesting || busy,
533
+ onClick: () => runTest(id, sessionLabel(session), { scope: 'session', sessionId: session.sessionId }),
534
+ }, isTesting ? '测试中…' : '测试连接'),
535
+ h('button', {
536
+ key: 'cfg', type: 'button', className: 'wsk-btn',
537
+ onClick: () => setEditing(isEditing ? null : {
538
+ kind: 'session',
539
+ id,
540
+ path: ws.path,
541
+ sessionId: session.sessionId,
542
+ title: sessionLabel(session),
543
+ ref: session.ref,
544
+ key: '',
545
+ provider: session.overrideProvider ?? session.provider ?? '',
546
+ sessionProvider: session.provider,
547
+ model: session.overrideModel ?? session.model,
548
+ level: session.level,
549
+ keyMasked: session.effectiveKeyMasked,
550
+ configured: hasOwn,
551
+ workspaceConfigured: ws.configured === true,
552
+ testBody: { scope: 'session', sessionId: session.sessionId },
553
+ }),
554
+ }, isEditing ? '收起' : '配置'))),
555
+ isEditing ? renderEditor(editing) : null)
556
+ }
557
+
558
+ const renderWorkspace = (ws, options = {}) => {
559
+ const reason = invalidReason(ws.invalid)
560
+ const id = `ws:${ws.path}`
561
+ const lastTest = tests[id]
562
+ const sessions = Array.isArray(ws.sessions) ? ws.sessions : []
563
+ const open = expanded.has(ws.path)
564
+ const isEditing = editing !== null && editing.kind === 'workspace' && editing.path === ws.path
565
+ const isTesting = testing === id
566
+ const title = ws.title
567
+ return h('div', { key: `${ws.kind ?? 'ws'}:${id}` },
568
+ h('div', { className: 'wsk-row' },
569
+ h('div', { className: 'wsk-row-main' },
570
+ h('div', { className: 'wsk-name' },
571
+ sessions.length > 0
572
+ ? h('button', {
573
+ key: 'tgl', type: 'button', className: 'wsk-toggle',
574
+ title: open ? '折叠会话' : '展开会话',
575
+ onClick: () => toggle(ws.path),
576
+ }, caretSvg(12, open), h('span', null, `会话 ${sessions.length}`))
577
+ : null,
578
+ h('span', { key: 'n' }, title),
579
+ reason !== undefined
580
+ ? h('span', { key: 's', className: 'wsk-badge bad', title: reason }, 'key 已失效')
581
+ : ws.configured === true
582
+ ? h('span', { key: 's', className: 'wsk-badge ok' }, '专属 key')
583
+ : h('span', { key: 's', className: 'wsk-badge' }, '系统默认'),
584
+ ws.kind !== 'workspace'
585
+ ? h('span', { key: 'o', className: 'wsk-badge warn' },
586
+ ws.kind === 'directory' ? '未分组目录' : '目录已不存在')
587
+ : null,
588
+ lastTest !== undefined && lastTest !== null
589
+ ? h('span', {
590
+ key: 'tt',
591
+ className: `wsk-badge ${lastTest.ok === true ? 'ok' : 'bad'}`,
592
+ }, verdictText(lastTest.verdict))
593
+ : null),
594
+ h('div', { className: 'wsk-path' }, prettyPath(ws.path)),
595
+ reason !== undefined
596
+ ? h('div', { className: 'wsk-hint', style: { color: 'var(--wsk-bad)', marginTop: 4 } }, reason)
597
+ : null),
598
+ h('div', { className: 'wsk-actions' },
599
+ reason !== undefined
600
+ ? h('button', {
601
+ key: 'clear', type: 'button', className: 'wsk-btn', disabled: busy,
602
+ onClick: () => clearFlag(id, { path: ws.path }, title),
603
+ }, '清除标记')
604
+ : null,
605
+ h('button', {
606
+ key: 'test', type: 'button', className: 'wsk-btn', disabled: isTesting || busy,
607
+ onClick: () => runTest(id, title, { path: ws.path }),
608
+ }, isTesting ? '测试中…' : '测试连接'),
609
+ h('button', {
610
+ key: 'cfg', type: 'button', className: 'wsk-btn',
611
+ onClick: () => setEditing(isEditing ? null : {
612
+ kind: 'workspace',
613
+ id,
614
+ path: ws.path,
615
+ title,
616
+ ref: ws.ref,
617
+ key: '',
618
+ provider: ws.overrideProvider ?? (sessions[0]?.provider ?? ''),
619
+ model: undefined,
620
+ configured: ws.configured === true,
621
+ keyMasked: ws.keyMasked,
622
+ testBody: { path: ws.path },
623
+ }),
624
+ }, isEditing ? '收起' : '配置'))),
625
+ isEditing ? renderEditor(editing) : null,
626
+ sessions.length > 0 && open
627
+ ? h('div', { className: 'wsk-sessions' }, sessions.map((session) => renderSession(ws, session)))
628
+ : null)
629
+ }
630
+
631
+ const renderPrompt = () => {
632
+ if (prompt === null) return null
633
+ if (prompt.kind === 'no-default') {
634
+ return h(Dialog, {
635
+ title: '尚未配置系统默认 API Key',
636
+ body: '当前没有任何可用的 DEEPSEEK_API_KEY。可以到「设置 → 模型」里配置系统默认 key,' +
637
+ '也可以直接为某个工作区写一把专属 key。',
638
+ onDismiss: () => setPrompt(null),
639
+ actions: [{ label: '知道了', onClick: () => setPrompt(null) }],
640
+ })
641
+ }
642
+ const ws = prompt.ws
643
+ const reason = invalidReason(ws.invalid)
644
+ return h(Dialog, {
645
+ title: `${ws.title} 的 API Key 已失效`,
646
+ body: h('div', null,
647
+ h('div', null, reason),
648
+ h('div', { className: 'wsk-hint', style: { marginTop: 6 } }, `工作区:${prettyPath(ws.path)}`),
649
+ h('div', { className: 'wsk-hint' }, '可以重新配置这把 key,或让该工作区改用系统默认设置。')),
650
+ onDismiss: () => setPrompt(null),
651
+ actions: [
652
+ {
653
+ label: '重新配置', primary: true,
654
+ onClick: () => {
655
+ setPrompt(null)
656
+ setExpanded((prev) => new Set(prev).add(ws.path))
657
+ setEditing({
658
+ kind: 'workspace',
659
+ id: `ws:${ws.path}`,
660
+ path: ws.path,
661
+ title: ws.title,
662
+ ref: ws.ref,
663
+ key: '',
664
+ provider: ws.overrideProvider ?? '',
665
+ configured: true,
666
+ keyMasked: ws.keyMasked,
667
+ testBody: { path: ws.path },
668
+ })
669
+ },
670
+ },
671
+ {
672
+ label: '改用系统设置',
673
+ onClick: async () => {
674
+ setPrompt(null)
675
+ await save({
676
+ kind: 'workspace', id: `ws:${ws.path}`, path: ws.path, title: ws.title,
677
+ configured: true, key: '',
678
+ }, '', { quiet: true })
679
+ },
680
+ },
681
+ { label: '稍后', onClick: () => setPrompt(null) },
682
+ ],
683
+ })
684
+ }
685
+
686
+ return h('div', { className: 'wsk' },
687
+ h('div', { className: 'wsk-head' },
688
+ h('button', {
689
+ type: 'button', className: 'wsk-back', onClick: close, title: '返回会话',
690
+ }, chevronSvg(14), h('span', null, '返回')),
691
+ h('span', { className: 'wsk-title' }, 'API Key 分配'),
692
+ h('span', { className: 'wsk-sub' }, '按工作区 / 会话隔离计费'),
693
+ h('span', { className: 'wsk-spacer' }),
694
+ h('button', {
695
+ type: 'button', className: 'wsk-btn', disabled: busy, onClick: refresh,
696
+ }, refreshSvg(14), h('span', null, busy ? '刷新中…' : '刷新'))),
697
+
698
+ h('div', { className: 'wsk-body' },
699
+ notice !== null
700
+ ? h('div', { className: `wsk-msg ${notice.kind}`, style: { marginBottom: 12 } }, notice.text)
701
+ : null,
702
+ error !== null
703
+ ? h('div', { className: 'wsk-msg bad', style: { marginBottom: 12 } },
704
+ `读取失败:${error}`,
705
+ h('div', { className: 'wsk-hint' }, '请确认 DSH Web 服务正在运行、且本插件的宿主半边已加载。'))
706
+ : null,
707
+ state !== null && state.sessionListAvailable === false
708
+ ? h('div', { className: 'wsk-msg info', style: { marginBottom: 12 } },
709
+ '当前 DSH 版本没有提供会话列表服务,只能按工作区配置 key。')
710
+ : null,
711
+
712
+ h('div', { className: 'wsk-card' },
713
+ h('h4', null, '系统默认设置'),
714
+ h('div', { className: 'wsk-row' },
715
+ h('div', { className: 'wsk-row-main' },
716
+ h('div', { className: 'wsk-name' },
717
+ h('span', { key: 'r' }, defaultRef?.ref ?? DEFAULT_REF),
718
+ defaultRef?.configured === true
719
+ ? h('span', { key: 's', className: 'wsk-badge ok' },
720
+ `已配置${defaultRef.source ? ` · ${defaultRef.source}` : ''}`)
721
+ : h('span', { key: 's', className: 'wsk-badge bad' }, '未配置'),
722
+ defaultRef?.writable === false
723
+ ? h('span', { key: 'w', className: 'wsk-badge warn' }, '被环境变量遮蔽(只读)')
724
+ : null),
725
+ h('div', { className: 'wsk-hint' },
726
+ '没有单独配置的工作区/会话,一律使用这把 key。',
727
+ defaultRef?.keyMasked ? ` 当前值:${defaultRef.keyMasked}` : '')),
728
+ h('div', { className: 'wsk-actions' },
729
+ h('button', {
730
+ type: 'button', className: 'wsk-btn',
731
+ disabled: busy || testing !== null || defaultRef?.configured !== true,
732
+ onClick: () => runTest(DEFAULT_KEY, '系统默认', { ref: defaultRef?.ref ?? DEFAULT_REF }),
733
+ }, testing === DEFAULT_KEY ? '测试中…' : '测试连接')))),
734
+
735
+ h('div', { className: 'wsk-card', style: { background: 'transparent', border: 0, padding: 0 } },
736
+ h('h4', { style: { marginBottom: 6 } }, `工作区(${grouped.length})`),
737
+ h('div', { className: 'wsk-hint', style: { marginBottom: 6 } },
738
+ '展开工作区可以看到它下面的会话,并单独给某个会话指定 key(会话 > 工作区 > 系统默认)。'),
739
+ grouped.length === 0
740
+ ? h('div', { className: 'wsk-hint' }, '还没有工作区。先在侧栏「工作区」里新建一个。')
741
+ : h('div', { className: 'wsk-list' }, grouped.map((ws) => renderWorkspace(ws)))),
742
+
743
+ ungrouped.length > 0
744
+ ? h('div', { className: 'wsk-card' },
745
+ h('h4', null, `未分组目录(${ungrouped.length})`),
746
+ h('div', { className: 'wsk-hint', style: { marginBottom: 6 } },
747
+ '这些目录里的会话没有挂在工作区下(侧栏的「未分组」),同样可以单独配置。'),
748
+ h('div', { className: 'wsk-list' }, ungrouped.map((ws) => renderWorkspace(ws))))
749
+ : null,
750
+
751
+ orphans.length > 0
752
+ ? h('div', { className: 'wsk-card' },
753
+ h('h4', null, `已失效的配置(${orphans.length})`),
754
+ h('div', { className: 'wsk-hint', style: { marginBottom: 6 } },
755
+ '这些目录已经不是注册的工作区了,对应的 key 仍留在凭据库里。可改用系统默认以清理。'),
756
+ h('div', { className: 'wsk-list' }, orphans.map((ws) => renderWorkspace(ws))))
757
+ : null,
758
+
759
+ h('div', { className: 'wsk-hint' },
760
+ '专属 key 存进 DSH 凭据库中的独立引用,因此换 key 不写进会话日志、也不影响历史回放。',
761
+ state?.statePath ? h('div', null, `状态文件:${state.statePath}`) : null)),
762
+
763
+ renderPrompt())
764
+ }
765
+
766
+ // ─────────────────────── 进入会话时的提醒(覆盖层) ───────────────────────
767
+
768
+ function createOverlayWatcher(ctx) {
769
+ return function ApiKeyWatch() {
770
+ const [prompt, setPrompt] = useState(null)
771
+ const asked = useRef(new Set())
772
+
773
+ useEffect(() => {
774
+ ensureStyles()
775
+ const sessions = ctx.get('sessions')
776
+ if (sessions === undefined || sessions === null) return undefined
777
+
778
+ let disposed = false
779
+ const currentSessionId = () => {
780
+ try {
781
+ const snapshot = sessions.list?.getSnapshot?.()
782
+ const byId = snapshot?.byId ?? {}
783
+ for (const [id, item] of Object.entries(byId)) {
784
+ if (Number(item?.retainedBy?.mainView) > 0) return id
785
+ }
786
+ } catch {
787
+ /* 快照不可用时静默跳过 */
788
+ }
789
+ return undefined
790
+ }
791
+
792
+ const evaluate = async () => {
793
+ const sessionId = currentSessionId()
794
+ if (sessionId === undefined || asked.current.has(sessionId)) return
795
+ asked.current.add(sessionId)
796
+ try {
797
+ const res = await api(`/check?sessionId=${encodeURIComponent(sessionId)}`)
798
+ if (disposed) return
799
+ if (res.needsAttention === true) setPrompt(res)
800
+ } catch {
801
+ /* 宿主接口不可用时不打扰用户 */
802
+ }
803
+ }
804
+
805
+ evaluate()
806
+ const unsubscribe = typeof sessions.list?.subscribe === 'function'
807
+ ? sessions.list.subscribe(() => { evaluate() })
808
+ : undefined
809
+ return () => {
810
+ disposed = true
811
+ if (typeof unsubscribe === 'function') unsubscribe()
812
+ }
813
+ }, [])
814
+
815
+ const openPanel = () => {
816
+ const res = prompt
817
+ if (res !== null) {
818
+ bridge.focus = {
819
+ sessionId: res.sessionId,
820
+ path: res.workspaceScope?.path ?? res.cwd ?? undefined,
821
+ title: res.title ?? undefined,
822
+ provider: res.provider ?? undefined,
823
+ model: res.model ?? undefined,
824
+ ref: res.sessionScope?.ref ?? undefined,
825
+ }
826
+ }
827
+ try {
828
+ const layout = ctx.get('layout')
829
+ if (layout !== null && typeof layout === 'object' && typeof layout.selectPanel === 'function') {
830
+ layout.selectPanel(PANEL_ID)
831
+ }
832
+ } catch {
833
+ /* layout 不可用时至少留下焦点 */
834
+ }
835
+ if (typeof bridge.refresh === 'function') bridge.refresh()
836
+ setPrompt(null)
837
+ }
838
+
839
+ const useUpperLevel = async () => {
840
+ const res = prompt
841
+ setPrompt(null)
842
+ if (res === null) return
843
+ try {
844
+ const body = res.level === 'session'
845
+ ? { scope: 'session', sessionId: res.sessionId, useDefault: true }
846
+ : { scope: 'workspace', path: res.workspaceScope?.path ?? res.cwd, useDefault: true }
847
+ await api('/set', { method: 'POST', body: JSON.stringify(body) })
848
+ } catch {
849
+ /* 用户点不动就保持原样,绝不悄悄改 key */
850
+ }
851
+ }
852
+
853
+ if (prompt === null) return null
854
+ const invalid = invalidReason(prompt.invalid)
855
+ const mismatch = mismatchText(prompt.mismatch)
856
+ return h(Dialog, {
857
+ title: invalid !== undefined ? '这个会话用的 API Key 已失效' : '这个会话的 API Key 可能不匹配',
858
+ body: h('div', null,
859
+ kv('会话', prompt.title ?? String(prompt.sessionId ?? '').slice(0, 12)),
860
+ kv('模型', `${prompt.provider ?? '未知'} / ${prompt.model ?? '默认模型'}`),
861
+ kv('生效 key', `${levelText(prompt.level)}${prompt.keyMasked ? ` · ${prompt.keyMasked}` : ''}`),
862
+ invalid !== undefined
863
+ ? h('div', { className: 'wsk-hint', style: { marginTop: 8, color: 'var(--wsk-bad)' } }, invalid)
864
+ : null,
865
+ mismatch !== undefined
866
+ ? h('div', { className: 'wsk-hint', style: { marginTop: 8, color: 'var(--wsk-warn)' } }, mismatch)
867
+ : null,
868
+ h('div', { className: 'wsk-hint', style: { marginTop: 8 } },
869
+ '插件不会自动更换 key —— 用哪把 key 由你决定,避免计费被悄悄改到别的账号上。')),
870
+ onDismiss: () => setPrompt(null),
871
+ actions: [
872
+ { label: '去配置', primary: true, onClick: openPanel },
873
+ ...(invalid !== undefined
874
+ ? [{ label: res2Upper(prompt), onClick: useUpperLevel }]
875
+ : []),
876
+ { label: invalid !== undefined ? '稍后' : '继续用当前 key', onClick: () => setPrompt(null) },
877
+ ],
878
+ })
879
+ }
880
+ }
881
+
882
+ function res2Upper(prompt) {
883
+ return prompt.level === 'session' ? '清除本会话配置' : '改用系统默认'
884
+ }
885
+
886
+ // ─────────────────────────────── 插件入口 ───────────────────────────────
887
+
888
+ function apply(ctx) {
889
+ ensureStyles()
890
+ const slots = ctx.get('slots')
891
+ if (slots === undefined || slots === null) return
892
+
893
+ // 惰性取 layout:点击时才读,避免服务尚未就绪时把 undefined 缓存下来。
894
+ const back = () => {
895
+ try {
896
+ const layout = ctx.get('layout')
897
+ if (layout === null || typeof layout !== 'object') return false
898
+ if (typeof layout.selectPanel !== 'function') return false
899
+ layout.selectPanel(null)
900
+ return true
901
+ } catch {
902
+ return false
903
+ }
904
+ }
905
+
906
+ ctx.effect(() => slots.inject('sidebar.panellist', () =>
907
+ slots.register({
908
+ name: 'sidebar.panellist',
909
+ id: PANEL_ID,
910
+ order: 1,
911
+ label: () => 'API Key 分配',
912
+ }, KeyIcon)), 'workspace-api-key: sidebar row')
913
+
914
+ ctx.effect(() => slots.inject('main', () =>
915
+ slots.register({
916
+ name: 'main',
917
+ key: PANEL_ID,
918
+ inject: () => ({ back }),
919
+ }, WorkspaceKeyPage)), 'workspace-api-key: main page')
920
+
921
+ ctx.effect(() => slots.inject('shell.overlay', () =>
922
+ slots.register({
923
+ name: 'shell.overlay',
924
+ id: OVERLAY_ID,
925
+ order: 60,
926
+ label: 'API Key 提醒',
927
+ }, createOverlayWatcher(ctx))), 'workspace-api-key: session entry watcher')
928
+ }
929
+
930
+ exports.apply = apply
931
+ exports.inject = ['slots', 'layout']
932
+ return exports
933
+ },
934
+ })