@weibaohui/skills-management 0.1.6 → 0.3.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 CHANGED
@@ -9,11 +9,11 @@
9
9
 
10
10
  ## 核心功能
11
11
 
12
- - **全来源扫描**:自动扫描本机各 coding agent 的技能目录(Claude、ZCode、Codex 等十余个执行器),统一在一个页面查看、查看详情、单文件预览
12
+ - **全来源扫描**:自动扫描本机各 coding agent 的技能目录(Claude、ZCode、Codex、WorkBuddy 等十余个执行器),统一在一个页面查看、查看详情、单文件预览
13
13
  - **一键收编**:任意执行器的技能可一键复制进 DSH 用户库,供模型的 `skill` 工具直接调用
14
14
  - **技能市场**:内置 ntd 技能合集(6600+ 条),按来源分组浏览、搜索筛选、详情预览、一键安装
15
15
  - **模型可见性治理**:每个已装技能都有「模型可调用」开关,不想暴露给模型的技能一键隐藏/恢复
16
- - **输入框 +技能**:composer 工具行新增「+技能」按钮,弹出技能候选菜单,选中即把 `/技能名` 写入草稿,发送时技能内容注入该条消息
16
+ - **输入框 + 技能**:composer 工具行新增「+ 技能」按钮,弹出带搜索框的技能候选浮层(候选 = 宿主技能注册表,与 `/` 菜单同源;支持键盘 ↑/↓/Enter/Esc),选中即把 `/技能名` 写入草稿,发送时技能内容注入该条消息
17
17
  - **市场自动同步**:市场仓库自动克隆与每日更新(可关),支持 GitCode 私有仓库 access token
18
18
  - **稀疏检出**:ntd-resource 仓库同时携带专家/模板等子树,市场只检出 `skills` 子目录(git partial clone + sparse-checkout),省一半以上流量与磁盘;已有全量检出会在下次同步时原地转换
19
19
  - **软链布局兼容**:各执行器目录间软链共享的技能不会重复展示
@@ -28,7 +28,11 @@ dsh plugin --profile web add @weibaohui/skills-management -w
28
28
 
29
29
  ## 使用
30
30
 
31
- 1. 打开 Web UI → 侧栏进入 **技能市场** 页面
31
+ 1. 打开 Web UI → **设置** 左侧「技能市场」section 即完整管理页(可搭配 dsh-settings-ui 插件把设置窗口调大/全屏)
32
32
  2. 「已安装」视图管理本机技能;「市场」视图浏览/搜索/安装 ntd 合集技能;「执行器」视图按来源钻入查看各 coding agent 的技能
33
33
  3. 详情页可预览 SKILL.md 全文、安装到用户库、切换模型可调用开关
34
34
  4. ⚙ 设置面板里可配置市场仓库地址、分支、access token 与自动同步
