@restregular/dsh-skill-center 0.0.0-stage → 0.1.7

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.
@@ -0,0 +1,767 @@
1
+ /**
2
+ * dsh-skill-center — Client half.
3
+ *
4
+ * 两处注册:
5
+ * 1. `sidebar.panellist`(list, root)— 侧栏全局面板列表里的一行图标 + 标签,
6
+ * `order: 1` 使其排在 `plugins`(order 0)下方;
7
+ * 2. `main`(keyed, root, key = "skill-center")— 中央区域的面板本体。
8
+ *
9
+ * 数据与写操作全部走宿主同源回环路由 `/dsh-skill-center/api`(见 ../lib/index.js)。
10
+ * 样式只用宿主主题令牌 `--dsw-*`,不 import 任何 Harness Client 包。
11
+ */
12
+ window.__ModuleLoader__.load({
13
+ id: 'dsh-skill-center',
14
+ factory: (require) => {
15
+ const React = require('react');
16
+ const h = React.createElement;
17
+
18
+ const OWNER = 'dsh-skill-center';
19
+ const PANEL_ID = 'skill-center';
20
+ const NS = 'skill-center';
21
+ const API = '/dsh-skill-center/api';
22
+
23
+ // ------------------------------------------------------------------
24
+ // 文案
25
+ // ------------------------------------------------------------------
26
+ const DICT = {
27
+ zh: {
28
+ panel: '技能中心',
29
+ title: '技能中心',
30
+ subtitle: '管理当前会话可见的技能',
31
+ search: '搜索名称或描述',
32
+ refresh: '刷新',
33
+ loading: '正在读取技能目录…',
34
+ empty: '没有匹配的技能',
35
+ emptyAll: '当前技能目录是空的',
36
+ create: '新建技能',
37
+ createSubmit: '创建',
38
+ formName: '名称(kebab-case)',
39
+ formDesc: '描述(必填)',
40
+ formBody: '正文(Markdown,可选)',
41
+ formRoot: '写入位置',
42
+ delete: '删除',
43
+ deleteConfirm: '确认删除?',
44
+ deleteHint: '删除会把技能移动到同根目录的 .trash 下,可手动恢复。',
45
+ deleted: '已移动到 .trash',
46
+ preview: '正文',
47
+ previewHide: '收起正文',
48
+ reloadBody: '读取正文',
49
+ provider: '提供方',
50
+ source: '来源',
51
+ path: '路径',
52
+ size: '大小',
53
+ model: '模型可调用',
54
+ user: '用户可调用',
55
+ modelHint: '关闭后该技能不会出现在模型技能目录里,skill 工具也会拒绝加载。',
56
+ userHint: '关闭后该技能不会出现在 / 菜单里。',
57
+ root: '技能根目录',
58
+ incomplete: '技能目录读取不完整(部分提供方不可用)',
59
+ scopeLive: '当前会话作用域',
60
+ scopePreset: '会话预设作用域',
61
+ scopeCwd: '未绑定会话,按工作目录读取',
62
+ filterAll: '全部来源',
63
+ total: '{n} 个技能',
64
+ shown: '显示 {n} / {m}',
65
+ whenToUse: '何时使用',
66
+ internalOnly: '该技能由插件内嵌提供,只能查看,不能改写或删除。',
67
+ noBody: '(正文为空)',
68
+ created: '已创建',
69
+ saved: '已保存',
70
+ back: '返回会话',
71
+ retry: '重试',
72
+ modelOff: '模型关闭',
73
+ userOff: '用户关闭',
74
+ embedded: '内嵌',
75
+ },
76
+ en: {
77
+ panel: 'Skill Center',
78
+ title: 'Skill Center',
79
+ subtitle: 'Manage the skills visible to this session',
80
+ search: 'Search name or description',
81
+ refresh: 'Refresh',
82
+ loading: 'Reading the skill catalog…',
83
+ empty: 'No matching skills',
84
+ emptyAll: 'This skill catalog is empty',
85
+ create: 'New skill',
86
+ createSubmit: 'Create',
87
+ formName: 'Name (kebab-case)',
88
+ formDesc: 'Description (required)',
89
+ formBody: 'Body (Markdown, optional)',
90
+ formRoot: 'Write to',
91
+ delete: 'Delete',
92
+ deleteConfirm: 'Confirm delete?',
93
+ deleteHint: 'Delete moves the skill into a .trash folder under the same root; restore it by hand.',
94
+ deleted: 'Moved to .trash',
95
+ preview: 'Body',
96
+ previewHide: 'Hide body',
97
+ reloadBody: 'Load body',
98
+ provider: 'Provider',
99
+ source: 'Source',
100
+ path: 'Path',
101
+ size: 'Size',
102
+ model: 'Model invocation',
103
+ user: 'User invocation',
104
+ modelHint: 'When off, the skill leaves the model catalog and the skill tool refuses to load it.',
105
+ userHint: 'When off, the skill disappears from the / menu.',
106
+ root: 'Skill root',
107
+ incomplete: 'The catalog read was incomplete (some providers were unavailable)',
108
+ scopeLive: 'Current session scope',
109
+ scopePreset: 'Session preset scope',
110
+ scopeCwd: 'No session bound; read from the working directory',
111
+ filterAll: 'All sources',
112
+ total: '{n} skills',
113
+ shown: 'Showing {n} of {m}',
114
+ whenToUse: 'When to use',
115
+ internalOnly: 'This skill is embedded in a plugin: it can be viewed but not edited or deleted.',
116
+ noBody: '(empty body)',
117
+ created: 'Created',
118
+ saved: 'Saved',
119
+ back: 'Back to session',
120
+ retry: 'Retry',
121
+ modelOff: 'model off',
122
+ userOff: 'user off',
123
+ embedded: 'embedded',
124
+ },
125
+ };
126
+
127
+ /** 无 locale 服务时的兜底取词(带 `{name}` 插值)。 */
128
+ function fallbackText(key, params) {
129
+ let text = DICT.zh[key] ?? key;
130
+ if (params !== undefined && params !== null) {
131
+ for (const [name, value] of Object.entries(params)) {
132
+ text = text.split(`{${name}}`).join(String(value));
133
+ }
134
+ }
135
+ return text;
136
+ }
137
+
138
+ /**
139
+ * 建一个归属本插件的 <style>。
140
+ * 必须打 `data-plugin`:宿主在模块工厂跑完后会认领"无主 style",
141
+ * 被认领的标签会随该模块的下一次变更一起删除。
142
+ * @param css - 样式文本。
143
+ * @returns 插入的 style 元素。
144
+ */
145
+ function createOwnedStyle(css) {
146
+ const el = document.createElement('style');
147
+ el.setAttribute('data-plugin', OWNER);
148
+ el.textContent = css;
149
+ document.head.appendChild(el);
150
+ return el;
151
+ }
152
+
153
+ const CSS = `
154
+ .dsh-sc-root{display:flex;flex-direction:column;height:100%;min-height:0;font-size:var(--dsw-font-xs-13-font-size,13px);line-height:1.5;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base)}
155
+ .dsh-sc-head{display:flex;flex-direction:column;gap:10px;padding:14px 20px 12px;border-bottom:1px solid var(--dsw-alias-border-l2);flex:none}
156
+ .dsh-sc-headrow{display:flex;align-items:baseline;gap:10px;min-width:0}
157
+ .dsh-sc-title{font-size:15px;font-weight:600;white-space:nowrap}
158
+ .dsh-sc-sub{color:var(--dsw-alias-label-tertiary);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
159
+ .dsh-sc-spacer{flex:1}
160
+ .dsh-sc-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
161
+ .dsh-sc-input{flex:1;min-width:140px;padding:6px 10px;border-radius:var(--dsw-radius-md,8px);border:1px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;font-family:inherit;outline:none}
162
+ .dsh-sc-input:focus-visible{border-color:var(--dsw-alias-state-business-primary)}
163
+ .dsh-sc-select{padding:6px 8px;border-radius:var(--dsw-radius-md,8px);border:1px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;font-family:inherit}
164
+ .dsh-sc-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:var(--dsw-radius-md,8px);border:1px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-button-elevated-fill);color:var(--dsw-alias-label-primary);font-size:13px;font-family:inherit;cursor:pointer;line-height:1.4;white-space:nowrap}
165
+ .dsh-sc-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
166
+ .dsh-sc-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
167
+ .dsh-sc-btn:disabled{opacity:.45;cursor:default}
168
+ .dsh-sc-btn-primary{background:var(--dsw-alias-button-primary-fill);border-color:transparent;color:var(--dsw-alias-label-primary-inverted)}
169
+ .dsh-sc-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}
170
+ .dsh-sc-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}
171
+ .dsh-sc-btn-danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
172
+ .dsh-sc-body{display:flex;flex:1;min-height:0}
173
+ .dsh-sc-list{width:288px;flex:none;overflow:auto;border-right:1px solid var(--dsw-alias-border-l2);padding:8px}
174
+ .dsh-sc-row{display:flex;flex-direction:column;gap:3px;width:100%;text-align:left;padding:8px 10px;border-radius:var(--dsw-radius-md,8px);border:none;background:transparent;color:inherit;cursor:pointer;font:inherit}
175
+ .dsh-sc-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
176
+ .dsh-sc-row:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
177
+ .dsh-sc-row-on{background:var(--dsw-alias-interactive-bg-active)}
178
+ .dsh-sc-name{font-weight:600;word-break:break-all}
179
+ .dsh-sc-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
180
+ .dsh-sc-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:var(--dsw-alias-label-caption);font-size:11px}
181
+ .dsh-sc-badge{padding:0 6px;border-radius:var(--dsw-radius-xs,4px);border:.5px solid var(--dsw-alias-border-l3);line-height:16px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
182
+ .dsh-sc-badge-warn{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}
183
+ .dsh-sc-detail{flex:1;min-width:0;overflow:auto;padding:18px 22px 32px}
184
+ .dsh-sc-h2{font-size:15px;font-weight:600;margin:0 0 6px;word-break:break-all}
185
+ .dsh-sc-p{color:var(--dsw-alias-label-secondary);margin:0 0 16px;white-space:pre-wrap;word-break:break-word}
186
+ .dsh-sc-section{margin:0 0 18px}
187
+ .dsh-sc-label{color:var(--dsw-alias-label-caption);font-size:11px;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}
188
+ .dsh-sc-kv{display:grid;grid-template-columns:84px 1fr;gap:4px 10px;font-size:12px;margin:0}
189
+ .dsh-sc-kv dt{color:var(--dsw-alias-label-tertiary)}
190
+ .dsh-sc-kv dd{margin:0;word-break:break-all;color:var(--dsw-alias-label-secondary)}
191
+ .dsh-sc-switchrow{display:flex;align-items:flex-start;gap:10px;padding:9px 0;border-top:1px solid var(--dsw-alias-border-l1)}
192
+ .dsh-sc-switch{position:relative;flex:none;width:34px;height:20px;border-radius:999px;border:1px solid var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-3);cursor:pointer;padding:0;transition:background .15s ease}
193
+ .dsh-sc-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary);border-color:transparent}
194
+ .dsh-sc-switch:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
195
+ .dsh-sc-switch:disabled{opacity:.45;cursor:default}
196
+ .dsh-sc-thumb{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dsw-alias-switch-thumb,#fff);transition:transform .15s ease}
197
+ .dsh-sc-switch[aria-checked="true"] .dsh-sc-thumb{transform:translateX(14px)}
198
+ .dsh-sc-switchtext{display:flex;flex-direction:column;gap:2px;min-width:0}
199
+ .dsh-sc-switchlabel{font-weight:600;font-size:12px}
200
+ .dsh-sc-hint{color:var(--dsw-alias-label-caption);font-size:11px}
201
+ .dsh-sc-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:14px}
202
+ .dsh-sc-pre{margin:0;padding:12px;border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-markdown-code-block);color:var(--dsw-alias-label-secondary);font-size:12px;white-space:pre-wrap;word-break:break-word;max-height:340px;overflow:auto;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace)}
203
+ .dsh-sc-note{border-radius:var(--dsw-radius-md,8px);padding:9px 12px;font-size:12px;margin:0 20px 0;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
204
+ .dsh-sc-note-warn{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-warn-primary)}
205
+ .dsh-sc-note-err{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary)}
206
+ .dsh-sc-note-ok{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-success-primary)}
207
+ .dsh-sc-form{display:flex;flex-direction:column;gap:8px;margin-top:12px;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg,12px);background:var(--dsw-alias-bg-layer-2)}
208
+ .dsh-sc-area{min-height:96px;resize:vertical;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,Consolas,monospace);font-size:12px}
209
+ .dsh-sc-foot{flex:none;display:flex;gap:10px;align-items:center;padding:7px 20px;border-top:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-caption);font-size:11px}
210
+ .dsh-sc-foot-cwd{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
211
+ .dsh-sc-center{display:flex;align-items:center;justify-content:center;flex:1;color:var(--dsw-alias-label-tertiary)}
212
+ [data-platform='darwin'] .dsh-sc-head{padding-top:calc(28px + var(--dsh-frame-top-clearance,0px))}
213
+ `;
214
+
215
+ // ------------------------------------------------------------------
216
+ // 宿主 API
217
+ // ------------------------------------------------------------------
218
+
219
+ /**
220
+ * 调宿主路由(GET)。
221
+ * @param op - 操作名。
222
+ * @param params - 查询参数。
223
+ * @returns 响应体。
224
+ */
225
+ async function apiGet(op, params = {}) {
226
+ const url = new URL(API, window.location.origin);
227
+ url.searchParams.set('op', op);
228
+ for (const [key, value] of Object.entries(params)) {
229
+ if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
230
+ }
231
+ const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } });
232
+ const payload = await response.json().catch(() => ({ ok: false, error: `HTTP ${response.status}` }));
233
+ if (payload.ok !== true) throw new Error(payload.error ?? `HTTP ${response.status}`);
234
+ return payload;
235
+ }
236
+
237
+ /**
238
+ * 调宿主路由(POST)。
239
+ * @param body - 请求体(含 op)。
240
+ * @returns 响应体。
241
+ */
242
+ async function apiPost(body) {
243
+ const response = await fetch(API, {
244
+ method: 'POST',
245
+ headers: { 'Content-Type': 'application/json' },
246
+ body: JSON.stringify(body),
247
+ });
248
+ const payload = await response.json().catch(() => ({ ok: false, error: `HTTP ${response.status}` }));
249
+ if (payload.ok !== true) throw new Error(payload.error ?? `HTTP ${response.status}`);
250
+ return payload;
251
+ }
252
+
253
+ /**
254
+ * 选一个会话作为作用域来源:优先"主区域正在显示的会话",否则最近更新的会话。
255
+ * 面板本身占用主区域,切过来之后 mainView 归属可能已经让出,
256
+ * 所以命中的结果会缓存下来供后续刷新复用。
257
+ * @param sessions - 客户端 sessions 服务。
258
+ * @param cache - 记忆上一次命中的会话 id 与 cwd。
259
+ * @returns `{ sessionId, cwd }`。
260
+ */
261
+ function pickScope(sessions, cache) {
262
+ try {
263
+ const snapshot = sessions?.list?.getSnapshot?.();
264
+ const byId = snapshot?.byId ?? {};
265
+ const entries = Object.entries(byId);
266
+ const main = entries.find(([, row]) => (row?.retainedBy?.mainView ?? 0) > 0);
267
+ if (main !== undefined) {
268
+ cache.sessionId = main[0];
269
+ cache.cwd = main[1]?.cwd;
270
+ return { sessionId: cache.sessionId, cwd: cache.cwd };
271
+ }
272
+ if (cache.sessionId !== undefined && byId[cache.sessionId] !== undefined) {
273
+ cache.cwd = byId[cache.sessionId]?.cwd ?? cache.cwd;
274
+ return { sessionId: cache.sessionId, cwd: cache.cwd };
275
+ }
276
+ let best;
277
+ for (const [id, row] of entries) {
278
+ const at = row?.updatedAt ?? row?.lastActivityAt ?? row?.createdAt ?? 0;
279
+ if (best === undefined || at > best.at) best = { id, at, cwd: row?.cwd };
280
+ }
281
+ if (best !== undefined) {
282
+ cache.sessionId = best.id;
283
+ cache.cwd = best.cwd;
284
+ return { sessionId: best.id, cwd: best.cwd };
285
+ }
286
+ } catch {
287
+ /* 会话服务不可用时退化为不传作用域 */
288
+ }
289
+ return { sessionId: cache.sessionId, cwd: cache.cwd };
290
+ }
291
+
292
+ // ------------------------------------------------------------------
293
+ // 小组件
294
+ // ------------------------------------------------------------------
295
+
296
+ function Switch({ checked, disabled, label, hint, onChange }) {
297
+ return h('div', { className: 'dsh-sc-switchrow' }, [
298
+ h('button', {
299
+ key: 'sw',
300
+ type: 'button',
301
+ role: 'switch',
302
+ 'aria-checked': checked,
303
+ 'aria-label': label,
304
+ disabled: disabled === true,
305
+ className: 'dsh-sc-switch',
306
+ onClick: () => { if (disabled !== true) onChange(!checked); },
307
+ }, h('span', { className: 'dsh-sc-thumb' })),
308
+ h('div', { key: 'tx', className: 'dsh-sc-switchtext' }, [
309
+ h('span', { key: 'l', className: 'dsh-sc-switchlabel' }, label),
310
+ h('span', { key: 'h', className: 'dsh-sc-hint' }, hint),
311
+ ]),
312
+ ]);
313
+ }
314
+
315
+ function formatBytes(bytes) {
316
+ if (typeof bytes !== 'number') return '—';
317
+ if (bytes < 1024) return `${bytes} B`;
318
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
319
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
320
+ }
321
+
322
+ // ------------------------------------------------------------------
323
+ // 侧栏图标
324
+ // ------------------------------------------------------------------
325
+
326
+ /**
327
+ * 侧栏全局面板行的图标;`size` 由宿主给(展开 16 / 收起 18)。
328
+ * @param props - `{ size, active }`。
329
+ * @returns SVG 元素。
330
+ */
331
+ function SkillCenterIcon({ size = 16 }) {
332
+ const dimension = size ?? 16;
333
+ return h('svg', {
334
+ width: dimension,
335
+ height: dimension,
336
+ viewBox: '0 0 16 16',
337
+ 'aria-hidden': true,
338
+ fill: 'none',
339
+ stroke: 'currentColor',
340
+ strokeWidth: 1.3,
341
+ strokeLinecap: 'round',
342
+ strokeLinejoin: 'round',
343
+ }, [
344
+ h('rect', { key: 'a', x: 2.2, y: 3, width: 5.2, height: 4.4, rx: 1.1 }),
345
+ h('rect', { key: 'b', x: 8.6, y: 3, width: 5.2, height: 9.9, rx: 1.1 }),
346
+ h('rect', { key: 'c', x: 2.2, y: 8.6, width: 5.2, height: 4.3, rx: 1.1 }),
347
+ ]);
348
+ }
349
+
350
+ // ------------------------------------------------------------------
351
+ // 面板
352
+ // ------------------------------------------------------------------
353
+
354
+ /**
355
+ * 造面板组件(闭包持有 ctx、取词函数与记忆的作用域)。
356
+ * @param ctx - 客户端根上下文。
357
+ * @param localT - locale 不可用时的兜底取词。
358
+ * @returns 面板组件。
359
+ */
360
+ function makePage(ctx, localT) {
361
+ const sessions = ctx.get('sessions');
362
+ const scopeCache = {};
363
+
364
+ return function SkillCenterPage(props) {
365
+ const t = typeof props?.t === 'function' ? props.t : localT;
366
+ const [catalog, setCatalog] = React.useState(undefined);
367
+ const [error, setError] = React.useState(undefined);
368
+ const [busy, setBusy] = React.useState(false);
369
+ const [notice, setNotice] = React.useState(undefined);
370
+ const [query, setQuery] = React.useState('');
371
+ const [sourceFilter, setSourceFilter] = React.useState('');
372
+ const [selected, setSelected] = React.useState(undefined);
373
+ const [body, setBody] = React.useState(undefined);
374
+ const [pendingDelete, setPendingDelete] = React.useState(false);
375
+ const [creating, setCreating] = React.useState(false);
376
+
377
+ const load = React.useCallback(async () => {
378
+ setBusy(true);
379
+ setError(undefined);
380
+ try {
381
+ const payload = await apiGet('list', pickScope(sessions, scopeCache));
382
+ setCatalog(payload);
383
+ setSelected((previous) => previous ?? payload.skills[0]?.name);
384
+ } catch (cause) {
385
+ setError(String(cause?.message ?? cause));
386
+ } finally {
387
+ setBusy(false);
388
+ }
389
+ }, [sessions]);
390
+
391
+ React.useEffect(() => { load(); }, [load]);
392
+
393
+ const skills = catalog?.skills ?? [];
394
+ const sources = Array.from(new Set(skills.map((skill) => skill.source).filter(Boolean))).sort();
395
+ const filtered = skills.filter((skill) => {
396
+ if (sourceFilter !== '' && skill.source !== sourceFilter) return false;
397
+ const needle = query.trim().toLowerCase();
398
+ if (needle === '') return true;
399
+ return skill.name.toLowerCase().includes(needle)
400
+ || String(skill.description ?? '').toLowerCase().includes(needle);
401
+ });
402
+ const current = skills.find((skill) => skill.name === selected) ?? filtered[0];
403
+ const scopeText = catalog === undefined ? ''
404
+ : catalog.tier === 'process-cwd' || catalog.tier === 'global' ? t('scopeCwd')
405
+ : catalog.tier === 'preset-scope' ? t('scopePreset') : t('scopeLive');
406
+
407
+ /**
408
+ * 跑一个写操作,然后重读目录。
409
+ * @param action - 返回 Promise 的动作。
410
+ * @param okMessage - 成功提示。
411
+ */
412
+ const mutate = async (action, okMessage) => {
413
+ setBusy(true);
414
+ setError(undefined);
415
+ setNotice(undefined);
416
+ try {
417
+ await action();
418
+ await load();
419
+ setNotice(okMessage);
420
+ } catch (cause) {
421
+ setError(String(cause?.message ?? cause));
422
+ } finally {
423
+ setBusy(false);
424
+ }
425
+ };
426
+
427
+ const setInvocation = (skill, patch) => mutate(
428
+ () => apiPost({
429
+ op: 'setInvocation',
430
+ ...pickScope(sessions, scopeCache),
431
+ name: skill.name,
432
+ modelInvocable: patch.modelInvocable ?? skill.modelInvocable,
433
+ userInvocable: patch.userInvocable ?? skill.userInvocable,
434
+ }),
435
+ t('saved'),
436
+ );
437
+
438
+ const removeSkill = (skill) => mutate(
439
+ async () => {
440
+ await apiPost({ op: 'delete', ...pickScope(sessions, scopeCache), name: skill.name });
441
+ setSelected(undefined);
442
+ setBody(undefined);
443
+ setPendingDelete(false);
444
+ },
445
+ t('deleted'),
446
+ );
447
+
448
+ const loadBody = async (skill) => {
449
+ setBusy(true);
450
+ setError(undefined);
451
+ try {
452
+ const payload = await apiPost({ op: 'read', ...pickScope(sessions, scopeCache), name: skill.name });
453
+ setBody(payload.text);
454
+ } catch (cause) {
455
+ setError(String(cause?.message ?? cause));
456
+ } finally {
457
+ setBusy(false);
458
+ }
459
+ };
460
+
461
+ const head = h('div', { className: 'dsh-sc-head', key: 'head' }, [
462
+ h('div', { className: 'dsh-sc-headrow', key: 'r1' }, [
463
+ h('span', { className: 'dsh-sc-title', key: 't' }, t('title')),
464
+ h('span', { className: 'dsh-sc-sub', key: 's' }, catalog === undefined
465
+ ? t('subtitle')
466
+ : `${t('total', { n: skills.length })} · ${scopeText}`),
467
+ h('span', { className: 'dsh-sc-spacer', key: 'sp' }),
468
+ h('button', {
469
+ key: 'back',
470
+ type: 'button',
471
+ className: 'dsh-sc-btn',
472
+ onClick: () => { try { ctx.get('layout')?.selectPanel?.(null); } catch { /* 布局服务不可用 */ } },
473
+ }, t('back')),
474
+ ]),
475
+ h('div', { className: 'dsh-sc-tools', key: 'r2' }, [
476
+ h('input', {
477
+ key: 'q',
478
+ type: 'search',
479
+ className: 'dsh-sc-input',
480
+ placeholder: t('search'),
481
+ value: query,
482
+ onChange: (event) => setQuery(event.target.value),
483
+ }),
484
+ sources.length > 1 ? h('select', {
485
+ key: 'f',
486
+ className: 'dsh-sc-select',
487
+ value: sourceFilter,
488
+ onChange: (event) => setSourceFilter(event.target.value),
489
+ }, [
490
+ h('option', { key: '__all', value: '' }, t('filterAll')),
491
+ ...sources.map((source) => h('option', { key: source, value: source }, source)),
492
+ ]) : null,
493
+ h('button', {
494
+ key: 'new',
495
+ type: 'button',
496
+ className: 'dsh-sc-btn',
497
+ onClick: () => { setCreating((value) => !value); setPendingDelete(false); },
498
+ }, t('create')),
499
+ h('button', {
500
+ key: 'refresh',
501
+ type: 'button',
502
+ className: 'dsh-sc-btn',
503
+ disabled: busy,
504
+ onClick: () => { setBody(undefined); load(); },
505
+ }, t('refresh')),
506
+ ]),
507
+ ]);
508
+
509
+ const list = h('div', { className: 'dsh-sc-list', key: 'list' },
510
+ filtered.length === 0
511
+ ? [h('div', {
512
+ key: 'e',
513
+ className: 'dsh-sc-hint',
514
+ style: { padding: '10px' },
515
+ }, skills.length === 0 ? t('emptyAll') : t('empty'))]
516
+ : filtered.map((skill) => h('button', {
517
+ key: skill.name,
518
+ type: 'button',
519
+ className: `dsh-sc-row${current?.name === skill.name ? ' dsh-sc-row-on' : ''}`,
520
+ onClick: () => { setSelected(skill.name); setBody(undefined); setPendingDelete(false); },
521
+ }, [
522
+ h('span', { key: 'n', className: 'dsh-sc-name' }, skill.name),
523
+ h('span', { key: 'm', className: 'dsh-sc-meta' }, [
524
+ h('span', { key: 's', className: 'dsh-sc-badge' }, skill.source ?? skill.provider ?? '—'),
525
+ skill.modelInvocable === false ? h('span', { key: 'mm', className: 'dsh-sc-badge dsh-sc-badge-warn' }, t('modelOff')) : null,
526
+ skill.userInvocable === false ? h('span', { key: 'uu', className: 'dsh-sc-badge dsh-sc-badge-warn' }, t('userOff')) : null,
527
+ skill.writable !== true ? h('span', { key: 'em', className: 'dsh-sc-badge' }, t('embedded')) : null,
528
+ ]),
529
+ h('span', { key: 'd', className: 'dsh-sc-desc' }, skill.description),
530
+ ])));
531
+
532
+ let detail;
533
+ if (creating) {
534
+ detail = h(CreateForm, {
535
+ key: 'create',
536
+ t,
537
+ catalog,
538
+ scopeCache,
539
+ sessions,
540
+ onDone: (message) => { setCreating(false); setNotice(message); load(); },
541
+ onError: setError,
542
+ });
543
+ } else if (current === undefined) {
544
+ detail = h('div', { key: 'empty', className: 'dsh-sc-center' }, t('emptyAll'));
545
+ } else {
546
+ detail = h('div', { key: 'detail', className: 'dsh-sc-detail' }, [
547
+ h('h2', { key: 'h', className: 'dsh-sc-h2' }, current.name),
548
+ h('p', { key: 'p', className: 'dsh-sc-p' }, current.description ?? ''),
549
+ current.whenToUse ? h('div', { key: 'w', className: 'dsh-sc-section' }, [
550
+ h('div', { key: 'l', className: 'dsh-sc-label' }, t('whenToUse')),
551
+ h('p', { key: 'v', className: 'dsh-sc-p' }, current.whenToUse),
552
+ ]) : null,
553
+ h('div', { key: 'kv', className: 'dsh-sc-section' }, [
554
+ h('div', { key: 'l', className: 'dsh-sc-label' }, t('path')),
555
+ h('dl', { key: 'd', className: 'dsh-sc-kv' }, [
556
+ h('dt', { key: 'p' }, t('provider')), h('dd', { key: 'pv' }, current.provider ?? '—'),
557
+ h('dt', { key: 's' }, t('source')), h('dd', { key: 'sv' }, current.source ?? '—'),
558
+ h('dt', { key: 'r' }, t('root')), h('dd', { key: 'rv' }, current.rootLabel ?? '—'),
559
+ h('dt', { key: 'f' }, t('path')), h('dd', { key: 'fv' }, current.path ?? '—'),
560
+ h('dt', { key: 'z' }, t('size')), h('dd', { key: 'zv' }, formatBytes(current.bytes)),
561
+ ]),
562
+ ]),
563
+ h('div', { key: 'sw', className: 'dsh-sc-section' }, [
564
+ h(Switch, {
565
+ key: 'model',
566
+ checked: current.modelInvocable !== false,
567
+ disabled: busy || current.writable !== true,
568
+ label: t('model'),
569
+ hint: t('modelHint'),
570
+ onChange: (value) => setInvocation(current, { modelInvocable: value }),
571
+ }),
572
+ h(Switch, {
573
+ key: 'user',
574
+ checked: current.userInvocable !== false,
575
+ disabled: busy || current.writable !== true,
576
+ label: t('user'),
577
+ hint: t('userHint'),
578
+ onChange: (value) => setInvocation(current, { userInvocable: value }),
579
+ }),
580
+ ]),
581
+ current.writable !== true
582
+ ? h('div', { key: 'note', className: 'dsh-sc-note dsh-sc-note-warn', style: { margin: '0 0 14px' } }, t('internalOnly'))
583
+ : null,
584
+ h('div', { key: 'act', className: 'dsh-sc-actions' }, [
585
+ h('button', {
586
+ key: 'body',
587
+ type: 'button',
588
+ className: 'dsh-sc-btn',
589
+ disabled: busy || current.writable !== true,
590
+ onClick: () => { if (body === undefined) loadBody(current); else setBody(undefined); },
591
+ }, body === undefined ? t('reloadBody') : t('previewHide')),
592
+ h('button', {
593
+ key: 'del',
594
+ type: 'button',
595
+ className: `dsh-sc-btn${pendingDelete ? ' dsh-sc-btn-danger' : ''}`,
596
+ disabled: busy || current.writable !== true,
597
+ onClick: () => { if (pendingDelete) removeSkill(current); else setPendingDelete(true); },
598
+ }, pendingDelete ? t('deleteConfirm') : t('delete')),
599
+ pendingDelete ? h('span', { key: 'dh', className: 'dsh-sc-hint' }, t('deleteHint')) : null,
600
+ ]),
601
+ body !== undefined ? h('div', {
602
+ key: 'pre',
603
+ className: 'dsh-sc-section',
604
+ style: { marginTop: '16px' },
605
+ }, [
606
+ h('div', { key: 'l', className: 'dsh-sc-label' }, t('preview')),
607
+ h('pre', { key: 'v', className: 'dsh-sc-pre' }, body === '' ? t('noBody') : body),
608
+ ]) : null,
609
+ ]);
610
+ }
611
+
612
+ const foot = h('div', { className: 'dsh-sc-foot', key: 'foot' }, [
613
+ h('span', { key: 'n' }, catalog === undefined ? '' : t('shown', { n: filtered.length, m: skills.length })),
614
+ h('span', { key: 'c', className: 'dsh-sc-foot-cwd' }, catalog?.cwd ?? ''),
615
+ catalog?.complete === false ? h('span', { key: 'w', className: 'dsh-sc-badge dsh-sc-badge-warn' }, t('incomplete')) : null,
616
+ ]);
617
+
618
+ return h('div', { className: 'dsh-sc-root' }, [
619
+ head,
620
+ error !== undefined
621
+ ? h('div', { key: 'err', className: 'dsh-sc-note dsh-sc-note-err', style: { marginTop: '12px' } }, [
622
+ h('span', { key: 'm' }, error),
623
+ h('button', {
624
+ key: 'retry',
625
+ type: 'button',
626
+ className: 'dsh-sc-btn',
627
+ onClick: () => load(),
628
+ }, t('retry')),
629
+ ])
630
+ : null,
631
+ notice !== undefined
632
+ ? h('div', { key: 'ok', className: 'dsh-sc-note dsh-sc-note-ok', style: { marginTop: '12px' } }, notice)
633
+ : null,
634
+ catalog === undefined && error === undefined
635
+ ? h('div', { key: 'loading', className: 'dsh-sc-center' }, t('loading'))
636
+ : h('div', { key: 'body', className: 'dsh-sc-body' }, [list, detail]),
637
+ foot,
638
+ ]);
639
+ };
640
+ }
641
+
642
+ /**
643
+ * 新建技能表单。
644
+ * @param props - `{ t, catalog, scopeCache, sessions, onDone, onError }`。
645
+ * @returns 表单元素。
646
+ */
647
+ function CreateForm({ t, catalog, scopeCache, sessions, onDone, onError }) {
648
+ const [name, setName] = React.useState('');
649
+ const [description, setDescription] = React.useState('');
650
+ const [body, setBody] = React.useState('');
651
+ const [rootId, setRootId] = React.useState('user-agents');
652
+ const [busy, setBusy] = React.useState(false);
653
+ const roots = (catalog?.roots ?? []).filter((root) => root.id === 'user-agents' || root.id === 'user-dsh');
654
+ const submit = async () => {
655
+ setBusy(true);
656
+ try {
657
+ const payload = await apiPost({
658
+ op: 'create',
659
+ ...pickScope(sessions, scopeCache),
660
+ name,
661
+ description,
662
+ body,
663
+ rootId,
664
+ });
665
+ onDone(`${t('created')}: ${payload.name}`);
666
+ } catch (cause) {
667
+ onError(String(cause?.message ?? cause));
668
+ } finally {
669
+ setBusy(false);
670
+ }
671
+ };
672
+ return h('div', { className: 'dsh-sc-detail' }, [
673
+ h('h2', { key: 'h', className: 'dsh-sc-h2' }, t('create')),
674
+ h('div', { key: 'f', className: 'dsh-sc-form' }, [
675
+ h('input', {
676
+ key: 'n', className: 'dsh-sc-input', placeholder: t('formName'),
677
+ value: name, onChange: (event) => setName(event.target.value),
678
+ }),
679
+ h('input', {
680
+ key: 'd', className: 'dsh-sc-input', placeholder: t('formDesc'),
681
+ value: description, onChange: (event) => setDescription(event.target.value),
682
+ }),
683
+ h('textarea', {
684
+ key: 'b', className: 'dsh-sc-input dsh-sc-area', placeholder: t('formBody'),
685
+ value: body, onChange: (event) => setBody(event.target.value),
686
+ }),
687
+ h('div', { key: 'r', className: 'dsh-sc-tools' }, [
688
+ h('span', { key: 'l', className: 'dsh-sc-hint' }, t('formRoot')),
689
+ h('select', {
690
+ key: 's', className: 'dsh-sc-select', value: rootId,
691
+ onChange: (event) => setRootId(event.target.value),
692
+ }, roots.map((root) => h('option', {
693
+ key: root.id, value: root.id, title: root.path,
694
+ }, `${root.label} — ${root.path}`))),
695
+ ]),
696
+ h('div', { key: 'a', className: 'dsh-sc-actions' }, [
697
+ h('button', {
698
+ key: 'ok',
699
+ type: 'button',
700
+ className: 'dsh-sc-btn dsh-sc-btn-primary',
701
+ disabled: busy || name === '' || description === '',
702
+ onClick: submit,
703
+ }, t('createSubmit')),
704
+ ]),
705
+ ]),
706
+ ]);
707
+ }
708
+
709
+ // ------------------------------------------------------------------
710
+ // 插件体
711
+ // ------------------------------------------------------------------
712
+
713
+ const inject = ['slots', 'sessions', 'layout'];
714
+
715
+ /**
716
+ * 注册侧栏行与中央面板。
717
+ * @param ctx - 客户端根上下文。
718
+ */
719
+ function apply(ctx) {
720
+ const slots = ctx.get('slots');
721
+ if (slots === undefined) return;
722
+
723
+ let t = fallbackText;
724
+ ctx.effect(() => {
725
+ const locale = ctx.get('locale');
726
+ if (locale === undefined) return undefined;
727
+ let dispose;
728
+ try {
729
+ dispose = locale.register(NS, { zh: DICT.zh, en: DICT.en });
730
+ t = locale.bind(NS);
731
+ } catch {
732
+ t = fallbackText;
733
+ return undefined;
734
+ }
735
+ return () => {
736
+ t = fallbackText;
737
+ try {
738
+ dispose?.();
739
+ } catch {
740
+ /* 摘除失败不影响卸载 */
741
+ }
742
+ };
743
+ }, 'skill-center: dictionaries');
744
+
745
+ ctx.effect(() => {
746
+ const style = createOwnedStyle(CSS);
747
+ return () => { style.remove(); };
748
+ }, 'skill-center: styles');
749
+
750
+ slots.inject('sidebar.panellist', () => slots.register({
751
+ name: 'sidebar.panellist',
752
+ id: PANEL_ID,
753
+ order: 1,
754
+ label: () => t('panel'),
755
+ locale: NS,
756
+ }, SkillCenterIcon));
757
+
758
+ slots.inject('main', () => slots.register({
759
+ name: 'main',
760
+ key: PANEL_ID,
761
+ locale: NS,
762
+ }, makePage(ctx, (key, params) => t(key, params))));
763
+ }
764
+
765
+ return { apply, inject };
766
+ },
767
+ });