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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* and tag-editor views without violating the rules of hooks.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react'
|
|
10
10
|
import type {
|
|
11
11
|
CatalogResponse,
|
|
12
12
|
CatalogSkill,
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
MarketCheckResponse,
|
|
17
17
|
MarketSourceRecord,
|
|
18
18
|
RepoDiscoverResponse,
|
|
19
|
+
RepoImportProgressResponse,
|
|
19
20
|
RepoImportResponse,
|
|
20
21
|
SkillDetail,
|
|
21
22
|
SkillTag,
|
|
@@ -96,7 +97,9 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
96
97
|
const [scanningRepo, setScanningRepo] = useState<string | null>(null)
|
|
97
98
|
const [repoSelected, setRepoSelected] = useState<ReadonlySet<string>>(new Set())
|
|
98
99
|
const [repoImporting, setRepoImporting] = useState(false)
|
|
99
|
-
const [repoResult, setRepoResult] = useState<
|
|
100
|
+
const [repoResult, setRepoResult] = useState<RepoImportProgressResponse | null>(null)
|
|
101
|
+
const [importJobId, setImportJobId] = useState<string | null>(null)
|
|
102
|
+
const pollAbortRef = useRef<AbortController | null>(null)
|
|
100
103
|
const [search, setSearch] = useState('')
|
|
101
104
|
/** 工作区(项目)路径;空 = 只看用户级技能。 */
|
|
102
105
|
const [workspace, setWorkspace] = useState('')
|
|
@@ -132,6 +135,7 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
132
135
|
const [conflictDialog, setConflictDialog] = useState<ConflictDialogState | null>(null)
|
|
133
136
|
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState | null>(null)
|
|
134
137
|
const [deleteSkillDialog, setDeleteSkillDialog] = useState<string | null>(null)
|
|
138
|
+
const [deleteGroupDialog, setDeleteGroupDialog] = useState<{ name: string; skillNames: string[] } | null>(null)
|
|
135
139
|
const [confirmClearTrash, setConfirmClearTrash] = useState(false)
|
|
136
140
|
/** 「全部更新」确认对话框(市场 tab)。 */
|
|
137
141
|
const [updateAllDialog, setUpdateAllDialog] = useState(false)
|
|
@@ -146,6 +150,7 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
146
150
|
/** 项目级三级树里已细分(按 .dsh/.agents)的项目键。 */
|
|
147
151
|
const [subdividedProjects, setSubdividedProjects] = useState<ReadonlySet<string>>(new Set())
|
|
148
152
|
const [showLegend, setShowLegend] = useState(false)
|
|
153
|
+
const [editMode, setEditMode] = useState(false)
|
|
149
154
|
|
|
150
155
|
const toggleGroupCollapse = useCallback((key: string): void => {
|
|
151
156
|
setCollapsedGroups((previous) => {
|
|
@@ -483,7 +488,37 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
483
488
|
}
|
|
484
489
|
}, [api, deleteSkillDialog, load, loadGroups, loadSources])
|
|
485
490
|
|
|
486
|
-
|
|
491
|
+
/** 打开整组删除确认(来源分组一键删除)。 */
|
|
492
|
+
const requestDeleteGroup = useCallback((name: string, skillNames: string[]): void => {
|
|
493
|
+
setDeleteGroupDialog({ name, skillNames })
|
|
494
|
+
}, [])
|
|
495
|
+
|
|
496
|
+
/** 执行整组删除(逐个移入回收站,跳过只读)。 */
|
|
497
|
+
const runDeleteGroup = useCallback(async (): Promise<void> => {
|
|
498
|
+
const dialog = deleteGroupDialog
|
|
499
|
+
if (dialog === null) return
|
|
500
|
+
setDeleteGroupDialog(null)
|
|
501
|
+
setTagBusy(true)
|
|
502
|
+
setLoadError(null)
|
|
503
|
+
const failures: string[] = []
|
|
504
|
+
let done = 0
|
|
505
|
+
for (const name of dialog.skillNames) {
|
|
506
|
+
try {
|
|
507
|
+
await api.deleteSkill(name)
|
|
508
|
+
done += 1
|
|
509
|
+
} catch (error) {
|
|
510
|
+
// 只读/不存在的跳过并记录
|
|
511
|
+
failures.push(name + ': ' + errorMessage(error))
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
await Promise.all([load(), loadGroups(), loadSources()])
|
|
515
|
+
setTagBusy(false)
|
|
516
|
+
if (failures.length > 0) {
|
|
517
|
+
setLoadError(`删除整组 "${dialog.name}":成功 ${done} 个,失败 ${failures.length} 个:` + failures.join('; '))
|
|
518
|
+
} else if (done > 0) {
|
|
519
|
+
setSuccessBanner(`已删除整组 "${dialog.name}":${done} 个技能已移入回收站`)
|
|
520
|
+
}
|
|
521
|
+
}, [api, deleteGroupDialog, load, loadGroups, loadSources])
|
|
487
522
|
|
|
488
523
|
/** 新建一个空 tag 分组。 */
|
|
489
524
|
const createTag = useCallback(async (event: FormEvent): Promise<void> => {
|
|
@@ -535,6 +570,91 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
535
570
|
}
|
|
536
571
|
}, [api, applyTags])
|
|
537
572
|
|
|
573
|
+
/** 拖拽重排场景分组 */
|
|
574
|
+
const reorderTags = useCallback(async (orderedIds: string[]): Promise<void> => {
|
|
575
|
+
setTagBusy(true)
|
|
576
|
+
setLoadError(null)
|
|
577
|
+
try {
|
|
578
|
+
const tags = await api.reorderTags(orderedIds)
|
|
579
|
+
applyTags(tags)
|
|
580
|
+
} catch (error) {
|
|
581
|
+
const msg = errorMessage(error)
|
|
582
|
+
if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
|
|
583
|
+
// 旧宿主无此路由:本地重排
|
|
584
|
+
const byId = new Map(groupsState?.tags.map((t) => [t.id, t] as const) ?? [])
|
|
585
|
+
const reordered = orderedIds.map((id) => byId.get(id)).filter((t): t is NonNullable<typeof t> => t !== undefined)
|
|
586
|
+
if (reordered.length === orderedIds.length) {
|
|
587
|
+
applyTags(reordered as typeof groupsState extends { tags: infer T } ? T : never)
|
|
588
|
+
setSuccessBanner('已临时调整顺序(本地生效,重启宿主后持久化)')
|
|
589
|
+
} else {
|
|
590
|
+
setLoadError(msg)
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
setLoadError(msg)
|
|
594
|
+
}
|
|
595
|
+
} finally {
|
|
596
|
+
setTagBusy(false)
|
|
597
|
+
}
|
|
598
|
+
}, [api, applyTags, groupsState])
|
|
599
|
+
|
|
600
|
+
/** 拖拽重排来源集合 */
|
|
601
|
+
const reorderCollections = useCallback(async (orderedNames: string[]): Promise<void> => {
|
|
602
|
+
setTagBusy(true)
|
|
603
|
+
setLoadError(null)
|
|
604
|
+
try {
|
|
605
|
+
const collections = await api.reorderCollections(orderedNames)
|
|
606
|
+
setGroupsState((prev) => prev === null ? prev : { ...prev, collections })
|
|
607
|
+
void loadGroups()
|
|
608
|
+
} catch (error) {
|
|
609
|
+
const msg = errorMessage(error)
|
|
610
|
+
// 404 说明宿主仍在跑旧版(需重启后才有新路由),降级为本地即时生效
|
|
611
|
+
if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
|
|
612
|
+
setGroupsState((prev) => {
|
|
613
|
+
if (prev === null) return prev
|
|
614
|
+
const map = new Map(prev.collections.map((c) => [c.name, c] as const))
|
|
615
|
+
const reordered = orderedNames.map((n) => map.get(n)).filter((c): c is NonNullable<typeof c> => c !== undefined)
|
|
616
|
+
// 补上未在 orderedNames 中的集合(新出现的)
|
|
617
|
+
for (const c of prev.collections) if (!reordered.some((r) => r.name === c.name)) reordered.push(c)
|
|
618
|
+
return { ...prev, collections: reordered }
|
|
619
|
+
})
|
|
620
|
+
setSuccessBanner('已临时调整顺序(本地生效,重启宿主后持久化)')
|
|
621
|
+
} else {
|
|
622
|
+
setLoadError(msg)
|
|
623
|
+
}
|
|
624
|
+
} finally {
|
|
625
|
+
setTagBusy(false)
|
|
626
|
+
}
|
|
627
|
+
}, [api, loadGroups])
|
|
628
|
+
|
|
629
|
+
/** 拖拽重排来源顶层分组(project / col:xxx / personal 全量可拖) */
|
|
630
|
+
const reorderSourceGroups = useCallback(async (orderedKeys: string[]): Promise<void> => {
|
|
631
|
+
setTagBusy(true)
|
|
632
|
+
setLoadError(null)
|
|
633
|
+
try {
|
|
634
|
+
await api.reorderSourceGroups(orderedKeys)
|
|
635
|
+
await loadGroups()
|
|
636
|
+
} catch (error) {
|
|
637
|
+
const msg = errorMessage(error)
|
|
638
|
+
if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
|
|
639
|
+
// 旧宿主无此路由:本地重排顶层顺序,提示重启后持久化
|
|
640
|
+
setGroupsState((prev) => {
|
|
641
|
+
if (prev === null) return prev
|
|
642
|
+
// 本地重排 collections 以匹配 topOrderedKeys 中的 col:xxx 顺序
|
|
643
|
+
const colOrder = orderedKeys.filter((k) => k.startsWith('col:')).map((k) => k.slice(4))
|
|
644
|
+
const map = new Map(prev.collections.map((c) => [c.name, c] as const))
|
|
645
|
+
const reordered = colOrder.map((n) => map.get(n)).filter((c): c is NonNullable<typeof c> => c !== undefined)
|
|
646
|
+
for (const c of prev.collections) if (!reordered.some((r) => r.name === c.name)) reordered.push(c)
|
|
647
|
+
return { ...prev, collections: reordered, sourceGroupOrder: orderedKeys }
|
|
648
|
+
})
|
|
649
|
+
setSuccessBanner('已临时调整顺序(本地生效,重启宿主后持久化)')
|
|
650
|
+
} else {
|
|
651
|
+
setLoadError(msg)
|
|
652
|
+
}
|
|
653
|
+
} finally {
|
|
654
|
+
setTagBusy(false)
|
|
655
|
+
}
|
|
656
|
+
}, [api, loadGroups])
|
|
657
|
+
|
|
538
658
|
// -------------------------------------------------------------- market
|
|
539
659
|
|
|
540
660
|
/** 添加一个市场源(内置市场目录与手动输入共用),并立即扫描它。 */
|
|
@@ -635,23 +755,110 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
635
755
|
})
|
|
636
756
|
}, [])
|
|
637
757
|
|
|
638
|
-
/** Import every checked, non-existing repo skill
|
|
758
|
+
/** Import every checked, non-existing repo skill (B方案:job+轮询+进度). */
|
|
639
759
|
const importRepo = useCallback(async (): Promise<void> => {
|
|
640
760
|
if (repoDiscoverState.status !== 'ready') return
|
|
641
761
|
setRepoImporting(true)
|
|
642
762
|
setRepoResult(null)
|
|
763
|
+
setImportJobId(null)
|
|
643
764
|
setLoadError(null)
|
|
765
|
+
let finalProgress: RepoImportProgressResponse | null = null
|
|
644
766
|
try {
|
|
645
|
-
const
|
|
646
|
-
|
|
767
|
+
const created = await api.repoImport(repoDiscoverState.data.repo, [...repoSelected], repoDiscoverState.data.ref ?? undefined)
|
|
768
|
+
setImportJobId(created.jobId)
|
|
769
|
+
// 初始占位,让进度卡片立刻出现(带真实 totalBytes)
|
|
770
|
+
setRepoResult({ ok: true, jobId: created.jobId, status: 'running', total: created.total, done: 0, totalBytes: created.totalBytes, downloadedBytes: 0, imported: [], skipped: [], failed: [] })
|
|
771
|
+
// 轮询直到 done/cancelled/error(800ms 起步 + 退避,避免 276 技能 900 次请求)
|
|
772
|
+
pollAbortRef.current?.abort()
|
|
773
|
+
pollAbortRef.current = new AbortController()
|
|
774
|
+
const signal = pollAbortRef.current.signal
|
|
775
|
+
let attempt = 0
|
|
776
|
+
for (;;) {
|
|
777
|
+
if (signal.aborted) break
|
|
778
|
+
const delay = Math.min(2000, 800 + attempt * 200)
|
|
779
|
+
await new Promise((r) => setTimeout(r, delay))
|
|
780
|
+
if (signal.aborted) break
|
|
781
|
+
try {
|
|
782
|
+
const progress = await api.repoImportProgress(created.jobId)
|
|
783
|
+
setRepoResult(progress)
|
|
784
|
+
finalProgress = progress
|
|
785
|
+
if (progress.status !== 'running') {
|
|
786
|
+
break
|
|
787
|
+
}
|
|
788
|
+
attempt = 0
|
|
789
|
+
} catch (pollError) {
|
|
790
|
+
// 轮询失败退避重试
|
|
791
|
+
const msg = errorMessage(pollError)
|
|
792
|
+
if (msg.includes('not found')) break
|
|
793
|
+
attempt += 1
|
|
794
|
+
if (attempt > 8) break
|
|
795
|
+
}
|
|
796
|
+
}
|
|
647
797
|
await Promise.all([load(), loadMarket(), loadGroups(), loadSources()])
|
|
648
798
|
} catch (error) {
|
|
649
799
|
setLoadError(errorMessage(error))
|
|
650
800
|
} finally {
|
|
801
|
+
// 导入完成后把已导入/已跳过的项从勾选中移除,并把扫描快照里的 existing 置为 true,避免“仍可勾选已导入”的错觉
|
|
802
|
+
if (finalProgress !== null && (finalProgress.imported.length > 0 || finalProgress.skipped.length > 0)) {
|
|
803
|
+
const doneNames = new Set([...finalProgress.imported.map((r) => r.name), ...finalProgress.skipped.map((r) => r.name)])
|
|
804
|
+
const donePaths = new Set(
|
|
805
|
+
repoDiscoverState.data.entries.filter((e) => doneNames.has(e.name)).map((e) => e.path),
|
|
806
|
+
)
|
|
807
|
+
setRepoSelected((prev) => {
|
|
808
|
+
const next = new Set(prev)
|
|
809
|
+
for (const p of donePaths) next.delete(p)
|
|
810
|
+
return next
|
|
811
|
+
})
|
|
812
|
+
setRepoDiscoverState((prev) => {
|
|
813
|
+
if (prev.status !== 'ready') return prev
|
|
814
|
+
return {
|
|
815
|
+
...prev,
|
|
816
|
+
data: {
|
|
817
|
+
...prev.data,
|
|
818
|
+
entries: prev.data.entries.map((e) => doneNames.has(e.name) ? { ...e, existing: true } : e),
|
|
819
|
+
},
|
|
820
|
+
}
|
|
821
|
+
})
|
|
822
|
+
}
|
|
651
823
|
setRepoImporting(false)
|
|
652
824
|
}
|
|
653
825
|
}, [api, repoDiscoverState, repoSelected, load, loadMarket, loadGroups, loadSources])
|
|
654
826
|
|
|
827
|
+
// 卸载时中断轮询,避免泄露
|
|
828
|
+
useEffect(() => {
|
|
829
|
+
return () => { pollAbortRef.current?.abort() }
|
|
830
|
+
}, [])
|
|
831
|
+
|
|
832
|
+
/** 取消正在进行的导入(选项2:唯有取消才停) */
|
|
833
|
+
const cancelImport = useCallback(async (): Promise<void> => {
|
|
834
|
+
if (importJobId === null) return
|
|
835
|
+
pollAbortRef.current?.abort()
|
|
836
|
+
try {
|
|
837
|
+
const res = await api.repoImportCancel(importJobId)
|
|
838
|
+
// 立刻刷新一次进度
|
|
839
|
+
try {
|
|
840
|
+
const progress = await api.repoImportProgress(importJobId)
|
|
841
|
+
setRepoResult(progress)
|
|
842
|
+
} catch {
|
|
843
|
+
setRepoResult((prev) => prev !== null ? { ...prev, status: res.status as 'cancelled' } : prev)
|
|
844
|
+
}
|
|
845
|
+
} catch (error) {
|
|
846
|
+
setLoadError(errorMessage(error))
|
|
847
|
+
} finally {
|
|
848
|
+
setRepoImporting(false)
|
|
849
|
+
}
|
|
850
|
+
}, [api, importJobId])
|
|
851
|
+
|
|
852
|
+
/** 清空扫描结果(关闭归属卡片) */
|
|
853
|
+
const clearScan = useCallback((): void => {
|
|
854
|
+
setRepoDiscoverState({ status: 'idle' })
|
|
855
|
+
setScanningRepo(null)
|
|
856
|
+
setRepoResult(null)
|
|
857
|
+
setRepoSelected(new Set())
|
|
858
|
+
setImportJobId(null)
|
|
859
|
+
pollAbortRef.current?.abort()
|
|
860
|
+
}, [])
|
|
861
|
+
|
|
655
862
|
/** 检查所有市场源的上游更新(服务端节流)。 */
|
|
656
863
|
const checkMarket = useCallback(async (): Promise<void> => {
|
|
657
864
|
try {
|
|
@@ -790,22 +997,22 @@ export function useSkillHub(api: SkillHubApi) {
|
|
|
790
997
|
|
|
791
998
|
return {
|
|
792
999
|
// state
|
|
793
|
-
catalog, loading, loadError, successBanner, updateState, repoDiscoverState, scanningRepo, repoSelected, repoImporting, repoResult,
|
|
1000
|
+
catalog, loading, loadError, successBanner, updateState, repoDiscoverState, scanningRepo, repoSelected, repoImporting, repoResult, importJobId,
|
|
794
1001
|
search, workspace, detail, detailLoading, busyNames, batchBusy, showForm, formName, formDesc, formRoot, formBusy, formMessage,
|
|
795
1002
|
uses, hubConfig, tab, skillView, sourceFilter, sortKey, marketState, marketCheck, branchChoice, branchBusy,
|
|
796
1003
|
marketSyncDialog, syncingMarket, syncBusy, newSourceName, groupsState, sourcesState, sourceCheck, checkingSource, syncingSource,
|
|
797
|
-
conflictDialog, confirmDialog, deleteSkillDialog, confirmClearTrash, updateAllDialog, editingTag, editName, membersDraft, newTagName, tagBusy,
|
|
798
|
-
editSearch, collapsedGroups, subdividedProjects, showLegend,
|
|
1004
|
+
conflictDialog, confirmDialog, deleteSkillDialog, deleteGroupDialog, confirmClearTrash, updateAllDialog, editingTag, editName, membersDraft, newTagName, tagBusy,
|
|
1005
|
+
editSearch, collapsedGroups, subdividedProjects, showLegend, editMode,
|
|
799
1006
|
// derived
|
|
800
1007
|
actionNames, viewNames, normalized, origins, sourceOptions, filtered, sorted,
|
|
801
1008
|
// actions + setters
|
|
802
1009
|
setLoadError, setSuccessBanner, setSearch, setWorkspace, setDetail, setShowForm, setFormName, setFormDesc, setFormRoot, setFormMessage,
|
|
803
1010
|
setRepoSelected, setTab, setSkillView,
|
|
804
1011
|
setSourceFilter, setSortKey, setBranchChoice, setMarketSyncDialog, setNewSourceName, setConflictDialog, setConfirmDialog,
|
|
805
|
-
setDeleteSkillDialog, setConfirmClearTrash, setUpdateAllDialog, setEditingTag, setEditName, setMembersDraft, setNewTagName, setEditSearch, setShowLegend,
|
|
1012
|
+
setDeleteSkillDialog, setDeleteGroupDialog, setConfirmClearTrash, setUpdateAllDialog, setEditingTag, setEditName, setMembersDraft, setNewTagName, setEditSearch, setShowLegend, setEditMode,
|
|
806
1013
|
toggleGroupCollapse, toggleSubdivide, checkUpdate, loadMarket, openDetail, toggle, enableDisabled, batchToggleNames, toggleGroup, resolveConflict,
|
|
807
|
-
runConfirmed, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, requestDeleteSkill, runDeleteSkill, createTag,
|
|
808
|
-
deleteTag, saveTag, addSource, addMarketSource, removeMarketSource, scanRepo, confirmBranchChoice, toggleRepoSelected, importRepo,
|
|
1014
|
+
runConfirmed, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, requestDeleteSkill, runDeleteSkill, requestDeleteGroup, runDeleteGroup, createTag,
|
|
1015
|
+
deleteTag, saveTag, reorderTags, reorderCollections, reorderSourceGroups, addSource, addMarketSource, removeMarketSource, scanRepo, confirmBranchChoice, toggleRepoSelected, importRepo, cancelImport, clearScan,
|
|
809
1016
|
checkMarket, syncMarketSource, confirmMarketSync, updateAll, create,
|
|
810
1017
|
}
|
|
811
1018
|
}
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* land keeps its drafts, so the user can correct them instead of retyping.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { createSnapshotStore
|
|
12
|
+
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
13
|
+
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
13
14
|
|
|
14
15
|
/** One staged field: its draft text plus the clear/reset marker. */
|
|
15
16
|
interface StagedEdit {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { beforeEach, describe, expect, it } from 'vitest'
|
|
9
|
-
import type { SettingsScope } from '@deepseek-ai/dsh-client-
|
|
9
|
+
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
10
10
|
import type { InputTriggerCandidate, InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
11
11
|
import type { HubSettingsValue } from '../protocol.ts'
|
|
12
12
|
import type { SkillHubApi } from './api.ts'
|
|
@@ -39,7 +39,8 @@ function scopeWith(value: HubSettingsValue): SettingsScope<HubSettingsValue> {
|
|
|
39
39
|
subscribe: () => () => {},
|
|
40
40
|
set: async () => {},
|
|
41
41
|
unset: async () => {},
|
|
42
|
-
|
|
42
|
+
mutate: async () => {},
|
|
43
|
+
} as unknown as SettingsScope<HubSettingsValue>
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/** Fake hub api whose catalog lists the given skills. */
|
|
@@ -84,7 +85,7 @@ describe('findSkillSource', () => {
|
|
|
84
85
|
})
|
|
85
86
|
|
|
86
87
|
describe('wrapSkillSource', () => {
|
|
87
|
-
it('adds a model-colored dot to model-callable skills and a user-colored dot to user-only ones', async () => {
|
|
88
|
+
it('adds a model-colored dot to model-callable skills and a user-colored dot to user-only ones (old host, no drilled)', async () => {
|
|
88
89
|
const source = skillSource(async () => [
|
|
89
90
|
{ name: 'code-review', description: 'review code' },
|
|
90
91
|
{ name: 'personal-note', description: 'only me' },
|
|
@@ -98,7 +99,8 @@ describe('wrapSkillSource', () => {
|
|
|
98
99
|
|
|
99
100
|
const restore = wrapSkillSource(source, api, scope)
|
|
100
101
|
try {
|
|
101
|
-
|
|
102
|
+
// Old host: CandidateRequest 无 drilled 字段,注入彩色点
|
|
103
|
+
const rows = await source.candidates({ sessionId: 's1' as never }, { query: '', position: 'leading', signal: new AbortController().signal } as unknown as import('@deepseek-ai/dsh-client-ui-input-trigger/client').CandidateRequest)
|
|
102
104
|
|
|
103
105
|
expect(rows).toHaveLength(3)
|
|
104
106
|
// Model-callable → model color.
|
|
@@ -115,14 +117,14 @@ describe('wrapSkillSource', () => {
|
|
|
115
117
|
}
|
|
116
118
|
})
|
|
117
119
|
|
|
118
|
-
it('falls back to the default colors when settings omit them and catalog fails', async () => {
|
|
120
|
+
it('falls back to the default colors when settings omit them and catalog fails (old host)', async () => {
|
|
119
121
|
const source = skillSource(async () => [{ name: 'lonely', description: '' }])
|
|
120
122
|
const api = { catalog: async () => { throw new Error('route down') } } as unknown as SkillHubApi
|
|
121
123
|
const scope = scopeWith({ enabled: true, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true })
|
|
122
124
|
|
|
123
125
|
const restore = wrapSkillSource(source, api, scope)
|
|
124
126
|
try {
|
|
125
|
-
const rows = await source.candidates({ sessionId: 's1' as never }, { query: '', position: 'leading', signal: new AbortController().signal })
|
|
127
|
+
const rows = await source.candidates({ sessionId: 's1' as never }, { query: '', position: 'leading', signal: new AbortController().signal } as unknown as import('@deepseek-ai/dsh-client-ui-input-trigger/client').CandidateRequest)
|
|
126
128
|
// Catalog failed → unknown name → model default dot still renders.
|
|
127
129
|
expect(rows[0].icon).toBeDefined()
|
|
128
130
|
expect((rows[0].icon as unknown as { props: { style: { background: string } } }).props.style.background).toBe('#2f81f7')
|
|
@@ -131,6 +133,22 @@ describe('wrapSkillSource', () => {
|
|
|
131
133
|
}
|
|
132
134
|
})
|
|
133
135
|
|
|
136
|
+
it('skips dot injection on new host (alpha.2, drilled present) for ReferenceIcon compat', async () => {
|
|
137
|
+
const source = skillSource(async () => [{ name: 'code-review', description: 'review code' }])
|
|
138
|
+
const api = apiWith([{ name: 'code-review', modelInvocable: true }])
|
|
139
|
+
const scope = scopeWith({ enabled: true, announceToAgent: true, showUseCount: true, showUseTime: true, showGroupSummary: true, dotModelColor: '#112233', dotUserColor: '#445566' })
|
|
140
|
+
|
|
141
|
+
const restore = wrapSkillSource(source, api, scope)
|
|
142
|
+
try {
|
|
143
|
+
const rows = await source.candidates({ sessionId: 's1' as never }, { query: '', position: 'leading', drilled: false, signal: new AbortController().signal })
|
|
144
|
+
expect(rows).toHaveLength(1)
|
|
145
|
+
expect(rows[0].icon).toBeUndefined()
|
|
146
|
+
expect(rows[0].name).toBe('code-review')
|
|
147
|
+
} finally {
|
|
148
|
+
restore()
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
134
152
|
it('restores the original candidates on dispose', async () => {
|
|
135
153
|
const originalCandidates = async (): Promise<InputTriggerCandidate[]> => [{ name: 'plain', description: '' }]
|
|
136
154
|
const source = skillSource(originalCandidates)
|
|
@@ -18,9 +18,12 @@
|
|
|
18
18
|
* / `sessionOf` — the lookup below is defensive: it never throws).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { ClientContext
|
|
21
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
22
|
+
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
22
23
|
// Type-only: pulls the Context merge for ctx.inputTriggers.
|
|
23
24
|
import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
25
|
+
// Type-only: pulls the connection/reset event.
|
|
26
|
+
import type {} from '@deepseek-ai/dsh-client-connection/client'
|
|
24
27
|
import type { InputTriggerCandidate, InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
|
|
25
28
|
import { createElement } from 'react'
|
|
26
29
|
import type { HubSettingsValue } from '../protocol.ts'
|
|
@@ -121,6 +124,12 @@ export function wrapSkillSource(source: InputTriggerSource, api: SkillHubApi, sc
|
|
|
121
124
|
source.candidates = async (session, req) => {
|
|
122
125
|
const items = await original(session, req)
|
|
123
126
|
if (req.signal.aborted) return items
|
|
127
|
+
// Dual-compat: 0.1.2-alpha.2 的 MenuView 将 icon 收窄为 'file'|'folder'|'session'
|
|
128
|
+
// 并通过 ReferenceIcon 渲染,旧版的自定义 ReactElement 点已无法展示。
|
|
129
|
+
// 以 `drilled` 是否存在探测新版(alpha.2 必有,rc 无),新版直接透传不注入
|
|
130
|
+
// icon,旧版保持彩色点,避免新版传入非法 icon 导致渲染异常。
|
|
131
|
+
const isNewHost = req !== null && typeof req === 'object' && 'drilled' in (req as unknown as Record<string, unknown>)
|
|
132
|
+
if (isNewHost) return items
|
|
124
133
|
const modelByName = await modelInvocableMap(api)
|
|
125
134
|
if (req.signal.aborted) return items
|
|
126
135
|
const snapshot = scope.getSnapshot()
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { Context } from '@deepseek-ai/cordis'
|
|
11
11
|
import type { SkillProviderControl } from '@deepseek-ai/dsh-skill'
|
|
12
|
-
import {
|
|
12
|
+
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
13
13
|
import z from 'schemastery'
|
|
14
14
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
15
15
|
import type {} from '@deepseek-ai/dsh-skill'
|
|
@@ -21,6 +21,10 @@ import { SkillHubProvider } from './provider.ts'
|
|
|
21
21
|
import { makeRoutes } from './routes.ts'
|
|
22
22
|
import { createSkillStatsReader, type SkillStatsReader } from './stats.ts'
|
|
23
23
|
import { SkillHubStore } from './store.ts'
|
|
24
|
+
import { cleanupLeftoverImportDirs } from './repo.ts'
|
|
25
|
+
import { dshHome } from './store.ts'
|
|
26
|
+
import { join } from 'node:path'
|
|
27
|
+
import { homedir } from 'node:os'
|
|
24
28
|
|
|
25
29
|
/** Stable cordis plugin name (matches cordis.patch.yml insert id). */
|
|
26
30
|
export const name = 'skill-hub'
|
|
@@ -63,7 +67,7 @@ export const Config: z<Config> = z.object({
|
|
|
63
67
|
* page edit this namespace through the official settings transport, and the
|
|
64
68
|
* plugin consumes the same resolved value — one source of truth.
|
|
65
69
|
*/
|
|
66
|
-
export const CONFIG_NAMESPACE =
|
|
70
|
+
export const CONFIG_NAMESPACE = 'dsh-skill-hub' as SettingsNamespace
|
|
67
71
|
|
|
68
72
|
/** Schema of the hub's settings namespace: the card's fields (booleans + optional dot colors). */
|
|
69
73
|
export const HubSettingsSchema: z<HubSettingsValue> = z.object({
|
|
@@ -213,6 +217,20 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
213
217
|
// build seeds the settings namespace from the saved sidecar config when the
|
|
214
218
|
// namespace has no user section yet. Later edits live only in the settings
|
|
215
219
|
// document; the sidecar keeps its (now-stale) copy untouched.
|
|
220
|
+
// Startup: 清理 Issue #3 遗留的 .*.import-* 临时目录(尽早回收,不阻塞)
|
|
221
|
+
void (async () => {
|
|
222
|
+
const home = dshHome()
|
|
223
|
+
const agentsHome = process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')
|
|
224
|
+
for (const root of [join(home, 'skills'), join(agentsHome, 'skills')]) {
|
|
225
|
+
try {
|
|
226
|
+
const c = await cleanupLeftoverImportDirs(root)
|
|
227
|
+
if (c > 0) ctx.logger.info(`[dsh-skill-hub] startup cleaned ${c} leftover import temp dir(s) in ${root}`)
|
|
228
|
+
} catch (error) {
|
|
229
|
+
ctx.logger.warn('[dsh-skill-hub] startup cleanup failed', error)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
})()
|
|
233
|
+
|
|
216
234
|
void (async () => {
|
|
217
235
|
const legacy = await store.getConfig()
|
|
218
236
|
if (Object.keys(legacy).length > 0 && Object.keys(saved()).length === 0) {
|
package/src/protocol.ts
CHANGED
|
@@ -23,11 +23,16 @@ export const SKILL_HUB_API = {
|
|
|
23
23
|
marketSync: '/api/skill-hub/market/source/sync',
|
|
24
24
|
repo: '/api/skill-hub/repo',
|
|
25
25
|
repoImport: '/api/skill-hub/repo/import',
|
|
26
|
+
repoImportProgress: '/api/skill-hub/repo/import/progress',
|
|
27
|
+
repoImportCancel: '/api/skill-hub/repo/import/cancel',
|
|
26
28
|
update: '/api/skill-hub/update',
|
|
27
29
|
groups: '/api/skill-hub/groups',
|
|
28
30
|
tag: '/api/skill-hub/tag',
|
|
29
31
|
tagDelete: '/api/skill-hub/tag/delete',
|
|
30
32
|
tagMembers: '/api/skill-hub/tag/members',
|
|
33
|
+
tagReorder: '/api/skill-hub/tag/reorder',
|
|
34
|
+
collectionReorder: '/api/skill-hub/collections/reorder',
|
|
35
|
+
sourceGroupReorder: '/api/skill-hub/source-groups/reorder',
|
|
31
36
|
sources: '/api/skill-hub/sources',
|
|
32
37
|
sourceCheck: '/api/skill-hub/sources/check',
|
|
33
38
|
sourceSync: '/api/skill-hub/sources/sync',
|
|
@@ -414,7 +419,7 @@ export interface MarketSyncResponse {
|
|
|
414
419
|
skills: string[]
|
|
415
420
|
}
|
|
416
421
|
|
|
417
|
-
/** A skill discovered in a GitHub repo under skills
|
|
422
|
+
/** A skill discovered in a GitHub repo under any top-level root (e.g. skills/, templates/). */
|
|
418
423
|
export interface RepoSkillEntry {
|
|
419
424
|
/** Kebab-case skill name (directory basename). */
|
|
420
425
|
name: string
|
|
@@ -434,8 +439,8 @@ export interface RepoSkillEntry {
|
|
|
434
439
|
existing: boolean
|
|
435
440
|
}
|
|
436
441
|
|
|
437
|
-
/**
|
|
438
|
-
export type RepoRoot =
|
|
442
|
+
/** Skill root in a GitHub repo: the top-level directory that contains skills (e.g. skills, design-templates, templates). Auto-derived from SKILL.md locations, not hard-coded. */
|
|
443
|
+
export type RepoRoot = string
|
|
439
444
|
|
|
440
445
|
/** GET /api/skill-hub/repo — discover importable skills in a GitHub repo. */
|
|
441
446
|
export interface RepoDiscoverResponse {
|
|
@@ -461,8 +466,49 @@ export interface RepoImportRequest {
|
|
|
461
466
|
ref?: string
|
|
462
467
|
}
|
|
463
468
|
|
|
464
|
-
/** POST /api/skill-hub/repo/import */
|
|
469
|
+
/** POST /api/skill-hub/repo/import — now creates an async job (B方案) */
|
|
465
470
|
export interface RepoImportResponse {
|
|
471
|
+
ok: true
|
|
472
|
+
jobId: string
|
|
473
|
+
total: number
|
|
474
|
+
totalBytes: number
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** GET /api/skill-hub/repo/import/progress?jobId=xxx */
|
|
478
|
+
export interface RepoImportProgressResponse {
|
|
479
|
+
ok: true
|
|
480
|
+
jobId: string
|
|
481
|
+
status: 'running' | 'done' | 'cancelled' | 'error'
|
|
482
|
+
total: number
|
|
483
|
+
done: number
|
|
484
|
+
/** 当前正在下载的 skill 名,done < total 时有效 */
|
|
485
|
+
current?: string
|
|
486
|
+
/** 当前正在下载的文件路径(相对 skill dir) */
|
|
487
|
+
currentFile?: string
|
|
488
|
+
/** 字节级进度 */
|
|
489
|
+
totalBytes: number
|
|
490
|
+
downloadedBytes: number
|
|
491
|
+
/** 下载速度 bytes/sec,running 时有效 */
|
|
492
|
+
bytesPerSecond?: number
|
|
493
|
+
imported: Array<{ name: string; origin: string; path: string }>
|
|
494
|
+
skipped: Array<{ name: string; reason: 'exists' }>
|
|
495
|
+
failed: Array<{ name: string; error: string }>
|
|
496
|
+
/** error 状态时的原因 */
|
|
497
|
+
error?: string
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** POST /api/skill-hub/repo/import/cancel */
|
|
501
|
+
export interface RepoImportCancelRequest {
|
|
502
|
+
jobId: string
|
|
503
|
+
}
|
|
504
|
+
export interface RepoImportCancelResponse {
|
|
505
|
+
ok: true
|
|
506
|
+
jobId: string
|
|
507
|
+
status: 'cancelled' | 'done'
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** @deprecated 旧同步响应,保留供类型兼容,实际已由 Progress 代替 */
|
|
511
|
+
export interface RepoImportLegacyResponse {
|
|
466
512
|
ok: true
|
|
467
513
|
imported: Array<{ name: string; origin: string; path: string }>
|
|
468
514
|
skipped: Array<{ name: string; reason: 'exists' }>
|
|
@@ -507,12 +553,16 @@ export interface CollectionGroup {
|
|
|
507
553
|
/** GET /api/skill-hub/groups */
|
|
508
554
|
export interface GroupsResponse {
|
|
509
555
|
ok: true
|
|
510
|
-
/** 用户自定义 tag
|
|
556
|
+
/** 用户自定义 tag 分组(按拖拽顺序)。 */
|
|
511
557
|
tags: SkillTag[]
|
|
512
|
-
/**
|
|
558
|
+
/** 系统集合组(按拖拽顺序,兜底按名称)。 */
|
|
513
559
|
collections: CollectionGroup[]
|
|
514
560
|
/** skillName → 集合名 的完整映射。 */
|
|
515
561
|
origins: Record<string, string>
|
|
562
|
+
/** 来源分组整体顺序(project / col:xxx / uncategorized-source),用于 SourcesView 顶层排序 */
|
|
563
|
+
sourceGroupOrder?: string[]
|
|
564
|
+
/** 兼容旧 collectionOrder 字段 */
|
|
565
|
+
collectionOrder?: string[]
|
|
516
566
|
}
|
|
517
567
|
|
|
518
568
|
/** POST /api/skill-hub/tag — 新建(缺省 id)或重命名(带 id)。 */
|
|
@@ -554,6 +604,39 @@ export interface TagMembersResponse {
|
|
|
554
604
|
tags: SkillTag[]
|
|
555
605
|
}
|
|
556
606
|
|
|
607
|
+
/** POST /api/skill-hub/tag/reorder — 拖拽重排场景分组 */
|
|
608
|
+
export interface TagReorderRequest {
|
|
609
|
+
/** 按新顺序排列的 tag id 列表(需包含全部 id) */
|
|
610
|
+
orderedIds: string[]
|
|
611
|
+
}
|
|
612
|
+
/** POST /api/skill-hub/tag/reorder */
|
|
613
|
+
export interface TagReorderResponse {
|
|
614
|
+
ok: true
|
|
615
|
+
tags: SkillTag[]
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** POST /api/skill-hub/collections/reorder — 拖拽重排来源集合 */
|
|
619
|
+
export interface CollectionReorderRequest {
|
|
620
|
+
/** 按新顺序排列的集合名列表(需包含全部 name) */
|
|
621
|
+
orderedNames: string[]
|
|
622
|
+
}
|
|
623
|
+
/** POST /api/skill-hub/collections/reorder */
|
|
624
|
+
export interface CollectionReorderResponse {
|
|
625
|
+
ok: true
|
|
626
|
+
collections: CollectionGroup[]
|
|
627
|
+
order: string[]
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** POST /api/skill-hub/source-groups/reorder — 拖拽重排来源顶层分组(project / collections / personal) */
|
|
631
|
+
export interface SourceGroupReorderRequest {
|
|
632
|
+
/** 按新顺序排列的顶层分组 key 列表(project / col:xxx / uncategorized-source) */
|
|
633
|
+
orderedKeys: string[]
|
|
634
|
+
}
|
|
635
|
+
export interface SourceGroupReorderResponse {
|
|
636
|
+
ok: true
|
|
637
|
+
order: string[]
|
|
638
|
+
}
|
|
639
|
+
|
|
557
640
|
/** 来源跟踪记录:一组来自同一上游仓库根目录的技能。 */
|
|
558
641
|
export interface SourceRecord {
|
|
559
642
|
/** GitHub owner/repo。 */
|