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