35
+
36
+ ## 联系我 :飞书群
37
+
38
+ ![link](https://foruda.gitee.com/images/1774880015525784725/4fd67005_77493.png "link")
package/client/bundle.js CHANGED
@@ -8,10 +8,114 @@ window.__ModuleLoader__.load({
8
8
  var exports = module.exports
9
9
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" })
10
10
  var React = require("react")
11
+ /**
12
+ * @weibaohui/dsh-plugin-kit — client source(由消费者构建脚本内联进 bundle,
13
+ * 不经 loader 运行时加载)。对外暴露 PluginKit:
14
+ *
15
+ * PluginKit.substituteParams(template, params) — {{key}} 模板插值
16
+ * PluginKit.makeActionShareDialog(React, opts) — 返回 ActionShareDialog 组件
17
+ *
18
+ * ActionShareDialog props:
19
+ * title / hint / rows: [[label, value], ...] / initialPrompt
20
+ * run: async (prompt) => { jobId } — 发起执行
21
+ * poll: async (jobId) => { status, output, code }
22
+ * labels: { copy, copied, run, running, done, failed, outputLabel, close }
23
+ * onClose
24
+ *
25
+ * 全部样式内联(主题 token + 回退值),消费者无需自带 CSS。
26
+ */
27
+ var PluginKit = (function () {
28
+ function substituteParams(template, params) {
29
+ var out = String(template || '')
30
+ for (var key in (params || {})) out = out.split('{{' + key + '}}').join(String(params[key]))
31
+ return out
32
+ }
33
+
34
+ function makeActionShareDialog(React, options) {
35
+ options = options || {}
36
+ var h = React.createElement
37
+ var useState = React.useState
38
+ var useEffect = React.useEffect
39
+ var doFetch = options.fetch || (typeof fetch !== 'undefined' ? fetch : null)
40
+ var inputStyle = { width: '100%', minHeight: 190, resize: 'vertical', fontFamily: 'var(--dsw-font-family)', lineHeight: 1.6, fontSize: 12, background: 'var(--dsw-alias-bg-layer-2,transparent)', color: 'inherit', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '8px', padding: '10px', boxSizing: 'border-box' }
41
+ var btnStyle = { background: 'transparent', color: 'inherit', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '8px', padding: '5px 12px', fontSize: 13, cursor: 'pointer', font: 'inherit' }
42
+ var primaryStyle = Object.assign({}, btnStyle, { background: 'var(--dsw-alias-brand-primary,#4a7dff)', borderColor: 'var(--dsw-alias-brand-primary,#4a7dff)', color: '#fff' })
43
+
44
+ return function ActionShareDialog(props) {
45
+ var title = props.title
46
+ var hint = props.hint
47
+ var labels = props.labels || {}
48
+ var _p = useState(props.initialPrompt || '')
49
+ var prompt = _p[0]; var setPrompt = _p[1]
50
+ var _j = useState(null)
51
+ var job = _j[0]; var setJob = _j[1]
52
+ var _b = useState(false)
53
+ var busy = _b[0]; var setBusy = _b[1]
54
+ var _c = useState(false)
55
+ var copied = _c[0]; var setCopied = _c[1]
56
+ var _e = useState('')
57
+ var error = _e[0]; var setError = _e[1]
58
+ var _d = useState(false)
59
+ var dirty = _d[0]; var setDirty = _d[1]
60
+
61
+ // initialPrompt 异步到位(如宿主先要下发真实路径)时跟随刷新;用户编辑过则不打断
62
+ useEffect(function () {
63
+ if (!dirty) setPrompt(props.initialPrompt || '')
64
+ }, [props.initialPrompt])
65
+
66
+ useEffect(function () {
67
+ if (job === null || job.status !== 'running' || typeof props.poll !== 'function') return
68
+ var timer = setInterval(function () {
69
+ props.poll(job.jobId).then(function (d) {
70
+ setJob({ jobId: job.jobId, status: d.status, output: d.output || '', code: d.code !== undefined ? d.code : null, sessionId: d.sessionId })
71
+ }).catch(function () {})
72
+ }, 1500)
73
+ return function () { clearInterval(timer) }
74
+ }, [job !== null && job.jobId])
75
+
76
+ var doRun = function () {
77
+ if (typeof props.run !== 'function') return
78
+ setBusy(true); setError('')
79
+ props.run(prompt).then(function (r) {
80
+ setJob({ jobId: r.jobId, status: 'running', output: '', code: null })
81
+ }).catch(function (e) { setError(String(e && e.message)) }).finally(function () { setBusy(false) })
82
+ }
83
+ var copy = function () {
84
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
85
+ navigator.clipboard.writeText(prompt).then(function () { setCopied(true); setTimeout(function () { setCopied(false) }, 1500) }).catch(function () {})
86
+ }
87
+ }
88
+ var statusText = job === null ? '' : job.status === 'running' ? (labels.running || 'running') : job.status === 'done' ? (labels.done || 'done') : (labels.failed || 'failed') + (job.code != null ? ' (' + job.code + ')' : '')
89
+
90
+ return h('div', { onClick: function (e) { if (e.target === e.currentTarget && props.onClose) props.onClose() }, style: { position: 'fixed', inset: 0, zIndex: 2147483000, background: 'rgba(0,0,0,.45)', display: 'flex', alignItems: 'center', justifyContent: 'center' } },
91
+ h('div', { style: { width: 'min(640px,92vw)', maxHeight: '86vh', overflow: 'auto', background: 'var(--dsw-alias-bg-layer-1,#fff)', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3))', borderRadius: '16px', padding: '20px', display: 'flex', flexDirection: 'column', gap: 12, color: 'var(--dsw-alias-label-primary,inherit)', font: 'var(--dsw-font-family,inherit)' } },
92
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
93
+ h('div', { style: { fontSize: 17, fontWeight: 600 } }, title || ''),
94
+ h('button', { onClick: props.onClose, style: Object.assign({}, btnStyle, { marginLeft: 'auto', width: 28, height: 28, padding: 0, borderRadius: 28 }) }, '✕')),
95
+ hint ? h('div', { style: { fontSize: 12, opacity: .7 } }, hint) : null,
96
+ (props.rows || []).length > 0 ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 13 } },
97
+ props.rows.map(function (r, i) {
98
+ return r[1] ? h('div', { key: i }, h('b', null, r[0] + ':'), h('span', null, r[1])) : null
99
+ })) : null,
100
+ h('textarea', { value: prompt, onChange: function (e) { setDirty(true); setPrompt(e.target.value) }, spellCheck: false, style: inputStyle }),
101
+ error !== '' ? h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error,#c75050)' } }, error) : null,
102
+ job !== null ? h('div', null,
103
+ h('div', { style: { fontSize: 12, opacity: .7, margin: '4px 0' } }, (labels.outputLabel || 'Output') + ' · ' + statusText),
104
+ h('pre', { style: { maxHeight: 220, margin: 0, overflow: 'auto', whiteSpace: 'pre-wrap', fontSize: 12, background: 'var(--dsw-alias-bg-layer-2,transparent)', border: '1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2))', borderRadius: '8px', padding: '8px' } }, job.output || '…')) : null,
105
+ h('div', { style: { display: 'flex', gap: 8 } },
106
+ h('button', { onClick: copy, style: btnStyle }, copied ? (labels.copied || 'Copied') : (labels.copy || 'Copy')),
107
+ h('button', { onClick: doRun, disabled: busy || (job !== null && job.status === 'running'), style: primaryStyle }, job !== null && job.status === 'running' ? (labels.running || 'Running…') : (labels.run || 'Run')))))
108
+ }
109
+ }
110
+
111
+ return { substituteParams: substituteParams, makeActionShareDialog: makeActionShareDialog }
112
+ })()
113
+
11
114
  /**
12
115
  * dsh-plugin-skills-management - Browser half.
13
116
  *
14
- * One React app for every surface (sidebar overlay + settings section).
117
+ * One React app for every surface (settings section; the former sidebar
118
+ * full-page entry was retired — pair with dsh-settings-ui for room).
15
119
  * All interactive controls are host primitives (@deepseek-ai/dsh-client-ui-
16
120
  * primitives); all colors come from the ui-theme `--dsw-*` token layers so
17
121
  * light/dark follows the shell; all copy comes from the locale registry
@@ -74,15 +178,24 @@ window.__ModuleLoader__.load({
74
178
  let sessionsApi = null
75
179
  const sessionsSvc = () => sessionsApi
76
180
 
77
- // Composer services (inputTriggers + sessions) for the +技能 button: opens
78
- // the `skill` source (registered by dsh-client-ui-skill) as a menu. Absence
79
- // hides the button; nothing else depends on it.
181
+ // Composer services (inputTriggers + sessions) for the 技能 button plus
182
+ // the `connection` service for the picker's skill catalog: the button opens
183
+ // the plugin's own searchable picker popover (the host slash menu filters
184
+ // only by a typed query, which a button click cannot provide); the pick is
185
+ // written into the draft through the same scoped `slash/input-insert-text`
186
+ // event the host menu executes. Absence hides the button; nothing else
187
+ // depends on it.
80
188
  let composerScope = null
189
+ let connectionApi = null
81
190
 
82
191
  /**
83
192
  * Open one registered '/' source over a synthetic collapsed span appended at
84
193
  * the draft end (host toggleCommandMenu 同款调用形状;标准 kit 不暴露光标,
85
194
  * pick 依赖 span-CAS:点击后草稿若再变动则本次 pick 静默作废)。
195
+ *
196
+ * NOTE: the + 技能 button no longer uses this — the host menu cannot offer
197
+ * a search box, so the button opens SkillPicker instead. Kept for the
198
+ * contract tests and as the documented toggleSource path.
86
199
  */
