dsh-skill-hub 0.3.7 → 0.3.8

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 (109) hide show
  1. package/README.md +40 -50
  2. package/README.zh.md +38 -48
  3. package/lib/client.js +2209 -1820
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +653 -419
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -0
  7. package/lib/types/client/api.d.ts +12 -6
  8. package/lib/types/client/helpers.d.ts +0 -1
  9. package/lib/types/client/locales/common.d.ts +52 -0
  10. package/lib/types/client/locales/detail.d.ts +16 -0
  11. package/lib/types/client/locales/market.d.ts +73 -0
  12. package/lib/types/client/locales/settings.d.ts +41 -0
  13. package/lib/types/client/locales/skills.d.ts +65 -0
  14. package/lib/types/client/locales/sources.d.ts +35 -0
  15. package/lib/types/client/locales.d.ts +124 -118
  16. package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
  17. package/lib/types/client/panel/MarketView.d.ts +1 -0
  18. package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
  19. package/lib/types/client/panel/hooks/shared.d.ts +72 -0
  20. package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
  21. package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
  22. package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
  23. package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
  24. package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
  25. package/lib/types/client/panel/market-format.d.ts +6 -0
  26. package/lib/types/client/panel/useSkillHub.d.ts +45 -73
  27. package/lib/types/client/settings-card.d.ts +20 -0
  28. package/lib/types/client/settings-form.d.ts +6 -0
  29. package/lib/types/protocol/api.d.ts +38 -0
  30. package/lib/types/protocol/catalog.d.ts +155 -0
  31. package/lib/types/protocol/config.d.ts +113 -0
  32. package/lib/types/protocol/groups.d.ts +95 -0
  33. package/lib/types/protocol/market.d.ts +94 -0
  34. package/lib/types/protocol/repo.d.ts +107 -0
  35. package/lib/types/protocol/sources.d.ts +139 -0
  36. package/lib/types/protocol/stats.d.ts +46 -0
  37. package/lib/types/protocol.d.ts +13 -743
  38. package/lib/types/repo/github-client.d.ts +42 -0
  39. package/lib/types/repo.d.ts +6 -17
  40. package/lib/types/routes/catalog.d.ts +7 -0
  41. package/lib/types/routes/config.d.ts +7 -0
  42. package/lib/types/routes/groups.d.ts +7 -0
  43. package/lib/types/routes/helpers.d.ts +145 -0
  44. package/lib/types/routes/market.d.ts +8 -0
  45. package/lib/types/routes/repo-import.d.ts +7 -0
  46. package/lib/types/routes/route-state.d.ts +55 -0
  47. package/lib/types/routes/sources.d.ts +8 -0
  48. package/lib/types/routes.d.ts +5 -34
  49. package/lib/types/stats.d.ts +3 -2
  50. package/lib/types/store/errors.d.ts +10 -0
  51. package/lib/types/store/migrate.d.ts +44 -0
  52. package/lib/types/store/paths.d.ts +32 -0
  53. package/lib/types/store/store.d.ts +133 -0
  54. package/lib/types/store.d.ts +7 -146
  55. package/package.json +28 -27
  56. package/src/client/SkillHubSettingsCard.tsx +17 -3
  57. package/src/client/api.ts +101 -195
  58. package/src/client/helpers.ts +10 -1
  59. package/src/client/locales/common.ts +102 -0
  60. package/src/client/locales/detail.ts +30 -0
  61. package/src/client/locales/market.ts +144 -0
  62. package/src/client/locales/settings.ts +80 -0
  63. package/src/client/locales/skills.ts +128 -0
  64. package/src/client/locales/sources.ts +68 -0
  65. package/src/client/locales.ts +26 -512
  66. package/src/client/panel/MarketSourceRow.tsx +108 -0
  67. package/src/client/panel/MarketView.tsx +12 -253
  68. package/src/client/panel/RepoScanCard.tsx +171 -0
  69. package/src/client/panel/hooks/shared.ts +81 -0
  70. package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
  71. package/src/client/panel/hooks/useGroupFlow.ts +240 -0
  72. package/src/client/panel/hooks/useMarketFlow.ts +421 -0
  73. package/src/client/panel/hooks/useMetaFlow.ts +58 -0
  74. package/src/client/panel/hooks/useSourceFlow.ts +193 -0
  75. package/src/client/panel/market-format.ts +22 -0
  76. package/src/client/panel/useSkillHub.ts +141 -1032
  77. package/src/client/settings-card.tsx +51 -0
  78. package/src/client/settings-form.ts +18 -0
  79. package/src/index.ts +14 -4
  80. package/src/protocol/api.ts +38 -0
  81. package/src/protocol/catalog.ts +168 -0
  82. package/src/protocol/config.ts +145 -0
  83. package/src/protocol/groups.ts +106 -0
  84. package/src/protocol/market.ts +100 -0
  85. package/src/protocol/repo.ts +104 -0
  86. package/src/protocol/sources.ts +146 -0
  87. package/src/protocol/stats.ts +42 -0
  88. package/src/protocol.ts +83 -809
  89. package/src/repo/github-client.ts +131 -0
  90. package/src/repo.test.ts +24 -1
  91. package/src/repo.ts +50 -170
  92. package/src/routes/catalog.ts +309 -0
  93. package/src/routes/config.ts +119 -0
  94. package/src/routes/groups.ts +133 -0
  95. package/src/routes/helpers.ts +489 -0
  96. package/src/routes/market.ts +255 -0
  97. package/src/routes/repo-import.ts +267 -0
  98. package/src/routes/route-state.ts +89 -0
  99. package/src/routes/sources.ts +318 -0
  100. package/src/routes.test.ts +20 -0
  101. package/src/routes.ts +21 -1699
  102. package/src/stats.test.ts +58 -3
  103. package/src/stats.ts +45 -16
  104. package/src/store/errors.ts +14 -0
  105. package/src/store/migrate.ts +272 -0
  106. package/src/store/paths.ts +43 -0
  107. package/src/store/store.ts +532 -0
  108. package/src/store.test.ts +18 -0
  109. package/src/store.ts +7 -737
@@ -1,154 +1,49 @@
1
1
  /**
2
- * useSkillHub — the panel's state owner. Everything the skill hub panel
3
- * knows (catalog, groups, sources, market, dialogs, drafts) plus every
4
- * async flow lives here; the panel itself only renders. All hooks run
5
- * unconditionally at the top, so the panel may early-return for the detail
6
- * and tag-editor views without violating the rules of hooks.
2
+ * useSkillHub — 面板的状态聚合根:只持有跨域共享的通知状态与纯视图状态,
3
+ * 各域状态与动作分散在 ./hooks/*,这里按依赖顺序组装(meta → 目录 →
4
+ * 分组 → 来源 → 市场),轮询与派生组装在此。返回形状与拆分前完全一致,
5
+ * 所有视图无需改动。All hooks run unconditionally at the top, so the panel
6
+ * may early-return for the detail and tag-editor views without violating
7
+ * the rules of hooks.
7
8
  */
8
9
 
9
- import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react'
10
- import type {
11
- CatalogResponse,
12
- CatalogSkill,
13
- DisabledSkill,
14
- GroupsResponse,
15
- HubConfig,
16
- MarketCheckResponse,
17
- MarketSourceRecord,
18
- RepoDiscoverResponse,
19
- RepoImportProgressResponse,
20
- RepoImportResponse,
21
- SkillDetail,
22
- SkillTag,
23
- SourceCheckResult,
24
- SourcesResponse,
25
- UpdateCheckResponse,
26
- WritableRoot,
27
- } from '../../protocol.ts'
10
+ import { useCallback, useEffect, useMemo, useState } from 'react'
28
11
  import type { SkillHubApi } from '../api.ts'
