♻️ refactor: change discover page from RSC to SPA to improve performance (#9828)

* feat: change discord page to spa

* fix: change locals

* feat: update router change

* fix: revert some files

* feat: add model provider detail page use link

* fix: add trpc back

* feat: update e2e timeout time

* feat: change discord page to spa

* fix: change locals

* feat: update router change

* fix: revert some files

* feat: add model provider detail page use link

* fix: add trpc back

* feat: update e2e timeout time

* fix: use reactrouter-dom link replace next link
This commit is contained in:
Shinji-Li
2025-10-22 23:35:49 +08:00
committed by GitHub
parent d481315a66
commit b59ee0aabe
68 changed files with 1085 additions and 923 deletions

View File

@@ -1,7 +1,11 @@
import { InterceptRouteParams } from '@lobechat/electron-client-ipc';
import { InterceptRouteParams, OpenSettingsWindowOptions } from '@lobechat/electron-client-ipc';
import { extractSubPath, findMatchingRoute } from '~common/routes';
import { AppBrowsersIdentifiers, BrowsersIdentifiers, WindowTemplateIdentifiers } from '@/appBrowsers';
import {
AppBrowsersIdentifiers,
BrowsersIdentifiers,
WindowTemplateIdentifiers,
} from '@/appBrowsers';
import { IpcClientEventSender } from '@/types/ipcClientEvent';
import { ControllerModule, ipcClientEvent, shortcut } from './index';
@@ -14,11 +18,16 @@ export default class BrowserWindowsCtr extends ControllerModule {
}
@ipcClientEvent('openSettingsWindow')
async openSettingsWindow(tab?: string) {
console.log('[BrowserWindowsCtr] Received request to open settings window', tab);
async openSettingsWindow(options?: string | OpenSettingsWindowOptions) {
const normalizedOptions: OpenSettingsWindowOptions =
typeof options === 'string' || options === undefined
? { tab: typeof options === 'string' ? options : undefined }
: options;
console.log('[BrowserWindowsCtr] Received request to open settings window', normalizedOptions);
try {
await this.app.browserManager.showSettingsWindowWithTab(tab);
await this.app.browserManager.showSettingsWindowWithTab(normalizedOptions);
return { success: true };
} catch (error) {
@@ -68,15 +77,37 @@ export default class BrowserWindowsCtr extends ControllerModule {
try {
if (matchedRoute.targetWindow === BrowsersIdentifiers.settings) {
const subPath = extractSubPath(path, matchedRoute.pathPrefix);
const extractedSubPath = extractSubPath(path, matchedRoute.pathPrefix);
const sanitizedSubPath =
extractedSubPath && !extractedSubPath.startsWith('?') ? extractedSubPath : undefined;
let searchParams: Record<string, string> | undefined;
try {
const url = new URL(params.url);
const entries = Array.from(url.searchParams.entries());
if (entries.length > 0) {
searchParams = entries.reduce<Record<string, string>>((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {});
}
} catch (error) {
console.warn(
'[BrowserWindowsCtr] Failed to parse URL for settings route interception:',
params.url,
error,
);
}
await this.app.browserManager.showSettingsWindowWithTab(subPath);
await this.app.browserManager.showSettingsWindowWithTab({
searchParams,
tab: sanitizedSubPath,
});
return {
intercepted: true,
path,
source,
subPath,
subPath: sanitizedSubPath,
targetWindow: matchedRoute.targetWindow,
};
} else {
@@ -105,8 +136,8 @@ export default class BrowserWindowsCtr extends ControllerModule {
*/
@ipcClientEvent('createMultiInstanceWindow')
async createMultiInstanceWindow(params: {
templateId: WindowTemplateIdentifiers;
path: string;
templateId: WindowTemplateIdentifiers;
uniqueId?: string;
}) {
try {

View File

@@ -64,7 +64,7 @@ describe('BrowserWindowsCtr', () => {
it('should show the settings window with the specified tab', async () => {
const tab = 'appearance';
const result = await browserWindowsCtr.openSettingsWindow(tab);
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith(tab);
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({ tab });
expect(result).toEqual({ success: true });
});
@@ -120,11 +120,11 @@ describe('BrowserWindowsCtr', () => {
it('should show settings window if matched route target is settings', async () => {
const params: InterceptRouteParams = {
...baseParams,
path: '/settings?active=common',
url: 'app://host/settings?active=common',
path: '/settings/provider',
url: 'app://host/settings/provider?active=provider&provider=ollama',
};
const matchedRoute = { targetWindow: BrowsersIdentifiers.settings, pathPrefix: '/settings' };
const subPath = 'common';
const subPath = 'provider';
(findMatchingRoute as Mock).mockReturnValue(matchedRoute);
(extractSubPath as Mock).mockReturnValue(subPath);
@@ -132,7 +132,10 @@ describe('BrowserWindowsCtr', () => {
expect(findMatchingRoute).toHaveBeenCalledWith(params.path);
expect(extractSubPath).toHaveBeenCalledWith(params.path, matchedRoute.pathPrefix);
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith(subPath);
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({
searchParams: { active: 'provider', provider: 'ollama' },
tab: subPath,
});
expect(result).toEqual({
intercepted: true,
path: params.path,
@@ -170,11 +173,11 @@ describe('BrowserWindowsCtr', () => {
it('should return error if processing route interception fails for settings', async () => {
const params: InterceptRouteParams = {
...baseParams,
path: '/settings?active=general',
path: '/settings',
url: 'app://host/settings?active=general',
};
const matchedRoute = { targetWindow: BrowsersIdentifiers.settings, pathPrefix: '/settings' };
const subPath = 'general';
const subPath = undefined;
const errorMessage = 'Processing error for settings';
(findMatchingRoute as Mock).mockReturnValue(matchedRoute);
(extractSubPath as Mock).mockReturnValue(subPath);
@@ -182,6 +185,10 @@ describe('BrowserWindowsCtr', () => {
const result = await browserWindowsCtr.interceptRoute(params);
expect(mockShowSettingsWindowWithTab).toHaveBeenCalledWith({
searchParams: { active: 'general' },
tab: subPath,
});
expect(result).toEqual({
error: errorMessage,
intercepted: false,

View File

@@ -1,9 +1,18 @@
import { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
import {
MainBroadcastEventKey,
MainBroadcastParams,
OpenSettingsWindowOptions,
} from '@lobechat/electron-client-ipc';
import { WebContents } from 'electron';
import { createLogger } from '@/utils/logger';
import { AppBrowsersIdentifiers, appBrowsers, WindowTemplate, WindowTemplateIdentifiers, windowTemplates } from '../../appBrowsers';
import {
AppBrowsersIdentifiers,
WindowTemplateIdentifiers,
appBrowsers,
windowTemplates,
} from '../../appBrowsers';
import type { App } from '../App';
import type { BrowserWindowOpts } from './Browser';
import Browser from './Browser';
@@ -63,14 +72,35 @@ export class BrowserManager {
* Display the settings window and navigate to a specific tab
* @param tab Settings window sub-path tab
*/
async showSettingsWindowWithTab(tab?: string) {
logger.debug(`Showing settings window with tab: ${tab || 'default'}`);
// common is the main path for settings route
if (tab && tab !== 'common') {
const browser = await this.redirectToPage('settings', tab);
async showSettingsWindowWithTab(options?: OpenSettingsWindowOptions) {
const tab = options?.tab;
const searchParams = options?.searchParams;
const query = new URLSearchParams();
if (searchParams) {
Object.entries(searchParams).forEach(([key, value]) => {
if (value !== undefined) query.set(key, value);
});
}
if (tab && tab !== 'common' && !query.has('active')) {
query.set('active', tab);
}
const queryString = query.toString();
const activeTab = query.get('active') ?? tab;
logger.debug(
`Showing settings window with navigation: active=${activeTab || 'default'}, query=${
queryString || 'none'
}`,
);
if (queryString) {
const browser = await this.redirectToPage('settings', undefined, queryString);
// make provider page more large
if (tab.startsWith('provider/')) {
if (activeTab?.startsWith('provider')) {
logger.debug('Resizing window for provider settings');
browser.setWindowSize({ height: 1000, width: 1400 });
browser.moveToCenter();
@@ -87,7 +117,7 @@ export class BrowserManager {
* @param identifier Window identifier
* @param subPath Sub-path, such as 'agent', 'about', etc.
*/
async redirectToPage(identifier: string, subPath?: string) {
async redirectToPage(identifier: string, subPath?: string, search?: string) {
try {
// Ensure window is retrieved or created
const browser = this.retrieveByIdentifier(identifier);
@@ -105,11 +135,14 @@ export class BrowserManager {
// Build complete URL path
const fullPath = subPath ? `${baseRoute}/${subPath}` : baseRoute;
const normalizedSearch =
search && search.length > 0 ? (search.startsWith('?') ? search : `?${search}`) : '';
const fullUrl = `${fullPath}${normalizedSearch}`;
logger.debug(`Redirecting to: ${fullPath}`);
logger.debug(`Redirecting to: ${fullUrl}`);
// Load URL and show window
await browser.loadUrl(fullPath);
await browser.loadUrl(fullUrl);
browser.show();
return browser;
@@ -143,14 +176,20 @@ export class BrowserManager {
* @param uniqueId Optional unique identifier, will be generated if not provided
* @returns The window identifier and Browser instance
*/
createMultiInstanceWindow(templateId: WindowTemplateIdentifiers, path: string, uniqueId?: string) {
createMultiInstanceWindow(
templateId: WindowTemplateIdentifiers,
path: string,
uniqueId?: string,
) {
const template = windowTemplates[templateId];
if (!template) {
throw new Error(`Window template ${templateId} not found`);
}
// Generate unique identifier
const windowId = uniqueId || `${template.baseIdentifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const windowId =
uniqueId ||
`${template.baseIdentifier}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
// Create browser options from template
const browserOpts: BrowserWindowOpts = {
@@ -164,8 +203,8 @@ export class BrowserManager {
const browser = this.retrieveOrInitialize(browserOpts);
return {
identifier: windowId,
browser: browser,
identifier: windowId,
};
}
@@ -176,7 +215,7 @@ export class BrowserManager {
*/
getWindowsByTemplate(templateId: string): string[] {
const prefix = `${templateId}_`;
return Array.from(this.browsers.keys()).filter(id => id.startsWith(prefix));
return Array.from(this.browsers.keys()).filter((id) => id.startsWith(prefix));
}
/**
@@ -185,7 +224,7 @@ export class BrowserManager {
*/
closeWindowsByTemplate(templateId: string): void {
const windowIds = this.getWindowsByTemplate(templateId);
windowIds.forEach(id => {
windowIds.forEach((id) => {
const browser = this.browsers.get(id);
if (browser) {
browser.close();
@@ -235,8 +274,7 @@ export class BrowserManager {
});
browser.browserWindow.on('show', () => {
if (browser.webContents)
this.webContentsMap.set(browser.webContents, browser.identifier);
if (browser.webContents) this.webContentsMap.set(browser.webContents, browser.identifier);
});
return browser;

View File

@@ -266,7 +266,9 @@
"react-layout-kit": "^2.0.0",
"react-lazy-load": "^4.0.1",
"react-pdf": "^9.2.1",
"react-responsive": "^10.0.1",
"react-rnd": "^10.5.2",
"react-router-dom": "^7.9.4",
"react-scan": "^0.4.3",
"react-virtuoso": "^4.14.1",
"react-wrap-balancer": "^1.1.1",

View File

@@ -50,3 +50,5 @@ export type MainBroadcastEventKey = keyof MainBroadcastEvents;
export type MainBroadcastParams<T extends MainBroadcastEventKey> = Parameters<
MainBroadcastEvents[T]
>[0];
export type { OpenSettingsWindowOptions } from './windows';

View File

@@ -1,24 +1,58 @@
import { InterceptRouteParams, InterceptRouteResponse } from '../types/route';
export interface OpenSettingsWindowOptions {
/**
* Query parameters that should be appended to the settings URL.
*/
searchParams?: Record<string, string | undefined>;
/**
* Settings page tab path or identifier.
*/
tab?: string;
}
export interface CreateMultiInstanceWindowParams {
templateId: string;
path: string;
templateId: string;
uniqueId?: string;
}
export interface CreateMultiInstanceWindowResponse {
error?: string;
success: boolean;
windowId?: string;
error?: string;
}
export interface GetWindowsByTemplateResponse {
error?: string;
success: boolean;
windowIds?: string[];
error?: string;
}
export interface WindowsDispatchEvents {
/**
* Close all windows by template
* @param templateId Template identifier
* @returns Operation result
*/
closeWindowsByTemplate: (templateId: string) => { error?: string, success: boolean; };
/**
* Create a new multi-instance window
* @param params Window creation parameters
* @returns Creation result
*/
createMultiInstanceWindow: (
params: CreateMultiInstanceWindowParams,
) => CreateMultiInstanceWindowResponse;
/**
* Get all windows by template
* @param templateId Template identifier
* @returns List of window identifiers
*/
getWindowsByTemplate: (templateId: string) => GetWindowsByTemplateResponse;
/**
* 拦截客户端路由导航请求
* @param params 包含路径和来源信息的参数对象
@@ -31,26 +65,5 @@ export interface WindowsDispatchEvents {
*/
openDevtools: () => void;
openSettingsWindow: (tab?: string) => void;
/**
* Create a new multi-instance window
* @param params Window creation parameters
* @returns Creation result
*/
createMultiInstanceWindow: (params: CreateMultiInstanceWindowParams) => CreateMultiInstanceWindowResponse;
/**
* Get all windows by template
* @param templateId Template identifier
* @returns List of window identifiers
*/
getWindowsByTemplate: (templateId: string) => GetWindowsByTemplateResponse;
/**
* Close all windows by template
* @param templateId Template identifier
* @returns Operation result
*/
closeWindowsByTemplate: (templateId: string) => { success: boolean; error?: string };
openSettingsWindow: (options?: OpenSettingsWindowOptions | string) => void;
}

View File

@@ -4,7 +4,7 @@ import { UAParser } from 'ua-parser-js';
/**
* check mobile device in server
*/
const isMobileDevice = async () => {
export const isMobileDevice = async () => {
if (typeof process === 'undefined') {
throw new Error('[Server method] you are importing a server-only module outside of server');
}

View File

@@ -14,7 +14,7 @@ export default defineConfig({
reporter: 'list',
retries: 0,
testDir: './e2e',
timeout: 60_000,
timeout: 1_200_000,
use: {
baseURL: `http://localhost:${PORT}`,
trace: 'on-first-retry',

View File

@@ -20,6 +20,11 @@ const handler = (req: NextRequest) =>
},
req,
responseMeta({ ctx }) {
const headers = ctx?.resHeaders;
return { headers };
},
router: desktopRouter,
});

View File

@@ -0,0 +1,22 @@
'use client';
import { PropsWithChildren, memo } from 'react';
import Desktop from './Desktop';
import Mobile from './Mobile';
interface DetailLayoutProps extends PropsWithChildren {
mobile?: boolean;
}
const DetailLayout = memo<DetailLayoutProps>(({ children, mobile }) => {
if (mobile) {
return <Mobile>{children}</Mobile>;
}
return <Desktop>{children}</Desktop>;
});
DetailLayout.displayName = 'DetailLayout';
export default DetailLayout;

View File

@@ -1,22 +1,21 @@
'use client';
import { ChatHeader } from '@lobehub/ui/mobile';
import { usePathname } from 'next/navigation';
import { useRouter } from 'nextjs-toploader/app';
import { memo } from 'react';
import urlJoin from 'url-join';
import { useLocation, useNavigate } from 'react-router-dom';
import { mobileHeaderSticky } from '@/styles/mobileHeader';
const Header = memo(() => {
const pathname = usePathname();
const router = useRouter();
const location = useLocation();
const navigate = useNavigate();
const path = pathname.split('/').filter(Boolean)[1];
// Extract the path segment (assistant, model, provider, mcp)
const path = location.pathname.split('/').find(Boolean);
return (
<ChatHeader
onBackClick={() => router.push(urlJoin('/discover', path))}
onBackClick={() => navigate(`/${path}`)}
showBackButton
style={mobileHeaderSticky}
/>

View File

@@ -0,0 +1,47 @@
'use client';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { Flexbox } from 'react-layout-kit';
import { withSuspense } from '@/components/withSuspense';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab } from '@/types/discover';
import Breadcrumb from '../features/Breadcrumb';
import { TocProvider } from '../features/Toc/useToc';
import NotFound from '../components/NotFound';
import { DetailProvider } from './[...slugs]/features/DetailProvider';
import Details from './[...slugs]/features/Details';
import Header from './[...slugs]/features/Header';
import Loading from './[...slugs]/loading';
interface AssistantDetailPageProps {
mobile?: boolean;
}
const AssistantDetailPage = memo<AssistantDetailPageProps>(({ mobile }) => {
const params = useParams();
const slugs = params['*']?.split('/') || [];
const identifier = decodeURIComponent(slugs.join('/'));
const useAssistantDetail = useDiscoverStore((s) => s.useAssistantDetail);
const { data, isLoading } = useAssistantDetail({ identifier });
if (isLoading) return <Loading />;
if (!data) return <NotFound />;
return (
<TocProvider>
<DetailProvider config={data}>
{!mobile && <Breadcrumb identifier={identifier} tab={DiscoverTab.Assistants} />}
<Flexbox gap={16}>
<Header mobile={mobile} />
<Details mobile={mobile} />
</Flexbox>
</DetailProvider>
</TocProvider>
);
});
export default withSuspense(AssistantDetailPage);

View File

@@ -4,11 +4,12 @@ import { Github, MCP } from '@lobehub/icons';
import { ActionIcon, Avatar, Button, Icon, Text, Tooltip } from '@lobehub/ui';
import { createStyles, useResponsive } from 'antd-style';
import { BookTextIcon, CoinsIcon, DotIcon } from 'lucide-react';
import Link from 'next/link';
import NextLink from 'next/link';
import qs from 'query-string';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Link as RouterLink } from 'react-router-dom';
import urlJoin from 'url-join';
import { formatIntergerNumber } from '@/utils/format';
@@ -52,16 +53,16 @@ const Header = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
const cate = categories.find((c) => c.key === category);
const cateButton = (
<Link
href={qs.stringifyUrl({
<RouterLink
to={qs.stringifyUrl({
query: { category: cate?.key },
url: '/discover/assistant',
url: '/assistant',
})}
>
<Button icon={cate?.icon} size={'middle'} variant={'outlined'}>
{cate?.label}
</Button>
</Link>
</RouterLink>
);
return (
@@ -105,7 +106,7 @@ const Header = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
</Text>
</Flexbox>
<Flexbox align={'center'} gap={6} horizontal>
<Link
<NextLink
href={urlJoin(
'https://github.com/lobehub/lobe-chat-agents/tree/main/locales',
identifier as string,
@@ -114,14 +115,14 @@ const Header = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
target={'_blank'}
>
<ActionIcon fill={theme.colorTextDescription} icon={Github} />
</Link>
</NextLink>
</Flexbox>
</Flexbox>
<Flexbox align={'center'} gap={4} horizontal>
{author && (
<Link href={urlJoin('https://github.com', author)} target={'_blank'}>
<NextLink href={urlJoin('https://github.com', author)} target={'_blank'}>
{author}
</Link>
</NextLink>
)}
<Icon icon={DotIcon} />
<PublishedTime

View File

@@ -1,8 +1,8 @@
import Link from 'next/link';
import qs from 'query-string';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Link } from 'react-router-dom';
import urlJoin from 'url-join';
import Title from '../../../../../../features/Title';
@@ -28,9 +28,9 @@ const Related = memo(() => {
</Title>
<Flexbox gap={8}>
{related?.map((item, index) => {
const link = urlJoin('/discover/assistant', item.identifier);
const link = urlJoin('/assistant', item.identifier);
return (
<Link href={link} key={index} style={{ color: 'inherit', overflow: 'hidden' }}>
<Link key={index} style={{ color: 'inherit', overflow: 'hidden' }} to={link}>
<Item {...item} />
</Link>
);

View File

@@ -1,110 +0,0 @@
import { notFound } from 'next/navigation';
import urlJoin from 'url-join';
import StructuredData from '@/components/StructuredData';
import { Locales } from '@/locales/resources';
import { ldModule } from '@/server/ld';
import { metadataModule } from '@/server/metadata';
import { DiscoverService } from '@/server/services/discover';
import { translation } from '@/server/translation';
import { DiscoverTab } from '@/types/discover';
import { PageProps } from '@/types/next';
import { RouteVariants } from '@/utils/server/routeVariants';
import Breadcrumb from '../../features/Breadcrumb';
import Client from './Client';
type DiscoverPageProps = PageProps<
{ slugs: string[]; variants: string },
{ hl?: Locales; version?: string }
>;
const getSharedProps = async (props: DiscoverPageProps) => {
const params = await props.params;
const { slugs } = params;
const identifier = decodeURIComponent(slugs.join('/'));
const { isMobile, locale: hl } = await RouteVariants.getVariantsFromProps(props);
const discoverService = new DiscoverService();
const [{ t, locale }, data] = await Promise.all([
translation('metadata', hl),
discoverService.getAssistantDetail({ identifier, locale: hl }),
]);
return {
data,
identifier,
isMobile,
locale,
t,
};
};
export const generateMetadata = async (props: DiscoverPageProps) => {
const { data, t, locale, identifier } = await getSharedProps(props);
if (!data) return;
const { tags, createdAt, homepage, author, description, title } = data;
return {
authors: [
{ name: author, url: homepage },
{ name: 'LobeHub', url: 'https://github.com/lobehub' },
{ name: 'LobeHub Cloud', url: 'https://lobehub.com' },
],
keywords: tags,
...metadataModule.generate({
alternate: true,
canonical: urlJoin('https://lobehub.com/agent', identifier),
description: description,
locale,
tags: tags,
title: [title, t('discover.assistants.title')].join(' · '),
url: urlJoin('/discover/assistant', identifier),
}),
other: {
'article:author': author,
'article:published_time': createdAt
? new Date(createdAt).toISOString()
: new Date().toISOString(),
'robots': 'index,follow,max-image-preview:large',
},
};
};
const Page = async (props: DiscoverPageProps) => {
const { data, t, locale, identifier, isMobile } = await getSharedProps(props);
if (!data) return notFound();
const { tags, title, description, createdAt, author } = data;
const ld = ldModule.generate({
article: {
author: [author],
enable: true,
identifier,
tags: tags,
},
date: createdAt ? new Date(createdAt).toISOString() : new Date().toISOString(),
description: description || t('discover.assistants.description'),
locale,
title: [title, t('discover.assistants.title')].join(' · '),
url: urlJoin('/discover/assistant', identifier),
webpage: {
enable: true,
search: '/discover/assistant',
},
});
return (
<>
<StructuredData ld={ld} />
{!isMobile && <Breadcrumb identifier={identifier} tab={DiscoverTab.Assistants} />}
<Client identifier={identifier} mobile={isMobile} />
</>
);
};
export const generateStaticParams = async () => [];
Page.DisplayName = 'DiscoverAssistantsDetail';
export default Page;

View File

@@ -0,0 +1,23 @@
'use client';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
const NotFound = memo(() => {
const { t } = useTranslation('error', { keyPrefix: 'notFound' });
return (
<Flexbox
align="center"
height="100%"
justify="center"
style={{ minHeight: 400 }}
width="100%"
>
<h2>{t('title')}</h2>
</Flexbox>
);
});
export default NotFound;

View File

@@ -3,10 +3,10 @@
import { Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { CSSProperties, memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Link } from 'react-router-dom';
const useStyles = createStyles(({ css, token }) => {
return {
@@ -25,7 +25,7 @@ const Back = memo<{ href: string; style?: CSSProperties }>(({ href, style }) =>
const { styles } = useStyles();
return (
<Link className={styles.back} href={href} style={{ marginBottom: 8, ...style }}>
<Link className={styles.back} style={{ marginBottom: 8, ...style }} to={href}>
<Flexbox align={'center'} gap={8} horizontal>
<Icon icon={ArrowLeft} />
{t(`back`)}

View File

@@ -3,11 +3,10 @@
import { CopyButton } from '@lobehub/ui';
import { Breadcrumb as AntdBreadcrumb } from 'antd';
import { useTheme } from 'antd-style';
import Link from 'next/link';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import urlJoin from 'url-join';
import { Link } from 'react-router-dom';
import { DiscoverTab } from '@/types/discover';
@@ -18,11 +17,11 @@ const Breadcrumb = memo<{ identifier: string; tab: DiscoverTab }>(({ tab, identi
<AntdBreadcrumb
items={[
{
title: <Link href={'/discover'}>Discover</Link>,
title: <Link to={'/'}>Discover</Link>,
},
{
title: (
<Link href={urlJoin('/discover', tab)}>
<Link to={`/${tab}`}>
{tab === DiscoverTab.Mcp ? 'MCP Servers' : t(`tab.${tab}` as any)}
</Link>
),

View File

@@ -1,12 +0,0 @@
import { PropsWithChildren } from 'react';
import ServerLayout from '@/components/server/ServerLayout';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
const MainLayout = ServerLayout<PropsWithChildren>({ Desktop, Mobile });
MainLayout.displayName = 'DiscoverAssistantsDetailLayout';
export default MainLayout;

View File

@@ -0,0 +1,51 @@
'use client';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { Flexbox } from 'react-layout-kit';
import { withSuspense } from '@/components/withSuspense';
import { DetailProvider } from '@/features/MCPPluginDetail/DetailProvider';
import Header from '@/features/MCPPluginDetail/Header';
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
import { useQuery } from '@/hooks/useQuery';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab } from '@/types/discover';
import Breadcrumb from '../features/Breadcrumb';
import { TocProvider } from '../features/Toc/useToc';
import NotFound from '../components/NotFound';
import Details from './[slug]/features/Details';
import Loading from './[slug]/loading';
interface McpDetailPageProps {
mobile?: boolean;
}
const McpDetailPage = memo<McpDetailPageProps>(({ mobile }) => {
const params = useParams();
const identifier = params['*'] || params.slug || '';
const { version } = useQuery() as { version?: string };
const useMcpDetail = useDiscoverStore((s) => s.useFetchMcpDetail);
const { data, isLoading } = useMcpDetail({ identifier, version });
useFetchInstalledPlugins();
if (isLoading) return <Loading />;
if (!data) return <NotFound />;
return (
<TocProvider>
<DetailProvider config={data}>
{!mobile && <Breadcrumb identifier={identifier} tab={DiscoverTab.Mcp} />}
<Flexbox gap={16}>
<Header mobile={mobile} />
<Details mobile={mobile} />
</Flexbox>
</DetailProvider>
</TocProvider>
);
});
export default withSuspense(McpDetailPage);

View File

@@ -1,8 +1,8 @@
import Link from 'next/link';
import qs from 'query-string';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Link } from 'react-router-dom';
import urlJoin from 'url-join';
import { useDetailContext } from '@/features/MCPPluginDetail/DetailProvider';
@@ -29,9 +29,9 @@ const Related = memo(() => {
</Title>
<Flexbox gap={8}>
{related?.map((item, index) => {
const link = urlJoin('/discover/mcp', item.identifier);
const link = urlJoin('/mcp', item.identifier);
return (
<Link href={link} key={index} style={{ color: 'inherit', overflow: 'hidden' }}>
<Link key={index} style={{ color: 'inherit', overflow: 'hidden' }} to={link}>
<Item {...item} />
</Link>
);

View File

@@ -1,103 +0,0 @@
import { notFound } from 'next/navigation';
import urlJoin from 'url-join';
import StructuredData from '@/components/StructuredData';
import { isDesktop } from '@/const/version';
import { ldModule } from '@/server/ld';
import { metadataModule } from '@/server/metadata';
import { DiscoverService } from '@/server/services/discover';
import { translation } from '@/server/translation';
import { PageProps } from '@/types/next';
import { RouteVariants } from '@/utils/server/routeVariants';
import Client from './Client';
type DiscoverPageProps = PageProps<{ slug: string; variants: string }>;
const getSharedProps = async (props: DiscoverPageProps) => {
const params = await props.params;
const { slug: identifier } = params;
const { isMobile, locale: hl } = await RouteVariants.getVariantsFromProps(props);
const discoverService = new DiscoverService();
const [{ t, locale }, data] = await Promise.all([
translation('metadata', hl),
discoverService.getMcpDetail({ identifier, locale: hl }),
]);
return {
data,
identifier,
isMobile,
locale,
t,
};
};
export const generateMetadata = async (props: DiscoverPageProps) => {
const { data, t, locale, identifier } = await getSharedProps(props);
if (!data) return notFound();
const { tags, createdAt, homepage, author, description, name } = data;
return {
authors: [
{ name: author, url: homepage },
{ name: 'LobeHub', url: 'https://github.com/lobehub' },
{ name: 'LobeHub Cloud', url: 'https://lobehub,com' },
],
keywords: tags,
...metadataModule.generate({
alternate: true,
canonical: urlJoin('https://lobehub.com/mcp', identifier),
description: description,
locale,
tags: tags,
title: [name, t('discover.mcp.title')].join(' · '),
url: urlJoin('/discover/mcp', identifier),
}),
other: {
'article:author': author,
'article:published_time': createdAt
? new Date(createdAt).toISOString()
: new Date().toISOString(),
'robots': 'index,follow,max-image-preview:large',
},
};
};
export const generateStaticParams = async () => [];
const Page = async (props: DiscoverPageProps) => {
const { data, identifier, isMobile, locale, t } = await getSharedProps(props);
if (!data) return notFound();
const { tags, name, description, createdAt, author } = data;
const ld = ldModule.generate({
article: {
author: [author?.name || 'LobeHub'],
enable: true,
identifier,
tags: tags,
},
date: createdAt ? new Date(createdAt).toISOString() : new Date().toISOString(),
description: description || t('discover.mcp.description'),
locale,
title: [name, t('discover.mcp.title')].join(' · '),
url: urlJoin('/discover/mcp', identifier),
webpage: {
enable: true,
search: '/discover/mcp',
},
});
return (
<>
{!isDesktop && <StructuredData ld={ld} />}
<Client identifier={identifier} mobile={isMobile} />
</>
);
};
Page.displayName = 'DiscoverMCPDetail';
export default Page;

View File

@@ -0,0 +1,44 @@
'use client';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { Flexbox } from 'react-layout-kit';
import { withSuspense } from '@/components/withSuspense';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab } from '@/types/discover';
import Breadcrumb from '../features/Breadcrumb';
import NotFound from '../components/NotFound';
import { DetailProvider } from './[...slugs]/features/DetailProvider';
import Details from './[...slugs]/features/Details';
import Header from './[...slugs]/features/Header';
import Loading from './[...slugs]/loading';
interface ModelDetailPageProps {
mobile?: boolean;
}
const ModelDetailPage = memo<ModelDetailPageProps>(({ mobile }) => {
const params = useParams();
const slugs = params['*']?.split('/') || [];
const identifier = decodeURIComponent(slugs.join('/'));
const useModelDetail = useDiscoverStore((s) => s.useModelDetail);
const { data, isLoading } = useModelDetail({ identifier });
if (isLoading) return <Loading />;
if (!data) return <NotFound />;
return (
<DetailProvider config={data}>
{!mobile && <Breadcrumb identifier={identifier} tab={DiscoverTab.Models} />}
<Flexbox gap={16}>
<Header mobile={mobile} />
<Details mobile={mobile} />
</Flexbox>
</DetailProvider>
);
});
export default withSuspense(ModelDetailPage);

View File

@@ -1,8 +1,8 @@
import Link from 'next/link';
import qs from 'query-string';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Link } from 'react-router-dom';
import urlJoin from 'url-join';
import Title from '../../../../../../features/Title';
@@ -28,9 +28,9 @@ const Related = memo(() => {
</Title>
<Flexbox gap={8}>
{related?.map((item, index) => {
const link = urlJoin('/discover/model', item.identifier);
const link = urlJoin('/model', item.identifier);
return (
<Link href={link} key={index} style={{ color: 'inherit', overflow: 'hidden' }}>
<Link key={index} style={{ color: 'inherit', overflow: 'hidden' }} to={link}>
<Item {...item} />
</Link>
);

View File

@@ -1,104 +0,0 @@
import { notFound } from 'next/navigation';
import urlJoin from 'url-join';
import StructuredData from '@/components/StructuredData';
import { ldModule } from '@/server/ld';
import { metadataModule } from '@/server/metadata';
import { DiscoverService } from '@/server/services/discover';
import { translation } from '@/server/translation';
import { PageProps } from '@/types/next';
import { RouteVariants } from '@/utils/server/routeVariants';
import Client from './Client';
type DiscoverPageProps = PageProps<{ slugs: string[]; variants: string }>;
const getSharedProps = async (props: DiscoverPageProps) => {
const params = await props.params;
const { isMobile, locale: hl } = await RouteVariants.getVariantsFromProps(props);
const { slugs } = params;
const identifier = decodeURIComponent(slugs.join('/'));
const { t, locale } = await translation('metadata', hl);
const { t: td } = await translation('models', hl);
const discoverService = new DiscoverService();
const data = await discoverService.getModelDetail({ identifier });
return {
data,
discoverService,
identifier,
isMobile,
locale,
t,
td,
};
};
export const generateMetadata = async (props: DiscoverPageProps) => {
const { data, locale, identifier, t, td } = await getSharedProps(props);
if (!data) return;
const { displayName, releasedAt, providers } = data;
return {
authors: [
{ name: displayName || identifier },
{ name: 'LobeHub', url: 'https://github.com/lobehub' },
{ name: 'LobeChat', url: 'https://github.com/lobehub/lobe-chat' },
],
webpage: {
enable: true,
search: true,
},
...metadataModule.generate({
alternate: true,
description: td(`${identifier}.description`) || t('discover.models.description'),
locale,
tags: providers.map((item) => item.name) || [],
title: [displayName || identifier, t('discover.models.title')].join(' · '),
url: urlJoin('/discover/model', identifier),
}),
other: {
'article:author': displayName || identifier,
'article:published_time': releasedAt
? new Date(releasedAt).toISOString()
: new Date().toISOString(),
'robots': 'index,follow,max-image-preview:large',
},
};
};
export const generateStaticParams = async () => [];
const Page = async (props: DiscoverPageProps) => {
const { data, locale, identifier, t, td, isMobile } = await getSharedProps(props);
if (!data) return notFound();
const { displayName, releasedAt, providers } = data;
const ld = ldModule.generate({
article: {
author: [displayName || identifier],
enable: true,
identifier,
tags: providers.map((item) => item.name) || [],
},
date: releasedAt ? new Date(releasedAt).toISOString() : new Date().toISOString(),
description: td(`${identifier}.description`) || t('discover.models.description'),
locale,
title: [displayName || identifier, t('discover.models.title')].join(' · '),
url: urlJoin('/discover/model', identifier),
});
return (
<>
<StructuredData ld={ld} />
<Client identifier={identifier} mobile={isMobile} />
</>
);
};
Page.DisplayName = 'DiscoverModelDetail';
export default Page;

View File

@@ -0,0 +1,44 @@
'use client';
import { memo } from 'react';
import { useParams } from 'react-router-dom';
import { Flexbox } from 'react-layout-kit';
import { withSuspense } from '@/components/withSuspense';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab } from '@/types/discover';
import Breadcrumb from '../features/Breadcrumb';
import NotFound from '../components/NotFound';
import { DetailProvider } from './[...slugs]/features/DetailProvider';
import Details from './[...slugs]/features/Details';
import Header from './[...slugs]/features/Header';
import Loading from './[...slugs]/loading';
interface ProviderDetailPageProps {
mobile?: boolean;
}
const ProviderDetailPage = memo<ProviderDetailPageProps>(({ mobile }) => {
const params = useParams();
const slugs = params['*']?.split('/') || [];
const identifier = decodeURIComponent(slugs.join('/'));
const useProviderDetail = useDiscoverStore((s) => s.useProviderDetail);
const { data, isLoading } = useProviderDetail({ identifier, withReadme: true });
if (isLoading) return <Loading />;
if (!data) return <NotFound />;
return (
<DetailProvider config={data}>
{!mobile && <Breadcrumb identifier={identifier} tab={DiscoverTab.Providers} />}
<Flexbox gap={16}>
<Header mobile={mobile} />
<Details mobile={mobile} />
</Flexbox>
</DetailProvider>
);
});
export default withSuspense(ProviderDetailPage);

View File

@@ -9,7 +9,7 @@ import { useRouter } from 'nextjs-toploader/app';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { isDeprecatedEdition } from '@/const/version';
import { isDeprecatedEdition, isDesktop } from '@/const/version';
import { useDetailContext } from '../../DetailProvider';
@@ -26,7 +26,21 @@ const ProviderConfig = memo(() => {
const { t } = useTranslation('discover');
const { url, modelsUrl, identifier } = useDetailContext();
const router = useRouter();
const openSettings = () => {
const openSettings = async () => {
const searchParams = isDeprecatedEdition
? { active: 'llm' }
: { active: 'provider', provider: identifier };
const tab = isDeprecatedEdition ? 'llm' : 'provider';
if (isDesktop) {
const { dispatch } = await import('@lobechat/electron-client-ipc');
await dispatch('openSettingsWindow', {
searchParams,
tab,
});
return;
}
router.push(
isDeprecatedEdition
? '/settings?active=llm'

View File

@@ -1,7 +1,7 @@
import Link from 'next/link';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Link } from 'react-router-dom';
import urlJoin from 'url-join';
import Title from '../../../../../../features/Title';
@@ -19,9 +19,9 @@ const Related = memo(() => {
</Title>
<Flexbox gap={8}>
{related?.map((item, index) => {
const link = urlJoin('/discover/provider', item.identifier);
const link = urlJoin('/provider', item.identifier);
return (
<Link href={link} key={index} style={{ color: 'inherit', overflow: 'hidden' }}>
<Link key={index} style={{ color: 'inherit', overflow: 'hidden' }} to={link}>
<Item {...item} />
</Link>
);

View File

@@ -1,103 +0,0 @@
import { notFound } from 'next/navigation';
import urlJoin from 'url-join';
import StructuredData from '@/components/StructuredData';
import { ldModule } from '@/server/ld';
import { metadataModule } from '@/server/metadata';
import { DiscoverService } from '@/server/services/discover';
import { translation } from '@/server/translation';
import { PageProps } from '@/types/next';
import { RouteVariants } from '@/utils/server/routeVariants';
import Client from './Client';
type DiscoverPageProps = PageProps<{ slugs: string[]; variants: string }, { version?: string }>;
const getSharedProps = async (props: DiscoverPageProps) => {
const [params, { isMobile, locale: hl }] = await Promise.all([
props.params,
RouteVariants.getVariantsFromProps(props),
]);
const { slugs } = params;
const identifier = decodeURIComponent(slugs.join('/'));
const discoverService = new DiscoverService();
const [{ t, locale }, { t: td }, data] = await Promise.all([
translation('metadata', hl),
translation('providers', hl),
discoverService.getProviderDetail({ identifier }),
]);
return {
data,
identifier,
isMobile,
locale,
t,
td,
};
};
export const generateMetadata = async (props: DiscoverPageProps) => {
const { data, t, td, locale, identifier } = await getSharedProps(props);
if (!data) return;
const { name, models = [] } = data;
return {
authors: [
{ name: name },
{ name: 'LobeHub', url: 'https://github.com/lobehub' },
{ name: 'LobeChat', url: 'https://github.com/lobehub/lobe-chat' },
],
...metadataModule.generate({
alternate: true,
description: td(`${identifier}.description`) || t('discover.providers.description'),
locale,
tags: models.map((item) => item.displayName || item.id) || [],
title: [name, t('discover.providers.title')].join(' · '),
url: urlJoin('/discover/provider', identifier),
}),
other: {
'article:author': name,
'article:published_time': new Date().toISOString(),
'robots': 'index,follow,max-image-preview:large',
},
};
};
export const generateStaticParams = async () => [];
const Page = async (props: DiscoverPageProps) => {
const { data, t, td, locale, identifier, isMobile } = await getSharedProps(props);
if (!data) return notFound();
const { models, name } = data;
const ld = ldModule.generate({
article: {
author: [name],
enable: true,
identifier,
tags: models.map((item) => item.displayName || item.id) || [],
},
date: new Date().toISOString(),
description: td(`${identifier}.description`) || t('discover.providers.description'),
locale,
title: [name, t('discover.providers.title')].join(' · '),
url: urlJoin('/discover/provider', identifier),
webpage: {
enable: true,
search: true,
},
});
return (
<>
<StructuredData ld={ld} />
<Client identifier={identifier} mobile={isMobile} />
</>
);
};
Page.DisplayName = 'DiscoverProviderDetail';
export default Page;

View File

@@ -0,0 +1,45 @@
'use client';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDiscoverStore } from '@/store/discover';
import Title from '../../components/Title';
import AssistantList from '../assistant/features/List';
import McpList from '../mcp/features/List';
import Loading from './loading';
const HomePage = memo<{ mobile?: boolean }>(() => {
const { t } = useTranslation('discover');
const useAssistantList = useDiscoverStore((s) => s.useAssistantList);
const useMcpList = useDiscoverStore((s) => s.useFetchMcpList);
const { data: assistantList, isLoading: assistantLoading } = useAssistantList({
page: 1,
pageSize: 12,
});
const { data: mcpList, isLoading: pluginLoading } = useMcpList({
page: 1,
pageSize: 12,
});
if (assistantLoading || pluginLoading || !assistantList || !mcpList) return <Loading />;
return (
<>
<Title more={t('home.more')} moreLink={'/discover/assistant'}>
{t('home.featuredAssistants')}
</Title>
<AssistantList data={assistantList.items} rows={4} />
<div />
<Title more={t('home.more')} moreLink={'/discover/mcp'}>
{t('home.featuredTools')}
</Title>
<McpList data={mcpList.items} rows={4} />
</>
);
});
export default HomePage;

View File

@@ -1,44 +0,0 @@
import StructuredData from '@/components/StructuredData';
import { ldModule } from '@/server/ld';
import { metadataModule } from '@/server/metadata';
import { DynamicLayoutProps } from '@/types/next';
import { parsePageMetaProps } from '@/utils/server/pageProps';
import Client from './Client';
export const generateMetadata = async (props: DynamicLayoutProps) => {
const { locale, t } = await parsePageMetaProps(props);
return metadataModule.generate({
alternate: true,
description: t('discover.description'),
locale,
title: t('discover.title'),
url: '/discover',
});
};
const Page = async (props: DynamicLayoutProps) => {
const { locale, t, isMobile } = await parsePageMetaProps(props);
const ld = ldModule.generate({
description: t('discover.description'),
locale,
title: t('discover.title'),
url: '/discover',
webpage: {
enable: true,
search: true,
},
});
return (
<>
<StructuredData ld={ld} />
<Client mobile={isMobile} />
</>
);
};
Page.DisplayName = 'DiscoverHome';
export default Page;

View File

@@ -2,15 +2,13 @@
import { Tabs } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { usePathname } from 'next/navigation';
import { rgba } from 'polished';
import { memo, useState } from 'react';
import { Center, Flexbox } from 'react-layout-kit';
import urlJoin from 'url-join';
import { useLocation, useNavigate } from 'react-router-dom';
import { withSuspense } from '@/components/withSuspense';
import { useQuery } from '@/hooks/useQuery';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { DiscoverTab } from '@/types/discover';
import { MAX_WIDTH, SCROLL_PARENT_ID } from '../../../features/const';
@@ -42,11 +40,11 @@ export const useStyles = createStyles(({ cx, stylish, css, token }) => ({
const Nav = memo(() => {
const [hide, setHide] = useState(false);
const pathname = usePathname();
const location = useLocation();
const navigate = useNavigate();
const { cx, styles } = useStyles();
const { items, activeKey } = useNav();
const { q } = useQuery() as { q?: string };
const router = useQueryRoute();
useScroll((scroll, delta) => {
if (delta < 0) {
@@ -58,7 +56,7 @@ const Nav = memo(() => {
}
});
const isHome = pathname === '/discover';
const isHome = location.pathname === '/';
return (
<Center className={cx(styles.container, hide && styles.hide)} height={46}>
@@ -77,8 +75,9 @@ const Nav = memo(() => {
compact
items={items as any}
onChange={(key) => {
const href = key === DiscoverTab.Home ? '/discover' : urlJoin('/discover', key);
router.push(href, { query: q ? { q } : {}, replace: true });
const path = key === DiscoverTab.Home ? '/' : `/${key}`;
const search = q ? `?q=${encodeURIComponent(q)}` : '';
navigate(path + search, { replace: true });
const scrollableElement = document?.querySelector(`#${SCROLL_PARENT_ID}`);
if (!scrollableElement) return;
scrollableElement.scrollTo({ behavior: 'smooth', top: 0 });

View File

@@ -0,0 +1,22 @@
'use client';
import { PropsWithChildren, memo } from 'react';
import Desktop from './Desktop';
import Mobile from './Mobile';
interface ListLayoutProps extends PropsWithChildren {
mobile?: boolean;
}
const ListLayout = memo<ListLayoutProps>(({ children, mobile }) => {
if (mobile) {
return <Mobile>{children}</Mobile>;
}
return <Desktop>{children}</Desktop>;
});
ListLayout.displayName = 'ListLayout';
export default ListLayout;

View File

@@ -6,11 +6,10 @@ import { createStyles } from 'antd-style';
import { MenuIcon } from 'lucide-react';
import { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import urlJoin from 'url-join';
import { useNavigate } from 'react-router-dom';
import Menu from '@/components/Menu';
import { withSuspense } from '@/components/withSuspense';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { DiscoverTab } from '@/types/discover';
import { useNav } from '../../../features/useNav';
@@ -38,7 +37,7 @@ const Nav = memo(() => {
const [open, setOpen] = useState(false);
const { styles, theme } = useStyles();
const { items, activeKey, activeItem } = useNav();
const router = useQueryRoute();
const navigate = useNavigate();
return (
<>
@@ -79,9 +78,9 @@ const Nav = memo(() => {
items={items}
onClick={({ key }) => {
if (key === DiscoverTab.Home) {
router.push('/discover');
navigate('/');
} else {
router.push(urlJoin('/discover', key));
navigate(`/${key}`);
}
}}
selectable

View File

@@ -0,0 +1,21 @@
'use client';
import { memo, PropsWithChildren } from 'react';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
interface AssistantLayoutProps extends PropsWithChildren {
mobile?: boolean;
}
const AssistantLayout = memo<AssistantLayoutProps>(({ children, mobile }) => {
if (mobile) {
return <Mobile>{children}</Mobile>;
}
return <Desktop>{children}</Desktop>;
});
AssistantLayout.displayName = 'AssistantLayout';
export default AssistantLayout;

View File

@@ -0,0 +1,44 @@
'use client';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { withSuspense } from '@/components/withSuspense';
import { useQuery } from '@/hooks/useQuery';
import { useDiscoverStore } from '@/store/discover';
import { AssistantQueryParams, DiscoverTab } from '@/types/discover';
import Pagination from '../features/Pagination';
import List from './features/List';
import Loading from './loading';
const AssistantPage = memo<{ mobile?: boolean }>(() => {
const { q, page, category, sort, order } = useQuery() as AssistantQueryParams;
const useAssistantList = useDiscoverStore((s) => s.useAssistantList);
const { data, isLoading } = useAssistantList({
category,
order,
page,
pageSize: 21,
q,
sort,
});
if (isLoading || !data) return <Loading />;
const { items, currentPage, pageSize, totalCount } = data;
return (
<Flexbox gap={32} width={'100%'}>
<List data={items} />
<Pagination
currentPage={currentPage}
pageSize={pageSize}
tab={DiscoverTab.Assistants}
total={totalCount}
/>
</Flexbox>
);
});
export default withSuspense(AssistantPage);

View File

@@ -1,8 +1,7 @@
'use client';
import { Icon, Tag } from '@lobehub/ui';
import Link from 'next/link';
import { useRouter } from 'nextjs-toploader/app';
import { Link, useNavigate } from 'react-router-dom';
import qs from 'query-string';
import { memo, useMemo } from 'react';
@@ -19,20 +18,20 @@ const Category = memo(() => {
const useAssistantCategories = useDiscoverStore((s) => s.useAssistantCategories);
const { category = 'all', q } = useQuery() as { category?: AssistantCategory; q?: string };
const { data: items = [] } = useAssistantCategories({ q });
const route = useRouter();
const navigate = useNavigate();
const cates = useCategory();
const genUrl = (key: AssistantCategory) =>
qs.stringifyUrl(
{
query: { category: key === AssistantCategory.All ? null : key, q },
url: '/discover/assistant',
url: '/assistant',
},
{ skipNull: true },
);
const handleClick = (key: AssistantCategory) => {
route.push(genUrl(key));
navigate(genUrl(key));
const scrollableElement = document?.querySelector(`#${SCROLL_PARENT_ID}`);
if (!scrollableElement) return;
scrollableElement.scrollTo({ behavior: 'smooth', top: 0 });
@@ -71,7 +70,7 @@ const Category = memo(() => {
),
...item,
icon: <Icon icon={item.icon} size={18} />,
label: <Link href={genUrl(item.key)}>{item.label}</Link>,
label: <Link to={genUrl(item.key)}>{item.label}</Link>,
};
})}
mode={'inline'}

View File

@@ -2,11 +2,10 @@ import { Github } from '@lobehub/icons';
import { ActionIcon, Avatar, Block, Icon, Text } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { ClockIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'nextjs-toploader/app';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Link, useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import PublishedTime from '@/components/PublishedTime';
@@ -63,8 +62,8 @@ const AssistantItem = memo<DiscoverAssistantItem>(
backgroundColor,
}) => {
const { styles, theme } = useStyles();
const router = useRouter();
const link = urlJoin('/discover/assistant', identifier);
const navigate = useNavigate();
const link = urlJoin('/assistant', identifier);
const { t } = useTranslation('discover');
return (
@@ -72,7 +71,7 @@ const AssistantItem = memo<DiscoverAssistantItem>(
clickable
height={'100%'}
onClick={() => {
router.push(link);
navigate(link);
}}
style={{
overflow: 'hidden',
@@ -119,7 +118,7 @@ const AssistantItem = memo<DiscoverAssistantItem>(
overflow: 'hidden',
}}
>
<Link href={link} style={{ color: 'inherit', overflow: 'hidden' }}>
<Link style={{ color: 'inherit', overflow: 'hidden' }} to={link}>
<Text as={'h2'} className={styles.title} ellipsis>
{title}
</Text>
@@ -129,16 +128,17 @@ const AssistantItem = memo<DiscoverAssistantItem>(
</Flexbox>
</Flexbox>
<Flexbox align={'center'} gap={4} horizontal>
<Link
<a
href={urlJoin(
'https://github.com/lobehub/lobe-chat-agents/tree/main/locales',
identifier,
)}
onClick={(e) => e.stopPropagation()}
rel="noopener noreferrer"
target={'_blank'}
>
<ActionIcon fill={theme.colorTextDescription} icon={Github} />
</Link>
</a>
</Flexbox>
</Flexbox>
<Flexbox flex={1} gap={12} paddingInline={16}>

View File

@@ -1,12 +0,0 @@
import { PropsWithChildren } from 'react';
import ServerLayout from '@/components/server/ServerLayout';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
const MainLayout = ServerLayout<PropsWithChildren>({ Desktop, Mobile });
MainLayout.displayName = 'DiscoverAssistantsLayout';
export default MainLayout;

View File

@@ -1,46 +0,0 @@
import StructuredData from '@/components/StructuredData';
import { ldModule } from '@/server/ld';
import { metadataModule } from '@/server/metadata';
import { DynamicLayoutProps } from '@/types/next';
import { parsePageMetaProps } from '@/utils/server/pageProps';
import Client from './Client';
export const generateMetadata = async (props: DynamicLayoutProps) => {
const { locale, t } = await parsePageMetaProps(props);
return metadataModule.generate({
alternate: true,
canonical: 'https://lobehub.com/agent',
description: t('discover.assistants.description'),
locale,
title: t('discover.assistants.title'),
url: '/discover/assistant',
});
};
const Page = async (props: DynamicLayoutProps) => {
const { locale, t, isMobile } = await parsePageMetaProps(props);
const ld = ldModule.generate({
description: t('discover.assistants.description'),
locale,
title: t('discover.assistants.title'),
url: '/discover/assistant',
webpage: {
enable: true,
search: '/discover/assistant',
},
});
return (
<>
<StructuredData ld={ld} />
<Client mobile={isMobile} />
</>
);
};
Page.DisplayName = 'DiscoverAssistants';
export default Page;

View File

@@ -3,11 +3,10 @@
import { Pagination as Page } from 'antd';
import { createStyles } from 'antd-style';
import { memo } from 'react';
import urlJoin from 'url-join';
import { useLocation, useNavigate } from 'react-router-dom';
import { SCROLL_PARENT_ID } from '@/app/[variants]/(main)/discover/features/const';
import { useQuery } from '@/hooks/useQuery';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { DiscoverTab } from '@/types/discover';
const useStyles = createStyles(({ css, token, prefixCls }) => {
@@ -36,14 +35,14 @@ interface PaginationProps {
const Pagination = memo<PaginationProps>(({ tab, currentPage, total, pageSize }) => {
const { styles } = useStyles();
const { page } = useQuery();
const router = useQueryRoute();
const navigate = useNavigate();
const location = useLocation();
const handlePageChange = (newPage: number) => {
router.push(urlJoin('/discover', tab), {
query: {
page: String(newPage),
},
});
const searchParams = new URLSearchParams(location.search);
searchParams.set('page', String(newPage));
navigate(`/${tab}?${searchParams.toString()}`);
const scrollableElement = document?.querySelector(`#${SCROLL_PARENT_ID}`);
if (!scrollableElement) return;
scrollableElement.scrollTo({ behavior: 'smooth', top: 0 });

View File

@@ -1,12 +0,0 @@
import { PropsWithChildren } from 'react';
import ServerLayout from '@/components/server/ServerLayout';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
const MainLayout = ServerLayout<PropsWithChildren>({ Desktop, Mobile });
MainLayout.displayName = 'DiscoverLayout';
export default MainLayout;

View File

@@ -0,0 +1,21 @@
'use client';
import { memo, PropsWithChildren } from 'react';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
interface McpLayoutProps extends PropsWithChildren {
mobile?: boolean;
}
const McpLayout = memo<McpLayoutProps>(({ children, mobile }) => {
if (mobile) {
return <Mobile>{children}</Mobile>;
}
return <Desktop>{children}</Desktop>;
});
McpLayout.displayName = 'McpLayout';
export default McpLayout;

View File

@@ -0,0 +1,44 @@
'use client';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { withSuspense } from '@/components/withSuspense';
import { useQuery } from '@/hooks/useQuery';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab, McpQueryParams } from '@/types/discover';
import Pagination from '../features/Pagination';
import List from './features/List';
import Loading from './loading';
const McpPage = memo<{ mobile?: boolean }>(() => {
const { q, page, category, sort, order } = useQuery() as McpQueryParams;
const useMcpList = useDiscoverStore((s) => s.useFetchMcpList);
const { data, isLoading } = useMcpList({
category,
order,
page,
pageSize: 21,
q,
sort,
});
if (isLoading || !data) return <Loading />;
const { items, currentPage, pageSize, totalCount } = data;
return (
<Flexbox gap={32} width={'100%'}>
<List data={items} />
<Pagination
currentPage={currentPage}
pageSize={pageSize}
tab={DiscoverTab.Mcp}
total={totalCount}
/>
</Flexbox>
);
});
export default withSuspense(McpPage);

View File

@@ -1,8 +1,7 @@
'use client';
import { Icon, Tag } from '@lobehub/ui';
import Link from 'next/link';
import { useRouter } from 'nextjs-toploader/app';
import { Link, useNavigate } from 'react-router-dom';
import qs from 'query-string';
import { memo, useMemo } from 'react';
@@ -19,20 +18,20 @@ const Category = memo(() => {
const useMcpCategories = useDiscoverStore((s) => s.useMcpCategories);
const { category = 'all', q } = useQuery() as { category?: McpCategory; q?: string };
const { data: items = [] } = useMcpCategories({ q });
const route = useRouter();
const navigate = useNavigate();
const cates = useCategory();
const genUrl = (key: McpCategory) =>
qs.stringifyUrl(
{
query: { category: key === McpCategory.All ? null : key, q },
url: '/discover/mcp',
url: '/mcp',
},
{ skipNull: true },
);
const handleClick = (key: McpCategory) => {
route.push(genUrl(key));
navigate(genUrl(key));
const scrollableElement = document?.querySelector(`#${SCROLL_PARENT_ID}`);
if (!scrollableElement) return;
scrollableElement.scrollTo({ behavior: 'smooth', top: 0 });
@@ -70,7 +69,7 @@ const Category = memo(() => {
),
...item,
icon: <Icon icon={item.icon} size={18} />,
label: <Link href={genUrl(item.key)}>{item.label}</Link>,
label: <Link to={genUrl(item.key)}>{item.label}</Link>,
};
})}
mode={'inline'}

View File

@@ -5,11 +5,10 @@ import { ActionIcon, Avatar, Block, Icon, Tag, Text, Tooltip } from '@lobehub/ui
import { Spotlight } from '@lobehub/ui/awesome';
import { createStyles } from 'antd-style';
import { ClockIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'nextjs-toploader/app';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Link, useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import InstallationIcon from '@/components/MCPDepsIcon';
@@ -78,14 +77,14 @@ const McpItem = memo<DiscoverMcpItem>(
}) => {
const { t } = useTranslation('discover');
const { styles, theme } = useStyles();
const router = useRouter();
const link = urlJoin('/discover/mcp', identifier);
const navigate = useNavigate();
const link = urlJoin('/mcp', identifier);
return (
<Block
clickable
height={'100%'}
onClick={() => {
router.push(link);
navigate(link);
}}
style={{
overflow: 'hidden',
@@ -128,7 +127,7 @@ const McpItem = memo<DiscoverMcpItem>(
overflow: 'hidden',
}}
>
<Link href={link} style={{ color: 'inherit', overflow: 'hidden' }}>
<Link style={{ color: 'inherit', overflow: 'hidden' }} to={link}>
<Text as={'h2'} className={styles.title} ellipsis>
{name}
</Text>
@@ -145,9 +144,14 @@ const McpItem = memo<DiscoverMcpItem>(
<Flexbox align={'center'} gap={4} horizontal>
{installationMethods && <InstallationIcon type={installationMethods} />}
{github && (
<Link href={github.url} onClick={(e) => e.stopPropagation()} target={'_blank'}>
<a
href={github.url}
onClick={(e) => e.stopPropagation()}
rel="noopener noreferrer"
target={'_blank'}
>
<ActionIcon fill={theme.colorTextDescription} icon={Github} />
</Link>
</a>
)}
</Flexbox>
</Flexbox>

View File

@@ -1,12 +0,0 @@
import { PropsWithChildren } from 'react';
import ServerLayout from '@/components/server/ServerLayout';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
const MainLayout = ServerLayout<PropsWithChildren>({ Desktop, Mobile });
MainLayout.displayName = 'DiscoverToolsLayout';
export default MainLayout;

View File

@@ -1,46 +0,0 @@
import StructuredData from '@/components/StructuredData';
import { ldModule } from '@/server/ld';
import { metadataModule } from '@/server/metadata';
import { DynamicLayoutProps } from '@/types/next';
import { parsePageMetaProps } from '@/utils/server/pageProps';
import Client from './Client';
export const generateMetadata = async (props: DynamicLayoutProps) => {
const { locale, t } = await parsePageMetaProps(props);
return metadataModule.generate({
alternate: true,
canonical: 'https://lobehub.com/mcp',
description: t('discover.plugins.description'),
locale,
title: t('discover.plugins.title'),
url: '/discover/mcp',
});
};
const Page = async (props: DynamicLayoutProps) => {
const { locale, t, isMobile } = await parsePageMetaProps(props);
const ld = ldModule.generate({
description: t('discover.plugins.description'),
locale,
title: t('discover.plugins.title'),
url: '/discover/mcp',
webpage: {
enable: true,
search: '/discover/mcp',
},
});
return (
<>
<StructuredData ld={ld} />
<Client mobile={isMobile} />
</>
);
};
Page.DisplayName = 'DiscoverMCP';
export default Page;

View File

@@ -0,0 +1,21 @@
'use client';
import { memo, PropsWithChildren } from 'react';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
interface ModelLayoutProps extends PropsWithChildren {
mobile?: boolean;
}
const ModelLayout = memo<ModelLayoutProps>(({ children, mobile }) => {
if (mobile) {
return <Mobile>{children}</Mobile>;
}
return <Desktop>{children}</Desktop>;
});
ModelLayout.displayName = 'ModelLayout';
export default ModelLayout;

View File

@@ -0,0 +1,44 @@
'use client';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { withSuspense } from '@/components/withSuspense';
import { useQuery } from '@/hooks/useQuery';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab, ModelQueryParams } from '@/types/discover';
import Pagination from '../features/Pagination';
import List from './features/List';
import Loading from './loading';
const ModelPage = memo<{ mobile?: boolean }>(() => {
const { q, page, category, sort, order } = useQuery() as ModelQueryParams;
const useModelList = useDiscoverStore((s) => s.useModelList);
const { data, isLoading } = useModelList({
category,
order,
page,
pageSize: 21,
q,
sort,
});
if (isLoading || !data) return <Loading />;
const { items, currentPage, pageSize, totalCount } = data;
return (
<Flexbox gap={32} width={'100%'}>
<List data={items} />
<Pagination
currentPage={currentPage}
pageSize={pageSize}
tab={DiscoverTab.Models}
total={totalCount}
/>
</Flexbox>
);
});
export default withSuspense(ModelPage);

View File

@@ -4,7 +4,7 @@ import { Flexbox } from 'react-layout-kit';
import CategoryContainer from '../../../components/CategoryContainer';
import Category from '../features/Category';
const Layout = async ({ children }: PropsWithChildren) => {
const Layout = ({ children }: PropsWithChildren) => {
return (
<Flexbox gap={24} horizontal style={{ position: 'relative' }} width={'100%'}>
<CategoryContainer>

View File

@@ -1,8 +1,7 @@
'use client';
import { Icon, Tag } from '@lobehub/ui';
import Link from 'next/link';
import { useRouter } from 'nextjs-toploader/app';
import { Link, useNavigate } from 'react-router-dom';
import qs from 'query-string';
import { memo, useMemo } from 'react';
@@ -18,20 +17,20 @@ const Category = memo(() => {
const useModelCategories = useDiscoverStore((s) => s.useModelCategories);
const { category = 'all', q } = useQuery() as { category?: string; q?: string };
const { data: items = [] } = useModelCategories({ q });
const route = useRouter();
const navigate = useNavigate();
const cates = useCategory();
const genUrl = (key: string) =>
qs.stringifyUrl(
{
query: { category: key === 'all' ? null : key, q },
url: '/discover/model',
url: '/model',
},
{ skipNull: true },
);
const handleClick = (key: string) => {
route.push(genUrl(key));
navigate(genUrl(key));
const scrollableElement = document?.querySelector(`#${SCROLL_PARENT_ID}`);
if (!scrollableElement) return;
scrollableElement.scrollTo({ behavior: 'smooth', top: 0 });
@@ -69,7 +68,7 @@ const Category = memo(() => {
),
...item,
icon: <Icon icon={item.icon} size={18} />,
label: <Link href={genUrl(item.key)}>{item.label}</Link>,
label: <Link to={genUrl(item.key)}>{item.label}</Link>,
};
})}
mode={'inline'}

View File

@@ -6,11 +6,10 @@ import { Popover } from 'antd';
import { createStyles } from 'antd-style';
import dayjs from 'dayjs';
import { ClockIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'nextjs-toploader/app';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Link, useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { ModelInfoTags } from '@/components/ModelSelect';
@@ -57,14 +56,14 @@ const ModelItem = memo<DiscoverModelItem>(
({ identifier, displayName, contextWindowTokens, releasedAt, type, abilities, providers }) => {
const { t } = useTranslation(['models', 'discover']);
const { styles } = useStyles();
const router = useRouter();
const link = urlJoin('/discover/model', identifier);
const navigate = useNavigate();
const link = urlJoin('/model', identifier);
return (
<Block
clickable
height={'100%'}
onClick={() => {
router.push(link);
navigate(link);
}}
style={{
overflow: 'hidden',
@@ -106,7 +105,7 @@ const ModelItem = memo<DiscoverModelItem>(
overflow: 'hidden',
}}
>
<Link href={link} style={{ color: 'inherit', overflow: 'hidden' }}>
<Link style={{ color: 'inherit', overflow: 'hidden' }} to={link}>
<Text as={'h2'} className={styles.title} ellipsis>
{displayName}
</Text>

View File

@@ -1,12 +0,0 @@
import { PropsWithChildren } from 'react';
import ServerLayout from '@/components/server/ServerLayout';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
const MainLayout = ServerLayout<PropsWithChildren>({ Desktop, Mobile });
MainLayout.displayName = 'DiscoverModelsLayout';
export default MainLayout;

View File

@@ -1,44 +0,0 @@
import StructuredData from '@/components/StructuredData';
import { ldModule } from '@/server/ld';
import { metadataModule } from '@/server/metadata';
import { DynamicLayoutProps } from '@/types/next';
import { parsePageMetaProps } from '@/utils/server/pageProps';
import Client from './Client';
export const generateMetadata = async (props: DynamicLayoutProps) => {
const { locale, t } = await parsePageMetaProps(props);
return metadataModule.generate({
alternate: true,
description: t('discover.models.description'),
locale,
title: t('discover.models.title'),
url: '/discover/model',
});
};
const Page = async (props: DynamicLayoutProps) => {
const { locale, t } = await parsePageMetaProps(props);
const ld = ldModule.generate({
description: t('discover.models.description'),
locale,
title: t('discover.models.title'),
url: '/discover/model',
webpage: {
enable: true,
search: '/discover/model',
},
});
return (
<>
<StructuredData ld={ld} />
<Client />
</>
);
};
Page.DisplayName = 'DiscoverModels';
export default Page;

View File

@@ -0,0 +1,43 @@
'use client';
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { withSuspense } from '@/components/withSuspense';
import { useQuery } from '@/hooks/useQuery';
import { useDiscoverStore } from '@/store/discover';
import { DiscoverTab, ProviderQueryParams } from '@/types/discover';
import Pagination from '../features/Pagination';
import List from './features/List';
import Loading from './loading';
const ProviderPage = memo<{ mobile?: boolean }>(() => {
const { q, page, sort, order } = useQuery() as ProviderQueryParams;
const useProviderList = useDiscoverStore((s) => s.useProviderList);
const { data, isLoading } = useProviderList({
order,
page,
pageSize: 21,
q,
sort,
});
if (isLoading || !data) return <Loading />;
const { items, currentPage, pageSize, totalCount } = data;
return (
<Flexbox gap={32} width={'100%'}>
<List data={items} />
<Pagination
currentPage={currentPage}
pageSize={pageSize}
tab={DiscoverTab.Providers}
total={totalCount}
/>
</Flexbox>
);
});
export default withSuspense(ProviderPage);

View File

@@ -2,11 +2,10 @@ import { Github, ModelTag, ProviderCombine } from '@lobehub/icons';
import { ActionIcon, Block, MaskShadow, Text } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { GlobeIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'nextjs-toploader/app';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { Link, useNavigate } from 'react-router-dom';
import urlJoin from 'url-join';
import { DiscoverProviderItem } from '@/types/discover';
@@ -48,8 +47,8 @@ const useStyles = createStyles(({ css, token }) => {
const ProviderItem = memo<DiscoverProviderItem>(
({ url, name, description, identifier, models }) => {
const { styles, theme } = useStyles();
const router = useRouter();
const link = urlJoin('/discover/provider', identifier);
const navigate = useNavigate();
const link = urlJoin('/provider', identifier);
const { t } = useTranslation(['discover', 'providers']);
return (
@@ -57,7 +56,7 @@ const ProviderItem = memo<DiscoverProviderItem>(
clickable
height={'100%'}
onClick={() => {
router.push(link);
navigate(link);
}}
style={{
overflow: 'hidden',
@@ -80,22 +79,28 @@ const ProviderItem = memo<DiscoverProviderItem>(
}}
title={identifier}
>
<Link href={link} style={{ color: 'inherit', overflow: 'hidden' }}>
<Link style={{ color: 'inherit', overflow: 'hidden' }} to={link}>
<ProviderCombine provider={identifier} size={28} style={{ flex: 'none' }} />
</Link>
<div className={styles.author}>@{name}</div>
</Flexbox>
<Flexbox align={'center'} horizontal>
<Link href={url} onClick={(e) => e.stopPropagation()} target={'_blank'}>
<a
href={url}
onClick={(e) => e.stopPropagation()}
rel="noopener noreferrer"
target={'_blank'}
>
<ActionIcon color={theme.colorTextDescription} icon={GlobeIcon} />
</Link>
<Link
</a>
<a
href={`https://github.com/lobehub/lobe-chat/blob/main/src/config/modelProviders/${identifier}.ts`}
onClick={(e) => e.stopPropagation()}
rel="noopener noreferrer"
target={'_blank'}
>
<ActionIcon fill={theme.colorTextDescription} icon={Github} />
</Link>
</a>
</Flexbox>
</Flexbox>
<Flexbox flex={1} gap={12} paddingInline={16}>
@@ -122,7 +127,7 @@ const ProviderItem = memo<DiscoverProviderItem>(
.slice(0, 6)
.filter(Boolean)
.map((tag: string) => (
<Link href={urlJoin('/discover/model', tag)} key={tag}>
<Link key={tag} to={urlJoin('/model', tag)}>
<ModelTag model={tag} style={{ margin: 0 }} />
</Link>
))}

View File

@@ -1,44 +0,0 @@
import StructuredData from '@/components/StructuredData';
import { ldModule } from '@/server/ld';
import { metadataModule } from '@/server/metadata';
import { DynamicLayoutProps } from '@/types/next';
import { parsePageMetaProps } from '@/utils/server/pageProps';
import Client from './Client';
export const generateMetadata = async (props: DynamicLayoutProps) => {
const { locale, t } = await parsePageMetaProps(props);
return metadataModule.generate({
alternate: true,
description: t('discover.providers.description'),
locale,
title: t('discover.providers.title'),
url: '/discover/provider',
});
};
const Page = async (props: DynamicLayoutProps) => {
const { locale, t, isMobile } = await parsePageMetaProps(props);
const ld = ldModule.generate({
description: t('discover.providers.description'),
locale,
title: t('discover.providers.title'),
url: '/discover/provider',
webpage: {
enable: true,
search: '/discover/provider',
},
});
return (
<>
<StructuredData ld={ld} />
<Client mobile={isMobile} />
</>
);
};
Page.DisplayName = 'DiscoverProviders';
export default Page;

View File

@@ -0,0 +1,167 @@
'use client';
import { memo, useEffect } from 'react';
import { MemoryRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { useMediaQuery } from 'react-responsive';
import DiscoverLayout from './_layout/DiscoverLayout';
import ListLayout from './(list)/_layout/ListLayout';
import DetailLayout from './(detail)/_layout/DetailLayout';
import HomePage from './(list)/(home)/HomePage';
import AssistantPage from './(list)/assistant/AssistantPage';
import AssistantLayout from './(list)/assistant/AssistantLayout';
import McpPage from './(list)/mcp/McpPage';
import McpLayout from './(list)/mcp/McpLayout';
import ModelPage from './(list)/model/ModelPage';
import ModelLayout from './(list)/model/ModelLayout';
import ProviderPage from './(list)/provider/ProviderPage';
import AssistantDetailPage from './(detail)/assistant/AssistantDetailPage';
import McpDetailPage from './(detail)/mcp/McpDetailPage';
import ModelDetailPage from './(detail)/model/ModelDetailPage';
import ProviderDetailPage from './(detail)/provider/ProviderDetailPage';
// Get initial path from URL
const getInitialPath = () => {
if (typeof window === 'undefined') return '/';
const fullPath = window.location.pathname;
const searchParams = window.location.search;
const discoverIndex = fullPath.indexOf('/discover');
if (discoverIndex !== -1) {
const pathAfterDiscover = fullPath.slice(discoverIndex + '/discover'.length) || '/';
return pathAfterDiscover + searchParams;
}
return '/';
};
// Helper component to sync URL with MemoryRouter
const UrlSynchronizer = () => {
const location = useLocation();
const navigate = useNavigate();
// Sync initial URL
useEffect(() => {
const fullPath = window.location.pathname;
const searchParams = window.location.search;
const discoverIndex = fullPath.indexOf('/discover');
if (discoverIndex !== -1) {
const pathAfterDiscover = fullPath.slice(discoverIndex + '/discover'.length) || '/';
const targetPath = pathAfterDiscover + searchParams;
if (location.pathname + location.search !== targetPath) {
navigate(targetPath, { replace: true });
}
}
}, []);
// Update browser URL when location changes
useEffect(() => {
const newUrl = `/discover${location.pathname}${location.search}`;
if (window.location.pathname + window.location.search !== newUrl) {
window.history.replaceState({}, '', newUrl);
}
}, [location.pathname, location.search]);
return null;
};
const DiscoverRouter = memo(() => {
const mobile = useMediaQuery({ maxWidth: 768 });
return (
<MemoryRouter initialEntries={[getInitialPath()]} initialIndex={0}>
<UrlSynchronizer />
<DiscoverLayout mobile={mobile}>
<Routes>
{/* List routes with ListLayout */}
<Route
element={
<ListLayout mobile={mobile}>
<HomePage mobile={mobile} />
</ListLayout>
}
path="/"
/>
<Route
element={
<ListLayout mobile={mobile}>
<AssistantLayout mobile={mobile}>
<AssistantPage mobile={mobile} />
</AssistantLayout>
</ListLayout>
}
path="/assistant"
/>
<Route
element={
<ListLayout mobile={mobile}>
<ModelLayout mobile={mobile}>
<ModelPage mobile={mobile} />
</ModelLayout>
</ListLayout>
}
path="/model"
/>
<Route
element={
<ListLayout mobile={mobile}>
<ProviderPage mobile={mobile} />
</ListLayout>
}
path="/provider"
/>
<Route
element={
<ListLayout mobile={mobile}>
<McpLayout mobile={mobile}>
<McpPage mobile={mobile} />
</McpLayout>
</ListLayout>
}
path="/mcp"
/>
{/* Detail routes with DetailLayout */}
<Route
element={
<DetailLayout mobile={mobile}>
<AssistantDetailPage mobile={mobile} />
</DetailLayout>
}
path="/assistant/*"
/>
<Route
element={
<DetailLayout mobile={mobile}>
<ModelDetailPage mobile={mobile} />
</DetailLayout>
}
path="/model/*"
/>
<Route
element={
<DetailLayout mobile={mobile}>
<ProviderDetailPage mobile={mobile} />
</DetailLayout>
}
path="/provider/*"
/>
<Route
element={
<DetailLayout mobile={mobile}>
<McpDetailPage mobile={mobile} />
</DetailLayout>
}
path="/mcp/*"
/>
{/* Fallback */}
<Route element={<Navigate replace to="/" />} path="*" />
</Routes>
</DiscoverLayout>
</MemoryRouter>
);
});
export default DiscoverRouter;

View File

@@ -0,0 +1,11 @@
'use client';
import DiscoverRouter from '../DiscoverRouter';
const DiscoverPage = () => {
return <DiscoverRouter />;
};
DiscoverPage.displayName = 'DiscoverPage';
export default DiscoverPage;

View File

@@ -1,8 +1,8 @@
'use client';
import { ChatHeader } from '@lobehub/ui/chat';
import Link from 'next/link';
import { memo } from 'react';
import { Link } from 'react-router-dom';
import { ProductLogo } from '@/components/Branding';
import { isCustomBranding } from '@/const/version';
@@ -14,7 +14,7 @@ const Header = memo(() => {
return (
<ChatHeader
left={
<Link href={'/discover'} style={{ color: 'inherit' }}>
<Link style={{ color: 'inherit' }} to={'/'}>
<ProductLogo extra={'Discover'} size={36} type={'text'} />
</Link>
}

View File

@@ -0,0 +1,22 @@
'use client';
import { PropsWithChildren, memo } from 'react';
import Desktop from './Desktop';
import Mobile from './Mobile';
interface DiscoverLayoutProps extends PropsWithChildren {
mobile?: boolean;
}
const DiscoverLayout = memo<DiscoverLayoutProps>(({ children, mobile }) => {
if (mobile) {
return <Mobile>{children}</Mobile>;
}
return <Desktop>{children}</Desktop>;
});
DiscoverLayout.displayName = 'DiscoverLayout';
export default DiscoverLayout;

View File

@@ -3,9 +3,9 @@
import { Button, Icon, Tag } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { ChevronRight } from 'lucide-react';
import Link from 'next/link';
import { ReactNode, memo } from 'react';
import { Flexbox, FlexboxProps } from 'react-layout-kit';
import { Link } from 'react-router-dom';
const useStyles = createStyles(({ css, responsive, token }) => ({
more: css`
@@ -59,7 +59,10 @@ const Title = memo<TitleProps>(({ tag, children, moreLink, more }) => {
title
)}
{moreLink && (
<Link href={moreLink} target={moreLink.startsWith('http') ? '_blank' : undefined}>
<Link
target={moreLink.startsWith('http') ? '_blank' : undefined}
to={moreLink}
>
<Button className={styles.more} style={{ paddingInline: 6 }} type={'text'}>
<span>{more}</span>
<Icon icon={ChevronRight} />

View File

@@ -3,9 +3,10 @@
import { Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { ChevronRight } from 'lucide-react';
import Link from 'next/link';
import NextLink from 'next/link';
import { ReactNode, memo } from 'react';
import { Flexbox, FlexboxProps } from 'react-layout-kit';
import { Link as RouterLink } from 'react-router-dom';
const useStyles = createStyles(({ css, token }) => ({
more: css`
@@ -39,10 +40,41 @@ const Title = memo<TitleProps>(
({ id, tag, children, moreLink, more, level = 2, icon, ...rest }) => {
const { cx, styles } = useStyles();
const title = (
<h2 className={cx(styles.title, styles[`title${level}`])} id={id}>
<h2 className={cx(styles.title, styles[`title${level}` as 'title2' | 'title3'])} id={id}>
{children}
</h2>
);
// Check if it's an external link or internal discover route
const isExternalLink = moreLink?.startsWith('http') ?? false;
const isDiscoverRoute = moreLink?.startsWith('/discover') ?? false;
let moreLinkElement = null;
if (moreLink) {
if (isExternalLink) {
moreLinkElement = (
<NextLink className={styles.more} href={moreLink} target="_blank">
<span style={{ marginRight: 4 }}>{more}</span>
<Icon icon={ChevronRight} />
</NextLink>
);
} else if (isDiscoverRoute) {
moreLinkElement = (
<RouterLink className={styles.more} to={moreLink.replace('/discover', '')}>
<span style={{ marginRight: 4 }}>{more}</span>
<Icon icon={ChevronRight} />
</RouterLink>
);
} else {
moreLinkElement = (
<NextLink className={styles.more} href={moreLink}>
<span style={{ marginRight: 4 }}>{more}</span>
<Icon icon={ChevronRight} />
</NextLink>
);
}
}
return (
<Flexbox
align={'center'}
@@ -65,16 +97,7 @@ const Title = memo<TitleProps>(
) : (
title
)}
{moreLink && (
<Link
className={styles.more}
href={moreLink}
target={moreLink.startsWith('http') ? '_blank' : undefined}
>
<span style={{ marginRight: 4 }}>{more}</span>
<Icon icon={ChevronRight} />
</Link>
)}
{moreLinkElement}
</Flexbox>
);
},

View File

@@ -1,11 +1,9 @@
import { MCP } from '@lobehub/icons';
import { Icon } from '@lobehub/ui';
import { Bot, Brain, BrainCircuit, House } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import urlJoin from 'url-join';
import { Link, useLocation } from 'react-router-dom';
import type { MenuProps } from '@/components/Menu';
import { DiscoverTab } from '@/types/discover';
@@ -13,19 +11,20 @@ import { DiscoverTab } from '@/types/discover';
const ICON_SIZE = 16;
export const useNav = () => {
const pathname = usePathname();
const location = useLocation();
const { t } = useTranslation('discover');
const activeKey = useMemo(() => {
const pathname = location.pathname;
for (const value of Object.values(DiscoverTab)) {
if (pathname.includes(urlJoin('/discover', DiscoverTab.Plugins))) {
if (pathname.includes(`/${DiscoverTab.Plugins}`)) {
return DiscoverTab.Mcp;
} else if (pathname.includes(urlJoin('/discover', value))) {
} else if (pathname.includes(`/${value}`)) {
return value;
}
}
return DiscoverTab.Home;
}, [pathname]);
}, [location.pathname]);
const items: MenuProps['items'] = useMemo(
() => [
@@ -33,7 +32,7 @@ export const useNav = () => {
icon: <Icon icon={House} size={ICON_SIZE} />,
key: DiscoverTab.Home,
label: (
<Link href={'/discover'} style={{ color: 'inherit' }}>
<Link style={{ color: 'inherit' }} to={'/'}>
{t('tab.home')}
</Link>
),
@@ -42,7 +41,7 @@ export const useNav = () => {
icon: <Icon icon={Bot} size={ICON_SIZE} />,
key: DiscoverTab.Assistants,
label: (
<Link href={urlJoin('/discover', DiscoverTab.Assistants)} style={{ color: 'inherit' }}>
<Link style={{ color: 'inherit' }} to={`/${DiscoverTab.Assistants}`}>
{t('tab.assistant')}
</Link>
),
@@ -51,7 +50,7 @@ export const useNav = () => {
icon: <MCP className={'anticon'} size={ICON_SIZE} />,
key: DiscoverTab.Mcp,
label: (
<Link href={urlJoin('/discover', DiscoverTab.Mcp)} style={{ color: 'inherit' }}>
<Link style={{ color: 'inherit' }} to={`/${DiscoverTab.Mcp}`}>
{`MCP ${t('tab.plugin')}`}
</Link>
),
@@ -60,7 +59,7 @@ export const useNav = () => {
icon: <Icon icon={Brain} size={ICON_SIZE} />,
key: DiscoverTab.Models,
label: (
<Link href={urlJoin('/discover', DiscoverTab.Models)} style={{ color: 'inherit' }}>
<Link style={{ color: 'inherit' }} to={`/${DiscoverTab.Models}`}>
{t('tab.model')}
</Link>
),
@@ -69,7 +68,7 @@ export const useNav = () => {
icon: <Icon icon={BrainCircuit} size={ICON_SIZE} />,
key: DiscoverTab.Providers,
label: (
<Link href={urlJoin('/discover', DiscoverTab.Providers)} style={{ color: 'inherit' }}>
<Link style={{ color: 'inherit' }} to={`/${DiscoverTab.Providers}`}>
{t('tab.provider')}
</Link>
),

View File

@@ -1,12 +0,0 @@
import { PropsWithChildren } from 'react';
import ServerLayout from '@/components/server/ServerLayout';
import Desktop from './_layout/Desktop';
import Mobile from './_layout/Mobile';
const MainLayout = ServerLayout<PropsWithChildren>({ Desktop, Mobile });
MainLayout.displayName = 'DiscoverStoreLayout';
export default MainLayout;

View File

@@ -2,6 +2,7 @@
import { Switch } from 'antd';
import { createStyles } from 'antd-style';
import Image from 'next/image';
import { PropsWithChildren, memo } from 'react';
import { Flexbox } from 'react-layout-kit';
@@ -41,6 +42,8 @@ const useStyles = createStyles(({ css, token }) => ({
align-items: center;
`,
thumb: css`
position: relative;
overflow: hidden;
width: 250px;
@@ -48,12 +51,6 @@ const useStyles = createStyles(({ css, token }) => ({
border-radius: ${token.borderRadiusLG}px;
background: linear-gradient(135deg, ${token.colorFillTertiary}, ${token.colorFillQuaternary});
img {
width: 100%;
height: 100%;
object-fit: cover;
}
`,
title: css`
font-size: 16px;
@@ -73,7 +70,9 @@ const LabCard = memo<PropsWithChildren<LabCardProps>>(
<div className={styles.wrap}>
<div className={styles.card}>
<div className={styles.row}>
<div className={styles.thumb}>{cover && <img alt={title} src={cover} />}</div>
<div className={styles.thumb}>
{cover && <Image alt={title} fill src={cover} style={{ objectFit: 'cover' }} />}
</div>
<Flexbox gap={6}>
<div className={styles.title}>{title}</div>
<div className={styles.desc}>{desc}</div>

View File

@@ -1,15 +1,20 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryClient } from '@tanstack/query-core';
import { QueryClientProvider } from '@tanstack/react-query';
import React, { PropsWithChildren, useState } from 'react';
import { lambdaQuery, lambdaQueryClient } from '@/libs/trpc/client';
const QueryProvider = ({ children }: PropsWithChildren) => {
const [queryClient] = useState(() => new QueryClient());
// Cast required because pnpm installs separate QueryClient type instances for trpc and app
const providerQueryClient = queryClient as unknown as React.ComponentProps<
typeof lambdaQuery.Provider
>['queryClient'];
return (
<lambdaQuery.Provider client={lambdaQueryClient} queryClient={queryClient}>
<lambdaQuery.Provider client={lambdaQueryClient} queryClient={providerQueryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</lambdaQuery.Provider>
);