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.
Files changed (41) hide show
  1. package/README.md +68 -229
  2. package/README.zh.md +67 -203
  3. package/lib/client.js +1955 -413
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +441 -84
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -1
  7. package/lib/types/client/api.d.ts +12 -2
  8. package/lib/types/client/index.d.ts +6 -1
  9. package/lib/types/client/locales.d.ts +13 -0
  10. package/lib/types/client/panel/ScenesView.d.ts +1 -1
  11. package/lib/types/client/panel/SourcesView.d.ts +1 -1
  12. package/lib/types/client/panel/useSkillHub.d.ts +20 -2
  13. package/lib/types/client/settings-form.d.ts +1 -1
  14. package/lib/types/client/slash-dots.d.ts +2 -1
  15. package/lib/types/index.d.ts +2 -1
  16. package/lib/types/protocol.d.ts +93 -6
  17. package/lib/types/repo.d.ts +18 -11
  18. package/lib/types/store.d.ts +14 -2
  19. package/package.json +38 -29
  20. package/src/client/SkillHubSettingsCard.tsx +1 -1
  21. package/src/client/api.ts +61 -1
  22. package/src/client/index.tsx +10 -1
  23. package/src/client/locales.ts +26 -0
  24. package/src/client/panel/MarketView.tsx +201 -47
  25. package/src/client/panel/ScenesView.tsx +27 -2
  26. package/src/client/panel/SkillHubPanel.tsx +18 -19
  27. package/src/client/panel/SkillRow.tsx +8 -8
  28. package/src/client/panel/SourcesView.tsx +203 -26
  29. package/src/client/panel/panel.module.css +17 -6
  30. package/src/client/panel/useSkillHub.ts +219 -12
  31. package/src/client/settings-form.ts +2 -1
  32. package/src/client/slash-dots.test.ts +24 -6
  33. package/src/client/slash-dots.tsx +10 -1
  34. package/src/index.ts +20 -2
  35. package/src/protocol.ts +89 -6
  36. package/src/repo.test.ts +61 -19
  37. package/src/repo.ts +86 -23
  38. package/src/routes.test.ts +18 -4
  39. package/src/routes.ts +269 -47
  40. package/src/skillfs.ts +17 -4
  41. 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<RepoImportResponse | null>(null)
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
- // ----------------------------------------------------------- tag editing
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, then refresh the hub. */
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 result = await api.repoImport(repoDiscoverState.data.repo, [...repoSelected], repoDiscoverState.data.ref ?? undefined)
646
- setRepoResult(result)
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, type SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
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-runtime/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
- } as SettingsScope<HubSettingsValue>
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
- const rows = await source.candidates({ sessionId: 's1' as never }, { query: '', position: 'leading', signal: new AbortController().signal })
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, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client'
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 { settingsNamespace } from '@deepseek-ai/dsh-settings'
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 = settingsNamespace('dsh-skill-hub')
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/ or design-templates/. */
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
- /** Supported skill roots in a GitHub repo. */
438
- export type RepoRoot = 'skills' | 'design-templates'
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
- /** 系统集合组(按 origin 聚合,名称排序)。 */
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。 */