@nextclaw/ui 0.9.2 → 0.9.4
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/CHANGELOG.md +12 -0
- package/dist/assets/ChannelsList-DDfZIiJa.js +1 -0
- package/dist/assets/ChatPage-FpRraTxm.js +38 -0
- package/dist/assets/{DocBrowser-CVwUDJMO.js → DocBrowser-Kndx8OJj.js} +1 -1
- package/dist/assets/LogoBadge-hKHoLH9n.js +1 -0
- package/dist/assets/MarketplacePage-CZIJyfjK.js +49 -0
- package/dist/assets/McpMarketplacePage-BGrAMA37.js +40 -0
- package/dist/assets/{ModelConfig-CsX-_fyy.js → ModelConfig-BpKQeGfb.js} +1 -1
- package/dist/assets/ProvidersList-qfUL6mrW.js +1 -0
- package/dist/assets/RemoteAccessPage-BQuMsngI.js +1 -0
- package/dist/assets/{RuntimeConfig-CX2TGEG1.js → RuntimeConfig-CVlqNWKO.js} +1 -1
- package/dist/assets/{SearchConfig-C-WBTcWi.js → SearchConfig-DXFV6Mvx.js} +1 -1
- package/dist/assets/{SecretsConfig-9kbR0ZCB.js → SecretsConfig-BGW9aUqv.js} +2 -2
- package/dist/assets/{SessionsConfig-Bohn3P1q.js → SessionsConfig-BByfa1ke.js} +2 -2
- package/dist/assets/{chat-message-AWIcksDK.js → chat-message-ZwnDwDuQ.js} +1 -1
- package/dist/assets/index-BWvap_iq.js +8 -0
- package/dist/assets/index-COrhpAdh.css +1 -0
- package/dist/assets/{index-CPDASUXh.js → index-Ct7FQpxN.js} +1 -1
- package/dist/assets/{label-DD61y-4v.js → label-Bklr3fXc.js} +1 -1
- package/dist/assets/marketplace-localization-Dk31LJJJ.js +1 -0
- package/dist/assets/{page-layout-CfnoVycc.js → page-layout-sNhcbwtm.js} +1 -1
- package/dist/assets/{popover-DsugZ6rp.js → popover-C3rJrJJG.js} +1 -1
- package/dist/assets/{security-config-DIrf2Z0O.js → security-config-BueosYw1.js} +1 -1
- package/dist/assets/skeleton-CiG6msbm.js +1 -0
- package/dist/assets/status-dot-CsIV5YrS.js +1 -0
- package/dist/assets/{switch-NX5OmUXQ.js → switch-DSdHSIsC.js} +1 -1
- package/dist/assets/{tabs-custom-9ihB5Jem.js → tabs-custom-BB-VjdL2.js} +1 -1
- package/dist/assets/{useConfirmDialog-BuQnVTeR.js → useConfirmDialog-BL5s8KDC.js} +2 -2
- package/dist/assets/{vendor-DKBNiC31.js → vendor-CwsIoNvJ.js} +128 -93
- package/dist/index.html +3 -3
- package/package.json +3 -3
- package/src/App.tsx +4 -0
- package/src/api/auth.types.ts +24 -0
- package/src/api/chat-session-type.types.ts +21 -0
- package/src/api/marketplace.ts +8 -2
- package/src/api/mcp-marketplace.ts +138 -0
- package/src/api/remote.ts +77 -0
- package/src/api/remote.types.ts +104 -0
- package/src/api/types.ts +28 -34
- package/src/components/chat/ChatSidebar.test.tsx +31 -2
- package/src/components/chat/ChatSidebar.tsx +26 -2
- package/src/components/chat/chat-page-data.ts +36 -38
- package/src/components/chat/chat-page-runtime.test.ts +96 -2
- package/src/components/chat/chat-page-runtime.ts +1 -135
- package/src/components/chat/chat-session-preference-governance.ts +303 -0
- package/src/components/chat/legacy/LegacyChatPage.tsx +4 -19
- package/src/components/chat/ncp/NcpChatPage.tsx +4 -19
- package/src/components/chat/ncp/ncp-chat-page-data.test.ts +36 -0
- package/src/components/chat/ncp/ncp-chat-page-data.ts +62 -21
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +2 -0
- package/src/components/chat/ncp/ncp-session-adapter.ts +2 -0
- package/src/components/chat/stores/chat-input.store.ts +14 -1
- package/src/components/chat/useChatSessionTypeState.test.tsx +29 -0
- package/src/components/chat/useChatSessionTypeState.ts +55 -12
- package/src/components/layout/Sidebar.tsx +11 -1
- package/src/components/marketplace/MarketplacePage.test.tsx +152 -0
- package/src/components/marketplace/MarketplacePage.tsx +52 -199
- package/src/components/marketplace/marketplace-installed-cache.test.ts +110 -0
- package/src/components/marketplace/marketplace-installed-cache.ts +149 -0
- package/src/components/marketplace/marketplace-localization.ts +77 -0
- package/src/components/marketplace/marketplace-page-parts.tsx +102 -0
- package/src/components/marketplace/mcp/McpMarketplacePage.test.tsx +208 -0
- package/src/components/marketplace/mcp/McpMarketplacePage.tsx +578 -0
- package/src/components/remote/RemoteAccessPage.tsx +396 -0
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/label.tsx +1 -1
- package/src/hooks/useMarketplace.ts +36 -7
- package/src/hooks/useMcpMarketplace.ts +99 -0
- package/src/hooks/useRemoteAccess.ts +120 -0
- package/src/hooks/useWebSocket.ts +25 -16
- package/src/lib/i18n.marketplace.ts +91 -0
- package/src/lib/i18n.remote.ts +142 -0
- package/src/lib/i18n.ts +10 -68
- package/dist/assets/ChannelsList-DKD6Llid.js +0 -1
- package/dist/assets/ChatPage-BK9X4Tin.js +0 -38
- package/dist/assets/LogoBadge-CYQ_b7jk.js +0 -1
- package/dist/assets/MarketplacePage-B_2z3ii_.js +0 -49
- package/dist/assets/ProvidersList-CZstsyv7.js +0 -1
- package/dist/assets/index-BEgClaDH.js +0 -8
- package/dist/assets/index-C8GsgIUn.css +0 -1
- package/dist/assets/skeleton-DJ-Wen2o.js +0 -1
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/* eslint-disable max-lines-per-function */
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { useMutation } from '@tanstack/react-query';
|
|
4
|
+
import { PageHeader, PageLayout } from '@/components/layout/page-layout';
|
|
5
|
+
import { Tabs } from '@/components/ui/tabs-custom';
|
|
6
|
+
import { Input } from '@/components/ui/input';
|
|
7
|
+
import { Switch } from '@/components/ui/switch';
|
|
8
|
+
import { Button } from '@/components/ui/button';
|
|
9
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
10
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
11
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
12
|
+
import { useConfirmDialog } from '@/hooks/useConfirmDialog';
|
|
13
|
+
import {
|
|
14
|
+
fetchMcpMarketplaceContent,
|
|
15
|
+
doctorMcpMarketplaceItem
|
|
16
|
+
} from '@/api/mcp-marketplace';
|
|
17
|
+
import {
|
|
18
|
+
useInstallMcpMarketplaceItem,
|
|
19
|
+
useManageMcpMarketplaceItem,
|
|
20
|
+
useMcpMarketplaceInstalled,
|
|
21
|
+
useMcpMarketplaceItems
|
|
22
|
+
} from '@/hooks/useMcpMarketplace';
|
|
23
|
+
import type {
|
|
24
|
+
MarketplaceInstalledRecord,
|
|
25
|
+
MarketplaceItemSummary,
|
|
26
|
+
MarketplaceMcpDoctorResult,
|
|
27
|
+
MarketplaceMcpInstallSpec,
|
|
28
|
+
MarketplaceSort
|
|
29
|
+
} from '@/api/types';
|
|
30
|
+
import { useDocBrowser } from '@/components/doc-browser';
|
|
31
|
+
import { useI18n } from '@/components/providers/I18nProvider';
|
|
32
|
+
import {
|
|
33
|
+
buildLocaleFallbacks,
|
|
34
|
+
pickInstalledRecordDescription,
|
|
35
|
+
pickLocalizedText
|
|
36
|
+
} from '@/components/marketplace/marketplace-localization';
|
|
37
|
+
import { t } from '@/lib/i18n';
|
|
38
|
+
import { cn } from '@/lib/utils';
|
|
39
|
+
|
|
40
|
+
type ScopeType = 'catalog' | 'installed';
|
|
41
|
+
|
|
42
|
+
const PAGE_SIZE = 12;
|
|
43
|
+
|
|
44
|
+
function normalizeMarketplaceKey(value: string | undefined): string {
|
|
45
|
+
return (value ?? '').trim().toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildInstalledRecordLookup(records: MarketplaceInstalledRecord[]): Map<string, MarketplaceInstalledRecord> {
|
|
49
|
+
const lookup = new Map<string, MarketplaceInstalledRecord>();
|
|
50
|
+
|
|
51
|
+
for (const record of records) {
|
|
52
|
+
const candidates = [record.catalogSlug, record.spec, record.id, record.label];
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
const normalized = normalizeMarketplaceKey(candidate);
|
|
55
|
+
if (!normalized || lookup.has(normalized)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
lookup.set(normalized, record);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return lookup;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function findInstalledRecordForItem(
|
|
66
|
+
item: MarketplaceItemSummary,
|
|
67
|
+
installedRecordLookup: Map<string, MarketplaceInstalledRecord>
|
|
68
|
+
): MarketplaceInstalledRecord | undefined {
|
|
69
|
+
const candidates = [item.slug, item.install.spec, item.id, item.name];
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
const normalized = normalizeMarketplaceKey(candidate);
|
|
72
|
+
if (!normalized) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const record = installedRecordLookup.get(normalized);
|
|
76
|
+
if (record) {
|
|
77
|
+
return record;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildDocDataUrl(title: string, metadata: string, content: string, sourceUrl?: string, summary?: string): string {
|
|
84
|
+
const escape = (value: string) =>
|
|
85
|
+
value
|
|
86
|
+
.replace(/&/g, '&')
|
|
87
|
+
.replace(/</g, '<')
|
|
88
|
+
.replace(/>/g, '>')
|
|
89
|
+
.replace(/"/g, '"')
|
|
90
|
+
.replace(/'/g, ''');
|
|
91
|
+
|
|
92
|
+
const html = `<!doctype html>
|
|
93
|
+
<html>
|
|
94
|
+
<head>
|
|
95
|
+
<meta charset="utf-8" />
|
|
96
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
97
|
+
<title>${escape(title)}</title>
|
|
98
|
+
<style>
|
|
99
|
+
body { margin: 0; background: #f8fafc; color: #0f172a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|
100
|
+
.wrap { max-width: 980px; margin: 0 auto; padding: 28px 20px 40px; }
|
|
101
|
+
.hero { border: 1px solid #dbeafe; border-radius: 16px; background: linear-gradient(180deg, #eff6ff, #ffffff); padding: 20px; }
|
|
102
|
+
.hero h1 { margin: 0; font-size: 26px; }
|
|
103
|
+
.grid { display: grid; grid-template-columns: 280px 1fr; gap: 14px; margin-top: 16px; }
|
|
104
|
+
.card { border: 1px solid #e2e8f0; background: #fff; border-radius: 14px; overflow: hidden; }
|
|
105
|
+
.card h2 { margin: 0; padding: 12px 14px; font-size: 13px; font-weight: 700; color: #1d4ed8; border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
|
|
106
|
+
.body { padding: 12px 14px; }
|
|
107
|
+
pre { margin: 0; white-space: pre-wrap; line-height: 1.7; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
108
|
+
a { color: #2563eb; text-decoration: none; }
|
|
109
|
+
@media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
|
|
110
|
+
</style>
|
|
111
|
+
</head>
|
|
112
|
+
<body>
|
|
113
|
+
<main class="wrap">
|
|
114
|
+
<section class="hero">
|
|
115
|
+
<h1>${escape(title)}</h1>
|
|
116
|
+
${summary ? `<p>${escape(summary)}</p>` : ''}
|
|
117
|
+
${sourceUrl ? `<p><a href="${escape(sourceUrl)}" target="_blank" rel="noopener noreferrer">${escape(sourceUrl)}</a></p>` : ''}
|
|
118
|
+
</section>
|
|
119
|
+
<section class="grid">
|
|
120
|
+
<article class="card">
|
|
121
|
+
<h2>Metadata</h2>
|
|
122
|
+
<div class="body"><pre>${escape(metadata)}</pre></div>
|
|
123
|
+
</article>
|
|
124
|
+
<article class="card">
|
|
125
|
+
<h2>Content</h2>
|
|
126
|
+
<div class="body"><pre>${escape(content)}</pre></div>
|
|
127
|
+
</article>
|
|
128
|
+
</section>
|
|
129
|
+
</main>
|
|
130
|
+
</body>
|
|
131
|
+
</html>`;
|
|
132
|
+
|
|
133
|
+
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readSummary(localeFallbacks: string[], item?: MarketplaceItemSummary, record?: MarketplaceInstalledRecord): string {
|
|
137
|
+
const localizedSummary = pickLocalizedText(item?.summaryI18n, item?.summary, localeFallbacks);
|
|
138
|
+
if (localizedSummary) {
|
|
139
|
+
return localizedSummary;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const localizedRecordDescription = pickInstalledRecordDescription(record, localeFallbacks);
|
|
143
|
+
return localizedRecordDescription || t('marketplaceInstalledLocalSummary');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readTransportLabel(item?: MarketplaceItemSummary, record?: MarketplaceInstalledRecord): string {
|
|
147
|
+
if (record?.transport) {
|
|
148
|
+
return record.transport.toUpperCase();
|
|
149
|
+
}
|
|
150
|
+
const install = item?.install as MarketplaceMcpInstallSpec | undefined;
|
|
151
|
+
return (install?.transportTypes ?? []).map((entry) => entry.toUpperCase()).join(' / ') || 'MCP';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function InstallDialog(props: {
|
|
155
|
+
item: MarketplaceItemSummary | null;
|
|
156
|
+
open: boolean;
|
|
157
|
+
pending: boolean;
|
|
158
|
+
onOpenChange: (open: boolean) => void;
|
|
159
|
+
onSubmit: (payload: { name: string; allAgents: boolean; inputs: Record<string, string> }) => Promise<void>;
|
|
160
|
+
}) {
|
|
161
|
+
const template = props.item?.install as MarketplaceMcpInstallSpec | undefined;
|
|
162
|
+
const [name, setName] = useState('');
|
|
163
|
+
const [allAgents, setAllAgents] = useState(true);
|
|
164
|
+
const [inputs, setInputs] = useState<Record<string, string>>({});
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
setName(template?.defaultName ?? '');
|
|
168
|
+
setAllAgents(true);
|
|
169
|
+
setInputs(
|
|
170
|
+
Object.fromEntries((template?.inputs ?? []).map((field) => [field.id, field.defaultValue ?? '']))
|
|
171
|
+
);
|
|
172
|
+
}, [template, props.open]);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
|
176
|
+
<DialogContent>
|
|
177
|
+
<DialogHeader>
|
|
178
|
+
<DialogTitle>{t('marketplaceMcpInstallDialogTitle')}</DialogTitle>
|
|
179
|
+
<DialogDescription>{props.item?.name ?? '-'}</DialogDescription>
|
|
180
|
+
</DialogHeader>
|
|
181
|
+
|
|
182
|
+
<div className="space-y-4">
|
|
183
|
+
<div className="space-y-2">
|
|
184
|
+
<div className="text-sm font-medium text-gray-800">{t('marketplaceMcpServerName')}</div>
|
|
185
|
+
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder={template?.defaultName ?? 'mcp-server'} />
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div className="flex items-center justify-between rounded-xl border border-gray-200 px-3 py-3">
|
|
189
|
+
<div>
|
|
190
|
+
<div className="text-sm font-medium text-gray-900">{t('marketplaceMcpAllAgents')}</div>
|
|
191
|
+
<div className="text-xs text-gray-500">{t('marketplaceMcpAllAgentsDescription')}</div>
|
|
192
|
+
</div>
|
|
193
|
+
<Switch checked={allAgents} onCheckedChange={setAllAgents} />
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{(template?.inputs ?? []).map((field) => (
|
|
197
|
+
<div key={field.id} className="space-y-2">
|
|
198
|
+
<div className="text-sm font-medium text-gray-800">{field.label}</div>
|
|
199
|
+
{field.description && <div className="text-xs text-gray-500">{field.description}</div>}
|
|
200
|
+
<Input
|
|
201
|
+
type={field.secret ? 'password' : 'text'}
|
|
202
|
+
value={inputs[field.id] ?? ''}
|
|
203
|
+
onChange={(event) => setInputs((current) => ({ ...current, [field.id]: event.target.value }))}
|
|
204
|
+
placeholder={field.defaultValue ?? ''}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<DialogFooter>
|
|
211
|
+
<Button variant="outline" onClick={() => props.onOpenChange(false)} disabled={props.pending}>
|
|
212
|
+
{t('cancel')}
|
|
213
|
+
</Button>
|
|
214
|
+
<Button
|
|
215
|
+
onClick={() => void props.onSubmit({ name, allAgents, inputs })}
|
|
216
|
+
disabled={props.pending || !name.trim()}
|
|
217
|
+
>
|
|
218
|
+
{props.pending ? t('marketplaceInstalling') : t('marketplaceInstall')}
|
|
219
|
+
</Button>
|
|
220
|
+
</DialogFooter>
|
|
221
|
+
</DialogContent>
|
|
222
|
+
</Dialog>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function DoctorDialog(props: {
|
|
227
|
+
result: MarketplaceMcpDoctorResult | null;
|
|
228
|
+
targetName: string | null;
|
|
229
|
+
open: boolean;
|
|
230
|
+
pending: boolean;
|
|
231
|
+
onOpenChange: (open: boolean) => void;
|
|
232
|
+
}) {
|
|
233
|
+
return (
|
|
234
|
+
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
|
235
|
+
<DialogContent>
|
|
236
|
+
<DialogHeader>
|
|
237
|
+
<DialogTitle>{t('marketplaceMcpDoctorTitle')}</DialogTitle>
|
|
238
|
+
<DialogDescription>{props.targetName ?? '-'}</DialogDescription>
|
|
239
|
+
</DialogHeader>
|
|
240
|
+
{props.pending && <div className="text-sm text-gray-500">{t('loading')}</div>}
|
|
241
|
+
{!props.pending && props.result && (
|
|
242
|
+
<div className="space-y-3 text-sm text-gray-700">
|
|
243
|
+
<div>{t('marketplaceMcpDoctorAccessible')}: {props.result.accessible ? t('statusReady') : t('marketplaceOperationFailed')}</div>
|
|
244
|
+
<div>{t('marketplaceMcpDoctorTransport')}: {props.result.transport.toUpperCase()}</div>
|
|
245
|
+
<div>{t('marketplaceMcpDoctorTools')}: {props.result.toolCount}</div>
|
|
246
|
+
{props.result.error && <div className="rounded-lg bg-rose-50 px-3 py-2 text-rose-600">{props.result.error}</div>}
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</DialogContent>
|
|
250
|
+
</Dialog>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function McpMarketplacePage() {
|
|
255
|
+
const [scope, setScope] = useState<ScopeType>('catalog');
|
|
256
|
+
const [searchText, setSearchText] = useState('');
|
|
257
|
+
const [query, setQuery] = useState('');
|
|
258
|
+
const [sort, setSort] = useState<MarketplaceSort>('relevance');
|
|
259
|
+
const [page, setPage] = useState(1);
|
|
260
|
+
const [installingItem, setInstallingItem] = useState<MarketplaceItemSummary | null>(null);
|
|
261
|
+
const [doctorTarget, setDoctorTarget] = useState<string | null>(null);
|
|
262
|
+
const [doctorResult, setDoctorResult] = useState<MarketplaceMcpDoctorResult | null>(null);
|
|
263
|
+
const { language } = useI18n();
|
|
264
|
+
const docBrowser = useDocBrowser();
|
|
265
|
+
const { confirm, ConfirmDialog } = useConfirmDialog();
|
|
266
|
+
const localeFallbacks = useMemo(() => buildLocaleFallbacks(language), [language]);
|
|
267
|
+
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
const timer = window.setTimeout(() => {
|
|
270
|
+
setPage(1);
|
|
271
|
+
setQuery(searchText.trim());
|
|
272
|
+
}, 250);
|
|
273
|
+
return () => window.clearTimeout(timer);
|
|
274
|
+
}, [searchText]);
|
|
275
|
+
|
|
276
|
+
const itemsQuery = useMcpMarketplaceItems({
|
|
277
|
+
q: query || undefined,
|
|
278
|
+
sort,
|
|
279
|
+
page,
|
|
280
|
+
pageSize: PAGE_SIZE
|
|
281
|
+
});
|
|
282
|
+
const installedQuery = useMcpMarketplaceInstalled();
|
|
283
|
+
|
|
284
|
+
const installMutation = useInstallMcpMarketplaceItem();
|
|
285
|
+
const manageMutation = useManageMcpMarketplaceItem();
|
|
286
|
+
const doctorMutation = useMutation({
|
|
287
|
+
mutationFn: doctorMcpMarketplaceItem,
|
|
288
|
+
onSuccess: (result, name) => {
|
|
289
|
+
setDoctorTarget(name);
|
|
290
|
+
setDoctorResult(result);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const installedRecordLookup = useMemo(() => {
|
|
295
|
+
return buildInstalledRecordLookup(installedQuery.data?.records ?? []);
|
|
296
|
+
}, [installedQuery.data?.records]);
|
|
297
|
+
|
|
298
|
+
const installedRecords = useMemo(() => {
|
|
299
|
+
const entries = installedQuery.data?.records ?? [];
|
|
300
|
+
return entries.filter((record) => {
|
|
301
|
+
const text = [
|
|
302
|
+
record.id ?? '',
|
|
303
|
+
record.label ?? '',
|
|
304
|
+
record.catalogSlug ?? '',
|
|
305
|
+
record.description ?? '',
|
|
306
|
+
record.descriptionZh ?? ''
|
|
307
|
+
].join(' ').toLowerCase();
|
|
308
|
+
return query ? text.includes(query.toLowerCase()) : true;
|
|
309
|
+
});
|
|
310
|
+
}, [installedQuery.data?.records, query]);
|
|
311
|
+
|
|
312
|
+
const openDoc = async (item?: MarketplaceItemSummary, record?: MarketplaceInstalledRecord) => {
|
|
313
|
+
const title = item?.name ?? record?.label ?? record?.id ?? 'MCP';
|
|
314
|
+
const summary = readSummary(localeFallbacks, item, record);
|
|
315
|
+
if (!item) {
|
|
316
|
+
const url = buildDocDataUrl(
|
|
317
|
+
title,
|
|
318
|
+
JSON.stringify(record ?? {}, null, 2),
|
|
319
|
+
t('marketplaceInstalledLocalSummary'),
|
|
320
|
+
record?.docsUrl,
|
|
321
|
+
summary
|
|
322
|
+
);
|
|
323
|
+
docBrowser.open(url, { newTab: true, title, kind: 'content' });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
const content = await fetchMcpMarketplaceContent(item.slug);
|
|
328
|
+
const url = buildDocDataUrl(
|
|
329
|
+
title,
|
|
330
|
+
content.metadataRaw || JSON.stringify(item, null, 2),
|
|
331
|
+
content.bodyRaw || content.raw,
|
|
332
|
+
content.sourceUrl,
|
|
333
|
+
summary
|
|
334
|
+
);
|
|
335
|
+
docBrowser.open(url, { newTab: true, title, kind: 'content' });
|
|
336
|
+
} catch (error) {
|
|
337
|
+
const url = buildDocDataUrl(
|
|
338
|
+
title,
|
|
339
|
+
JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2),
|
|
340
|
+
summary,
|
|
341
|
+
undefined,
|
|
342
|
+
summary
|
|
343
|
+
);
|
|
344
|
+
docBrowser.open(url, { newTab: true, title, kind: 'content' });
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const handleInstall = async (payload: { name: string; allAgents: boolean; inputs: Record<string, string> }) => {
|
|
349
|
+
if (!installingItem) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
await installMutation.mutateAsync({
|
|
353
|
+
spec: installingItem.slug,
|
|
354
|
+
name: payload.name.trim(),
|
|
355
|
+
allAgents: payload.allAgents,
|
|
356
|
+
inputs: payload.inputs
|
|
357
|
+
});
|
|
358
|
+
setInstallingItem(null);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const handleManage = async (action: 'enable' | 'disable' | 'remove', record: MarketplaceInstalledRecord) => {
|
|
362
|
+
const target = record.id || record.spec;
|
|
363
|
+
if (!target) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (action === 'remove') {
|
|
367
|
+
const confirmed = await confirm({
|
|
368
|
+
title: `${t('marketplaceMcpRemoveTitle')} ${target}?`,
|
|
369
|
+
description: t('marketplaceMcpRemoveDescription'),
|
|
370
|
+
confirmLabel: t('marketplaceMcpRemove'),
|
|
371
|
+
variant: 'destructive'
|
|
372
|
+
});
|
|
373
|
+
if (!confirmed) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
await manageMutation.mutateAsync({
|
|
378
|
+
action,
|
|
379
|
+
id: target,
|
|
380
|
+
spec: record.spec
|
|
381
|
+
});
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const renderCard = (item?: MarketplaceItemSummary, record?: MarketplaceInstalledRecord) => {
|
|
385
|
+
const installed = record ?? (item ? findInstalledRecordForItem(item, installedRecordLookup) : undefined);
|
|
386
|
+
const name = item?.name ?? record?.label ?? record?.id ?? 'MCP';
|
|
387
|
+
const summary = readSummary(localeFallbacks, item, record);
|
|
388
|
+
const transport = readTransportLabel(item, record);
|
|
389
|
+
const status = installed ? (installed.enabled === false ? t('marketplaceDisable') : t('statusReady')) : null;
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
<article
|
|
393
|
+
key={`${item?.id ?? record?.id ?? record?.spec}`}
|
|
394
|
+
onClick={() => void openDoc(item, record)}
|
|
395
|
+
className="cursor-pointer rounded-2xl border border-gray-200/70 bg-white p-4 shadow-sm transition hover:border-blue-300 hover:shadow-md"
|
|
396
|
+
>
|
|
397
|
+
<div className="flex items-start justify-between gap-3">
|
|
398
|
+
<div className="min-w-0">
|
|
399
|
+
<div className="text-sm font-semibold text-gray-900">{name}</div>
|
|
400
|
+
<div className="mt-1 text-xs text-gray-500">{transport}</div>
|
|
401
|
+
<div className="mt-2 line-clamp-2 text-sm text-gray-600">{summary}</div>
|
|
402
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
403
|
+
{(item?.tags ?? []).map((tag) => (
|
|
404
|
+
<span key={tag} className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] text-slate-600">{tag}</span>
|
|
405
|
+
))}
|
|
406
|
+
{status && <span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] text-emerald-700">{status}</span>}
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
<div className="flex shrink-0 flex-col gap-2">
|
|
411
|
+
{!installed && item && (
|
|
412
|
+
<button
|
|
413
|
+
className="rounded-xl bg-primary px-3 py-1.5 text-xs font-medium text-white"
|
|
414
|
+
onClick={(event) => {
|
|
415
|
+
event.stopPropagation();
|
|
416
|
+
setInstallingItem(item);
|
|
417
|
+
}}
|
|
418
|
+
>
|
|
419
|
+
{t('marketplaceInstall')}
|
|
420
|
+
</button>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{installed && (
|
|
424
|
+
<>
|
|
425
|
+
<button
|
|
426
|
+
className="rounded-xl border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-700"
|
|
427
|
+
onClick={(event) => {
|
|
428
|
+
event.stopPropagation();
|
|
429
|
+
void handleManage(installed.enabled === false ? 'enable' : 'disable', installed);
|
|
430
|
+
}}
|
|
431
|
+
>
|
|
432
|
+
{installed.enabled === false ? t('marketplaceEnable') : t('marketplaceDisable')}
|
|
433
|
+
</button>
|
|
434
|
+
<button
|
|
435
|
+
className="rounded-xl border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700"
|
|
436
|
+
onClick={(event) => {
|
|
437
|
+
event.stopPropagation();
|
|
438
|
+
setDoctorTarget(installed.id ?? null);
|
|
439
|
+
setDoctorResult(null);
|
|
440
|
+
void doctorMutation.mutateAsync(installed.id ?? '');
|
|
441
|
+
}}
|
|
442
|
+
>
|
|
443
|
+
{t('marketplaceMcpDoctor')}
|
|
444
|
+
</button>
|
|
445
|
+
<button
|
|
446
|
+
className="rounded-xl border border-rose-200 px-3 py-1.5 text-xs font-medium text-rose-600"
|
|
447
|
+
onClick={(event) => {
|
|
448
|
+
event.stopPropagation();
|
|
449
|
+
void handleManage('remove', installed);
|
|
450
|
+
}}
|
|
451
|
+
>
|
|
452
|
+
{t('marketplaceMcpRemove')}
|
|
453
|
+
</button>
|
|
454
|
+
</>
|
|
455
|
+
)}
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
</article>
|
|
459
|
+
);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<PageLayout className="flex h-full min-h-0 flex-col pb-0">
|
|
464
|
+
<PageHeader title={t('marketplaceMcpPageTitle')} description={t('marketplaceMcpPageDescription')} />
|
|
465
|
+
|
|
466
|
+
<Tabs
|
|
467
|
+
tabs={[
|
|
468
|
+
{ id: 'catalog', label: t('marketplaceMcpTabCatalog') },
|
|
469
|
+
{ id: 'installed', label: t('marketplaceMcpTabInstalled'), count: installedQuery.data?.total ?? 0 }
|
|
470
|
+
]}
|
|
471
|
+
activeTab={scope}
|
|
472
|
+
onChange={(value) => setScope(value as ScopeType)}
|
|
473
|
+
className="mb-4"
|
|
474
|
+
/>
|
|
475
|
+
|
|
476
|
+
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
477
|
+
<Input
|
|
478
|
+
value={searchText}
|
|
479
|
+
onChange={(event) => setSearchText(event.target.value)}
|
|
480
|
+
placeholder={t('marketplaceMcpSearchPlaceholder')}
|
|
481
|
+
className="md:max-w-sm"
|
|
482
|
+
/>
|
|
483
|
+
|
|
484
|
+
<Select value={sort} onValueChange={(value) => setSort(value as MarketplaceSort)}>
|
|
485
|
+
<SelectTrigger className="h-9 w-[180px] rounded-lg">
|
|
486
|
+
<SelectValue />
|
|
487
|
+
</SelectTrigger>
|
|
488
|
+
<SelectContent>
|
|
489
|
+
<SelectItem value="relevance">{t('marketplaceSortRelevance')}</SelectItem>
|
|
490
|
+
<SelectItem value="updated">{t('marketplaceSortUpdated')}</SelectItem>
|
|
491
|
+
</SelectContent>
|
|
492
|
+
</Select>
|
|
493
|
+
</div>
|
|
494
|
+
|
|
495
|
+
<section className="flex min-h-0 flex-1 flex-col">
|
|
496
|
+
<div className="mb-3 flex items-center justify-between">
|
|
497
|
+
<h3 className="text-sm font-semibold text-gray-900">
|
|
498
|
+
{scope === 'catalog' ? t('marketplaceMcpSectionCatalog') : t('marketplaceMcpSectionInstalled')}
|
|
499
|
+
</h3>
|
|
500
|
+
<span className="text-xs text-gray-500">
|
|
501
|
+
{scope === 'catalog' ? (itemsQuery.data?.total ?? 0) : (installedQuery.data?.total ?? 0)}
|
|
502
|
+
</span>
|
|
503
|
+
</div>
|
|
504
|
+
|
|
505
|
+
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
|
506
|
+
<div className={cn('grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3')}>
|
|
507
|
+
{scope === 'catalog' && itemsQuery.isLoading && Array.from({ length: 6 }, (_, index) => (
|
|
508
|
+
<div key={index} className="rounded-2xl border border-gray-200/70 bg-white p-4 shadow-sm">
|
|
509
|
+
<Skeleton className="h-4 w-32" />
|
|
510
|
+
<Skeleton className="mt-2 h-3 w-20" />
|
|
511
|
+
<Skeleton className="mt-3 h-3 w-full" />
|
|
512
|
+
<Skeleton className="mt-2 h-3 w-2/3" />
|
|
513
|
+
</div>
|
|
514
|
+
))}
|
|
515
|
+
|
|
516
|
+
{scope === 'catalog' && !itemsQuery.isLoading && (itemsQuery.data?.items ?? []).map((item) => renderCard(item))}
|
|
517
|
+
{scope === 'installed' && installedRecords.map((record) => renderCard(undefined, record))}
|
|
518
|
+
</div>
|
|
519
|
+
|
|
520
|
+
{scope === 'catalog' && itemsQuery.isError && (
|
|
521
|
+
<div className="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
|
|
522
|
+
{itemsQuery.error.message}
|
|
523
|
+
</div>
|
|
524
|
+
)}
|
|
525
|
+
{scope === 'installed' && installedQuery.isError && (
|
|
526
|
+
<div className="rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
|
|
527
|
+
{installedQuery.error.message}
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
{scope === 'catalog' && !itemsQuery.isLoading && (itemsQuery.data?.items?.length ?? 0) === 0 && (
|
|
531
|
+
<div className="py-8 text-center text-sm text-gray-500">{t('marketplaceNoMcp')}</div>
|
|
532
|
+
)}
|
|
533
|
+
{scope === 'installed' && installedRecords.length === 0 && (
|
|
534
|
+
<div className="py-8 text-center text-sm text-gray-500">{t('marketplaceNoInstalledMcp')}</div>
|
|
535
|
+
)}
|
|
536
|
+
</div>
|
|
537
|
+
</section>
|
|
538
|
+
|
|
539
|
+
{scope === 'catalog' && (
|
|
540
|
+
<div className="mt-4 flex items-center justify-end gap-2">
|
|
541
|
+
<button
|
|
542
|
+
className="h-8 rounded-xl border border-gray-200/80 px-3 text-sm text-gray-600 disabled:opacity-40"
|
|
543
|
+
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
544
|
+
disabled={page <= 1 || itemsQuery.isFetching}
|
|
545
|
+
>
|
|
546
|
+
{t('prev')}
|
|
547
|
+
</button>
|
|
548
|
+
<div className="min-w-20 text-center text-sm text-gray-600">
|
|
549
|
+
{itemsQuery.data?.totalPages ? `${page} / ${itemsQuery.data.totalPages}` : '0 / 0'}
|
|
550
|
+
</div>
|
|
551
|
+
<button
|
|
552
|
+
className="h-8 rounded-xl border border-gray-200/80 px-3 text-sm text-gray-600 disabled:opacity-40"
|
|
553
|
+
onClick={() => setPage((current) => current + 1)}
|
|
554
|
+
disabled={!itemsQuery.data?.totalPages || page >= itemsQuery.data.totalPages || itemsQuery.isFetching}
|
|
555
|
+
>
|
|
556
|
+
{t('next')}
|
|
557
|
+
</button>
|
|
558
|
+
</div>
|
|
559
|
+
)}
|
|
560
|
+
|
|
561
|
+
<InstallDialog
|
|
562
|
+
item={installingItem}
|
|
563
|
+
open={Boolean(installingItem)}
|
|
564
|
+
pending={installMutation.isPending}
|
|
565
|
+
onOpenChange={(open) => !open && setInstallingItem(null)}
|
|
566
|
+
onSubmit={handleInstall}
|
|
567
|
+
/>
|
|
568
|
+
<DoctorDialog
|
|
569
|
+
open={Boolean(doctorTarget)}
|
|
570
|
+
targetName={doctorTarget}
|
|
571
|
+
result={doctorResult}
|
|
572
|
+
pending={doctorMutation.isPending}
|
|
573
|
+
onOpenChange={(open) => !open && setDoctorTarget(null)}
|
|
574
|
+
/>
|
|
575
|
+
<ConfirmDialog />
|
|
576
|
+
</PageLayout>
|
|
577
|
+
);
|
|
578
|
+
}
|