dsh-skill-hub 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -33
- package/lib/client.js +3477 -436
- package/lib/client.js.map +1 -1
- package/lib/index.js +2408 -366
- package/lib/types/client/SkillHubSettingsCard.d.ts +5 -0
- package/lib/types/client/api-config-scope.d.ts +6 -0
- package/lib/types/client/api.d.ts +44 -2
- package/lib/types/client/grouping.d.ts +64 -0
- package/lib/types/client/locales.d.ts +155 -18
- package/lib/types/client/market-catalog.d.ts +14 -0
- package/lib/types/client/panel/DisabledRow.d.ts +12 -0
- package/lib/types/client/panel/GroupSummary.d.ts +10 -0
- package/lib/types/client/panel/MarketView.d.ts +11 -0
- package/lib/types/client/panel/ScenesView.d.ts +11 -0
- package/lib/types/client/panel/SkillDetailView.d.ts +31 -0
- package/lib/types/client/panel/SkillHubPanel.d.ts +11 -3
- package/lib/types/client/panel/SkillRow.d.ts +11 -0
- package/lib/types/client/panel/SourceStatusBadge.d.ts +18 -0
- package/lib/types/client/panel/SourcesView.d.ts +10 -0
- package/lib/types/client/panel/TagEditorView.d.ts +29 -0
- package/lib/types/client/panel/dialogs.d.ts +73 -0
- package/lib/types/client/panel/format.d.ts +14 -0
- package/lib/types/client/panel/useSkillHub.d.ts +183 -0
- package/lib/types/client/settings-card.d.ts +21 -4
- package/lib/types/client/settings-form.d.ts +2 -0
- package/lib/types/client/settings-nav-icon.d.ts +8 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/protocol.d.ts +428 -7
- package/lib/types/provider.d.ts +6 -1
- package/lib/types/repo.d.ts +133 -0
- package/lib/types/routes.d.ts +9 -4
- package/lib/types/skillfs.d.ts +19 -14
- package/lib/types/stats.d.ts +17 -4
- package/lib/types/store.d.ts +112 -6
- package/lib/types/update.d.ts +20 -0
- package/package.json +5 -3
- package/src/client/SkillHubSettingsCard.tsx +61 -8
- package/src/client/api-config-scope.test.ts +79 -0
- package/src/client/api-config-scope.ts +28 -4
- package/src/client/api.ts +239 -9
- package/src/client/grouping.test.ts +109 -0
- package/src/client/grouping.ts +120 -0
- package/src/client/index.tsx +5 -2
- package/src/client/locales.ts +314 -40
- package/src/client/market-catalog.ts +22 -0
- package/src/client/panel/DisabledRow.tsx +32 -0
- package/src/client/panel/GroupSummary.tsx +27 -0
- package/src/client/panel/MarketView.tsx +173 -0
- package/src/client/panel/ScenesView.tsx +75 -0
- package/src/client/panel/SkillDetailView.tsx +95 -0
- package/src/client/panel/SkillHubPanel.tsx +251 -257
- package/src/client/panel/SkillRow.tsx +54 -0
- package/src/client/panel/SourceStatusBadge.tsx +61 -0
- package/src/client/panel/SourcesView.tsx +104 -0
- package/src/client/panel/TagEditorView.tsx +89 -0
- package/src/client/panel/dialogs.tsx +185 -0
- package/src/client/panel/format.ts +31 -0
- package/src/client/panel/panel.module.css +209 -45
- package/src/client/panel/useSkillHub.ts +797 -0
- package/src/client/settings-card.module.css +44 -28
- package/src/client/settings-card.tsx +64 -8
- package/src/client/settings-form.ts +12 -0
- package/src/client/settings-nav-icon.ts +42 -0
- package/src/index.ts +19 -15
- package/src/protocol.ts +460 -7
- package/src/provider.test.ts +2 -4
- package/src/provider.ts +18 -14
- package/src/repo.test.ts +212 -0
- package/src/repo.ts +528 -0
- package/src/routes.test.ts +564 -52
- package/src/routes.ts +999 -153
- package/src/skillfs.test.ts +98 -16
- package/src/skillfs.ts +68 -33
- package/src/stats.test.ts +52 -17
- package/src/stats.ts +55 -18
- package/src/store.test.ts +205 -1
- package/src/store.ts +527 -24
- package/src/update.test.ts +73 -0
- package/src/update.ts +136 -0
package/src/client/api.ts
CHANGED
|
@@ -9,9 +9,33 @@ import {
|
|
|
9
9
|
type ConfigResponse,
|
|
10
10
|
type CreateRequest,
|
|
11
11
|
type CreateResponse,
|
|
12
|
+
type MarketCheckResponse,
|
|
13
|
+
type MarketSourceRefRequest,
|
|
14
|
+
type MarketSourceRequest,
|
|
15
|
+
type MarketSourceResponse,
|
|
16
|
+
type MarketSourcesResponse,
|
|
17
|
+
type MarketSyncResponse,
|
|
18
|
+
type RepoDiscoverResponse,
|
|
19
|
+
type RepoImportRequest,
|
|
20
|
+
type RepoImportResponse,
|
|
12
21
|
type SkillDetail,
|
|
13
22
|
type SkillDetailResponse,
|
|
23
|
+
type SkillDeleteRequest,
|
|
24
|
+
type SkillDeleteResponse,
|
|
25
|
+
type SourceCheckRequest,
|
|
26
|
+
type SourceCheckResponse,
|
|
27
|
+
type SourceDeleteRequest,
|
|
28
|
+
type SourceDeleteResponse,
|
|
29
|
+
type SourceRestoreRequest,
|
|
30
|
+
type SourceRestoreResponse,
|
|
31
|
+
type SourceTrashClearResponse,
|
|
32
|
+
type SourcesResponse,
|
|
33
|
+
type SourceSyncRequest,
|
|
34
|
+
type SourceSyncResponse,
|
|
14
35
|
type StatsResponse,
|
|
36
|
+
type ToggleBatchRequest,
|
|
37
|
+
type ToggleBatchResponse,
|
|
38
|
+
type UpdateCheckResponse,
|
|
15
39
|
} from '../protocol.ts'
|
|
16
40
|
|
|
17
41
|
/** Error carrying the route's JSON error message. */
|
|
@@ -39,23 +63,48 @@ async function readJson<T>(response: Response): Promise<T> {
|
|
|
39
63
|
return body as T
|
|
40
64
|
}
|
|
41
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Fetch with a hard timeout: a hung connection (proxy, stalled keep-alive,
|
|
68
|
+
* host wedged) must never leave the panel stuck on "loading" forever — it
|
|
69
|
+
* fails fast and surfaces the error banner instead.
|
|
70
|
+
*/
|
|
71
|
+
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit, ms = 10_000): Promise<Response> {
|
|
72
|
+
const controller = new AbortController()
|
|
73
|
+
const timer = setTimeout(() => controller.abort(), ms)
|
|
74
|
+
try {
|
|
75
|
+
return await fetch(input, { ...init, signal: controller.signal })
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
42
81
|
/** The browser half's only data entry point. */
|
|
43
82
|
export class SkillHubApi {
|
|
44
83
|
async catalog(): Promise<CatalogResponse> {
|
|
45
|
-
const response = await
|
|
84
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.catalog)
|
|
46
85
|
return readJson<CatalogResponse>(response)
|
|
47
86
|
}
|
|
48
87
|
|
|
49
88
|
async skill(name: string): Promise<SkillDetail> {
|
|
50
89
|
const url = SKILL_HUB_API.skill + '?name=' + encodeURIComponent(name)
|
|
51
|
-
const response = await
|
|
90
|
+
const response = await fetchWithTimeout(url)
|
|
52
91
|
const body = await readJson<SkillDetailResponse>(response)
|
|
53
92
|
return body.skill
|
|
54
93
|
}
|
|
55
94
|
|
|
56
|
-
/**
|
|
95
|
+
/** Move one writable skill into the restorable trash. */
|
|
96
|
+
async deleteSkill(name: string): Promise<SkillDeleteResponse> {
|
|
97
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.skillDelete, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({ name } satisfies SkillDeleteRequest),
|
|
101
|
+
})
|
|
102
|
+
return readJson<SkillDeleteResponse>(response)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Toggle one skill; resolves with the fresh catalog from the route. */
|
|
57
106
|
async toggle(name: string, enabled: boolean): Promise<CatalogResponse> {
|
|
58
|
-
const response = await
|
|
107
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.toggle, {
|
|
59
108
|
method: 'POST',
|
|
60
109
|
headers: { 'content-type': 'application/json' },
|
|
61
110
|
body: JSON.stringify({ name, enabled } satisfies import('../protocol.ts').ToggleRequest),
|
|
@@ -65,7 +114,7 @@ export class SkillHubApi {
|
|
|
65
114
|
}
|
|
66
115
|
|
|
67
116
|
async create(payload: CreateRequest): Promise<CreateResponse> {
|
|
68
|
-
const response = await
|
|
117
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.create, {
|
|
69
118
|
method: 'POST',
|
|
70
119
|
headers: { 'content-type': 'application/json' },
|
|
71
120
|
body: JSON.stringify(payload),
|
|
@@ -74,24 +123,205 @@ export class SkillHubApi {
|
|
|
74
123
|
}
|
|
75
124
|
|
|
76
125
|
async stats(): Promise<StatsResponse> {
|
|
77
|
-
const response = await
|
|
126
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.stats)
|
|
78
127
|
return readJson<StatsResponse>(response)
|
|
79
128
|
}
|
|
80
129
|
|
|
130
|
+
/** Toggle a whole group in one write; resolves with the fresh catalog + failures. */
|
|
131
|
+
async toggleBatch(names: string[], enabled: boolean): Promise<ToggleBatchResponse> {
|
|
132
|
+
const payload: ToggleBatchRequest = { names, enabled }
|
|
133
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.toggleBatch, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'content-type': 'application/json' },
|
|
136
|
+
body: JSON.stringify(payload),
|
|
137
|
+
})
|
|
138
|
+
return readJson<ToggleBatchResponse>(response)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The user's added market sources (codex-style repo slugs). */
|
|
142
|
+
async market(): Promise<MarketSourcesResponse> {
|
|
143
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.market)
|
|
144
|
+
return readJson<MarketSourcesResponse>(response)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Add a market source repo; resolves with the fresh list. */
|
|
148
|
+
async addMarketSource(repo: string): Promise<MarketSourcesResponse> {
|
|
149
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.marketSource, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'content-type': 'application/json' },
|
|
152
|
+
body: JSON.stringify({ repo } satisfies MarketSourceRequest),
|
|
153
|
+
})
|
|
154
|
+
return readJson<MarketSourceResponse>(response)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Remove a market source repo; resolves with the fresh list. */
|
|
158
|
+
async removeMarketSource(repo: string): Promise<MarketSourcesResponse> {
|
|
159
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.marketSourceDelete, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { 'content-type': 'application/json' },
|
|
162
|
+
body: JSON.stringify({ repo } satisfies MarketSourceRequest),
|
|
163
|
+
})
|
|
164
|
+
return readJson<MarketSourceResponse>(response)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Check every market source for updates (throttled server-side). */
|
|
168
|
+
async marketCheck(): Promise<MarketCheckResponse> {
|
|
169
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.marketCheck)
|
|
170
|
+
return readJson<MarketCheckResponse>(response)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Align a market source to its pinned ref; resolves with tracked skills. */
|
|
174
|
+
async marketSync(repo: string): Promise<MarketSyncResponse> {
|
|
175
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.marketSync, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: { 'content-type': 'application/json' },
|
|
178
|
+
body: JSON.stringify({ repo }),
|
|
179
|
+
})
|
|
180
|
+
return readJson<MarketSyncResponse>(response)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Pin a market source to an explicit ref (branch picker). */
|
|
184
|
+
async setMarketSourceRef(repo: string, ref: string): Promise<MarketSourcesResponse> {
|
|
185
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.marketSourceRef, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'content-type': 'application/json' },
|
|
188
|
+
body: JSON.stringify({ repo, ref } satisfies MarketSourceRefRequest),
|
|
189
|
+
})
|
|
190
|
+
return readJson<MarketSourceResponse>(response)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Check the hub's own latest GitHub release. */
|
|
194
|
+
async updateCheck(): Promise<UpdateCheckResponse> {
|
|
195
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.update)
|
|
196
|
+
return readJson<UpdateCheckResponse>(response)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Discover importable skills in a GitHub repo. */
|
|
200
|
+
async repoDiscover(repo: string): Promise<RepoDiscoverResponse> {
|
|
201
|
+
const url = SKILL_HUB_API.repo + '?repo=' + encodeURIComponent(repo)
|
|
202
|
+
const response = await fetchWithTimeout(url)
|
|
203
|
+
return readJson<RepoDiscoverResponse>(response)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Install selected repo skills into the user-dsh root (records the source). */
|
|
207
|
+
async repoImport(repo: string, paths: string[], ref?: string): Promise<RepoImportResponse> {
|
|
208
|
+
const payload: RepoImportRequest = { repo, paths, ...(ref !== undefined && ref !== '' ? { ref } : {}) }
|
|
209
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.repoImport, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'content-type': 'application/json' },
|
|
212
|
+
body: JSON.stringify(payload),
|
|
213
|
+
})
|
|
214
|
+
return readJson<RepoImportResponse>(response)
|
|
215
|
+
}
|
|
216
|
+
|
|
81
217
|
/** Read the hub's runtime config (effective values + saved overrides). */
|
|
82
218
|
async config(): Promise<ConfigResponse> {
|
|
83
|
-
const response = await
|
|
219
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.config)
|
|
84
220
|
return readJson<ConfigResponse>(response)
|
|
85
221
|
}
|
|
86
222
|
|
|
87
223
|
/** Patch the hub's runtime config (null clears a saved override). */
|
|
88
224
|
async saveConfig(patch: ConfigRequest): Promise<ConfigResponse> {
|
|
89
|
-
const response = await
|
|
225
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.config, {
|
|
90
226
|
method: 'POST',
|
|
91
227
|
headers: { 'content-type': 'application/json' },
|
|
92
228
|
body: JSON.stringify(patch),
|
|
93
229
|
})
|
|
94
230
|
return readJson<ConfigResponse>(response)
|
|
95
231
|
}
|
|
96
|
-
}
|
|
97
232
|
|
|
233
|
+
/** 用户 tag 分组 + 系统集合组 + origin 映射。 */
|
|
234
|
+
async groups(): Promise<import('../protocol.ts').GroupsResponse> {
|
|
235
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.groups)
|
|
236
|
+
return readJson<import('../protocol.ts').GroupsResponse>(response)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 新建(无 id)或重命名(带 id)一个 tag 分组;返回全部 tag。 */
|
|
240
|
+
async saveTag(payload: import('../protocol.ts').TagSaveRequest): Promise<import('../protocol.ts').SkillTag[]> {
|
|
241
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.tag, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: { 'content-type': 'application/json' },
|
|
244
|
+
body: JSON.stringify(payload),
|
|
245
|
+
})
|
|
246
|
+
const body = await readJson<import('../protocol.ts').TagSaveResponse>(response)
|
|
247
|
+
return body.tags
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 删除一个 tag 分组;返回全部 tag。 */
|
|
251
|
+
async deleteTag(id: string): Promise<import('../protocol.ts').SkillTag[]> {
|
|
252
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.tagDelete, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'content-type': 'application/json' },
|
|
255
|
+
body: JSON.stringify({ id } satisfies import('../protocol.ts').TagDeleteRequest),
|
|
256
|
+
})
|
|
257
|
+
const body = await readJson<import('../protocol.ts').TagDeleteResponse>(response)
|
|
258
|
+
return body.tags
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 直接设置某 tag 的完整成员列表;返回全部 tag。 */
|
|
262
|
+
async setTagMembers(id: string, skillNames: string[]): Promise<import('../protocol.ts').SkillTag[]> {
|
|
263
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.tagMembers, {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: { 'content-type': 'application/json' },
|
|
266
|
+
body: JSON.stringify({ id, skillNames } satisfies import('../protocol.ts').TagMembersRequest),
|
|
267
|
+
})
|
|
268
|
+
const body = await readJson<import('../protocol.ts').TagMembersResponse>(response)
|
|
269
|
+
return body.tags
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 来源列表 + 派生 origin 映射 + 集合组 + 回收站。 */
|
|
273
|
+
async sources(): Promise<SourcesResponse> {
|
|
274
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.sources)
|
|
275
|
+
return readJson<SourcesResponse>(response)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 检查指定(或全部)来源的上游更新。 */
|
|
279
|
+
async checkSources(repo?: string): Promise<SourceCheckResponse> {
|
|
280
|
+
const payload: SourceCheckRequest = repo !== undefined ? { repo } : {}
|
|
281
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.sourceCheck, {
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: { 'content-type': 'application/json' },
|
|
284
|
+
body: JSON.stringify(payload),
|
|
285
|
+
})
|
|
286
|
+
return readJson<SourceCheckResponse>(response)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 同步一个来源的所选(或全部)技能到上游最新版本。 */
|
|
290
|
+
async syncSource(repo: string, skills?: string[]): Promise<SourceSyncResponse> {
|
|
291
|
+
const payload: SourceSyncRequest = skills !== undefined ? { repo, skills } : { repo }
|
|
292
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.sourceSync, {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
headers: { 'content-type': 'application/json' },
|
|
295
|
+
body: JSON.stringify(payload),
|
|
296
|
+
})
|
|
297
|
+
return readJson<SourceSyncResponse>(response)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 跟进上游删除:把所选技能移入回收站。 */
|
|
301
|
+
async confirmDeleteSource(repo: string, skills: string[]): Promise<SourceDeleteResponse> {
|
|
302
|
+
const payload: SourceDeleteRequest = { repo, skills }
|
|
303
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.sourceDelete, {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
headers: { 'content-type': 'application/json' },
|
|
306
|
+
body: JSON.stringify(payload),
|
|
307
|
+
})
|
|
308
|
+
return readJson<SourceDeleteResponse>(response)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 从回收站恢复一个技能。 */
|
|
312
|
+
async restoreSource(name: string): Promise<SourceRestoreResponse> {
|
|
313
|
+
const payload: SourceRestoreRequest = { name }
|
|
314
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.sourceRestore, {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
headers: { 'content-type': 'application/json' },
|
|
317
|
+
body: JSON.stringify(payload),
|
|
318
|
+
})
|
|
319
|
+
return readJson<SourceRestoreResponse>(response)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Permanently delete every skill currently in the trash. */
|
|
323
|
+
async clearTrash(): Promise<SourceTrashClearResponse> {
|
|
324
|
+
const response = await fetchWithTimeout(SKILL_HUB_API.sourceTrashClear, { method: 'POST' })
|
|
325
|
+
return readJson<SourceTrashClearResponse>(response)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { CatalogSkill, SkillTag } from '../protocol.ts'
|
|
3
|
+
import { conflictsOnClose, filterBySource, formatRelativeTime, groupNamesOf, groupSwitchView, PRIVATE_SOURCE, sortSkills } from './grouping.ts'
|
|
4
|
+
|
|
5
|
+
function skill(name: string, writable = true): CatalogSkill {
|
|
6
|
+
return {
|
|
7
|
+
name,
|
|
8
|
+
description: '',
|
|
9
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
10
|
+
provider: 'filesystem',
|
|
11
|
+
writable,
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('groupSwitchView', () => {
|
|
16
|
+
it('is on when every member is enabled, off when none, mixed otherwise', () => {
|
|
17
|
+
const enabled = new Set(['a', 'b', 'c'])
|
|
18
|
+
expect(groupSwitchView(['a', 'b', 'c'], enabled).state).toBe('on')
|
|
19
|
+
expect(groupSwitchView(['a'], new Set()).state).toBe('off')
|
|
20
|
+
expect(groupSwitchView(['a', 'b'], new Set(['a'])).state).toBe('mixed')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('lists the enabled and disabled sides', () => {
|
|
24
|
+
const view = groupSwitchView(['a', 'b', 'c'], new Set(['a', 'c']))
|
|
25
|
+
expect(view.enabled).toEqual(['a', 'c'])
|
|
26
|
+
expect(view.disabled).toEqual(['b'])
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('conflictsOnClose', () => {
|
|
31
|
+
it('flags enabled members that live in another group', () => {
|
|
32
|
+
const members = ['a', 'b', 'c']
|
|
33
|
+
const enabled = new Set(['a', 'b'])
|
|
34
|
+
const others = [{ members: ['c', 'a'] }, { members: ['x'] }]
|
|
35
|
+
expect(conflictsOnClose(members, enabled, others)).toEqual(['a'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('ignores disabled members and groups without the member', () => {
|
|
39
|
+
const members = ['a', 'b']
|
|
40
|
+
const enabled = new Set(['b'])
|
|
41
|
+
const others = [{ members: ['a'] }]
|
|
42
|
+
expect(conflictsOnClose(members, enabled, others)).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('groupNamesOf', () => {
|
|
47
|
+
it('collects tag and collection names for a skill', () => {
|
|
48
|
+
const tags: SkillTag[] = [{ id: '1', name: 'web', skillNames: ['a'] }]
|
|
49
|
+
const collections = [{ name: 'repo/x', skillNames: ['a', 'b'] }, { name: 'repo/y', skillNames: ['b'] }]
|
|
50
|
+
expect(groupNamesOf('a', tags, collections)).toEqual(['web', 'repo/x'])
|
|
51
|
+
expect(groupNamesOf('b', tags, collections)).toEqual(['repo/x', 'repo/y'])
|
|
52
|
+
expect(groupNamesOf('c', tags, collections)).toEqual([])
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('filterBySource', () => {
|
|
57
|
+
it('filters by origin repo, buckets untracked skills as private, and returns all for "all"', () => {
|
|
58
|
+
const origins = { a: 'repo/x', b: 'repo/y' }
|
|
59
|
+
const skills = [skill('a'), skill('b'), skill('c')]
|
|
60
|
+
expect(filterBySource(skills, 'repo/x', origins).map((s) => s.name)).toEqual(['a'])
|
|
61
|
+
expect(filterBySource(skills, PRIVATE_SOURCE, origins).map((s) => s.name)).toEqual(['c'])
|
|
62
|
+
expect(filterBySource(skills, 'all', origins)).toHaveLength(3)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('sortSkills', () => {
|
|
67
|
+
it('sorts by name ascending', () => {
|
|
68
|
+
const skills = [skill('zeta'), skill('alpha'), skill('beta')]
|
|
69
|
+
expect(sortSkills(skills, 'name').map((s) => s.name)).toEqual(['alpha', 'beta', 'zeta'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('sorts by added time descending, unknown times last', () => {
|
|
73
|
+
const skills = [
|
|
74
|
+
{ ...skill('old'), addedAt: 100 },
|
|
75
|
+
{ ...skill('new'), addedAt: 300 },
|
|
76
|
+
{ ...skill('unknown') },
|
|
77
|
+
]
|
|
78
|
+
expect(sortSkills(skills, 'added').map((s) => s.name)).toEqual(['new', 'old', 'unknown'])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('sorts by invocation count descending, unknown counts as zero', () => {
|
|
82
|
+
const skills = [skill('few'), skill('many'), skill('none')]
|
|
83
|
+
const uses: Record<string, number | undefined> = { few: 2, many: 9 }
|
|
84
|
+
expect(sortSkills(skills, 'uses', (name) => uses[name]).map((s) => s.name)).toEqual(['many', 'few', 'none'])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('does not mutate the input list', () => {
|
|
88
|
+
const skills = [skill('b'), skill('a')]
|
|
89
|
+
const sorted = sortSkills(skills, 'name')
|
|
90
|
+
expect(sorted.map((s) => s.name)).toEqual(['a', 'b'])
|
|
91
|
+
expect(skills.map((s) => s.name)).toEqual(['b', 'a'])
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('formatRelativeTime', () => {
|
|
96
|
+
const now = 1_700_000_000_000
|
|
97
|
+
it('buckets relative times correctly', () => {
|
|
98
|
+
expect(formatRelativeTime(now, now)).toEqual({ key: 'time.justNow' })
|
|
99
|
+
expect(formatRelativeTime(now - 30_000, now)).toEqual({ key: 'time.justNow' })
|
|
100
|
+
expect(formatRelativeTime(now - 5 * 60_000, now)).toEqual({ key: 'time.minutesAgo', value: 5 })
|
|
101
|
+
expect(formatRelativeTime(now - 3 * 3_600_000, now)).toEqual({ key: 'time.hoursAgo', value: 3 })
|
|
102
|
+
expect(formatRelativeTime(now - 2 * 86_400_000, now)).toEqual({ key: 'time.daysAgo', value: 2 })
|
|
103
|
+
expect(formatRelativeTime(now - 14 * 86_400_000, now)).toEqual({ key: 'time.weeksAgo', value: 2 })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('clamps future timestamps to just now', () => {
|
|
107
|
+
expect(formatRelativeTime(now + 5_000, now)).toEqual({ key: 'time.justNow' })
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure grouping helpers for the skill-hub panel (no React/DOM deps, so the
|
|
3
|
+
* merge/classify/filter logic stays unit-testable).
|
|
4
|
+
*
|
|
5
|
+
* The "分组" view unifies two ways a skill can belong to a group:
|
|
6
|
+
* - user tags (sidecar SkillTag)
|
|
7
|
+
* - origin collections (sidecar sources → backend-aggregated CollectionGroup)
|
|
8
|
+
*
|
|
9
|
+
* Switch semantics: a skill's enabled state is global (the file is renamed
|
|
10
|
+
* once), so a group switch is derived from its members' actual states:
|
|
11
|
+
* all members enabled → 'on', all disabled → 'off', otherwise 'mixed'.
|
|
12
|
+
* Closing a group whose member is enabled in another group is a conflict the
|
|
13
|
+
* GUI resolves with a dialog; the helpers below compute both sides.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { CatalogSkill, CollectionGroup, SkillTag } from '../protocol.ts'
|
|
17
|
+
|
|
18
|
+
/** Grouped switch state derived from member enablement. */
|
|
19
|
+
export type GroupSwitchState = 'on' | 'off' | 'mixed'
|
|
20
|
+
|
|
21
|
+
/** The derived switch view of one group. */
|
|
22
|
+
export interface GroupSwitchView {
|
|
23
|
+
state: GroupSwitchState
|
|
24
|
+
/** Writable members currently enabled. */
|
|
25
|
+
enabled: string[]
|
|
26
|
+
/** Writable members currently disabled. */
|
|
27
|
+
disabled: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Derive a group switch view from its member names and the set of currently
|
|
32
|
+
* enabled skill names (catalog.skills). Members not in the enabled set count
|
|
33
|
+
* as disabled (catalog.disabled or absent read-only rows).
|
|
34
|
+
*/
|
|
35
|
+
export function groupSwitchView(members: readonly string[], enabledNames: ReadonlySet<string>): GroupSwitchView {
|
|
36
|
+
const enabled: string[] = []
|
|
37
|
+
const disabled: string[] = []
|
|
38
|
+
for (const name of members) {
|
|
39
|
+
if (enabledNames.has(name)) enabled.push(name)
|
|
40
|
+
else disabled.push(name)
|
|
41
|
+
}
|
|
42
|
+
const state: GroupSwitchState = disabled.length === 0 ? 'on' : enabled.length === 0 ? 'off' : 'mixed'
|
|
43
|
+
return { state, enabled, disabled }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Names of every group a skill belongs to (tags + collections). */
|
|
47
|
+
export function groupNamesOf(name: string, tags: readonly SkillTag[], collections: readonly CollectionGroup[]): string[] {
|
|
48
|
+
const names: string[] = []
|
|
49
|
+
for (const tag of tags) if (tag.skillNames.includes(name)) names.push(tag.name)
|
|
50
|
+
for (const collection of collections) if (collection.skillNames.includes(name)) names.push(collection.name)
|
|
51
|
+
return names
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Members of a group that are currently enabled AND also belong to at least
|
|
56
|
+
* one other group — the set a "close" action must ask about.
|
|
57
|
+
*/
|
|
58
|
+
export function conflictsOnClose(
|
|
59
|
+
members: readonly string[],
|
|
60
|
+
enabledNames: ReadonlySet<string>,
|
|
61
|
+
otherGroups: ReadonlyArray<{ members: readonly string[] }>,
|
|
62
|
+
): string[] {
|
|
63
|
+
return members.filter((name) => {
|
|
64
|
+
if (!enabledNames.has(name)) return false
|
|
65
|
+
return otherGroups.some((group) => group.members.includes(name))
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Origin-repo filter value: skills with no source record (private skills). */
|
|
70
|
+
export const PRIVATE_SOURCE = 'private'
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Apply the origin filter ('all' or a specific origin repo; skills without a
|
|
74
|
+
* source record count as PRIVATE_SOURCE). The origins map is the store's
|
|
75
|
+
* skillName → repo derivation, so filtering follows the tracked source
|
|
76
|
+
* records instead of the filesystem root a skill happens to live under.
|
|
77
|
+
*/
|
|
78
|
+
export function filterBySource(skills: readonly CatalogSkill[], source: string, origins: Readonly<Record<string, string>>): CatalogSkill[] {
|
|
79
|
+
if (source === 'all') return [...skills]
|
|
80
|
+
return skills.filter((skill) => (origins[skill.name] ?? PRIVATE_SOURCE) === source)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Catalog sort keys offered by the filter bar. */
|
|
84
|
+
export type SortKey = 'name' | 'added' | 'uses'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Sort a skill list in place-safe copy order: name ascending, added
|
|
88
|
+
* descending (newest first, unknown addedAt last), or uses descending
|
|
89
|
+
* (most-called first). Unknown values always trail.
|
|
90
|
+
*/
|
|
91
|
+
export function sortSkills(skills: readonly CatalogSkill[], key: SortKey, getUses?: (name: string) => number | undefined): CatalogSkill[] {
|
|
92
|
+
const list = [...skills]
|
|
93
|
+
if (key === 'name') {
|
|
94
|
+
list.sort((a, b) => a.name.localeCompare(b.name))
|
|
95
|
+
} else if (key === 'added') {
|
|
96
|
+
list.sort((a, b) => (b.addedAt ?? -Infinity) - (a.addedAt ?? -Infinity))
|
|
97
|
+
} else if (key === 'uses') {
|
|
98
|
+
list.sort((a, b) => (getUses?.(b.name) ?? 0) - (getUses?.(a.name) ?? 0))
|
|
99
|
+
}
|
|
100
|
+
return list
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Localized relative-time tuple; the caller resolves it via tt(). */
|
|
104
|
+
export interface RelativeTime {
|
|
105
|
+
key: 'time.justNow' | 'time.minutesAgo' | 'time.hoursAgo' | 'time.daysAgo' | 'time.weeksAgo'
|
|
106
|
+
value?: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Format an epoch-ms timestamp as a short relative time bucket. */
|
|
110
|
+
export function formatRelativeTime(ms: number, now = Date.now()): RelativeTime {
|
|
111
|
+
const diff = Math.max(0, now - ms)
|
|
112
|
+
const minutes = Math.floor(diff / 60_000)
|
|
113
|
+
if (minutes < 1) return { key: 'time.justNow' }
|
|
114
|
+
if (minutes < 60) return { key: 'time.minutesAgo', value: minutes }
|
|
115
|
+
const hours = Math.floor(minutes / 60)
|
|
116
|
+
if (hours < 24) return { key: 'time.hoursAgo', value: hours }
|
|
117
|
+
const days = Math.floor(hours / 24)
|
|
118
|
+
if (days < 7) return { key: 'time.daysAgo', value: days }
|
|
119
|
+
return { key: 'time.weeksAgo', value: Math.floor(days / 7) }
|
|
120
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -34,6 +34,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
|
|
34
34
|
import { SkillHubApi } from './api.ts'
|
|
35
35
|
import { ApiConfigScope } from './api-config-scope.ts'
|
|
36
36
|
import { en, zh, type HubKey } from './locales.ts'
|
|
37
|
+
import { applySettingsNavIcon } from './settings-nav-icon.ts'
|
|
37
38
|
import { SkillHubSettingsCard, SkillHubSettingsCardController } from './SkillHubSettingsCard.tsx'
|
|
38
39
|
import { SkillHubPanel } from './panel/SkillHubPanel.tsx'
|
|
39
40
|
|
|
@@ -89,7 +90,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
89
90
|
ctx.effect(
|
|
90
91
|
() => ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
91
92
|
name: 'settings.section',
|
|
92
|
-
id: '
|
|
93
|
+
id: 'skill-hub',
|
|
93
94
|
order: 12,
|
|
94
95
|
label: () => t('entry.label'),
|
|
95
96
|
locale: NS,
|
|
@@ -97,5 +98,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
97
98
|
}, () => <SkillHubPanel api={api} />)),
|
|
98
99
|
'dsh-skill-hub: settings section',
|
|
99
100
|
)
|
|
100
|
-
}
|
|
101
101
|
|
|
102
|
+
// Host shell has no section-icon registration; keep the nav gear swapped for the skill icon.
|
|
103
|
+
ctx.effect(() => applySettingsNavIcon(), 'dsh-skill-hub: settings nav icon')
|
|
104
|
+
}
|