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.
- package/README.md +40 -50
- package/README.zh.md +38 -48
- package/lib/client.js +2209 -1820
- package/lib/client.js.map +1 -1
- package/lib/index.js +653 -419
- package/lib/types/client/SkillHubSettingsCard.d.ts +1 -0
- package/lib/types/client/api.d.ts +12 -6
- package/lib/types/client/helpers.d.ts +0 -1
- package/lib/types/client/locales/common.d.ts +52 -0
- package/lib/types/client/locales/detail.d.ts +16 -0
- package/lib/types/client/locales/market.d.ts +73 -0
- package/lib/types/client/locales/settings.d.ts +41 -0
- package/lib/types/client/locales/skills.d.ts +65 -0
- package/lib/types/client/locales/sources.d.ts +35 -0
- package/lib/types/client/locales.d.ts +124 -118
- package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
- package/lib/types/client/panel/MarketView.d.ts +1 -0
- package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
- package/lib/types/client/panel/hooks/shared.d.ts +72 -0
- package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
- package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
- package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
- package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
- package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
- package/lib/types/client/panel/market-format.d.ts +6 -0
- package/lib/types/client/panel/useSkillHub.d.ts +45 -73
- package/lib/types/client/settings-card.d.ts +20 -0
- package/lib/types/client/settings-form.d.ts +6 -0
- package/lib/types/protocol/api.d.ts +38 -0
- package/lib/types/protocol/catalog.d.ts +155 -0
- package/lib/types/protocol/config.d.ts +113 -0
- package/lib/types/protocol/groups.d.ts +95 -0
- package/lib/types/protocol/market.d.ts +94 -0
- package/lib/types/protocol/repo.d.ts +107 -0
- package/lib/types/protocol/sources.d.ts +139 -0
- package/lib/types/protocol/stats.d.ts +46 -0
- package/lib/types/protocol.d.ts +13 -743
- package/lib/types/repo/github-client.d.ts +42 -0
- package/lib/types/repo.d.ts +6 -17
- package/lib/types/routes/catalog.d.ts +7 -0
- package/lib/types/routes/config.d.ts +7 -0
- package/lib/types/routes/groups.d.ts +7 -0
- package/lib/types/routes/helpers.d.ts +145 -0
- package/lib/types/routes/market.d.ts +8 -0
- package/lib/types/routes/repo-import.d.ts +7 -0
- package/lib/types/routes/route-state.d.ts +55 -0
- package/lib/types/routes/sources.d.ts +8 -0
- package/lib/types/routes.d.ts +5 -34
- package/lib/types/stats.d.ts +3 -2
- package/lib/types/store/errors.d.ts +10 -0
- package/lib/types/store/migrate.d.ts +44 -0
- package/lib/types/store/paths.d.ts +32 -0
- package/lib/types/store/store.d.ts +133 -0
- package/lib/types/store.d.ts +7 -146
- package/package.json +28 -27
- package/src/client/SkillHubSettingsCard.tsx +17 -3
- package/src/client/api.ts +101 -195
- package/src/client/helpers.ts +10 -1
- package/src/client/locales/common.ts +102 -0
- package/src/client/locales/detail.ts +30 -0
- package/src/client/locales/market.ts +144 -0
- package/src/client/locales/settings.ts +80 -0
- package/src/client/locales/skills.ts +128 -0
- package/src/client/locales/sources.ts +68 -0
- package/src/client/locales.ts +26 -512
- package/src/client/panel/MarketSourceRow.tsx +108 -0
- package/src/client/panel/MarketView.tsx +12 -253
- package/src/client/panel/RepoScanCard.tsx +171 -0
- package/src/client/panel/hooks/shared.ts +81 -0
- package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
- package/src/client/panel/hooks/useGroupFlow.ts +240 -0
- package/src/client/panel/hooks/useMarketFlow.ts +421 -0
- package/src/client/panel/hooks/useMetaFlow.ts +58 -0
- package/src/client/panel/hooks/useSourceFlow.ts +193 -0
- package/src/client/panel/market-format.ts +22 -0
- package/src/client/panel/useSkillHub.ts +141 -1032
- package/src/client/settings-card.tsx +51 -0
- package/src/client/settings-form.ts +18 -0
- package/src/index.ts +14 -4
- package/src/protocol/api.ts +38 -0
- package/src/protocol/catalog.ts +168 -0
- package/src/protocol/config.ts +145 -0
- package/src/protocol/groups.ts +106 -0
- package/src/protocol/market.ts +100 -0
- package/src/protocol/repo.ts +104 -0
- package/src/protocol/sources.ts +146 -0
- package/src/protocol/stats.ts +42 -0
- package/src/protocol.ts +83 -809
- package/src/repo/github-client.ts +131 -0
- package/src/repo.test.ts +24 -1
- package/src/repo.ts +50 -170
- package/src/routes/catalog.ts +309 -0
- package/src/routes/config.ts +119 -0
- package/src/routes/groups.ts +133 -0
- package/src/routes/helpers.ts +489 -0
- package/src/routes/market.ts +255 -0
- package/src/routes/repo-import.ts +267 -0
- package/src/routes/route-state.ts +89 -0
- package/src/routes/sources.ts +318 -0
- package/src/routes.test.ts +20 -0
- package/src/routes.ts +21 -1699
- package/src/stats.test.ts +58 -3
- package/src/stats.ts +45 -16
- package/src/store/errors.ts +14 -0
- package/src/store/migrate.ts +272 -0
- package/src/store/paths.ts +43 -0
- package/src/store/store.ts +532 -0
- package/src/store.test.ts +18 -0
- package/src/store.ts +7 -737
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hooks 共享层:域 hooks 之间传递的通知接口、状态机类型、轮询常量、
|
|
3
|
+
* 每日自动检查 helpers。从 useSkillHub.ts 原样搬出,行为不变。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { MarketCheckResponse, MarketSourceRecord, RepoDiscoverResponse, UpdateCheckResponse } from '../../../protocol.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 跨域共享的通知原语:聚合根用稳定的 setState setter 实现一次,
|
|
10
|
+
* 各域 hooks 只调接口,不直接碰别人的 state。对象本身经 useMemo
|
|
11
|
+
* 常驻,域回调的 useCallback 依赖它不会失稳。
|
|
12
|
+
*/
|
|
13
|
+
export interface FlowNotices {
|
|
14
|
+
/** 报错条幅(覆盖旧报错)。 */
|
|
15
|
+
fail: (message: string) => void
|
|
16
|
+
/** 清掉报错条幅。 */
|
|
17
|
+
clearFail: () => void
|
|
18
|
+
/** 成功条幅(null = 清掉)。 */
|
|
19
|
+
succeed: (message: string | null) => void
|
|
20
|
+
/** tag 域忙碌开关(多域共用)。 */
|
|
21
|
+
setTagBusy: (busy: boolean) => void
|
|
22
|
+
/** 整组操作忙碌开关(toggle-batch 与全部更新共用)。 */
|
|
23
|
+
setBatchBusy: (busy: boolean) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Catalog poll interval while the panel is mounted (the provider watcher feeds this). */
|
|
27
|
+
export const POLL_MS = 5000
|
|
28
|
+
/** Relaxed poll for slower-changing data (stats, groups, sources, config). */
|
|
29
|
+
export const SLOW_POLL_MS = 60_000
|
|
30
|
+
/** 空统计补捞上限:冷启动全量扫描(数十 MB 会话日志)可能超过 2 分钟,72 次约 6 分钟内保持 5 秒粒度,之后交回 60 秒慢轮询。 */
|
|
31
|
+
export const USES_CATCH_UP_MAX = 72
|
|
32
|
+
|
|
33
|
+
/** localStorage key of the last daily auto-check timestamp (survives sessions). */
|
|
34
|
+
export const AUTO_CHECK_KEY = 'skill-hub.last-auto-check'
|
|
35
|
+
/** Daily auto-check interval: at most one network round per day. */
|
|
36
|
+
export const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60_000
|
|
37
|
+
|
|
38
|
+
/** Whether the daily auto-check should run now (first visit or ≥24h since the last). */
|
|
39
|
+
export function shouldAutoCheck(): boolean {
|
|
40
|
+
try {
|
|
41
|
+
const raw = localStorage.getItem(AUTO_CHECK_KEY)
|
|
42
|
+
const last = raw === null ? NaN : Number(raw)
|
|
43
|
+
if (!Number.isFinite(last)) return true
|
|
44
|
+
return Date.now() - last >= AUTO_CHECK_INTERVAL_MS
|
|
45
|
+
} catch {
|
|
46
|
+
// 无 localStorage(隐私模式等):每次打开都尝试,服务端 5 分钟节流兜底。
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Record that the daily auto-check ran. */
|
|
52
|
+
export function markAutoChecked(): void {
|
|
53
|
+
try {
|
|
54
|
+
localStorage.setItem(AUTO_CHECK_KEY, String(Date.now()))
|
|
55
|
+
} catch {
|
|
56
|
+
// 无 localStorage:不记录,下次打开仍会尝试(服务端节流兜底)。
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One-shot self-update check state. */
|
|
61
|
+
export type UpdateState =
|
|
62
|
+
| { status: 'idle' }
|
|
63
|
+
| { status: 'checking' }
|
|
64
|
+
| { status: 'ready'; data: UpdateCheckResponse }
|
|
65
|
+
| { status: 'error'; message: string }
|
|
66
|
+
|
|
67
|
+
/** Repo scanner/import state (drives the market source preview). */
|
|
68
|
+
export type RepoDiscoverState =
|
|
69
|
+
| { status: 'idle' }
|
|
70
|
+
| { status: 'scanning' }
|
|
71
|
+
| { status: 'ready'; data: RepoDiscoverResponse }
|
|
72
|
+
| { status: 'error'; message: string }
|
|
73
|
+
|
|
74
|
+
/** Market sources list state. */
|
|
75
|
+
export type MarketState = { status: 'loading' | 'ready' | 'error'; repos: MarketSourceRecord[] }
|
|
76
|
+
|
|
77
|
+
/** One market source's update-check outcome. */
|
|
78
|
+
export type MarketCheckResult = MarketCheckResponse['results'][number]
|
|
79
|
+
|
|
80
|
+
/** skillName → 调用次数/末次使用(统计域产出,目录排序消费)。 */
|
|
81
|
+
export type UsesMap = ReadonlyMap<string, { count: number; lastUsed?: number }>
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useCatalogFlow — 目录域:catalog 加载、详情、开关(单个/禁用态/批量)、
|
|
3
|
+
* 新建、诊断修复,以及列表过滤/排序派生。跨域刷新只通过 shared 通知,
|
|
4
|
+
* 不直接碰其他域的 state。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useMemo, useRef, useState, type FormEvent } from 'react'
|
|
8
|
+
import type {
|
|
9
|
+
CatalogResponse,
|
|
10
|
+
CatalogSkill,
|
|
11
|
+
DisabledSkill,
|
|
12
|
+
SkillDetail,
|
|
13
|
+
WritableRoot,
|
|
14
|
+
} from '../../../protocol.ts'
|
|
15
|
+
import type { SkillHubApi } from '../../api.ts'
|
|
16
|
+
import { errorMessage, tt } from '../../helpers.ts'
|
|
17
|
+
import { sortSkills, type SortKey } from '../../grouping.ts'
|
|
18
|
+
import type { FlowNotices, UsesMap } from './shared.ts'
|
|
19
|
+
|
|
20
|
+
export function useCatalogFlow(
|
|
21
|
+
api: SkillHubApi,
|
|
22
|
+
uses: UsesMap,
|
|
23
|
+
shared: FlowNotices,
|
|
24
|
+
/** 技能总数 >80 时折叠 personal 分组(聚合根实现,状态归它)。 */
|
|
25
|
+
collapsePersonal: () => void,
|
|
26
|
+
/** 清掉来源筛选(状态归聚合根,这里只在清空筛选项时调用)。 */
|
|
27
|
+
clearSourceFilter: () => void,
|
|
28
|
+
) {
|
|
29
|
+
const [catalog, setCatalog] = useState<CatalogResponse | null>(null)
|
|
30
|
+
const [loading, setLoading] = useState(true)
|
|
31
|
+
const [detail, setDetail] = useState<SkillDetail | null>(null)
|
|
32
|
+
const [detailLoading, setDetailLoading] = useState(false)
|
|
33
|
+
const [busyNames, setBusyNames] = useState<ReadonlySet<string>>(new Set())
|
|
34
|
+
const [search, setSearch] = useState('')
|
|
35
|
+
/** 工作区(项目)路径;空 = 只看用户级技能。 */
|
|
36
|
+
const [workspace, setWorkspace] = useState('')
|
|
37
|
+
const [showForm, setShowForm] = useState(false)
|
|
38
|
+
const [formName, setFormName] = useState('')
|
|
39
|
+
const [formDesc, setFormDesc] = useState('')
|
|
40
|
+
const [formRoot, setFormRoot] = useState<WritableRoot>('user-dsh')
|
|
41
|
+
const [formBusy, setFormBusy] = useState(false)
|
|
42
|
+
const [formMessage, setFormMessage] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)
|
|
43
|
+
const [fixingPaths, setFixingPaths] = useState<ReadonlySet<string>>(new Set())
|
|
44
|
+
const [invocationFilter, setInvocationFilter] = useState<'all' | 'model' | 'user'>('all')
|
|
45
|
+
const [sortKey, setSortKey] = useState<SortKey>('name')
|
|
46
|
+
|
|
47
|
+
const autoCollapsedPersonal = useRef(false)
|
|
48
|
+
|
|
49
|
+
const load = useCallback(async (): Promise<void> => {
|
|
50
|
+
try {
|
|
51
|
+
const next = await api.catalog(workspace !== '' ? { cwd: workspace } : undefined)
|
|
52
|
+
setCatalog(next)
|
|
53
|
+
if (!autoCollapsedPersonal.current && next.skills.length + next.disabled.length > 80) {
|
|
54
|
+
autoCollapsedPersonal.current = true
|
|
55
|
+
collapsePersonal()
|
|
56
|
+
}
|
|
57
|
+
shared.clearFail()
|
|
58
|
+
} catch (error) {
|
|
59
|
+
shared.fail(errorMessage(error))
|
|
60
|
+
} finally {
|
|
61
|
+
setLoading(false)
|
|
62
|
+
}
|
|
63
|
+
}, [api, workspace, shared, collapsePersonal])
|
|
64
|
+
|
|
65
|
+
const openDetail = useCallback(async (name: string): Promise<void> => {
|
|
66
|
+
setDetailLoading(true)
|
|
67
|
+
shared.clearFail()
|
|
68
|
+
try {
|
|
69
|
+
setDetail(await api.skill(name, workspace !== '' ? { cwd: workspace } : undefined))
|
|
70
|
+
} catch (error) {
|
|
71
|
+
shared.fail(errorMessage(error))
|
|
72
|
+
} finally {
|
|
73
|
+
setDetailLoading(false)
|
|
74
|
+
}
|
|
75
|
+
}, [api, workspace, shared])
|
|
76
|
+
|
|
77
|
+
const toggle = useCallback(async (skill: CatalogSkill, enabled: boolean): Promise<void> => {
|
|
78
|
+
setBusyNames((previous) => new Set(previous).add(skill.name))
|
|
79
|
+
shared.clearFail()
|
|
80
|
+
try {
|
|
81
|
+
const next = await api.toggle(skill.name, enabled)
|
|
82
|
+
setCatalog(next)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
shared.fail(errorMessage(error))
|
|
85
|
+
} finally {
|
|
86
|
+
setBusyNames((previous) => {
|
|
87
|
+
const next = new Set(previous)
|
|
88
|
+
next.delete(skill.name)
|
|
89
|
+
return next
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}, [api, shared])
|
|
93
|
+
|
|
94
|
+
const enableDisabled = useCallback(async (record: DisabledSkill): Promise<void> => {
|
|
95
|
+
setBusyNames((previous) => new Set(previous).add(record.name))
|
|
96
|
+
shared.clearFail()
|
|
97
|
+
try {
|
|
98
|
+
const next = await api.toggle(record.name, true)
|
|
99
|
+
setCatalog(next)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
shared.fail(errorMessage(error))
|
|
102
|
+
} finally {
|
|
103
|
+
setBusyNames((previous) => {
|
|
104
|
+
const next = new Set(previous)
|
|
105
|
+
next.delete(record.name)
|
|
106
|
+
return next
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}, [api, shared])
|
|
110
|
+
|
|
111
|
+
/** Toggle an explicit name set in one write (enables disabled members too). */
|
|
112
|
+
const batchToggleNames = useCallback(async (names: string[], enabled: boolean): Promise<void> => {
|
|
113
|
+
if (names.length === 0) return
|
|
114
|
+
shared.setBatchBusy(true)
|
|
115
|
+
shared.clearFail()
|
|
116
|
+
try {
|
|
117
|
+
const next = await api.toggleBatch(names, enabled)
|
|
118
|
+
setCatalog(next.catalog)
|
|
119
|
+
if (next.failures.length > 0) {
|
|
120
|
+
shared.fail('toggle-batch: ' + next.failures.map((failure) => failure.name + ': ' + failure.error).join('; '))
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
shared.fail(errorMessage(error))
|
|
124
|
+
} finally {
|
|
125
|
+
shared.setBatchBusy(false)
|
|
126
|
+
}
|
|
127
|
+
}, [api, shared])
|
|
128
|
+
|
|
129
|
+
const fixDiagnostic = useCallback(async (path: string): Promise<void> => {
|
|
130
|
+
setFixingPaths((previous) => new Set(previous).add(path))
|
|
131
|
+
shared.clearFail()
|
|
132
|
+
try {
|
|
133
|
+
const confirmed = window.confirm(tt('diag.fixConfirm', { path }))
|
|
134
|
+
if (!confirmed) return
|
|
135
|
+
await api.fixDiagnostic(path)
|
|
136
|
+
await load()
|
|
137
|
+
shared.succeed(tt('diag.fixed'))
|
|
138
|
+
} catch (error) {
|
|
139
|
+
shared.fail(errorMessage(error))
|
|
140
|
+
} finally {
|
|
141
|
+
setFixingPaths((previous) => {
|
|
142
|
+
const next = new Set(previous)
|
|
143
|
+
next.delete(path)
|
|
144
|
+
return next
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}, [api, load, shared])
|
|
148
|
+
|
|
149
|
+
const create = useCallback(async (event: FormEvent): Promise<void> => {
|
|
150
|
+
event.preventDefault()
|
|
151
|
+
setFormBusy(true)
|
|
152
|
+
setFormMessage(null)
|
|
153
|
+
shared.succeed(null)
|
|
154
|
+
try {
|
|
155
|
+
const result = await api.create({ name: formName, description: formDesc, root: formRoot })
|
|
156
|
+
// The form closes on success, so the confirmation lives in the green
|
|
157
|
+
// banner outside it (a success message inside the closing form is
|
|
158
|
+
// never visible).
|
|
159
|
+
shared.succeed(tt('form.success') + result.path)
|
|
160
|
+
setFormName('')
|
|
161
|
+
setFormDesc('')
|
|
162
|
+
setShowForm(false)
|
|
163
|
+
await load()
|
|
164
|
+
} catch (error) {
|
|
165
|
+
setFormMessage({ kind: 'error', text: tt('form.error') + errorMessage(error) })
|
|
166
|
+
} finally {
|
|
167
|
+
setFormBusy(false)
|
|
168
|
+
}
|
|
169
|
+
}, [api, formName, formDesc, formRoot, load, shared])
|
|
170
|
+
|
|
171
|
+
// ------------------------------------------------------------- derived
|
|
172
|
+
|
|
173
|
+
const normalized = search.trim().toLocaleLowerCase()
|
|
174
|
+
|
|
175
|
+
/** 当前启用且可写的技能名(组开关只作用于它们)。 */
|
|
176
|
+
const actionNames = useMemo(() => new Set((catalog?.skills ?? []).filter((skill) => skill.writable).map((skill) => skill.name)), [catalog])
|
|
177
|
+
|
|
178
|
+
/** 当前启用的全部技能名(含只读,用于派生开关状态)。 */
|
|
179
|
+
const viewNames = useMemo(() => new Set((catalog?.skills ?? []).map((skill) => skill.name)), [catalog])
|
|
180
|
+
|
|
181
|
+
const filtered = useMemo(() => (catalog?.skills ?? []).filter((skill) => {
|
|
182
|
+
if (invocationFilter === 'model' && !skill.invocation.modelInvocable) return false
|
|
183
|
+
if (invocationFilter === 'user' && !skill.invocation.userInvocable) return false
|
|
184
|
+
if (normalized.length === 0) return true
|
|
185
|
+
return skill.name.toLocaleLowerCase().includes(normalized)
|
|
186
|
+
|| skill.description.toLocaleLowerCase().includes(normalized)
|
|
187
|
+
|| skill.displayName?.toLocaleLowerCase().includes(normalized)
|
|
188
|
+
|| skill.shortDescription?.toLocaleLowerCase().includes(normalized)
|
|
189
|
+
}), [catalog, normalized, invocationFilter])
|
|
190
|
+
/** Rows actually rendered with a shortened description (shortDescription in use). */
|
|
191
|
+
const shortenedCount = useMemo(() => filtered.filter((skill) => skill.shortDescription !== undefined).length, [filtered])
|
|
192
|
+
/** 排序后的技能列表(所有视图共用;调用次数未知按 0 处理)。 */
|
|
193
|
+
const sorted = useMemo(() => sortSkills(filtered, sortKey, (name) => uses.get(name)?.count), [filtered, sortKey, uses])
|
|
194
|
+
|
|
195
|
+
/** Clear the list filters (search + source + invocation) back to the full view. */
|
|
196
|
+
const clearListFilters = useCallback((): void => {
|
|
197
|
+
setSearch('')
|
|
198
|
+
clearSourceFilter()
|
|
199
|
+
setInvocationFilter('all')
|
|
200
|
+
}, [clearSourceFilter])
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
catalog, loading, detail, detailLoading, busyNames, search, workspace,
|
|
204
|
+
showForm, formName, formDesc, formRoot, formBusy, formMessage, fixingPaths,
|
|
205
|
+
invocationFilter, sortKey, normalized, actionNames, viewNames,
|
|
206
|
+
filtered, sorted, shortenedCount,
|
|
207
|
+
setDetail, setSearch, setWorkspace, setShowForm, setFormName, setFormDesc,
|
|
208
|
+
setFormRoot, setFormMessage, setInvocationFilter, setSortKey,
|
|
209
|
+
load, openDetail, toggle, enableDisabled, batchToggleNames, fixDiagnostic, create, clearListFilters,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useGroupFlow — 分组域:groups 加载、tag 新建/删除/成员/重排、集合重排、
|
|
3
|
+
* 来源顶层重排、组开关与冲突解决。目录侧只消费 batchToggleNames +
|
|
4
|
+
* actionNames(聚合根传入),不碰目录 state。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useState, type FormEvent } from 'react'
|
|
8
|
+
import type {
|
|
9
|
+
CollectionReorderRequest,
|
|
10
|
+
GroupsResponse,
|
|
11
|
+
SkillTag,
|
|
12
|
+
SourceGroupReorderRequest,
|
|
13
|
+
TagDeleteRequest,
|
|
14
|
+
TagMembersRequest,
|
|
15
|
+
TagReorderRequest,
|
|
16
|
+
} from '../../../protocol.ts'
|
|
17
|
+
import type { SkillHubApi } from '../../api.ts'
|
|
18
|
+
import { errorMessage } from '../../helpers.ts'
|
|
19
|
+
import { conflictsOnClose, type GroupSwitchState } from '../../grouping.ts'
|
|
20
|
+
import type { FlowNotices } from './shared.ts'
|
|
21
|
+
import type { ConflictDialogState } from '../dialogs.tsx'
|
|
22
|
+
|
|
23
|
+
export function useGroupFlow(
|
|
24
|
+
api: SkillHubApi,
|
|
25
|
+
shared: FlowNotices,
|
|
26
|
+
/** 整组开关的执行器(目录域提供,stable)。 */
|
|
27
|
+
batchToggleNames: (names: string[], enabled: boolean) => Promise<void>,
|
|
28
|
+
/** 当前启用且可写的技能名(目录域派生)。 */
|
|
29
|
+
actionNames: ReadonlySet<string>,
|
|
30
|
+
) {
|
|
31
|
+
const [groupsState, setGroupsState] = useState<GroupsResponse | null>(null)
|
|
32
|
+
const [conflictDialog, setConflictDialog] = useState<ConflictDialogState | null>(null)
|
|
33
|
+
const [editingTag, setEditingTag] = useState<SkillTag | null>(null)
|
|
34
|
+
const [editName, setEditName] = useState('')
|
|
35
|
+
const [membersDraft, setMembersDraft] = useState<ReadonlySet<string>>(new Set())
|
|
36
|
+
const [newTagName, setNewTagName] = useState('')
|
|
37
|
+
const [editSearch, setEditSearch] = useState('')
|
|
38
|
+
|
|
39
|
+
/** 加载用户 tag 分组 + 系统集合组 + origin 映射。 */
|
|
40
|
+
const loadGroups = useCallback(async (): Promise<void> => {
|
|
41
|
+
try {
|
|
42
|
+
setGroupsState(await api.groups())
|
|
43
|
+
} catch (error) {
|
|
44
|
+
shared.fail(errorMessage(error))
|
|
45
|
+
}
|
|
46
|
+
}, [api, shared])
|
|
47
|
+
|
|
48
|
+
/** 把最新的 tag 列表合并进 groups 状态(tags 以外的字段保持原样)。 */
|
|
49
|
+
const applyTags = useCallback((tags: SkillTag[]): void => {
|
|
50
|
+
setGroupsState((previous) => previous === null
|
|
51
|
+
? { ok: true, tags, collections: [], origins: {} }
|
|
52
|
+
: { ...previous, tags })
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
/** 所有分组:key → 成员名(tag 与来源集合)。 */
|
|
56
|
+
const groupMap = useCallback((): Map<string, string[]> => {
|
|
57
|
+
const map = new Map<string, string[]>()
|
|
58
|
+
for (const tag of groupsState?.tags ?? []) map.set('tag:' + tag.id, tag.skillNames)
|
|
59
|
+
for (const collection of groupsState?.collections ?? []) map.set('col:' + collection.name, collection.skillNames)
|
|
60
|
+
return map
|
|
61
|
+
}, [groupsState])
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Toggle a whole group via its tri-state switch. Closing with members that
|
|
65
|
+
* are enabled in other groups opens the conflict dialog instead.
|
|
66
|
+
*/
|
|
67
|
+
const toggleGroup = useCallback((key: string, name: string, view: GroupSwitchState): void => {
|
|
68
|
+
const members = groupMap().get(key) ?? []
|
|
69
|
+
if (members.length === 0) return
|
|
70
|
+
if (view === 'off') {
|
|
71
|
+
void batchToggleNames(members, true)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const others = [...groupMap().entries()].filter(([otherKey]) => otherKey !== key).map(([, memberNames]) => ({ members: memberNames }))
|
|
75
|
+
const conflicts = conflictsOnClose(members, actionNames, others)
|
|
76
|
+
if (conflicts.length > 0) {
|
|
77
|
+
setConflictDialog({ key, name, conflicts })
|
|
78
|
+
} else {
|
|
79
|
+
void batchToggleNames(members.filter((member) => actionNames.has(member)), false)
|
|
80
|
+
}
|
|
81
|
+
}, [groupMap, actionNames, batchToggleNames])
|
|
82
|
+
|
|
83
|
+
/** Resolve the open conflict dialog. */
|
|
84
|
+
const resolveConflict = useCallback(async (closeAll: boolean): Promise<void> => {
|
|
85
|
+
const dialog = conflictDialog
|
|
86
|
+
if (dialog === null) return
|
|
87
|
+
setConflictDialog(null)
|
|
88
|
+
const members = groupMap().get(dialog.key) ?? []
|
|
89
|
+
if (closeAll) {
|
|
90
|
+
await batchToggleNames(members.filter((member) => actionNames.has(member)), false)
|
|
91
|
+
} else {
|
|
92
|
+
await batchToggleNames(
|
|
93
|
+
members.filter((member) => actionNames.has(member) && !dialog.conflicts.includes(member)),
|
|
94
|
+
false,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}, [conflictDialog, groupMap, actionNames, batchToggleNames])
|
|
98
|
+
|
|
99
|
+
/** 新建一个空 tag 分组。 */
|
|
100
|
+
const createTag = useCallback(async (event: FormEvent): Promise<void> => {
|
|
101
|
+
event.preventDefault()
|
|
102
|
+
const name = newTagName.trim()
|
|
103
|
+
if (name === '') return
|
|
104
|
+
shared.setTagBusy(true)
|
|
105
|
+
shared.clearFail()
|
|
106
|
+
try {
|
|
107
|
+
applyTags(await api.saveTag({ name }))
|
|
108
|
+
setNewTagName('')
|
|
109
|
+
} catch (error) {
|
|
110
|
+
shared.fail(errorMessage(error))
|
|
111
|
+
} finally {
|
|
112
|
+
shared.setTagBusy(false)
|
|
113
|
+
}
|
|
114
|
+
}, [api, applyTags, newTagName, shared])
|
|
115
|
+
|
|
116
|
+
/** 删除一个 tag 分组(不影响技能文件)。 */
|
|
117
|
+
const deleteTag = useCallback(async (id: string): Promise<void> => {
|
|
118
|
+
shared.setTagBusy(true)
|
|
119
|
+
shared.clearFail()
|
|
120
|
+
try {
|
|
121
|
+
applyTags(await api.deleteTag(id))
|
|
122
|
+
setEditingTag(null)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
shared.fail(errorMessage(error))
|
|
125
|
+
} finally {
|
|
126
|
+
shared.setTagBusy(false)
|
|
127
|
+
}
|
|
128
|
+
}, [api, applyTags, shared])
|
|
129
|
+
|
|
130
|
+
/** 重命名 tag,或保存成员勾选后回到列表。 */
|
|
131
|
+
const saveTag = useCallback(async (id: string, name: string, memberNames: string[] | null): Promise<void> => {
|
|
132
|
+
shared.setTagBusy(true)
|
|
133
|
+
shared.clearFail()
|
|
134
|
+
try {
|
|
135
|
+
const safeName = name.trim()
|
|
136
|
+
let tags: SkillTag[] | null = null
|
|
137
|
+
if (safeName !== '') tags = await api.saveTag({ id, name: safeName })
|
|
138
|
+
if (memberNames !== null) tags = await api.setTagMembers(id, memberNames)
|
|
139
|
+
if (tags === null) return
|
|
140
|
+
applyTags(tags)
|
|
141
|
+
setEditingTag(null)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
shared.fail(errorMessage(error))
|
|
144
|
+
} finally {
|
|
145
|
+
shared.setTagBusy(false)
|
|
146
|
+
}
|
|
147
|
+
}, [api, applyTags, shared])
|
|
148
|
+
|
|
149
|
+
/** 拖拽重排场景分组 */
|
|
150
|
+
const reorderTags = useCallback(async (orderedIds: string[]): Promise<void> => {
|
|
151
|
+
shared.setTagBusy(true)
|
|
152
|
+
shared.clearFail()
|
|
153
|
+
try {
|
|
154
|
+
const tags = await api.reorderTags(orderedIds)
|
|
155
|
+
applyTags(tags)
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const msg = errorMessage(error)
|
|
158
|
+
if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
|
|
159
|
+
// 旧宿主无此路由:本地重排
|
|
160
|
+
const byId = new Map(groupsState?.tags.map((t) => [t.id, t] as const) ?? [])
|
|
161
|
+
const reordered = orderedIds.map((id) => byId.get(id)).filter((t): t is NonNullable<typeof t> => t !== undefined)
|
|
162
|
+
if (reordered.length === orderedIds.length) {
|
|
163
|
+
applyTags(reordered as typeof groupsState extends { tags: infer T } ? T : never)
|
|
164
|
+
shared.succeed('已临时调整顺序(本地生效,重启宿主后持久化)')
|
|
165
|
+
} else {
|
|
166
|
+
shared.fail(msg)
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
shared.fail(msg)
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
shared.setTagBusy(false)
|
|
173
|
+
}
|
|
174
|
+
}, [api, applyTags, groupsState, shared])
|
|
175
|
+
|
|
176
|
+
/** 拖拽重排来源集合 */
|
|
177
|
+
const reorderCollections = useCallback(async (orderedNames: string[]): Promise<void> => {
|
|
178
|
+
shared.setTagBusy(true)
|
|
179
|
+
shared.clearFail()
|
|
180
|
+
try {
|
|
181
|
+
const collections = await api.reorderCollections(orderedNames)
|
|
182
|
+
setGroupsState((prev) => prev === null ? prev : { ...prev, collections })
|
|
183
|
+
void loadGroups()
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const msg = errorMessage(error)
|
|
186
|
+
// 404 说明宿主仍在跑旧版(需重启后才有新路由),降级为本地即时生效
|
|
187
|
+
if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
|
|
188
|
+
setGroupsState((prev) => {
|
|
189
|
+
if (prev === null) return prev
|
|
190
|
+
const map = new Map(prev.collections.map((c) => [c.name, c] as const))
|
|
191
|
+
const reordered = orderedNames.map((n) => map.get(n)).filter((c): c is NonNullable<typeof c> => c !== undefined)
|
|
192
|
+
// 补上未在 orderedNames 中的集合(新出现的)
|
|
193
|
+
for (const c of prev.collections) if (!reordered.some((r) => r.name === c.name)) reordered.push(c)
|
|
194
|
+
return { ...prev, collections: reordered }
|
|
195
|
+
})
|
|
196
|
+
shared.succeed('已临时调整顺序(本地生效,重启宿主后持久化)')
|
|
197
|
+
} else {
|
|
198
|
+
shared.fail(msg)
|
|
199
|
+
}
|
|
200
|
+
} finally {
|
|
201
|
+
shared.setTagBusy(false)
|
|
202
|
+
}
|
|
203
|
+
}, [api, loadGroups, shared])
|
|
204
|
+
|
|
205
|
+
/** 拖拽重排来源顶层分组(project / col:xxx / personal 全量可拖) */
|
|
206
|
+
const reorderSourceGroups = useCallback(async (orderedKeys: string[]): Promise<void> => {
|
|
207
|
+
shared.setTagBusy(true)
|
|
208
|
+
shared.clearFail()
|
|
209
|
+
try {
|
|
210
|
+
await api.reorderSourceGroups(orderedKeys)
|
|
211
|
+
await loadGroups()
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const msg = errorMessage(error)
|
|
214
|
+
if (msg.includes('404') || msg.toLowerCase().includes('not found')) {
|
|
215
|
+
// 旧宿主无此路由:本地重排顶层顺序,提示重启后持久化
|
|
216
|
+
setGroupsState((prev) => {
|
|
217
|
+
if (prev === null) return prev
|
|
218
|
+
// 本地重排 collections 以匹配 topOrderedKeys 中的 col:xxx 顺序
|
|
219
|
+
const colOrder = orderedKeys.filter((k) => k.startsWith('col:')).map((k) => k.slice(4))
|
|
220
|
+
const map = new Map(prev.collections.map((c) => [c.name, c] as const))
|
|
221
|
+
const reordered = colOrder.map((n) => map.get(n)).filter((c): c is NonNullable<typeof c> => c !== undefined)
|
|
222
|
+
for (const c of prev.collections) if (!reordered.some((r) => r.name === c.name)) reordered.push(c)
|
|
223
|
+
return { ...prev, collections: reordered, sourceGroupOrder: orderedKeys }
|
|
224
|
+
})
|
|
225
|
+
shared.succeed('已临时调整顺序(本地生效,重启宿主后持久化)')
|
|
226
|
+
} else {
|
|
227
|
+
shared.fail(msg)
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
shared.setTagBusy(false)
|
|
231
|
+
}
|
|
232
|
+
}, [api, loadGroups, shared])
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
groupsState, conflictDialog, editingTag, editName, membersDraft, newTagName, editSearch,
|
|
236
|
+
setConflictDialog, setEditingTag, setEditName, setMembersDraft, setNewTagName, setEditSearch,
|
|
237
|
+
loadGroups, applyTags, groupMap, toggleGroup, resolveConflict,
|
|
238
|
+
createTag, deleteTag, saveTag, reorderTags, reorderCollections, reorderSourceGroups,
|
|
239
|
+
}
|
|
240
|
+
}
|