29
- import { errorMessage, tt } from '../helpers.ts'
30
- import { conflictsOnClose, isProjectSource, PRIVATE_SOURCE, sortSkills, type SortKey, type GroupSwitchState } from '../grouping.ts'
31
- import type { BranchChoiceState, ConfirmDialogState, ConflictDialogState, MarketSyncDialogState, VersionChoiceState } from './dialogs.tsx'
32
-
33
- /** Catalog poll interval while the panel is mounted (the provider watcher feeds this). */
34
- const POLL_MS = 5000
35
- /** Relaxed poll for slower-changing data (stats, groups, sources, config). */
36
- const SLOW_POLL_MS = 60_000
37
-
38
- /** localStorage key of the last daily auto-check timestamp (survives sessions). */
39
- const AUTO_CHECK_KEY = 'skill-hub.last-auto-check'
40
- /** Daily auto-check interval: at most one network round per day. */
41
- const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60_000
42
-
43
- /** Whether the daily auto-check should run now (first visit or ≥24h since the last). */
44
- function shouldAutoCheck(): boolean {
45
- try {
46
- const raw = localStorage.getItem(AUTO_CHECK_KEY)
47
- const last = raw === null ? NaN : Number(raw)
48
- if (!Number.isFinite(last)) return true
49
- return Date.now() - last >= AUTO_CHECK_INTERVAL_MS
50
- } catch {
51
- // 无 localStorage(隐私模式等):每次打开都尝试,服务端 5 分钟节流兜底。
52
- return true
53
- }
54
- }
55
-
56
- /** Record that the daily auto-check ran. */
57
- function markAutoChecked(): void {
58
- try {
59
- localStorage.setItem(AUTO_CHECK_KEY, String(Date.now()))
60
- } catch {
61
- // 无 localStorage:不记录,下次打开仍会尝试(服务端节流兜底)。
62
- }
63
- }
64
-
65
- /** One-shot self-update check state. */
66
- type UpdateState =
67
- | { status: 'idle' }
68
- | { status: 'checking' }
69
- | { status: 'ready'; data: UpdateCheckResponse }
70
- | { status: 'error'; message: string }
71
-
72
- /** Repo scanner/import state (drives the market source preview). */
73
- type RepoDiscoverState =
74
- | { status: 'idle' }
75
- | { status: 'scanning' }
76
- | { status: 'ready'; data: RepoDiscoverResponse }
77
- | { status: 'error'; message: string }
78
-
79
- /** Market sources list state. */
80
- type MarketState = { status: 'loading' | 'ready' | 'error'; repos: MarketSourceRecord[] }
81
-
82
- /** One market source's update-check outcome. */
83
- type MarketCheckResult = MarketCheckResponse['results'][number]
12
+ import { isProjectSource, PRIVATE_SOURCE } from '../grouping.ts'
13
+ import { useMetaFlow } from './hooks/useMetaFlow.ts'
14
+ import { useCatalogFlow } from './hooks/useCatalogFlow.ts'
15
+ import { useGroupFlow } from './hooks/useGroupFlow.ts'
16
+ import { useSourceFlow } from './hooks/useSourceFlow.ts'
17
+ import { useMarketFlow } from './hooks/useMarketFlow.ts'
18
+ import { markAutoChecked, shouldAutoCheck, type FlowNotices } from './hooks/shared.ts'
84
19
 
85
20
  /** The hook's result: the panel's complete state + action surface. */
86
21
  export type SkillHubState = ReturnType<typeof useSkillHub>
87
22
 
88
23
  export function useSkillHub(api: SkillHubApi) {
89
- const [catalog, setCatalog] = useState<CatalogResponse | null>(null)
90
- const [loading, setLoading] = useState(true)
24
+ // ------------------------------------------------------- shared notices
25
+ // 多域共用的报错/成功条幅与忙碌开关:setter 本身引用稳定,memo 常驻后
26
+ // 各域回调依赖它不会失稳。
91
27
  const [loadError, setLoadError] = useState<string | null>(null)
92
28
  /** Green success banner (create finished); shown outside the closing form. */
93
29
  const [successBanner, setSuccessBanner] = useState<string | null>(null)
94
- const [updateState, setUpdateState] = useState<UpdateState>({ status: 'idle' })
95
- const [repoDiscoverState, setRepoDiscoverState] = useState<RepoDiscoverState>({ status: 'idle' })
96
- /** Market repo currently being scanned (null when idle); per-row busy state. */
97
- const [scanningRepo, setScanningRepo] = useState<string | null>(null)
98
- const [repoSelected, setRepoSelected] = useState<ReadonlySet<string>>(new Set())
99
- const [repoImporting, setRepoImporting] = useState(false)
100
- const [repoResult, setRepoResult] = useState<RepoImportProgressResponse | null>(null)
101
- const [importJobId, setImportJobId] = useState<string | null>(null)
102
- const pollAbortRef = useRef<AbortController | null>(null)
103
- const [search, setSearch] = useState('')
104
- /** 工作区(项目)路径;空 = 只看用户级技能。 */
105
- const [workspace, setWorkspace] = useState('')
106
- const [detail, setDetail] = useState<SkillDetail | null>(null)
107
- const [detailLoading, setDetailLoading] = useState(false)
108
- const [busyNames, setBusyNames] = useState<ReadonlySet<string>>(new Set())
109
30
  const [batchBusy, setBatchBusy] = useState(false)
110
- const [showForm, setShowForm] = useState(false)
111
- const [formName, setFormName] = useState('')
112
- const [formDesc, setFormDesc] = useState('')
113
- const [formRoot, setFormRoot] = useState<WritableRoot>('user-dsh')
114
- const [formBusy, setFormBusy] = useState(false)
115
- const [formMessage, setFormMessage] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)
116
- const [uses, setUses] = useState<ReadonlyMap<string, { count: number; lastUsed?: number }>>(new Map())
117
- const [hubConfig, setHubConfig] = useState<HubConfig | null>(null)
31
+ const [tagBusy, setTagBusy] = useState(false)
32
+ const shared = useMemo<FlowNotices>(() => ({
33
+ fail: (message: string) => setLoadError(message),
34
+ clearFail: () => setLoadError(null),
35
+ succeed: (message: string | null) => setSuccessBanner(message),
36
+ setTagBusy,
37
+ setBatchBusy,
38
+ }), [])
39
+
40
+ // ------------------------------------------------------------ view state
41
+ // 纯视图状态:不触发数据加载,只影响渲染。
118
42
  const [tab, setTab] = useState<'sources' | 'scenes' | 'market'>('sources')
119
43
  const [skillView, setSkillView] = useState<'flat' | 'groups'>('groups')
120
44
  const [sourceFilter, setSourceFilter] = useState('all')
121
- const [invocationFilter, setInvocationFilter] = useState<'all' | 'model' | 'user'>('all')
122
- const [sortKey, setSortKey] = useState<SortKey>('name')
123
- const [marketState, setMarketState] = useState<MarketState>({ status: 'loading', repos: [] })
124
- const [marketCheck, setMarketCheck] = useState<Readonly<Record<string, MarketCheckResult>>>({})
125
- const [branchChoice, setBranchChoice] = useState<BranchChoiceState | null>(null)
126
- const [branchBusy, setBranchBusy] = useState(false)
127
- const [marketSyncDialog, setMarketSyncDialog] = useState<MarketSyncDialogState | null>(null)
128
- const [syncingMarket, setSyncingMarket] = useState<string | null>(null)
129
- const [syncBusy, setSyncBusy] = useState(false)
130
- const [newSourceName, setNewSourceName] = useState('')
131
- const [groupsState, setGroupsState] = useState<GroupsResponse | null>(null)
132
- const [sourcesState, setSourcesState] = useState<SourcesResponse | null>(null)
133
- const [sourceCheck, setSourceCheck] = useState<Readonly<Record<string, SourceCheckResult>>>({})
134
- const [checkingSource, setCheckingSource] = useState<string | null>(null)
135
- const [syncingSource, setSyncingSource] = useState<string | null>(null)
136
- const [conflictDialog, setConflictDialog] = useState<ConflictDialogState | null>(null)
137
- const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState | null>(null)
138
- const [deleteSkillDialog, setDeleteSkillDialog] = useState<string | null>(null)
139
- const [deleteGroupDialog, setDeleteGroupDialog] = useState<{ name: string; skillNames: string[] } | null>(null)
140
- const [confirmClearTrash, setConfirmClearTrash] = useState(false)
141
- /** 「全部更新」确认对话框(市场 tab)。 */
142
- const [updateAllDialog, setUpdateAllDialog] = useState(false)
143
- const [editingTag, setEditingTag] = useState<SkillTag | null>(null)
144
- const [editName, setEditName] = useState('')
145
- const [membersDraft, setMembersDraft] = useState<ReadonlySet<string>>(new Set())
146
- const [newTagName, setNewTagName] = useState('')
147
- const [tagBusy, setTagBusy] = useState(false)
148
- const [editSearch, setEditSearch] = useState('')
149
45
  /** 分组视图里收起的分组(key 为 tag:<id>、col:<name> 或 project 树键)。技能总数 >80 时首次加载自动折叠 personal。 */
150
46
  const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<string>>(new Set())
151
- const autoCollapsedPersonal = useRef(false)
152
47
  /** 项目级三级树里已细分(按 .dsh/.agents)的项目键。 */
153
48
  const [subdividedProjects, setSubdividedProjects] = useState<ReadonlySet<string>>(new Set())
154
49
  const [showLegend, setShowLegend] = useState(false)
@@ -172,843 +67,64 @@ export function useSkillHub(api: SkillHubApi) {
172
67
  })
173
68
  }, [])
174
69
 
