dsh-plugin 0.1.1 → 1.0.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.
Files changed (64) hide show
  1. package/README.en.md +49 -26
  2. package/README.md +49 -26
  3. package/client/client.js +2148 -229
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +396 -0
  6. package/lib/index.js +17 -8
  7. package/lib/services/install-types.js +11 -0
  8. package/lib/services/install.js +4 -0
  9. package/lib/services/installed-versions.js +51 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/pending-restart.js +34 -0
  12. package/lib/services/preflight.js +94 -0
  13. package/lib/services/profile.js +88 -0
  14. package/lib/services/progress.js +50 -0
  15. package/lib/services/task-queue.js +417 -0
  16. package/lib/types/http/routes.d.ts +23 -0
  17. package/lib/types/index.d.ts +27 -0
  18. package/lib/types/services/install-types.d.ts +63 -0
  19. package/lib/types/services/install.d.ts +18 -0
  20. package/lib/types/services/installed-versions.d.ts +14 -0
  21. package/lib/types/services/loader.d.ts +43 -0
  22. package/lib/types/services/pending-restart.d.ts +13 -0
  23. package/lib/types/services/preflight.d.ts +6 -0
  24. package/lib/types/services/profile.d.ts +24 -0
  25. package/lib/types/services/progress.d.ts +17 -0
  26. package/lib/types/services/task-queue.d.ts +76 -0
  27. package/package.json +27 -6
  28. package/src/client/components/CatalogControls.tsx +104 -0
  29. package/src/client/components/CatalogHeader.tsx +78 -0
  30. package/src/client/components/CatalogList.tsx +75 -0
  31. package/src/client/components/CategoryTabs.tsx +35 -0
  32. package/src/client/components/Dropdown.tsx +80 -0
  33. package/src/client/components/NotificationsModal.tsx +230 -0
  34. package/src/client/components/PluginCard.tsx +104 -0
  35. package/src/client/components/PluginHubSection.tsx +339 -0
  36. package/src/client/components/ProgressView.tsx +23 -0
  37. package/src/client/components/icons.tsx +125 -0
  38. package/src/client/components/modals.tsx +365 -0
  39. package/src/client/hooks/useCatalog.ts +192 -0
  40. package/src/client/hooks/useTaskQueue.ts +405 -0
  41. package/src/client/index.tsx +8 -422
  42. package/src/client/lib/catalog.ts +204 -0
  43. package/src/client/lib/env.ts +16 -0
  44. package/src/client/lib/failures.ts +153 -0
  45. package/src/client/lib/format.ts +22 -0
  46. package/src/client/locales.ts +186 -18
  47. package/src/client/styles/Dropdown.module.css +123 -0
  48. package/src/client/styles/Header.module.css +432 -0
  49. package/src/client/styles/List.module.css +324 -0
  50. package/src/client/styles/Modal.module.css +965 -0
  51. package/src/client/styles/tokens.module.css +86 -0
  52. package/src/client/types.ts +80 -0
  53. package/src/server/http/routes.ts +397 -0
  54. package/src/server/index.ts +35 -0
  55. package/src/server/services/install-types.ts +63 -0
  56. package/src/server/services/install.ts +18 -0
  57. package/src/server/services/installed-versions.ts +65 -0
  58. package/src/server/services/loader.ts +110 -0
  59. package/src/server/services/pending-restart.ts +36 -0
  60. package/src/server/services/preflight.ts +97 -0
  61. package/src/server/services/profile.ts +89 -0
  62. package/src/server/services/progress.ts +48 -0
  63. package/src/server/services/task-queue.ts +450 -0
  64. package/src/client/Section.module.css +0 -589
@@ -1,435 +1,21 @@
1
1
  /**
2
- * dsh-plugin client: a native "Plugin Hub" settings section that renders
3
- * the community marketplace (dsh-plugin.org) as a first-class catalog —
4
- * category tabs (11 categories + All), search, sorting and one-click
5
- * install-command copy. Plugin data is fetched live from dsh-plugin.org,
6
- * so the browser bundle stays small and always in sync with the site.
7
- *
8
- * Built by tsdown into the __ModuleLoader__ factory bundle at client/client.js.
2
+ * dsh-plugin client entry: wires the Plugin Hub into the host settings via
3
+ * the module-loader bundle (see tsdown.config.ts). All UI logic lives in
4
+ * PluginHubSection; this file only performs the cordis apply wiring.
9
5
  */
10
- import { createElement as h, useEffect, useMemo, useRef, useState } from 'react'
11
- import styles from './Section.module.css'
6
+ import { createElement as h } from 'react'
7
+ import type { HubClientContext } from './types.ts'
12
8
  import { en, zh } from './locales.ts'
9
+ import { PluginHubSection } from './components/PluginHubSection.tsx'
10
+ import './styles/tokens.module.css'
13
11
 
14
12
  const NS = 'dsh-plugin'