87
200
  function openTriggerSource(scope, sessionId, input, sourceName) {
88
201
  const inputTriggers = scope && scope.inputTriggers
@@ -105,6 +218,127 @@ window.__ModuleLoader__.load({
105
218
  return true
106
219
  }
107
220
 
221
+ /**
222
+ * Insert `text` at the end of the session draft through the same scoped
223
+ * event the host slash menu executes (`slash/input-insert-text`). The span
224
+ * CAS uses the freshest input snapshot handed to the slot props — while the
225
+ * picker popover is open the composer draft cannot move (focus is in the
226
+ * picker), so the splice applies; a stale snapshot quietly no-ops, same as
227
+ * the host menu's span-CAS.
228
+ */
229
+ function insertComposerText(scope, sessionId, input, text) {
230
+ const sessions = scope && scope.sessions
231
+ if (!sessions) return false
232
+ let actx
233
+ try { actx = sessions.scope(sessionId) } catch { return false }
234
+ if (actx === undefined || actx === null || typeof actx.bail !== 'function') return false
235
+ const draft = (input && input.draft) || ''
236
+ const at = draft.length
237
+ try {
238
+ return actx.bail(actx, 'slash/input-insert-text', {
239
+ text,
240
+ span: { start: at, end: at, draftRev: (input && input.draftRev) || 0 },
241
+ }) === true
242
+ } catch { return false }
243
+ }
244
+
245
+ /** Best-effort refocus of the composer textarea after the picker closes. */
246
+ function refocusComposer() {
247
+ try {
248
+ const card = document.querySelector('[data-composer-card]')
249
+ const ta = card && card.querySelector('textarea')
250
+ if (ta && typeof ta.focus === 'function') ta.focus()
251
+ } catch {}
252
+ }
253
+
254
+ /** Picker popover list cap — beyond this the search input is the filter. */
255
+ const PICKER_ROW_CAP = 200
256
+
257
+ /** Skill catalog cache for the picker (ui-skill 同源:connection.api.skills). */
258
+ let skillCatalog = { sessionId: null, at: 0, rows: null }
259
+ const SKILL_CATALOG_TTL = 60_000
260
+
261
+ /**
262
+ * Picker candidates from the host skill registry (the same list the `/`
263
+ * skill source shows). Subagent sessions have no catalog (ui-skill 同款守卫);
264
+ * a failed/absent connection rejects → the picker shows its empty state.
265
+ */
266
+ async function fetchSkillCandidates(connection, sessions, sessionId) {
267
+ try { if (sessions && typeof sessions.subagentAddress === 'function' && sessions.subagentAddress(sessionId) !== undefined) return [] } catch {}
268
+ const now = Date.now()
269
+ if (skillCatalog.rows !== null && skillCatalog.sessionId === sessionId && now - skillCatalog.at < SKILL_CATALOG_TTL) return skillCatalog.rows
270
+ const skills = connection && connection.api && connection.api.skills
271
+ if (!skills || typeof skills.list !== 'function') throw new Error('connection.api.skills unavailable')
272
+ const res = await skills.list({ sessionId })
273
+ const result = res && res.result
274
+ if (!result || result.ok !== true) throw new Error('skill.list failed')
275
+ const list = result.value && Array.isArray(result.value.skills) ? result.value.skills : []
276
+ const rows = list.map((s) => ({ name: s.name, description: s.description || '', modelInvocable: s.modelInvocable !== false }))
277
+ skillCatalog = { sessionId, at: now, rows }
278
+ return rows
279
+ }
280
+
281
+ /**
282
+ * + 技能 picker:锚定在按钮上方、自带搜索框的候选浮层(portal 到 body)。
283
+ * 宿主斜杠菜单靠「输入的 query」过滤,按钮打开的菜单没有输入载体——候选
284
+ * 太多时无从筛选,所以浮层自带搜索框。键盘 ↑/↓/Enter/Esc,鼠标 hover+点击。
285
+ */
286
+ function SkillPicker(props) {
287
+ const t = props.t
288
+ const [query, setQuery] = useState('')
289
+ const [active, setActive] = useState(0)
290
+ const inputRef = useRef(null)
291
+ const listRef = useRef(null)
292
+ useEffect(() => { try { if (inputRef.current) inputRef.current.focus() } catch {} }, [])
293
+ useEffect(() => {
294
+ const onKey = (e) => { if (e.key === 'Escape' && !(e.isComposing === true)) props.onClose() }
295
+ try { document.addEventListener('keydown', onKey) } catch {}
296
+ return () => { try { document.removeEventListener('keydown', onKey) } catch {} }
297
+ }, [])
298
+ const lower = query.trim().toLowerCase()
299
+ const all = props.rows || []
300
+ const filtered = lower === '' ? all : all.filter((r) => matchSkill(r, lower))
301
+ const shown = filtered.slice(0, PICKER_ROW_CAP)
302
+ useEffect(() => { setActive(0) }, [lower, props.rows])
303
+ useEffect(() => {
304
+ const list = listRef.current
305
+ const el = list && list.children[active]
306
+ if (el && typeof el.scrollIntoView === 'function') { try { el.scrollIntoView({ block: 'nearest' }) } catch {} }
307
+ }, [active])
308
+ const onKeyDown = (e) => {
309
+ // IME 组词中的按键不触发选择(回车是选定拼音候选,不是 pick)
310
+ if (e.nativeEvent && e.nativeEvent.isComposing === true) return
311
+ if (e.key === 'ArrowDown') { e.preventDefault(); setActive((i) => Math.min(i + 1, shown.length - 1)) }
312
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)) }
313
+ else if (e.key === 'Enter') { e.preventDefault(); const row = shown[active]; if (row) props.onPick(row) }
314
+ }
315
+ const width = 400
316
+ const winW = typeof window !== 'undefined' ? window.innerWidth : 800
317
+ const winH = typeof window !== 'undefined' ? window.innerHeight : 600
318
+ const left = Math.max(8, Math.min(props.anchor.left, winW - width - 8))
319
+ const bottom = Math.max(8, winH - props.anchor.top + 6)
320
+ return h('div', { className: 'sk-picker-backdrop', onMouseDown: (e) => { if (e.target === e.currentTarget) props.onClose() } },
321
+ h('div', { className: 'sk-picker', style: { left, bottom, width }, role: 'dialog', 'aria-label': t('pickSkillTitle') },
322
+ h('input', {
323
+ ref: inputRef, className: 'sk-input sk-picker-input', value: query,
324
+ placeholder: t('pickerSearch'), onChange: (e) => setQuery(e.target.value), onKeyDown,
325
+ }),
326
+ h('div', { className: 'sk-picker-list', ref: listRef, role: 'listbox' },
327
+ props.rows === null
328
+ ? h('div', { className: 'sk-picker-empty' }, t('pickerLoading'))
329
+ : shown.length === 0
330
+ ? h('div', { className: 'sk-picker-empty' }, t('emptySearch'))
331
+ : shown.map((row, i) => h('button', {
332
+ key: row.name, type: 'button', role: 'option', 'aria-selected': i === active,
333
+ className: 'sk-picker-row', 'data-active': i === active,
334
+ onMouseEnter: () => setActive(i),
335
+ onMouseDown: (e) => { e.preventDefault(); props.onPick(row) },
336
+ },
337
+ h('span', { className: 'sk-picker-name' }, row.name),
338
+ h('span', { className: 'sk-picker-desc' },
339
+ row.modelInvocable ? row.description : `${t('pickerUserOnly')} · ${row.description}`))))))
340
+ }
341
+
108
342
  // ── Locale ───────────────────────────────────────────────────────────────
