dsh-plugin 0.1.0 → 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.
- package/README.en.md +52 -27
- package/README.md +52 -27
- package/client/client.js +2148 -229
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +396 -0
- package/lib/index.js +17 -8
- package/lib/services/install-types.js +11 -0
- package/lib/services/install.js +4 -0
- package/lib/services/installed-versions.js +51 -0
- package/lib/services/loader.js +98 -0
- package/lib/services/pending-restart.js +34 -0
- package/lib/services/preflight.js +94 -0
- package/lib/services/profile.js +88 -0
- package/lib/services/progress.js +50 -0
- package/lib/services/task-queue.js +417 -0
- package/lib/types/http/routes.d.ts +23 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/services/install-types.d.ts +63 -0
- package/lib/types/services/install.d.ts +18 -0
- package/lib/types/services/installed-versions.d.ts +14 -0
- package/lib/types/services/loader.d.ts +43 -0
- package/lib/types/services/pending-restart.d.ts +13 -0
- package/lib/types/services/preflight.d.ts +6 -0
- package/lib/types/services/profile.d.ts +24 -0
- package/lib/types/services/progress.d.ts +17 -0
- package/lib/types/services/task-queue.d.ts +76 -0
- package/package.json +27 -6
- package/src/client/components/CatalogControls.tsx +104 -0
- package/src/client/components/CatalogHeader.tsx +78 -0
- package/src/client/components/CatalogList.tsx +75 -0
- package/src/client/components/CategoryTabs.tsx +35 -0
- package/src/client/components/Dropdown.tsx +80 -0
- package/src/client/components/NotificationsModal.tsx +230 -0
- package/src/client/components/PluginCard.tsx +104 -0
- package/src/client/components/PluginHubSection.tsx +339 -0
- package/src/client/components/ProgressView.tsx +23 -0
- package/src/client/components/icons.tsx +125 -0
- package/src/client/components/modals.tsx +365 -0
- package/src/client/hooks/useCatalog.ts +192 -0
- package/src/client/hooks/useTaskQueue.ts +405 -0
- package/src/client/index.tsx +8 -422
- package/src/client/lib/catalog.ts +204 -0
- package/src/client/lib/env.ts +16 -0
- package/src/client/lib/failures.ts +153 -0
- package/src/client/lib/format.ts +22 -0
- package/src/client/locales.ts +186 -18
- package/src/client/styles/Dropdown.module.css +123 -0
- package/src/client/styles/Header.module.css +432 -0
- package/src/client/styles/List.module.css +324 -0
- package/src/client/styles/Modal.module.css +965 -0
- package/src/client/styles/tokens.module.css +86 -0
- package/src/client/types.ts +80 -0
- package/src/server/http/routes.ts +397 -0
- package/src/server/index.ts +35 -0
- package/src/server/services/install-types.ts +63 -0
- package/src/server/services/install.ts +18 -0
- package/src/server/services/installed-versions.ts +65 -0
- package/src/server/services/loader.ts +110 -0
- package/src/server/services/pending-restart.ts +36 -0
- package/src/server/services/preflight.ts +97 -0
- package/src/server/services/profile.ts +89 -0
- package/src/server/services/progress.ts +48 -0
- package/src/server/services/task-queue.ts +450 -0
- package/src/client/Section.module.css +0 -569
package/src/client/index.tsx
CHANGED
|
@@ -1,435 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-plugin client:
|
|
3
|
-
* the
|
|
4
|
-
*
|
|
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
|
|
11
|
-
import
|
|
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 }),
|
|
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
|
+
}
|