175
- const load = useCallback(async (): Promise<void> => {
176
- try {
177
- const next = await api.catalog(workspace !== '' ? { cwd: workspace } : undefined)
178
- setCatalog(next)
179
- if (!autoCollapsedPersonal.current && next.skills.length + next.disabled.length > 80) {
180
- autoCollapsedPersonal.current = true
181
- setCollapsedGroups((previous) => new Set(previous).add('uncategorized-source'))
182
- }
183
- setLoadError(null)
184
- } catch (error) {
185
- setLoadError(errorMessage(error))
186
- } finally {
187
- setLoading(false)
188
- }
189
- }, [api, workspace])
70
+ const collapsePersonal = useCallback((): void => {
71
+ setCollapsedGroups((previous) => new Set(previous).add('uncategorized-source'))
72
+ }, [])
190
73
 
191
- const loadUses = useCallback(async (): Promise<void> => {
192
- try {
193
- const result = await api.stats()
194
- if (result.available) setUses(new Map(result.stats.map((stat) => [stat.name, { count: stat.count, ...(stat.lastUsed !== undefined ? { lastUsed: stat.lastUsed } : {}) }])))
195
- } catch {
196
- // Invocation counts are best-effort; a stats failure must not disturb the catalog.
197
- }
198
- }, [api])
74
+ const clearSourceFilter = useCallback((): void => {
75
+ setSourceFilter('all')
76
+ }, [])
199
77
 
200
- const loadConfig = useCallback(async (): Promise<void> => {
201
- try {
202
- setHubConfig((await api.config()).config)
203
- } catch {
204
- // Dot colors are cosmetic; a config failure falls back to the defaults.
205
- }
206
- }, [api])
78
+ // ------------------------------------------------------------ meta first
79
+ // 更新检查/统计/配置只依赖 api,最先组装。
80
+ const meta = useMetaFlow(api)
207
81
 
208
- /** Check the hub's own GitHub release once, and again on demand. */
209
- const checkUpdate = useCallback(async (): Promise<void> => {
210
- setUpdateState({ status: 'checking' })
211
- try {
212
- setUpdateState({ status: 'ready', data: await api.updateCheck() })
213
- } catch (error) {
214
- setUpdateState({ status: 'error', message: errorMessage(error) })
215
- }
216
- }, [api])
82
+ // ---------------------------------------------------------------- catalog
83
+ const catalogFlow = useCatalogFlow(api, meta.uses, shared, collapsePersonal, clearSourceFilter)
217
84
 
218
- /** 加载用户 tag 分组 + 系统集合组 + origin 映射。 */
219
- const loadGroups = useCallback(async (): Promise<void> => {
220
- try {
221
- setGroupsState(await api.groups())
222
- } catch (error) {
223
- setLoadError(errorMessage(error))
224
- }
225
- }, [api])
85
+ // ----------------------------------------------------------------- groups
86
+ const groupFlow = useGroupFlow(api, shared, catalogFlow.batchToggleNames, catalogFlow.actionNames)
226
87
 
227
- /** 加载来源记录 + 回收站。 */
228
- const loadSources = useCallback(async (): Promise<void> => {
229
- try {
230
- setSourcesState(await api.sources())
231
- } catch (error) {
232
- setLoadError(errorMessage(error))
233
- }
234
- }, [api])
88
+ // ----------------------------------------------------------------- sources
89
+ const sourceFlow = useSourceFlow(api, shared, catalogFlow.load, groupFlow.loadGroups)
235
90
 
236
- /** 加载市场源列表。 */
237
- const loadMarket = useCallback(async (): Promise<void> => {
238
- try {
239
- const next = await api.market()
240
- setMarketState({ status: 'ready', repos: next.repos })
241
- } catch (error) {
242
- setMarketState({ status: 'error', repos: [] })
243
- setLoadError(errorMessage(error))
244
- }
245
- }, [api])
91
+ // ------------------------------------------------------------------ market
92
+ const marketFlow = useMarketFlow(
93
+ api,
94
+ shared,
95
+ catalogFlow.load,
96
+ groupFlow.loadGroups,
97
+ sourceFlow.loadSources,
98
+ sourceFlow.checkSources,
99
+ sourceFlow.sourceCheck,
100
+ )
246
101
 