15
- const SITE_URL = 'https://dsh-plugin.org/'
16
-
17
- type LocaleId = 'zh' | 'en'
18
-
19
- /** The subset of the locale service this plugin touches. */
20
- interface LocaleService {
21
- register(namespace: string, dicts: { zh: Record<string, string>; en: Record<string, string> }): unknown
22
- bind(namespace: string): (key: string, params?: Record<string, string | number>) => string
23
- getSnapshot(): { active: LocaleId }
24
- subscribe(fn: () => void): () => void
25
- }
26
-
27
- /** The subset of the slots service this plugin touches. */
28
- interface SlotsService {
29
- inject(slot: string, register: () => unknown): void
30
- register(meta: Record<string, unknown>, component: () => unknown): unknown
31
- }
32
-
33
- /** The client cordis context shape this plugin relies on (structural). */
34
- interface HubClientContext {
35
- effect(callback: () => unknown, label?: string): void
36
- slots: SlotsService
37
- locale: LocaleService
38
- }
39
-
40
- interface SectionProps {
41
- t: (key: string, params?: Record<string, string | number>) => string
42
- locale: LocaleService
43
- }
44
-
45
- /** Slim projection of the site's DirectoryPlugin entries. */
46
- interface HubPlugin {
47
- /** 作者小写 key,与 slug 共同构成唯一身份(两级路径 /plugins/{ownerSlug}/{slug});旧数据可能缺省 */
48
- ownerSlug?: string
49
- slug: string
50
- displayName?: string
51
- category?: string
52
- topics?: string[]
53
- features?: string[]
54
- description?: string
55
- source?: { repo?: string }
56
- compatibility?: { status?: string }
57
- dates?: { repoUpdatedAt?: string; addedAt?: string }
58
- stats?: { stargazers_count?: number; forks_count?: number }
59
- }
60
-
61
- const CATEGORY_ORDER = [
62
- 'interface', 'session', 'memory', 'tools', 'agent', 'workflow',
63
- 'integration', 'model', 'dev', 'knowledge', 'fun',
64
- ]
65
-
66
- /**
67
- * 归一化在线 API(dsh-plugin.org/api/plugins.{lang}.json)返回的短 key 结构
68
- * (s/o/n/c/t/f/d/r/v/u/a/sg/fk),统一为 HubPlugin,保证渲染逻辑只认一种结构。
69
- */
70
- function normalize(raw: Record<string, unknown>): HubPlugin {
71
- if (typeof raw.s === 'string') {
72
- return {
73
- slug: raw.s,
74
- ownerSlug: typeof raw.o === 'string' ? raw.o : undefined,
75
- displayName: typeof raw.n === 'string' ? raw.n : undefined,
76
- category: typeof raw.c === 'string' ? raw.c : undefined,
77
- topics: Array.isArray(raw.t) ? (raw.t as string[]) : undefined,
78
- features: Array.isArray(raw.f) ? (raw.f as string[]) : undefined,
79
- description: typeof raw.d === 'string' ? raw.d : undefined,
80
- source: typeof raw.r === 'string' ? { repo: raw.r } : undefined,
81
- compatibility: typeof raw.v === 'string' ? { status: raw.v } : undefined,
82
- dates: {
83
- repoUpdatedAt: typeof raw.u === 'string' ? raw.u : undefined,
84
- addedAt: typeof raw.a === 'string' ? raw.a : undefined,
85
- },
86
- stats: {
87
- stargazers_count: typeof raw.sg === 'number' ? raw.sg : undefined,
88
- forks_count: typeof raw.fk === 'number' ? raw.fk : undefined,
89
- },
90
- }
91
- }
92
- return raw as unknown as HubPlugin
93
- }
94
-
95
- const CATEGORY_LABELS: Record<string, { zh: string; en: string }> = {
96
- interface: { zh: '界面与体验', en: 'UI & Experience' },
97
- session: { zh: '会话与消息', en: 'Sessions & Messages' },
98
- memory: { zh: '记忆与上下文', en: 'Memory & Context' },
99
- tools: { zh: '工具与能力', en: 'Tools & Capabilities' },
100
- agent: { zh: '技能与智能体', en: 'Skills & Agents' },
101
- workflow: { zh: '工作流与自动化', en: 'Workflow & Automation' },
102
- integration: { zh: '集成与连接', en: 'Integrations & Connections' },
103
- model: { zh: '模型与推理', en: 'Models & Reasoning' },
104
- dev: { zh: '开发与运维', en: 'Development & Operations' },
105
- knowledge: { zh: '数据与知识', en: 'Data & Knowledge' },
106
- fun: { zh: '娱乐', en: 'Entertainment' },
107
- }
108
-
109
- const CATEGORY_SHORT_LABELS: Record<string, { zh: string; en: string }> = {
110
- interface: { zh: '界面体验', en: 'UI Exp' },
111
- session: { zh: '会话消息', en: 'Sessions' },
112
- memory: { zh: '记忆上下文', en: 'Memory' },
113
- tools: { zh: '工具能力', en: 'Tools' },
114
- agent: { zh: '技能智能体', en: 'Agents' },
115
- workflow: { zh: '工作流', en: 'Workflow' },
116
- integration: { zh: '集成连接', en: 'Integrations' },
117
- model: { zh: '模型推理', en: 'Models' },
118
- dev: { zh: '开发运维', en: 'Dev' },
119
- knowledge: { zh: '数据知识', en: 'Knowledge' },
120
- fun: { zh: '娱乐', en: 'Fun' },
121
- }
122
-
123
- const SORTS = ['sortStars', 'sortUpdated', 'sortNewest'] as const
124
- type SortKey = (typeof SORTS)[number]
125
-
126
- function fmtStars(count?: number): string {
127
- if (!count || count <= 0) return '0'
128
- if (count < 1000) return String(count)
129
- const k = count / 1000
130
- return `${k >= 100 ? Math.round(k) : k.toFixed(1).replace(/\.0$/, '')}k`
131
- }
132
-
133
- function relTime(iso: string | undefined, t: SectionProps['t']): string {
134
- if (!iso) return ''
135
- const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000)
136
- if (days <= 0) return t('today')
137
- if (days < 30) return t('daysAgo', { days })
138
- if (days < 365) return t('monthsAgo', { months: Math.floor(days / 30) })
139
- return t('yearsAgo', { years: Math.floor(days / 365) })
140
- }
141
-
142
- function PluginHubSection({ t: _hostT, locale }: SectionProps) {
143
- const [lang, setLang] = useState<LocaleId>(locale.getSnapshot().active)
144
- const [plugins, setPlugins] = useState<HubPlugin[] | null>(null)
145
- /** 收录/精选统计(官网 /api/stats.json 实时拉取) */
146
- const [stats, setStats] = useState<{ total: number; verified: number } | null>(null)
147
- const [failed, setFailed] = useState(false)
148
- const [reloadKey, setReloadKey] = useState(0)
149
- const [category, setCategory] = useState('all')
150
- const [query, setQuery] = useState('')
151
- const [sort, setSort] = useState<SortKey>('sortStars')
152
- const [copied, setCopied] = useState<string | null>(null)
153
- /** 全局复制成功 Toast:{id} 用于重复点击时重新触发入场动画 */
154
- const [toast, setToast] = useState<{ id: number } | null>(null)
155
- /** 列表滚动容器:分类/搜索切换后列表内容替换但 scrollTop 保留,会让用户误以为列表没更新,需重置回顶部 */
156
- const listRef = useRef<HTMLDivElement | null>(null)
157
-
158
- useEffect(() => {
159
- listRef.current?.scrollTo({ top: 0 })
160
- }, [category])
161
-
162
- useEffect(() => locale.subscribe(() => setLang(locale.getSnapshot().active)), [locale])
163
-
164
- useEffect(() => {
165
- let cancelled = false
166
- setPlugins(null)
167
- setStats(null)
168
- setFailed(false)
169
- const apply = (data: unknown) => {
170
- if (cancelled) return
171
- // 在线 API 已只返回 verified;再过滤一次,保证只展示人工验证通过的插件。
172
- const list = (Array.isArray(data) ? data : []).map((item) => normalize(item as Record<string, unknown>))
173
- setPlugins(list.filter((p) => p.compatibility?.status === 'verified'))
174
- }
175
- const fail = () => {
176
- if (!cancelled) setFailed(true)
177
- }
178
- const fetchData = (url: string): Promise<unknown> =>
179
- fetch(url).then((res) => {
180
- if (!res.ok) throw new Error(String(res.status))
181
- return res.json()
182
- })
183
- // 在线拉取最新数据(dsh-plugin.org,走 CDN 缓存),插件不发布也能拿到每日更新。
184
- const remote = `https://dsh-plugin.org/api/plugins.${lang}.json`
185
- fetchData(remote).then(apply).catch(fail)
186
- // 收录/精选统计:实时拉取官网 /api/stats.json。
187
- const applyStats = (s: { total: number; verified: number }) => {
188
- if (!cancelled && s && typeof s.total === 'number' && typeof s.verified === 'number') setStats(s)
189
- }
190
- fetchData('https://dsh-plugin.org/api/stats.json')
191
- .then((s) => applyStats(s as { total: number; verified: number }))
192
- .catch(() => {})
193
- return () => { cancelled = true }
194
- }, [lang, reloadKey])
195
-
196
- const visible = useMemo(() => {
197
- if (!plugins) return []
198
- const q = query.trim().toLowerCase()
199
- const list = plugins.filter((p) => {
200
- if (category !== 'all' && p.category !== category) return false
201
- if (!q) return true
202
- return (
203
- (p.displayName ?? '').toLowerCase().includes(q) ||
204
- (p.description ?? '').toLowerCase().includes(q) ||
205
- (p.topics ?? []).some((topic) => topic.toLowerCase().includes(q))
206
- )
207
- })
208
- return [...list].sort((a, b) => {
209
- if (sort === 'sortStars') return (b.stats?.stargazers_count ?? 0) - (a.stats?.stargazers_count ?? 0)
210
- if (sort === 'sortNewest') return (b.dates?.addedAt ?? '').localeCompare(a.dates?.addedAt ?? '')
211
- return (b.dates?.repoUpdatedAt ?? '').localeCompare(a.dates?.repoUpdatedAt ?? '')
212
- })
213
- }, [plugins, category, query, sort])
214
-
215
- const langKey: LocaleId = lang === 'en' ? 'en' : 'zh'
216
- // dsh-plugin.org keeps zh pages under the /zh/ prefix; en is the root.
217
- const langPath = lang === 'zh' ? 'zh/' : ''
218
-
219
- // 插件界面语言由组件自身 lang 控制(左上角 EN/中文 按钮),宿主的 t() 绑定宿主 locale、
220
- // 不跟随组件内 lang,因此这里基于本地字典自建翻译函数,保证切换语言后所有文本即时刷新。
221
- // (settings.section 的导航 label 仍在 apply 里用宿主 t(),跟随宿主语言。)
222
- const dict: Record<string, string> = langKey === 'en' ? en : zh
223
- const t = (key: string, params?: Record<string, string | number>): string => {
224
- const raw = dict[key] ?? key
225
- return params ? raw.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? '')) : raw
226
- }
227
- const catLabel = (map: Record<string, { zh: string; en: string }>, key: string): string =>
228
- map[key]?.[langKey] ?? key
229
-
230
- const copyInstall = async (repo: string) => {
231
- const text = `dsh plugin add ${repo}`
232
- let ok = false
233
- try {
234
- await navigator.clipboard.writeText(text)
235
- ok = true
236
- } catch {
237
- // Clipboard API unavailable (permissions/iframe) — fall back to the
238
- // legacy hidden-textarea trick, which works in any context.
239
- const ta = document.createElement('textarea')
240
- ta.value = text
241
- ta.style.position = 'fixed'
242
- ta.style.opacity = '0'
243
- document.body.appendChild(ta)
244
- ta.select()
245
- try {
246
- ok = document.execCommand('copy')
247
- } catch {
248
- ok = false
249
- }
250
- document.body.removeChild(ta)
251
- }
252
- if (ok) {
253
- setCopied(repo)
254
- setToast({ id: Date.now() })
255
- window.setTimeout(() => setCopied((cur) => (cur === repo ? null : cur)), 1600)
256
- window.setTimeout(() => setToast(null), 1800)
257
- } else {
258
- setCopied(null)
259
- }
260
- }
261
-
262
- const total = plugins?.length ?? 0
263
- const count = visible.length
264
-
265
- // 统计数字:官网 /api/stats.json 优先;拉取失败时用已加载列表兜底,避免展示 undefined。
266
- const statsTotal = stats?.total ?? total
267
- const statsVerified = stats?.verified ?? 0
268
-
269
- // Per-category plugin counts shown on the category chips.
270
- const categoryCounts = useMemo(() => {
271
- const counts: Record<string, number> = { all: total }
272
- for (const p of plugins ?? []) {
273
- if (p.category) counts[p.category] = (counts[p.category] ?? 0) + 1
274
- }
275
- return counts
276
- }, [plugins, total])
277
-
278
- return h('div', { className: styles.root },
279
- h('div', { className: styles.header },
280
- h('div', { className: styles.brand },
281
- h('div', { className: styles.brandText },
282
- // 市场主标题即本区块的 H1:与官网首页 H1 同文案(DSH-Plugin Hub for DeepSeek
283
- // Harness),命中品牌词 DSH-Plugin + 产品词 DeepSeek Harness;关键词落在 tagline。
284
- h('h1', { className: styles.title }, t('title')),
285
- h('div', { className: styles.tagline }, t('tagline', { total: statsTotal, verified: statsVerified })),
286
- ),
287
- ),
288
- h('div', { className: styles.controls },
289
- h('input', {
290
- className: styles.search,
291
- type: 'search',
292
- placeholder: t('search'),
293
- value: query,
294
- spellCheck: false,
295
- onInput: (e: React.FormEvent<HTMLInputElement>) =>
296
- setQuery((e.target as HTMLInputElement).value),
297
- }),
298
- h('select', {
299
- className: styles.sort,
300
- value: sort,
301
- onChange: (e: React.FormEvent<HTMLSelectElement>) =>
302
- setSort((e.target as HTMLSelectElement).value as SortKey),
303
- }, SORTS.map((key) => h('option', { key, value: key }, t(key)))),
304
- h('button', {
305
- className: styles.langBtn,
306
- title: lang === 'zh' ? t('toEn') : t('toZh'),
307
- onClick: () => setLang(lang === 'zh' ? 'en' : 'zh'),
308
- }, lang === 'zh' ? 'EN' : '中文'),
309
- h('a', {
310
- className: styles.openBtn,
311
- href: `${SITE_URL}${langPath}`,
312
- target: '_blank',
313
- rel: 'noopener noreferrer',
314
- title: t('openHint'),
315
- }, t('openBtn'), h('span', { className: styles.openArrow }, '\u2197')),
316
- ),
317
- ),
318
-
319
- h('a', {
320
- className: styles.adBanner,
321
- href: `${SITE_URL}${langPath}`,
322
- target: '_blank',
323
- rel: 'noopener noreferrer',
324
- },
325
- h('span', { className: styles.adBadge }, t('adBadge')),
326
- h('span', { className: styles.adText }, t('ad', { total: statsTotal, verified: statsVerified })),
327
- h('span', { className: styles.adArrow }, '\u2197')),
328
-
329
- h('div', { className: styles.tabs },
330
- h('button', {
331
- key: 'all',
332
- className: category === 'all' ? styles.tabActive : styles.tab,
333
- onClick: () => setCategory('all'),
334
- },
335
- t('all'),
336
- h('span', { className: category === 'all' ? styles.tabCountActive : styles.tabCount }, total)),
337
- CATEGORY_ORDER.map((id) => h('button', {
338
- key: id,
339
- className: category === id ? styles.tabActive : styles.tab,
340
- onClick: () => setCategory(id),
341
- },
342
- catLabel(CATEGORY_SHORT_LABELS, id),
343
- h('span', { className: category === id ? styles.tabCountActive : styles.tabCount }, categoryCounts[id] ?? 0))),
344
- ),
345
-
346
- h('div', { className: styles.body },
347
- h('div', { ref: listRef, className: styles.list },
348
- plugins === null && !failed && h('div', { className: styles.state }, t('loading')),
349
- failed && h('div', { className: styles.state },
350
- h('div', { className: styles.stateTitle }, t('failed')),
351
- h('div', { className: styles.stateDesc }, t('failedDesc')),
352
- h('button', { className: styles.retryBtn, onClick: () => setReloadKey((k) => k + 1) }, t('retry')),
353
- ),
354
- plugins !== null && !failed && count === 0 && h('div', { className: styles.state },
355
- h('div', { className: styles.stateTitle }, t('noResult')),
356
- h('div', { className: styles.stateDesc }, t('noResultDesc')),
357
- ),
358
- plugins !== null && !failed && visible.map((p) => {
359
- const repo = p.source?.repo ?? ''
360
- const isCopied = copied === repo
361
- return h('div', {
362
- // 唯一 key:数据重构后同一 slug 可能对应多个作者仓库,仅用 slug 会撞 key,
363
- // 导致切换分类时 React 复用旧 DOM、列表不刷新。
364
- key: p.ownerSlug ? `${p.ownerSlug}/${p.slug}` : p.slug,
365
- className: styles.card,
366
- },
367
- h('div', { className: styles.cardMain },
368
- h('div', { className: styles.cardHead },
369
- h('div', { className: styles.cardTitle, title: p.description ?? '' }, p.displayName ?? p.slug),
370
- p.category ? h('span', { className: styles.categoryBadge }, catLabel(CATEGORY_LABELS, p.category)) : null,
371
- p.compatibility?.status === 'verified'
372
- ? h('span', { className: styles.verified }, t('verified'))
373
- : null,
374
- ),
375
- // 英文模式下隐藏仍为中文的描述:站点英文数据缺翻译时 description 会回退成中文,
376
- // 不显示可保证界面语言一致,不残留中文字符。
377
- p.description && (langKey === 'zh' || !/[\u4e00-\u9fff]/.test(p.description))
378
- ? h('p', { className: styles.desc }, p.description)
379
- : null,
380
- (p.topics?.length ?? 0) > 0
381
- ? h('div', { className: styles.topics },
382
- p.topics!.slice(0, 3).map((topic) => h('span', { key: topic, className: styles.topic }, topic)),
383
- )
384
- : null,
385
- ),
386
- h('div', { className: styles.cardSide },
387
- h('div', { className: styles.stats },
388
- h('span', { className: styles.star }, '\u2605 ', fmtStars(p.stats?.stargazers_count)),
389
- h('span', { className: styles.fork }, t('fork'), ' ', fmtStars(p.stats?.forks_count)),
390
- h('span', { className: styles.date }, relTime(p.dates?.repoUpdatedAt, t)),
391
- ),
392
- repo
393
- ? h('div', { className: styles.actions },
394
- h('a', {
395
- className: styles.detailBtn,
396
- // 两级路径:/plugins/{ownerSlug}/{slug};旧数据缺 ownerSlug 时从 repo 推导
397
- href: `${SITE_URL}${langPath}plugins/${p.ownerSlug ?? repo.split('/')[0]?.toLowerCase() ?? ''}/${p.slug}`,
398
- target: '_blank',
399
- rel: 'noopener noreferrer',
400
- title: p.slug,
401
- }, t('detail')),
402
- h('button', {
403
- className: isCopied ? styles.installBtnCopied : styles.installBtn,
404
- // 文字恒定避免按钮宽度变化导致卡片跳动;成功反馈 = 蓝底高亮 + 右下角 Toast
405
- onClick: () => copyInstall(repo),
406
- }, t('copy')),
407
- )
408
- : null,
409
- ),
410
- )
411
- }),
412
- ),
413
- plugins !== null && !failed && h('div', { className: styles.footer },
414
- h('span', { className: styles.footText }, count === total ? t('pluginsTotal', { n: total }) : t('filteredCount', { n: count })),
415
- h('a', {
416
- className: styles.footLink,
417
- href: `${SITE_URL}${langPath}`,
418
- target: '_blank',
419
- rel: 'noopener noreferrer',
420
- }, t('browseAll', { n: total })),
421
- ),
422
- ),
423
- // 复制成功提示条:右下角纯黑色文字条,1800ms 后自动消失
424
- toast && h('div', { key: toast.id, className: styles.toast }, t('toastCopied')),
425
- )
426
- }
427
13
 
