cs-bots 0.0.0-stage → 0.1.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/src/client.js ADDED
@@ -0,0 +1,2089 @@
1
+ /**
2
+ * cs-bots /client —— Web 页面层(L3)。2026-11-03 按「普通人能操作」重做。
3
+ *
4
+ * ── 三条铁律(都有机械检查守住)────────────────────────────────────────────
5
+ * 1. **绝不使用 `window.prompt` / `confirm` / `alert`**。
6
+ * 依据:官方 DSH 客户端包全量 grep 0 处使用,且存在 sandboxed 渲染 ——
7
+ * 在沙箱视图里这两个 API 被**静默屏蔽**(返回 null/false、不报错),
8
+ * 用户看到的就是「按钮点了没反应」。所有输入改内联表单,所有确认改站内弹窗。
9
+ * CI 检查 5 会 grep 本文件,命中即 FAIL。
10
+ * 2. **宿主服务只能从 `apply(ctx)` 的 ctx 取**(`ctx.get('uiWorkspace')`)。
11
+ * 以前猜过 `window.__DSH_CTX__` —— 那个永远是 undefined,导致「选目录」静默失效。
12
+ * 3. **每个 Tab 单独包 ErrorBoundary**:一处炸不连累整页。
13
+ *
14
+ * ── 视觉(依据实测几何)───────────────────────────────────────────────────
15
+ * · 只用官方 14 个主题令牌;浅色下 bg-base/layer-1/layer-2 都是 #fff → 层次靠边框
16
+ * · brand-primary 是单色(浅黑/深白)→ 主按钮 bg=brand + color=bg-base 自动反色
17
+ * · 状态色只上「点和字」,不做整块底色
18
+ */
19
+
20
+ window.__ModuleLoader__.load({
21
+ id: 'cs-bots',
22
+ factory: (require) => {
23
+ const module = { exports: {} }
24
+ const exports = module.exports
25
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
26
+
27
+ const React = require('react')
28
+ const h = React.createElement
29
+ const { useState, useEffect, useCallback, useRef } = React
30
+
31
+ const PANEL_ID = 'cs-bots'
32
+ const API = '/cs-bots/api'
33
+
34
+ // ── 宿主上下文(apply 时捕获;取代不可靠的全局猜测)────────────────────
35
+ let hostCtx = null
36
+ let hostUiWorkspace = null
37
+ function bindHost(ctx) {
38
+ hostCtx = ctx
39
+ try {
40
+ hostUiWorkspace = ctx?.get?.('uiWorkspace') ?? null
41
+ } catch {
42
+ hostUiWorkspace = null
43
+ }
44
+ }
45
+
46
+ // ── 请求 ────────────────────────────────────────────────────────────────
47
+
48
+ async function call(path, options) {
49
+ const hasBody = options?.body !== undefined
50
+ const res = await fetch(API + path, {
51
+ headers: { accept: 'application/json', ...(hasBody ? { 'content-type': 'application/json' } : {}) },
52
+ cache: 'no-store',
53
+ ...options,
54
+ ...(hasBody ? { body: JSON.stringify(options.body) } : {}),
55
+ })
56
+ let data
57
+ try {
58
+ data = await res.json()
59
+ } catch {
60
+ throw new Error(`接口返回非 JSON(HTTP ${res.status})`)
61
+ }
62
+ if (!data || data.ok === false) throw new Error(data?.reason ?? data?.message ?? `HTTP ${res.status}`)
63
+ return data
64
+ }
65
+
66
+ // ── 样式 ────────────────────────────────────────────────────────────────
67
+
68
+ const CSS = `
69
+ .csb{display:flex;flex-direction:column;height:100%;min-height:0;font-size:13px;
70
+ color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base)}
71
+ .csb-head{flex:none;padding:16px 24px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}
72
+ .csb-hrow{display:flex;align-items:baseline;gap:10px;margin-bottom:14px}
73
+ .csb-hrow h2{margin:0;font-size:16px;font-weight:600;letter-spacing:-.01em}
74
+ .csb-hint{color:var(--dsw-alias-label-secondary);font-size:12px;margin-left:auto}
75
+ .csb-tabs{display:flex;gap:2px;overflow-x:auto}
76
+ .csb-tab{position:relative;padding:9px 13px;border:0;background:transparent;cursor:pointer;white-space:nowrap;
77
+ color:var(--dsw-alias-label-secondary);font-size:13px;font-family:inherit;border-radius:8px 8px 0 0}
78
+ .csb-tab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
79
+ .csb-tab-on{color:var(--dsw-alias-label-primary);font-weight:500}
80
+ .csb-tab-on::after{content:"";position:absolute;left:9px;right:9px;bottom:-1px;height:2px;
81
+ background:var(--dsw-alias-brand-primary);border-radius:2px 2px 0 0}
82
+ .csb-body{flex:1;overflow-y:auto;padding:20px 24px 56px;min-height:0}
83
+ .csb-wrap{max-width:920px;margin:0 auto}
84
+
85
+ .csb-bar{display:flex;gap:8px;align-items:center;margin-bottom:16px;flex-wrap:wrap}
86
+ .csb-spacer{flex:1}
87
+ .csb-in,.csb-sel,.csb-ta{box-sizing:border-box;height:32px;padding:0 10px;border-radius:8px;
88
+ border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);
89
+ color:inherit;font-size:13px;font-family:inherit;outline:none}
90
+ .csb-in::placeholder,.csb-ta::placeholder{color:var(--dsw-alias-label-secondary)}
91
+ .csb-in:focus,.csb-sel:focus,.csb-ta:focus{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-1px}
92
+ .csb-ta{height:auto;min-height:76px;padding:8px 10px;resize:vertical;line-height:1.6}
93
+ .csb-grow{flex:1;min-width:140px}
94
+ .csb-btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;height:32px;padding:0 13px;
95
+ border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);
96
+ color:var(--dsw-alias-label-primary);font-size:12.5px;font-family:inherit;cursor:pointer;white-space:nowrap}
97
+ .csb-btn:hover{background:var(--dsw-alias-bg-layer-2)}
98
+ .csb-btn:disabled{opacity:.4;cursor:not-allowed}
99
+ .csb-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
100
+ .csb-btn-p{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-base);border-color:transparent;font-weight:500}
101
+ .csb-btn-p:hover{background:var(--dsw-alias-brand-primary);filter:brightness(1.12)}
102
+ .csb-btn-d{color:var(--dsw-alias-state-error-primary);
103
+ border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent)}
104
+ .csb-btn-d:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent)}
105
+ .csb-btn-s{height:28px;padding:0 10px;font-size:12px}
106
+ .csb-btn-x{border:0;background:transparent;color:var(--dsw-alias-label-secondary);font-size:16px;
107
+ line-height:1;cursor:pointer;padding:4px 6px;font-family:inherit}
108
+ .csb-btn-x:hover{color:var(--dsw-alias-label-primary)}
109
+
110
+ .csb-list{display:flex;flex-direction:column;gap:8px}
111
+ .csb-card{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;
112
+ border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}
113
+ .csb-card:hover{border-color:var(--dsw-alias-border-l2)}
114
+ .csb-card-c{cursor:pointer}
115
+ .csb-card-c:hover{background:var(--dsw-alias-bg-layer-2)}
116
+ .csb-card-on{border-color:var(--dsw-alias-brand-primary)}
117
+ .csb-card-main{flex:1;min-width:0}
118
+ .csb-card-t{font-weight:500;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
119
+ .csb-card-t2{font-weight:500;line-height:1.5;white-space:normal;word-break:break-word}
120
+ .csb-card-m{display:flex;align-items:center;gap:7px;flex-wrap:wrap;
121
+ color:var(--dsw-alias-label-secondary);font-size:11.5px;margin-top:4px}
122
+ .csb-mono{font-family:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:11px}
123
+
124
+ .csb-b{display:inline-flex;align-items:center;height:20px;padding:0 7px;border-radius:5px;
125
+ font-size:11px;font-weight:500;border:1px solid var(--dsw-alias-border-l2);
126
+ color:var(--dsw-alias-label-secondary);white-space:nowrap}
127
+ .csb-b-run{color:var(--dsw-alias-state-warn-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 38%,transparent)}
128
+ .csb-b-ok{color:var(--dsw-alias-state-success-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 38%,transparent)}
129
+ .csb-b-err{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 38%,transparent)}
130
+ .csb-b-idle{color:var(--dsw-alias-state-idle-primary)}
131
+ .csb-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-idle-primary);flex:none}
132
+ .csb-dot-ok{background:var(--dsw-alias-state-success-primary)}
133
+ .csb-dot-err{background:var(--dsw-alias-state-error-primary)}
134
+ .csb-dot-run{background:var(--dsw-alias-state-warn-primary)}
135
+
136
+ .csb-err{padding:10px 12px;border-radius:10px;font-size:12.5px;margin-bottom:14px;line-height:1.6;
137
+ color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-bg-layer-1);
138
+ border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent)}
139
+ .csb-note{padding:9px 12px;border-radius:10px;font-size:12.5px;margin-bottom:14px;line-height:1.6;
140
+ color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1)}
141
+ .csb-ok{padding:9px 12px;border-radius:10px;font-size:12.5px;margin-bottom:14px;
142
+ color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-bg-layer-1);
143
+ border:1px solid color-mix(in srgb,var(--dsw-alias-state-success-primary) 40%,transparent)}
144
+
145
+ .csb-empty{display:flex;flex-direction:column;align-items:center;gap:9px;padding:52px 16px;text-align:center}
146
+ .csb-empty-i{font-size:24px;opacity:.32;line-height:1}
147
+ .csb-empty-t{font-size:13px;color:var(--dsw-alias-label-secondary)}
148
+ .csb-empty-h{font-size:11.5px;color:var(--dsw-alias-label-secondary);max-width:400px;line-height:1.7}
149
+ .csb-sk{height:56px;border-radius:12px;border:1px solid var(--dsw-alias-border-l1);
150
+ background:linear-gradient(90deg,var(--dsw-alias-bg-layer-1) 25%,var(--dsw-alias-bg-layer-2) 37%,var(--dsw-alias-bg-layer-1) 63%);
151
+ background-size:400% 100%;animation:csb-sk 1.3s ease-in-out infinite;margin-bottom:8px}
152
+ @keyframes csb-sk{0%{background-position:100% 0}100%{background-position:0 0}}
153
+
154
+ /* 弹窗与抽屉 */
155
+ .csb-mask{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;
156
+ padding:24px;background:color-mix(in srgb,#000 34%,transparent)}
157
+ .csb-mask-r{align-items:stretch;justify-content:flex-end;padding:0}
158
+ .csb-modal{width:min(520px,100%);max-height:calc(100vh - 48px);display:flex;flex-direction:column;
159
+ border-radius:16px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2)}
160
+ .csb-modal-w{width:min(760px,100%)}
161
+ .csb-drawer{width:min(660px,100%);height:100%;display:flex;flex-direction:column;
162
+ background:var(--dsw-alias-bg-layer-2);border-left:1px solid var(--dsw-alias-border-l1)}
163
+ .csb-mhead{flex:none;display:flex;align-items:center;gap:10px;padding:14px 18px;
164
+ border-bottom:1px solid var(--dsw-alias-border-l1)}
165
+ .csb-mhead h3{margin:0;font-size:14px;font-weight:600;flex:1}
166
+ .csb-mbody{flex:1;overflow-y:auto;padding:16px 18px;min-height:0}
167
+ .csb-mfoot{flex:none;display:flex;gap:8px;justify-content:flex-end;padding:12px 18px;
168
+ border-top:1px solid var(--dsw-alias-border-l1)}
169
+
170
+ .csb-kv{display:grid;grid-template-columns:110px 1fr;gap:8px 14px;font-size:12.5px;margin-bottom:16px}
171
+ .csb-k{color:var(--dsw-alias-label-secondary)}
172
+ .csb-v{word-break:break-word;font-variant-numeric:tabular-nums}
173
+ .csb-pre{white-space:pre-wrap;word-break:break-word;
174
+ font-family:var(--ds-font-family-code,ui-monospace,Menlo,monospace);font-size:11.5px;line-height:1.65;
175
+ background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;
176
+ padding:11px 13px;max-height:320px;overflow:auto;margin:0}
177
+
178
+ .csb-sec{margin:26px 0 6px}
179
+ .csb-sec:first-child{margin-top:0}
180
+ .csb-sec h4{margin:0;font-size:13.5px;font-weight:600;line-height:1.4}
181
+ .csb-sec p{margin:4px 0 0;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary)}
182
+
183
+ .csb-fld{margin-bottom:14px}
184
+ .csb-fld > label{display:block;font-size:12px;font-weight:500;margin-bottom:5px}
185
+ .csb-fld-h{font-size:11.5px;line-height:1.6;color:var(--dsw-alias-label-secondary);margin-top:5px}
186
+
187
+ .csb-set{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:24px;align-items:start;
188
+ padding:16px 0;border-bottom:1px solid var(--dsw-alias-border-l2)}
189
+ .csb-set:last-of-type{border-bottom:0}
190
+ .csb-set-l label{display:block;font-size:13px;line-height:1.45;margin-bottom:4px}
191
+ .csb-set-d{font-size:11.5px;line-height:1.6;color:var(--dsw-alias-label-secondary)}
192
+ .csb-set-c{display:flex;align-items:center;gap:8px;justify-self:end;padding-top:2px}
193
+ .csb-num{position:relative;display:inline-flex;align-items:center}
194
+ .csb-num .csb-in{width:112px;text-align:right;font-variant-numeric:tabular-nums;padding-right:36px}
195
+ .csb-unit{position:absolute;right:10px;font-size:11.5px;color:var(--dsw-alias-label-secondary);pointer-events:none}
196
+ .csb-step{width:28px;height:32px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);
197
+ background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);cursor:pointer;font-size:14px;font-family:inherit}
198
+ .csb-step:hover{background:var(--dsw-alias-bg-layer-2)}
199
+ .csb-sw{position:relative;width:36px;height:20px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);
200
+ background:var(--dsw-alias-bg-layer-1);cursor:pointer;flex:none}
201
+ .csb-sw::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
202
+ background:var(--dsw-alias-label-secondary);transition:transform .15s,background .15s}
203
+ .csb-sw-on{background:var(--dsw-alias-brand-primary);border-color:transparent}
204
+ .csb-sw-on::after{transform:translateX(16px);background:var(--dsw-alias-bg-base)}
205
+ .csb-sw-t{font-size:11.5px;color:var(--dsw-alias-label-secondary);min-width:16px}
206
+
207
+ .csb-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(112px,1fr));gap:12px}
208
+ .csb-stat{padding:12px 14px;border-radius:14px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}
209
+ .csb-stat-n{font-size:20px;font-weight:600;line-height:1.3;font-variant-numeric:tabular-nums}
210
+ .csb-stat-l{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:2px}
211
+ .csb-fold{border:1px solid var(--dsw-alias-border-l1);border-radius:14px;padding:14px 16px;
212
+ background:var(--dsw-alias-bg-base);margin-top:12px}
213
+ .csb-cap{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:6px 16px;margin-top:12px}
214
+ .csb-cap-i{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--dsw-alias-label-secondary);
215
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
216
+
217
+ .csb-msg{padding:9px 12px;border-radius:10px;margin-bottom:6px;background:var(--dsw-alias-bg-layer-1);
218
+ border:1px solid var(--dsw-alias-border-l1)}
219
+ .csb-msg-me{background:var(--dsw-alias-bg-layer-2)}
220
+ .csb-msg-h{display:flex;align-items:center;gap:7px;margin-bottom:3px;font-size:11px;color:var(--dsw-alias-label-secondary)}
221
+ .csb-msg-b{font-size:12.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word}
222
+
223
+ .csb-tags{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
224
+ .csb-tag{display:inline-flex;align-items:center;gap:6px;min-height:26px;padding:3px 8px;border-radius:6px;
225
+ border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);font-size:11.5px}
226
+ .csb-tag-x{border:0;background:transparent;cursor:pointer;color:var(--dsw-alias-label-secondary);
227
+ font-size:13px;line-height:1;padding:0;font-family:inherit}
228
+ .csb-tag-x:hover{color:var(--dsw-alias-state-error-primary)}
229
+ .csb-pick{display:flex;flex-direction:column;gap:4px;max-height:220px;overflow-y:auto;
230
+ border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:6px;background:var(--dsw-alias-bg-base)}
231
+ .csb-pick-i{display:flex;align-items:center;gap:9px;padding:7px 9px;border-radius:7px;cursor:pointer;font-size:12.5px}
232
+ .csb-pick-i:hover{background:var(--dsw-alias-bg-layer-2)}
233
+ .csb-pick-i input{accent-color:var(--dsw-alias-brand-primary);margin:0}
234
+ .csb-crumb{display:flex;align-items:center;gap:4px;flex-wrap:wrap;font-size:12px;margin-bottom:8px}
235
+ .csb-crumb button{border:0;background:transparent;cursor:pointer;color:var(--dsw-alias-label-primary);
236
+ font-size:12px;font-family:inherit;padding:2px 4px;border-radius:4px}
237
+ .csb-crumb button:hover{background:var(--dsw-alias-bg-layer-2)}
238
+ .csb-crumb span{color:var(--dsw-alias-label-secondary)}
239
+ .csb-fslist{display:flex;flex-direction:column;gap:2px;max-height:260px;overflow-y:auto;
240
+ border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:6px;background:var(--dsw-alias-bg-base)}
241
+ .csb-fs-i{display:flex;align-items:center;gap:9px;padding:7px 9px;border-radius:7px;cursor:pointer;font-size:12.5px}
242
+ .csb-fs-i:hover{background:var(--dsw-alias-bg-layer-2)}
243
+ .csb-fs-i-on{background:var(--dsw-alias-bg-layer-2);font-weight:500}
244
+ .csb-fs-n{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
245
+ .csb-steps{display:flex;flex-direction:column;gap:8px}
246
+ .csb-step-row{display:flex;gap:8px;align-items:flex-start;padding:10px;border-radius:10px;
247
+ border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-base)}
248
+ .csb-step-n{width:22px;height:22px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;
249
+ font-size:11px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}
250
+ `
251
+
252
+ function installStyles() {
253
+ try {
254
+ const styles = typeof window !== 'undefined' ? window.__DSH_STYLES__ : undefined
255
+ if (styles?.insert) return styles.insert(CSS)
256
+ } catch {}
257
+ try {
258
+ const id = 'cs-bots/styles'
259
+ if (document.querySelector(`style[data-plugin-css="${id}"]`)) return () => {}
260
+ const el = document.createElement('style')
261
+ el.dataset.plugin = 'cs-bots'
262
+ el.dataset.pluginCss = id
263
+ el.textContent = CSS
264
+ document.head.appendChild(el)
265
+ return () => el.remove()
266
+ } catch {
267
+ return () => {}
268
+ }
269
+ }
270
+
271
+ // ── 基建组件 ────────────────────────────────────────────────────────────
272
+
273
+ class Boundary extends React.Component {
274
+ constructor(props) {
275
+ super(props)
276
+ this.state = { error: null }
277
+ }
278
+ static getDerivedStateFromError(error) {
279
+ return { error }
280
+ }
281
+ componentDidCatch(error) {
282
+ try {
283
+ console.warn('[cs-bots] 页面出错:', this.props.tab, error)
284
+ } catch {}
285
+ }
286
+ render() {
287
+ if (this.state.error) {
288
+ return h('div', { className: 'csb-err' },
289
+ `「${this.props.tab}」出错了:${String(this.state.error.message || this.state.error)}`,
290
+ h('button', { className: 'csb-btn csb-btn-s', style: { marginLeft: 10 }, onClick: () => this.setState({ error: null }) }, '重试'))
291
+ }
292
+ return this.props.children
293
+ }
294
+ }
295
+
296
+ function useApi(path, deps) {
297
+ const [state, setState] = useState({ loading: true, data: null, error: null })
298
+ const reload = useCallback(() => {
299
+ let alive = true
300
+ setState((s) => ({ ...s, loading: true, error: null }))
301
+ call(path)
302
+ .then((d) => alive && setState({ loading: false, data: d, error: null }))
303
+ .catch((e) => alive && setState({ loading: false, data: null, error: e.message }))
304
+ return () => {
305
+ alive = false
306
+ }
307
+ }, [path])
308
+ useEffect(() => reload(), [reload, ...(deps ?? [])])
309
+ return { ...state, reload }
310
+ }
311
+
312
+ function Skeleton({ n }) {
313
+ return h('div', null, Array.from({ length: n ?? 3 }, (_, i) => h('div', { key: i, className: 'csb-sk' })))
314
+ }
315
+ function Empty({ icon, title, hint }) {
316
+ return h('div', { className: 'csb-empty' },
317
+ icon ? h('div', { className: 'csb-empty-i' }, icon) : null,
318
+ h('div', { className: 'csb-empty-t' }, title),
319
+ hint ? h('div', { className: 'csb-empty-h' }, hint) : null)
320
+ }
321
+ function Section({ title, desc, children }) {
322
+ return h('div', { className: 'csb-sec' },
323
+ h('h4', null, title),
324
+ desc ? h('p', null, desc) : null,
325
+ children)
326
+ }
327
+
328
+ /** 站内弹窗:**取代 window.confirm**(沙箱里 confirm 被静默屏蔽)。 */
329
+ function Modal({ title, wide, onClose, footer, children }) {
330
+ useEffect(() => {
331
+ const onKey = (e) => e.key === 'Escape' && onClose?.()
332
+ window.addEventListener('keydown', onKey)
333
+ return () => window.removeEventListener('keydown', onKey)
334
+ }, [onClose])
335
+ return h('div', { className: 'csb-mask', onClick: (e) => e.target === e.currentTarget && onClose?.() },
336
+ h('div', { className: `csb-modal${wide ? ' csb-modal-w' : ''}` },
337
+ h('div', { className: 'csb-mhead' }, h('h3', null, title), h('button', { className: 'csb-btn-x', onClick: onClose, 'aria-label': '关闭' }, '×')),
338
+ h('div', { className: 'csb-mbody' }, children),
339
+ footer ? h('div', { className: 'csb-mfoot' }, footer) : null))
340
+ }
341
+
342
+ /** 抽屉(右侧滑出,用于详情)。 */
343
+ function Drawer({ title, onClose, children, footer }) {
344
+ useEffect(() => {
345
+ const onKey = (e) => e.key === 'Escape' && onClose?.()
346
+ window.addEventListener('keydown', onKey)
347
+ return () => window.removeEventListener('keydown', onKey)
348
+ }, [onClose])
349
+ return h('div', { className: 'csb-mask csb-mask-r', onClick: (e) => e.target === e.currentTarget && onClose?.() },
350
+ h('div', { className: 'csb-drawer' },
351
+ h('div', { className: 'csb-mhead' }, h('h3', null, title), h('button', { className: 'csb-btn-x', onClick: onClose, 'aria-label': '关闭' }, '×')),
352
+ h('div', { className: 'csb-mbody' }, children),
353
+ footer ? h('div', { className: 'csb-mfoot' }, footer) : null))
354
+ }
355
+
356
+ /** 二次确认弹窗(取代 confirm),需要用户明确点主按钮。 */
357
+ function ConfirmModal({ title, message, danger, confirmText, onCancel, onConfirm }) {
358
+ const [busy, setBusy] = useState(false)
359
+ return h(Modal, {
360
+ title,
361
+ onClose: onCancel,
362
+ footer: [
363
+ h('button', { key: 'c', className: 'csb-btn', onClick: onCancel }, '取消'),
364
+ h('button', {
365
+ key: 'o',
366
+ className: `csb-btn ${danger ? 'csb-btn-d' : 'csb-btn-p'}`,
367
+ disabled: busy,
368
+ onClick: async () => {
369
+ setBusy(true)
370
+ try {
371
+ await onConfirm()
372
+ } finally {
373
+ setBusy(false)
374
+ }
375
+ },
376
+ }, busy ? '处理中…' : (confirmText ?? '确认')),
377
+ ],
378
+ }, h('div', { style: { fontSize: 12.5, lineHeight: 1.75, whiteSpace: 'pre-wrap' } }, message))
379
+ }
380
+
381
+ const ST_TEXT = {
382
+ pending: '待办', in_progress: '进行中', completed: '已完成', blocked: '已终止', deleted: '已删除',
383
+ idle: '空闲', running: '运行中', parked: '已挂起', failed: '失败', retired: '已退役',
384
+ }
385
+ const ST_CLS = {
386
+ in_progress: 'csb-b csb-b-run', completed: 'csb-b csb-b-ok', blocked: 'csb-b csb-b-err',
387
+ deleted: 'csb-b csb-b-err', failed: 'csb-b csb-b-err', retired: 'csb-b csb-b-err',
388
+ parked: 'csb-b csb-b-idle', running: 'csb-b csb-b-run',
389
+ }
390
+ const DOT_CLS = {
391
+ in_progress: 'csb-dot csb-dot-run', running: 'csb-dot csb-dot-run',
392
+ completed: 'csb-dot csb-dot-ok', idle: 'csb-dot csb-dot-ok',
393
+ blocked: 'csb-dot csb-dot-err', deleted: 'csb-dot csb-dot-err',
394
+ failed: 'csb-dot csb-dot-err', retired: 'csb-dot csb-dot-err',
395
+ }
396
+ const Badge = ({ status, children }) => h('span', { className: ST_CLS[status] ?? 'csb-b' }, children ?? ST_TEXT[status] ?? status)
397
+ const Dot = ({ status }) => h('span', { className: DOT_CLS[status] ?? 'csb-dot' })
398
+ const Switch = ({ checked, onChange }) =>
399
+ h('button', {
400
+ type: 'button', role: 'switch', 'aria-checked': checked === true,
401
+ className: `csb-sw${checked ? ' csb-sw-on' : ''}`, onClick: () => onChange(!checked),
402
+ })
403
+ const fmt = (v) => {
404
+ if (!v) return '—'
405
+ try {
406
+ return new Date(v).toLocaleString('zh-CN', { hour12: false })
407
+ } catch {
408
+ return String(v)
409
+ }
410
+ }
411
+ const short = (s, n) => (typeof s === 'string' && s.length > (n ?? 14) ? `${s.slice(0, n ?? 14)}…` : String(s ?? '—'))
412
+
413
+ // ── 目录选择器(自建,替代宿主原生 picker)──────────────────────────────
414
+
415
+ /**
416
+ * 目录浏览器。为什么自建:
417
+ * `uiWorkspace.pickDirectory()` 在部分环境(Linux/沙箱/无桌面)不可用,
418
+ * 而且它**不支持新建/重命名**。用户明确要求这两项,所以必须有一套自带的。
419
+ * 优先仍尝试原生 picker(能弹就用),失败则完全走自建列表。
420
+ */
421
+ function DirPicker({ value, onChange, onClose }) {
422
+ const [path, setPath] = useState(value || '')
423
+ const [listing, setListing] = useState(null)
424
+ const [err, setErr] = useState(null)
425
+ const [busy, setBusy] = useState(false)
426
+ const [newName, setNewName] = useState('')
427
+ const [renaming, setRenaming] = useState(null)
428
+ const [renameTo, setRenameTo] = useState('')
429
+ const [showHidden, setShowHidden] = useState(false)
430
+ const [verdict, setVerdict] = useState(null)
431
+
432
+ const load = useCallback(async (p) => {
433
+ setErr(null)
434
+ setBusy(true)
435
+ try {
436
+ const q = `/fs/list?showHidden=${showHidden ? 1 : 0}${p ? `&path=${encodeURIComponent(p)}` : ''}`
437
+ const d = await call(q)
438
+ setListing(d)
439
+ setPath(d.path)
440
+ // 顺手校验"能不能当 Bot 目录"
441
+ try {
442
+ const v = await call('/fs/validate', { method: 'POST', body: { path: d.path } })
443
+ setVerdict(v.verdict)
444
+ } catch {
445
+ setVerdict(null)
446
+ }
447
+ } catch (e) {
448
+ setErr(e.message)
449
+ } finally {
450
+ setBusy(false)
451
+ }
452
+ }, [showHidden])
453
+
454
+ useEffect(() => { load(value || '') }, [load])
455
+
456
+ const doMkdir = async () => {
457
+ const n = newName.trim()
458
+ if (!n) return
459
+ setErr(null)
460
+ try {
461
+ const d = await call('/fs/mkdir', { method: 'POST', body: { path, name: n } })
462
+ setNewName('')
463
+ await load(d.path)
464
+ } catch (e) {
465
+ setErr(e.message)
466
+ }
467
+ }
468
+ const doRename = async () => {
469
+ const n = renameTo.trim()
470
+ if (!n || !renaming) return
471
+ setErr(null)
472
+ try {
473
+ await call('/fs/rename', { method: 'POST', body: { path: renaming, name: n } })
474
+ setRenaming(null)
475
+ setRenameTo('')
476
+ await load(path)
477
+ } catch (e) {
478
+ setErr(e.message)
479
+ }
480
+ }
481
+ const tryNative = async () => {
482
+ try {
483
+ const picked = await hostUiWorkspace?.pickDirectory?.()
484
+ if (picked) {
485
+ onChange(picked)
486
+ onClose?.()
487
+ } else {
488
+ setErr('系统目录选择器不可用(这套环境不提供),请用下面的列表选择')
489
+ }
490
+ } catch (e) {
491
+ setErr(`系统目录选择器不可用:${e.message}。请用下面的列表选择`)
492
+ }
493
+ }
494
+
495
+ const crumbs = (() => {
496
+ if (!path) return []
497
+ const parts = path.split('/').filter(Boolean)
498
+ const out = [{ label: '/', to: '/' }]
499
+ let acc = ''
500
+ for (const p of parts) {
501
+ acc += `/${p}`
502
+ out.push({ label: p, to: acc })
503
+ }
504
+ return out
505
+ })()
506
+
507
+ return h(Drawer, {
508
+ title: '选择工作目录',
509
+ onClose,
510
+ footer: [
511
+ h('button', { key: 'n', className: 'csb-btn', onClick: tryNative }, '用系统选择器'),
512
+ h('div', { key: 's', className: 'csb-spacer' }),
513
+ h('button', { key: 'c', className: 'csb-btn', onClick: onClose }, '取消'),
514
+ h('button', {
515
+ key: 'o', className: 'csb-btn csb-btn-p', disabled: !path,
516
+ onClick: () => { onChange(path); onClose?.() },
517
+ }, '用这个目录'),
518
+ ],
519
+ },
520
+ err ? h('div', { className: 'csb-err' }, err) : null,
521
+ h('div', { className: 'csb-crumb' },
522
+ crumbs.map((c, i) =>
523
+ h('span', { key: c.to, style: { display: 'inline-flex', alignItems: 'center', gap: 4 } },
524
+ i > 0 ? h('span', null, '/') : null,
525
+ h('button', { onClick: () => load(c.to) }, c.label)))),
526
+ h('div', { className: 'csb-bar' },
527
+ h('button', { className: 'csb-btn csb-btn-s', disabled: !listing?.parent, onClick: () => load(listing.parent) }, '↑ 上一级'),
528
+ h('button', { className: 'csb-btn csb-btn-s', onClick: () => load('/') }, '根目录'),
529
+ h('button', { className: 'csb-btn csb-btn-s', onClick: () => load('') }, '家目录'),
530
+ h('div', { className: 'csb-spacer' }),
531
+ h('label', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11.5, color: 'var(--dsw-alias-label-secondary)' } },
532
+ h('input', { type: 'checkbox', checked: showHidden, onChange: (e) => { setShowHidden(e.target.checked); load(path) } }),
533
+ '显示隐藏项')),
534
+ busy && !listing ? h(Skeleton, { n: 3 }) : null,
535
+ listing
536
+ ? h('div', { className: 'csb-fslist' },
537
+ (listing.entries ?? []).length === 0
538
+ ? h('div', { className: 'csb-set-d', style: { padding: '12px 9px' } }, '这个目录是空的')
539
+ : null,
540
+ (listing.entries ?? []).map((e) =>
541
+ h('div', { key: e.path, className: 'csb-fs-i' },
542
+ h('span', { style: { opacity: .55, width: 14, textAlign: 'center' } }, e.kind === 'dir' ? '📁' : '📄'),
543
+ h('span', {
544
+ className: 'csb-fs-n',
545
+ style: { cursor: e.kind === 'dir' ? 'pointer' : 'default' },
546
+ onClick: () => e.kind === 'dir' && load(e.path),
547
+ }, e.name),
548
+ e.kind === 'dir'
549
+ ? h('button', {
550
+ className: 'csb-btn csb-btn-s',
551
+ onClick: () => { setRenaming(e.path); setRenameTo(e.name) },
552
+ }, '重命名')
553
+ : null)))
554
+ : null,
555
+ listing?.error ? h('div', { className: 'csb-note', style: { marginTop: 10 } }, '这个目录读不出内容(权限不足),可以往上一级继续找。') : null,
556
+
557
+ h(Section, { title: '在这里新建子目录', desc: '例如给这个 Bot 建一个专属目录,避免和别的 Bot 抢文件。' }),
558
+ h('div', { className: 'csb-bar' },
559
+ h('input', {
560
+ className: 'csb-in csb-grow', placeholder: '新目录名(字母数字 . _ -)',
561
+ value: newName, onChange: (e) => setNewName(e.target.value),
562
+ onKeyDown: (e) => e.key === 'Enter' && doMkdir(),
563
+ }),
564
+ h('button', { className: 'csb-btn', disabled: !newName.trim(), onClick: doMkdir }, '新建')),
565
+
566
+ renaming
567
+ ? h('div', null,
568
+ h(Section, { title: '重命名目录' }),
569
+ h('div', { className: 'csb-bar' },
570
+ h('input', { className: 'csb-in csb-grow', value: renameTo, onChange: (e) => setRenameTo(e.target.value) }),
571
+ h('button', { className: 'csb-btn csb-btn-p', onClick: doRename }, '确认'),
572
+ h('button', { className: 'csb-btn', onClick: () => { setRenaming(null); setRenameTo('') } }, '取消')))
573
+ : null,
574
+
575
+ verdict
576
+ ? h('div', { style: { marginTop: 12 } },
577
+ verdict.errors?.length
578
+ ? h('div', { className: 'csb-err' }, `这个目录不能用作 Bot 工作目录:${verdict.errors.join(';')}`)
579
+ : h('div', { className: 'csb-ok' }, '这个目录可以用作 Bot 工作目录'),
580
+ verdict.warnings?.length
581
+ ? h('div', { className: 'csb-note', style: { marginTop: 8 } }, `提醒:${verdict.warnings.join(';')}`)
582
+ : null)
583
+ : null)
584
+ }
585
+
586
+ // ── 任务 ────────────────────────────────────────────────────────────────
587
+
588
+ function TaskDrawer({ id, bots, workflows, onClose, onChange }) {
589
+ const { data, loading, error, reload } = useApi(`/tasks/detail?id=${encodeURIComponent(id)}`, [id])
590
+ const [err, setErr] = useState(null)
591
+ const [picking, setPicking] = useState(null) // 'complete' | 'abort'
592
+ const [artifacts, setArtifacts] = useState('')
593
+ const [abortReason, setAbortReason] = useState('需求取消')
594
+ const task = data?.task
595
+ const history = data?.history ?? []
596
+ const act = async (fn, label) => {
597
+ setErr(null)
598
+ try {
599
+ await fn()
600
+ reload()
601
+ onChange?.()
602
+ } catch (e) {
603
+ setErr(`${label}失败:${e.message}`)
604
+ }
605
+ }
606
+ const upd = (action, extra) =>
607
+ call('/tasks/update', { method: 'POST', body: { id: task.id, expectedRev: task.rev, action, ...(extra ?? {}) } })
608
+
609
+ return h(Drawer, { title: task?.subject ?? id, onClose },
610
+ loading ? h(Skeleton, { n: 2 }) : null,
611
+ error ? h('div', { className: 'csb-err' }, error) : null,
612
+ err ? h('div', { className: 'csb-err' }, err) : null,
613
+ task ? h('div', null,
614
+ h('div', { className: 'csb-kv' },
615
+ h('span', { className: 'csb-k' }, '状态'), h('span', { className: 'csb-v' }, h(Badge, { status: task.status })),
616
+ h('span', { className: 'csb-k' }, '任务编号'), h('span', { className: 'csb-v csb-mono' }, task.id),
617
+ h('span', { className: 'csb-k' }, '谁在做'), h('span', { className: 'csb-v' }, task.ownerId ? botName(bots, task.ownerId) : '还没人领'),
618
+ h('span', { className: 'csb-k' }, '完成前必须先'), h('span', { className: 'csb-v' }, (task.blockedBy ?? []).length ? task.blockedBy.join('、') : '无'),
619
+ h('span', { className: 'csb-k' }, '交付物'), h('span', { className: 'csb-v' }, (task.artifacts ?? []).join('、') || '—'),
620
+ h('span', { className: 'csb-k' }, '验收结果'), h('span', { className: 'csb-v' }, task.acceptance ? `${task.acceptance.result === 'pass' ? '通过' : '打回'} · ${task.acceptance.by}` : '未验收'),
621
+ task.blockedReason ? h('span', { className: 'csb-k' }, '终止原因') : null,
622
+ task.blockedReason ? h('span', { className: 'csb-v' }, task.blockedReason) : null),
623
+ task.description ? h('div', { className: 'csb-pre', style: { marginBottom: 4 } }, task.description) : null,
624
+
625
+ h(Section, { title: '可以做什么', desc: '只有和当前状态相符的动作才会显示。' }),
626
+ h('div', { className: 'csb-bar', style: { marginTop: 10 } },
627
+ h('select', {
628
+ className: 'csb-sel', style: { width: 200 }, value: '',
629
+ onChange: (e) => {
630
+ const ownerId = e.target.value
631
+ if (!ownerId) return
632
+ act(() => call('/tasks/assign', { method: 'POST', body: { id: task.id, expectedRev: task.rev, ownerId } }), '指派')
633
+ e.target.value = ''
634
+ },
635
+ },
636
+ h('option', { value: '' }, '指派给某个 Bot…'),
637
+ (bots ?? []).filter((b) => b.state !== 'retired').map((b) =>
638
+ h('option', { key: b.id, value: b.sessionId }, b.name))),
639
+ task.status === 'pending'
640
+ ? h('button', { className: 'csb-btn csb-btn-p', onClick: () => act(() => upd('claim'), '领取') }, '我来做(领取)')
641
+ : null,
642
+ task.status === 'in_progress'
643
+ ? h('button', { className: 'csb-btn csb-btn-p', onClick: () => { setArtifacts(''); setPicking('complete') } }, '标记完成')
644
+ : null,
645
+ task.status === 'completed'
646
+ ? h('button', { className: 'csb-btn csb-btn-p', onClick: () => act(() => call('/tasks/accept', { method: 'POST', body: { id: task.id, result: 'pass' } }), '验收') }, '验收通过')
647
+ : null,
648
+ task.status === 'blocked' ? h('button', { className: 'csb-btn', onClick: () => act(() => upd('reopen'), '重开') }, '重新打开') : null,
649
+ task.status !== 'blocked' && task.status !== 'deleted'
650
+ ? h('button', { className: 'csb-btn csb-btn-d', onClick: () => { setAbortReason('需求取消'); setPicking('abort') } }, '终止任务')
651
+ : null),
652
+
653
+ // 高级:租约(原「续租」按钮,普通人看不懂,收进折叠并解释)
654
+ h(Section, { title: '高级:租约', desc: '租约是「我先占着,别人别抢」的凭据。领取任务默认占 5 分钟,长任务需要隔一会儿点一次「延长」,否则会被自动释放回待办。' }),
655
+ task.status === 'in_progress'
656
+ ? h('div', { className: 'csb-bar', style: { marginTop: 10 } },
657
+ h('span', { className: 'csb-set-d' }, task.lease ? `当前占用到 ${fmt(task.lease.expiresAt)}` : '当前没有租约'),
658
+ h('button', { className: 'csb-btn csb-btn-s', onClick: () => act(() => upd('heartbeat'), '延长') }, '延长 5 分钟'))
659
+ : h('div', { className: 'csb-set-d', style: { marginTop: 10 } }, '任务不在进行中,没有租约。'),
660
+
661
+ h(Section, { title: '审计流', desc: `共 ${history.length} 条,最新在前` }),
662
+ history.length === 0
663
+ ? h(Empty, { title: '还没有操作记录' })
664
+ : h('div', { className: 'csb-list', style: { marginTop: 10 } },
665
+ history.slice(-40).reverse().map((ev, i) =>
666
+ h('div', { key: i, className: 'csb-card', style: { padding: '9px 12px' } },
667
+ h('div', { className: 'csb-card-main' },
668
+ h('div', { style: { fontSize: 12.5 } }, `${ev.action ?? '?'} · 第 ${ev.rev ?? '?'} 版`),
669
+ h('div', { className: 'csb-card-m' }, `${ev.byId ?? '?'} · ${fmt(ev.at)}`)))))
670
+ ) : null,
671
+
672
+ // 完成 / 终止 的站内弹窗(取代 prompt)
673
+ picking === 'complete'
674
+ ? h(Modal, {
675
+ title: '标记完成',
676
+ onClose: () => setPicking(null),
677
+ footer: [
678
+ h('button', { key: 'c', className: 'csb-btn', onClick: () => setPicking(null) }, '取消'),
679
+ h('button', {
680
+ key: 'o', className: 'csb-btn csb-btn-p',
681
+ onClick: async () => {
682
+ const list = artifacts.split(',').map((s) => s.trim()).filter(Boolean)
683
+ if (!list.length) return setErr('必须填写交付物 —— 完成要留证据')
684
+ setPicking(null)
685
+ await act(() => upd('complete', { artifacts: list }), '完成')
686
+ },
687
+ }, '确认完成'),
688
+ ],
689
+ },
690
+ h('div', { className: 'csb-fld' },
691
+ h('label', null, '交付物(必填)'),
692
+ h('input', {
693
+ className: 'csb-in', placeholder: '例如 src/api.js, test/api.test.mjs',
694
+ value: artifacts, onChange: (e) => setArtifacts(e.target.value),
695
+ autoFocus: true,
696
+ }),
697
+ h('div', { className: 'csb-fld-h' }, '多个用英文逗号分隔。这是强制的:不留交付物就不算完成。')))
698
+ : null,
699
+
700
+ picking === 'abort'
701
+ ? h(Modal, {
702
+ title: '终止任务',
703
+ onClose: () => setPicking(null),
704
+ footer: [
705
+ h('button', { key: 'c', className: 'csb-btn', onClick: () => setPicking(null) }, '取消'),
706
+ h('button', {
707
+ key: 'o', className: 'csb-btn csb-btn-d',
708
+ onClick: async () => {
709
+ setPicking(null)
710
+ await act(() => call('/tasks/abort', { method: 'POST', body: { id: task.id, expectedRev: task.rev, reason: abortReason } }), '终止')
711
+ },
712
+ }, '确认终止'),
713
+ ],
714
+ },
715
+ h('div', { className: 'csb-fld' },
716
+ h('label', null, '终止原因'),
717
+ h('input', { className: 'csb-in', value: abortReason, onChange: (e) => setAbortReason(e.target.value) }),
718
+ h('div', { className: 'csb-fld-h' }, '任务会变成「已终止」并保留在列表里,之后可以重新打开。')))
719
+ : null)
720
+ }
721
+
722
+ function botName(bots, sessionId) {
723
+ const b = (bots ?? []).find((x) => x.sessionId === sessionId)
724
+ return b ? b.name : short(sessionId, 16)
725
+ }
726
+
727
+ function TasksTab() {
728
+ const [filter, setFilter] = useState('')
729
+ const [subject, setSubject] = useState('')
730
+ const [openId, setOpenId] = useState(null)
731
+ const [busy, setBusy] = useState(false)
732
+ const [err, setErr] = useState(null)
733
+ const [completing, setCompleting] = useState(null)
734
+ const [artifacts, setArtifacts] = useState('')
735
+ const { data, loading, error, reload } = useApi(`/tasks${filter ? `?status=${filter}` : ''}`, [filter])
736
+ const { data: rosterData } = useApi('/roster')
737
+ const { data: wfData } = useApi('/library/workflows')
738
+ const bots = rosterData?.bots ?? []
739
+ const workflows = wfData?.workflows ?? []
740
+ const tasks = data?.tasks ?? []
741
+
742
+ const create = async () => {
743
+ const s = subject.trim()
744
+ if (!s) return
745
+ setBusy(true)
746
+ setErr(null)
747
+ try {
748
+ await call('/tasks', { method: 'POST', body: { subject: s } })
749
+ setSubject('')
750
+ reload()
751
+ } catch (e) {
752
+ setErr(e.message)
753
+ } finally {
754
+ setBusy(false)
755
+ }
756
+ }
757
+ const quick = async (t, action, extra) => {
758
+ setBusy(true)
759
+ setErr(null)
760
+ try {
761
+ await call('/tasks/update', { method: 'POST', body: { id: t.id, expectedRev: t.rev, action, ...(extra ?? {}) } })
762
+ reload()
763
+ } catch (e) {
764
+ setErr(e.message)
765
+ } finally {
766
+ setBusy(false)
767
+ }
768
+ }
769
+
770
+ return h('div', { className: 'csb-wrap' },
771
+ h(Section, {
772
+ title: '任务板',
773
+ desc: '跨会话共享的一块板:任何会话、任何 Bot 看到的都是同一批任务。完成必须填交付物,否则不算完成。',
774
+ }),
775
+ h('div', { className: 'csb-bar', style: { marginTop: 12 } },
776
+ h('input', {
777
+ className: 'csb-in csb-grow', placeholder: '要做什么?(例如:实现登录接口。回车创建)',
778
+ value: subject, onChange: (e) => setSubject(e.target.value),
779
+ onKeyDown: (e) => e.key === 'Enter' && create(),
780
+ }),
781
+ h('button', { className: 'csb-btn csb-btn-p', disabled: busy || !subject.trim(), onClick: create }, '创建任务'),
782
+ h('select', { className: 'csb-sel', style: { width: 116 }, value: filter, onChange: (e) => setFilter(e.target.value) },
783
+ h('option', { value: '' }, '全部状态'),
784
+ h('option', { value: 'pending' }, '待办'),
785
+ h('option', { value: 'in_progress' }, '进行中'),
786
+ h('option', { value: 'completed' }, '已完成'),
787
+ h('option', { value: 'blocked' }, '已终止'))),
788
+ err ? h('div', { className: 'csb-err' }, err) : null,
789
+ error ? h('div', { className: 'csb-err' }, error) : null,
790
+ loading ? h(Skeleton, { n: 3 }) : null,
791
+ !loading && !error && tasks.length === 0
792
+ ? h(Empty, {
793
+ icon: '◫', title: '还没有任务',
794
+ hint: '在上面输入一句话就能建任务。建好后可以指派给某个 Bot,也可以自己领取来做。',
795
+ })
796
+ : null,
797
+ h('div', { className: 'csb-list' }, tasks.map((t) =>
798
+ h('div', { key: t.id, className: 'csb-card csb-card-c', onClick: () => setOpenId(t.id) },
799
+ h(Dot, { status: t.status }),
800
+ h('div', { className: 'csb-card-main' },
801
+ h('div', { className: 'csb-card-t' }, t.subject),
802
+ h('div', { className: 'csb-card-m' },
803
+ h(Badge, { status: t.status }),
804
+ h('span', null, t.ownerId ? `由 ${botName(bots, t.ownerId)} 负责` : '还没人领'),
805
+ (t.blockedBy ?? []).length ? h('span', null, ` · 等 ${t.blockedBy.length} 个前置任务`) : null,
806
+ h('span', null, ` · 第 ${t.rev} 版`))),
807
+ h('div', { style: { display: 'flex', gap: 6, flex: 'none' }, onClick: (e) => e.stopPropagation() },
808
+ t.status === 'pending'
809
+ ? h('button', { className: 'csb-btn csb-btn-s', disabled: busy, onClick: () => quick(t, 'claim') }, '我来做')
810
+ : null,
811
+ t.status === 'in_progress'
812
+ ? h('button', {
813
+ className: 'csb-btn csb-btn-s csb-btn-p', disabled: busy,
814
+ onClick: () => { setCompleting(t); setArtifacts('') },
815
+ }, '完成')
816
+ : null,
817
+ t.status === 'completed'
818
+ ? h('button', {
819
+ className: 'csb-btn csb-btn-s', disabled: busy,
820
+ onClick: () => call('/tasks/accept', { method: 'POST', body: { id: t.id, result: 'pass' } }).then(reload).catch((e) => setErr(e.message)),
821
+ }, '验收')
822
+ : null)))),
823
+ openId ? h(TaskDrawer, { id: openId, bots, workflows, onClose: () => setOpenId(null), onChange: reload }) : null,
824
+ completing
825
+ ? h(Modal, {
826
+ title: `完成:${completing.subject}`,
827
+ onClose: () => setCompleting(null),
828
+ footer: [
829
+ h('button', { key: 'c', className: 'csb-btn', onClick: () => setCompleting(null) }, '取消'),
830
+ h('button', {
831
+ key: 'o', className: 'csb-btn csb-btn-p',
832
+ onClick: async () => {
833
+ const list = artifacts.split(',').map((s) => s.trim()).filter(Boolean)
834
+ if (!list.length) return setErr('必须填写交付物')
835
+ const t = completing
836
+ setCompleting(null)
837
+ await quick(t, 'complete', { artifacts: list })
838
+ },
839
+ }, '确认完成'),
840
+ ],
841
+ },
842
+ h('div', { className: 'csb-fld' },
843
+ h('label', null, '交付物(必填)'),
844
+ h('input', {
845
+ className: 'csb-in', placeholder: '例如 src/api.js, test/api.test.mjs',
846
+ value: artifacts, onChange: (e) => setArtifacts(e.target.value), autoFocus: true,
847
+ }),
848
+ h('div', { className: 'csb-fld-h' }, '多个用英文逗号分隔。')) )
849
+ : null)
850
+ }
851
+
852
+ // ── 技能库 / 工作流库 ───────────────────────────────────────────────────
853
+
854
+ function SkillsTab() {
855
+ const { data, loading, error, reload } = useApi('/library/skills')
856
+ const [editing, setEditing] = useState(null) // null | {} | skill
857
+ const [confirmDel, setConfirmDel] = useState(null)
858
+ const [err, setErr] = useState(null)
859
+ const skills = data?.skills ?? []
860
+
861
+ return h('div', { className: 'csb-wrap' },
862
+ h(Section, {
863
+ title: '技能库',
864
+ desc: '可复用的「怎么做某件事」的说明书。建好后可以在 Bot 的档案里关联,Bot 就能在它自己的工作目录里加载到。',
865
+ }),
866
+ h('div', { className: 'csb-bar', style: { marginTop: 12 } },
867
+ h('button', { className: 'csb-btn csb-btn-p', onClick: () => setEditing({}) }, '新建技能')),
868
+ err ? h('div', { className: 'csb-err' }, err) : null,
869
+ error ? h('div', { className: 'csb-err' }, error) : null,
870
+ loading ? h(Skeleton, { n: 2 }) : null,
871
+ !loading && skills.length === 0
872
+ ? h(Empty, { icon: '◇', title: '技能库是空的', hint: '一个技能就是一段说明:这个 Bot 遇到某类任务时应该怎么做。写一次,多个 Bot 都能用。' })
873
+ : null,
874
+ h('div', { className: 'csb-list' }, skills.map((s) =>
875
+ h('div', { key: s.id, className: 'csb-card' },
876
+ h('div', { className: 'csb-card-main' },
877
+ h('div', { className: 'csb-card-t' }, s.name),
878
+ h('div', { className: 'csb-card-m' },
879
+ s.description ? h('span', null, s.description) : h('span', null, '(无说明)'),
880
+ h('span', null, `· 第 ${s.rev} 版`))),
881
+ h('div', { style: { display: 'flex', gap: 6, flex: 'none' } },
882
+ h('button', { className: 'csb-btn csb-btn-s', onClick: () => setEditing(s) }, '编辑'),
883
+ h('button', { className: 'csb-btn csb-btn-s csb-btn-d', onClick: () => setConfirmDel(s) }, '删除'))))),
884
+ editing
885
+ ? h(SkillEditor, {
886
+ skill: editing, onClose: () => setEditing(null),
887
+ onSaved: () => { setEditing(null); reload() },
888
+ onError: setErr,
889
+ })
890
+ : null,
891
+ confirmDel
892
+ ? h(ConfirmModal, {
893
+ title: '删除技能?',
894
+ message: `「${confirmDel.name}」会从技能库移除。\n已经关联了这个技能的 Bot,下次同步时不会再生成对应的技能文件。`,
895
+ danger: true, confirmText: '删除',
896
+ onCancel: () => setConfirmDel(null),
897
+ onConfirm: async () => {
898
+ try {
899
+ await call('/library/skills/remove', { method: 'POST', body: { id: confirmDel.id, by: 'ui' } })
900
+ setConfirmDel(null)
901
+ reload()
902
+ } catch (e) {
903
+ setErr(e.message)
904
+ setConfirmDel(null)
905
+ }
906
+ },
907
+ })
908
+ : null)
909
+ }
910
+
911
+ function SkillEditor({ skill, onClose, onSaved, onError }) {
912
+ const isNew = !skill.id
913
+ const [form, setForm] = useState({
914
+ name: skill.name ?? '', description: skill.description ?? '', body: skill.body ?? '',
915
+ })
916
+ const save = async () => {
917
+ try {
918
+ if (isNew) {
919
+ await call('/library/skills', { method: 'POST', body: { ...form, by: 'ui' } })
920
+ } else {
921
+ await call('/library/skills/update', {
922
+ method: 'POST',
923
+ body: { id: skill.id, expectedRev: skill.rev, ...form, by: 'ui' },
924
+ })
925
+ }
926
+ onSaved()
927
+ } catch (e) {
928
+ onError(e.message)
929
+ onClose()
930
+ }
931
+ }
932
+ return h(Modal, {
933
+ title: isNew ? '新建技能' : `编辑技能 · ${skill.name}`,
934
+ wide: true,
935
+ onClose,
936
+ footer: [
937
+ h('button', { key: 'c', className: 'csb-btn', onClick: onClose }, '取消'),
938
+ h('button', { key: 's', className: 'csb-btn csb-btn-p', disabled: !form.name.trim() || !form.body.trim(), onClick: save }, '保存'),
939
+ ],
940
+ },
941
+ h('div', { className: 'csb-fld' },
942
+ h('label', null, '技能名'),
943
+ h('input', {
944
+ className: 'csb-in', value: form.name,
945
+ placeholder: '例如 code-review(会成为目录名,只能用字母数字 . _ -)',
946
+ onChange: (e) => setForm({ ...form, name: e.target.value }),
947
+ }),
948
+ h('div', { className: 'csb-fld-h' }, '这个名字会变成工作目录下的 .agents/skills/<名字>/,所以要短、要英文。')),
949
+ h('div', { className: 'csb-fld' },
950
+ h('label', null, '一句话说明'),
951
+ h('input', {
952
+ className: 'csb-in', value: form.description,
953
+ placeholder: '例如:提交前检查测试覆盖与命名规范',
954
+ onChange: (e) => setForm({ ...form, description: e.target.value }),
955
+ })),
956
+ h('div', { className: 'csb-fld' },
957
+ h('label', null, '技能正文'),
958
+ h('textarea', {
959
+ className: 'csb-ta', style: { minHeight: 220 },
960
+ value: form.body,
961
+ placeholder: '写清楚:什么情况下用它、具体步骤、判断标准、常见坑。',
962
+ onChange: (e) => setForm({ ...form, body: e.target.value }),
963
+ })))
964
+ }
965
+
966
+ function WorkflowsTab() {
967
+ const { data, loading, error, reload } = useApi('/library/workflows')
968
+ const [editing, setEditing] = useState(null)
969
+ const [confirmDel, setConfirmDel] = useState(null)
970
+ const [err, setErr] = useState(null)
971
+ const workflows = data?.workflows ?? []
972
+
973
+ return h('div', { className: 'csb-wrap' },
974
+ h(Section, {
975
+ title: '工作流库',
976
+ desc: '把「一件事的标准步骤」存下来。Bot 可以关联它,任务也可以关联它 —— 目的是让大家按同一套步骤做事,而不是各做各的。',
977
+ }),
978
+ h('div', { className: 'csb-bar', style: { marginTop: 12 } },
979
+ h('button', { className: 'csb-btn csb-btn-p', onClick: () => setEditing({ steps: [{ title: '' }] }) }, '新建工作流')),
980
+ err ? h('div', { className: 'csb-err' }, err) : null,
981
+ error ? h('div', { className: 'csb-err' }, error) : null,
982
+ loading ? h(Skeleton, { n: 2 }) : null,
983
+ !loading && workflows.length === 0
984
+ ? h(Empty, { icon: '⇉', title: '工作流库是空的', hint: '例如「发布流程」:改版本号 → 跑测试 → 打 tag → 推送。存一次,所有任务都能引用。' })
985
+ : null,
986
+ h('div', { className: 'csb-list' }, workflows.map((w) =>
987
+ h('div', { key: w.id, className: 'csb-card' },
988
+ h('div', { className: 'csb-card-main' },
989
+ h('div', { className: 'csb-card-t' }, w.name),
990
+ h('div', { className: 'csb-card-m' },
991
+ w.description ? h('span', null, w.description) : h('span', null, '(无说明)'),
992
+ h('span', null, `· ${(w.steps ?? []).length} 步`),
993
+ h('span', null, `· 第 ${w.rev} 版`))),
994
+ h('div', { style: { display: 'flex', gap: 6, flex: 'none' } },
995
+ h('button', { className: 'csb-btn csb-btn-s', onClick: () => setEditing(w) }, '编辑'),
996
+ h('button', { className: 'csb-btn csb-btn-s csb-btn-d', onClick: () => setConfirmDel(w) }, '删除'))))),
997
+ editing
998
+ ? h(WorkflowEditor, {
999
+ workflow: editing, onClose: () => setEditing(null),
1000
+ onSaved: () => { setEditing(null); reload() },
1001
+ onError: setErr,
1002
+ })
1003
+ : null,
1004
+ confirmDel
1005
+ ? h(ConfirmModal, {
1006
+ title: '删除工作流?',
1007
+ message: `「${confirmDel.name}」会从工作流库移除。已经关联它的 Bot 和任务不会自动改,只是引用会失效。`,
1008
+ danger: true, confirmText: '删除',
1009
+ onCancel: () => setConfirmDel(null),
1010
+ onConfirm: async () => {
1011
+ try {
1012
+ await call('/library/workflows/remove', { method: 'POST', body: { id: confirmDel.id, by: 'ui' } })
1013
+ setConfirmDel(null)
1014
+ reload()
1015
+ } catch (e) {
1016
+ setErr(e.message)
1017
+ setConfirmDel(null)
1018
+ }
1019
+ },
1020
+ })
1021
+ : null)
1022
+ }
1023
+
1024
+ function WorkflowEditor({ workflow, onClose, onSaved, onError }) {
1025
+ const isNew = !workflow.id
1026
+ const [form, setForm] = useState({
1027
+ name: workflow.name ?? '',
1028
+ description: workflow.description ?? '',
1029
+ steps: (workflow.steps ?? [{ title: '' }]).map((s) => ({ title: s.title ?? '', detail: s.detail ?? '' })),
1030
+ })
1031
+ const setStep = (i, patch) => {
1032
+ const steps = form.steps.map((s, j) => (j === i ? { ...s, ...patch } : s))
1033
+ setForm({ ...form, steps })
1034
+ }
1035
+ const save = async () => {
1036
+ const steps = form.steps.filter((s) => s.title.trim())
1037
+ if (!steps.length) return onError('至少要有一个步骤')
1038
+ try {
1039
+ const body = { name: form.name, description: form.description, steps, by: 'ui' }
1040
+ if (isNew) await call('/library/workflows', { method: 'POST', body })
1041
+ else await call('/library/workflows/update', { method: 'POST', body: { id: workflow.id, expectedRev: workflow.rev, ...body } })
1042
+ onSaved()
1043
+ } catch (e) {
1044
+ onError(e.message)
1045
+ onClose()
1046
+ }
1047
+ }
1048
+ return h(Modal, {
1049
+ title: isNew ? '新建工作流' : `编辑工作流 · ${workflow.name}`,
1050
+ wide: true,
1051
+ onClose,
1052
+ footer: [
1053
+ h('button', { key: 'c', className: 'csb-btn', onClick: onClose }, '取消'),
1054
+ h('button', { key: 's', className: 'csb-btn csb-btn-p', disabled: !form.name.trim(), onClick: save }, '保存'),
1055
+ ],
1056
+ },
1057
+ h('div', { className: 'csb-fld' },
1058
+ h('label', null, '工作流名'),
1059
+ h('input', {
1060
+ className: 'csb-in', value: form.name, placeholder: '例如:发布流程',
1061
+ onChange: (e) => setForm({ ...form, name: e.target.value }),
1062
+ })),
1063
+ h('div', { className: 'csb-fld' },
1064
+ h('label', null, '一句话说明'),
1065
+ h('input', {
1066
+ className: 'csb-in', value: form.description, placeholder: '例如:从改版本号到推 tag 的完整步骤',
1067
+ onChange: (e) => setForm({ ...form, description: e.target.value }),
1068
+ })),
1069
+ h('div', { className: 'csb-fld' },
1070
+ h('label', null, '步骤'),
1071
+ h('div', { className: 'csb-steps' },
1072
+ form.steps.map((s, i) =>
1073
+ h('div', { key: i, className: 'csb-step-row' },
1074
+ h('div', { className: 'csb-step-n' }, String(i + 1)),
1075
+ h('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', gap: 6 } },
1076
+ h('input', {
1077
+ className: 'csb-in', placeholder: `第 ${i + 1} 步做什么`,
1078
+ value: s.title, onChange: (e) => setStep(i, { title: e.target.value }),
1079
+ }),
1080
+ h('input', {
1081
+ className: 'csb-in', placeholder: '补充细节(可空)',
1082
+ value: s.detail, onChange: (e) => setStep(i, { detail: e.target.value }),
1083
+ })),
1084
+ h('button', {
1085
+ className: 'csb-btn-x',
1086
+ onClick: () => setForm({ ...form, steps: form.steps.filter((_, j) => j !== i) }),
1087
+ 'aria-label': '删除这一步',
1088
+ }, '×'))),
1089
+ h('button', {
1090
+ className: 'csb-btn', style: { alignSelf: 'flex-start' },
1091
+ onClick: () => setForm({ ...form, steps: [...form.steps, { title: '', detail: '' }] }),
1092
+ }, '+ 加一步'))))
1093
+ }
1094
+
1095
+ // ── Bot ─────────────────────────────────────────────────────────────────
1096
+
1097
+ function BotsTab() {
1098
+ const { data, loading, error, reload } = useApi('/roster?includeRetired=1')
1099
+ const { data: presetData } = useApi('/presets')
1100
+ const [form, setForm] = useState({ name: '', cwd: '', preset: '' })
1101
+ const [picking, setPicking] = useState(false)
1102
+ const [busy, setBusy] = useState(false)
1103
+ const [err, setErr] = useState(null)
1104
+ const [warn, setWarn] = useState(null)
1105
+ const [profileFor, setProfileFor] = useState(null)
1106
+ const [renaming, setRenaming] = useState(null)
1107
+ const [renameTo, setRenameTo] = useState('')
1108
+ const [retiring, setRetiring] = useState(null)
1109
+
1110
+ const presets = presetData?.presets ?? []
1111
+ const defaultPreset = presetData?.defaultId ?? ''
1112
+ const bots = data?.bots ?? []
1113
+
1114
+ const create = async () => {
1115
+ if (!form.name.trim() || !form.cwd.trim()) return setErr('名字和工作目录都要填')
1116
+ setBusy(true)
1117
+ setErr(null)
1118
+ setWarn(null)
1119
+ try {
1120
+ const res = await call('/roster', {
1121
+ method: 'POST',
1122
+ body: { name: form.name.trim(), cwd: form.cwd.trim(), ...(form.preset ? { preset: form.preset } : {}) },
1123
+ })
1124
+ setForm({ name: '', cwd: '', preset: '' })
1125
+ if (res.warnings?.length) setWarn(res.warnings.join('\n'))
1126
+ reload()
1127
+ } catch (e) {
1128
+ setErr(e.message)
1129
+ } finally {
1130
+ setBusy(false)
1131
+ }
1132
+ }
1133
+ const manage = async (bot, action, extra) => {
1134
+ setErr(null)
1135
+ try {
1136
+ await call('/roster/manage', { method: 'POST', body: { id: bot.id, action, expectedRev: bot.rev, ...(extra ?? {}) } })
1137
+ reload()
1138
+ } catch (e) {
1139
+ setErr(e.message)
1140
+ }
1141
+ }
1142
+ const openSession = (bot) => {
1143
+ try {
1144
+ if (!hostUiWorkspace?.openSession) return setErr('当前环境不支持跳转到会话,可在左侧会话列表里找它的名字')
1145
+ hostUiWorkspace.openSession({ sessionId: bot.sessionId })
1146
+ } catch (e) {
1147
+ setErr(`无法打开会话:${e.message}`)
1148
+ }
1149
+ }
1150
+
1151
+ return h('div', { className: 'csb-wrap' },
1152
+ h(Section, {
1153
+ title: 'Bot 是什么',
1154
+ desc: '一个 Bot = 一个长期存在的成员:有自己的会话、自己的工作目录(决定了它能改哪些文件)、自己的身份和经验。只有「需要长期存在 + 需要独立目录」的角色才值得建 Bot。',
1155
+ }),
1156
+
1157
+ h(Section, { title: '新建 Bot' }),
1158
+ h('div', { className: 'csb-fld', style: { marginTop: 10 } },
1159
+ h('label', null, '名字'),
1160
+ h('input', {
1161
+ className: 'csb-in', style: { maxWidth: 260 }, placeholder: '例如:后端、前端、测试',
1162
+ value: form.name, onChange: (e) => setForm({ ...form, name: e.target.value }),
1163
+ })),
1164
+ h('div', { className: 'csb-fld' },
1165
+ h('label', null, '工作目录'),
1166
+ h('div', { className: 'csb-bar', style: { marginBottom: 6 } },
1167
+ h('input', {
1168
+ className: 'csb-in csb-grow', placeholder: '点右边「选择目录」,或直接粘贴绝对路径',
1169
+ value: form.cwd, onChange: (e) => setForm({ ...form, cwd: e.target.value }),
1170
+ }),
1171
+ h('button', { className: 'csb-btn', onClick: () => setPicking(true) }, '选择目录')),
1172
+ h('div', { className: 'csb-fld-h' },
1173
+ '这个目录就是它的「写入边界」:它只能改这里面的文件。建议每个 Bot 一个独立目录,避免互相覆盖。')),
1174
+ h('div', { className: 'csb-fld' },
1175
+ h('label', null, '专家身份(预设)'),
1176
+ h('select', {
1177
+ className: 'csb-sel', style: { maxWidth: 300 }, value: form.preset,
1178
+ onChange: (e) => setForm({ ...form, preset: e.target.value }),
1179
+ },
1180
+ h('option', { value: '' }, `用默认(${defaultPreset || 'standard'})`),
1181
+ presets.map((p) => h('option', { key: p.id, value: p.id }, p.broken ? `${p.name}(当前不可用)` : (p.description ? `${p.name} —— ${p.description}` : p.name)))),
1182
+ h('div', { className: 'csb-fld-h' },
1183
+ '预设决定「它是哪类专家」:人格、可用工具都不一样。建好后还能在「档案」里补写更具体的身份与经验。')),
1184
+ h('div', { className: 'csb-bar' },
1185
+ h('button', { className: 'csb-btn csb-btn-p', disabled: busy, onClick: create }, busy ? '创建中…' : '创建 Bot')),
1186
+ err ? h('div', { className: 'csb-err' }, err) : null,
1187
+ warn ? h('div', { className: 'csb-note' }, warn) : null,
1188
+
1189
+ h(Section, { title: '所有 Bot', desc: `${bots.filter((b) => b.state !== 'retired').length} 个在用` }),
1190
+ loading ? h(Skeleton, { n: 2 }) : null,
1191
+ error ? h('div', { className: 'csb-err' }, error) : null,
1192
+ !loading && bots.length === 0
1193
+ ? h(Empty, { icon: '⬡', title: '还没有 Bot', hint: '先在上面建一个。如果只是临时分工,不用建 Bot —— 直接建任务指派给人就行。' })
1194
+ : null,
1195
+ h('div', { className: 'csb-list', style: { marginTop: 12 } }, bots.map((b) =>
1196
+ h('div', { key: b.id, className: 'csb-card' },
1197
+ h(Dot, { status: b.state }),
1198
+ h('div', { className: 'csb-card-main' },
1199
+ h('div', { className: 'csb-card-t' }, b.name),
1200
+ h('div', { className: 'csb-card-m' },
1201
+ h(Badge, { status: b.state }),
1202
+ h('span', null, b.preset ? `身份:${b.preset}` : '身份:默认'),
1203
+ h('span', { className: 'csb-mono' }, b.cwd),
1204
+ b.profile?.identity ? h('span', { className: 'csb-b csb-b-ok' }, '已写身份') : null,
1205
+ (b.profile?.skills ?? []).length ? h('span', { className: 'csb-b' }, `${b.profile.skills.length} 技能`) : null,
1206
+ (b.profile?.memory ?? []).length ? h('span', { className: 'csb-b' }, `${b.profile.memory.length} 记忆`) : null)),
1207
+ h('div', { style: { display: 'flex', gap: 6, flex: 'none' } },
1208
+ b.state !== 'retired' ? h('button', { className: 'csb-btn csb-btn-s csb-btn-p', onClick: () => setProfileFor(b) }, '档案') : null,
1209
+ b.state !== 'retired' ? h('button', { className: 'csb-btn csb-btn-s', onClick: () => openSession(b) }, '打开会话') : null,
1210
+ b.state !== 'retired'
1211
+ ? h('button', { className: 'csb-btn csb-btn-s', onClick: () => { setRenaming(b); setRenameTo(b.name) } }, '改名')
1212
+ : null,
1213
+ b.state !== 'retired'
1214
+ ? h('button', { className: 'csb-btn csb-btn-s csb-btn-d', onClick: () => setRetiring(b) }, '退役')
1215
+ : null)))),
1216
+
1217
+ picking ? h(DirPicker, { value: form.cwd, onChange: (p) => setForm({ ...form, cwd: p }), onClose: () => setPicking(false) }) : null,
1218
+
1219
+ renaming
1220
+ ? h(Modal, {
1221
+ title: `改名 · ${renaming.name}`,
1222
+ onClose: () => setRenaming(null),
1223
+ footer: [
1224
+ h('button', { key: 'c', className: 'csb-btn', onClick: () => setRenaming(null) }, '取消'),
1225
+ h('button', {
1226
+ key: 'o', className: 'csb-btn csb-btn-p',
1227
+ onClick: async () => {
1228
+ const b = renaming
1229
+ setRenaming(null)
1230
+ if (renameTo.trim()) await manage(b, 'rename', { name: renameTo.trim() })
1231
+ },
1232
+ }, '保存'),
1233
+ ],
1234
+ },
1235
+ h('div', { className: 'csb-fld' },
1236
+ h('label', null, '新名字'),
1237
+ h('input', { className: 'csb-in', value: renameTo, onChange: (e) => setRenameTo(e.target.value), autoFocus: true }),
1238
+ h('div', { className: 'csb-fld-h' }, '名字在未退役的 Bot 之间必须唯一。')))
1239
+ : null,
1240
+
1241
+ retiring
1242
+ ? h(ConfirmModal, {
1243
+ title: `退役 ${retiring.name}?`,
1244
+ message: `退役后:\n· 名字会被释放(别的 Bot 可以用这个名字)\n· 目录名额释放\n· **会话和历史都保留**,不会丢\n\n想彻底清掉会话,请到左侧会话列表里归档它。`,
1245
+ danger: true, confirmText: '确认退役',
1246
+ onCancel: () => setRetiring(null),
1247
+ onConfirm: async () => {
1248
+ const b = retiring
1249
+ setRetiring(null)
1250
+ await manage(b, 'retire', { reason: '在界面上退役' })
1251
+ },
1252
+ })
1253
+ : null,
1254
+
1255
+ profileFor
1256
+ ? h(BotProfileDrawer, { bot: profileFor, onClose: () => setProfileFor(null), onChange: reload })
1257
+ : null)
1258
+ }
1259
+
1260
+ /**
1261
+ * Bot 档案:身份 / 经验 / 技能 / 工作流 / 记忆。
1262
+ * 全部落到官方机制上(AGENTS.md + .agents/skills),不自造运行时。
1263
+ */
1264
+ function BotProfileDrawer({ bot, onClose, onChange }) {
1265
+ const { data, loading, error, reload } = useApi(`/roster/profile?id=${encodeURIComponent(bot.id)}`, [bot.id, bot.rev])
1266
+ const { data: libData } = useApi('/library/skills')
1267
+ const { data: wfData } = useApi('/library/workflows')
1268
+ const [draft, setDraft] = useState(null)
1269
+ const [msg, setMsg] = useState(null)
1270
+ const [err, setErr] = useState(null)
1271
+ const [busy, setBusy] = useState(false)
1272
+ const [skillInput, setSkillInput] = useState('')
1273
+ const [memInput, setMemInput] = useState('')
1274
+
1275
+ useEffect(() => {
1276
+ if (data && draft === null) {
1277
+ const p = data.profile ?? {}
1278
+ setDraft({
1279
+ identity: p.identity ?? '',
1280
+ experience: p.experience ?? '',
1281
+ skills: Array.isArray(p.skills) ? [...p.skills] : [],
1282
+ workflows: Array.isArray(p.workflows) ? [...p.workflows] : [],
1283
+ memory: Array.isArray(p.memory) ? [...p.memory] : [],
1284
+ })
1285
+ }
1286
+ }, [data, draft])
1287
+
1288
+ const save = async () => {
1289
+ setBusy(true)
1290
+ setErr(null)
1291
+ setMsg(null)
1292
+ try {
1293
+ const res = await call('/roster/profile', {
1294
+ method: 'POST',
1295
+ body: { id: bot.id, expectedRev: data?.bot?.rev ?? bot.rev, profile: draft },
1296
+ })
1297
+ setMsg(`已保存${res.memory ? `,同步了 ${res.memory.added} 条记忆` : ''}`)
1298
+ reload()
1299
+ onChange?.()
1300
+ } catch (e) {
1301
+ setErr(e.message)
1302
+ } finally {
1303
+ setBusy(false)
1304
+ }
1305
+ }
1306
+
1307
+ const libSkills = libData?.skills ?? []
1308
+ const libWfs = wfData?.workflows ?? []
1309
+
1310
+ return h(Drawer, {
1311
+ title: `档案 · ${bot.name}`,
1312
+ onClose,
1313
+ footer: [
1314
+ h('button', { key: 'r', className: 'csb-btn', onClick: () => { setDraft(null); reload() } }, '重新载入'),
1315
+ h('button', { key: 's', className: 'csb-btn csb-btn-p', disabled: busy || !draft, onClick: save }, busy ? '保存中…' : '保存档案'),
1316
+ ],
1317
+ },
1318
+ loading ? h(Skeleton, { n: 2 }) : null,
1319
+ error ? h('div', { className: 'csb-err' }, error) : null,
1320
+ msg ? h('div', { className: 'csb-ok' }, msg) : null,
1321
+ err ? h('div', { className: 'csb-err' }, err) : null,
1322
+ data && draft ? h('div', null,
1323
+ h('div', { className: 'csb-kv' },
1324
+ h('span', { className: 'csb-k' }, '工作目录'), h('span', { className: 'csb-v csb-mono' }, bot.cwd),
1325
+ h('span', { className: 'csb-k' }, '专家身份'), h('span', { className: 'csb-v' }, bot.preset ?? '默认'),
1326
+ h('span', { className: 'csb-k' }, '档案文件'), h('span', { className: 'csb-v csb-mono' }, data.profilePath ?? '—')),
1327
+ data.hasOutside
1328
+ ? h('div', { className: 'csb-note' }, '你的 AGENTS.md 里还有手写内容 —— 保存档案不会覆盖它,只改 cs-bots 托管的那一段。')
1329
+ : null,
1330
+
1331
+ h(Section, { title: '① 身份', desc: '它是谁。这段话会在每次对话开始前进到它的上下文里。' }),
1332
+ h('textarea', {
1333
+ className: 'csb-ta', style: { marginTop: 10 },
1334
+ placeholder: '例如:你是本项目的后端工程师,负责 API 设计、数据模型与数据库迁移;对 SQL 性能负责;拿不准时先给方案再动手。',
1335
+ value: draft.identity, onChange: (e) => setDraft({ ...draft, identity: e.target.value }),
1336
+ }),
1337
+
1338
+ h(Section, { title: '② 经验与规矩', desc: '它踩过的坑、你定下的规矩。写在这里的每一条,它每次都会看到。' }),
1339
+ h('textarea', {
1340
+ className: 'csb-ta', style: { marginTop: 10, minHeight: 100 },
1341
+ placeholder: '例如:改数据库结构必须先写迁移;禁止直接 force push;测试必须全绿才算完成。',
1342
+ value: draft.experience, onChange: (e) => setDraft({ ...draft, experience: e.target.value }),
1343
+ }),
1344
+
1345
+ h(Section, { title: '③ 技能', desc: '从技能库挑,或临时加一个名字。勾选的技能会同步到它自己目录下的 .agents/skills/,别的 Bot 看不到。' }),
1346
+ h('div', { className: 'csb-pick', style: { marginTop: 10 } },
1347
+ libSkills.length === 0
1348
+ ? h('div', { className: 'csb-set-d', style: { padding: '8px 9px' } }, '技能库还是空的 —— 先去「技能库」页建一个')
1349
+ : libSkills.map((s) =>
1350
+ h('label', { key: s.id, className: 'csb-pick-i' },
1351
+ h('input', {
1352
+ type: 'checkbox',
1353
+ checked: draft.skills.includes(s.name),
1354
+ onChange: (e) => {
1355
+ const next = e.target.checked
1356
+ ? [...draft.skills, s.name]
1357
+ : draft.skills.filter((x) => x !== s.name)
1358
+ setDraft({ ...draft, skills: next })
1359
+ },
1360
+ }),
1361
+ h('span', null, s.name),
1362
+ s.description ? h('span', { className: 'csb-set-d' }, s.description) : null))),
1363
+ h('div', { className: 'csb-bar', style: { marginTop: 8 } },
1364
+ h('input', {
1365
+ className: 'csb-in csb-grow', placeholder: '也可以直接加一个技能名(会生成空白技能文件)',
1366
+ value: skillInput, onChange: (e) => setSkillInput(e.target.value),
1367
+ onKeyDown: (e) => {
1368
+ if (e.key !== 'Enter') return
1369
+ const n = skillInput.trim()
1370
+ if (!n || draft.skills.includes(n)) return
1371
+ setDraft({ ...draft, skills: [...draft.skills, n] })
1372
+ setSkillInput('')
1373
+ },
1374
+ }),
1375
+ h('button', {
1376
+ className: 'csb-btn',
1377
+ onClick: () => {
1378
+ const n = skillInput.trim()
1379
+ if (!n || draft.skills.includes(n)) return
1380
+ setDraft({ ...draft, skills: [...draft.skills, n] })
1381
+ setSkillInput('')
1382
+ },
1383
+ }, '添加')),
1384
+ draft.skills.length
1385
+ ? h('div', { className: 'csb-tags', style: { marginTop: 8 } },
1386
+ draft.skills.map((s) =>
1387
+ h('span', { key: s, className: 'csb-tag' }, s,
1388
+ h('button', { className: 'csb-tag-x', onClick: () => setDraft({ ...draft, skills: draft.skills.filter((x) => x !== s) }) }, '×'))))
1389
+ : null,
1390
+
1391
+ h(Section, { title: '④ 工作流', desc: '这个 Bot 干活时该按哪套步骤。勾选后会写进它的身份说明里。' }),
1392
+ h('div', { className: 'csb-pick', style: { marginTop: 10 } },
1393
+ libWfs.length === 0
1394
+ ? h('div', { className: 'csb-set-d', style: { padding: '8px 9px' } }, '工作流库还是空的 —— 先去「工作流库」页建一个')
1395
+ : libWfs.map((w) =>
1396
+ h('label', { key: w.id, className: 'csb-pick-i' },
1397
+ h('input', {
1398
+ type: 'checkbox',
1399
+ checked: draft.workflows.includes(w.name),
1400
+ onChange: (e) => {
1401
+ const next = e.target.checked
1402
+ ? [...draft.workflows, w.name]
1403
+ : draft.workflows.filter((x) => x !== w.name)
1404
+ setDraft({ ...draft, workflows: next })
1405
+ },
1406
+ }),
1407
+ h('span', null, w.name),
1408
+ h('span', { className: 'csb-set-d' }, `${(w.steps ?? []).length} 步`)))),
1409
+
1410
+ h(Section, { title: '⑤ 预制记忆', desc: '它一开始就该知道的事实。会写进身份说明,同时存进记忆服务(按 Bot 隔离,可检索)。' }),
1411
+ h('div', { className: 'csb-tags', style: { marginTop: 10 } },
1412
+ draft.memory.length === 0 ? h('span', { className: 'csb-set-d' }, '(还没有)') : null,
1413
+ draft.memory.map((m, i) =>
1414
+ h('span', { key: i, className: 'csb-tag', style: { whiteSpace: 'normal', maxWidth: '100%' } },
1415
+ m.length > 60 ? `${m.slice(0, 60)}…` : m,
1416
+ h('button', { className: 'csb-tag-x', onClick: () => setDraft({ ...draft, memory: draft.memory.filter((_, j) => j !== i) }) }, '×')))),
1417
+ h('div', { className: 'csb-bar' },
1418
+ h('input', {
1419
+ className: 'csb-in csb-grow', placeholder: '例如:生产数据库只读,任何写操作必须走审批',
1420
+ value: memInput, onChange: (e) => setMemInput(e.target.value),
1421
+ onKeyDown: (e) => {
1422
+ if (e.key !== 'Enter') return
1423
+ const n = memInput.trim()
1424
+ if (!n) return
1425
+ setDraft({ ...draft, memory: [...draft.memory, n] })
1426
+ setMemInput('')
1427
+ },
1428
+ }),
1429
+ h('button', {
1430
+ className: 'csb-btn',
1431
+ onClick: () => {
1432
+ const n = memInput.trim()
1433
+ if (!n) return
1434
+ setDraft({ ...draft, memory: [...draft.memory, n] })
1435
+ setMemInput('')
1436
+ },
1437
+ }, '添加')),
1438
+
1439
+ data.managedText
1440
+ ? h('div', null,
1441
+ h(Section, { title: '它实际读到的内容', desc: '这就是写进 AGENTS.md 托管段的原文,保存后可在工作目录里查看。' }),
1442
+ h('div', { className: 'csb-pre', style: { marginTop: 10 } }, data.managedText))
1443
+ : null)
1444
+ : null)
1445
+ }
1446
+
1447
+ // ── 房间 ────────────────────────────────────────────────────────────────
1448
+
1449
+ function RoomsTab() {
1450
+ const { data, loading, error, reload } = useApi('/rooms')
1451
+ const { data: rosterData } = useApi('/roster')
1452
+ const [active, setActive] = useState(null)
1453
+ const [msgs, setMsgs] = useState([])
1454
+ const [text, setText] = useState('')
1455
+ const [roomName, setRoomName] = useState('')
1456
+ const [err, setErr] = useState(null)
1457
+ const [editing, setEditing] = useState(null)
1458
+ const endRef = useRef(null)
1459
+
1460
+ const bots = rosterData?.bots ?? []
1461
+ const rooms = data?.rooms ?? []
1462
+ const current = rooms.find((r) => r.id === active)
1463
+
1464
+ const loadMsgs = useCallback((roomId) => {
1465
+ call(`/rooms/messages?roomId=${encodeURIComponent(roomId)}`)
1466
+ .then((d) => setMsgs(d.messages ?? []))
1467
+ .catch((e) => setErr(e.message))
1468
+ }, [])
1469
+ useEffect(() => {
1470
+ if (active) loadMsgs(active)
1471
+ }, [active, loadMsgs])
1472
+ useEffect(() => {
1473
+ endRef.current?.scrollIntoView?.({ block: 'end' })
1474
+ }, [msgs])
1475
+
1476
+ const create = async () => {
1477
+ const n = roomName.trim()
1478
+ if (!n) return
1479
+ setErr(null)
1480
+ try {
1481
+ const r = await call('/rooms', { method: 'POST', body: { name: n, members: ['user'] } })
1482
+ setRoomName('')
1483
+ reload()
1484
+ setActive(r.room?.id ?? null)
1485
+ } catch (e) {
1486
+ setErr(e.message)
1487
+ }
1488
+ }
1489
+ const post = async () => {
1490
+ const t = text.trim()
1491
+ if (!t || !active) return
1492
+ setErr(null)
1493
+ try {
1494
+ await call('/rooms/post', { method: 'POST', body: { roomId: active, text: t } })
1495
+ setText('')
1496
+ loadMsgs(active)
1497
+ } catch (e) {
1498
+ setErr(e.message)
1499
+ }
1500
+ }
1501
+ const member = async (op, sessionId) => {
1502
+ if (!sessionId || !active) return
1503
+ setErr(null)
1504
+ try {
1505
+ await call(`/rooms/${op}`, { method: 'POST', body: { roomId: active, sessionId } })
1506
+ reload()
1507
+ } catch (e) {
1508
+ setErr(e.message)
1509
+ }
1510
+ }
1511
+
1512
+ return h('div', { className: 'csb-wrap' },
1513
+ h(Section, {
1514
+ title: '房间 = 群聊',
1515
+ desc: '你的理解基本对:房间就是一群成员共用的消息流。只比普通群聊多两件事 —— 成员是「会话」,而且消息可以直接转发去唤醒某个 Bot。',
1516
+ }),
1517
+ h('div', { className: 'csb-bar', style: { marginTop: 12 } },
1518
+ h('input', {
1519
+ className: 'csb-in csb-grow', placeholder: '新建房间…(例如:发布协调群。回车创建)',
1520
+ value: roomName, onChange: (e) => setRoomName(e.target.value),
1521
+ onKeyDown: (e) => e.key === 'Enter' && create(),
1522
+ }),
1523
+ h('button', { className: 'csb-btn csb-btn-p', disabled: !roomName.trim(), onClick: create }, '创建房间')),
1524
+ err ? h('div', { className: 'csb-err' }, err) : null,
1525
+ error ? h('div', { className: 'csb-err' }, error) : null,
1526
+ loading ? h(Skeleton, { n: 2 }) : null,
1527
+ !loading && rooms.length === 0
1528
+ ? h(Empty, { icon: '◌', title: '还没有房间', hint: '建一个房间,然后把 Bot 拉进来,它们就能在同一个消息流里对话。' })
1529
+ : null,
1530
+ h('div', { className: 'csb-list' }, rooms.map((r) =>
1531
+ h('div', {
1532
+ key: r.id, className: `csb-card csb-card-c${active === r.id ? ' csb-card-on' : ''}`,
1533
+ onClick: () => setActive(r.id),
1534
+ },
1535
+ h('div', { className: 'csb-card-main' },
1536
+ h('div', { className: 'csb-card-t' }, r.name),
1537
+ h('div', { className: 'csb-card-m' },
1538
+ h('span', null, `${(r.members ?? []).length} 人`),
1539
+ h('span', null, `· ${r.mode === 'admin' ? '管理员模式' : '自由发言'}`),
1540
+ h('span', null, `· ${r.seq ?? 0} 条消息`))),
1541
+ h('button', {
1542
+ className: 'csb-btn csb-btn-s', style: { flex: 'none' },
1543
+ onClick: (e) => { e.stopPropagation(); setEditing(r) },
1544
+ }, '设置')))),
1545
+ current ? h('div', null,
1546
+ h(Section, { title: `消息 · ${current.name}`, desc: `${msgs.length} 条。发帖者必须是成员。` }),
1547
+ current.notice ? h('div', { className: 'csb-note', style: { marginTop: 10 } }, current.notice) : null,
1548
+ h('div', { style: { maxHeight: 420, overflowY: 'auto', margin: '12px 0' } },
1549
+ msgs.length === 0 ? h(Empty, { title: '还没有消息' }) : null,
1550
+ msgs.slice(-100).map((m) =>
1551
+ h('div', { key: m.id, className: `csb-msg${m.from === 'user' || m.from === 'ui' ? ' csb-msg-me' : ''}` },
1552
+ h('div', { className: 'csb-msg-h' },
1553
+ h('span', null, `#${m.seq}`),
1554
+ h('span', null, botName(bots, m.from)),
1555
+ m.bodyRef ? h('span', { className: 'csb-b' }, '长正文') : null),
1556
+ h('div', { className: 'csb-msg-b' }, m.bodyRef ? `(正文较长,已单独存为:${m.bodyRef})` : m.text))),
1557
+ h('div', { ref: endRef })),
1558
+ h('div', { className: 'csb-bar' },
1559
+ h('input', {
1560
+ className: 'csb-in csb-grow', placeholder: '说点什么…(回车发送)',
1561
+ value: text, onChange: (e) => setText(e.target.value),
1562
+ onKeyDown: (e) => e.key === 'Enter' && post(),
1563
+ }),
1564
+ h('button', { className: 'csb-btn csb-btn-p', disabled: !text.trim(), onClick: post }, '发送')),
1565
+ h(Section, { title: '成员', desc: '勾选要拉进来的 Bot;勾掉就是移出。' }),
1566
+ h('div', { className: 'csb-pick', style: { marginTop: 10 } },
1567
+ bots.filter((b) => b.state !== 'retired').length === 0
1568
+ ? h('div', { className: 'csb-set-d', style: { padding: '8px 9px' } }, '还没有 Bot —— 先去「Bot」页建一个')
1569
+ : bots.filter((b) => b.state !== 'retired').map((b) => {
1570
+ const inRoom = (current.members ?? []).includes(b.sessionId)
1571
+ return h('label', { key: b.id, className: 'csb-pick-i' },
1572
+ h('input', {
1573
+ type: 'checkbox', checked: inRoom,
1574
+ onChange: (e) => member(e.target.checked ? 'join' : 'leave', b.sessionId),
1575
+ }),
1576
+ h('span', null, b.name),
1577
+ h('span', { className: 'csb-set-d' }, inRoom ? '已在房间' : '未加入'))
1578
+ })))
1579
+ : null,
1580
+ editing
1581
+ ? h(RoomEditor, {
1582
+ room: editing, bots,
1583
+ onClose: () => setEditing(null),
1584
+ onSaved: () => { setEditing(null); reload() },
1585
+ onError: setErr,
1586
+ })
1587
+ : null)
1588
+ }
1589
+
1590
+ function RoomEditor({ room, bots, onClose, onSaved, onError }) {
1591
+ const [form, setForm] = useState({
1592
+ name: room.name ?? '',
1593
+ notice: room.notice ?? '',
1594
+ mode: room.mode ?? 'everyone',
1595
+ })
1596
+ const save = async () => {
1597
+ try {
1598
+ await call('/rooms/update', {
1599
+ method: 'POST',
1600
+ body: { roomId: room.id, expectedRev: room.rev, ...form, byId: 'user' },
1601
+ })
1602
+ onSaved()
1603
+ } catch (e) {
1604
+ onError(e.message)
1605
+ onClose()
1606
+ }
1607
+ }
1608
+ return h(Modal, {
1609
+ title: `房间设置 · ${room.name}`,
1610
+ onClose,
1611
+ footer: [
1612
+ h('button', { key: 'c', className: 'csb-btn', onClick: onClose }, '取消'),
1613
+ h('button', { key: 's', className: 'csb-btn csb-btn-p', onClick: save }, '保存'),
1614
+ ],
1615
+ },
1616
+ h('div', { className: 'csb-fld' },
1617
+ h('label', null, '房间名'),
1618
+ h('input', { className: 'csb-in', value: form.name, onChange: (e) => setForm({ ...form, name: e.target.value }) })),
1619
+ h('div', { className: 'csb-fld' },
1620
+ h('label', null, '发言方式'),
1621
+ h('select', {
1622
+ className: 'csb-sel', value: form.mode,
1623
+ onChange: (e) => setForm({ ...form, mode: e.target.value }),
1624
+ },
1625
+ h('option', { value: 'everyone' }, '自由发言(所有成员都能说话)'),
1626
+ h('option', { value: 'admin' }, '管理员模式(只有管理员能改设置)')),
1627
+ h('div', { className: 'csb-fld-h' }, `${(room.members ?? []).length} 个成员,${room.seq ?? 0} 条消息`)),
1628
+ h('div', { className: 'csb-fld' },
1629
+ h('label', null, '房间公告'),
1630
+ h('textarea', {
1631
+ className: 'csb-ta', value: form.notice,
1632
+ placeholder: '例如:本房间只讨论发布相关事项;发版前 24 小时冻结代码。',
1633
+ onChange: (e) => setForm({ ...form, notice: e.target.value }),
1634
+ }),
1635
+ h('div', { className: 'csb-fld-h' }, '公告会显示在消息列表上方。')))
1636
+ }
1637
+
1638
+ // ── 记忆 ────────────────────────────────────────────────────────────────
1639
+
1640
+ const LAYERS = ['L0', 'L1', 'L2', 'L3', 'L4', 'L5']
1641
+ const LAYER_HINT = { L0: '人格(仅人可改)', L1: '用户偏好', L2: '团队规矩', L3: '分支专属', L4: '任务笔记', L5: '归档' }
1642
+
1643
+ function MemoryTab() {
1644
+ const [layer, setLayer] = useState('')
1645
+ const [query, setQuery] = useState('')
1646
+ const [err, setErr] = useState(null)
1647
+ const [adding, setAdding] = useState(false)
1648
+ const [newLayer, setNewLayer] = useState('L2')
1649
+ const [newContent, setNewContent] = useState('')
1650
+ const [ctxPreview, setCtxPreview] = useState(null)
1651
+ const [confirmDel, setConfirmDel] = useState(null)
1652
+ const path = `/memory${query ? `?query=${encodeURIComponent(query)}` : layer ? `?layer=${layer}` : ''}`
1653
+ const { data, loading, error, reload } = useApi(path, [layer, query])
1654
+ const entries = data?.entries ?? []
1655
+ const act = (p, body) => call(p, { method: 'POST', body }).then(reload).catch((e) => setErr(e.message))
1656
+
1657
+ const add = async () => {
1658
+ if (!newContent.trim()) return
1659
+ setErr(null)
1660
+ try {
1661
+ await call('/memory', { method: 'POST', body: { layer: newLayer, content: newContent.trim(), by: 'user' } })
1662
+ setNewContent('')
1663
+ setAdding(false)
1664
+ reload()
1665
+ } catch (e) {
1666
+ setErr(e.message)
1667
+ }
1668
+ }
1669
+
1670
+ return h('div', { className: 'csb-wrap' },
1671
+ h(Section, {
1672
+ title: '记忆',
1673
+ desc: 'L0–L3 会一直注入到上下文里(模型每轮都看得到);L4/L5 按需检索。标了「explicit」的条目是你明确说的,自动整理只能建议、不能改。',
1674
+ }),
1675
+ h('div', { className: 'csb-bar', style: { marginTop: 12 } },
1676
+ h('select', { className: 'csb-sel', style: { width: 176 }, value: layer, onChange: (e) => setLayer(e.target.value) },
1677
+ h('option', { value: '' }, '全部层'),
1678
+ LAYERS.map((l) => h('option', { key: l, value: l }, `${l} · ${LAYER_HINT[l]}`))),
1679
+ h('input', {
1680
+ className: 'csb-in csb-grow', placeholder: '搜记忆…',
1681
+ value: query, onChange: (e) => setQuery(e.target.value),
1682
+ }),
1683
+ h('button', { className: 'csb-btn csb-btn-p', onClick: () => setAdding(true) }, '新建条目'),
1684
+ h('button', {
1685
+ className: 'csb-btn',
1686
+ onClick: () => call('/memory/context').then((d) => setCtxPreview(d.text)).catch((e) => setErr(e.message)),
1687
+ }, '看注入效果')),
1688
+ err ? h('div', { className: 'csb-err' }, err) : null,
1689
+ error ? h('div', { className: 'csb-err' }, error) : null,
1690
+ loading ? h(Skeleton, { n: 3 }) : null,
1691
+ !loading && entries.length === 0
1692
+ ? h(Empty, { icon: '◇', title: '还没有记忆条目', hint: '把「事实」和「规矩」写进来,模型每次都能看到。任务笔记这类一次性的放 L4。' })
1693
+ : null,
1694
+ h('div', { className: 'csb-list' }, entries.map((e) =>
1695
+ h('div', { key: e.id, className: 'csb-card' },
1696
+ h('div', { className: 'csb-card-main' },
1697
+ h('div', { className: 'csb-card-t2' }, e.content),
1698
+ h('div', { className: 'csb-card-m' },
1699
+ h('span', { className: 'csb-b' }, `${e.layer} ${LAYER_HINT[e.layer] ?? ''}`),
1700
+ e.botId ? h('span', { className: 'csb-b' }, `属于 Bot ${short(e.botId, 10)}`) : h('span', { className: 'csb-b' }, '全局'),
1701
+ e.explicit ? h('span', { className: 'csb-b csb-b-ok' }, 'explicit') : null,
1702
+ e.conflict ? h('span', { className: 'csb-b csb-b-err' }, '与另一条冲突') : null,
1703
+ e.archived ? h('span', { className: 'csb-b csb-b-idle' }, '已归档') : null,
1704
+ h('span', null, `· 第 ${e.rev} 版`))),
1705
+ h('div', { style: { display: 'flex', gap: 6, flex: 'none' } },
1706
+ e.archived
1707
+ ? h('button', { className: 'csb-btn csb-btn-s', onClick: () => act('/memory/restore', { layer: e.layer, id: e.id }) }, '恢复')
1708
+ : h('button', { className: 'csb-btn csb-btn-s', onClick: () => act('/memory/archive', { layer: e.layer, id: e.id, reason: '过时' }) }, '归档'),
1709
+ h('button', { className: 'csb-btn csb-btn-s csb-btn-d', onClick: () => setConfirmDel(e) }, '删除'))))),
1710
+
1711
+ adding
1712
+ ? h(Modal, {
1713
+ title: '新建记忆条目',
1714
+ onClose: () => setAdding(false),
1715
+ footer: [
1716
+ h('button', { key: 'c', className: 'csb-btn', onClick: () => setAdding(false) }, '取消'),
1717
+ h('button', { key: 's', className: 'csb-btn csb-btn-p', disabled: !newContent.trim(), onClick: add }, '保存'),
1718
+ ],
1719
+ },
1720
+ h('div', { className: 'csb-fld' },
1721
+ h('label', null, '放在哪一层'),
1722
+ h('select', { className: 'csb-sel', value: newLayer, onChange: (e) => setNewLayer(e.target.value) },
1723
+ LAYERS.filter((l) => l !== 'L0').map((l) => h('option', { key: l, value: l }, `${l} · ${LAYER_HINT[l]}`))),
1724
+ h('div', { className: 'csb-fld-h' }, 'L0 是人格层,只能通过配置改,界面不给写。')),
1725
+ h('div', { className: 'csb-fld' },
1726
+ h('label', null, '内容'),
1727
+ h('textarea', {
1728
+ className: 'csb-ta', value: newContent,
1729
+ placeholder: '例如:本项目只用 pnpm,不用 npm。',
1730
+ onChange: (e) => setNewContent(e.target.value),
1731
+ }),
1732
+ h('div', { className: 'csb-fld-h' }, `${newContent.length} / 600 字符,超长会自动切分存储。`)))
1733
+ : null,
1734
+
1735
+ ctxPreview !== null
1736
+ ? h(Modal, {
1737
+ title: '模型实际看到的记忆',
1738
+ wide: true,
1739
+ onClose: () => setCtxPreview(null),
1740
+ footer: [h('button', { key: 'c', className: 'csb-btn', onClick: () => setCtxPreview(null) }, '关闭')],
1741
+ }, h('div', { className: 'csb-pre' }, ctxPreview || '(空)'))
1742
+ : null,
1743
+
1744
+ confirmDel
1745
+ ? h(ConfirmModal, {
1746
+ title: '删除这条记忆?',
1747
+ message: `内容:${confirmDel.content.slice(0, 80)}\n\n删除后模型不会再看到它。如果只是想暂时不用,建议「归档」而不是删除。`,
1748
+ danger: true, confirmText: '删除',
1749
+ onCancel: () => setConfirmDel(null),
1750
+ onConfirm: async () => {
1751
+ const e = confirmDel
1752
+ setConfirmDel(null)
1753
+ await act('/memory/remove', { layer: e.layer, id: e.id })
1754
+ },
1755
+ })
1756
+ : null)
1757
+ }
1758
+
1759
+ // ── 设置 ────────────────────────────────────────────────────────────────
1760
+
1761
+ const NUM_META = {
1762
+ defaultLeaseMs: { scale: 60000, unit: '分钟', step: 1 },
1763
+ throttleMs: { scale: 1000, unit: '秒', step: 1 },
1764
+ wakeTtlMs: { scale: 3600000, unit: '小时', step: 1 },
1765
+ maxWakesPerTarget: { scale: 1, unit: '条', step: 10 },
1766
+ memoryEntryMax: { scale: 1, unit: '字符', step: 50 },
1767
+ }
1768
+ const PLAIN = {
1769
+ defaultLeaseMs: { label: '任务占用时长', help: '领取任务后「占着」多久。超过这个时间没动静,任务会自动放回待办,别人可以接手。' },
1770
+ throttleMs: { label: '唤醒最小间隔', help: '同一个 Bot 在这段时间内最多被唤醒一次,防止刷屏。' },
1771
+ maxWakesPerTarget: { label: '单个 Bot 待处理唤醒上限', help: '排队超过这个数量就丢弃新的,避免堆积。' },
1772
+ wakeTtlMs: { label: '唤醒记录保留时长', help: '多久以前的唤醒记录可以清掉。' },
1773
+ memoryEntryMax: { label: '单条记忆字数上限', help: '超过就自动切成多条存(不丢内容)。' },
1774
+ memorySplitOnOverflow: { label: '超长记忆自动切分', help: '打开:切分存储。关闭:直接拒收。' },
1775
+ k1Enabled: { label: '禁止 Bot 阻塞式提问', help: 'Bot 会话里禁用「停下来等人回答」的工具,避免整条消息链被卡住。排查问题时才临时关。' },
1776
+ }
1777
+
1778
+ function NumberRow({ id, spec, value, onChange }) {
1779
+ const meta = NUM_META[id] ?? { scale: 1, unit: '', step: 1 }
1780
+ const disp = Math.round(Number(value) / meta.scale)
1781
+ const min = Math.round(spec.min / meta.scale)
1782
+ const max = Math.round(spec.max / meta.scale)
1783
+ const [raw, setRaw] = useState(String(disp))
1784
+ useEffect(() => setRaw(String(disp)), [disp])
1785
+ const commit = (n) => {
1786
+ if (!Number.isFinite(n) || n < min || n > max) return setRaw(String(disp))
1787
+ onChange(Math.round(n * meta.scale))
1788
+ }
1789
+ const bump = (d) => {
1790
+ const next = Math.min(max, Math.max(min, (Number(raw) || disp) + d * meta.step))
1791
+ setRaw(String(next))
1792
+ commit(next)
1793
+ }
1794
+ const info = PLAIN[id] ?? {}
1795
+ return h('div', { className: 'csb-set' },
1796
+ h('div', { className: 'csb-set-l' },
1797
+ h('label', null, info.label ?? spec.label ?? id),
1798
+ h('div', { className: 'csb-set-d' },
1799
+ info.help ?? '',
1800
+ ` 默认 ${Math.round(spec.def / meta.scale)} ${meta.unit},可填 ${min}–${max}。`)),
1801
+ h('div', { className: 'csb-set-c' },
1802
+ h('button', { className: 'csb-step', onClick: () => bump(-1), 'aria-label': '减少' }, '−'),
1803
+ h('span', { className: 'csb-num' },
1804
+ h('input', {
1805
+ className: 'csb-in', type: 'text', inputMode: 'numeric', value: raw,
1806
+ onChange: (e) => setRaw(e.target.value.replace(/[^\d.-]/g, '')),
1807
+ onBlur: () => commit(Number(raw)),
1808
+ onKeyDown: (e) => {
1809
+ if (e.key === 'Enter') e.currentTarget.blur()
1810
+ if (e.key === 'Escape') setRaw(String(disp))
1811
+ },
1812
+ }),
1813
+ h('span', { className: 'csb-unit' }, meta.unit)),
1814
+ h('button', { className: 'csb-step', onClick: () => bump(1), 'aria-label': '增加' }, '+')))
1815
+ }
1816
+
1817
+ function BoolRow({ id, spec, value, onChange }) {
1818
+ const info = PLAIN[id] ?? {}
1819
+ return h('div', { className: 'csb-set' },
1820
+ h('div', { className: 'csb-set-l' },
1821
+ h('label', null, info.label ?? spec.label ?? id),
1822
+ h('div', { className: 'csb-set-d' }, info.help ?? '')),
1823
+ h('div', { className: 'csb-set-c' },
1824
+ h(Switch, { checked: value === true, onChange }),
1825
+ h('span', { className: 'csb-sw-t' }, value === true ? '开' : '关')))
1826
+ }
1827
+
1828
+ function SettingsBody() {
1829
+ const { data, loading, error, reload } = useApi('/settings')
1830
+ const { data: statusData, reload: reloadStatus } = useApi('/status')
1831
+ const { data: libData } = useApi('/library/skills')
1832
+ const { data: wfData } = useApi('/library/workflows')
1833
+ const [draft, setDraft] = useState({})
1834
+ const [msg, setMsg] = useState(null)
1835
+ const [err, setErr] = useState(null)
1836
+ const [busy, setBusy] = useState(false)
1837
+ const [openDiag, setOpenDiag] = useState(false)
1838
+ const [resetting, setResetting] = useState(false)
1839
+
1840
+ const specs = data?.specs ?? {}
1841
+ const values = data?.values ?? {}
1842
+ const svc = statusData?.services ?? {}
1843
+ const compat = statusData?.compat ?? null
1844
+ const avail = compat?.available ?? {}
1845
+ const okCount = Object.values(avail).filter(Boolean).length
1846
+ const totalCount = Object.keys(avail).length || 0
1847
+ const svcOk = Object.values(svc).filter((v) => v?.available).length
1848
+ const svcTotal = Object.keys(svc).length || 0
1849
+ const val = (id) => (id in draft ? draft[id] : values[id])
1850
+ const dirty = Object.keys(draft).some((k) => String(draft[k]) !== String(values[k]))
1851
+ const changedCount = Object.keys(specs).filter((k) => String(values[k]) !== String(specs[k].def)).length
1852
+
1853
+ const save = async () => {
1854
+ const patch = {}
1855
+ for (const [k, v] of Object.entries(draft)) if (String(v) !== String(values[k])) patch[k] = v
1856
+ if (!Object.keys(patch).length) return
1857
+ setBusy(true)
1858
+ setErr(null)
1859
+ setMsg(null)
1860
+ try {
1861
+ const res = await call('/settings', { method: 'POST', body: patch })
1862
+ setMsg(res.note ?? '已保存')
1863
+ setDraft({})
1864
+ reload()
1865
+ } catch (e) {
1866
+ setErr(e.message)
1867
+ } finally {
1868
+ setBusy(false)
1869
+ }
1870
+ }
1871
+
1872
+ const TASK_KEYS = ['defaultLeaseMs', 'throttleMs', 'maxWakesPerTarget', 'wakeTtlMs']
1873
+ const MEM_KEYS = ['memoryEntryMax', 'memorySplitOnOverflow', 'k1Enabled']
1874
+
1875
+ return h('div', { className: 'csb-wrap' },
1876
+ loading ? h(Skeleton, { n: 3 }) : null,
1877
+ error ? h('div', { className: 'csb-err' }, error) : null,
1878
+ msg ? h('div', { className: 'csb-ok' }, msg) : null,
1879
+ err ? h('div', { className: 'csb-err' }, err) : null,
1880
+
1881
+ h(Section, { title: '概览', desc: '现在平台里有多少东西在跑。' }),
1882
+ h('div', { className: 'csb-stats', style: { marginTop: 12 } },
1883
+ [['任务', svc.board?.stats?.total], ['Bot', svc.roster?.stats?.total],
1884
+ ['房间', svc.room?.stats?.total], ['记忆', svc.memory?.stats?.total],
1885
+ ['技能', (libData?.skills ?? []).length], ['工作流', (wfData?.workflows ?? []).length]]
1886
+ .map(([l, n]) => h('div', { key: l, className: 'csb-stat' },
1887
+ h('div', { className: 'csb-stat-n' }, n ?? '—'),
1888
+ h('div', { className: 'csb-stat-l' }, l)))),
1889
+
1890
+ h(Section, { title: '系统体检', desc: '出问题时把这一段发回开发者,能直接定位。' }),
1891
+ h('div', { className: 'csb-fold' },
1892
+ h('div', { className: 'csb-bar', style: { marginBottom: 0 } },
1893
+ h('span', { className: `csb-dot${svcTotal && svcOk === svcTotal ? ' csb-dot-ok' : svcOk < svcTotal ? ' csb-dot-err' : ''}` }),
1894
+ h('span', { className: 'csb-set-d', style: { flex: 1 } },
1895
+ totalCount && okCount === totalCount
1896
+ ? `一切正常:${totalCount} 项能力全部就绪,${svcTotal} 个服务都在跑。`
1897
+ : `${okCount}/${totalCount || '?'} 项能力就绪${okCount < totalCount ? ',部分功能已降级' : ''}。`),
1898
+ h('button', { className: 'csb-btn csb-btn-s', onClick: () => setOpenDiag(!openDiag) }, openDiag ? '收起' : '看详情')),
1899
+ h('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 10 } },
1900
+ Object.entries(avail).map(([k, ok]) =>
1901
+ h('span', { key: k, className: `csb-dot${ok ? ' csb-dot-ok' : ' csb-dot-err'}`, title: `${k}:${ok ? '就绪' : '缺失'}` }))),
1902
+ openDiag
1903
+ ? h('div', null,
1904
+ h('div', { className: 'csb-kv', style: { marginTop: 14, marginBottom: 10 } },
1905
+ h('span', { className: 'csb-k' }, 'DSH 版本'), h('span', { className: 'csb-v csb-mono' }, compat?.dshVersion ?? '未探到'),
1906
+ h('span', { className: 'csb-k' }, 'Node 版本'), h('span', { className: 'csb-v csb-mono' }, compat?.node ?? '—'),
1907
+ h('span', { className: 'csb-k' }, '数据目录'), h('span', { className: 'csb-v csb-mono' }, data?.baseDir ?? '—')),
1908
+ h('div', { className: 'csb-list' },
1909
+ Object.keys(svc).map((k) =>
1910
+ h('div', { key: k, className: 'csb-card', style: { padding: '9px 12px' } },
1911
+ h('span', { className: `csb-dot${svc[k]?.available ? ' csb-dot-ok' : ' csb-dot-err'}` }),
1912
+ h('div', { className: 'csb-card-main' }, h('div', { style: { fontSize: 12.5 } }, k)),
1913
+ h('span', { className: 'csb-set-d' }, svc[k]?.available ? '正常' : '未装配')))),
1914
+ (compat?.missing ?? []).length
1915
+ ? h('div', { className: 'csb-note', style: { marginTop: 12 } }, `缺少:${compat.missing.join('、')}(相关功能降级,但不影响启动)`)
1916
+ : null)
1917
+ : null),
1918
+
1919
+ h(Section, { title: '任务与提醒', desc: '影响任务能占多久、以及 Bot 被唤醒的频率。' }),
1920
+ TASK_KEYS.filter((k) => specs[k]).map((k) =>
1921
+ specs[k].bool
1922
+ ? h(BoolRow, { key: k, id: k, spec: specs[k], value: val(k), onChange: (v) => setDraft({ ...draft, [k]: v }) })
1923
+ : h(NumberRow, { key: k, id: k, spec: specs[k], value: val(k), onChange: (v) => setDraft({ ...draft, [k]: v }) })),
1924
+
1925
+ h(Section, { title: '记忆与安全', desc: '影响记忆怎么存、Bot 能不能停下来等人回答。' }),
1926
+ MEM_KEYS.filter((k) => specs[k]).map((k) =>
1927
+ specs[k].bool
1928
+ ? h(BoolRow, { key: k, id: k, spec: specs[k], value: val(k), onChange: (v) => setDraft({ ...draft, [k]: v }) })
1929
+ : h(NumberRow, { key: k, id: k, spec: specs[k], value: val(k), onChange: (v) => setDraft({ ...draft, [k]: v }) })),
1930
+
1931
+ h('div', { className: 'csb-bar', style: { marginTop: 20 } },
1932
+ h('button', { className: 'csb-btn csb-btn-p', disabled: !dirty || busy, onClick: save }, busy ? '保存中…' : dirty ? '保存改动' : '没有改动'),
1933
+ h('button', { className: 'csb-btn', disabled: !Object.keys(draft).length, onClick: () => { setDraft({}); setMsg(null); setErr(null) } }, '撤销'),
1934
+ h('div', { className: 'csb-spacer' }),
1935
+ h('button', { className: 'csb-btn', onClick: () => { reload(); reloadStatus() } }, '刷新')),
1936
+ h('div', { className: 'csb-fld-h', style: { marginTop: 8 } }, '这些参数存在数据目录的 settings.json 里;改完要重启 DSH 才真正生效。'),
1937
+
1938
+ h(Section, { title: '维护', desc: '不可撤销的操作,请先核对影响范围。' }),
1939
+ h('div', { className: 'csb-set' },
1940
+ h('div', { className: 'csb-set-l' },
1941
+ h('label', null, '恢复默认参数'),
1942
+ h('div', { className: 'csb-set-d' }, `把上面所有参数恢复出厂值。当前有 ${changedCount} 项被改过。只影响参数,不删除任务、Bot、房间或记忆。`)),
1943
+ h('div', { className: 'csb-set-c' },
1944
+ h('button', { className: 'csb-btn csb-btn-d', onClick: () => setResetting(true) }, '恢复默认'))),
1945
+
1946
+ resetting
1947
+ ? h(ConfirmModal, {
1948
+ title: '恢复默认参数?',
1949
+ message: (() => {
1950
+ const changed = Object.keys(specs).filter((k) => String(values[k]) !== String(specs[k].def))
1951
+ if (!changed.length) return '当前所有参数都是默认值,无需恢复。'
1952
+ return `以下 ${changed.length} 项会变成:\n` +
1953
+ changed.map((k) => `· ${PLAIN[k]?.label ?? k}:${values[k]} → ${specs[k].def}`).join('\n') +
1954
+ '\n\n只改参数,不动业务数据。'
1955
+ })(),
1956
+ confirmText: '恢复默认',
1957
+ onCancel: () => setResetting(false),
1958
+ onConfirm: async () => {
1959
+ const patch = {}
1960
+ for (const [k, s] of Object.entries(specs)) patch[k] = s.def
1961
+ try {
1962
+ await call('/settings', { method: 'POST', body: patch })
1963
+ setDraft({})
1964
+ setMsg('已恢复默认参数')
1965
+ reload()
1966
+ } catch (e) {
1967
+ setErr(e.message)
1968
+ } finally {
1969
+ setResetting(false)
1970
+ }
1971
+ },
1972
+ })
1973
+ : null)
1974
+ }
1975
+
1976
+ // ── 主面板 ──────────────────────────────────────────────────────────────
1977
+
1978
+ const TABS = [
1979
+ { id: 'tasks', label: '任务', Comp: TasksTab },
1980
+ { id: 'bots', label: 'Bot', Comp: BotsTab },
1981
+ { id: 'rooms', label: '房间', Comp: RoomsTab },
1982
+ { id: 'memory', label: '记忆', Comp: MemoryTab },
1983
+ { id: 'skills', label: '技能库', Comp: SkillsTab },
1984
+ { id: 'workflows', label: '工作流库', Comp: WorkflowsTab },
1985
+ { id: 'settings', label: '设置', Comp: SettingsBody },
1986
+ ]
1987
+
1988
+ function Workbench() {
1989
+ const [tab, setTab] = useState('tasks')
1990
+ const current = TABS.find((t) => t.id === tab) ?? TABS[0]
1991
+ const Comp = current.Comp
1992
+ return h('div', { className: 'csb' },
1993
+ h('div', { className: 'csb-head' },
1994
+ h('div', { className: 'csb-hrow' },
1995
+ h('h2', null, '协作平台'),
1996
+ h('span', { className: 'csb-hint' }, '任务 · Bot · 房间 · 记忆 · 技能 · 工作流')),
1997
+ h('div', { className: 'csb-tabs', role: 'tablist' },
1998
+ TABS.map((t) =>
1999
+ h('button', {
2000
+ key: t.id, role: 'tab', 'aria-selected': t.id === tab,
2001
+ className: `csb-tab${t.id === tab ? ' csb-tab-on' : ''}`,
2002
+ onClick: () => setTab(t.id),
2003
+ }, t.label)))),
2004
+ h('div', { className: 'csb-body' },
2005
+ h(Boundary, { tab: current.label }, h(Comp, { key: current.id }))))
2006
+ }
2007
+
2008
+ function PanelIcon() {
2009
+ return h('span', { 'aria-hidden': 'true', style: { fontSize: 14 } }, '◈')
2010
+ }
2011
+
2012
+ function apply(ctx) {
2013
+ bindHost(ctx)
2014
+ const disposers = []
2015
+ try {
2016
+ const d = installStyles()
2017
+ if (typeof d === 'function') disposers.push(d)
2018
+ } catch {}
2019
+
2020
+ const reg = (label, slot, factory) => {
2021
+ try {
2022
+ disposers.push(ctx.slots.inject(slot, () => ctx.slots.register(factory(), undefined)))
2023
+ } catch (error) {
2024
+ try {
2025
+ console.warn(`[cs-bots] 注册 ${label} 失败:`, error)
2026
+ } catch {}
2027
+ }
2028
+ }
2029
+
2030
+ // 顺序锁死:main 必须在 panellist 之前
2031
+ try {
2032
+ disposers.push(ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: PANEL_ID }, Workbench)))
2033
+ } catch (error) {
2034
+ try {
2035
+ console.warn('[cs-bots] 注册 main 失败:', error)
2036
+ } catch {}
2037
+ }
2038
+ try {
2039
+ disposers.push(
2040
+ ctx.slots.inject('sidebar.panellist', () =>
2041
+ ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 10, label: () => '协作平台' }, PanelIcon)),
2042
+ )
2043
+ } catch (error) {
2044
+ try {
2045
+ console.warn('[cs-bots] 注册 panellist 失败:', error)
2046
+ } catch {}
2047
+ }
2048
+ // 设置也挂到官方设置面板
2049
+ try {
2050
+ disposers.push(
2051
+ ctx.slots.inject('settings.section', () =>
2052
+ ctx.slots.register({ name: 'settings.section', id: 'cs-bots', order: 60, label: () => '协作平台' }, SettingsBody)),
2053
+ )
2054
+ } catch (error) {
2055
+ try {
2056
+ console.warn('[cs-bots] 注册 settings.section 失败(不影响主面板):', error)
2057
+ } catch {}
2058
+ }
2059
+
2060
+ return function () {
2061
+ for (let i = disposers.length - 1; i >= 0; i -= 1) {
2062
+ try {
2063
+ if (typeof disposers[i] === 'function') disposers[i]()
2064
+ } catch {}
2065
+ }
2066
+ }
2067
+ }
2068
+
2069
+ exports.apply = apply
2070
+ exports.inject = ['slots', 'layout']
2071
+ exports.Workbench = Workbench
2072
+ exports.Boundary = Boundary
2073
+ exports.TasksTab = TasksTab
2074
+ exports.BotsTab = BotsTab
2075
+ exports.RoomsTab = RoomsTab
2076
+ exports.MemoryTab = MemoryTab
2077
+ exports.SkillsTab = SkillsTab
2078
+ exports.WorkflowsTab = WorkflowsTab
2079
+ exports.SettingsBody = SettingsBody
2080
+ exports.BotProfileDrawer = BotProfileDrawer
2081
+ exports.DirPicker = DirPicker
2082
+ exports.Modal = Modal
2083
+ exports.ConfirmModal = ConfirmModal
2084
+ exports.PANEL_ID = PANEL_ID
2085
+ exports.API = API
2086
+
2087
+ return module.exports
2088
+ },
2089
+ })