247
- /** 把最新的 tag 列表合并进 groups 状态(tags 以外的字段保持原样)。 */
248
- const applyTags = useCallback((tags: SkillTag[]): void => {
249
- setGroupsState((previous) => previous === null
250
- ? { ok: true, tags, collections: [], origins: {} }
251
- : { ...previous, tags })
252
- }, [])
102
+ // ------------------------------------------------------------- derived
103
+ /** skillName → origin repo(无来源记录的技能不在此映射中,筛选中视为 private)。 */
104
+ const origins = groupFlow.groupsState?.origins ?? {}
105
+ /** 来源筛选选项:来源记录中的仓库(排序)+ 末尾的「个人」;没有技能的仓库不列。 */
106
+ const sourceOptions = useMemo(() => {
107
+ const skills = catalogFlow.catalog?.skills ?? []
108
+ const repos = [...new Set(skills.map((skill) => origins[skill.name]).filter((repo): repo is string => repo !== undefined))].sort()
109
+ // 项目级技能(有 workspace 归属)不算「个人」。
110
+ const hasPrivate = skills.some((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
111
+ return [...repos, ...(hasPrivate ? [PRIVATE_SOURCE] : [])]
112
+ }, [catalogFlow.catalog, origins])
253
113
 
254
114
  // 初始全量加载 + 双速轮询:目录是「活视图」(文件变化要 5 秒内可见),
255
115
  // 统计/分组/来源/配置变化慢,60 秒一次足够;写操作本身会触发即时刷新。
256
116
  // 更新检查、来源检查、市场检查全部改为手动按钮触发,打开面板不再自动
257
117
  // 打三路 GitHub 请求。
258
118
  useEffect(() => {
259
- void load()
260
- void loadUses()
261
- void loadGroups()
262
- void loadSources()
263
- void loadConfig()
264
- const fast = window.setInterval(() => { void load() }, POLL_MS)
265
- const slow = window.setInterval(() => { void loadUses(); void loadGroups(); void loadSources(); void loadConfig() }, SLOW_POLL_MS)
119
+ void catalogFlow.load()
120
+ void meta.loadUses()
121
+ void groupFlow.loadGroups()
122
+ void sourceFlow.loadSources()
123
+ void meta.loadConfig()
124
+ const fast = window.setInterval(() => { void catalogFlow.load() }, 5000)
125
+ const slow = window.setInterval(() => { void meta.loadUses(); void groupFlow.loadGroups(); void sourceFlow.loadSources(); void meta.loadConfig() }, 60_000)
266
126
  return () => { window.clearInterval(fast); window.clearInterval(slow) }
267
- }, [load, loadUses, loadGroups, loadSources, loadConfig])
268
-
269
- const openDetail = useCallback(async (name: string): Promise<void> => {
270
- setDetailLoading(true)
271
- setLoadError(null)
272
- try {
273
- setDetail(await api.skill(name, workspace !== '' ? { cwd: workspace } : undefined))
274
- } catch (error) {
275
- setLoadError(errorMessage(error))
276
- } finally {
277
- setDetailLoading(false)
278
- }
279
- }, [api, workspace])
280
-
281
- const toggle = useCallback(async (skill: CatalogSkill, enabled: boolean): Promise<void> => {
282
- setBusyNames((previous) => new Set(previous).add(skill.name))
283
- setLoadError(null)
284
- try {
285
- const next = await api.toggle(skill.name, enabled)
286
- setCatalog(next)
287
- } catch (error) {
288
- setLoadError(errorMessage(error))
289
- } finally {
290
- setBusyNames((previous) => {
291
- const next = new Set(previous)
292
- next.delete(skill.name)
293
- return next
294
- })
295
- }
296
- }, [api])
297
-
298
- const enableDisabled = useCallback(async (record: DisabledSkill): Promise<void> => {
299
- setBusyNames((previous) => new Set(previous).add(record.name))
300
- setLoadError(null)
301
- try {
302
- const next = await api.toggle(record.name, true)
303
- setCatalog(next)
304
- } catch (error) {
305
- setLoadError(errorMessage(error))
306
- } finally {
307
- setBusyNames((previous) => {
308
- const next = new Set(previous)
309
- next.delete(record.name)
310
- return next
311
- })
312
- }
313
- }, [api])
314
-
315
- /** Toggle an explicit name set in one write (enables disabled members too). */
316
- const batchToggleNames = useCallback(async (names: string[], enabled: boolean): Promise<void> => {
317
- if (names.length === 0) return
318
- setBatchBusy(true)
319
- setLoadError(null)
320
- try {
321
- const next = await api.toggleBatch(names, enabled)
322
- setCatalog(next.catalog)
323
- if (next.failures.length > 0) {
324
- setLoadError('toggle-batch: ' + next.failures.map((failure) => failure.name + ': ' + failure.error).join('; '))
325
- }
326
- } catch (error) {
327
- setLoadError(errorMessage(error))
328
- } finally {
329
- setBatchBusy(false)
330
- }
331
- }, [api])
332
-
333
- // -------------------------------------------------------- group switches
334
-
335
- /** 所有分组:key → 成员名(tag 与来源集合)。 */
336
- const groupMap = useCallback((): Map<string, string[]> => {
337
- const map = new Map<string, string[]>()
338
- for (const tag of groupsState?.tags ?? []) map.set('tag:' + tag.id, tag.skillNames)
339
- for (const collection of groupsState?.collections ?? []) map.set('col:' + collection.name, collection.skillNames)
340
- return map
341
- }, [groupsState])
342
-
343
- /** 当前启用且可写的技能名(组开关只作用于它们)。 */
344
- const actionNames = useMemo(() => new Set((catalog?.skills ?? []).filter((skill) => skill.writable).map((skill) => skill.name)), [catalog])
345
-
346
- /** 当前启用的全部技能名(含只读,用于派生开关状态)。 */
347
- const viewNames = useMemo(() => new Set((catalog?.skills ?? []).map((skill) => skill.name)), [catalog])
348
-
349
- /**
350
- * Toggle a whole group via its tri-state switch. Closing with members that
351
- * are enabled in other groups opens the conflict dialog instead.
352
- */
353
- const toggleGroup = useCallback((key: string, name: string, view: GroupSwitchState): void => {
354
- const members = groupMap().get(key) ?? []
355
- if (members.length === 0) return
356
- if (view === 'off') {
357
- void batchToggleNames(members, true)
358
- return
359
- }
360
- const others = [...groupMap().entries()].filter(([otherKey]) => otherKey !== key).map(([, memberNames]) => ({ members: memberNames }))
361
- const conflicts = conflictsOnClose(members, actionNames, others)
362
- if (conflicts.length > 0) {
363
- setConflictDialog({ key, name, conflicts })
364
- } else {
365
- void batchToggleNames(members.filter((member) => actionNames.has(member)), false)
366
- }
367
- }, [groupMap, actionNames, batchToggleNames])
368
-
369
- /** Resolve the open conflict dialog. */
370
- const resolveConflict = useCallback(async (closeAll: boolean): Promise<void> => {
371
- const dialog = conflictDialog
372
- if (dialog === null) return
373
- setConflictDialog(null)
374
- const members = groupMap().get(dialog.key) ?? []
375
- if (closeAll) {
376
- await batchToggleNames(members.filter((member) => actionNames.has(member)), false)
377
- } else {
378
- await batchToggleNames(
379
- members.filter((member) => actionNames.has(member) && !dialog.conflicts.includes(member)),
380
- false,
381
- )
382
- }
383
- }, [conflictDialog, groupMap, actionNames, batchToggleNames])
384
-
385
- /** Dismiss a confirm dialog and run the pending source action. */
386
- const runConfirmed = useCallback(async (): Promise<void> => {
387
- const dialog = confirmDialog
388
- if (dialog === null) return
389
- setConfirmDialog(null)
390
- if (dialog.kind === 'sync') {
391
- setSyncingSource(dialog.repo)
392
- setLoadError(null)
393
- try {
394
- const result = await api.syncSource(dialog.repo, dialog.skills)
395
- await Promise.all([load(), loadGroups(), loadSources()])
396
- if (result.failed.length > 0) {
397
- setLoadError('sync: ' + result.failed.map((failure) => failure.name + ': ' + failure.error).join('; '))
398
- }
399
- } catch (error) {
400
- setLoadError(errorMessage(error))
401
- } finally {
402
- setSyncingSource(null)
403
- }
404
- } else {
405
- setLoadError(null)
406
- try {
407
- await api.confirmDeleteSource(dialog.repo, dialog.skills)
408
- await Promise.all([load(), loadGroups(), loadSources()])
409
- } catch (error) {
410
- setLoadError(errorMessage(error))
411
- }
412
- }
413
- }, [confirmDialog, api, load, loadGroups, loadSources])
414
-
415
- // ---------------------------------------------------------- source ops
416
-
417
- /** 检查全部来源的上游更新(服务端 5 分钟节流)。 */
418
- const checkSources = useCallback(async (repo?: string): Promise<void> => {
419
- setCheckingSource(repo ?? 'all')
420
- setLoadError(null)
421
- try {
422
- const result = await api.checkSources(repo)
423
- const next: Record<string, SourceCheckResult> = { ...sourceCheck }
424
- for (const item of result.results) next[item.repo] = item
425
- setSourceCheck(next)
426
- } catch (error) {
427
- setLoadError(errorMessage(error))
428
- } finally {
429
- setCheckingSource(null)
430
- }
431
- }, [api, sourceCheck])
432
-
433
- /** 请求同步某个来源的所选技能(弹确认,因为会覆盖本地修改)。 */
434
- const requestSync = useCallback((repo: string, skills: string[]): void => {
435
- setConfirmDialog({ kind: 'sync', repo, skills })
436
- }, [])
437
-
438
- /** 请求跟进上游删除(弹确认,移入回收站)。 */
439
- const requestDelete = useCallback((repo: string, skills: string[]): void => {
440
- setConfirmDialog({ kind: 'delete', repo, skills })
441
- }, [])
442
-
443
- /** 从回收站恢复一个技能。 */
444
- const restoreTrash = useCallback(async (name: string): Promise<void> => {
445
- setTagBusy(true)
446
- setLoadError(null)
447
- try {
448
- await api.restoreSource(name)
449
- await Promise.all([load(), loadGroups(), loadSources()])
450
- } catch (error) {
451
- setLoadError(errorMessage(error))
452
- } finally {
453
- setTagBusy(false)
454
- }
455
- }, [api, load, loadGroups, loadSources])
456
-
457
- /** 确认后永久删除回收站里的全部技能。 */
458
- const clearTrash = useCallback(async (): Promise<void> => {
459
- setConfirmClearTrash(false)
460
- setTagBusy(true)
461
- setLoadError(null)
462
- try {
463
- const result = await api.clearTrash()
464
- if (result.failed.length > 0) {
465
- setLoadError('clear trash: ' + result.failed.map((failure) => failure.name + ': ' + failure.error).join('; '))
466
- }
467
- await Promise.all([load(), loadSources()])
468
- } catch (error) {
469
- setLoadError(errorMessage(error))
470
- } finally {
471
- setTagBusy(false)
472
- }
473
- }, [api, load, loadSources])
474
-
475
- const [fixingPaths, setFixingPaths] = useState<ReadonlySet<string>>(new Set())
476
- const fixDiagnostic = useCallback(async (path: string): Promise<void> => {
477
- setFixingPaths((previous) => new Set(previous).add(path))
478
- setLoadError(null)
479
- try {
480
- const confirmed = window.confirm(tt('diag.fixConfirm', { path }))
481
- if (!confirmed) return
482
- await api.fixDiagnostic(path)
483
- await load()
484
- setSuccessBanner(tt('diag.fixed'))
485
- } catch (error) {
486
- setLoadError(errorMessage(error))
487
- } finally {
488
- setFixingPaths((previous) => {
489
- const next = new Set(previous)
490
- next.delete(path)
491
- return next
492
- })
493
- }
494
- }, [api, load])
495
-
496
- /** 打开单个技能的删除确认(移入回收站,可恢复)。 */
497
- const requestDeleteSkill = useCallback((name: string): void => {
498
- setDeleteSkillDialog(name)
499
- }, [])
500
-
501
- /** 执行删除确认后的回收站迁移。 */
502
- const runDeleteSkill = useCallback(async (): Promise<void> => {
503
- const name = deleteSkillDialog
504
- if (name === null) return
505
- setDeleteSkillDialog(null)
506
- setTagBusy(true)
507
- setLoadError(null)
508
- try {
509
- await api.deleteSkill(name)
510
- await Promise.all([load(), loadGroups(), loadSources()])
511
- } catch (error) {
512
- setLoadError(errorMessage(error))
513
- } finally {
514
- setTagBusy(false)
515
- }
516
- }, [api, deleteSkillDialog, load, loadGroups, loadSources])
517
-
518
- /** 打开整组删除确认(来源分组一键删除)。 */
519
- const requestDeleteGroup = useCallback((name: string, skillNames: string[]): void => {
520
- setDeleteGroupDialog({ name, skillNames })
521
- }, [])
522
-
523
- /** 执行整组删除(逐个移入回收站,跳过只读)。 */
524
- const runDeleteGroup = useCallback(async (): Promise<void> => {
525
- const dialog = deleteGroupDialog
526
- if (dialog === null) return
527
- setDeleteGroupDialog(null)
528
- setTagBusy(true)
529
- setLoadError(null)
530
- const failures: string[] = []
531
- let done = 0
532
- for (const name of dialog.skillNames) {
533
- try {
534
- await api.deleteSkill(name)
535
- done += 1
536
- } catch (error) {
537
- // 只读/不存在的跳过并记录
538
- failures.push(name + ': ' + errorMessage(error))
539
- }
540
- }
541
- await Promise.all([load(), loadGroups(), loadSources()])
542
- setTagBusy(false)
543
- if (failures.length > 0) {
544
- setLoadError(`删除整组 "${dialog.name}":成功 ${done} 个,失败 ${failures.length} 个:` + failures.join('; '))
545
- } else if (done > 0) {
546
- setSuccessBanner(`已删除整组 "${dialog.name}":${done} 个技能已移入回收站`)
547
- }
548
- }, [api, deleteGroupDialog, load, loadGroups, loadSources])
549
-
550
- /** 新建一个空 tag 分组。 */
551
- const createTag = useCallback(async (event: FormEvent): Promise<void> => {
552
- event.preventDefault()
553
- const name = newTagName.trim()
554
- if (name === '') return
555
- setTagBusy(true)
556
- setLoadError(null)
557
- try {
558
- applyTags(await api.saveTag({ name }))
559
- setNewTagName('')
560
- } catch (error) {
561
- setLoadError(errorMessage(error))
562
- } finally {
563
- setTagBusy(false)
564
- }
565
- }, [api, applyTags, newTagName])
566
-
567
- /** 删除一个 tag 分组(不影响技能文件)。 */
568
- const deleteTag = useCallback(async (id: string): Promise<void> => {
569
- setTagBusy(true)
570
- setLoadError(null)
571
- try {
572
- applyTags(await api.deleteTag(id))
573
- setEditingTag(null)
574
- } catch (error) {
575
- setLoadError(errorMessage(error))
576
- } finally {
577
- setTagBusy(false)
578
- }
579
- }, [api, applyTags])
580
-
581
- /** 重命名 tag,或保存成员勾选后回到列表。 */
582
- const saveTag = useCallback(async (id: string, name: string, memberNames: string[] | null): Promise<void> => {
583
- setTagBusy(true)
584
- setLoadError(null)
585
- try {
586
- const safeName = name.trim()
587
- let tags: SkillTag[] | null = null
588
- if (safeName !== '') tags = await api.saveTag({ id, name: safeName })
589
- if (memberNames !== null) tags = await api.setTagMembers(id, memberNames)
590
- if (tags === null) return
591
- applyTags(tags)
592
- setEditingTag(null)
593
- } catch (error) {
594
- setLoadError(errorMessage(error))
595
- } finally {
596
- setTagBusy(false)
597
- }
598
- }, [api, applyTags])
599
-
600
- /** 拖拽重排场景分组 */
601
- const reorderTags = useCallback(async (orderedIds: string[]): Promise<void> => {
602
- setTagBusy(true)
603
- setLoadError(null)
604
- try {
605
- const tags = await api.reorderTags(orderedIds)
606
- applyTags(tags)
607
- } catch (error) {
608
- const msg = errorMessage(error)
609
- if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
610
- // 旧宿主无此路由:本地重排
611
- const byId = new Map(groupsState?.tags.map((t) => [t.id, t] as const) ?? [])
612
- const reordered = orderedIds.map((id) => byId.get(id)).filter((t): t is NonNullable<typeof t> => t !== undefined)
613
- if (reordered.length === orderedIds.length) {
614
- applyTags(reordered as typeof groupsState extends { tags: infer T } ? T : never)
615
- setSuccessBanner('已临时调整顺序(本地生效,重启宿主后持久化)')
616
- } else {
617
- setLoadError(msg)
618
- }
619
- } else {
620
- setLoadError(msg)
621
- }
622
- } finally {
623
- setTagBusy(false)
624
- }
625
- }, [api, applyTags, groupsState])
626
-
627
- /** 拖拽重排来源集合 */
628
- const reorderCollections = useCallback(async (orderedNames: string[]): Promise<void> => {
629
- setTagBusy(true)
630
- setLoadError(null)
631
- try {
632
- const collections = await api.reorderCollections(orderedNames)
633
- setGroupsState((prev) => prev === null ? prev : { ...prev, collections })
634
- void loadGroups()
635
- } catch (error) {
636
- const msg = errorMessage(error)
637
- // 404 说明宿主仍在跑旧版(需重启后才有新路由),降级为本地即时生效
638
- if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
639
- setGroupsState((prev) => {
640
- if (prev === null) return prev
641
- const map = new Map(prev.collections.map((c) => [c.name, c] as const))
642
- const reordered = orderedNames.map((n) => map.get(n)).filter((c): c is NonNullable<typeof c> => c !== undefined)
643
- // 补上未在 orderedNames 中的集合(新出现的)
644
- for (const c of prev.collections) if (!reordered.some((r) => r.name === c.name)) reordered.push(c)
645
- return { ...prev, collections: reordered }
646
- })
647
- setSuccessBanner('已临时调整顺序(本地生效,重启宿主后持久化)')
648
- } else {
649
- setLoadError(msg)
650
- }
651
- } finally {
652
- setTagBusy(false)
653
- }
654
- }, [api, loadGroups])
655
-
656
- /** 拖拽重排来源顶层分组(project / col:xxx / personal 全量可拖) */
657
- const reorderSourceGroups = useCallback(async (orderedKeys: string[]): Promise<void> => {
658
- setTagBusy(true)
659
- setLoadError(null)
660
- try {
661
- await api.reorderSourceGroups(orderedKeys)
662
- await loadGroups()
663
- } catch (error) {
664
- const msg = errorMessage(error)
665
- if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
666
- // 旧宿主无此路由:本地重排顶层顺序,提示重启后持久化
667
- setGroupsState((prev) => {
668
- if (prev === null) return prev
669
- // 本地重排 collections 以匹配 topOrderedKeys 中的 col:xxx 顺序
670
- const colOrder = orderedKeys.filter((k) => k.startsWith('col:')).map((k) => k.slice(4))
671
- const map = new Map(prev.collections.map((c) => [c.name, c] as const))
672
- const reordered = colOrder.map((n) => map.get(n)).filter((c): c is NonNullable<typeof c> => c !== undefined)
673
- for (const c of prev.collections) if (!reordered.some((r) => r.name === c.name)) reordered.push(c)
674
- return { ...prev, collections: reordered, sourceGroupOrder: orderedKeys }
675
- })
676
- setSuccessBanner('已临时调整顺序(本地生效,重启宿主后持久化)')
677
- } else {
678
- setLoadError(msg)
679
- }
680
- } finally {
681
- setTagBusy(false)
682
- }
683
- }, [api, loadGroups])
684
-
685
- // -------------------------------------------------------------- market
686
-
687
- /** 添加一个市场源(内置市场目录与手动输入共用),并立即扫描它。 */
688
- const addSource = useCallback(async (input: string): Promise<void> => {
689
- const value = input.trim()
690
- if (value === '') return
691
- setLoadError(null)
692
- try {
693
- const result = await api.addMarketSource(value)
694
- setMarketState({ status: 'ready', repos: result.repos })
695
- setNewSourceName('')
696
- setRepoResult(null)
697
- setRepoSelected(new Set())
698
- // 用服务端归一化后的 slug 扫描(URL/owner@repo 输入都能对上记录)。
699
- const slug = result.repos[result.repos.length - 1].repo
700
- setScanningRepo(slug)
701
- setRepoDiscoverState({ status: 'scanning' })
702
- try {
703
- const data = await api.repoDiscover(slug)
704
- setRepoDiscoverState({ status: 'ready', data })
705
- } catch (error) {
706
- setRepoDiscoverState({ status: 'error', message: errorMessage(error) })
707
- } finally {
708
- setScanningRepo(null)
709
- }
710
- } catch (error) {
711
- setLoadError(errorMessage(error))
712
- }
713
- }, [api])
714
-
715
- /** 手动输入框的添加动作。 */
716
- const addMarketSource = useCallback(async (): Promise<void> => {
717
- await addSource(newSourceName)
718
- }, [addSource, newSourceName])
719
-
720
- /** 删除一个市场源(不影响已装技能)。 */
721
- const removeMarketSource = useCallback(async (repo: string): Promise<void> => {
722
- setTagBusy(true)
723
- setLoadError(null)
724
- try {
725
- const result = await api.removeMarketSource(repo)
726
- setMarketState({ status: 'ready', repos: result.repos })
727
- } catch (error) {
728
- setLoadError(errorMessage(error))
729
- } finally {
730
- setTagBusy(false)
731
- }
732
- }, [api])
733
-
734
- /** 扫描一个市场源(或手动输入)的仓库。 */
735
- const scanRepo = useCallback(async (input: string): Promise<void> => {
736
- const value = input.trim()
737
- if (value === '') return
738
- setRepoResult(null)
739
- setRepoSelected(new Set())
740
- setScanningRepo(value)
741
- setRepoDiscoverState({ status: 'scanning' })
742
- setLoadError(null)
743
- try {
744
- const data = await api.repoDiscover(value)
745
- if (data.ref === null) {
746
- // 无 release 且未定版:让用户选分支(默认第一项,通常是 main)。
747
- setBranchChoice({ repo: data.repo, branches: data.branches ?? [], selected: (data.branches ?? [])[0] ?? 'main' })
748
- setRepoDiscoverState({ status: 'idle' })
749
- return
750
- }
751
- setRepoDiscoverState({ status: 'ready', data })
752
- // The discover route may have auto-pinned the release; refresh the list so the ref badge shows.
753
- void loadMarket()
754
- } catch (error) {
755
- setRepoDiscoverState({ status: 'error', message: errorMessage(error) })
756
- } finally {
757
- setScanningRepo(null)
758
- }
759
- }, [api, loadMarket])
760
-
761
- /** 确认分支选择:持久化 ref 后重新扫描。 */
762
- const confirmBranchChoice = useCallback(async (): Promise<void> => {
763
- if (branchChoice === null) return
764
- setBranchBusy(true)
765
- setLoadError(null)
766
- try {
767
- await api.setMarketSourceRef(branchChoice.repo, branchChoice.selected)
768
- setBranchChoice(null)
769
- await scanRepo(branchChoice.repo)
770
- } catch (error) {
771
- setLoadError(errorMessage(error))
772
- } finally {
773
- setBranchBusy(false)
774
- }
775
- }, [api, branchChoice, scanRepo])
776
-
777
- /** 版本对话框:打开时拉 releases + branches,确认后定版并重扫。 */
778
- const [versionDialog, setVersionDialog] = useState<VersionChoiceState | null>(null)
779
- const [versionBusy, setVersionBusy] = useState(false)
780
- const openVersionDialog = useCallback(async (repo: string): Promise<void> => {
781
- const current = marketState.repos.find((item) => item.repo === repo)?.ref
782
- setVersionDialog({ repo, ...(current !== undefined ? { current } : {}), releases: [], branches: [], selected: current ?? '', custom: '', loading: true })
783
- setLoadError(null)
784
- try {
785
- const data = await api.marketSourceVersions(repo)
786
- setVersionDialog({
787
- repo,
788
- ...(data.current !== undefined ? { current: data.current } : {}),
789
- releases: data.releases,
790
- branches: data.branches,
791
- selected: data.current ?? data.releases[0] ?? data.branches[0] ?? '',
792
- custom: '',
793
- loading: false,
794
- })
795
- } catch (error) {
796
- setLoadError(errorMessage(error))
797
- setVersionDialog(null)
798
- }
799
- }, [api, marketState])
800
- const confirmVersionDialog = useCallback(async (): Promise<void> => {
801
- if (versionDialog === null) return
802
- const ref = versionDialog.custom.trim() !== '' ? versionDialog.custom.trim() : versionDialog.selected
803
- if (ref === '') return
804
- setVersionBusy(true)
805
- setLoadError(null)
806
- try {
807
- await api.setMarketSourceRef(versionDialog.repo, ref)
808
- setVersionDialog(null)
809
- await loadMarket()
810
- await scanRepo(versionDialog.repo)
811
- } catch (error) {
812
- setLoadError(errorMessage(error))
813
- } finally {
814
- setVersionBusy(false)
815
- }
816
- }, [api, versionDialog, loadMarket, scanRepo])
817
-
818
- /** Toggle one repo preview row. */
819
- const toggleRepoSelected = useCallback((path: string, checked: boolean): void => {
820
- setRepoSelected((previous) => {
821
- const next = new Set(previous)
822
- if (checked) next.add(path)
823
- else next.delete(path)
824
- return next
825
- })
826
- }, [])
827
-
828
- /** Import every checked, non-existing repo skill (B方案:job+轮询+进度). */
829
- const importRepo = useCallback(async (): Promise<void> => {
830
- if (repoDiscoverState.status !== 'ready') return
831
- setRepoImporting(true)
832
- setRepoResult(null)
833
- setImportJobId(null)
834
- setLoadError(null)
835
- let finalProgress: RepoImportProgressResponse | null = null
836
- try {
837
- const created = await api.repoImport(repoDiscoverState.data.repo, [...repoSelected], repoDiscoverState.data.ref ?? undefined)
838
- setImportJobId(created.jobId)
839
- // 初始占位,让进度卡片立刻出现(带真实 totalBytes)
840
- setRepoResult({ ok: true, jobId: created.jobId, status: 'running', total: created.total, done: 0, totalBytes: created.totalBytes, downloadedBytes: 0, imported: [], skipped: [], failed: [] })
841
- // 轮询直到 done/cancelled/error(800ms 起步 + 退避,避免 276 技能 900 次请求)
842
- pollAbortRef.current?.abort()
843
- pollAbortRef.current = new AbortController()
844
- const signal = pollAbortRef.current.signal
845
- let attempt = 0
846
- for (;;) {
847
- if (signal.aborted) break
848
- const delay = Math.min(2000, 800 + attempt * 200)
849
- await new Promise((r) => setTimeout(r, delay))
850
- if (signal.aborted) break
851
- try {
852
- const progress = await api.repoImportProgress(created.jobId)
853
- setRepoResult(progress)
854
- finalProgress = progress
855
- if (progress.status !== 'running') {
856
- break
857
- }
858
- attempt = 0
859
- } catch (pollError) {
860
- // 轮询失败退避重试
861
- const msg = errorMessage(pollError)
862
- if (msg.includes('not found')) break
863
- attempt += 1
864
- if (attempt > 8) break
865
- }
866
- }
867
- await Promise.all([load(), loadMarket(), loadGroups(), loadSources()])
868
- } catch (error) {
869
- setLoadError(errorMessage(error))
870
- } finally {
871
- // 导入完成后把已导入/已跳过的项从勾选中移除,并把扫描快照里的 existing 置为 true,避免“仍可勾选已导入”的错觉
872
- if (finalProgress !== null && (finalProgress.imported.length > 0 || finalProgress.skipped.length > 0)) {
873
- const doneNames = new Set([...finalProgress.imported.map((r) => r.name), ...finalProgress.skipped.map((r) => r.name)])
874
- const donePaths = new Set(
875
- repoDiscoverState.data.entries.filter((e) => doneNames.has(e.name)).map((e) => e.path),
876
- )
877
- setRepoSelected((prev) => {
878
- const next = new Set(prev)
879
- for (const p of donePaths) next.delete(p)
880
- return next
881
- })
882
- setRepoDiscoverState((prev) => {
883
- if (prev.status !== 'ready') return prev
884
- return {
885
- ...prev,
886
- data: {
887
- ...prev.data,
888
- entries: prev.data.entries.map((e) => doneNames.has(e.name) ? { ...e, existing: true } : e),
889
- },
890
- }
891
- })
892
- }
893
- setRepoImporting(false)
894
- }
895
- }, [api, repoDiscoverState, repoSelected, load, loadMarket, loadGroups, loadSources])
896
-
897
- // 卸载时中断轮询,避免泄露
898
- useEffect(() => {
899
- return () => { pollAbortRef.current?.abort() }
900
- }, [])
901
-
902
- /** 取消正在进行的导入(选项2:唯有取消才停) */
903
- const cancelImport = useCallback(async (): Promise<void> => {
904
- if (importJobId === null) return
905
- pollAbortRef.current?.abort()
906
- try {
907
- const res = await api.repoImportCancel(importJobId)
908
- // 立刻刷新一次进度
909
- try {
910
- const progress = await api.repoImportProgress(importJobId)
911
- setRepoResult(progress)
912
- } catch {
913
- setRepoResult((prev) => prev !== null ? { ...prev, status: res.status as 'cancelled' } : prev)
914
- }
915
- } catch (error) {
916
- setLoadError(errorMessage(error))
917
- } finally {
918
- setRepoImporting(false)
919
- }
920
- }, [api, importJobId])
921
-
922
- /** 清空扫描结果(关闭归属卡片) */
923
- const clearScan = useCallback((): void => {
924
- setRepoDiscoverState({ status: 'idle' })
925
- setScanningRepo(null)
926
- setRepoResult(null)
927
- setRepoSelected(new Set())
928
- setImportJobId(null)
929
- pollAbortRef.current?.abort()
930
- }, [])
931
-
932
- /** 检查所有市场源的上游更新(服务端节流)。 */
933
- const checkMarket = useCallback(async (): Promise<void> => {
934
- try {
935
- const result = await api.marketCheck()
936
- const next: Record<string, MarketCheckResult> = {}
937
- for (const item of result.results) next[item.repo] = item
938
- setMarketCheck(next)
939
- } catch {
940
- // 检查失败不打扰市场列表本身。
941
- }
942
- }, [api])
943
-
944
- /** 市场源同步:版本对齐后询问是否批量更新本地技能。 */
945
- const syncMarketSource = useCallback(async (repo: string): Promise<void> => {
946
- setSyncingMarket(repo)
947
- setLoadError(null)
948
- try {
949
- const result = await api.marketSync(repo)
950
- setMarketSyncDialog({ repo: result.repo, ref: result.ref, skills: result.skills, selected: new Set(result.skills) })
951
- await loadMarket()
952
- } catch (error) {
953
- setLoadError(errorMessage(error))
954
- } finally {
955
- setSyncingMarket(null)
956
- }
957
- }, [api, loadMarket])
958
-
959
- /** 批量更新本地技能到市场源当前版本(复用来源同步路径)。 */
960
- const confirmMarketSync = useCallback(async (): Promise<void> => {
961
- if (marketSyncDialog === null) return
962
- const selected = [...marketSyncDialog.selected]
963
- setSyncBusy(true)
964
- setLoadError(null)
965
- try {
966
- if (selected.length > 0) {
967
- const result = await api.syncSource(marketSyncDialog.repo, selected)
968
- if (result.failed.length > 0) setLoadError(result.failed.map((item) => item.name + ': ' + item.error).join('\n'))
969
- }
970
- setMarketSyncDialog(null)
971
- await Promise.all([load(), loadGroups(), loadSources()])
972
- void checkMarket()
973
- } catch (error) {
974
- setLoadError(errorMessage(error))
975
- } finally {
976
- setSyncBusy(false)
977
- }
978
- }, [api, marketSyncDialog, load, loadGroups, loadSources, checkMarket])
979
-
980
- /**
981
- * 一键全部更新:把每个「有可更新技能」的来源逐个同步到上游最新版本
982
- * (复用来源同步路径,覆盖本地修改),随后刷新并重新检查。
983
- */
984
- const updateAll = useCallback(async (): Promise<void> => {
985
- setUpdateAllDialog(false)
986
- setBatchBusy(true)
987
- setLoadError(null)
988
- const failures: string[] = []
989
- let done = 0
990
- try {
991
- for (const [repo, check] of Object.entries(sourceCheck)) {
992
- if (!check.changed || check.updated.length === 0) continue
993
- try {
994
- const result = await api.syncSource(repo, check.updated)
995
- done += 1
996
- if (result.failed.length > 0) {
997
- failures.push(tt('market.updateAllItem', { repo, count: result.failed.length }) + ': ' + result.failed.map((item) => item.name + ': ' + item.error).join('; '))
998
- }
999
- } catch (error) {
1000
- failures.push(repo + ': ' + errorMessage(error))
1001
- }
1002
- }
1003
- await Promise.all([load(), loadGroups(), loadSources()])
1004
- await checkSources()
1005
- void checkMarket()
1006
- if (failures.length > 0) setLoadError(failures.join('\n'))
1007
- else if (done > 0) setSuccessBanner(tt('market.updateAllDone', { count: done }))
1008
- } finally {
1009
- setBatchBusy(false)
1010
- }
1011
- }, [api, sourceCheck, checkSources, checkMarket, load, loadGroups, loadSources])
127
+ }, [catalogFlow.load, meta.loadUses, groupFlow.loadGroups, sourceFlow.loadSources, meta.loadConfig])
1012
128
 
