@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.
- package/README.md +79 -0
- package/client/bundle.js +1380 -0
- package/client/components/skills/helpers.ts +108 -0
- package/client/hooks/index.ts +10 -0
- package/client/hooks/useSkillCatalog.ts +201 -0
- package/client/hooks/useSkillDetail.ts +89 -0
- package/client/hooks/useSkillInstall.ts +74 -0
- package/client/index.js +1366 -0
- package/cordis.patch.yml +12 -0
- package/package.json +61 -0
- package/src/index.js +1073 -0
package/client/bundle.js
ADDED
|
@@ -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
|
+
})
|