@weibaohui/skills-management 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1380 @@
1
+ /* Generated from client/index.js by scripts/build-client.mjs — do not edit by hand.
2
+ * Regenerate with: npm run build:client
3
+ */
4
+ window.__ModuleLoader__.load({
5
+ id: "dsh-plugin-skills-management",
6
+ factory: (require) => {
7
+ var module = { exports: {} }
8
+ var exports = module.exports
9
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" })
10
+ var React = require("react")
11
+ /**
12
+ * dsh-plugin-skills-management - Browser half.
13
+ *
14
+ * One React app for every surface (sidebar overlay + settings section).
15
+ * All interactive controls are host primitives (@deepseek-ai/dsh-client-ui-
16
+ * primitives); all colors come from the ui-theme `--dsw-*` token layers so
17
+ * light/dark follows the shell; all copy comes from the locale registry
18
+ * (`zh`/`en`) so switches render live. No hardcoded colors, no ad-hoc copy.
19
+ */
20
+
21
+ // React is a loader platform module. Under plain Node (contract tests) a
22
+ // minimal createElement/hook shim keeps the source loadable for assertions.
23
+ let __React = null
24
+ try { __React = require('react') } catch {}
25
+ if (!__React || typeof __React.createElement !== 'function') {
26
+ __React = {
27
+ createElement(type, props, ...kids) {
28
+ return { type, props: props || {}, kids: kids.flat(9).filter(k => k !== null && k !== undefined && k !== false && k !== true && typeof k !== 'string' || true) }
29
+ },
30
+ useState(init) { const v = [typeof init === 'function' ? init() : init]; return [v[0], x => { v[0] = typeof x === 'function' ? x(v[0]) : x }] },
31
+ useEffect() {}, useMemo(fn) { return fn() }, useRef(v = null) { return { current: v } },
32
+ }
33
+ }
34
+ const { createElement: h, useState, useEffect, useMemo, useRef } = __React
35
+
36
+ // Platform module — always present in the loader's seeded require table.
37
+ // Under plain Node (tests) it is absent; a tagged-element shim keeps the
38
+ // tree structurally testable while every real surface ships primitives.
39
+ let P = null
40
+ try { P = require('@deepseek-ai/dsh-client-ui-primitives') } catch {}
41
+ let RDP = null
42
+ try { RDP = require('react-dom') } catch {}
43
+
44
+ // NOTE: no class components in this module. A `class X extends
45
+ // React.Component` error boundary defined here silently killed rendering in
46
+ // the plugin loader (components before it rendered, it never instantiated,
47
+ // zero errors) — render-time crashes are handled by the try/catch inside
48
+ // SkillsPage and recorded into globalThis.__skErrors instead.
49
+
50
+ /** Idempotent stylesheet injection — the overlay/tab/card classes are
51
+ * position-critical (.sk-overlay is position:fixed) so mounting without
52
+ * them renders the panel invisibly at the end of <body>. */
53
+ function ensureStyles() {
54
+ if (typeof document === 'undefined' || document.getElementById('sk-styles')) return
55
+ const holder = document.createElement('div')
56
+ holder.id = 'sk-styles'
57
+ holder.style.display = 'none'
58
+ holder.innerHTML = STYLE
59
+ document.head.appendChild(holder)
60
+ }
61
+
62
+ const prim = (name) => P && P[name]
63
+ ? P[name]
64
+ : function Shim(props) {
65
+ const { children, text, ...rest } = props
66
+ const tag = props.href ? 'a' : ['item', 'entry'].includes(props.__kind) ? 'div' : 'button'
67
+ const el = document.createElement(tag === 'function' || typeof props.as === 'string' ? props.as : tag)
68
+ return h(tag, { ...rest, 'data-p-shim': name }, children)
69
+ }
70
+
71
+ // Sessions service (client runtime): opens the run's conversation in the
72
+ // real UI. Resolved through dynamic ctx.inject (ui-commands precedent);
73
+ // absence degrades the 打开对话 button to hidden.
74
+ let sessionsApi = null
75
+ const sessionsSvc = () => sessionsApi
76
+
77
+ // ── Locale ───────────────────────────────────────────────────────────────
78
+
79
+ const NS = 'skillsManagement'
80
+
81
+ const ZH = {
82
+ title: '技能市场',
83
+ close: '关闭',
84
+ tabExecutors: '执行器',
85
+ tabMarket: '市场',
86
+ tabSources: '来源',
87
+ cardsHint: '选择一个执行器浏览它的技能,或一次查看全部',
88
+ marketCardsHint: '选择一个来源仓库浏览其技能,或一次查看全部',
89
+ marketLoading: '正在加载市场目录…',
90
+ marketSettings: '市场设置',
91
+ syncNow: '立即同步',
92
+ syncing: '同步中,可能需要一分钟…',
93
+ syncDoneUpdated: '同步完成,市场已更新',
94
+ syncDoneLatest: '已是最新版本',
95
+ firstCloneDone: '首次克隆完成',
96
+ repoUrlLabel: '仓库地址',
97
+ branchLabel: '分支',
98
+ lastSyncLabel: '上次同步',
99
+ localCommitLabel: '本地版本',
100
+ remoteCommitLabel: '远程版本',
101
+ needsUpdateTag: '有更新',
102
+ autoSyncLabel: '每天自动同步',
103
+ syncOnStartupLabel: '启动时同步',
104
+ save: '保存',
105
+ saved: '设置已保存',
106
+ gitMissing: '未检测到 git',
107
+ repoDirLabel: '本地目录(同步内容存放处)',
108
+ tokenLabel: '访问令牌(私有仓库需要)',
109
+ tokenConfigured: '已配置',
110
+ clearToken: '清除',
111
+ shareBtn: '分享',
112
+ shareTitle: '分享技能到官方仓库',
113
+ shareHintPatMissing: '尚未配置访问令牌:先在 ⚙ 市场设置 中保存 GitCode 令牌,AI 才能以你的身份提交 PR。',
114
+ shareHint: 'AI 将读取本机令牌,fork 官方仓库 → 建分支 → 提交该技能目录 → 创建 PR。确认或修改提示词后,复制到当前会话发送执行。',
115
+ shareParamName: '技能名',
116
+ shareParamDir: '本机目录',
117
+ shareParamRemote: '目标路径',
118
+ shareParamVersion: '版本',
119
+ copyPrompt: '复制提示词',
120
+ runBtn: '执行',
121
+ openChat: '打开对话',
122
+ running: '执行中…(可能需要几分钟)',
123
+ runDone: '执行完成',
124
+ runFailed: '执行失败',
125
+ outputLabel: '执行输出',
126
+ promptCopied: '提示词已复制,粘贴到输入框发送即可执行',
127
+ repoMissing: '尚未克隆,点「立即同步」拉取',
128
+ backSources: '← 按来源',
129
+ backMarketCards: '← 来源卡片',
130
+ allMarketTitle: '全部市场技能',
131
+ browseAllMarket: '浏览全部市场技能',
132
+ showMore: '显示更多(剩余 {n})',
133
+ browseAll: '浏览全部技能',
134
+ refresh: '刷新',
135
+ backCards: '← 执行器卡片',
136
+ backExecutors: '← 执行器',
137
+ backAll: '← 全部技能',
138
+ searchAll: '搜索全部技能…',
139
+ filterWithin: '在 {label} 内筛选…',
140
+ pickSource: '全部执行器',
141
+ loadingCatalogs: '正在加载全部执行器目录…',
142
+ scanning: '正在扫描 {label}…',
143
+ skillsSuffix: '个技能',
144
+ noExecutors: '本机未发现任何执行器目录',
145
+ dirMissingTitle: '{label}:目录不存在',
146
+ dirMissingHint: '预期位置 {dir}',
147
+ notFoundGroup: '本机不存在的来源({n})',
148
+ dirNotPresent: '目录不存在',
149
+ emptySearch: '没有匹配的技能',
150
+ emptySkillsIn: '{label} 中还没有技能',
151
+ installedTag: '已装',
152
+ readOnlyTag: '只读',
153
+ toDsh: '安装',
154
+ deleteBtn: '删除',
155
+ detail: '详情',
156
+ filesCount: '{n} 个文件',
157
+ totalSize: '共 {size}',
158
+ copy: '复制内容',
159
+ copied: '已复制到剪贴板',
160
+ installFrom: '从 {label} 安装',
161
+ installTitle: '安装',
162
+ installOk: '确认安装',
163
+ cancel: '取消',
164
+ installedToast: '已装入 DSH 技能库',
165
+ marketLabel: '市场',
166
+ installConfirm: '即将把「{name}」从「{label}」安装到 DSH 技能库,安装后可通过 DSH 的 skill 工具调用。',
167
+ installFromMarket: '从市场安装 {name}?',
168
+ deleteTitle: '删除技能',
169
+ deleteConfirm: '即将从「{where}」删除技能「{name}」,删除后不可恢复。',
170
+ whereDsh: 'DSH 技能库',
171
+ operationFailed: '操作失败',
172
+ preview: '文件预览',
173
+ content: '内容',
174
+ activeInDsh: '已在 DSH 生效',
175
+ invocationToggle: '模型可调用',
176
+ hiddenTag: '已隐藏',
177
+ hideAction: '隐藏',
178
+ restoreAction: '恢复可见',
179
+ modelVisibleTag: '模型可见',
180
+ detailHideAction: '在模型目录隐藏',
181
+ invocationOn: '模型可见',
182
+ invocationOff: '已从模型目录隐藏',
183
+ invocationHint: '关闭后技能保留在库里,但不再注入对话目录(skill 工具也调不到)',
184
+ pathLabel: '路径',
185
+ meTag: '本机',
186
+ }
187
+
188
+ const EN = {
189
+ title: 'Skills Market',
190
+ close: 'Close',
191
+ tabExecutors: 'Executors',
192
+ tabMarket: 'Market',
193
+ tabSources: 'Sources',
194
+ cardsHint: 'Pick an executor to browse its skills, or view everything at once',
195
+ marketCardsHint: 'Pick a source repo to browse its skills, or view everything at once',
196
+ marketLoading: 'Loading market catalog…',
197
+ marketSettings: 'Market Settings',
198
+ syncNow: 'Sync now',
199
+ syncing: 'Syncing, may take a minute…',
200
+ syncDoneUpdated: 'Synced, market updated',
201
+ syncDoneLatest: 'Already up to date',
202
+ firstCloneDone: 'Initial clone complete',
203
+ repoUrlLabel: 'Repository URL',
204
+ branchLabel: 'Branch',
205
+ lastSyncLabel: 'Last sync',
206
+ localCommitLabel: 'Local commit',
207
+ remoteCommitLabel: 'Remote commit',
208
+ needsUpdateTag: 'Updates available',
209
+ autoSyncLabel: 'Auto sync daily',
210
+ syncOnStartupLabel: 'Sync on startup',
211
+ save: 'Save',
212
+ saved: 'Settings saved',
213
+ gitMissing: 'git not found',
214
+ repoDirLabel: 'Local directory (sync target)',
215
+ tokenLabel: 'Access token (private repos)',
216
+ tokenConfigured: 'configured',
217
+ clearToken: 'Clear',
218
+ shareBtn: 'Share',
219
+ shareTitle: 'Share skill to the official repo',
220
+ shareHintPatMissing: 'No access token configured — save a GitCode token in ⚙ Market Settings first so the AI can open the PR as you.',
221
+ shareHint: 'The AI will read the local token, fork the official repo, create a branch, commit this skill directory and open a PR. Review or edit the prompt, copy it into the current session and send.',
222
+ shareParamName: 'Skill',
223
+ shareParamDir: 'Local directory',
224
+ shareParamRemote: 'Remote path',
225
+ shareParamVersion: 'Version',
226
+ copyPrompt: 'Copy prompt',
227
+ runBtn: 'Run',
228
+ openChat: 'Open chat',
229
+ running: 'Running… (may take minutes)',
230
+ runDone: 'Run complete',
231
+ runFailed: 'Run failed',
232
+ outputLabel: 'Output',
233
+ promptCopied: 'Prompt copied — paste into the composer and send',
234
+ repoMissing: 'Not cloned yet — hit Sync now',
235
+ backSources: '← By source',
236
+ backMarketCards: '← Source cards',
237
+ allMarketTitle: 'All Market Skills',
238
+ browseAllMarket: 'Browse all market skills',
239
+ showMore: 'Show more ({n} left)',
240
+ browseAll: 'Browse all skills',
241
+ refresh: 'Refresh',
242
+ backCards: '← Executor cards',
243
+ backExecutors: '← Executors',
244
+ backAll: '← All skills',
245
+ searchAll: 'Search all skills…',
246
+ filterWithin: 'Filter within {label}…',
247
+ pickSource: 'All Executors',
248
+ loadingCatalogs: 'Loading all executor catalogs…',
249
+ scanning: 'Scanning {label}…',
250
+ skillsSuffix: 'skills',
251
+ noExecutors: 'No executor directories found on this machine',
252
+ dirMissingTitle: '{label}: directory not found',
253
+ dirMissingHint: 'Expected skills at {dir}',
254
+ notFoundGroup: 'Not found on this machine ({n})',
255
+ dirNotPresent: 'directory not present',
256
+ emptySearch: 'No matching skills',
257
+ emptySkillsIn: 'No skills in {label}',
258
+ installedTag: 'Installed',
259
+ readOnlyTag: 'read-only',
260
+ toDsh: 'Install',
261
+ deleteBtn: 'Delete',
262
+ detail: 'Detail',
263
+ filesCount: '{n} files',
264
+ totalSize: '{size}',
265
+ copy: 'Copy content',
266
+ copied: 'Copied to clipboard',
267
+ installFrom: 'Install from {label}',
268
+ installTitle: 'Install',
269
+ installOk: 'Install',
270
+ cancel: 'Cancel',
271
+ installedToast: 'Installed into the DSH library',
272
+ marketLabel: 'Market',
273
+ installConfirm: 'Install "{name}" from {label} into the DSH skills library. It becomes callable through the DSH skill tool.',
274
+ installFromMarket: 'Install {name} from market?',
275
+ deleteTitle: 'Delete skill',
276
+ deleteConfirm: 'You are about to delete "{name}" from {where}. This cannot be undone.',
277
+ whereDsh: 'the DSH library',
278
+ operationFailed: 'Operation failed',
279
+ preview: 'File preview',
280
+ content: 'Content',
281
+ activeInDsh: 'active in DSH',
282
+ invocationToggle: 'Model-invocable',
283
+ hiddenTag: 'hidden',
284
+ hideAction: 'Hide',
285
+ restoreAction: 'Restore',
286
+ modelVisibleTag: 'model-visible',
287
+ detailHideAction: 'Hide from model catalog',
288
+ invocationOn: 'model-visible',
289
+ invocationOff: 'hidden from model catalog',
290
+ invocationHint: 'When off, the skill stays in the library but is not injected into conversation catalogs',
291
+ pathLabel: 'Path',
292
+ meTag: 'me',
293
+ }
294
+
295
+ // ── Pure helpers ────────────────────────────────────────────────────────
296
+
297
+ /** ntd ActionButton 同款 {{key}} 替换:split/join 规避正则元字符 */
298
+ function substituteParams(template, params) {
299
+ let out = template
300
+ for (const [key, value] of Object.entries(params)) {
301
+ out = out.split(`{{${key}}}`).join(String(value))
302
+ }
303
+ return out
304
+ }
305
+
306
+ const SHARE_PROMPT_ZH = [
307
+ '请把本地技能「{{skillName}}」{{version}}打包提交到 GitCode 官方仓库,作为一个 PR 供维护者审核。',
308
+ '',
309
+ '## 关键信息',
310
+ '- 技能目录:{{resourceDir}}(~ 表示当前用户家目录,执行前先展开为绝对路径)',
311
+ '- 官方仓库:weibaohui/ntd-resource(GitCode,API base = https://api.gitcode.com)',
312
+ '- PAT 位置:~/.dsh/settings.yaml 中 skills-management.market 段的 token 字段(由技能市场设置面板保存)。',
313
+ '',
314
+ '## 执行步骤(严格按顺序)',
315
+ '1. 读取 PAT:读取 ~/.dsh/settings.yaml,定位 skills-management.market 段下的 token 字段。token 是敏感凭据,读取后不要把明文打印到输出、日志或最终结果里。',
316
+ '2. 展开技能目录为绝对路径,遍历该目录,收集每个文件的「相对该目录的路径」与内容;跳过 .downloaded_at、.clawhub、.git 三类同步元数据。',
317
+ '3. 把第 1 步读到的 token 作为 HTTP 认证令牌,附加到下面每个 GitCode API 请求的认证头里(bearer 认证方式),不要写成占位符:',
318
+ ' a. 验证用户:`GET https://api.gitcode.com/api/v5/user`,拿到返回的 login 字段——这是 token 真实所属的账号,后续所有 URL 里的 {owner} 一律用它。',
319
+ ' b. fork:`POST https://api.gitcode.com/api/v5/repos/weibaohui/ntd-resource/forks`;若返回 409/422 表示已 fork,视为成功。',
320
+ ' c. 建分支:`POST https://api.gitcode.com/api/v5/repos/{步骤 a 的 login}/ntd-resource/branches`,JSON body 为 {"branch_name":"contrib/{{skillName}}-<unix 时间戳>","refs":"main"}。',
321
+ ' d. 写文件:对第 2 步收集的每个文件,`POST https://api.gitcode.com/api/v5/repos/{步骤 a 的 login}/ntd-resource/contents/{{remotePath}}<该文件的相对路径>`。**必须**用这个远端路径前缀,不能写到仓库根目录。表单字段 content=<文件字节的 base64>、message="贡献技能 {{skillName}} {{version}}"、branch=<步骤 c 的分支名>。',
322
+ ' e. 创建 PR:`POST https://api.gitcode.com/api/v5/repos/weibaohui/ntd-resource/pulls`,JSON body 为 {"title":"[技能] {{skillName}} {{version}}","body":"技能目录 {{resourceDir}} 的文件清单与用途简介","head":"{步骤 a 的 login}:{branch}","base":"main"}。',
323
+ '4. 完成后,最终输出 PR 的网页链接(响应里的 web_url 字段)。',
324
+ '',
325
+ '## 注意',
326
+ '- token 是敏感凭据,任何输出里都不要回显其明文。',
327
+ '- 如果任一步骤失败,先检查错误信息,不要盲目重试;若 token 失效,提示用户到技能市场的 ⚙ 设置面板重新填写。',
328
+ '- 全程与最终汇报都使用中文。',
329
+ ].join('\n')
330
+
331
+ const API = '/skills-management/api'
332
+
333
+ function formatSize(bytes) {
334
+ if (!Number.isFinite(bytes) || bytes < 0) return '-'
335
+ if (bytes < 1024) return bytes + ' B'
336
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
337
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
338
+ }
339
+
340
+ function formatTime(iso) {
341
+ if (!iso) return '-'
342
+ try {
343
+ const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000)
344
+ if (days > 30) return new Date(iso).toLocaleDateString()
345
+ if (days > 0) return days + 'd'
346
+ const hours = Math.floor((Date.now() - new Date(iso).getTime()) / 3600000)
347
+ if (hours > 0) return hours + 'h'
348
+ const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
349
+ if (minutes > 0) return minutes + 'm'
350
+ return 'now'
351
+ } catch { return '-' }
352
+ }
353
+
354
+ function gradient(name) {
355
+ let hash = 0
356
+ for (let i = 0; i < name.length; i++) hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0
357
+ const h1 = Math.abs(hash) % 360
358
+ return `linear-gradient(135deg, hsl(${h1},55%,55%), hsl(${(h1 + 40) % 360},45%,45%))`
359
+ }
360
+
361
+ const shortName = (name) => name.includes('/') ? name.split('/').slice(1).join('/') : name
362
+
363
+ /** Shared predicate for skill filtering (name / description / keywords). */
364
+ function matchSkill(s, lower) {
365
+ return (s.name || '').toLowerCase().includes(lower) ||
366
+ (s.description || '').toLowerCase().includes(lower) ||
367
+ (s.keywords || []).some(k => String(k).toLowerCase().includes(lower))
368
+ }
369
+
370
+ // ── Token-based stylesheet (light/dark adaptive by construction) ────────
371
+
372
+ const STYLE = `<style>
373
+ .sk-page{position:relative;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:var(--dsw-font-sm-14,14px)}
374
+ .sk-overlay{position:fixed;inset:0;z-index:2147483000;background:var(--dsw-alias-bg-base);overflow:auto;padding:20px 26px}
375
+ .sk-tabs{display:flex;gap:6px;border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:10px}
376
+ .sk-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
377
+ .sk-body{display:flex;flex-direction:column;gap:14px}
378
+ .sk-inv-toggle{display:inline-flex;gap:8px;align-items:center}
379
+ .spacer{flex:1}
380
+ .sk-hint{color:var(--dsw-alias-label-secondary)}
381
+ .sk-dir{color:var(--dsw-alias-label-tertiary);font-size:var(--dsw-font-xs-13,12px)}
382
+ .sk-tag{display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:11.5px}
383
+ .sk-tag.accent{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}
384
+ .sk-tag.danger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}
385
+ .sk-tag.ok{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}
386
+ .sk-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px}
387
+ .sk-src{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px}
388
+ .sk-card{display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:12px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);cursor:pointer;text-align:left}
389
+ .sk-card:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}
390
+ .sk-card.missing{opacity:.5;cursor:default}
391
+ .sk-avatar.sq{border-radius:12px}
392
+ .sk-avatar{width:42px;height:42px;border-radius:11px;display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--dsw-alias-label-primary-inverted,#fff);font-size:17px;flex:none}
393
+ .sk-title{font-weight:600;word-break:break-all}
394
+ .sk-desc{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
395
+ .sk-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:auto;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1);flex-wrap:wrap}
396
+ .sk-chips{display:flex;gap:6px;flex-wrap:wrap;min-width:0}
397
+ .sk-rowbtns{display:flex;gap:6px;flex-wrap:wrap}
398
+ .sk-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
399
+ .sk-countline{display:flex;align-items:baseline;gap:2px;color:var(--dsw-alias-label-secondary);font-size:13px}
400
+ .sk-count{font-size:26px;font-weight:700;color:var(--dsw-alias-brand-primary,var(--dsw-alias-state-business-primary))}
401
+ .sk-count.none{color:var(--dsw-alias-label-dimmed,var(--dsw-alias-label-tertiary))}
402
+ .sk-empty{border:1px dashed var(--dsw-alias-border-l2);border-radius:12px;padding:44px 20px;text-align:center;color:var(--dsw-alias-label-secondary)}
403
+ .sk-loading{padding:48px;text-align:center;color:var(--dsw-alias-label-secondary)}
404
+ .sk-spin{width:30px;height:30px;margin:0 auto 10px;border-radius:50%;border:3px solid var(--dsw-alias-border-l2);border-top-color:var(--dsw-alias-brand-primary,var(--dsw-alias-state-business-primary));animation:skspin .7s linear infinite}
405
+ @keyframes skspin{to{transform:rotate(360deg)}}
406
+ .sk-list{display:flex;flex-direction:column;gap:8px}
407
+ .sk-list .sk-card{flex-direction:row;align-items:center;padding:12px 14px}
408
+ .sk-filewrap{display:flex;gap:14px;min-height:300px}
409
+ .sk-files{flex:0 0 230px;max-height:430px;overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}
410
+ .sk-file{display:flex;align-items:center;gap:8px;width:100%;padding:7px 11px;font-size:12.5px;border:none;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left}
411
+ .sk-file:hover{background:var(--dsw-alias-interactive-bg-hover)}
412
+ .sk-file.on{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-state-business-primary)}
413
+ .sk-preview{flex:1;max-height:430px;overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:14px}
414
+ .sk-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
415
+ .sk-meta div{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;min-width:0}
416
+ .sk-dlg-backdrop{position:fixed;inset:0;z-index:30;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:24px}
417
+ .sk-dlg{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:14px;min-width:340px;max-width:640px;max-height:82vh;overflow:auto;padding:18px;box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family)}
418
+ .sk-dlg h3{margin:0 0 12px;font-size:15px}
419
+ .sk-dlg-foot{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
420
+ .sk-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;padding:6px 16px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;cursor:pointer;font-family:var(--dsw-font-family);white-space:nowrap}
421
+ .sk-btn:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}
422
+ .sk-btn:disabled{opacity:.5;cursor:not-allowed}
423
+ .sk-btn-primary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-label-primary-inverted,#fff)}
424
+ .sk-btn-primary:hover{filter:brightness(1.08);background:var(--dsw-alias-state-business-primary)}
425
+ .sk-btn-sm{min-height:28px;padding:4px 12px;font-size:12.5px;min-width:64px}
426
+ .sk-input{min-height:32px;padding:6px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:13px;font-family:var(--dsw-font-family);outline:none}
427
+ .sk-input:focus{border-color:var(--dsw-alias-state-business-primary)}
428
+ .sk-input::placeholder{color:var(--dsw-alias-label-tertiary)}
429
+ .sk-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:40;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:8px 18px;font-size:13px;box-shadow:var(--dsw-shadow-lv2)}
430
+ .sk-tabpill{background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family)}
431
+ </style>`
432
+
433
+ // ── Fetch layer ─────────────────────────────────────────────────────────
434
+
435
+ async function getJson(url) {
436
+ const r = await fetch(url)
437
+ if (!r.ok) throw new Error('HTTP ' + r.status)
438
+ return r.json()
439
+ }
440
+
441
+ // ── Small building blocks ────────────────────────────────────────────────
442
+
443
+ const Tag = ({ tone, children }) =>
444
+ h('span', { className: 'sk-tag' + (tone ? ' ' + tone : '') }, children)
445
+
446
+ const Spinner = ({ label }) =>
447
+ h('div', { className: 'sk-loading' },
448
+ h('div', { className: 'sk-spin' }),
449
+ h('div', null, label))
450
+
451
+ const Empty = ({ children }) => h('div', { className: 'sk-empty' }, children)
452
+
453
+ function Avatar({ name, square, size }) {
454
+ const sizing = size ? { width: size, height: size, fontSize: Math.round(size * 0.42) } : {}
455
+ return h('div', { className: 'sk-avatar' + (square ? ' sq' : ''),
456
+ style: { background: gradient(name), ...sizing } },
457
+ (name[0] || '?').toUpperCase())
458
+ }
459
+
460
+ /** Executor dropdown built on Menu (primitives have no Select). */
461
+ function SourceFilter({ rows, value, onChange, t }) {
462
+ const [open, setOpen] = useState(false)
463
+ const anchorRef = useRef(null)
464
+ const current = value === 'all' ? null : rows.find(r => r.key === value)
465
+ const label = value === 'all'
466
+ ? `${t('pickSource')} (${rows.reduce((a, r) => a + r.skillCount, 0)})`
467
+ : `${current ? current.label : value} (${current ? current.skillCount : 0})`
468
+ return h('span', { ref: anchorRef, style: { position: 'relative' } },
469
+ prim('Button') && P.Button
470
+ ? h(P.Button, { variant: 'outline', size: 'sm', ref: undefined,
471
+ onClick: () => setOpen(o => !o), title: label },
472
+ h('span', { ref: anchorRef, style: { display: 'inline-flex', alignItems: 'center', gap: 6 } },
473
+ label,
474
+ P.IconChevronDownOutline14 ? h(P.IconChevronDownOutline14, { size: 12 }) : '▾'))
475
+ : h('button', { className: 'sk-btn-fallback', onClick: () => setOpen(o => !o), style: { position: 'relative' } },
476
+ h('span', { ref: anchorRef }, label, ' ▾')),
477
+ open && prim('Menu')
478
+ ? h(P.Menu, {
479
+ open,
480
+ anchor: anchorRef.current,
481
+ align: 'start',
482
+ items: [
483
+ { id: 'all', label: `${t('pickSource')} (${rows.reduce((a, r) => a + r.skillCount, 0)})` },
484
+ ...rows.map(x => ({ id: x.key, label: `${x.label} (${x.skillCount})` })),
485
+ ],
486
+ selectedId: value,
487
+ onSelect: (id) => { onChange(id); setOpen(false) },
488
+ onClose: () => setOpen(false),
489
+ })
490
+ : null)
491
+ }
492
+
493
+ // ── Skill / executor cards ───────────────────────────────────────────────
494
+
495
+ function SkillCard({ row, s, t, onOpen, onInstall, onDelete, onShare, onToggleVisible }) {
496
+ const name = shortName(s.name)
497
+ return h('div', { className: 'sk-card', role: 'button', tabIndex: 0,
498
+ onClick: () => onOpen(s),
499
+ onKeyDown: e => e.key === 'Enter' && onOpen(s) },
500
+ h('div', { style: { display: 'flex', gap: 12, alignItems: 'center' } },
501
+ h(Avatar, { name }),
502
+ h('div', { style: { minWidth: 0 } },
503
+ h('div', { className: 'sk-title' }, name),
504
+ (s.fileCount || s.totalSize) && h('div', { className: 'sk-dir' }, `${s.fileCount || 0} · ${formatSize(s.totalSize || 0)} · ${formatTime(s.modifiedAt)}`))),
505
+ h('div', { className: 'sk-desc' }, s.description || ''),
506
+ h('div', { className: 'sk-foot' },
507
+ h('div', { className: 'sk-chips' },
508
+ h(Tag, null, row.label),
509
+ row.readOnly && h(Tag, { tone: 'danger' }, t('readOnlyTag')),
510
+ s.version && h(Tag, { tone: 'accent' }, 'v' + s.version),
511
+ (row.key === 'dsh' || row.key === 'agents') && s.modelInvocable === false && h(Tag, { tone: 'danger' }, t('hiddenTag'))),
512
+ h('div', { className: 'sk-rowbtns' },
513
+ row.key !== 'dsh' && h(ButtonLite, { primary: true, small: true,
514
+ onClick: e => { e.stopPropagation(); onInstall(row, s.installName || s.name) } }, t('toDsh')),
515
+ (row.key === 'dsh' || row.key === 'agents') && h(ButtonLite, { small: true,
516
+ title: s.modelInvocable === false ? t('restoreAction') : t('hideAction'),
517
+ onClick: e => { e.stopPropagation(); onToggleVisible(row, s.name, s.modelInvocable === false) } },
518
+ s.modelInvocable === false ? t('restoreAction') : t('hideAction')),
519
+ (row.deletable !== false && !row.readOnly) && h(ButtonLite, { danger: true, small: true,
520
+ onClick: e => { e.stopPropagation(); onDelete(row, s.name) } }, t('deleteBtn')),
521
+ onShare && h(ButtonLite, { small: true, title: t('shareBtn'),
522
+ onClick: e => { e.stopPropagation(); onShare(row, s) } }, t('shareBtn')))))
523
+ }
524
+
525
+ /** Tiny variant buttons before P.Button availability resolution settles —
526
+ * unified through primitives in the browser via data-p-* swap below. */
527
+ function ButtonLite({ primary, danger, small, children, ...rest }) {
528
+ const cls = 'sk-btn' + (primary ? ' sk-btn-primary' : '') + (small ? ' sk-btn-sm' : '')
529
+ if (prim('Button')) {
530
+ return h(P.Button, {
531
+ variant: primary ? 'primary' : 'outline',
532
+ size: small ? 'sm' : 'md',
533
+ className: cls,
534
+ ...rest,
535
+ }, children)
536
+ }
537
+ return h('button', {
538
+ className: cls,
539
+ ...rest,
540
+ }, children)
541
+ }
542
+
543
+ function ExecutorCard({ row, t, onEnter }) {
544
+ const count = Array.isArray(row.skills) ? row.skills.length : (row.skillCount || 0)
545
+ const sizeTotal = Array.isArray(row.skills) ? row.skills.reduce((a, s) => a + (s.totalSize || 0), 0) : null
546
+ return h('div', { className: 'sk-card', role: 'button', tabIndex: 0, style: !row.dirExists ? { opacity: 0.5, cursor: 'default' } : undefined,
547
+ onClick: () => row.dirExists && onEnter(row.key),
548
+ onKeyDown: e => e.key === 'Enter' && row.dirExists && onEnter(row.key) },
549
+ h('div', { className: 'sk-head' },
550
+ h(Avatar, { name: row.label, square: true, size: 40 }),
551
+ h('span', { className: 'sk-title' }, row.label),
552
+ row.readOnly && h(Tag, { tone: 'danger' }, t('readOnlyTag')),
553
+ row.key === 'dsh' && h(Tag, { tone: 'accent' }, t('meTag'))),
554
+ h('div', { className: 'sk-countline' },
555
+ row.dirExists
556
+ ? [h('span', { key: 'n', className: 'sk-count' + (count ? '' : ' none') }, String(count)),
557
+ ' ' + t('skillsSuffix') + (sizeTotal != null ? ' · ' + t('totalSize', { size: formatSize(sizeTotal) }) : '')]
558
+ : t('dirNotPresent')),
559
+ h('div', { className: 'sk-dir' }, row.dir))
560
+ }
561
+
562
+ // ── Detail modal ─────────────────────────────────────────────────────────
563
+
564
+ function DetailModal({ sel, executors, t, onClose, onInstalled, onDeleted }) {
565
+ const [data, setData] = useState(null)
566
+ const [file, setFile] = useState(null)
567
+ const [fileText, setFileText] = useState('')
568
+ const [confirming, setConfirming] = useState(false)
569
+ const [toast, setToast] = useState(false)
570
+ const meta = data?.meta || {}
571
+ const row = sel.executorKey ? executors.find(x => x.key === sel.executorKey) : null
572
+
573
+ useEffect(() => {
574
+ let alive = true
575
+ setData(null)
576
+ const q = `?name=${encodeURIComponent(sel.name)}${sel.executorKey ? '&executor=' + encodeURIComponent(sel.executorKey) : ''}`
577
+ getJson(API + '/detail' + q).then(d => { if (alive) setData(d) }).catch(() => {})
578
+ return () => { alive = false }
579
+ }, [sel])
580
+
581
+ const openFile = (f) => {
582
+ setFile(f)
583
+ const q = `?name=${encodeURIComponent(sel.name)}&path=${encodeURIComponent(f.path)}${sel.executorKey ? '&executor=' + encodeURIComponent(sel.executorKey) : ''}`
584
+ fetch(API + '/file' + q).then(r => { if (!r.ok) throw 0; return r.text() }).then(setFileText).catch(() => setFileText(''))
585
+ }
586
+
587
+ const doDelete = async () => {
588
+ try {
589
+ const body = { name: sel.name }
590
+ if (sel.executorKey) body.executor = sel.executorKey
591
+ const r = await fetch(API, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
592
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'HTTP ' + r.status)
593
+ setConfirming(false)
594
+ onDeleted()
595
+ } catch (e) { setConfirming(false); alert(t('operationFailed') + ': ' + e.message) }
596
+ }
597
+
598
+ const doInstall = async () => {
599
+ try {
600
+ const body = { name: sel.name }
601
+ if (sel.executorKey && sel.executorKey !== 'dsh') body.from = sel.executorKey
602
+ const r = await fetch(API + '/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
603
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'HTTP ' + r.status)
604
+ onInstalled()
605
+ } catch (e) { alert(t('operationFailed') + ': ' + e.message) }
606
+ }
607
+
608
+ const copyContent = () => {
609
+ navigator.clipboard.writeText(data?.content || '').then(() => {
610
+ setToast(true)
611
+ setTimeout(() => setToast(false), 2200)
612
+ }).catch(() => {})
613
+ }
614
+
615
+ // 治理键开关(dsh 原生 disable-model-invocation):对用户库(dsh 行)和
616
+ // user-agents 根(agents 行——dsh 内置扫描 ~/.agents/skills)的技能开放
617
+ const [invBusy, setInvBusy] = useState(false)
618
+ const modelVisible = meta['disable-model-invocation'] !== true
619
+ const toggleInvocation = async () => {
620
+ setInvBusy(true)
621
+ try {
622
+ const r = await fetch(API + '/invocation', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: sel.name, modelInvocable: !modelVisible }) })
623
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'HTTP ' + r.status)
624
+ const q = `?name=${encodeURIComponent(sel.name)}${sel.executorKey ? '&executor=' + encodeURIComponent(sel.executorKey) : ''}`
625
+ getJson(API + '/detail' + q).then(d => { setData(d) }).catch(() => {})
626
+ } catch (e) { alert(t('operationFailed') + ': ' + e.message) } finally { setInvBusy(false) }
627
+ }
628
+
629
+ const files = data?.files || []
630
+ const isMd = file ? file.path.endsWith('.md') : true
631
+
632
+ return h('div', null,
633
+ h(SkDialog, { title: shortName(sel.name), onClose, wide: true },
634
+ h('div', { className: 'sk-page' },
635
+ h('div', { className: 'sk-hint' }, meta.description || meta.whenToUse || ''),
636
+ h('div', { className: 'sk-toolbar' },
637
+ row && row.key !== 'dsh' && h(P.Button, { variant: 'primary', size: 'sm', onClick: doInstall }, `${t('installFrom', { label: row.label })}`),
638
+ row && row.key === 'dsh' && h(Tag, { tone: 'ok' }, t('activeInDsh')),
639
+ row && (row.key === 'dsh' || row.key === 'agents') && h('span', { className: 'sk-inv-toggle', title: t('invocationHint') },
640
+ h('span', { className: 'sk-dir' }, t('invocationToggle')),
641
+ modelVisible ? h(Tag, { tone: 'ok' }, t('modelVisibleTag')) : h(Tag, { tone: 'danger' }, t('hiddenTag')),
642
+ h(P.Button, { size: 'sm', variant: 'outline', disabled: invBusy, onClick: toggleInvocation },
643
+ modelVisible ? t('detailHideAction') : t('restoreAction'))),
644
+ row && row.readOnly && h(Tag, { tone: 'danger' }, t('readOnlyTag')),
645
+ h(P.Button, { variant: 'outline', size: 'sm', onClick: copyContent }, t('copy')),
646
+ h('span', { className: 'spacer' }),
647
+ row && !row.readOnly && h(P.Button, { variant: 'outline', size: 'sm', onClick: () => setConfirming(true) }, t('deleteBtn'))),
648
+ h('div', { className: 'sk-meta' },
649
+ h('div', null, h('div', { className: 'sk-dir' }, t('pathLabel')), h('div', { className: 'sk-hint' }, data?.dir || '-')),
650
+ h('div', null, h('div', { className: 'sk-dir' }, t('filesCount', { n: data?.fileCount ?? 0 })), h('div', { className: 'sk-hint' }, formatSize(data?.totalSize || 0))),
651
+ meta.version && h('div', null, h('div', { className: 'sk-dir' }, 'Version'), h('div', { className: 'sk-hint' }, 'v' + meta.version)),
652
+ meta.author && h('div', null, h('div', { className: 'sk-dir' }, 'Author'), h('div', { className: 'sk-hint' }, meta.author))),
653
+ files.length
654
+ ? h('div', { className: 'sk-filewrap' },
655
+ h('div', { className: 'sk-files' }, files.map(f =>
656
+ h('button', { key: f.path, className: 'sk-file' + ((file ? f.path === file.path : f.path === 'SKILL.md') ? ' on' : ''), onClick: () => openFile(f) },
657
+ h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, f.path),
658
+ h('span', { className: 'sk-dir' }, formatSize(f.size))))),
659
+ h('div', { className: 'sk-preview sk-md' },
660
+ isMd && prim('MarkdownText')
661
+ ? h(P.MarkdownText, { text: file ? fileText : (data?.content || '') })
662
+ : h('pre', { style: { whiteSpace: 'pre-wrap', margin: 0, fontFamily: 'var(--dsw-font-family)' } }, file ? fileText : (data?.content || ''))))
663
+ : h('div', { className: 'sk-preview sk-md' },
664
+ prim('MarkdownText')
665
+ ? h(P.MarkdownText, { text: data?.content || '' })
666
+ : h('pre', { style: { whiteSpace: 'pre-wrap', margin: 0 } }, data?.content || '')))),
667
+ confirming && h(SkDialog, {
668
+ title: t('deleteTitle'),
669
+ onClose: () => setConfirming(false),
670
+ footer: [
671
+ h(ButtonLite, { onClick: () => setConfirming(false) }, t('cancel')),
672
+ h(ButtonLite, { danger: true, primary: true, onClick: doDelete }, t('deleteBtn')),
673
+ ],
674
+ }, h('div', { className: 'sk-hint' }, t('deleteConfirm', { name: sel.name, where: row ? row.label : t('whereDsh') }))),
675
+ toast && h(InToast, { text: t('copied') }))
676
+ }
677
+
678
+ /** In-page dialog: rendered INSIDE the fullscreen overlay's stacking context
679
+ * so it can never fall behind it (host Modal portals to <body> with a lower
680
+ * z-index than the fullscreen page and would be invisible). */
681
+ function SkDialog({ title, onClose, footer, children, wide }) {
682
+ return h('div', { className: 'sk-dlg-backdrop', onClick: onClose },
683
+ h('div', { className: 'sk-dlg', style: wide ? { maxWidth: 920, width: '92vw' } : undefined,
684
+ onClick: e => e.stopPropagation() },
685
+ title && h('h3', null, title),
686
+ children,
687
+ footer && h('div', { className: 'sk-dlg-foot' }, footer)))
688
+ }
689
+
690
+ function InToast({ text }) {
691
+ return h('div', { className: 'sk-toast' }, text)
692
+ }
693
+
694
+ /** ntd ActionButton 同款分享抽屉:可编辑提示词 + 参数预览 + 复制到会话执行。 */
695
+ function ShareSkillDialog({ t, params, onClose, onToast }) {
696
+ const [prompt, setPrompt] = useState(substituteParams(SHARE_PROMPT_ZH, params))
697
+ const [hasToken, setHasToken] = useState(null)
698
+ const [job, setJob] = useState(null) // {jobId,status,output,code}
699
+ const [busy, setBusy] = useState(false)
700
+ useEffect(() => {
701
+ getJson(API + '/market/status').then(d => setHasToken(d.hasToken === true)).catch(() => setHasToken(false))
702
+ }, [])
703
+ // 轮询执行输出,直到关闭/结束
704
+ useEffect(() => {
705
+ if (job === null || job.status !== 'running') return
706
+ const timer = setInterval(() => {
707
+ getJson(API + '/share/run?id=' + encodeURIComponent(job.jobId))
708
+ .then(d => setJob(prev => prev && { ...prev, status: d.status, output: d.output || '', code: d.code, sessionId: d.sessionId || prev.sessionId }))
709
+ .catch(() => {})
710
+ }, 2000)
711
+ if (typeof timer.unref === 'function') timer.unref()
712
+ return () => clearInterval(timer)
713
+ }, [job && job.status])
714
+ const doRun = async () => {
715
+ setBusy(true)
716
+ try {
717
+ const r = await fetch(API + '/share/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, dir: params.resourceDir }) })
718
+ const d = await r.json().catch(() => ({}))
719
+ if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
720
+ setJob({ jobId: d.jobId, status: 'running', output: '', code: null })
721
+ } catch (e) { onToast(t('runFailed') + ': ' + e.message) } finally { setBusy(false) }
722
+ }
723
+ const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
724
+ h('span', { className: 'sk-dir' }, label), h('span', { className: 'sk-hint', style: { wordBreak: 'break-all', textAlign: 'right' } }, value))
725
+ const copy = () => {
726
+ navigator.clipboard.writeText(prompt).then(() => onToast(t('promptCopied'))).catch(() => {})
727
+ }
728
+ return h(SkDialog, { title: t('shareTitle'), onClose, wide: true },
729
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 380 } },
730
+ h('div', { className: 'sk-hint' }, hasToken === false ? t('shareHintPatMissing') : t('shareHint')),
731
+ h('div', null,
732
+ row(t('shareParamName'), params.skillName),
733
+ row(t('shareParamVersion'), params.version || '-'),
734
+ row(t('shareParamDir'), params.resourceDir),
735
+ row(t('shareParamRemote'), params.remotePath)),
736
+ h('textarea', { className: 'sk-input', value: prompt, onChange: e => setPrompt(e.target.value),
737
+ style: { width: '100%', minHeight: 190, resize: 'vertical', fontFamily: 'var(--dsw-font-family)', lineHeight: 1.6 } }),
738
+ job !== null && h('div', null,
739
+ h('div', { className: 'sk-dir', style: { margin: '4px 0' } },
740
+ t('outputLabel') + ' · ' + (job.status === 'running' ? t('running') : job.status === 'done' ? t('runDone') : t('runFailed') + (job.code != null ? ' (' + job.code + ')' : ''))),
741
+ h('pre', { className: 'sk-preview', style: { maxHeight: 220, margin: 0, whiteSpace: 'pre-wrap', fontSize: 12 } },
742
+ job.output || '…')),
743
+ h('div', { className: 'sk-dlg-foot', style: { marginTop: 0 } },
744
+ h(ButtonLite, { onClick: copy }, t('copyPrompt')),
745
+ job !== null && job.sessionId && sessionsSvc() && h(ButtonLite, { onClick: () => { if (openRunSession(job.sessionId)) onClose() } }, t('openChat')),
746
+ h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
747
+ job !== null && job.status === 'running' ? t('running') : t('runBtn')))))
748
+ }
749
+
750
+ /** Market sync settings: status card, sync action, editable url/branch. */
751
+ function MarketSettingsDialog({ t, onClose, onToast, onSynced }) {
752
+ const [status, setStatus] = useState(null)
753
+ const [busy, setBusy] = useState(false)
754
+ const [url, setUrl] = useState('')
755
+ const [branch, setBranch] = useState('')
756
+ const [token, setToken] = useState('')
757
+ const [repoDir, setRepoDir] = useState('')
758
+ const [autoSync, setAutoSync] = useState(true)
759
+ const [syncOnStartup, setSyncOnStartup] = useState(true)
760
+
761
+ const refresh = () => getJson(API + '/market/status').then(d => {
762
+ setStatus(d)
763
+ setUrl(d.url)
764
+ setBranch(d.branch)
765
+ setRepoDir(d.dir)
766
+ setAutoSync(d.autoSync)
767
+ setSyncOnStartup(d.syncOnStartup)
768
+ }).catch(() => {})
769
+ useEffect(() => { refresh() }, [])
770
+
771
+ const doSync = async () => {
772
+ setBusy(true)
773
+ try {
774
+ const r = await fetch(API + '/market/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
775
+ const d = await r.json().catch(() => ({}))
776
+ if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
777
+ onToast(d.isFirstClone ? t('firstCloneDone') : d.hasUpdates ? t('syncDoneUpdated') : t('syncDoneLatest'))
778
+ onSynced && onSynced()
779
+ refresh()
780
+ } catch (e) { onToast(t('operationFailed') + ': ' + e.message) } finally { setBusy(false) }
781
+ }
782
+ const putSettings = async (patch) => {
783
+ const r = await fetch(API + '/market/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
784
+ if (!r.ok) throw new Error('HTTP ' + r.status)
785
+ return r.json().catch(() => ({}))
786
+ }
787
+ const doSave = async () => {
788
+ try {
789
+ const patch = { url, branch, autoSync, syncOnStartup }
790
+ if (repoDir !== '' && repoDir !== (status && status.dir)) patch.repoDir = repoDir
791
+ if (token !== '') patch.token = token
792
+ await putSettings(patch)
793
+ setToken('')
794
+ onToast(t('saved'))
795
+ refresh()
796
+ } catch (e) { onToast(t('operationFailed') + ': ' + e.message) }
797
+ }
798
+ const doClearToken = async () => {
799
+ try {
800
+ await putSettings({ token: null })
801
+ onToast(t('saved'))
802
+ refresh()
803
+ } catch (e) { onToast(t('operationFailed') + ': ' + e.message) }
804
+ }
805
+
806
+ const short = (c) => (c ? String(c).slice(0, 8) : '-')
807
+ const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
808
+ h('span', { className: 'sk-dir' }, label), h('span', { className: 'sk-hint', style: { wordBreak: 'break-all', textAlign: 'right' } }, value))
809
+
810
+ return h(SkDialog, { title: t('marketSettings'), onClose },
811
+ h('div', { style: { minWidth: 380, display: 'flex', flexDirection: 'column', gap: 10 } },
812
+ status === null
813
+ ? h(Spinner, { label: '…' })
814
+ : [
815
+ status.gitAvailable === false && h('div', { className: 'sk-tag danger' }, t('gitMissing')),
816
+ h('div', null,
817
+ row(t('repoUrlLabel'), status.url),
818
+ row(t('branchLabel'), status.branch),
819
+ row(t('localCommitLabel'), short(status.localCommit) + (status.needsUpdate ? ' · ' : '')),
820
+ status.needsUpdate !== undefined && h('div', { style: { display: 'flex', justifyContent: 'flex-end', marginTop: -6 } },
821
+ status.needsUpdate ? h('span', { className: 'sk-tag accent' }, t('needsUpdateTag')) : null),
822
+ row(t('remoteCommitLabel'), short(status.remoteCommit)),
823
+ row(t('lastSyncLabel'), status.lastSyncAt ? formatTime(status.lastSyncAt) : t('repoMissing')),
824
+ row(t('repoDirLabel'), status.dir)),
825
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
826
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } },
827
+ h('input', { type: 'checkbox', checked: autoSync, onChange: e => setAutoSync(e.target.checked) }), t('autoSyncLabel')),
828
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } },
829
+ h('input', { type: 'checkbox', checked: syncOnStartup, onChange: e => setSyncOnStartup(e.target.checked) }), t('syncOnStartupLabel'))),
830
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
831
+ h('input', { className: 'sk-input', value: url, onChange: e => setUrl(e.target.value), placeholder: t('repoUrlLabel'), style: { width: '100%' } }),
832
+ h('input', { className: 'sk-input', value: branch, onChange: e => setBranch(e.target.value), placeholder: t('branchLabel'), style: { width: '100%' } }),
833
+ h('input', { className: 'sk-input', value: repoDir, onChange: e => setRepoDir(e.target.value), placeholder: t('repoDirLabel'), style: { width: '100%' } }),
834
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
835
+ h('input', { className: 'sk-input', type: 'password', value: token, onChange: e => setToken(e.target.value),
836
+ placeholder: status && status.hasToken ? `${t('tokenLabel')} · ${t('tokenConfigured')}` : t('tokenLabel'), style: { flex: 1 } }),
837
+ status && status.hasToken && h(ButtonLite, { onClick: doClearToken }, t('clearToken')))),
838
+ h('div', { className: 'sk-dlg-foot', style: { marginTop: 4 } },
839
+ h(ButtonLite, { onClick: doSave }, t('save')),
840
+ h(ButtonLite, { primary: true, onClick: doSync }, busy ? t('syncing') : t('syncNow')))]))
841
+ }
842
+
843
+ /** Incremental grid: renders the first pageSize cards and grows on demand —
844
+ * the market collection alone holds 6k+ skills and must not mount at once.
845
+ * Key the element by the active filter so filtering resets the window. */
846
+ function PagedGrid({ items, render, t, pageSize = 120, grow = 240, keyPrefix = '' }) {
847
+ const [shown, setShown] = useState(pageSize)
848
+ return [
849
+ h('div', { className: 'sk-grid' }, items.slice(0, shown).map(render)),
850
+ items.length > shown && h('div', { style: { textAlign: 'center', margin: '14px 0' } },
851
+ h(ButtonLite, { onClick: () => setShown(n => n + grow) }, t('showMore', { n: items.length - shown }))),
852
+ ]
853
+ }
854
+
855
+ // ── Views ────────────────────────────────────────────────────────────────
856
+
857
+ function CardsView({ executors, t, onEnter, onBrowseAll }) {
858
+ if (!executors.length) return h(Empty, null, t('noExecutors'))
859
+ const present = executors.filter(r => r.dirExists)
860
+ const missing = executors.filter(r => !r.dirExists)
861
+ return [
862
+ h('div', { className: 'sk-toolbar' },
863
+ h('span', { className: 'sk-hint' }, t('cardsHint')),
864
+ h('span', { className: 'spacer' }),
865
+ h(P.Button, { variant: 'primary', size: 'sm', onClick: onBrowseAll }, `${t('browseAll')} (${executors.reduce((a, r) => a + r.skillCount, 0)})`),
866
+ h('span', { style: { width: 6 } }),
867
+ h('span', { className: 'sk-refresh-slot' })),
868
+ h('div', { className: 'sk-src' },
869
+ present.map(row => h(ExecutorCard, { key: row.key, row, t, onEnter }))),
870
+ missing.length > 0 && [
871
+ h('div', { className: 'sk-hint', style: { marginTop: 6 } }, t('notFoundGroup', { n: missing.length })),
872
+ h('div', { className: 'sk-src' },
873
+ missing.map(row => h(ExecutorCard, { key: row.key, row, t, onEnter }))),
874
+ ],
875
+ ]
876
+ }
877
+
878
+ function AllSkillsView({ executors, searchText, sourceFilter, t, onSearch, onFilter, onBack, onOpen, onInstall, onDelete, onShare, onToggleVisible }) {
879
+ const pending = executors.some(x => x.dirExists && !Array.isArray(x.skills))
880
+ if (pending) return h(Spinner, { label: t('loadingCatalogs') })
881
+ let items = []
882
+ for (const row of executors) {
883
+ if (!row.dirExists || !Array.isArray(row.skills)) continue
884
+ if (sourceFilter !== 'all' && row.key !== sourceFilter) continue
885
+ for (const s of row.skills) items.push({ row, s })
886
+ }
887
+ if (searchText) items = items.filter(it => matchSkill(it.s, searchText.toLowerCase()))
888
+ return [
889
+ h('div', { className: 'sk-head' },
890
+ h(P.Button, { variant: 'ghost', size: 'sm', onClick: onBack }, t('backCards')),
891
+ h('span', { className: 'sk-title' }, t('title')),
892
+ h(InputBox, { value: searchText, placeholder: t('searchAll'), onSearch }),
893
+ SourceFilterEl({ rows: executors.filter(r => r.dirExists), value: sourceFilter, onChange: onFilter, t }),
894
+ h('span', { className: 'spacer' }),
895
+ h(Tag, null, `${items.length} ${t('skillsSuffix')}`)),
896
+ !items.length
897
+ ? h(Empty, null, t('emptySearch'))
898
+ : h('div', { className: 'sk-grid' }, items.map(({ row, s }) =>
899
+ h(SkillCard, { key: row.key + '/' + s.name, row, s, t, onOpen, onInstall, onDelete, onShare, onToggleVisible })))]
900
+ }
901
+
902
+ function DrillInView({ row, searchText, t, onSearch, onBack, onOpen, onInstall, onDelete, onShare, onToggleVisible }) {
903
+ if (!row) return null
904
+ if (!row.dirExists) {
905
+ return [
906
+ h('div', { className: 'sk-head' },
907
+ h(P.Button, { variant: 'ghost', size: 'sm', onClick: onBack }, t('backAll'))),
908
+ h(Empty, null, `${row.label} — ${row.dir}`)]
909
+ }
910
+ if (!Array.isArray(row.skills)) {
911
+ return h(Spinner, { label: t('scanning', { label: row.label }) })
912
+ }
913
+ let skills = row.skills
914
+ if (searchText) skills = skills.filter(s => matchSkill(s, searchText.toLowerCase()))
915
+ return [
916
+ h('div', { className: 'sk-head' },
917
+ h(P.Button, { variant: 'ghost', size: 'sm', onClick: onBack }, t('backAll')),
918
+ h('span', { className: 'sk-title' }, row.label),
919
+ h('span', { className: 'sk-tag' }, `${skills.length} ${t('skillsSuffix')}`),
920
+ row.readOnly && h(Tag, { tone: 'danger' }, t('readOnlyTag')),
921
+ h(InputBox, { value: searchText, placeholder: t('filterWithin', { label: row.label }), onSearch }),
922
+ h('span', { className: 'spacer' }),
923
+ h('span', { className: 'sk-dir' }, row.dir)),
924
+ !skills.length
925
+ ? h(Empty, null, searchText ? t('emptySearch') : t('emptySkillsIn', { label: row.label }))
926
+ : h(PagedGrid, { key: 'ed' + row.key + searchText, items: skills, t,
927
+ render: s => h(SkillCard, { key: s.name, row, s, t, onOpen, onInstall, onDelete, onShare, onToggleVisible }) }),
928
+ ]
929
+ }
930
+
931
+ function InputBox({ value, placeholder, onSearch }) {
932
+ if (prim('Input')) {
933
+ return h(P.Input, { value, placeholder, className: 'sk-input', onChange: e => onSearch(e.target.value),
934
+ style: { minWidth: 220 } })
935
+ }
936
+ return h('input', { value, placeholder, onChange: e => onSearch(e.target.value),
937
+ style: { minWidth: 220, minHeight: 32, borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-primary)', padding: '6px 12px' } })
938
+ }
939
+ function SourceFilterEl(args) { return SourceFilter(args) }
940
+
941
+ // ── App root ─────────────────────────────────────────────────────────────
942
+
943
+ function SkillsPage({ t, onClose, embedded }) {
944
+ const [base, setBase] = useState({ sources: [], market: [], installed: [] })
945
+ const [executors, setExecutors] = useState([])
946
+ const [tab, setTab] = useState('executors')
947
+ const [executorView, setExecutorView] = useState('cards')
948
+ const [filterExecutor, setFilterExecutor] = useState('all')
949
+ const [sourceFilter, setSourceFilter] = useState('all')
950
+ const [marketSettingsOpen, setMarketSettingsOpen] = useState(false)
951
+ const [shareParams, setShareParams] = useState(null)
952
+ const [marketToast, setMarketToast] = useState(null)
953
+ const [marketView, setMarketView] = useState('cards')
954
+ const [marketDrill, setMarketDrill] = useState(null)
955
+ const [searchMarketDrill, setSearchMarketDrill] = useState('')
956
+ const [searchMarketAll, setSearchMarketAll] = useState('')
957
+ const [searchExec, setSearchExec] = useState('')
958
+ const [searchDrill, setSearchDrill] = useState('')
959
+ const [searchAll, setSearchAll] = useState('')
960
+ const [sel, setSel] = useState(null)
961
+ const [tick, forceTick] = useState(0)
962
+ const rootRef = useRef(null)
963
+
964
+ // Two data planes with very different costs: the executor summary is a
965
+ // fast (~0.2s) count scan, while GET / walks the whole market collection
966
+ // (~2.3s). Load the summary eagerly and the market set lazily — only when
967
+ // a tab that needs it is opened, and re-fetch it after mutations.
968
+ const [baseStale, setBaseStale] = useState(true)
969
+ const [baseLoading, setBaseLoading] = useState(false)
970
+ const reloadExecutors = () => {
971
+ getJson(API + '/executors?mode=summary').then(d => setExecutors(d.executors || [])).catch(() => {})
972
+ }
973
+ const reloadBase = () => {
974
+ setBaseStale(false)
975
+ setBaseLoading(true)
976
+ getJson(API).then(setBase).catch(() => {}).finally(() => setBaseLoading(false))
977
+ }
978
+ useEffect(reloadExecutors, [])
979
+ useEffect(() => {
980
+ if (tab === 'market' && baseStale) reloadBase()
981
+ }, [tab, baseStale])
982
+
983
+ // Load full lists for one executor on demand
984
+ useEffect(() => {
985
+ if (filterExecutor === 'all') return
986
+ const row = executors.find(x => x.key === filterExecutor)
987
+ if (!row || Array.isArray(row.skills)) return
988
+ let alive = true
989
+ getJson(API + '/executors?executor=' + encodeURIComponent(filterExecutor))
990
+ .then(d => { if (alive) setExecutors(rows => rows.map(r => r.key === d.executor.key ? d.executor : r)) })
991
+ .catch(() => {})
992
+ return () => { alive = false }
993
+ }, [filterExecutor, executors])
994
+
995
+ // Load all catalogs when entering the flat all-skills view
996
+ useEffect(() => {
997
+ if (!(tab === 'executors' && executorView === 'all')) return
998
+ const targets = executors.filter(x => x.dirExists && !Array.isArray(x.skills))
999
+ targets.forEach(row => {
1000
+ getJson(API + '/executors?executor=' + encodeURIComponent(row.key))
1001
+ .then(d => setExecutors(rows => rows.map(r => r.key === d.executor.key ? d.executor : r)))
1002
+ .catch(() => {})
1003
+ })
1004
+ }, [tab, executorView, executors])
1005
+
1006
+ const row = filterExecutor !== 'all' ? executors.find(x => x.key === filterExecutor) : null
1007
+
1008
+ const openDetail = (s, executorKey) => setSel({ name: s.name, executorKey })
1009
+ const openShare = (row, s) => {
1010
+ const skillName = shortName(s.name)
1011
+ const dir = s.__dir || (row && row.dir ? row.dir + (s.relPath ? '/' + s.relPath : '/' + skillName) : '')
1012
+ if (!dir) return
1013
+ setShareParams({
1014
+ skillName,
1015
+ version: s.version || '',
1016
+ resourceDir: dir,
1017
+ remotePath: row && row.key && row.key !== 'dsh' && row.key !== '@market'
1018
+ ? `skills/${row.key}/${skillName}/`
1019
+ : `skills/${skillName}/`,
1020
+ })
1021
+ }
1022
+ const [pendingDelete, setPendingDelete] = useState(null)
1023
+ const [pendingInstall, setPendingInstall] = useState(null)
1024
+ const [toastText, setToastText] = useState(null)
1025
+ const doPendingInstall = async () => {
1026
+ if (!pendingInstall) return
1027
+ const { row, name } = pendingInstall
1028
+ setPendingInstall(null)
1029
+ try {
1030
+ const body = { name }
1031
+ if (row && row.key && row.key !== '@market') body.from = row.key
1032
+ const r = await fetch(API + '/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
1033
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'HTTP ' + r.status)
1034
+ setToastText(t('installedToast'))
1035
+ setTimeout(() => setToastText(null), 2600)
1036
+ reloadExecutors()
1037
+ setBaseStale(true)
1038
+ } catch (e) { alert(t('operationFailed') + ': ' + e.message) }
1039
+ }
1040
+ const doPendingDelete = async () => {
1041
+ if (!pendingDelete) return
1042
+ await quickDelete(t, pendingDelete.executor, pendingDelete.name)
1043
+ setPendingDelete(null)
1044
+ reloadExecutors()
1045
+ setBaseStale(true)
1046
+ }
1047
+
1048
+ let body = null
1049
+ try {
1050
+ if (tab === 'executors') {
1051
+ if (filterExecutor !== 'all') {
1052
+ body = h(DrillInView, { row, searchText: searchDrill, t,
1053
+ onSearch: setSearchDrill, onShare: openShare,
1054
+ onBack: () => { setFilterExecutor('all'); setSearchDrill('') },
1055
+ onOpen: s => openDetail(s, row?.key),
1056
+ onInstall: (r, name) => setPendingInstall({ row: r, name }),
1057
+ onDelete: (r, name) => setPendingDelete({ executor: r.key, name }),
1058
+ onToggleVisible: (r, name, modelInvocable) => {
1059
+ fetch(API + '/invocation', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, modelInvocable }) })
1060
+ .then(() => getJson(API + '/executors?executor=' + encodeURIComponent(r.key))
1061
+ .then(d => setExecutors(rows => rows.map(x => x.key === d.executor.key ? d.executor : x))))
1062
+ .catch(() => {})
1063
+ } })
1064
+ } else if (executorView === 'all') {
1065
+ body = h(AllSkillsView, { executors, searchText: searchAll, sourceFilter, t,
1066
+ onSearch: setSearchAll, onShare: openShare,
1067
+ onFilter: v => { setSourceFilter(v); if (v !== 'all') { setFilterExecutor(v); setSearchAll(''); setSearchExec('') } },
1068
+ onBack: () => setExecutorView('cards'),
1069
+ onOpen: s => { const owner = executors.find(x => x.dirExists && Array.isArray(x.skills) && x.skills.some(k => k.name === s.name)); openDetail(s, owner ? owner.key : sourceFilter !== 'all' ? sourceFilter : 'dsh') },
1070
+ onInstall: (r, name) => setPendingInstall({ row: r, name }),
1071
+ onDelete: (r, name) => setPendingDelete({ executor: r.key, name }),
1072
+ onToggleVisible: (r, name, modelInvocable) => {
1073
+ fetch(API + '/invocation', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, modelInvocable }) })
1074
+ .then(() => getJson(API + '/executors?executor=' + encodeURIComponent(r.key))
1075
+ .then(d => setExecutors(rows => rows.map(x => x.key === d.executor.key ? d.executor : x))))
1076
+ .catch(() => {})
1077
+ } })
1078
+ } else {
1079
+ body = h(CardsView, { executors, t,
1080
+ onEnter: key => { setFilterExecutor(key); setSearchDrill('') },
1081
+ onBrowseAll: () => setExecutorView('all') })
1082
+ }
1083
+ } else {
1084
+ // Market tab mirrors the executors tab: source cards by default, a flat
1085
+ // all-skills view on demand, and per-source drill-in with a scoped filter.
1086
+ const allMarket = base.market || []
1087
+ const lowerAll = searchMarketAll.toLowerCase()
1088
+ const mkRow = (src) => ({ key: '@market', label: splitSource(src), readOnly: false, deletable: false })
1089
+ if (baseLoading && !allMarket.length) {
1090
+ body = h(Spinner, { label: t('marketLoading') })
1091
+ } else if (marketDrill !== null) {
1092
+ let sk = allMarket.filter(s => s.source === marketDrill)
1093
+ const l = searchMarketDrill.toLowerCase()
1094
+ if (l) sk = sk.filter(s => matchSkill({ ...s, name: s.shortName || s.name }, l))
1095
+ body = [
1096
+ h('div', { className: 'sk-head' },
1097
+ h(ButtonLite, { onClick: () => { setMarketDrill(null); setSearchMarketDrill('') } }, t('backSources')),
1098
+ h('span', { className: 'sk-title' }, marketDrill),
1099
+ h('span', { className: 'sk-tag' }, `${sk.length} ${t('skillsSuffix')}`),
1100
+ h(InputBox, { value: searchMarketDrill, placeholder: t('filterWithin', { label: marketDrill }), onSearch: setSearchMarketDrill })),
1101
+ sk.length
1102
+ ? h(PagedGrid, { key: 'md' + marketDrill + searchMarketDrill, items: sk, t,
1103
+ render: s => h(SkillCard, { key: s.name, row: mkRow(s.source), s: mkCard(s), t,
1104
+ onOpen: item => openDetail({ name: item.installName || item.name }, null),
1105
+ onInstall: (_r, name) => setPendingInstall({ row: null, name }),
1106
+ onDelete: () => {} }) })
1107
+ : h(Empty, null, t('emptySearch')),
1108
+ ]
1109
+ } else if (marketView === 'all') {
1110
+ const sk = lowerAll ? allMarket.filter(s => matchSkill({ ...s, name: s.shortName || s.name }, lowerAll)) : allMarket
1111
+ body = [
1112
+ h('div', { className: 'sk-head' },
1113
+ h(ButtonLite, { onClick: () => { setMarketView('cards'); setSearchMarketAll('') } }, t('backMarketCards')),
1114
+ h('span', { className: 'sk-title' }, t('allMarketTitle')),
1115
+ h(InputBox, { value: searchMarketAll, placeholder: t('searchAll'), onSearch: setSearchMarketAll }),
1116
+ h('span', { className: 'spacer' }),
1117
+ h(Tag, null, `${sk.length} ${t('skillsSuffix')}`)),
1118
+ sk.length
1119
+ ? h(PagedGrid, { key: 'ma' + searchMarketAll, items: sk, t,
1120
+ render: s => h(SkillCard, { key: s.name, row: mkRow(s.source), s: mkCard(s), t,
1121
+ onOpen: item => openDetail({ name: item.installName || item.name }, null),
1122
+ onInstall: (_r, name) => setPendingInstall({ row: null, name }),
1123
+ onDelete: () => {} }) })
1124
+ : h(Empty, null, t('emptySearch')),
1125
+ ]
1126
+ } else {
1127
+ body = [
1128
+ h('div', { className: 'sk-toolbar' },
1129
+ h('span', { className: 'sk-hint' }, t('marketCardsHint')),
1130
+ h('span', { className: 'spacer' }),
1131
+ h(P.Button, { variant: 'primary', size: 'sm', onClick: () => setMarketView('all') }, `${t('browseAllMarket')} (${allMarket.length})`),
1132
+ h('span', { style: { width: 6 } }),
1133
+ h(ButtonLite, { onClick: () => setMarketSettingsOpen(true), title: t('marketSettings') }, t('marketSettings'))),
1134
+ base.sources.length
1135
+ ? h('div', { className: 'sk-src' }, base.sources.map(src =>
1136
+ h('div', { key: src.source, className: 'sk-card', role: 'button', tabIndex: 0,
1137
+ onClick: () => { setMarketDrill(src.source); setSearchMarketDrill('') } },
1138
+ h('div', { className: 'sk-head' },
1139
+ h(Avatar, { name: src.displayName || src.source, square: true, size: 40 }),
1140
+ h('span', { className: 'sk-title' }, src.displayName || src.source)),
1141
+ h('div', { className: 'sk-countline' },
1142
+ h('span', { className: 'sk-count' }, src.skills), ' ' + t('skillsSuffix')))))
1143
+ : h(Empty, null, t('emptySearch')),
1144
+ ]
1145
+ }
1146
+ }
1147
+ } catch (renderErr) {
1148
+ ;(globalThis.__skErrors = globalThis.__skErrors || []).push('body: ' + (renderErr && renderErr.message))
1149
+ body = h('div', { className: 'sk-empty', style: { color: 'var(--dsw-alias-state-error-primary)' } },
1150
+ '\u26A0\uFE0F ' + String((renderErr && renderErr.message) || renderErr))
1151
+ }
1152
+
1153
+ return h('div', { className: 'sk-page' + (embedded ? '' : ' sk-overlay'), ref: rootRef },
1154
+ !embedded && h('div', { className: 'sk-head' },
1155
+ h('span', { className: 'sk-title', style: { fontSize: 16 } }, t('title')),
1156
+ h('span', { className: 'spacer' }),
1157
+ h(ButtonLite, { onClick: () => onClose && onClose() }, t('close'))),
1158
+ h('div', { className: 'sk-tabs' }, ['executors', 'market'].map(key =>
1159
+ h('button', { key, className: 'sk-tabpill' + (tab === key ? ' on' : ''), style: pillStyle(tab === key),
1160
+ onClick: () => { setTab(key); setFilterExecutor('all'); setExecutorView('cards'); setSearchExec(''); setSearchDrill(''); setSearchAll(''); setMarketView('cards'); setMarketDrill(null); setSearchMarketDrill(''); setSearchMarketAll('') } }, t('tab' + key[0].toUpperCase() + key.slice(1))))),
1161
+ h('div', { className: 'sk-body' }, body),
1162
+ sel && h(DetailModal, { sel, executors, t,
1163
+ onClose: () => setSel(null),
1164
+ onInstalled: () => { setSel(null); reloadExecutors(); setBaseStale(true) },
1165
+ onDeleted: () => { setSel(null); reloadExecutors(); setBaseStale(true) } }),
1166
+ shareParams && h(ShareSkillDialog, {
1167
+ t, params: shareParams, onClose: () => setShareParams(null),
1168
+ onToast: (text) => { setMarketToast(text); setTimeout(() => setMarketToast(null), 3000) },
1169
+ }),
1170
+ marketSettingsOpen && h(MarketSettingsDialog, {
1171
+ t,
1172
+ onClose: () => setMarketSettingsOpen(false),
1173
+ onToast: (text) => { setMarketToast(text); setTimeout(() => setMarketToast(null), 3000) },
1174
+ onSynced: () => { setBaseStale(true) },
1175
+ }),
1176
+ marketToast && h(InToast, { text: marketToast }),
1177
+ pendingInstall && h(SkDialog, {
1178
+ title: t('installTitle'),
1179
+ onClose: () => setPendingInstall(null),
1180
+ footer: [
1181
+ h(ButtonLite, { onClick: () => setPendingInstall(null) }, t('cancel')),
1182
+ h(ButtonLite, { primary: true, onClick: doPendingInstall }, t('installOk')),
1183
+ ],
1184
+ }, h('div', { className: 'sk-hint' },
1185
+ t('installConfirm', { name: pendingInstall.name, label: pendingInstall.row ? pendingInstall.row.label : t('marketLabel') }))),
1186
+ toastText && h(InToast, { text: toastText }),
1187
+ pendingDelete && h(SkDialog, {
1188
+ title: t('deleteTitle'),
1189
+ onClose: () => setPendingDelete(null),
1190
+ footer: [
1191
+ h(ButtonLite, { onClick: () => setPendingDelete(null) }, t('cancel')),
1192
+ h(ButtonLite, { primary: true, danger: true, onClick: doPendingDelete }, t('deleteBtn')),
1193
+ ],
1194
+ }, h('div', { className: 'sk-hint' }, t('deleteConfirm', {
1195
+ name: pendingDelete.name,
1196
+ where: pendingDelete.executor === 'dsh' || !pendingDelete.executor ? t('whereDsh') : ((executors.find(x => x.key === pendingDelete.executor) || {}).label || pendingDelete.executor),
1197
+ }))))
1198
+ }
1199
+
1200
+ function splitSource(source) { return source.split('/')[0] || source }
1201
+ /** Market rows carry the full repo path in .name; cards display the short
1202
+ * name but install/detail must POST the full one. */
1203
+ function mkCard(s) { return { ...s, name: s.shortName || s.name, installName: s.name } }
1204
+ function selectStyle() {
1205
+ return { minHeight: 30, borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-specific-input-major, var(--dsw-alias-bg-layer-1))', color: 'var(--dsw-alias-label-primary)', padding: '4px 10px' }
1206
+ }
1207
+ function pillStyle(on) {
1208
+ return {
1209
+ padding: '6px 14px', borderRadius: 999, cursor: 'pointer', fontSize: 13.5, fontWeight: on ? 600 : 400,
1210
+ border: '1px solid ' + (on ? 'var(--dsw-alias-brand-primary,var(--dsw-alias-state-business-primary))' : 'transparent'),
1211
+ background: on ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
1212
+ color: on ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
1213
+ }
1214
+ }
1215
+
1216
+ async function quickDelete(t, executor, name, done) {
1217
+ try {
1218
+ const body = { name }
1219
+ if (executor) body.executor = executor
1220
+ const r = await fetch(API, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
1221
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'HTTP ' + r.status)
1222
+ if (typeof done === 'function') done()
1223
+ } catch (e) { alert(t('operationFailed') + ': ' + e.message) }
1224
+ }
1225
+
1226
+ // ── Slot entries ─────────────────────────────────────────────────────────
1227
+
1228
+ function footerStyle() {
1229
+ return { display: 'inline-flex', alignItems: 'center', gap: 6, margin: '4px 10px', padding: '8px 10px',
1230
+ border: 'none', borderRadius: 8, background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
1231
+ font: 'inherit', fontSize: 13, cursor: 'pointer', width: 'calc(100% - 20px)', textAlign: 'left' }
1232
+ }
1233
+
1234
+ /** Panel state lives OUTSIDE React: sidebar churn remounts slot entries,
1235
+ * and any state kept in them (the old bug) is torn down with them. */
1236
+ const panelStore = {
1237
+ open: false,
1238
+ listeners: new Set(),
1239
+ set(v) { panelStore.open = v; for (const fn of panelStore.listeners) fn(v) },
1240
+ subscribe(fn) { panelStore.listeners.add(fn); return () => panelStore.listeners.delete(fn) },
1241
+ }
1242
+
1243
+ /** Jump to the run's conversation: open() is best-effort (it may reject
1244
+ * after the selection lands), but folding our overlay must always happen. */
1245
+ function openRunSession(sessionId) {
1246
+ try {
1247
+ const svc = sessionsSvc()
1248
+ if (svc && typeof svc.open === 'function') svc.open(sessionId)
1249
+ } catch {}
1250
+ panelStore.set(false)
1251
+ return true
1252
+ }
1253
+
1254
+ /** Footer slot entry: the button, and — when open — the whole market page
1255
+ * through the host primitives Modal (portal + overlay handled by the host's
1256
+ * own React tree; no custom createRoot, which never commits here). */
1257
+ function FooterSlotComponent(props) {
1258
+ const [open, setOpen] = useState(panelStore.open)
1259
+ useEffect(() => panelStore.subscribe(setOpen), [])
1260
+ useEffect(ensureStyles, [])
1261
+
1262
+ const t = props.__t
1263
+ const labelText = t ? t('title') : 'Skills Market'
1264
+ // The sidebar renders this entry with a `wide` owner prop: the collapsed
1265
+ // rail passes false and shows the icon alone; expanded shows the label.
1266
+ const wide = props.wide !== false
1267
+ return h('span', { style: { display: 'contents' } },
1268
+ h('button', { title: labelText, 'aria-label': labelText, onClick: () => panelStore.set(!panelStore.open),
1269
+ style: footerStyle() },
1270
+ P && P.IconSkillOutline16 ? h(P.IconSkillOutline16, { size: 16 }) : '\u{1F3AF}',
1271
+ wide ? ' ' + labelText : ''),
1272
+ open && (() => {
1273
+ const page = h(SkillsPage, { t, embedded: false, onClose: () => panelStore.set(false) })
1274
+ // Fullscreen: portal the fixed-position page to <body> so no sidebar
1275
+ // ancestor (transform-containing or otherwise) can clip it.
1276
+ if (RDP && typeof RDP.createPortal === 'function' && typeof document !== 'undefined') {
1277
+ return RDP.createPortal(page, document.body)
1278
+ }
1279
+ return page // fallback: fixed positioning still applies from here
1280
+ })())
1281
+ }
1282
+
1283
+ /** Settings section slot entry: render the page directly in the host tree. */
1284
+ function SettingsSlotComponent(props) {
1285
+ useEffect(ensureStyles, [])
1286
+ return h(SkillsPage, { t: props.__t, embedded: true })
1287
+ }
1288
+
1289
+ // ── Plugin plane contract ────────────────────────────────────────────────
1290
+
1291
+ const CLIENT_NAME = 'dsh-plugin-skills-management'
1292
+
1293
+ module.exports = {
1294
+ name: CLIENT_NAME,
1295
+ inject: ['slots', 'locale'],
1296
+ __internals: { NS, ZH, EN, matchSkill, formatSize, formatTime },
1297
+ /** Test/host helper: mount a standalone page into any container. */
1298
+ __boot(container, opts = {}) {
1299
+ ensureStyles()
1300
+ let t = opts.t || ((key, vars) => {
1301
+ let out = EN[key] ?? key
1302
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
1303
+ return out
1304
+ })
1305
+ try {
1306
+ if (!opts.t && globalThis.document && !(container.ownerDocument !== document)) { /* noop */ }
1307
+ } catch {}
1308
+ const root = require('react-dom/client').createRoot(container)
1309
+ root.render(h(SkillsPage, { t, embedded: !!opts.embedded }))
1310
+ return root
1311
+ },
1312
+ apply(ctx) {
1313
+ // Sessions face for 打开对话: dynamic inject per the ui-commands
1314
+ // precedent (scope.sessions). Static inject must NOT list services —
1315
+ // that stalls activation; root-level slot entries get no standard kit.
1316
+ try {
1317
+ if (typeof ctx.inject === 'function') {
1318
+ ctx.inject(['sessions'], (scope) => {
1319
+ const svc = scope && scope.sessions
1320
+ if (svc && typeof svc.open === 'function') sessionsApi = svc
1321
+ })
1322
+ }
1323
+ } catch {}
1324
+ // Locale service is optional at boot order — degrade to EN until present
1325
+ let t = (key, vars) => {
1326
+ let out = EN[key] ?? key
1327
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
1328
+ return out
1329
+ }
1330
+ try {
1331
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
1332
+ ctx.locale.register(NS, 'zh', ZH)
1333
+ ctx.locale.register(NS, 'en', EN)
1334
+ const bound = typeof ctx.locale.bind === 'function' ? ctx.locale.bind(NS) : null
1335
+ if (bound) {
1336
+ t = (key, vars) => {
1337
+ let out = bound(key) || key
1338
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
1339
+ return out
1340
+ }
1341
+ globalThis.__skillsLocaleLive = true
1342
+ }
1343
+ }
1344
+ } catch (e) { try { console.error('[skills-management] locale init:', e) } catch {} }
1345
+ ctx.effect(() => {
1346
+ try {
1347
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
1348
+ name: 'sidebar.footer.action',
1349
+ id: CLIENT_NAME,
1350
+ order: 50,
1351
+ locale: NS,
1352
+ label: () => t('title'),
1353
+ inject: () => ({ t }),
1354
+ }, function FooterSlot(apiProps) {
1355
+ // ownerProps (the sidebar's wide flag) land here — forward them
1356
+ return h(FooterSlotComponent, { __t: t, wide: apiProps && apiProps.wide })
1357
+ }))
1358
+ } catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('footer:' + (e && e.message)); throw e }
1359
+ }, 'skills-management: sidebar footer action')
1360
+ ctx.effect(() => {
1361
+ try {
1362
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1363
+ name: 'settings.section',
1364
+ id: CLIENT_NAME,
1365
+ order: 90,
1366
+ locale: NS,
1367
+ // resolveSlotLabel 调用 label() 不传参;官方模式是自带绑定翻译的闭包
1368
+ label: () => t('title'),
1369
+ inject: () => ({}),
1370
+ }, function SettingsSectionSlot() {
1371
+ return h(SettingsSlotComponent, { __t: t })
1372
+ }))
1373
+ } catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('settings:' + (e && e.message)); throw e }
1374
+ }, 'skills-management: settings section')
1375
+ },
1376
+ }
1377
+
1378
+ return module.exports
1379
+ }
1380
+ })