1013
129
  // ------------------------------------------------- daily auto-check
1014
130
  // 打开面板时每天最多自动检查一次(自身更新 + 全部来源 + 全部市场源),
@@ -1016,88 +132,81 @@ export function useSkillHub(api: SkillHubApi) {
1016
132
  useEffect(() => {
1017
133
  if (!shouldAutoCheck()) return
1018
134
  markAutoChecked()
1019
- void checkUpdate()
1020
- void checkSources()
1021
- void checkMarket()
135
+ void meta.checkUpdate()
136
+ void sourceFlow.checkSources()
137
+ void marketFlow.checkMarket()
1022
138
  // eslint-disable-next-line react-hooks/exhaustive-deps
1023
139
  }, [])
1024
140
 
1025
- // ---------------------------------------------------------------- create
1026
-
1027
- const create = useCallback(async (event: FormEvent): Promise<void> => {
1028
- event.preventDefault()
1029
- setFormBusy(true)
1030
- setFormMessage(null)
1031
- setSuccessBanner(null)
1032
- try {
1033
- const result = await api.create({ name: formName, description: formDesc, root: formRoot })
1034
- // The form closes on success, so the confirmation lives in the green
1035
- // banner outside it (a success message inside the closing form is
1036
- // never visible).
1037
- setSuccessBanner(tt('form.success') + result.path)
1038
- setFormName('')
1039
- setFormDesc('')
1040
- setShowForm(false)
1041
- await load()
1042
- } catch (error) {
1043
- setFormMessage({ kind: 'error', text: tt('form.error') + errorMessage(error) })
1044
- } finally {
1045
- setFormBusy(false)
1046
- }
1047
- }, [api, formName, formDesc, formRoot, load])
1048
-
1049
- // ------------------------------------------------------------- derived
1050
-
1051
- const normalized = search.trim().toLocaleLowerCase()
1052
- /** skillName → origin repo(无来源记录的技能不在此映射中,筛选中视为 private)。 */
1053
- const origins = groupsState?.origins ?? {}
1054
- /** 来源筛选选项:来源记录中的仓库(排序)+ 末尾的「个人」;没有技能的仓库不列。 */
1055
- const sourceOptions = useMemo(() => {
1056
- const skills = catalog?.skills ?? []
1057
- const repos = [...new Set(skills.map((skill) => origins[skill.name]).filter((repo): repo is string => repo !== undefined))].sort()
1058
- // 项目级技能(有 workspace 归属)不算「个人」。
1059
- const hasPrivate = skills.some((skill) => origins[skill.name] === undefined && !isProjectSource(skill.source))
1060
- return [...repos, ...(hasPrivate ? [PRIVATE_SOURCE] : [])]
1061
- }, [catalog, origins])
1062
- const filtered = useMemo(() => (catalog?.skills ?? []).filter((skill) => {
1063
- if (invocationFilter === 'model' && !skill.invocation.modelInvocable) return false
1064
- if (invocationFilter === 'user' && !skill.invocation.userInvocable) return false
1065
- if (normalized.length === 0) return true
1066
- return skill.name.toLocaleLowerCase().includes(normalized)
1067
- || skill.description.toLocaleLowerCase().includes(normalized)
1068
- || skill.displayName?.toLocaleLowerCase().includes(normalized)
1069
- || skill.shortDescription?.toLocaleLowerCase().includes(normalized)
1070
- }), [catalog, normalized, invocationFilter])
1071
- /** Rows actually rendered with a shortened description (shortDescription in use). */
1072
- const shortenedCount = useMemo(() => filtered.filter((skill) => skill.shortDescription !== undefined).length, [filtered])
1073
- /** 排序后的技能列表(所有视图共用;调用次数未知按 0 处理)。 */
1074
- const sorted = useMemo(() => sortSkills(filtered, sortKey, (name) => uses.get(name)?.count), [filtered, sortKey, uses])
1075
-
1076
- /** Clear the list filters (search + source + invocation) back to the full view. */
1077
- const clearListFilters = useCallback((): void => {
1078
- setSearch('')
1079
- setSourceFilter('all')
1080
- setInvocationFilter('all')
1081
- }, [])
1082
-
1083
141
  return {
1084
142
  // state
1085
- catalog, loading, loadError, successBanner, updateState, repoDiscoverState, scanningRepo, repoSelected, repoImporting, repoResult, importJobId,
1086
- search, workspace, detail, detailLoading, busyNames, batchBusy, showForm, formName, formDesc, formRoot, formBusy, formMessage,
1087
- uses, hubConfig, tab, skillView, sourceFilter, invocationFilter, sortKey, marketState, marketCheck, branchChoice, branchBusy,
1088
- marketSyncDialog, syncingMarket, syncBusy, newSourceName, groupsState, sourcesState, sourceCheck, checkingSource, syncingSource,
1089
- conflictDialog, confirmDialog, deleteSkillDialog, deleteGroupDialog, confirmClearTrash, updateAllDialog, editingTag, editName, membersDraft, newTagName, tagBusy,
1090
- editSearch, collapsedGroups, subdividedProjects, showLegend, editMode, versionDialog, versionBusy,
143
+ catalog: catalogFlow.catalog, loading: catalogFlow.loading, loadError, successBanner,
144
+ updateState: meta.updateState,
145
+ repoDiscoverState: marketFlow.repoDiscoverState, scanningRepo: marketFlow.scanningRepo,
146
+ repoSelected: marketFlow.repoSelected, repoImporting: marketFlow.repoImporting,
147
+ repoResult: marketFlow.repoResult, importJobId: marketFlow.importJobId,
148
+ search: catalogFlow.search, workspace: catalogFlow.workspace,
149
+ detail: catalogFlow.detail, detailLoading: catalogFlow.detailLoading,
150
+ busyNames: catalogFlow.busyNames, batchBusy,
151
+ showForm: catalogFlow.showForm, formName: catalogFlow.formName, formDesc: catalogFlow.formDesc,
152
+ formRoot: catalogFlow.formRoot, formBusy: catalogFlow.formBusy, formMessage: catalogFlow.formMessage,
153
+ uses: meta.uses, hubConfig: meta.hubConfig, tab, skillView, sourceFilter,
154
+ invocationFilter: catalogFlow.invocationFilter, sortKey: catalogFlow.sortKey,
155
+ marketState: marketFlow.marketState, marketCheck: marketFlow.marketCheck,
156
+ marketStats: marketFlow.marketStats, branchChoice: marketFlow.branchChoice, branchBusy: marketFlow.branchBusy,
157
+ marketSyncDialog: marketFlow.marketSyncDialog, syncingMarket: marketFlow.syncingMarket,
158
+ syncBusy: marketFlow.syncBusy, newSourceName: marketFlow.newSourceName,
159
+ groupsState: groupFlow.groupsState, sourcesState: sourceFlow.sourcesState,
160
+ sourceCheck: sourceFlow.sourceCheck, checkingSource: sourceFlow.checkingSource,
161
+ syncingSource: sourceFlow.syncingSource,
162
+ conflictDialog: groupFlow.conflictDialog, confirmDialog: sourceFlow.confirmDialog,
163
+ deleteSkillDialog: sourceFlow.deleteSkillDialog, deleteGroupDialog: sourceFlow.deleteGroupDialog,
164
+ confirmClearTrash: sourceFlow.confirmClearTrash, updateAllDialog: marketFlow.updateAllDialog,
165
+ editingTag: groupFlow.editingTag, editName: groupFlow.editName, membersDraft: groupFlow.membersDraft,
166
+ newTagName: groupFlow.newTagName, tagBusy,
167
+ editSearch: groupFlow.editSearch, collapsedGroups, subdividedProjects, showLegend, editMode,
168
+ versionDialog: marketFlow.versionDialog, versionBusy: marketFlow.versionBusy,
1091
169
  // derived
1092
- actionNames, viewNames, normalized, origins, sourceOptions, filtered, sorted, shortenedCount,
170
+ actionNames: catalogFlow.actionNames, viewNames: catalogFlow.viewNames,
171
+ normalized: catalogFlow.normalized, origins, sourceOptions,
172
+ filtered: catalogFlow.filtered, sorted: catalogFlow.sorted, shortenedCount: catalogFlow.shortenedCount,
1093
173
  // actions + setters
1094
- setLoadError, setSuccessBanner, setSearch, setWorkspace, setDetail, setShowForm, setFormName, setFormDesc, setFormRoot, setFormMessage,
1095
- setRepoSelected, setTab, setSkillView,
1096
- setSourceFilter, setInvocationFilter, setSortKey, setBranchChoice, setMarketSyncDialog, setNewSourceName, setConflictDialog, setConfirmDialog,
1097
- setDeleteSkillDialog, setDeleteGroupDialog, setConfirmClearTrash, setUpdateAllDialog, setEditingTag, setEditName, setMembersDraft, setNewTagName, setEditSearch, setShowLegend, setEditMode, setVersionDialog,
1098
- toggleGroupCollapse, toggleSubdivide, checkUpdate, loadMarket, openDetail, toggle, enableDisabled, batchToggleNames, toggleGroup, resolveConflict,
1099
- runConfirmed, checkSources, requestSync, requestDelete, restoreTrash, clearTrash, fixingPaths, fixDiagnostic, clearListFilters, openVersionDialog, confirmVersionDialog, requestDeleteSkill, runDeleteSkill, requestDeleteGroup, runDeleteGroup, createTag,
1100
- deleteTag, saveTag, reorderTags, reorderCollections, reorderSourceGroups, addSource, addMarketSource, removeMarketSource, scanRepo, confirmBranchChoice, toggleRepoSelected, importRepo, cancelImport, clearScan,
1101
- checkMarket, syncMarketSource, confirmMarketSync, updateAll, create,
174
+ setLoadError, setSuccessBanner, setSearch: catalogFlow.setSearch, setWorkspace: catalogFlow.setWorkspace,
175
+ setDetail: catalogFlow.setDetail, setShowForm: catalogFlow.setShowForm, setFormName: catalogFlow.setFormName,
176
+ setFormDesc: catalogFlow.setFormDesc, setFormRoot: catalogFlow.setFormRoot,
177
+ setFormMessage: catalogFlow.setFormMessage,
178
+ setRepoSelected: marketFlow.setRepoSelected, setTab, setSkillView,
179
+ setSourceFilter, setInvocationFilter: catalogFlow.setInvocationFilter, setSortKey: catalogFlow.setSortKey,
180
+ setBranchChoice: marketFlow.setBranchChoice, setMarketSyncDialog: marketFlow.setMarketSyncDialog,
181
+ setNewSourceName: marketFlow.setNewSourceName, setConflictDialog: groupFlow.setConflictDialog,
182
+ setConfirmDialog: sourceFlow.setConfirmDialog,
183
+ setDeleteSkillDialog: sourceFlow.setDeleteSkillDialog, setDeleteGroupDialog: sourceFlow.setDeleteGroupDialog,
184
+ setConfirmClearTrash: sourceFlow.setConfirmClearTrash, setUpdateAllDialog: marketFlow.setUpdateAllDialog,
185
+ setEditingTag: groupFlow.setEditingTag, setEditName: groupFlow.setEditName,
186
+ setMembersDraft: groupFlow.setMembersDraft, setNewTagName: groupFlow.setNewTagName,
187
+ setEditSearch: groupFlow.setEditSearch, setShowLegend, setEditMode,
188
+ setVersionDialog: marketFlow.setVersionDialog,
189
+ toggleGroupCollapse, toggleSubdivide, checkUpdate: meta.checkUpdate, loadMarket: marketFlow.loadMarket,
190
+ openDetail: catalogFlow.openDetail, toggle: catalogFlow.toggle, enableDisabled: catalogFlow.enableDisabled,
191
+ batchToggleNames: catalogFlow.batchToggleNames, toggleGroup: groupFlow.toggleGroup,
192
+ resolveConflict: groupFlow.resolveConflict,
193
+ runConfirmed: sourceFlow.runConfirmed, checkSources: sourceFlow.checkSources,
194
+ requestSync: sourceFlow.requestSync, requestDelete: sourceFlow.requestDelete,
195
+ restoreTrash: sourceFlow.restoreTrash, clearTrash: sourceFlow.clearTrash,
196
+ fixingPaths: catalogFlow.fixingPaths, fixDiagnostic: catalogFlow.fixDiagnostic,
197
+ clearListFilters: catalogFlow.clearListFilters, openVersionDialog: marketFlow.openVersionDialog,
198
+ confirmVersionDialog: marketFlow.confirmVersionDialog, requestDeleteSkill: sourceFlow.requestDeleteSkill,
199
+ runDeleteSkill: sourceFlow.runDeleteSkill, requestDeleteGroup: sourceFlow.requestDeleteGroup,
200
+ runDeleteGroup: sourceFlow.runDeleteGroup, createTag: groupFlow.createTag,
201
+ deleteTag: groupFlow.deleteTag, saveTag: groupFlow.saveTag, reorderTags: groupFlow.reorderTags,
202
+ reorderCollections: groupFlow.reorderCollections, reorderSourceGroups: groupFlow.reorderSourceGroups,
203
+ addSource: marketFlow.addSource, addMarketSource: marketFlow.addMarketSource,
204
+ removeMarketSource: marketFlow.removeMarketSource, scanRepo: marketFlow.scanRepo,
205
+ confirmBranchChoice: marketFlow.confirmBranchChoice, toggleRepoSelected: marketFlow.toggleRepoSelected,
206
+ importRepo: marketFlow.importRepo, cancelImport: marketFlow.cancelImport,
207
+ clearScan: marketFlow.clearScan,
208
+ checkMarket: marketFlow.checkMarket, loadMarketStats: marketFlow.loadMarketStats,
209
+ syncMarketSource: marketFlow.syncMarketSource, confirmMarketSync: marketFlow.confirmMarketSync,
210
+ updateAll: marketFlow.updateAll, create: catalogFlow.create,
1102
211
  }
1103
212
  }