109
343
 
110
344
  const NS = 'skillsManagement'
@@ -136,7 +370,7 @@ window.__ModuleLoader__.load({
136
370
  saved: '设置已保存',
137
371
  gitMissing: '未检测到 git',
138
372
  repoDirLabel: '本地目录(同步内容存放处)',
139
- tokenLabel: '访问令牌(私有仓库需要)',
373
+ tokenLabel: '访问令牌(分享到社区/私有仓库需要)',
140
374
  tokenConfigured: '已配置',
141
375
  clearToken: '清除',
142
376
  shareBtn: '分享',
@@ -214,8 +448,11 @@ window.__ModuleLoader__.load({
214
448
  invocationHint: '关闭后技能保留在库里,但不再注入对话目录(skill 工具也调不到)',
215
449
  pathLabel: '路径',
216
450
  meTag: '本机',
217
- pickSkill: '+技能',
451
+ pickSkill: '+ 技能',
218
452
  pickSkillTitle: '选择一个技能,其内容将注入本条消息',
453
+ pickerSearch: '搜索技能名称或描述…',
454
+ pickerLoading: '正在加载技能目录…',
455
+ pickerUserOnly: '仅用户',
219
456
  }
220
457
 
221
458
  const EN = {
@@ -245,7 +482,7 @@ window.__ModuleLoader__.load({
245
482
  saved: 'Settings saved',
246
483
  gitMissing: 'git not found',
247
484
  repoDirLabel: 'Local directory (sync target)',
248
- tokenLabel: 'Access token (private repos)',
485
+ tokenLabel: 'Access token (community sharing / private repos)',
249
486
  tokenConfigured: 'configured',
250
487
  clearToken: 'Clear',
251
488
  shareBtn: 'Share',
@@ -325,29 +562,24 @@ window.__ModuleLoader__.load({
325
562
  meTag: 'me',
326
563
  pickSkill: '+ Skill',
327
564
  pickSkillTitle: 'Pick a skill; its content is injected into this message',
565
+ pickerSearch: 'Search skills by name or description…',
566
+ pickerLoading: 'Loading skill catalog…',
567
+ pickerUserOnly: 'user-only',
328
568
  }
329
569
 
330
570
  // ── Pure helpers ────────────────────────────────────────────────────────
331
571
 
332
572
  /** ntd ActionButton 同款 {{key}} 替换:split/join 规避正则元字符 */
333
- function substituteParams(template, params) {
334
- let out = template
335
- for (const [key, value] of Object.entries(params)) {
336
- out = out.split(`{{${key}}}`).join(String(value))
337
- }
338
- return out
339
- }
340
-
341
573
  const SHARE_PROMPT_ZH = [
342
574
  '请把本地技能「{{skillName}}」{{version}}打包提交到 GitCode 官方仓库,作为一个 PR 供维护者审核。',
343
575
  '',
344
576
  '## 关键信息',
345
577
  '- 技能目录:{{resourceDir}}(~ 表示当前用户家目录,执行前先展开为绝对路径)',
346
578
  '- 官方仓库:weibaohui/ntd-resource(GitCode,API base = https://api.gitcode.com)',
347
- '- PAT 位置:~/.dsh/settings.yaml 中 skills-management.market 段的 token 字段(由技能市场设置面板保存)。',
579
+ '- PAT 位置:{{settingsFile}} 中 skills-management.market 段的 token 字段(由技能市场设置面板保存)。',
348
580
  '',
349
581
  '## 执行步骤(严格按顺序)',
350
- '1. 读取 PAT:读取 ~/.dsh/settings.yaml,定位 skills-management.market 段下的 token 字段。token 是敏感凭据,读取后不要把明文打印到输出、日志或最终结果里。',
582
+ '1. 读取 PAT:读取 {{settingsFile}},定位 skills-management.market 段下的 token 字段。token 是敏感凭据,读取后不要把明文打印到输出、日志或最终结果里。',
351
583
  '2. 展开技能目录为绝对路径,遍历该目录,收集每个文件的「相对该目录的路径」与内容;跳过 .downloaded_at、.clawhub、.git 三类同步元数据。',
352
584
  '3. 把第 1 步读到的 token 作为 HTTP 认证令牌,附加到下面每个 GitCode API 请求的认证头里(bearer 认证方式),不要写成占位符:',
353
585
  ' a. 验证用户:`GET https://api.gitcode.com/api/v5/user`,拿到返回的 login 字段——这是 token 真实所属的账号,后续所有 URL 里的 {owner} 一律用它。',
@@ -469,6 +701,15 @@ window.__ModuleLoader__.load({
469
701
  .sk-tabpill{background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family)}
470
702
  .sk-chip{display:inline-flex;align-items:center;gap:4px;height:26px;padding:0 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family);font-size:12px;cursor:pointer;white-space:nowrap}
471
703
  .sk-chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
704
+ .sk-picker-backdrop{position:fixed;inset:0;z-index:2147483200}
705
+ .sk-picker{position:fixed;z-index:2147483201;width:400px;max-width:92vw;max-height:360px;display:flex;flex-direction:column;gap:4px;padding:6px;background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;box-shadow:var(--dsw-shadow-lv3)}
706
+ .sk-picker-input{flex:none;box-sizing:border-box;width:100%}
707
+ .sk-picker-list{display:flex;flex-direction:column;min-height:40px;overflow-y:auto}
708
+ .sk-picker-row{display:flex;align-items:center;gap:8px;width:100%;min-height:36px;padding:6px 10px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);text-align:left;cursor:pointer;font-family:var(--dsw-font-family);font-size:13px}
709
+ .sk-picker-row[data-active="true"]{background:var(--dsw-alias-interactive-bg-hover)}
710
+ .sk-picker-name{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
711
+ .sk-picker-desc{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px}
712
+ .sk-picker-empty{padding:12px 10px;text-align:center;color:var(--dsw-alias-label-dimmed);font-size:13px}
472
713
  </style>`
473
714
 
474
715
  // ── Fetch layer ─────────────────────────────────────────────────────────
@@ -725,60 +966,31 @@ window.__ModuleLoader__.load({
725
966
  return h('div', { className: 'sk-toast' }, text)
726
967
  }
727
968
 
728
- /** ntd ActionButton 同款分享抽屉:可编辑提示词 + 参数预览 + 复制到会话执行。 */
729
- function ShareSkillDialog({ t, params, onClose, onToast }) {
730
- const [prompt, setPrompt] = useState(substituteParams(SHARE_PROMPT_ZH, params))
731
- const [hasToken, setHasToken] = useState(null)
732
- const [job, setJob] = useState(null) // {jobId,status,output,code}
733
- const [busy, setBusy] = useState(false)
969
+ let _actionShareDialog = null
970
+ function getActionShareDialog() {
971
+ if (_actionShareDialog === null) _actionShareDialog = PluginKit.makeActionShareDialog(__React)
972
+ return _actionShareDialog
973
+ }
974
+
975
+ /** 分享抽屉:壳交给 PluginKit(ActionShareDialog),本插件只负责
976
+ * hasToken 提示、settingsFile 插值与 run/poll 的 API 映射。 */
977
+ function ShareSkillDialog({ t, params, onClose }) {
978
+ const _st = useState(null)
979
+ const status = _st[0]; const setStatus = _st[1]
734
980
  useEffect(() => {
735
- getJson(API + '/market/status').then(d => setHasToken(d.hasToken === true)).catch(() => setHasToken(false))
981
+ getJson(API + '/market/status').then((d) => setStatus(d)).catch(() => setStatus({ hasToken: false }))
736
982
  }, [])
737
- // 轮询执行输出,直到关闭/结束
738
- useEffect(() => {
739
- if (job === null || job.status !== 'running') return
740
- const timer = setInterval(() => {
741
- getJson(API + '/share/run?id=' + encodeURIComponent(job.jobId))
742
- .then(d => setJob(prev => prev && { ...prev, status: d.status, output: d.output || '', code: d.code, sessionId: d.sessionId || prev.sessionId }))
743
- .catch(() => {})
744
- }, 2000)
745
- if (typeof timer.unref === 'function') timer.unref()
746
- return () => clearInterval(timer)
747
- }, [job && job.status])
748
- const doRun = async () => {
749
- setBusy(true)
750
- try {
751
- const r = await fetch(API + '/share/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, dir: params.resourceDir }) })
752
- const d = await r.json().catch(() => ({}))
753
- if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
754
- setJob({ jobId: d.jobId, status: 'running', output: '', code: null })
755
- } catch (e) { onToast(t('runFailed') + ': ' + e.message) } finally { setBusy(false) }
756
- }
757
- const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
758
- h('span', { className: 'sk-dir' }, label), h('span', { className: 'sk-hint', style: { wordBreak: 'break-all', textAlign: 'right' } }, value))
759
- const copy = () => {
760
- navigator.clipboard.writeText(prompt).then(() => onToast(t('promptCopied'))).catch(() => {})
761
- }
762
- return h(SkDialog, { title: t('shareTitle'), onClose, wide: true },
763
- h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 380 } },
764
- h('div', { className: 'sk-hint' }, hasToken === false ? t('shareHintPatMissing') : t('shareHint')),
765
- h('div', null,
766
- row(t('shareParamName'), params.skillName),
767
- row(t('shareParamVersion'), params.version || '-'),
768
- row(t('shareParamDir'), params.resourceDir),
769
- row(t('shareParamRemote'), params.remotePath)),
770
- h('textarea', { className: 'sk-input', value: prompt, onChange: e => setPrompt(e.target.value),
771
- style: { width: '100%', minHeight: 190, resize: 'vertical', fontFamily: 'var(--dsw-font-family)', lineHeight: 1.6 } }),
772
- job !== null && h('div', null,
773
- h('div', { className: 'sk-dir', style: { margin: '4px 0' } },
774
- t('outputLabel') + ' · ' + (job.status === 'running' ? t('running') : job.status === 'done' ? t('runDone') : t('runFailed') + (job.code != null ? ' (' + job.code + ')' : ''))),
775
- h('pre', { className: 'sk-preview', style: { maxHeight: 220, margin: 0, whiteSpace: 'pre-wrap', fontSize: 12 } },
776
- job.output || '…')),
777
- h('div', { className: 'sk-dlg-foot', style: { marginTop: 0 } },
778
- h(ButtonLite, { onClick: copy }, t('copyPrompt')),
779
- job !== null && job.sessionId && sessionsSvc() && h(ButtonLite, { onClick: () => { if (openRunSession(job.sessionId)) onClose() } }, t('openChat')),
780
- h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
781
- job !== null && job.status === 'running' ? t('running') : t('runBtn')))))
983
+ const hasToken = status ? status.hasToken === true : null
984
+ const hint = hasToken === false ? t('shareHintPatMissing') : t('shareHint')
985
+ const initialPrompt = PluginKit.substituteParams(SHARE_PROMPT_ZH, { ...params, settingsFile: (status && status.settingsFile) || '' })
986
+ return h(getActionShareDialog(), {
987
+ title: t('shareTitle'), hint, initialPrompt,
988
+ rows: [[t('shareParamName'), params.skillName], [t('shareParamVersion'), params.version || '-'], [t('shareParamDir'), params.resourceDir]],
989
+ labels: { copy: t('copyPrompt'), copied: t('copied'), run: t('runBtn'), running: t('running'), done: t('runDone'), failed: t('runFailed'), outputLabel: t('outputLabel') },
990
+ run: (prompt) => fetch(API + '/share/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, dir: params.resourceDir }) }).then((r) => r.json()),
991
+ poll: (id) => fetch(API + '/share/run?id=' + encodeURIComponent(id)).then((r) => r.json()),
992
+ onClose,
993
+ })
782
994
  }
783
995
 
784
996
  /** Market sync settings: status card, sync action, editable url/branch. */
@@ -1259,61 +1471,16 @@ window.__ModuleLoader__.load({
1259
1471
 
1260
1472
  // ── Slot entries ─────────────────────────────────────────────────────────
1261
1473
 
1262
- function footerStyle() {
1263
- return { display: 'inline-flex', alignItems: 'center', gap: 6, margin: '4px 10px', padding: '8px 10px',
1264
- border: 'none', borderRadius: 8, background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
1265
- font: 'inherit', fontSize: 13, cursor: 'pointer', width: 'calc(100% - 20px)', textAlign: 'left' }
1266
- }
1267
-
1268
- /** Panel state lives OUTSIDE React: sidebar churn remounts slot entries,
1269
- * and any state kept in them (the old bug) is torn down with them. */
1270
- const panelStore = {
1271
- open: false,
1272
- listeners: new Set(),
1273
- set(v) { panelStore.open = v; for (const fn of panelStore.listeners) fn(v) },
1274
- subscribe(fn) { panelStore.listeners.add(fn); return () => panelStore.listeners.delete(fn) },
1275
- }
1276
-
1277
1474
  /** Jump to the run's conversation: open() is best-effort (it may reject
1278
- * after the selection lands), but folding our overlay must always happen. */
1475
+ * after the selection lands). */
1279
1476
  function openRunSession(sessionId) {
1280
1477
  try {
1281
1478
  const svc = sessionsSvc()
1282
1479
  if (svc && typeof svc.open === 'function') svc.open(sessionId)
1283
1480
  } catch {}
1284
- panelStore.set(false)
1285
1481
  return true
1286
1482
  }
1287
1483
 
1288
- /** Footer slot entry: the button, and — when open — the whole market page
1289
- * through the host primitives Modal (portal + overlay handled by the host's
1290
- * own React tree; no custom createRoot, which never commits here). */
1291
- function FooterSlotComponent(props) {
1292
- const [open, setOpen] = useState(panelStore.open)
1293
- useEffect(() => panelStore.subscribe(setOpen), [])
1294
- useEffect(ensureStyles, [])
1295
-
1296
- const t = props.__t
1297
- const labelText = t ? t('title') : 'Skills Market'
1298
- // The sidebar renders this entry with a `wide` owner prop: the collapsed
1299
- // rail passes false and shows the icon alone; expanded shows the label.
1300
- const wide = props.wide !== false
1301
- return h('span', { style: { display: 'contents' } },
1302
- h('button', { title: labelText, 'aria-label': labelText, onClick: () => panelStore.set(!panelStore.open),
1303
- style: footerStyle() },
1304
- P && P.IconSkillOutline16 ? h(P.IconSkillOutline16, { size: 16 }) : '\u{1F3AF}',
1305
- wide ? ' ' + labelText : ''),
1306
- open && (() => {
1307
- const page = h(SkillsPage, { t, embedded: false, onClose: () => panelStore.set(false) })
1308
- // Fullscreen: portal the fixed-position page to <body> so no sidebar
1309
- // ancestor (transform-containing or otherwise) can clip it.
1310
- if (RDP && typeof RDP.createPortal === 'function' && typeof document !== 'undefined') {
1311
- return RDP.createPortal(page, document.body)
1312
- }
1313
- return page // fallback: fixed positioning still applies from here
1314
- })())
1315
- }
1316
-
1317
1484
  /** Settings section slot entry: render the page directly in the host tree. */
1318
1485
  function SettingsSlotComponent(props) {
1319
1486
  useEffect(ensureStyles, [])
@@ -1327,7 +1494,7 @@ window.__ModuleLoader__.load({
1327
1494
  module.exports = {
1328
1495
  name: CLIENT_NAME,
1329
1496
  inject: ['slots', 'locale'],
1330
- __internals: { NS, ZH, EN, matchSkill, formatSize, formatTime, openTriggerSource },
1497
+ __internals: { NS, ZH, EN, matchSkill, formatSize, formatTime, openTriggerSource, insertComposerText, fetchSkillCandidates },
1331
1498
  /** Test/host helper: mount a standalone page into any container. */
1332
1499
  __boot(container, opts = {}) {
1333
1500
  ensureStyles()
@@ -1355,13 +1522,20 @@ window.__ModuleLoader__.load({
1355
1522
  })
1356
1523
  }
1357
1524
  } catch {}
1358
- // Composer services (inputTriggers + sessions) for the +技能 button;
1525
+ // Composer services (inputTriggers + sessions) for the 技能 button;
1359
1526
  // absence hides the button only.
1360
1527
  try {
1361
1528
  if (typeof ctx.inject === 'function') {
1362
1529
  ctx.inject(['inputTriggers', 'sessions'], (scope) => { composerScope = scope })
1363
1530
  }
1364
1531
  } catch {}
1532
+ // connection service for the picker skill catalog (host skill registry,
1533
+ // ui-skill 同源); absence keeps the button hidden.
1534
+ try {
1535
+ if (typeof ctx.inject === 'function') {
1536
+ ctx.inject(['connection'], (scope) => { connectionApi = scope && scope.connection })
1537
+ }
1538
+ } catch {}
1365
1539
  // Locale service is optional at boot order — degrade to EN until present
1366
1540
  let t = (key, vars) => {
1367
1541
  let out = EN[key] ?? key
@@ -1383,21 +1557,6 @@ window.__ModuleLoader__.load({
1383
1557
  }
1384
1558
  }
1385
1559
  } catch (e) { try { console.error('[skills-management] locale init:', e) } catch {} }
1386
- ctx.effect(() => {
1387
- try {
1388
- ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
1389
- name: 'sidebar.footer.action',
1390
- id: CLIENT_NAME,
1391
- order: 50,
1392
- locale: NS,
1393
- label: () => t('title'),
1394
- inject: () => ({ t }),
1395
- }, function FooterSlot(apiProps) {
1396
- // ownerProps (the sidebar's wide flag) land here — forward them
1397
- return h(FooterSlotComponent, { __t: t, wide: apiProps && apiProps.wide })
1398
- }))
1399
- } catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('footer:' + (e && e.message)); throw e }
1400
- }, 'skills-management: sidebar footer action')
1401
1560
  ctx.effect(() => {
1402
1561
  try {
1403
1562
  ctx.slots.inject('settings.section', () => ctx.slots.register({
@@ -1424,7 +1583,7 @@ window.__ModuleLoader__.load({
1424
1583
  inject: () => ({ t }),
1425
1584
  }, function SkillButtonSlot(apiProps) {
1426
1585
  return h(ComposerButtonSlot, {
1427
- __t: t, icon: '⚡', label: t('pickSkill'), title: t('pickSkillTitle'),
1586
+ __t: t, label: t('pickSkill'), title: t('pickSkillTitle'),
1428
1587
  source: 'skill', sessionId: apiProps && apiProps.sessionId, input: apiProps && apiProps.input,
1429
1588
  })
1430
1589
  }))
@@ -1433,18 +1592,49 @@ window.__ModuleLoader__.load({
1433
1592
  },
1434
1593
  }
1435
1594
 
1436
- /** Composer tool-row button: opens a registered '/' source (ui-skill's
1437
- * `skill` source) over the session's trigger controller. Hidden while the
1438
- * inputTriggers/sessions services are absent. */
1595
+ /** Composer tool-row button: 加号+文字 chip,点击在按钮上方打开自带搜索的
1596
+ * 技能 picker 浮层(候选 = 宿主技能注册表,ui-skill 同源);pick
1597
+ * slash/input-insert-text 写入 `/<name> `。浮层背板盖住按钮以外的区域,
1598
+ * 再点一次按钮会先落在背板上——天然形成开关切换。
1599
+ * connection 缺席(picker 无目录来源)时回退旧的 toggleSource 宿主菜单;
1600
+ * inputTriggers/sessions 缺席时按钮隐藏(与旧行为一致)。 */
1439
1601
  function ComposerButtonSlot(props) {
1440
1602
  useEffect(ensureStyles, [])
1603
+ const [picker, setPicker] = useState(null) // {left, top} 锚点快照;null = 关闭
1604
+ const [rows, setRows] = useState(null) // null = 加载中
1605
+ const btnRef = useRef(null)
1606
+ const liveInput = useRef(props.input)
1607
+ liveInput.current = props.input
1441
1608
  const ready = !!(composerScope && composerScope.inputTriggers && composerScope.sessions && props.sessionId)
1442
1609
  if (!ready) return null
1610
+ const close = () => setPicker(null)
1611
+ const open = () => {
1612
+ // 目录来源缺席 → 退回宿主斜杠菜单(无搜索,但按钮不消失)
1613
+ if (!connectionApi) { openTriggerSource(composerScope, props.sessionId, liveInput.current, props.source); return }
1614
+ let anchor = { left: 16, top: 160 }
1615
+ try { if (btnRef.current) anchor = btnRef.current.getBoundingClientRect() } catch {}
1616
+ setPicker({ left: anchor.left, top: anchor.top })
1617
+ setRows(null)
1618
+ fetchSkillCandidates(connectionApi, composerScope.sessions, props.sessionId)
1619
+ .then((list) => setRows(Array.isArray(list) ? list : []))
1620
+ .catch(() => setRows([]))
1621
+ }
1622
+ const pick = (row) => {
1623
+ insertComposerText(composerScope, props.sessionId, liveInput.current, `/${row.name} `)
1624
+ close()
1625
+ refocusComposer()
1626
+ }
1627
+ const popover = picker !== null && RDP && typeof RDP.createPortal === 'function'
1628
+ ? RDP.createPortal(h(SkillPicker, { t: props.__t, anchor: picker, rows, onClose: close, onPick: pick }), document.body)
1629
+ : null
1443
1630
  return h('button', {
1444
1631
  className: 'sk-chip',
1632
+ ref: btnRef,
1445
1633
  title: props.title || props.label,
1446
- onClick: () => { openTriggerSource(composerScope, props.sessionId, props.input, props.source) },
1447
- }, `${props.icon || ''}${props.icon ? ' ' : ''}${props.label}`)
1634
+ 'aria-haspopup': 'dialog',
1635
+ 'aria-expanded': picker !== null,
1636
+ onClick: open,
1637
+ }, props.label, popover)
1448
1638
  }
1449
1639
 
1450
1640
  return module.exports