dsh-skill-hub 0.2.6 → 0.3.1
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 +68 -229
- package/README.zh.md +67 -203
- package/lib/client.js +1955 -413
- package/lib/client.js.map +1 -1
- package/lib/index.js +441 -84
- package/lib/types/client/SkillHubSettingsCard.d.ts +1 -1
- package/lib/types/client/api.d.ts +12 -2
- package/lib/types/client/index.d.ts +6 -1
- package/lib/types/client/locales.d.ts +13 -0
- package/lib/types/client/panel/ScenesView.d.ts +1 -1
- package/lib/types/client/panel/SourcesView.d.ts +1 -1
- package/lib/types/client/panel/useSkillHub.d.ts +20 -2
- package/lib/types/client/settings-form.d.ts +1 -1
- package/lib/types/client/slash-dots.d.ts +2 -1
- package/lib/types/index.d.ts +2 -1
- package/lib/types/protocol.d.ts +93 -6
- package/lib/types/repo.d.ts +18 -11
- package/lib/types/store.d.ts +14 -2
- package/package.json +38 -29
- package/src/client/SkillHubSettingsCard.tsx +1 -1
- package/src/client/api.ts +61 -1
- package/src/client/index.tsx +10 -1
- package/src/client/locales.ts +26 -0
- package/src/client/panel/MarketView.tsx +201 -47
- package/src/client/panel/ScenesView.tsx +27 -2
- package/src/client/panel/SkillHubPanel.tsx +18 -19
- package/src/client/panel/SkillRow.tsx +8 -8
- package/src/client/panel/SourcesView.tsx +203 -26
- package/src/client/panel/panel.module.css +17 -6
- package/src/client/panel/useSkillHub.ts +219 -12
- package/src/client/settings-form.ts +2 -1
- package/src/client/slash-dots.test.ts +24 -6
- package/src/client/slash-dots.tsx +10 -1
- package/src/index.ts +20 -2
- package/src/protocol.ts +89 -6
- package/src/repo.test.ts +61 -19
- package/src/repo.ts +86 -23
- package/src/routes.test.ts +18 -4
- package/src/routes.ts +269 -47
- package/src/skillfs.ts +17 -4
- package/src/store.ts +72 -4
package/src/client/api.ts
CHANGED
|
@@ -5,10 +5,15 @@
|
|
|
5
5
|
import {
|
|
6
6
|
SKILL_HUB_API,
|
|
7
7
|
type CatalogResponse,
|
|
8
|
+
type CollectionGroup,
|
|
8
9
|
type ConfigRequest,
|
|
9
10
|
type ConfigResponse,
|
|
10
11
|
type CreateRequest,
|
|
11
12
|
type CreateResponse,
|
|
13
|
+
type CollectionReorderRequest,
|
|
14
|
+
type CollectionReorderResponse,
|
|
15
|
+
type SourceGroupReorderRequest,
|
|
16
|
+
type SourceGroupReorderResponse,
|
|
12
17
|
type MarketCheckResponse,
|
|
13
18
|
type MarketSourceRefRequest,
|
|
14
19
|
type MarketSourceRequest,
|
|
@@ -16,12 +21,15 @@ import {
|
|
|
16
21
|
type MarketSourcesResponse,
|
|
17
22
|
type MarketSyncResponse,
|
|
18
23
|
type RepoDiscoverResponse,
|
|
24
|
+
type RepoImportCancelResponse,
|
|
25
|
+
type RepoImportProgressResponse,
|
|
19
26
|
type RepoImportRequest,
|
|
20
27
|
type RepoImportResponse,
|
|
21
28
|
type SkillDetail,
|
|
22
29
|
type SkillDetailResponse,
|
|
23
30
|
type SkillDeleteRequest,
|
|
24
31
|
type SkillDeleteResponse,
|
|
32
|
+
type SkillTag,
|
|
25
33
|
type SourceCheckRequest,
|
|
26
34
|
type SourceCheckResponse,
|
|
27
35
|
type SourceDeleteRequest,
|
|
@@ -33,6 +41,8 @@ import {
|
|
|
33
41
|
type SourceSyncRequest,
|
|
34
42
|
type SourceSyncResponse,
|
|
35
43
|
type StatsResponse,
|
|
44
|
+
type TagReorderRequest,
|
|
45
|
+
type TagReorderResponse,
|
|
36
46
|
type ToggleBatchRequest,
|
|
37
47
|
type ToggleBatchResponse,
|
|
38
48
|
type UpdateCheckResponse,
|
|
@@ -206,7 +216,7 @@ export class SkillHubApi {
|
|
|
206
216
|
return readJson<RepoDiscoverResponse>(response)
|
|
207
217
|
}
|
|
208
218
|
|
|
209
|
-
/** Install selected repo skills into the user-dsh root (records the source). */
|
|
219
|
+
/** Install selected repo skills into the user-dsh root (records the source). Returns jobId for polling. */
|
|
210
220
|
async repoImport(repo: string, paths: string[], ref?: string): Promise<RepoImportResponse> {
|
|
211
221
|
const payload: RepoImportRequest = { repo, paths, ...(ref !== undefined && ref !== '' ? { ref } : {}) }
|
|
212
222
|
const response = await fetchWithTimeout(SKILL_HUB_API.repoImport, {
|
|
@@ -217,6 +227,23 @@ export class SkillHubApi {
|
|
|
217
227
|
return readJson<RepoImportResponse>(response)
|
|
218
228
|
}
|
|
219
229
|
|
|
230
|
+
/** Poll import job progress (B方案轮询) */
|
|
231
|
+
async repoImportProgress(jobId: string): Promise<RepoImportProgressResponse> {
|
|
232
|
+
const url = SKILL_HUB_API.repoImportProgress + '?jobId=' + encodeURIComponent(jobId)
|
|
233
|
+
const response = await fetchWithTimeout(url)
|
|
234
|
+
return readJson<RepoImportProgressResponse>(response)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Cancel a running import job (选项2:唯有取消才停) */
|
|
238
|
+
async repoImportCancel(jobId: string): Promise<RepoImportCancelResponse> {
|
|
239
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.repoImportCancel, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { 'content-type': 'application/json' },
|
|
242
|
+
body: JSON.stringify({ jobId }),
|
|
243
|
+
})
|
|
244
|
+
return readJson<RepoImportCancelResponse>(response)
|
|
245
|
+
}
|
|
246
|
+
|
|
220
247
|
/** Read the hub's runtime config (effective values + saved overrides). */
|
|
221
248
|
async config(): Promise<ConfigResponse> {
|
|
222
249
|
const response = await fetchWithTimeout(SKILL_HUB_API.config)
|
|
@@ -272,6 +299,39 @@ export class SkillHubApi {
|
|
|
272
299
|
return body.tags
|
|
273
300
|
}
|
|
274
301
|
|
|
302
|
+
/** 拖拽重排场景分组 */
|
|
303
|
+
async reorderTags(orderedIds: string[]): Promise<SkillTag[]> {
|
|
304
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.tagReorder, {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: { 'content-type': 'application/json' },
|
|
307
|
+
body: JSON.stringify({ orderedIds } satisfies TagReorderRequest),
|
|
308
|
+
})
|
|
309
|
+
const body = await readJson<TagReorderResponse>(response)
|
|
310
|
+
return body.tags
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 拖拽重排来源集合 */
|
|
314
|
+
async reorderCollections(orderedNames: string[]): Promise<CollectionGroup[]> {
|
|
315
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.collectionReorder, {
|
|
316
|
+
method: 'POST',
|
|
317
|
+
headers: { 'content-type': 'application/json' },
|
|
318
|
+
body: JSON.stringify({ orderedNames } satisfies CollectionReorderRequest),
|
|
319
|
+
})
|
|
320
|
+
const body = await readJson<CollectionReorderResponse>(response)
|
|
321
|
+
return body.collections
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 拖拽重排来源顶层分组(project / collections / personal) */
|
|
325
|
+
async reorderSourceGroups(orderedKeys: string[]): Promise<string[]> {
|
|
326
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.sourceGroupReorder, {
|
|
327
|
+
method: 'POST',
|
|
328
|
+
headers: { 'content-type': 'application/json' },
|
|
329
|
+
body: JSON.stringify({ orderedKeys } satisfies SourceGroupReorderRequest),
|
|
330
|
+
})
|
|
331
|
+
const body = await readJson<SourceGroupReorderResponse>(response)
|
|
332
|
+
return body.order
|
|
333
|
+
}
|
|
334
|
+
|
|
275
335
|
/** 来源列表 + 派生 origin 映射 + 集合组 + 回收站。 */
|
|
276
336
|
async sources(): Promise<SourcesResponse> {
|
|
277
337
|
const response = await fetchWithTimeout(SKILL_HUB_API.sources)
|
package/src/client/index.tsx
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* internal.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import type { ClientContext } from '@deepseek-ai/
|
|
25
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
26
26
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
27
27
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
28
28
|
// Type-only: pulls the settings-scope service merge and the settings.section slot.
|
|
@@ -33,6 +33,15 @@ import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
33
33
|
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
|
34
34
|
// Type-only: pulls the Context merge for ctx.inputTriggers (slash-dots wiring).
|
|
35
35
|
import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
36
|
+
// Type-only: pulls the connection/reset event.
|
|
37
|
+
import type {} from '@deepseek-ai/dsh-client-connection/client'
|
|
38
|
+
|
|
39
|
+
// 0.1.2-alpha.2: slots service moved to ui-renderer; provide local augmentation for build.
|
|
40
|
+
declare module '@deepseek-ai/cordis' {
|
|
41
|
+
interface Context {
|
|
42
|
+
slots: any
|
|
43
|
+
}
|
|
44
|
+
}
|
|
36
45
|
import type { HubSettingsValue } from '../protocol.ts'
|
|
37
46
|
import { SkillHubApi } from './api.ts'
|
|
38
47
|
import { en, zh, type HubKey } from './locales.ts'
|
package/src/client/locales.ts
CHANGED
|
@@ -53,6 +53,11 @@ export const zh = {
|
|
|
53
53
|
'market.scan': '扫描',
|
|
54
54
|
'market.scanning': '扫描中…',
|
|
55
55
|
'market.check': '检查',
|
|
56
|
+
'market.checkUpdate': '检查更新',
|
|
57
|
+
'market.checking': '检查中…',
|
|
58
|
+
'market.upToDate': '已是最新',
|
|
59
|
+
'market.update': '更新',
|
|
60
|
+
'market.updateCount': '更新 {count} 个',
|
|
56
61
|
'market.sync': '同步',
|
|
57
62
|
'market.syncing': '同步中…',
|
|
58
63
|
'market.updated': '有更新',
|
|
@@ -76,8 +81,12 @@ export const zh = {
|
|
|
76
81
|
'repo.imported': '导入成功 {count} 个',
|
|
77
82
|
'repo.skippedExisting': '跳过已存在 {count} 个',
|
|
78
83
|
'repo.failed': '失败 {count} 个',
|
|
84
|
+
'repo.cancel': '取消',
|
|
85
|
+
'repo.cancelled': '已取消 · 已导入 {imported}/{total} 已保留,临时文件已清理',
|
|
86
|
+
'repo.importingCurrent': '正在导入: {name} ({done}/{total})',
|
|
79
87
|
'repo.root.skills': '技能 skills',
|
|
80
88
|
'repo.root.designTemplates': '模板 design-templates',
|
|
89
|
+
'repo.root.generic': '{root}',
|
|
81
90
|
'update.check': '检查更新',
|
|
82
91
|
'update.checking': '检查中…',
|
|
83
92
|
'update.upToDate': '已是最新版本 v{version}',
|
|
@@ -164,6 +173,10 @@ export const zh = {
|
|
|
164
173
|
'source.followDelete': '跟进删除',
|
|
165
174
|
'source.deleteConfirmTitle': '跟进上游删除?',
|
|
166
175
|
'source.deleteConfirmText': '以下技能已从上游仓库移除,本地目录将移入回收站(可恢复):',
|
|
176
|
+
'source.deleteGroup': '删除整组',
|
|
177
|
+
'source.deleteGroupHint': '删除该分组下的全部 {count} 个技能',
|
|
178
|
+
'source.deleteGroupTitle': '删除整组?',
|
|
179
|
+
'source.deleteGroupText': '将把分组“{name}”下的全部 {count} 个技能移入回收站(可恢复),只读技能会跳过:',
|
|
167
180
|
'source.trash': '回收站',
|
|
168
181
|
'source.restore': '恢复',
|
|
169
182
|
'source.clearTrash': '清空回收站',
|
|
@@ -266,6 +279,11 @@ export const en: Record<HubKey, string> = {
|
|
|
266
279
|
'market.scan': 'Scan',
|
|
267
280
|
'market.scanning': 'Scanning…',
|
|
268
281
|
'market.check': 'Check',
|
|
282
|
+
'market.checkUpdate': 'Check for updates',
|
|
283
|
+
'market.checking': 'Checking…',
|
|
284
|
+
'market.upToDate': 'Up to date',
|
|
285
|
+
'market.update': 'Update',
|
|
286
|
+
'market.updateCount': 'Update {count}',
|
|
269
287
|
'market.sync': 'Sync',
|
|
270
288
|
'market.syncing': 'Syncing…',
|
|
271
289
|
'market.updated': 'Updates available',
|
|
@@ -289,8 +307,12 @@ export const en: Record<HubKey, string> = {
|
|
|
289
307
|
'repo.imported': 'Imported {count}',
|
|
290
308
|
'repo.skippedExisting': 'Skipped existing {count}',
|
|
291
309
|
'repo.failed': 'Failed {count}',
|
|
310
|
+
'repo.cancel': 'Cancel',
|
|
311
|
+
'repo.cancelled': 'Cancelled · {imported}/{total} kept, temp files cleaned',
|
|
312
|
+
'repo.importingCurrent': 'Importing: {name} ({done}/{total})',
|
|
292
313
|
'repo.root.skills': 'Skills',
|
|
293
314
|
'repo.root.designTemplates': 'Templates design-templates',
|
|
315
|
+
'repo.root.generic': '{root}',
|
|
294
316
|
'update.check': 'Check for updates',
|
|
295
317
|
'update.checking': 'Checking…',
|
|
296
318
|
'update.upToDate': 'Up to date: v{version}',
|
|
@@ -377,6 +399,10 @@ export const en: Record<HubKey, string> = {
|
|
|
377
399
|
'source.followDelete': 'Follow deletion',
|
|
378
400
|
'source.deleteConfirmTitle': 'Follow upstream deletion?',
|
|
379
401
|
'source.deleteConfirmText': 'These skills were removed upstream; their local directories move to trash (restorable):',
|
|
402
|
+
'source.deleteGroup': 'Delete group',
|
|
403
|
+
'source.deleteGroupHint': 'Delete all {count} skills in this group',
|
|
404
|
+
'source.deleteGroupTitle': 'Delete group?',
|
|
405
|
+
'source.deleteGroupText': 'All {count} skills in group "{name}" will be moved to trash (restorable); read-only skills will be skipped:',
|
|
380
406
|
'source.trash': 'Trash',
|
|
381
407
|
'source.restore': 'Restore',
|
|
382
408
|
'source.clearTrash': 'Empty trash',
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* update-all actions and the repo scan result with the importable checklist.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { Fragment, type JSX } from 'react'
|
|
8
|
+
import { Fragment, useEffect, useMemo, useState, type JSX } from 'react'
|
|
9
9
|
import { tt } from '../helpers.ts'
|
|
10
10
|
import { MARKET_CATALOG } from '../market-catalog.ts'
|
|
11
|
-
import type { MarketSourceRecord } from '../../protocol.ts'
|
|
11
|
+
import type { MarketSourceRecord, RepoSkillEntry } from '../../protocol.ts'
|
|
12
12
|
import { ConfirmDialog } from './dialogs.tsx'
|
|
13
13
|
import type { SkillHubState } from './useSkillHub.ts'
|
|
14
14
|
import css from './panel.module.css'
|
|
@@ -19,19 +19,60 @@ function formatBytes(bytes: number): string {
|
|
|
19
19
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
|
20
20
|
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
|
21
21
|
}
|
|
22
|
+
function formatSpeed(bps?: number): string {
|
|
23
|
+
if (bps === undefined || bps <= 0) return ''
|
|
24
|
+
if (bps < 1024) return bps + ' B/s'
|
|
25
|
+
if (bps < 1024 * 1024) return (bps / 1024).toFixed(1) + ' KB/s'
|
|
26
|
+
return (bps / 1024 / 1024).toFixed(1) + ' MB/s'
|
|
27
|
+
}
|
|
22
28
|
|
|
23
29
|
export function MarketView(props: { hub: SkillHubState }): JSX.Element {
|
|
24
30
|
const { hub } = props
|
|
25
|
-
const { marketState, marketCheck, sourceCheck, sourcesState, repoDiscoverState, scanningRepo, repoSelected, setRepoSelected, repoImporting, repoResult, newSourceName, setNewSourceName, syncingMarket, tagBusy, addSource, addMarketSource, removeMarketSource, scanRepo, checkMarket, checkSources, syncMarketSource, toggleRepoSelected, importRepo, updateAllDialog, setUpdateAllDialog, updateAll } = hub
|
|
31
|
+
const { marketState, marketCheck, sourceCheck, sourcesState, repoDiscoverState, scanningRepo, repoSelected, setRepoSelected, repoImporting, repoResult, newSourceName, setNewSourceName, syncingMarket, tagBusy, checkingSource, addSource, addMarketSource, removeMarketSource, scanRepo, checkMarket, checkSources, syncMarketSource, toggleRepoSelected, importRepo, cancelImport, updateAllDialog, setUpdateAllDialog, updateAll } = hub
|
|
26
32
|
/** 有可更新技能的来源(全部更新按钮与确认列表共用)。 */
|
|
27
33
|
const updatableRepos = Object.entries(sourceCheck).filter(([, check]) => check.changed && check.updated.length > 0)
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
// 276 列表本地状态:搜索 / 过滤 / 分页(精简:扁平列表,无 A-Z 分组)
|
|
36
|
+
const [repoSearch, setRepoSearch] = useState('')
|
|
37
|
+
const [repoFilter, setRepoFilter] = useState<string>('all')
|
|
38
|
+
const [visibleCount, setVisibleCount] = useState(50)
|
|
39
|
+
useEffect(() => { setVisibleCount(50) }, [repoDiscoverState, repoSearch, repoFilter])
|
|
40
|
+
|
|
41
|
+
/** 一个已添加来源的完整行:状态徽章 + 操作按钮。B 方案:扫描主按钮 + 智能更新按钮(检查/更新二合一)+ 溢出移除 */
|
|
30
42
|
const sourceRow = (record: MarketSourceRecord): JSX.Element => {
|
|
31
43
|
const releaseCheck = marketCheck[record.repo]
|
|
32
44
|
const skillCheck = sourceCheck[record.repo]
|
|
33
45
|
const installedCount = sourcesState?.sources.find((source) => source.repo === record.repo)?.skills.length ?? 0
|
|
34
46
|
const scanning = scanningRepo === record.repo
|
|
47
|
+
const syncing = syncingMarket === record.repo
|
|
48
|
+
const checking = checkingSource === record.repo
|
|
49
|
+
const hasSkillUpdate = skillCheck?.changed === true && skillCheck.updated.length > 0
|
|
50
|
+
const hasReleaseUpdate = releaseCheck?.updateAvailable === true
|
|
51
|
+
const hasUpdate = hasSkillUpdate || hasReleaseUpdate
|
|
52
|
+
const isChecked = releaseCheck !== undefined || skillCheck !== undefined
|
|
53
|
+
// 智能按钮三态:未检查→检查更新,未检查完/有更新→更新 N 个,已检查无更新→已是最新
|
|
54
|
+
let updateLabel: string
|
|
55
|
+
let updateDisabled = false
|
|
56
|
+
let updateDanger = false
|
|
57
|
+
let updateAction: () => void = () => {}
|
|
58
|
+
if (syncing) {
|
|
59
|
+
updateLabel = tt('market.syncing')
|
|
60
|
+
updateDisabled = true
|
|
61
|
+
} else if (checking) {
|
|
62
|
+
updateLabel = tt('market.checking')
|
|
63
|
+
updateDisabled = true
|
|
64
|
+
} else if (!isChecked) {
|
|
65
|
+
updateLabel = tt('market.checkUpdate')
|
|
66
|
+
updateAction = () => { void checkSources(record.repo); void checkMarket() }
|
|
67
|
+
} else if (hasUpdate) {
|
|
68
|
+
const count = hasSkillUpdate ? skillCheck!.updated.length : 1
|
|
69
|
+
updateLabel = count > 1 || hasSkillUpdate ? tt('market.updateCount', { count }) : tt('market.update')
|
|
70
|
+
updateDanger = true
|
|
71
|
+
updateAction = () => { void syncMarketSource(record.repo) }
|
|
72
|
+
} else {
|
|
73
|
+
updateLabel = tt('market.upToDate')
|
|
74
|
+
updateDisabled = true
|
|
75
|
+
}
|
|
35
76
|
return (
|
|
36
77
|
<div className={css.row + ' ' + css.rowStatic}>
|
|
37
78
|
<div className={css.rowMain}>
|
|
@@ -39,25 +80,38 @@ export function MarketView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
39
80
|
<a className={css.sourceLink} href={'https://github.com/' + record.repo} target='_blank' rel='noreferrer'>{record.repo}</a>
|
|
40
81
|
{record.ref !== undefined ? <span className={css.badge + ' ' + css.badgeSource}>{record.ref}</span> : null}
|
|
41
82
|
{installedCount > 0 ? <span className={css.badge + ' ' + css.badgeCount}>{tt('market.installed', { count: installedCount })}</span> : null}
|
|
42
|
-
{
|
|
43
|
-
? <span className={css.badge + ' ' + css.statusUpdated}>{tt('market.updatable', { count: skillCheck
|
|
83
|
+
{hasSkillUpdate
|
|
84
|
+
? <span className={css.badge + ' ' + css.statusUpdated}>{tt('market.updatable', { count: skillCheck!.updated.length })}</span>
|
|
44
85
|
: null}
|
|
45
86
|
{skillCheck !== undefined && skillCheck.deleted.length > 0
|
|
46
87
|
? <span className={css.badge + ' ' + css.statusError}>{tt('market.deletedUpstream', { count: skillCheck.deleted.length })}</span>
|
|
47
88
|
: null}
|
|
48
|
-
{
|
|
49
|
-
? <span className={css.badge + ' ' + css.statusUpdated}>{releaseCheck
|
|
89
|
+
{hasReleaseUpdate
|
|
90
|
+
? <span className={css.badge + ' ' + css.statusUpdated}>{releaseCheck!.latestTag !== undefined ? tt('market.newRelease', { version: releaseCheck!.latestTag }) : tt('market.updated')}</span>
|
|
50
91
|
: null}
|
|
51
92
|
</div>
|
|
52
93
|
</div>
|
|
53
|
-
<button type='button' className={css.
|
|
54
|
-
<button type='button' className={css.opBtn + (releaseCheck?.updateAvailable === true ? ' ' + css.opDanger : '')} disabled={syncingMarket === record.repo} onClick={() => { void syncMarketSource(record.repo) }}>
|
|
55
|
-
{syncingMarket === record.repo ? tt('market.syncing') : tt('market.sync')}
|
|
56
|
-
</button>
|
|
57
|
-
<button type='button' className={css.opBtn} disabled={scanning} onClick={() => { void scanRepo(record.repo) }}>
|
|
94
|
+
<button type='button' className={css.button + ' ' + css.primary} style={{ padding:'4px 10px', fontSize:12 }} disabled={scanning} onClick={() => { void scanRepo(record.repo) }}>
|
|
58
95
|
{scanning ? tt('market.scanning') : tt('market.scan')}
|
|
59
96
|
</button>
|
|
60
|
-
<button
|
|
97
|
+
<button
|
|
98
|
+
type='button'
|
|
99
|
+
className={css.opBtn + (updateDanger ? ' ' + css.opDanger : '')}
|
|
100
|
+
disabled={updateDisabled}
|
|
101
|
+
title={hasUpdate ? '将同步到上游最新版并提示可更新的本地技能' : undefined}
|
|
102
|
+
onClick={updateAction}
|
|
103
|
+
>
|
|
104
|
+
{updateLabel}
|
|
105
|
+
</button>
|
|
106
|
+
<button
|
|
107
|
+
type='button'
|
|
108
|
+
className={css.opBtn}
|
|
109
|
+
disabled={tagBusy}
|
|
110
|
+
title={tt('market.removeHint')}
|
|
111
|
+
aria-label={tt('market.deleteSource')}
|
|
112
|
+
onClick={() => { void removeMarketSource(record.repo) }}
|
|
113
|
+
style={{ padding:'4px 8px', minWidth:28 }}
|
|
114
|
+
>×</button>
|
|
61
115
|
</div>
|
|
62
116
|
)
|
|
63
117
|
}
|
|
@@ -122,53 +176,153 @@ export function MarketView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
122
176
|
{marketState.status !== 'loading' && rows.length === 0 ? <div className={css.empty}>{tt('market.noSources')}</div> : null}
|
|
123
177
|
{marketState.status !== 'loading' ? rows.map((row) => <Fragment key={row.key}>{row.element}</Fragment>) : null}
|
|
124
178
|
</section>
|
|
125
|
-
{
|
|
126
|
-
{repoDiscoverState.status
|
|
127
|
-
|
|
179
|
+
{/* 扫描结果归属卡片:用同一 section 卡片包裹扫描中/错误/就绪,标题直接点明所属仓库,消除割裂感 */}
|
|
180
|
+
{(repoDiscoverState.status !== 'idle' || repoResult !== null) ? (
|
|
181
|
+
<section className={css.section} style={{ borderLeft: '3px solid var(--hub-model, #2f81f7)', background: 'rgba(47,129,247,0.04)' }}>
|
|
182
|
+
<div className={css.sectionTitle + ' ' + css.sectionHeadRow} style={{ margin: '10px 14px 6px' }}>
|
|
183
|
+
<span className={css.sectionTitleFill} style={{ display:'flex', alignItems:'center', gap:6 }}>
|
|
184
|
+
<span style={{ width:7, height:7, borderRadius:'50%', background:'var(--hub-model, #2f81f7)', display:'inline-block' }} />
|
|
185
|
+
扫描结果
|
|
186
|
+
<span style={{ fontWeight:400, opacity:.75 }}>
|
|
187
|
+
— {repoDiscoverState.status === 'ready' ? repoDiscoverState.data.repo : repoDiscoverState.status === 'error' ? (scanningRepo ?? '') : (scanningRepo ?? '')}
|
|
188
|
+
{repoDiscoverState.status === 'ready' && repoDiscoverState.data.ref !== null ? <span style={{ opacity:.5, marginLeft:6 }}>ref {repoDiscoverState.data.ref}</span> : null}
|
|
189
|
+
</span>
|
|
190
|
+
</span>
|
|
191
|
+
<button type='button' className={css.opBtn} onClick={() => { hub.clearScan() }}>关闭</button>
|
|
192
|
+
</div>
|
|
193
|
+
<div style={{ padding:'0 12px 12px' }}>
|
|
194
|
+
{repoDiscoverState.status === 'scanning' ? <div className={css.empty} style={{ padding:'12px 0' }}>{tt('market.scanning')}</div> : null}
|
|
195
|
+
{repoDiscoverState.status === 'error' ? <div className={css.errorBanner}>{repoDiscoverState.message}</div> : null}
|
|
196
|
+
{repoDiscoverState.status === 'ready' ? (() => {
|
|
128
197
|
const entries = repoDiscoverState.data.entries
|
|
129
|
-
|
|
198
|
+
// 导入后 catalog 已包含新技能,但 scan 快照的 existing 仍为 false,需用实时 catalog + 本次导入结果兜底,避免已导入仍可选
|
|
199
|
+
const installedNames = new Set<string>([
|
|
200
|
+
...((hub.catalog?.skills ?? []).map((s) => s.name)),
|
|
201
|
+
...((hub.catalog?.disabled ?? []).map((d) => d.name)),
|
|
202
|
+
...((repoResult?.imported ?? []).map((r) => r.name)),
|
|
203
|
+
...((repoResult?.skipped ?? []).map((r) => r.name)),
|
|
204
|
+
])
|
|
205
|
+
const isExisting = (entry: RepoSkillEntry): boolean => entry.existing || installedNames.has(entry.name)
|
|
206
|
+
const selected = entries.filter((entry) => repoSelected.has(entry.path) && !isExisting(entry))
|
|
207
|
+
const selectedBytes = selected.reduce((s, e) => s + e.totalBytes, 0)
|
|
208
|
+
// 过滤 + 搜索(276 场景核心)
|
|
209
|
+
const filtered = entries.filter((entry) => {
|
|
210
|
+
if (repoFilter !== 'all' && entry.root !== repoFilter) return false
|
|
211
|
+
if (repoSearch.trim() !== '' && !entry.name.toLowerCase().includes(repoSearch.trim().toLowerCase())) return false
|
|
212
|
+
return true
|
|
213
|
+
}).sort((a, b) => a.name.localeCompare(b.name))
|
|
214
|
+
const paged = filtered.slice(0, visibleCount)
|
|
130
215
|
return (
|
|
131
216
|
<>
|
|
132
|
-
<p className={css.hintLine + ' ' + css.hintInline}>{tt('repo.ready', { count: entries.length })}</p>
|
|
217
|
+
<p className={css.hintLine + ' ' + css.hintInline}>{tt('repo.ready', { count: entries.length })} · {repoDiscoverState.data.ref !== null ? `ref ${repoDiscoverState.data.ref}` : ''}</p>
|
|
133
218
|
{entries.length === 0 ? <div className={css.empty}>{tt('repo.empty')}</div> : (
|
|
134
219
|
<>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
220
|
+
{/* 搜索 + 过滤 + 统计 — 精简 */}
|
|
221
|
+
{(() => {
|
|
222
|
+
// 动态根:按已知顺序 skills/design-templates 优先,其余按字母序
|
|
223
|
+
const order = new Map<string, number>([['skills', 0], ['design-templates', 1]])
|
|
224
|
+
const roots = [...new Set(entries.map((e) => e.root))].sort((a, b) => {
|
|
225
|
+
const ao = order.get(a) ?? 99
|
|
226
|
+
const bo = order.get(b) ?? 99
|
|
227
|
+
return ao !== bo ? ao - bo : a.localeCompare(b)
|
|
228
|
+
})
|
|
229
|
+
const counts = new Map<string, number>()
|
|
230
|
+
for (const e of entries) counts.set(e.root, (counts.get(e.root) ?? 0) + 1)
|
|
142
231
|
return (
|
|
143
|
-
<div
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<div className={css.rowName}>{entry.name}</div>
|
|
155
|
-
<div className={css.rowDesc}>{entry.dir} · {tt('repo.files', { count: entry.fileCount, size: formatBytes(entry.totalBytes) })}</div>
|
|
156
|
-
</div>
|
|
157
|
-
{entry.existing
|
|
158
|
-
? <span className={css.badge + ' ' + css.badgeReadonly}>{tt('repo.existing')}</span>
|
|
159
|
-
: <span className={css.badge + ' ' + css.badgeSource}>{entry.origin}</span>}
|
|
160
|
-
</label>
|
|
232
|
+
<div style={{ display:'flex', gap:8, marginBottom:8, flexWrap:'wrap' }}>
|
|
233
|
+
<input
|
|
234
|
+
className={css.input}
|
|
235
|
+
style={{ flex:1, minWidth:140 }}
|
|
236
|
+
value={repoSearch}
|
|
237
|
+
onChange={(e) => setRepoSearch(e.target.value)}
|
|
238
|
+
placeholder="搜索技能名…"
|
|
239
|
+
/>
|
|
240
|
+
<button type='button' className={css.button + (repoFilter === 'all' ? ' ' + css.primary : '')} style={{ padding:'6px 10px', fontSize:12 }} onClick={() => setRepoFilter('all')}>全部 {entries.length}</button>
|
|
241
|
+
{roots.map((root) => (
|
|
242
|
+
<button key={root} type='button' className={css.button + (repoFilter === root ? ' ' + css.primary : '')} style={{ padding:'6px 10px', fontSize:12 }} onClick={() => setRepoFilter(root)}>{root} {counts.get(root) ?? 0}</button>
|
|
161
243
|
))}
|
|
162
244
|
</div>
|
|
163
245
|
)
|
|
164
|
-
})}
|
|
165
|
-
<
|
|
246
|
+
})()}
|
|
247
|
+
<div className={css.hintLine} style={{ display:'flex', justifyContent:'space-between', flexWrap:'wrap', gap:8 }}>
|
|
248
|
+
<span>已选 {selected.length}/{entries.length} · {formatBytes(selectedBytes)} · 显示 {paged.length}/{filtered.length}(过滤后)</span>
|
|
249
|
+
<span style={{ display:'flex', gap:6 }}>
|
|
250
|
+
<button type='button' className={css.button} style={{ padding:'4px 8px', fontSize:11 }} onClick={() => { setRepoSelected(new Set(filtered.filter((e)=>!isExisting(e)).slice(0, visibleCount).map((e)=>e.path))) }}>全选当前显示</button>
|
|
251
|
+
<button type='button' className={css.button} style={{ padding:'4px 8px', fontSize:11 }} onClick={() => { setRepoSelected(new Set(entries.filter((e)=>!isExisting(e)).map((e)=>e.path))) }}>全选全部 {entries.filter(e=>!isExisting(e)).length}</button>
|
|
252
|
+
<button type='button' className={css.button} style={{ padding:'4px 8px', fontSize:11 }} onClick={() => { setRepoSelected(new Set()) }}>{tt('repo.clearAll')}</button>
|
|
253
|
+
</span>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{/* 扁平虚拟滚动容器 — 主题自适应,CSS Modules */}
|
|
257
|
+
<div className={css.scanList}>
|
|
258
|
+
{paged.length === 0 ? <div className={css.empty} style={{ padding:20 }}>无匹配技能</div> : paged.map((entry) => {
|
|
259
|
+
const existing = isExisting(entry)
|
|
260
|
+
return (
|
|
261
|
+
<label key={entry.path} className={css.row + (existing ? ' ' + css.rowMuted : '')}>
|
|
262
|
+
<input
|
|
263
|
+
type='checkbox'
|
|
264
|
+
checked={repoSelected.has(entry.path)}
|
|
265
|
+
disabled={existing}
|
|
266
|
+
onChange={(event) => { toggleRepoSelected(entry.path, event.target.checked) }}
|
|
267
|
+
/>
|
|
268
|
+
<div className={css.rowMain}>
|
|
269
|
+
<div className={css.rowName}>{entry.name}</div>
|
|
270
|
+
<div className={css.rowDesc}>{entry.dir} · {tt('repo.files', { count: entry.fileCount, size: formatBytes(entry.totalBytes) })}</div>
|
|
271
|
+
</div>
|
|
272
|
+
{existing
|
|
273
|
+
? <span className={css.badge + ' ' + css.badgeReadonly}>{tt('repo.existing')}</span>
|
|
274
|
+
: <span className={css.badge + ' ' + css.badgeSource}>{entry.origin}</span>}
|
|
275
|
+
</label>
|
|
276
|
+
)})}
|
|
277
|
+
</div>
|
|
278
|
+
{visibleCount < filtered.length ? (
|
|
279
|
+
<div style={{ textAlign:'center', marginTop:8 }}>
|
|
280
|
+
<button type='button' className={css.button} onClick={() => setVisibleCount((n) => n + 50)}>加载更多 50 (剩余 {filtered.length - visibleCount})</button>
|
|
281
|
+
</div>
|
|
282
|
+
) : null}
|
|
283
|
+
|
|
284
|
+
<div className={css.buttons + ' ' + css.actionsTop}>
|
|
285
|
+
<button type='button' className={css.button + ' ' + css.primary} disabled={selected.length === 0 || repoImporting} onClick={() => { void importRepo() }}>{repoImporting ? tt('repo.importing') : `${tt('repo.import', { count: selected.length })} · ${formatBytes(selectedBytes)}`}</button>
|
|
286
|
+
{repoImporting ? <button type='button' className={css.button} onClick={() => { void cancelImport() }}>{tt('repo.cancel')}</button> : null}
|
|
287
|
+
</div>
|
|
288
|
+
{repoResult !== null && repoResult.status === 'running' ? (() => {
|
|
289
|
+
const pct = repoResult.totalBytes > 0 ? Math.min(100, Math.round(repoResult.downloadedBytes / repoResult.totalBytes * 100)) : (repoResult.total > 0 ? Math.round(repoResult.done / repoResult.total * 100) : 0)
|
|
290
|
+
const speed = formatSpeed(repoResult.bytesPerSecond)
|
|
291
|
+
return (
|
|
292
|
+
<div className={css.formSuccess + ' ' + css.actionsTop}>
|
|
293
|
+
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:6 }}>
|
|
294
|
+
<div className={css.scanProgressTrack}>
|
|
295
|
+
<div className={css.scanProgressFill} style={{ width: `${pct}%` }} />
|
|
296
|
+
</div>
|
|
297
|
+
<span style={{ fontSize:11, opacity:.75, minWidth:32, textAlign:'right' }}>{pct}%</span>
|
|
298
|
+
</div>
|
|
299
|
+
<div className={css.hintLine}>
|
|
300
|
+
{formatBytes(repoResult.downloadedBytes)} / {formatBytes(repoResult.totalBytes)}
|
|
301
|
+
{speed !== '' ? ` · ${speed}` : ''} · {repoResult.done}/{repoResult.total} 个技能
|
|
302
|
+
</div>
|
|
303
|
+
<div className={css.hintLine} style={{marginTop:4}}>
|
|
304
|
+
{repoResult.current !== undefined ? tt('repo.importingCurrent', { name: repoResult.current, done: repoResult.done + 1, total: repoResult.total }) : tt('repo.importing')}
|
|
305
|
+
{repoResult.currentFile !== undefined ? ` · ${repoResult.currentFile}` : ''}
|
|
306
|
+
</div>
|
|
307
|
+
{repoResult.failed.length > 0 ? <div className={css.errorBanner} style={{marginTop:6}}>{repoResult.failed.map(f => `${f.name}: ${f.error}`).join('; ')}</div> : null}
|
|
308
|
+
</div>
|
|
309
|
+
)
|
|
310
|
+
})() : null}
|
|
166
311
|
</>
|
|
167
312
|
)}
|
|
168
313
|
</>
|
|
169
314
|
)
|
|
170
315
|
})() : null}
|
|
171
|
-
{repoResult !== null
|
|
316
|
+
{repoResult !== null && repoResult.status !== 'running' ? (
|
|
317
|
+
<div className={repoResult.status === 'cancelled' ? css.errorBanner + ' ' + css.actionsTop : css.formSuccess + ' ' + css.actionsTop}>
|
|
318
|
+
{repoResult.status === 'cancelled' ? tt('repo.cancelled', { imported: repoResult.imported.length, total: repoResult.total }) : `${tt('repo.imported', { count: repoResult.imported.length })} · ${tt('repo.skippedExisting', { count: repoResult.skipped.length })} · ${tt('repo.failed', { count: repoResult.failed.length })}`}
|
|
319
|
+
{repoResult.failed.length > 0 ? <div style={{marginTop:6}}>{repoResult.failed.map(f => `${f.name}: ${f.error}`).join('; ')}</div> : null}
|
|
320
|
+
{repoResult.status === 'error' && repoResult.error ? <div style={{marginTop:6}}>{repoResult.error}</div> : null}
|
|
321
|
+
</div>
|
|
322
|
+
) : null}
|
|
323
|
+
</div>
|
|
324
|
+
</section>
|
|
325
|
+
) : null}
|
|
172
326
|
|
|
173
327
|
{updateAllDialog ? (
|
|
174
328
|
<ConfirmDialog
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* are managed in the sources tab.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { useState, type JSX } from 'react'
|
|
9
9
|
import { tt } from '../helpers.ts'
|
|
10
10
|
import { groupSwitchView } from '../grouping.ts'
|
|
11
11
|
import { SkillRow } from './SkillRow.tsx'
|
|
@@ -17,6 +17,19 @@ import css from './panel.module.css'
|
|
|
17
17
|
export function ScenesView(props: { hub: SkillHubState }): JSX.Element {
|
|
18
18
|
const { hub } = props
|
|
19
19
|
const { catalog, groupsState, sorted, normalized, collapsedGroups, viewNames, actionNames, batchBusy, busyNames, newTagName, setNewTagName, tagBusy, createTag, toggleGroupCollapse, toggleGroup, setEditingTag, setEditName, setMembersDraft, setEditSearch, enableDisabled } = hub
|
|
20
|
+
const [dragId, setDragId] = useState<string | null>(null)
|
|
21
|
+
const [overId, setOverId] = useState<string | null>(null)
|
|
22
|
+
const handleDrop = (targetId: string): void => {
|
|
23
|
+
if (dragId === null || dragId === targetId || groupsState === null) return
|
|
24
|
+
const ids = groupsState.tags.map((t) => t.id)
|
|
25
|
+
const from = ids.indexOf(dragId)
|
|
26
|
+
const to = ids.indexOf(targetId)
|
|
27
|
+
if (from === -1 || to === -1) return
|
|
28
|
+
const next = [...ids]
|
|
29
|
+
const [moved] = next.splice(from, 1)
|
|
30
|
+
next.splice(to, 0, moved)
|
|
31
|
+
void hub.reorderTags(next)
|
|
32
|
+
}
|
|
20
33
|
return (
|
|
21
34
|
<>
|
|
22
35
|
<form className={css.form} onSubmit={(event) => { void createTag(event) }}>
|
|
@@ -38,9 +51,21 @@ export function ScenesView(props: { hub: SkillHubState }): JSX.Element {
|
|
|
38
51
|
const collapsed = collapsedGroups.has('tag:' + tag.id)
|
|
39
52
|
const view = groupSwitchView(tag.skillNames, viewNames)
|
|
40
53
|
const hasWritable = tag.skillNames.some((name) => actionNames.has(name))
|
|
54
|
+
const isDragging = dragId === tag.id
|
|
55
|
+
const isOver = overId === tag.id && dragId !== tag.id
|
|
41
56
|
return (
|
|
42
|
-
<section
|
|
57
|
+
<section
|
|
58
|
+
key={'tag:' + tag.id}
|
|
59
|
+
className={css.section + (isDragging ? ' ' + css.dragging : '') + (isOver ? ' ' + css.dragOver : '')}
|
|
60
|
+
draggable
|
|
61
|
+
onDragStart={(e) => { setDragId(tag.id); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', tag.id) }}
|
|
62
|
+
onDragOver={(e) => { e.preventDefault(); if (overId !== tag.id) setOverId(tag.id) }}
|
|
63
|
+
onDragLeave={() => { if (overId === tag.id) setOverId(null) }}
|
|
64
|
+
onDrop={(e) => { e.preventDefault(); handleDrop(tag.id); setOverId(null) }}
|
|
65
|
+
onDragEnd={() => { setDragId(null); setOverId(null) }}
|
|
66
|
+
>
|
|
43
67
|
<div className={css.groupHead}>
|
|
68
|
+
<span className={css.dragHandle} aria-hidden title="拖拽调整顺序">⋮⋮</span>
|
|
44
69
|
<button type='button' className={css.disclosure} aria-expanded={!collapsed} onClick={() => { toggleGroupCollapse('tag:' + tag.id) }}>
|
|
45
70
|
<span className={css.chevron + (collapsed ? ' ' + css.chevronCollapsed : '')} />
|
|
46
71
|
<span className={css.groupTitle}>
|