428
14
  export const name = NS
429
15
  export const inject = ['slots', 'locale']
430
16
 
431
17
  export function apply(ctx: HubClientContext): void {
432
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-plugin: dictionaries')
18
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), `${NS}: dictionaries`)
433
19
  const t = ctx.locale.bind(NS)
434
20
  ctx.slots.inject('settings.section', () => ctx.slots.register({
435
21
  name: 'settings.section',
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Catalog constants and normalizers: site URLs, category metadata, sort keys
3
+ * and the mapping from the online API's short-key payloads to HubPlugin.
4
+ */
5
+ import type { EnvInfo, HubPlugin, LocaleId } from '../types.ts'
6
+ import { MAX_CORE_CHARS, classifyFailure, coreErrorCode, summarizeError } from './failures.ts'
7
+
8
+ /** 插件中心官网地址。 */
9
+ export const SITE_URL = 'https://dsh-plugin.org/'
10
+
11
+ /** 插件中心源码仓库:头部右上角 GitHub 图标的跳转地址 */
12
+ export const GITHUB_URL = 'https://github.com/dshplugin/dsh-plugin-hub'
13
+
14
+ /** 构建时由 tsdown 从 package.json 注入的插件版本号(见 tsdown.config.ts define)。 */
15
+ declare const __PLUGIN_VERSION__: string
16
+
17
+ /**
18
+ * 插件当前版本号,供头部标题展示「DSH-Plugin Hub v0.1.1」。
19
+ * tsdown 构建时用 define 把 __PLUGIN_VERSION__ 替换成 package.json 的版本号;
20
+ * node --test 直接 import 本模块时该标识符不存在,typeof 守卫兜底为空串。
21
+ */
22
+ export const PLUGIN_VERSION: string =
23
+ typeof __PLUGIN_VERSION__ === 'string' ? __PLUGIN_VERSION__ : ''
24
+
25
+ /** dsh-plugin.org 中文页挂在 /zh/ 前缀下,英文在根路径。 */
26
+ export function langPathOf(lang: LocaleId): string {
27
+ return lang === 'zh' ? 'zh/' : ''
28
+ }
29
+
30
+ /**
31
+ * 官网详情页两级路径:/plugins/{ownerSlug}/{slug};
32
+ * 旧数据缺 ownerSlug 时从 repo 推导(卡片详情按钮与弹窗来源行共用)。
33
+ */
34
+ export function pluginDetailUrl(plugin: HubPlugin, langPath: string): string {
35
+ const repo = plugin.source?.repo ?? ''
36
+ return `${SITE_URL}${langPath}plugins/${plugin.ownerSlug ?? repo.split('/')[0]?.toLowerCase() ?? ''}/${plugin.slug}`
37
+ }
38
+
39
+ /**
40
+ * 失败弹窗里仓库地址的跳转目标:官网详情页(含插件收录信息),
41
+ * 与 issue 正文的 Catalog 链接同一形式(单级 /plugins/{repo})。
42
+ */
43
+ export function pluginSiteUrl(repo: string): string {
44
+ return `${SITE_URL}plugins/${repo}`
45
+ }
46
+
47
+ /**
48
+ * 一键反馈 GitHub Issue 的预填链接:标题带「来自 dsh-plugin.org」标识,正文只带
49
+ * 核心信息 —— 原因判定 + 关键错误代码 + 宿主机器环境快照 + 错误核心摘要(完整日志
50
+ * 太长,塞进 URL 会被 GitHub 以「request URL too long」拒绝,故只收集重点)。
51
+ * 错误弹窗、失败记录共用此逻辑。
52
+ * env 取不到时为 null,链接照常生成、只是少环境段。
53
+ */
54
+ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | null): string {
55
+ const title = `[dsh-plugin.org | dsh-plugin-hub] Install/Remove failed: ${repo}`
56
+ // 原因判定:让作者一眼知道这是不是自己插件的问题。
57
+ // [packaging](预检/装后校验拦截)单独列:点明「不支持官方默认安装方式」,并写清官方默认方式
58
+ // 是什么(git 直装要求仓库提交构建产物或提供 prepare 脚本),作者照此适配即可。
59
+ const kind = classifyFailure(message)
60
+ const reason = /\[packaging\]/i.test(message)
61
+ ? 'plugin does NOT support the official default install method (dsh plugin add github:owner/repo — requires build output committed to the repo or a prepare script); its git distribution lacks the entry file declared in package.json'
62
+ : kind === 'pluginPrepare'
63
+ ? 'plugin prepare/build script failed during install (packaging/distribution issue)'
64
+ : kind === 'pnpmIgnoredBuild'
65
+ ? 'plugin depends on a native module whose build script pnpm blocks by default (use a prebuilt variant)'
66
+ : 'plugin-side install failure'
67
+ const code = coreErrorCode(message)
68
+ // 按给定核心摘要预算构建完整预填 URL(含 URL 编码);预算可调,供超长时逐档缩小
69
+ const build = (coreChars: number): string => {
70
+ const body = [
71
+ '## Summary',
72
+ `- Cause: ${reason}`,
73
+ ...(code ? [`- Key error: \`${code}\``] : []),
74
+ '',
75
+ // 来源说明:标题(链官网)+ 一句来源(链仓库)+ 实际执行的官方安装命令(含 --profile)与执行结果,链接由常量动态拼接
76
+ `## [DSH-Plugin 插件中心](${SITE_URL}) · 安装 Plugin 失败错误信息`,
77
+ `本错误信息由 [dsh-plugin-hub](${GITHUB_URL}) 插件中心的安装程序自动生成,随本次安装失败一并提交。`,
78
+ `- 使用的安装命令(官方默认安装方式):\`dsh plugin${env?.profile ? ` --profile ${env.profile}` : ''} add github:${repo}\``,
79
+ `- 执行结果:安装失败,未能安装该插件。`,
80
+ // [packaging] 场景:向作者说明官方默认安装方式具体是什么,请其按该方式适配(提交构建产物或提供 prepare 脚本)
81
+ ...(/\[packaging\]/i.test(message)
82
+ ? [
83
+ '',
84
+ '## 官方默认安装方式',
85
+ 'DSH 生态的官方默认安装方式是 `dsh plugin add github:owner/repo`(git 直装):插件仓库需提交构建产物,或在 package.json 提供 `prepare` 脚本让 pnpm 安装时自动构建。当前插件两者皆不具备,因此无法按官方方式安装。',
86
+ ]
87
+ : []),
88
+ '',
89
+ '## Environment',
90
+ `- Plugin: \`${repo}\``,
91
+ // 宿主机器环境快照:便于作者本地复现;某字段缺失时如实写 unknown,不编造
92
+ ...(env
93
+ ? [
94
+ `- DSH: ${env.dshVersion ? `v${env.dshVersion}` : 'unknown'}`,
95
+ `- Plugin Hub: v${PLUGIN_VERSION}`,
96
+ `- Node: ${env.nodeVersion}`,
97
+ `- OS: ${env.platform} ${env.arch} (${env.release})`,
98
+ `- Profile: \`${env.profile}\``,
99
+ `- DSH Home: \`${env.dshHome}\``,
100
+ ]
101
+ : []),
102
+ // 该插件在插件中心的收录位置(详情页链接)
103
+ `- Catalog: [${pluginSiteUrl(repo)}](${pluginSiteUrl(repo)})`,
104
+ '',
105
+ '## Error (core)',
106
+ '',
107
+ '```',
108
+ // 核心错误收集器:只挑关键行(错误代码/构建失败/退出信息),去重截断,URL 才不至于超长
109
+ summarizeError(message, coreChars),
110
+ '```',
111
+ '',
112
+ '---',
113
+ // 底部署名:报错来自内置在 DeepSeek Harness 里的 dsh-plugin-hub 安装程序
114
+ `This error was produced by the [dsh-plugin-hub](${GITHUB_URL}) installer bundled with DeepSeek Harness.`,
115
+ ].join('\n')
116
+ return `https://github.com/${repo}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
117
+ }
118
+ // GitHub 请求行上限 8192 字节:先用宽松预算(最多 ~5K 核心错误)构建,超长再逐档缩小,保证最终 URL 不超限
119
+ const MAX_URL_CHARS = 7600
120
+ let url = build(MAX_CORE_CHARS)
121
+ if (url.length > MAX_URL_CHARS) {
122
+ for (const budget of [4000, 2500, 1400, 800, 400]) {
123
+ url = build(budget)
124
+ if (url.length <= MAX_URL_CHARS) break
125
+ }
126
+ }
127
+ return url
128
+ }
129
+
130
+ export const CATEGORY_ORDER = [
131
+ 'interface', 'session', 'memory', 'tools', 'agent', 'workflow',
132
+ 'integration', 'model', 'dev', 'knowledge', 'fun',
133
+ ]
134
+
135
+ /** 分类标签按界面语言取词;未知 key 原样返回。 */
136
+ export function categoryLabel(
137
+ map: Record<string, { zh: string; en: string }>,
138
+ key: string,
139
+ lang: LocaleId,
140
+ ): string {
141
+ return map[key]?.[lang] ?? key
142
+ }
143
+
144
+ /**
145
+ * 归一化在线 API(dsh-plugin.org/api/plugins.{lang}.json)返回的短 key 结构
146
+ * (s/o/n/vr/c/t/f/d/r/v/u/a/sg/fk),统一为 HubPlugin,保证渲染逻辑只认一种结构。
147
+ */
148
+ export function normalize(raw: Record<string, unknown>): HubPlugin {
149
+ if (typeof raw.s === 'string') {
150
+ return {
151
+ slug: raw.s,
152
+ ownerSlug: typeof raw.o === 'string' ? raw.o : undefined,
153
+ displayName: typeof raw.n === 'string' ? raw.n : undefined,
154
+ version: typeof raw.vr === 'string' ? raw.vr : undefined,
155
+ category: typeof raw.c === 'string' ? raw.c : undefined,
156
+ topics: Array.isArray(raw.t) ? (raw.t as string[]) : undefined,
157
+ features: Array.isArray(raw.f) ? (raw.f as string[]) : undefined,
158
+ description: typeof raw.d === 'string' ? raw.d : undefined,
159
+ source: typeof raw.r === 'string' ? { repo: raw.r } : undefined,
160
+ compatibility: typeof raw.v === 'string' ? { status: raw.v } : undefined,
161
+ dates: {
162
+ repoUpdatedAt: typeof raw.u === 'string' ? raw.u : undefined,
163
+ addedAt: typeof raw.a === 'string' ? raw.a : undefined,
164
+ },
165
+ stats: {
166
+ stargazers_count: typeof raw.sg === 'number' ? raw.sg : undefined,
167
+ forks_count: typeof raw.fk === 'number' ? raw.fk : undefined,
168
+ },
169
+ }
170
+ }
171
+ return raw as unknown as HubPlugin
172
+ }
173
+
174
+ export const CATEGORY_LABELS: Record<string, { zh: string; en: string }> = {
175
+ interface: { zh: '界面与体验', en: 'UI & Experience' },
176
+ session: { zh: '会话与消息', en: 'Sessions & Messages' },
177
+ memory: { zh: '记忆与上下文', en: 'Memory & Context' },
178
+ tools: { zh: '工具与能力', en: 'Tools & Capabilities' },
179
+ agent: { zh: '技能与智能体', en: 'Skills & Agents' },
180
+ workflow: { zh: '工作流与自动化', en: 'Workflow & Automation' },
181
+ integration: { zh: '集成与连接', en: 'Integrations & Connections' },
182
+ model: { zh: '模型与推理', en: 'Models & Reasoning' },
183
+ dev: { zh: '开发与运维', en: 'Development & Operations' },
184
+ knowledge: { zh: '数据与知识', en: 'Data & Knowledge' },
185
+ fun: { zh: '娱乐', en: 'Entertainment' },
186
+ }
187
+
188
+ /** 分类 Tab 的紧凑标签(英文模式空间受限时使用)。 */
189
+ export const CATEGORY_SHORT_LABELS: Record<string, { zh: string; en: string }> = {
190
+ interface: { zh: '界面体验', en: 'UI Exp' },
191
+ session: { zh: '会话消息', en: 'Sessions' },
192
+ memory: { zh: '记忆上下文', en: 'Memory' },
193
+ tools: { zh: '工具能力', en: 'Tools' },
194
+ agent: { zh: '技能智能体', en: 'Agents' },
195
+ workflow: { zh: '工作流', en: 'Workflow' },
196
+ integration: { zh: '集成连接', en: 'Integrations' },
197
+ model: { zh: '模型推理', en: 'Models' },
198
+ dev: { zh: '开发运维', en: 'Dev' },
199
+ knowledge: { zh: '数据知识', en: 'Knowledge' },
200
+ fun: { zh: '娱乐', en: 'Fun' },
201
+ }
202
+
203
+ export const SORTS = ['sortStars', 'sortForks', 'sortUpdated', 'sortNewest'] as const
204
+ export type SortKey = (typeof SORTS)[number]
@@ -0,0 +1,16 @@
1
+ /**
2
+ * One-shot host environment snapshot for bug reports. Fetched lazily and
3
+ * cached at module level: issue URLs embed this info, and the environment
4
+ * does not change during a session. A failed fetch degrades to null so the
5
+ * issue links still work — they just ship without the environment block.
6
+ */
7
+ import type { EnvInfo } from '../types.ts'
8
+
9
+ let envPromise: Promise<EnvInfo | null> | null = null
10
+
11
+ export function getEnv(): Promise<EnvInfo | null> {
12
+ envPromise ??= fetch('/dsh-plugin-hub/env', { cache: 'no-store' })
13
+ .then((res) => (res.ok ? (res.json() as Promise<EnvInfo>) : null))
14
+ .catch(() => null)
15
+ return envPromise
16
+ }