dsh-skill-hub 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +890 -356
- package/lib/client.js.map +1 -1
- package/lib/index.js +401 -47
- package/lib/types/client/SkillHubSettingsCard.d.ts +0 -2
- package/lib/types/client/api.d.ts +5 -1
- package/lib/types/client/helpers.d.ts +10 -0
- package/lib/types/client/locales.d.ts +30 -0
- package/lib/types/client/panel/DisabledRow.d.ts +2 -0
- package/lib/types/client/panel/SkillDetailView.d.ts +5 -1
- package/lib/types/client/panel/dialogs.d.ts +19 -0
- package/lib/types/client/panel/useSkillHub.d.ts +12 -1
- package/lib/types/client/settings-card.d.ts +0 -17
- package/lib/types/client/settings-form.d.ts +0 -1
- package/lib/types/protocol.d.ts +40 -17
- package/lib/types/repo.d.ts +4 -6
- package/lib/types/skillfs.d.ts +13 -0
- package/package.json +29 -28
- package/src/client/SkillHubSettingsCard.tsx +0 -2
- package/src/client/api.ts +18 -0
- package/src/client/helpers.ts +40 -0
- package/src/client/locales.ts +60 -0
- package/src/client/panel/DisabledRow.tsx +22 -7
- package/src/client/panel/MarketView.tsx +8 -1
- package/src/client/panel/ScenesView.tsx +1 -1
- package/src/client/panel/SkillDetailView.tsx +35 -11
- package/src/client/panel/SkillHubPanel.tsx +42 -4
- package/src/client/panel/SkillRow.tsx +5 -2
- package/src/client/panel/SourcesView.tsx +2 -17
- package/src/client/panel/dialogs.tsx +61 -0
- package/src/client/panel/panel.module.css +1 -8
- package/src/client/panel/useSkillHub.ts +97 -12
- package/src/client/settings-card.tsx +0 -42
- package/src/client/settings-form.ts +1 -5
- package/src/protocol.ts +42 -8
- package/src/repo.ts +71 -29
- package/src/routes.test.ts +22 -0
- package/src/routes.ts +181 -10
- package/src/skillfs.ts +188 -3
package/src/client/locales.ts
CHANGED
|
@@ -65,12 +65,22 @@ export const zh = {
|
|
|
65
65
|
'market.branchTitle': '选择分支',
|
|
66
66
|
'market.branchHint': '该仓库没有正式发布,选择要跟踪的分支(默认为 main):',
|
|
67
67
|
'market.branchConfirm': '确定',
|
|
68
|
+
'market.versionTitle': '切换跟踪版本',
|
|
69
|
+
'market.versionText': '选择 {repo} 要跟踪的版本,切换后建议重新扫描并同步已装技能',
|
|
70
|
+
'market.versionCurrent': '当前 {ref}',
|
|
71
|
+
'market.versionReleases': '发布版',
|
|
72
|
+
'market.versionBranches': '分支',
|
|
73
|
+
'market.versionCustom': '自定义(tag / 分支 / commit)',
|
|
74
|
+
'market.versionConfirm': '切换到 {ref}',
|
|
75
|
+
'market.versionHint': '点击切换跟踪版本',
|
|
76
|
+
'market.unpinned': '未定版',
|
|
68
77
|
'market.syncTitle': '同步本地技能',
|
|
69
78
|
'market.syncText': '市场源已对齐到 {ref},要同步更新以下本地技能吗?',
|
|
70
79
|
'market.syncNone': '该来源还没有导入本地技能。',
|
|
71
80
|
'market.syncCancel': '跳过',
|
|
72
81
|
'market.removeHint': '移除源不影响已安装的技能。',
|
|
73
82
|
'repo.ready': '发现 {count} 个可导入技能',
|
|
83
|
+
'repo.truncated': '⚠ 仓库过大,仅展示部分技能(GitHub 截断),可缩小仓库或指定子目录',
|
|
74
84
|
'repo.empty': '没有找到可导入技能。',
|
|
75
85
|
'repo.selectAll': '全选',
|
|
76
86
|
'repo.clearAll': '清空',
|
|
@@ -143,6 +153,26 @@ export const zh = {
|
|
|
143
153
|
'row.enable': '启用',
|
|
144
154
|
'row.delete': '移入回收站',
|
|
145
155
|
'row.open': '查看 {name} 详情(回车)',
|
|
156
|
+
'row.duplicate': '重名',
|
|
157
|
+
'row.duplicateHint': '同名技能存在多个来源,当前显示为优先级最高的版本',
|
|
158
|
+
'row.copy': '复制',
|
|
159
|
+
'row.copyMention': '复制 $mention',
|
|
160
|
+
'row.copied': '已复制',
|
|
161
|
+
'row.copyPath': '复制路径',
|
|
162
|
+
'detail.copyMention': '复制 $mention',
|
|
163
|
+
'detail.copyPath': '复制路径',
|
|
164
|
+
'detail.copied': '已复制',
|
|
165
|
+
'diag.fix': '修复',
|
|
166
|
+
'diag.fixing': '修复中…',
|
|
167
|
+
'diag.fixed': '已修复',
|
|
168
|
+
'diag.fixConfirm': '用自动修复重写以下文件的 frontmatter?建议先备份:{path}',
|
|
169
|
+
'filter.invocation': '调用',
|
|
170
|
+
'filter.invocationAll': '全部调用',
|
|
171
|
+
'filter.modelOnly': '模型可调',
|
|
172
|
+
'filter.userOnly': '用户可调',
|
|
173
|
+
'filter.showing': '显示 {shown}/{total}(已启用){filtered}',
|
|
174
|
+
'filter.filteredSuffix': ' · 已筛选',
|
|
175
|
+
'filter.shortened': '短描述 {count} 条,悬浮看全文',
|
|
146
176
|
'delete.confirmTitle': '移入回收站?',
|
|
147
177
|
'delete.confirmText': '确定把「{name}」移入回收站吗?可随时恢复。',
|
|
148
178
|
'delete.confirm': '移入回收站',
|
|
@@ -291,12 +321,22 @@ export const en: Record<HubKey, string> = {
|
|
|
291
321
|
'market.branchTitle': 'Choose a branch',
|
|
292
322
|
'market.branchHint': 'This repo has no releases. Pick a branch to track (main by default):',
|
|
293
323
|
'market.branchConfirm': 'Confirm',
|
|
324
|
+
'market.versionTitle': 'Switch tracked version',
|
|
325
|
+
'market.versionText': 'Pick the version for {repo} to track; rescan and sync installed skills afterwards',
|
|
326
|
+
'market.versionCurrent': 'current {ref}',
|
|
327
|
+
'market.versionReleases': 'Releases',
|
|
328
|
+
'market.versionBranches': 'Branches',
|
|
329
|
+
'market.versionCustom': 'Custom (tag / branch / commit)',
|
|
330
|
+
'market.versionConfirm': 'Switch to {ref}',
|
|
331
|
+
'market.versionHint': 'Click to switch the tracked version',
|
|
332
|
+
'market.unpinned': 'unpinned',
|
|
294
333
|
'market.syncTitle': 'Update local skills',
|
|
295
334
|
'market.syncText': 'Market source aligned to {ref}. Update these local skills?',
|
|
296
335
|
'market.syncNone': 'No local skills tracked from this repo yet.',
|
|
297
336
|
'market.syncCancel': 'Skip',
|
|
298
337
|
'market.removeHint': 'Removing a source does not affect installed skills.',
|
|
299
338
|
'repo.ready': '{count} importable skills found',
|
|
339
|
+
'repo.truncated': '⚠ Repo too large, only partial skills shown (GitHub truncated)',
|
|
300
340
|
'repo.empty': 'No importable skills found.',
|
|
301
341
|
'repo.selectAll': 'Select all',
|
|
302
342
|
'repo.clearAll': 'Clear',
|
|
@@ -369,6 +409,26 @@ export const en: Record<HubKey, string> = {
|
|
|
369
409
|
'row.enable': 'Enable',
|
|
370
410
|
'row.delete': 'Move to trash',
|
|
371
411
|
'row.open': 'View {name} details (Enter)',
|
|
412
|
+
'row.duplicate': 'duplicate',
|
|
413
|
+
'row.duplicateHint': 'Multiple sources provide this name; the highest-priority one is shown',
|
|
414
|
+
'row.copy': 'Copy',
|
|
415
|
+
'row.copyMention': 'Copy $mention',
|
|
416
|
+
'row.copied': 'Copied',
|
|
417
|
+
'row.copyPath': 'Copy path',
|
|
418
|
+
'detail.copyMention': 'Copy $mention',
|
|
419
|
+
'detail.copyPath': 'Copy path',
|
|
420
|
+
'detail.copied': 'Copied',
|
|
421
|
+
'diag.fix': 'Fix',
|
|
422
|
+
'diag.fixing': 'Fixing…',
|
|
423
|
+
'diag.fixed': 'Fixed',
|
|
424
|
+
'diag.fixConfirm': 'Rewrite the frontmatter of this file with the auto-fix? Back it up first: {path}',
|
|
425
|
+
'filter.invocation': 'Invocation',
|
|
426
|
+
'filter.invocationAll': 'All',
|
|
427
|
+
'filter.modelOnly': 'Model',
|
|
428
|
+
'filter.userOnly': 'User',
|
|
429
|
+
'filter.showing': 'Showing {shown}/{total} (enabled){filtered}',
|
|
430
|
+
'filter.filteredSuffix': ' · filtered',
|
|
431
|
+
'filter.shortened': '{count} short descriptions, hover for full text',
|
|
372
432
|
'delete.confirmTitle': 'Move to trash?',
|
|
373
433
|
'delete.confirmText': 'Move "{name}" to trash? It can be restored anytime.',
|
|
374
434
|
'delete.confirm': 'Move to trash',
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* disabled section.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { JSX } from 'react'
|
|
7
|
+
import type { JSX, KeyboardEvent } from 'react'
|
|
8
8
|
import type { DisabledSkill, WritableRoot } from '../../protocol.ts'
|
|
9
9
|
import { tt } from '../helpers.ts'
|
|
10
10
|
import type { HubKey } from '../locales.ts'
|
|
@@ -15,18 +15,33 @@ function disabledSourceLabel(root: WritableRoot): string {
|
|
|
15
15
|
return tt(('badge.source.' + root) as HubKey)
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export function DisabledRow(props: { record: DisabledSkill; busy: boolean; onEnable: () => void }): JSX.Element {
|
|
19
|
-
const { record, busy, onEnable } = props
|
|
18
|
+
export function DisabledRow(props: { record: DisabledSkill; busy: boolean; onEnable: () => void; duplicate?: boolean; onOpen?: () => void }): JSX.Element {
|
|
19
|
+
const { record, busy, onEnable, duplicate, onOpen } = props
|
|
20
|
+
/** 键盘打开详情:Enter 或空格。仅当焦点在行本身(而非行内开关)时生效。 */
|
|
21
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
22
|
+
if (onOpen === undefined || event.target !== event.currentTarget) return
|
|
23
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
24
|
+
event.preventDefault()
|
|
25
|
+
onOpen()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
20
28
|
return (
|
|
21
|
-
<div
|
|
29
|
+
<div
|
|
30
|
+
className={css.row + (onOpen === undefined ? ' ' + css.rowStatic : '')}
|
|
31
|
+
role={onOpen === undefined ? undefined : 'button'}
|
|
32
|
+
tabIndex={onOpen === undefined ? undefined : 0}
|
|
33
|
+
aria-label={onOpen === undefined ? undefined : tt('row.open', { name: record.name })}
|
|
34
|
+
onClick={onOpen}
|
|
35
|
+
onKeyDown={onKeyDown}
|
|
36
|
+
>
|
|
22
37
|
<div className={css.rowMain}>
|
|
23
|
-
<div className={css.rowName}>{record.name}</div>
|
|
24
|
-
<div className={css.rowDesc}>{record.description}</div>
|
|
38
|
+
<div className={css.rowName}>{record.name}{duplicate === true ? <span className={css.badge + ' ' + css.statusError} style={{ marginLeft: 6 }} title={tt('row.duplicateHint')}>{tt('row.duplicate')}</span> : null}</div>
|
|
39
|
+
<div className={css.rowDesc} title={record.description}>{record.description}</div>
|
|
25
40
|
</div>
|
|
26
41
|
<span className={css.badges}>
|
|
27
42
|
<span className={css.badge + ' ' + css.badgeSource}>{disabledSourceLabel(record.root)}</span>
|
|
28
43
|
</span>
|
|
29
|
-
<button type='button' role='switch' aria-checked={false} aria-label={tt('row.enable')} className={css.switch} disabled={busy} onClick={onEnable}><span className={css.switchThumb} /></button>
|
|
44
|
+
<button type='button' role='switch' aria-checked={false} aria-label={tt('row.enable')} className={css.switch} disabled={busy} onClick={(event) => { event.stopPropagation(); onEnable() }}><span className={css.switchThumb} /></button>
|
|
30
45
|
</div>
|
|
31
46
|
)
|
|
32
47
|
}
|
|
@@ -78,7 +78,13 @@ export function MarketView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
78
78
|
<div className={css.rowMain}>
|
|
79
79
|
<div className={css.rowName}>
|
|
80
80
|
<a className={css.sourceLink} href={'https://github.com/' + record.repo} target='_blank' rel='noreferrer'>{record.repo}</a>
|
|
81
|
-
|
|
81
|
+
<button
|
|
82
|
+
type='button'
|
|
83
|
+
className={css.badge + ' ' + css.badgeSource}
|
|
84
|
+
style={{ cursor:'pointer', fontFamily:'inherit' }}
|
|
85
|
+
title={tt('market.versionHint')}
|
|
86
|
+
onClick={() => { void hub.openVersionDialog(record.repo) }}
|
|
87
|
+
>{record.ref ?? tt('market.unpinned')}</button>
|
|
82
88
|
{installedCount > 0 ? <span className={css.badge + ' ' + css.badgeCount}>{tt('market.installed', { count: installedCount })}</span> : null}
|
|
83
89
|
{hasSkillUpdate
|
|
84
90
|
? <span className={css.badge + ' ' + css.statusUpdated}>{tt('market.updatable', { count: skillCheck!.updated.length })}</span>
|
|
@@ -214,6 +220,7 @@ export function MarketView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
214
220
|
return (
|
|
215
221
|
<>
|
|
216
222
|
<p className={css.hintLine + ' ' + css.hintInline}>{tt('repo.ready', { count: entries.length })} · {repoDiscoverState.data.ref !== null ? `ref ${repoDiscoverState.data.ref}` : ''}</p>
|
|
223
|
+
{repoDiscoverState.data.truncated === true ? <div className={css.errorBanner} style={{ background:'rgba(210,153,34,0.08)', color:'#d29922', borderLeft:'3px solid #d29922' }}>{tt('repo.truncated')}</div> : null}
|
|
217
224
|
{entries.length === 0 ? <div className={css.empty}>{tt('repo.empty')}</div> : (
|
|
218
225
|
<>
|
|
219
226
|
{/* 搜索 + 过滤 + 统计 — 精简 */}
|
|
@@ -88,7 +88,7 @@ export function ScenesView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
88
88
|
<>
|
|
89
89
|
{skills.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />)}
|
|
90
90
|
{disabledMembers.map((record) => (
|
|
91
|
-
<DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} onEnable={() => { void enableDisabled(record) }} />
|
|
91
|
+
<DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} duplicate={hub.catalog?.duplicateNames?.includes(record.name) === true} onEnable={() => { void enableDisabled(record) }} onOpen={() => { void hub.openDetail(record.name) }} />
|
|
92
92
|
))}
|
|
93
93
|
</>
|
|
94
94
|
) : null}
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* panel stays the single owner of state.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { useEffect, useRef, useState, type JSX } from 'react'
|
|
9
9
|
import type { GroupsResponse, HubConfig, SkillDetail, SourceCheckResult, SourcesResponse } from '../../protocol.ts'
|
|
10
|
-
import { tt } from '../helpers.ts'
|
|
10
|
+
import { copyTextToClipboard, isDisplayNameDistinct, tt } from '../helpers.ts'
|
|
11
11
|
import css from './panel.module.css'
|
|
12
12
|
import { dotStyle, formatDateTime, shortSha } from './format.ts'
|
|
13
13
|
import { SourceStatusBadge } from './SourceStatusBadge.tsx'
|
|
@@ -23,6 +23,10 @@ export interface SkillDetailViewProps {
|
|
|
23
23
|
checkingSource: string | null
|
|
24
24
|
syncingSource: string | null
|
|
25
25
|
loading: boolean
|
|
26
|
+
/** Whether the skill is currently hub-disabled (served from the .disabled file). */
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
/** Re-enable a hub-disabled skill. */
|
|
29
|
+
onEnable?: () => void
|
|
26
30
|
onBack: () => void
|
|
27
31
|
/** Check one source repo for upstream updates. */
|
|
28
32
|
onCheck: (repo: string) => void
|
|
@@ -33,22 +37,38 @@ export interface SkillDetailViewProps {
|
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export function SkillDetailView(props: SkillDetailViewProps): JSX.Element {
|
|
36
|
-
const { detail, hubConfig, uses, groupsState, sourcesState, sourceCheck, checkingSource, syncingSource, loading, onBack, onCheck, onSync, onFollowDelete } = props
|
|
40
|
+
const { detail, hubConfig, uses, groupsState, sourcesState, sourceCheck, checkingSource, syncingSource, loading, disabled, onEnable, onBack, onCheck, onSync, onFollowDelete } = props
|
|
37
41
|
const detailSource = sourcesState?.sources.find((source) => source.skills.includes(detail.name))
|
|
38
42
|
const detailCheck = detailSource !== undefined ? sourceCheck[detailSource.repo] : undefined
|
|
43
|
+
const [copied, setCopied] = useState<string | null>(null)
|
|
44
|
+
const copyTimer = useRef<number | undefined>(undefined)
|
|
45
|
+
useEffect(() => () => { window.clearTimeout(copyTimer.current) }, [])
|
|
46
|
+
const copyText = (text: string, key: string): void => {
|
|
47
|
+
void copyTextToClipboard(text).then((ok) => {
|
|
48
|
+
if (!ok) return
|
|
49
|
+
setCopied(key)
|
|
50
|
+
window.clearTimeout(copyTimer.current)
|
|
51
|
+
copyTimer.current = window.setTimeout(() => { setCopied(null) }, 1200)
|
|
52
|
+
})
|
|
53
|
+
}
|
|
39
54
|
return (
|
|
40
55
|
<div className={css.panel}>
|
|
41
56
|
<div className={css.detailHead}>
|
|
42
57
|
<button type='button' className={css.back} onClick={onBack}>{tt('detail.back')}</button>
|
|
43
|
-
<span className={css.detailName}>
|
|
58
|
+
<span className={css.detailName} style={{ display:'inline-flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
|
|
44
59
|
{detail.name}
|
|
45
|
-
{
|
|
60
|
+
{isDisplayNameDistinct(detail.name, detail.displayName) ? <span className={css.displayName} style={{ fontSize:13, marginLeft:0 }}>{detail.displayName}</span> : null}
|
|
46
61
|
{detail.invocation.modelInvocable
|
|
47
62
|
? <span className={css.dot + ' ' + css.dotModel} style={dotStyle(hubConfig?.dotModelColor)} title={tt('legend.model')} />
|
|
48
63
|
: detail.invocation.userInvocable
|
|
49
64
|
? <span className={css.dot + ' ' + css.dotUser} style={dotStyle(hubConfig?.dotUserColor)} title={tt('legend.user')} />
|
|
50
65
|
: null}
|
|
51
66
|
</span>
|
|
67
|
+
<span className={css.actions} style={{ marginLeft: 'auto', gap: 6 }}>
|
|
68
|
+
{disabled === true && onEnable !== undefined ? <button type='button' className={css.opBtn} role='switch' aria-checked={false} aria-label={tt('row.enable')} onClick={onEnable}>{tt('row.enable')}</button> : null}
|
|
69
|
+
<button type='button' className={css.opBtn} onClick={() => { copyText('$' + detail.name, 'mention') }}>{copied === 'mention' ? tt('detail.copied') : tt('detail.copyMention')}</button>
|
|
70
|
+
{detail.path !== undefined ? <button type='button' className={css.opBtn} onClick={() => { const path = detail.path; if (path !== undefined) copyText(path, 'path') }}>{copied === 'path' ? tt('detail.copied') : tt('detail.copyPath')}</button> : null}
|
|
71
|
+
</span>
|
|
52
72
|
</div>
|
|
53
73
|
<div className={css.detailMeta}>
|
|
54
74
|
<div className={css.detailMetaLine}>{tt('detail.provider')}: {detail.provider}</div>
|
|
@@ -81,12 +101,16 @@ export function SkillDetailView(props: SkillDetailViewProps): JSX.Element {
|
|
|
81
101
|
<button type='button' className={css.opBtn} disabled={checkingSource !== null} onClick={() => { onCheck(detailSource.repo) }}>
|
|
82
102
|
{checkingSource === detailSource.repo ? tt('source.checking') : tt('source.check')}
|
|
83
103
|
</button>
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
{disabled !== true ? (
|
|
105
|
+
<>
|
|
106
|
+
<button type='button' className={css.opBtn} disabled={syncingSource !== null} onClick={() => { onSync(detailSource.repo, [detail.name]) }}>
|
|
107
|
+
{syncingSource === detailSource.repo ? tt('source.syncing') : tt('source.sync')}
|
|
108
|
+
</button>
|
|
109
|
+
{detailCheck?.deleted.includes(detail.name) === true
|
|
110
|
+
? <button type='button' className={css.opBtn + ' ' + css.opDanger} onClick={() => { onFollowDelete(detailSource.repo, [detail.name]) }}>{tt('source.followDelete')}</button>
|
|
111
|
+
: null}
|
|
112
|
+
</>
|
|
113
|
+
) : null}
|
|
90
114
|
</div>
|
|
91
115
|
</div>
|
|
92
116
|
) : (
|
|
@@ -24,7 +24,7 @@ import { TagEditorView } from './TagEditorView.tsx'
|
|
|
24
24
|
import { SourcesView } from './SourcesView.tsx'
|
|
25
25
|
import { ScenesView } from './ScenesView.tsx'
|
|
26
26
|
import { MarketView } from './MarketView.tsx'
|
|
27
|
-
import { BranchChoiceDialog, ConfirmDialog, ConflictDialog, MarketSyncDialog } from './dialogs.tsx'
|
|
27
|
+
import { BranchChoiceDialog, ConfirmDialog, ConflictDialog, MarketSyncDialog, VersionChoiceDialog } from './dialogs.tsx'
|
|
28
28
|
import { useSkillHub } from './useSkillHub.ts'
|
|
29
29
|
import css from './panel.module.css'
|
|
30
30
|
|
|
@@ -51,10 +51,12 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
|
|
|
51
51
|
checkUpdate, loadMarket, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, runDeleteSkill, runDeleteGroup,
|
|
52
52
|
runConfirmed, resolveConflict, confirmBranchChoice, confirmMarketSync, create, saveTag, deleteTag, enableDisabled,
|
|
53
53
|
} = hub
|
|
54
|
+
const { shortenedCount, fixingPaths, clearListFilters } = hub
|
|
54
55
|
|
|
55
56
|
// -------------------------------------------------------------- detail
|
|
56
57
|
|
|
57
58
|
if (detail !== null) {
|
|
59
|
+
const disabledRecord = catalog?.disabled.find((record) => record.name === detail.name)
|
|
58
60
|
return (
|
|
59
61
|
<SkillDetailView
|
|
60
62
|
detail={detail}
|
|
@@ -66,6 +68,8 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
|
|
|
66
68
|
checkingSource={checkingSource}
|
|
67
69
|
syncingSource={syncingSource}
|
|
68
70
|
loading={detailLoading}
|
|
71
|
+
disabled={disabledRecord !== undefined}
|
|
72
|
+
onEnable={disabledRecord !== undefined ? () => { void enableDisabled(disabledRecord).then(() => { setDetail(null) }) } : undefined}
|
|
69
73
|
onBack={() => { setDetail(null) }}
|
|
70
74
|
onCheck={(repo) => { void checkSources(repo) }}
|
|
71
75
|
onSync={requestSync}
|
|
@@ -126,6 +130,7 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
|
|
|
126
130
|
</span>
|
|
127
131
|
: null}
|
|
128
132
|
{catalog !== null && !catalog.complete ? <span className={css.hint}>{tt('panel.incomplete')}</span> : null}
|
|
133
|
+
{catalog !== null && (catalog.duplicateNames?.length ?? 0) > 0 ? <button type='button' className={css.opBtn} title={tt('row.duplicateHint')} onClick={() => { clearListFilters() }}>⚠ {tt('row.duplicate')}×{(catalog.duplicateNames ?? []).length}</button> : null}
|
|
129
134
|
<span className={css.actions}>
|
|
130
135
|
<button type='button' className={css.button} disabled={updateState.status === 'checking'} title={updateTitle} onClick={() => { void checkUpdate() }}>{updateState.status === 'checking' ? tt('update.checking') : tt('update.check')}</button>
|
|
131
136
|
{updateState.status === 'ready' && updateState.data.updateAvailable && updateState.data.url !== null
|
|
@@ -234,9 +239,20 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
|
|
|
234
239
|
<option value='added'>{tt('sort.added')}</option>
|
|
235
240
|
<option value='uses'>{tt('sort.uses')}</option>
|
|
236
241
|
</select>
|
|
242
|
+
<select className={css.select} value={hub.invocationFilter} onChange={(event) => { hub.setInvocationFilter(event.target.value as 'all' | 'model' | 'user') }}>
|
|
243
|
+
<option value='all'>{tt('filter.invocationAll')}</option>
|
|
244
|
+
<option value='model'>{tt('filter.modelOnly')}</option>
|
|
245
|
+
<option value='user'>{tt('filter.userOnly')}</option>
|
|
246
|
+
</select>
|
|
237
247
|
<input className={css.search} value={search} onChange={(event) => { setSearch(event.target.value) }} placeholder={tt('panel.search')} />
|
|
238
248
|
<button type='button' className={css.button + (editMode ? ' ' + css.primary : '')} style={{ marginLeft:'auto' }} onClick={() => setEditMode((v) => !v)}>{editMode ? '完成' : '编辑'}</button>
|
|
239
249
|
</div>
|
|
250
|
+
{catalog !== null && (filtered.length !== catalog.skills.length || hub.invocationFilter !== 'all' || sourceFilter !== 'all') ? (
|
|
251
|
+
<div className={css.hintLine} style={{ margin: '2px 2px 0', display:'flex', gap:8, flexWrap:'wrap' }}>
|
|
252
|
+
<span>{tt('filter.showing', { shown: filtered.length, total: catalog.skills.length, filtered: hub.invocationFilter !== 'all' || sourceFilter !== 'all' ? tt('filter.filteredSuffix') : '' })}</span>
|
|
253
|
+
{shortenedCount > 0 ? <span title={tt('filter.shortened', { count: shortenedCount })}>{tt('filter.shortened', { count: shortenedCount })}</span> : null}
|
|
254
|
+
</div>
|
|
255
|
+
) : null}
|
|
240
256
|
|
|
241
257
|
{filtered.length === 0 && search.trim() !== '' ? <div className={css.empty}>{tt('panel.empty')}</div> : null}
|
|
242
258
|
{filtered.length === 0 && search.trim() === '' && catalog.skills.length === 0 && catalog.disabled.length === 0 && catalog.diagnostics.length === 0
|
|
@@ -267,9 +283,20 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
|
|
|
267
283
|
<section className={css.section}>
|
|
268
284
|
<div className={css.sectionTitle}>{tt('panel.diagnostics')}</div>
|
|
269
285
|
{catalog.diagnostics.map((entry) => (
|
|
270
|
-
<div key={entry.path} className={css.diagRow}>
|
|
271
|
-
<div
|
|
272
|
-
|
|
286
|
+
<div key={entry.path} className={css.diagRow} style={{ display:'flex', alignItems:'flex-start', gap:10 }}>
|
|
287
|
+
<div style={{ flex:1, minWidth:0 }}>
|
|
288
|
+
<div className={css.diagPath}>{entry.path}</div>
|
|
289
|
+
<div className={css.diagReason}>{entry.reason}</div>
|
|
290
|
+
</div>
|
|
291
|
+
{entry.fixable === true ? (
|
|
292
|
+
<button
|
|
293
|
+
type='button'
|
|
294
|
+
className={css.opBtn}
|
|
295
|
+
disabled={fixingPaths.has(entry.path)}
|
|
296
|
+
onClick={() => { void hub.fixDiagnostic(entry.path) }}
|
|
297
|
+
style={{ flex:'none', alignSelf:'center' }}
|
|
298
|
+
>{fixingPaths.has(entry.path) ? tt('diag.fixing') : tt('diag.fix')}</button>
|
|
299
|
+
) : null}
|
|
273
300
|
</div>
|
|
274
301
|
))}
|
|
275
302
|
</section>
|
|
@@ -310,6 +337,17 @@ export function SkillHubPanel(props: SkillHubPanelProps): React.JSX.Element {
|
|
|
310
337
|
/>
|
|
311
338
|
) : null}
|
|
312
339
|
|
|
340
|
+
{hub.versionDialog !== null ? (
|
|
341
|
+
<VersionChoiceDialog
|
|
342
|
+
choice={hub.versionDialog}
|
|
343
|
+
busy={hub.versionBusy}
|
|
344
|
+
onSelect={(selected) => { hub.setVersionDialog({ ...hub.versionDialog!, selected }) }}
|
|
345
|
+
onCustom={(custom) => { hub.setVersionDialog({ ...hub.versionDialog!, custom }) }}
|
|
346
|
+
onCancel={() => { hub.setVersionDialog(null) }}
|
|
347
|
+
onConfirm={() => { void hub.confirmVersionDialog() }}
|
|
348
|
+
/>
|
|
349
|
+
) : null}
|
|
350
|
+
|
|
313
351
|
{marketSyncDialog !== null ? (
|
|
314
352
|
<MarketSyncDialog
|
|
315
353
|
dialog={marketSyncDialog}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { JSX, KeyboardEvent } from 'react'
|
|
7
7
|
import type { CatalogSkill } from '../../protocol.ts'
|
|
8
8
|
import { IconTrashOutline16 } from '../icons.tsx'
|
|
9
|
-
import { tt } from '../helpers.ts'
|
|
9
|
+
import { isDisplayNameDistinct, tt } from '../helpers.ts'
|
|
10
10
|
import { dotStyle, relativeTimeText } from './format.ts'
|
|
11
11
|
import type { SkillHubState } from './useSkillHub.ts'
|
|
12
12
|
import css from './panel.module.css'
|
|
@@ -16,6 +16,7 @@ export function SkillRow(props: { skill: CatalogSkill; hub: SkillHubState }): JS
|
|
|
16
16
|
const stat = hub.uses.get(skill.name)
|
|
17
17
|
const count = stat?.count ?? 0
|
|
18
18
|
const lastUsed = stat?.lastUsed
|
|
19
|
+
const isDuplicate = hub.catalog?.duplicateNames?.includes(skill.name) === true
|
|
19
20
|
/** 单一状态圆点:模型可调 → 蓝;否则用户可调 → 绿。与聊天 / 菜单同规则。 */
|
|
20
21
|
const dot = skill.invocation.modelInvocable
|
|
21
22
|
? <span className={css.dot + ' ' + css.dotModel} style={dotStyle(hub.hubConfig?.dotModelColor)} title={tt('legend.model')} />
|
|
@@ -42,11 +43,13 @@ export function SkillRow(props: { skill: CatalogSkill; hub: SkillHubState }): JS
|
|
|
42
43
|
<div className={css.rowMain}>
|
|
43
44
|
<div className={css.rowName}>
|
|
44
45
|
<span className={css.rowNameText}>{skill.name}</span>
|
|
46
|
+
{isDisplayNameDistinct(skill.name, skill.displayName) ? <span className={css.displayName} title={skill.displayName}>{skill.displayName}</span> : null}
|
|
45
47
|
{count > 0 && hub.hubConfig?.showUseCount !== false ? <span className={css.useCount}>{count}</span> : null}
|
|
46
48
|
{dot}
|
|
49
|
+
{isDuplicate ? <span className={css.badge + ' ' + css.statusError} title={tt('row.duplicateHint')}>{tt('row.duplicate')}</span> : null}
|
|
47
50
|
{lastUsed !== undefined && hub.hubConfig?.showUseTime !== false ? <span className={css.useTime}>{relativeTimeText(lastUsed)}</span> : null}
|
|
48
51
|
</div>
|
|
49
|
-
<div className={css.rowDesc}>{skill.description}</div>
|
|
52
|
+
<div className={css.rowDesc} title={skill.description}>{skill.shortDescription ?? skill.description}</div>
|
|
50
53
|
</div>
|
|
51
54
|
{skill.writable
|
|
52
55
|
? <>
|
|
@@ -61,21 +61,6 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
61
61
|
next.splice(to, 0, moved)
|
|
62
62
|
void hub.reorderSourceGroups(next)
|
|
63
63
|
}
|
|
64
|
-
// 兼容旧逻辑:若用户拖的是 collection 且未使用顶层排序,回退到 collectionReorder(保留旧数据兼容)
|
|
65
|
-
const handleCollectionDropLegacy = (targetName: string): void => {
|
|
66
|
-
// 仅当顶层排序未启用时(storedTopOrder 为空)才用旧接口
|
|
67
|
-
if ((groupsState?.sourceGroupOrder?.length ?? 0) > 0) { handleTopDrop('col:' + targetName); return }
|
|
68
|
-
const dragCol = topDragKey !== null && topDragKey.startsWith('col:') ? topDragKey.slice(4) : null
|
|
69
|
-
if (dragCol === null || dragCol === targetName || groupsState === null) return
|
|
70
|
-
const names = groupsState.collections.map((c) => c.name)
|
|
71
|
-
const from = names.indexOf(dragCol)
|
|
72
|
-
const to = names.indexOf(targetName)
|
|
73
|
-
if (from === -1 || to === -1) return
|
|
74
|
-
const next = [...names]
|
|
75
|
-
const [moved] = next.splice(from, 1)
|
|
76
|
-
next.splice(to, 0, moved)
|
|
77
|
-
void hub.reorderCollections(next)
|
|
78
|
-
}
|
|
79
64
|
|
|
80
65
|
// 空状态:没有任何分组时提示
|
|
81
66
|
const isEmptyTop = !hasProject && collections.length === 0 && !hasPersonal
|
|
@@ -230,7 +215,7 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
230
215
|
<>
|
|
231
216
|
{skills.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />)}
|
|
232
217
|
{disabledMembers.map((record) => (
|
|
233
|
-
<DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} onEnable={() => { void enableDisabled(record) }} />
|
|
218
|
+
<DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} duplicate={catalog?.duplicateNames?.includes(record.name) === true} onEnable={() => { void enableDisabled(record) }} onOpen={() => { void hub.openDetail(record.name) }} />
|
|
234
219
|
))}
|
|
235
220
|
</>
|
|
236
221
|
) : null}
|
|
@@ -272,7 +257,7 @@ export function SourcesView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
272
257
|
{!collapsed ? (
|
|
273
258
|
<>
|
|
274
259
|
{uncategorized.map((skill) => <SkillRow key={skill.name} skill={skill} hub={hub} />)}
|
|
275
|
-
{personalDisabled.map((record) => (<DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} onEnable={() => { void enableDisabled(record) }} />))}
|
|
260
|
+
{personalDisabled.map((record) => (<DisabledRow key={record.name} record={record} busy={busyNames.has(record.name)} duplicate={catalog?.duplicateNames?.includes(record.name) === true} onEnable={() => { void enableDisabled(record) }} onOpen={() => { void hub.openDetail(record.name) }} />))}
|
|
276
261
|
</>
|
|
277
262
|
) : null}
|
|
278
263
|
</section>
|
|
@@ -35,6 +35,17 @@ export interface BranchChoiceState {
|
|
|
35
35
|
selected: string
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Version picker for a market source: releases + branches + custom ref. */
|
|
39
|
+
export interface VersionChoiceState {
|
|
40
|
+
repo: string
|
|
41
|
+
current?: string
|
|
42
|
+
releases: string[]
|
|
43
|
+
branches: string[]
|
|
44
|
+
selected: string
|
|
45
|
+
custom: string
|
|
46
|
+
loading: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
/** Post-sync skill update dialog: which tracked skills to refresh. */
|
|
39
50
|
export interface MarketSyncDialogState {
|
|
40
51
|
repo: string
|
|
@@ -148,6 +159,56 @@ export function BranchChoiceDialog(props: {
|
|
|
148
159
|
)
|
|
149
160
|
}
|
|
150
161
|
|
|
162
|
+
/** Version picker: switch a market source's tracked ref (release / branch / custom). */
|
|
163
|
+
export function VersionChoiceDialog(props: {
|
|
164
|
+
choice: VersionChoiceState
|
|
165
|
+
busy: boolean
|
|
166
|
+
onSelect: (ref: string) => void
|
|
167
|
+
onCustom: (custom: string) => void
|
|
168
|
+
onCancel: () => void
|
|
169
|
+
onConfirm: () => void
|
|
170
|
+
}): JSX.Element {
|
|
171
|
+
const { choice, busy, onSelect, onCustom, onCancel, onConfirm } = props
|
|
172
|
+
const effective = choice.custom.trim() !== '' ? choice.custom.trim() : choice.selected
|
|
173
|
+
return (
|
|
174
|
+
<DialogShell onClose={onCancel}>
|
|
175
|
+
<h3 className={css.dialogTitle}>{tt('market.versionTitle')}</h3>
|
|
176
|
+
<p className={css.dialogText}>{tt('market.versionText', { repo: choice.repo })}{choice.current !== undefined ? ` (${tt('market.versionCurrent', { ref: choice.current })})` : ''}</p>
|
|
177
|
+
{choice.loading ? <p className={css.dialogText}>{tt('market.scanning')}</p> : (
|
|
178
|
+
<>
|
|
179
|
+
{choice.releases.length > 0 ? (
|
|
180
|
+
<>
|
|
181
|
+
<p className={css.dialogText} style={{ marginBottom: 4 }}>{tt('market.versionReleases')}</p>
|
|
182
|
+
<select className={css.select + ' ' + css.dialogSelect} value={choice.releases.includes(choice.selected) ? choice.selected : choice.releases[0]}
|
|
183
|
+
onChange={(event) => { onSelect(event.target.value); onCustom('') }}>
|
|
184
|
+
{choice.releases.map((tag) => <option key={tag} value={tag}>{tag}</option>)}
|
|
185
|
+
</select>
|
|
186
|
+
</>
|
|
187
|
+
) : null}
|
|
188
|
+
{choice.branches.length > 0 ? (
|
|
189
|
+
<>
|
|
190
|
+
<p className={css.dialogText} style={{ marginBottom: 4 }}>{tt('market.versionBranches')}</p>
|
|
191
|
+
<select className={css.select + ' ' + css.dialogSelect} value={choice.branches.includes(choice.selected) ? choice.selected : choice.branches[0]}
|
|
192
|
+
onChange={(event) => { onSelect(event.target.value); onCustom('') }}>
|
|
193
|
+
{choice.branches.map((branch) => <option key={branch} value={branch}>{branch}</option>)}
|
|
194
|
+
</select>
|
|
195
|
+
</>
|
|
196
|
+
) : null}
|
|
197
|
+
<p className={css.dialogText} style={{ marginBottom: 4 }}>{tt('market.versionCustom')}</p>
|
|
198
|
+
<input className={css.input + ' ' + css.dialogSelect} value={choice.custom}
|
|
199
|
+
onChange={(event) => { onCustom(event.target.value) }} placeholder='v1.2.3 / main / abc1234' />
|
|
200
|
+
</>
|
|
201
|
+
)}
|
|
202
|
+
<div className={css.dialogActions}>
|
|
203
|
+
<button type='button' className={css.button} onClick={onCancel}>{tt('form.cancel')}</button>
|
|
204
|
+
<button type='button' className={css.button + ' ' + css.primary} disabled={busy || choice.loading || effective === ''} onClick={onConfirm}>
|
|
205
|
+
{busy ? tt('source.syncing') : tt('market.versionConfirm', { ref: effective })}
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
</DialogShell>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
151
212
|
/** Post-sync selection: which tracked skills to batch-update to the new version. */
|
|
152
213
|
export function MarketSyncDialog(props: {
|
|
153
214
|
dialog: MarketSyncDialogState
|
|
@@ -44,15 +44,9 @@
|
|
|
44
44
|
.rowNameText { font-weight: 600; }
|
|
45
45
|
.rowDesc { font-size: 12.5px; opacity: .52; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
46
46
|
|
|
47
|
-
/* Quiet inline metadata (e.g. invocation count) — no chips. */
|
|
48
|
-
.rowMeta { font-size: 11.5px; opacity: .45; flex: none; white-space: nowrap; }
|
|
49
|
-
|
|
50
47
|
/* No badge chips on list rows; keep only for the detail head. */
|
|
51
48
|
.badges { display: flex; gap: 6px; align-items: center; flex: none; }
|
|
52
49
|
.badge { font-size: 10.5px; padding: 1px 7px; border-radius: 999px; border: 0.5px solid rgba(128,128,128,.3); opacity: .8; white-space: nowrap; }
|
|
53
|
-
.badgeModel { color: var(--hub-model); border-color: currentColor; }
|
|
54
|
-
.badgeUser { color: var(--hub-user); border-color: currentColor; }
|
|
55
|
-
.badgeUses { color: #d29922; border-color: currentColor; }
|
|
56
50
|
.badgeReadonly { opacity: .5; }
|
|
57
51
|
|
|
58
52
|
/* iOS-style switch (kept, already matches the system look). */
|
|
@@ -102,6 +96,7 @@
|
|
|
102
96
|
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-left: 5px; vertical-align: middle; }
|
|
103
97
|
.dotModel { background: var(--hub-model); }
|
|
104
98
|
.dotUser { background: var(--hub-user); }
|
|
99
|
+
.displayName { font-size: 11px; opacity: .55; margin-left: 6px; font-weight: 400; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; min-width: 0; flex-shrink: 1; }
|
|
105
100
|
.legend { display: flex; align-items: center; gap: 12px; font-size: 11.5px; opacity: .55; flex-wrap: wrap; padding: 2px 2px 0; }
|
|
106
101
|
.legendItem { display: inline-flex; align-items: center; gap: 4px; }
|
|
107
102
|
.legendItem .dot { margin-left: 0; }
|
|
@@ -110,7 +105,6 @@
|
|
|
110
105
|
/* Chips become quiet text pills (no colored borders). */
|
|
111
106
|
.badgeSource { background: rgba(128,128,128,.1); border: none; opacity: .72; }
|
|
112
107
|
.badgeCount { background: rgba(128,128,128,.1); border: none; opacity: .72; }
|
|
113
|
-
.badgeDisabled { background: rgba(128,128,128,.1); border: none; opacity: .58; }
|
|
114
108
|
|
|
115
109
|
/* Collapsible drawer headers (groups view). */
|
|
116
110
|
.disclosure { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; padding: 2px 0; border: none; background: none; color: inherit; cursor: pointer; text-align: left; min-height: 24px; }
|
|
@@ -210,7 +204,6 @@
|
|
|
210
204
|
.rowMuted { cursor: default; opacity: .55; }
|
|
211
205
|
.actionsPadded { padding: 8px 12px; }
|
|
212
206
|
.actionsTop { margin-top: 8px; }
|
|
213
|
-
.actionsBottom { margin-bottom: 8px; }
|
|
214
207
|
/* Group-header last-used time: inline after the count chip (overrides useTime's right-align). */
|
|
215
208
|
.groupTime { margin-left: 6px; }
|
|
216
209
|
.sectionHeadRow { display: flex; align-items: center; gap: 8px; }
|