diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 08f85ef3dac..4410c6f918d 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -12,10 +12,36 @@
- [ ] 📝 docs
- [ ] 🔨 chore
+#### 🔗 Related Issue
+
+
+
+
+
#### 🔀 Description of Change
+#### 🧪 How to Test
+
+
+
+
+
+- [ ] Tested locally
+- [ ] Added/updated tests
+- [ ] No tests needed
+
+#### 📸 Screenshots / Videos
+
+
+
+| Before | After |
+| ------ | ----- |
+| ... | ... |
+
#### 📝 Additional Information
+
+
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index e0e5ddac3d3..fe4642ca31b 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -35,18 +35,18 @@ jobs:
PORT: 3010
run: bun run e2e
- - name: Upload Playwright HTML report (on failure)
+ - name: Upload Cucumber HTML report (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
- name: playwright-report
- path: playwright-report
+ name: cucumber-report
+ path: e2e/reports
if-no-files-found: ignore
- - name: Upload Playwright traces (on failure)
+ - name: Upload screenshots (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
- name: test-results
- path: test-results
+ name: test-screenshots
+ path: e2e/screenshots
if-no-files-found: ignore
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 97027e83a1b..4745fd43171 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -145,26 +145,24 @@ jobs:
node-version: 22
package-manager-cache: false
- - name: Install bun
- uses: oven-sh/setup-bun@v2
- with:
- bun-version: 1.2.23
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
- name: Install deps
- run: bun i
+ run: pnpm i
- name: Lint
- run: bun run lint
+ run: npm run lint
- name: Test Client DB
- run: bun run --filter @lobechat/database test:client-db
+ run: pnpm --filter @lobechat/database test:client-db
env:
KEY_VAULTS_SECRET: LA7n9k3JdEcbSgml2sxfw+4TV1AzaaFU5+R176aQz4s=
S3_PUBLIC_DOMAIN: https://example.com
APP_URL: https://home.com
- name: Test Coverage
- run: bun run --filter @lobechat/database test:coverage
+ run: pnpm --filter @lobechat/database test:coverage
env:
DATABASE_TEST_URL: postgresql://postgres:postgres@localhost:5432/postgres
DATABASE_DRIVER: node
diff --git a/.gitignore b/.gitignore
index a95429fa7f2..4171a9fde53 100644
--- a/.gitignore
+++ b/.gitignore
@@ -117,3 +117,4 @@ CLAUDE.local.md
prd
GEMINI.md
+e2e/reports
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1154549307..0f197a4e542 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,48 @@
# Changelog
+### [Version 1.141.6](https://github.com/lobehub/lobe-chat/compare/v1.141.5...v1.141.6)
+
+Released on **2025-10-22**
+
+
+
+
+Improvements and Fixes
+
+
+
+
+
+[](#readme-top)
+
+
+
+### [Version 1.141.5](https://github.com/lobehub/lobe-chat/compare/v1.141.4...v1.141.5)
+
+Released on **2025-10-22**
+
+#### ♻ Code Refactoring
+
+- **misc**: Change discover page from RSC to SPA to improve performance.
+
+
+
+
+Improvements and Fixes
+
+#### Code refactoring
+
+- **misc**: Change discover page from RSC to SPA to improve performance, closes [#9828](https://github.com/lobehub/lobe-chat/issues/9828) ([b59ee0a](https://github.com/lobehub/lobe-chat/commit/b59ee0a))
+
+
+
+
+
+[](#readme-top)
+
+
+
### [Version 1.141.4](https://github.com/lobehub/lobe-chat/compare/v1.141.3...v1.141.4)
Released on **2025-10-22**
diff --git a/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts b/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
index 96e7affe015..3671d858e48 100644
--- a/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
+++ b/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts
@@ -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);
-
- await this.app.browserManager.showSettingsWindowWithTab(subPath);
+ const extractedSubPath = extractSubPath(path, matchedRoute.pathPrefix);
+ const sanitizedSubPath =
+ extractedSubPath && !extractedSubPath.startsWith('?') ? extractedSubPath : undefined;
+ let searchParams: Record | undefined;
+ try {
+ const url = new URL(params.url);
+ const entries = Array.from(url.searchParams.entries());
+ if (entries.length > 0) {
+ searchParams = entries.reduce>((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({
+ 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 {
diff --git a/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts
index 9b67768f2e8..6665519f322 100644
--- a/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts
+++ b/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts
@@ -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,
diff --git a/apps/desktop/src/main/core/browser/BrowserManager.ts b/apps/desktop/src/main/core/browser/BrowserManager.ts
index 11323a6cd29..a9759e9c7f4 100644
--- a/apps/desktop/src/main/core/browser/BrowserManager.ts
+++ b/apps/desktop/src/main/core/browser/BrowserManager.ts
@@ -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;
diff --git a/changelog/v1.json b/changelog/v1.json
index 5ad52a199eb..c31d14cc6fb 100644
--- a/changelog/v1.json
+++ b/changelog/v1.json
@@ -1,4 +1,16 @@
[
+ {
+ "children": {},
+ "date": "2025-10-22",
+ "version": "1.141.6"
+ },
+ {
+ "children": {
+ "improvements": ["Change discover page from RSC to SPA to improve performance."]
+ },
+ "date": "2025-10-22",
+ "version": "1.141.5"
+ },
{
"children": {
"improvements": ["Fix model runtime cost calculate with CNY."]
diff --git a/e2e/README.md b/e2e/README.md
new file mode 100644
index 00000000000..a4fbf19a63f
--- /dev/null
+++ b/e2e/README.md
@@ -0,0 +1,143 @@
+# E2E Tests for LobeChat
+
+This directory contains end-to-end (E2E) tests for LobeChat using Cucumber (BDD) and Playwright.
+
+## Directory Structure
+
+````
+e2e/
+├── src/ # Source files
+│ ├── features/ # Gherkin feature files
+│ │ └── discover/ # Discover page tests
+│ ├── steps/ # Step definitions
+│ │ ├── common/ # Reusable step definitions
+│ │ └── discover/ # Discover-specific steps
+│ └── support/ # Test support files
+│ └── world.ts # Custom World context
+├── reports/ # Test reports (generated)
+├── cucumber.config.js # Cucumber configuration
+├── tsconfig.json # TypeScript configuration
+└── package.json # Dependencies and scripts
+
+## Prerequisites
+
+- Node.js 20, 22, or >=24
+- Dev server running on `http://localhost:3010` (or set `BASE_URL` env var)
+
+## Installation
+
+Install dependencies:
+
+```bash
+cd e2e
+pnpm install
+````
+
+Install Playwright browsers:
+
+```bash
+npx playwright install chromium
+```
+
+## Running Tests
+
+Run all tests:
+
+```bash
+npm test
+```
+
+Run tests in headed mode (see browser):
+
+```bash
+npm run test:headed
+```
+
+Run only smoke tests:
+
+```bash
+npm run test:smoke
+```
+
+Run discover tests:
+
+```bash
+npm run test:discover
+```
+
+## Environment Variables
+
+- `BASE_URL`: Base URL for the application (default: `http://localhost:3010`)
+- `PORT`: Port number (default: `3010`)
+- `HEADLESS`: Run browser in headless mode (default: `true`, set to `false` to see browser)
+
+Example:
+
+```bash
+HEADLESS=false BASE_URL=http://localhost:3000 npm run test:smoke
+```
+
+## Writing Tests
+
+### Feature Files
+
+Feature files are written in Gherkin syntax and placed in the `src/features/` directory:
+
+```gherkin
+@discover @smoke
+Feature: Discover Smoke Tests
+ Critical path tests to ensure the discover module is functional
+
+ @DISCOVER-SMOKE-001 @P0
+ Scenario: Load discover assistant list page
+ Given I navigate to "/discover/assistant"
+ Then the page should load without errors
+ And I should see the page body
+ And I should see the search bar
+ And I should see assistant cards
+```
+
+### Step Definitions
+
+Step definitions are TypeScript files in the `src/steps/` directory that implement the steps from feature files:
+
+```typescript
+import { Given, Then } from '@cucumber/cucumber';
+import { expect } from '@playwright/test';
+
+import { CustomWorld } from '../../support/world';
+
+Given('I navigate to {string}', async function (this: CustomWorld, path: string) {
+ await this.page.goto(path);
+ await this.page.waitForLoadState('domcontentloaded');
+});
+```
+
+## Test Reports
+
+After running tests, HTML and JSON reports are generated in the `reports/` directory:
+
+- `reports/cucumber-report.html` - Human-readable HTML report
+- `reports/cucumber-report.json` - Machine-readable JSON report
+
+## Troubleshooting
+
+### Browser not found
+
+If you see errors about missing browser executables:
+
+```bash
+npx playwright install chromium
+```
+
+### Port already in use
+
+Make sure the dev server is running on the expected port (3010 by default), or set `PORT` or `BASE_URL` environment variable.
+
+### Test timeout
+
+Increase timeout in `cucumber.config.js` or `src/steps/hooks.ts`:
+
+```typescript
+setDefaultTimeout(120000); // 2 minutes
+```
diff --git a/e2e/cucumber.config.js b/e2e/cucumber.config.js
new file mode 100644
index 00000000000..220bebfeb39
--- /dev/null
+++ b/e2e/cucumber.config.js
@@ -0,0 +1,20 @@
+/**
+ * @type {import('@cucumber/cucumber').IConfiguration}
+ */
+export default {
+ format: [
+ 'progress-bar',
+ 'html:reports/cucumber-report.html',
+ 'json:reports/cucumber-report.json',
+ ],
+ formatOptions: {
+ snippetInterface: 'async-await',
+ },
+ parallel: process.env.CI ? 1 : 4,
+ paths: ['src/features/**/*.feature'],
+ publishQuiet: true,
+ require: ['src/steps/**/*.ts', 'src/support/**/*.ts'],
+ requireModule: ['tsx/cjs'],
+ retry: 0,
+ timeout: 120_000,
+};
diff --git a/e2e/package.json b/e2e/package.json
new file mode 100644
index 00000000000..2d56f160632
--- /dev/null
+++ b/e2e/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@lobechat/e2e-tests",
+ "version": "0.1.0",
+ "private": true,
+ "description": "E2E tests for LobeChat using Cucumber and Playwright",
+ "scripts": {
+ "test": "cucumber-js --config cucumber.config.js",
+ "test:discover": "cucumber-js --config cucumber.config.js src/features/discover/",
+ "test:headed": "HEADLESS=false cucumber-js --config cucumber.config.js",
+ "test:routes": "cucumber-js --config cucumber.config.js --tags '@routes'",
+ "test:routes:ci": "cucumber-js --config cucumber.config.js --tags '@routes and not @ci-skip'",
+ "test:smoke": "cucumber-js --config cucumber.config.js --tags '@smoke'"
+ },
+ "dependencies": {
+ "@cucumber/cucumber": "^12.2.0",
+ "@playwright/test": "^1.56.1",
+ "playwright": "^1.56.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.5",
+ "tsx": "^4.20.6",
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts
deleted file mode 100644
index 8885ff3e407..00000000000
--- a/e2e/routes.spec.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { expect, test } from '@playwright/test';
-
-// 覆盖核心可访问路径(含重定向来源)
-const baseRoutes: string[] = [
- '/',
- '/chat',
- '/discover',
- '/image',
- '/files',
- '/repos', // next.config.ts -> /files
- '/changelog',
-];
-
-// settings 路由改为通过 query 参数控制 active tab
-// 参考 SettingsTabs: about, agent, common, hotkey, llm, provider, proxy, storage, system-agent, tts
-const settingsTabs = [
- 'common',
- 'llm',
- 'provider',
- 'about',
- 'hotkey',
- 'proxy',
- 'storage',
- 'tts',
- 'system-agent',
- 'agent',
-];
-
-const routes: string[] = [...baseRoutes, ...settingsTabs.map((key) => `/settings?active=${key}`)];
-
-// CI 环境下跳过容易不稳定或受特性开关影响的路由
-const ciSkipPaths = new Set([
- '/image',
- '/changelog',
- '/settings?active=common',
- '/settings?active=llm',
-]);
-
-// @ts-ignore
-async function assertNoPageErrors(page: Parameters[0]['page']) {
- const pageErrors: Error[] = [];
- const consoleErrors: string[] = [];
-
- page.on('pageerror', (err: Error) => pageErrors.push(err));
- page.on('console', (msg: any) => {
- if (msg.type() === 'error') consoleErrors.push(msg.text());
- });
-
- // 仅校验页面级错误,忽略控制台 error 以提升稳定性
- expect
- .soft(pageErrors, `page errors: ${pageErrors.map((e) => e.message).join('\n')}`)
- .toHaveLength(0);
-}
-
-test.describe('Smoke: core routes', () => {
- for (const path of routes) {
- test(`should open ${path} without error`, async ({ page }) => {
- if (process.env.CI && ciSkipPaths.has(path)) test.skip(true, 'skip flaky route on CI');
- const response = await page.goto(path, { waitUntil: 'commit' });
- // 2xx 或 3xx 视为可接受(允许中间件/重定向)
- const status = response?.status() ?? 0;
- expect(status, `unexpected status for ${path}: ${status}`).toBeLessThan(400);
-
- // 一般错误标题防御
- await expect(page).not.toHaveTitle(/not found|error/i);
-
- // body 可见
- await expect(page.locator('body')).toBeVisible();
-
- await assertNoPageErrors(page);
- });
- }
-});
diff --git a/e2e/src/features/discover/smoke.feature b/e2e/src/features/discover/smoke.feature
new file mode 100644
index 00000000000..e7ab5bf176b
--- /dev/null
+++ b/e2e/src/features/discover/smoke.feature
@@ -0,0 +1,11 @@
+@discover @smoke
+Feature: Discover Smoke Tests
+ Critical path tests to ensure the discover module is functional
+
+ @DISCOVER-SMOKE-001 @P0
+ Scenario: Load discover assistant list page
+ Given I navigate to "/discover/assistant"
+ Then the page should load without errors
+ And I should see the page body
+ And I should see the search bar
+ And I should see assistant cards
diff --git a/e2e/src/features/routes/core-routes.feature b/e2e/src/features/routes/core-routes.feature
new file mode 100644
index 00000000000..3555aadad22
--- /dev/null
+++ b/e2e/src/features/routes/core-routes.feature
@@ -0,0 +1,43 @@
+@routes @smoke
+Feature: Core Routes Accessibility
+ As a user
+ I want all core application routes to be accessible
+ So that I can navigate the application without errors
+
+ Background:
+ Given the application is running
+
+ @ROUTES-001 @P0
+ Scenario Outline: Access core routes without errors
+ When I navigate to ""
+ Then the response status should be less than 400
+ And the page should load without errors
+ And I should see the page body
+ And the page title should not contain "error" or "not found"
+
+ Examples:
+ | route |
+ | / |
+ | /chat |
+ | /discover |
+ | /files |
+ | /repos |
+
+ @ROUTES-002 @P0
+ Scenario Outline: Access settings routes without errors
+ When I navigate to "/settings?active="
+ Then the response status should be less than 400
+ And the page should load without errors
+ And I should see the page body
+ And the page title should not contain "error" or "not found"
+
+ Examples:
+ | tab |
+ | about |
+ | agent |
+ | hotkey |
+ | provider |
+ | proxy |
+ | storage |
+ | system-agent |
+ | tts |
diff --git a/e2e/src/steps/common/navigation.steps.ts b/e2e/src/steps/common/navigation.steps.ts
new file mode 100644
index 00000000000..ba8c98ad737
--- /dev/null
+++ b/e2e/src/steps/common/navigation.steps.ts
@@ -0,0 +1,36 @@
+import { Given, Then } from '@cucumber/cucumber';
+import { expect } from '@playwright/test';
+
+import { CustomWorld } from '../../support/world';
+
+// ============================================
+// Given Steps (Preconditions)
+// ============================================
+
+Given('I navigate to {string}', async function (this: CustomWorld, path: string) {
+ const response = await this.page.goto(path, { waitUntil: 'commit' });
+ this.testContext.lastResponse = response;
+ await this.page.waitForLoadState('domcontentloaded');
+});
+
+// ============================================
+// Then Steps (Assertions)
+// ============================================
+
+Then('the page should load without errors', async function (this: CustomWorld) {
+ // Check for no JavaScript errors
+ expect(this.testContext.jsErrors).toHaveLength(0);
+
+ // Check page didn't navigate to error page
+ const url = this.page.url();
+ expect(url).not.toMatch(/\/404|\/error|not-found/i);
+
+ // Check no error title
+ const title = await this.page.title();
+ expect(title).not.toMatch(/not found|error/i);
+});
+
+Then('I should see the page body', async function (this: CustomWorld) {
+ const body = this.page.locator('body');
+ await expect(body).toBeVisible();
+});
diff --git a/e2e/src/steps/discover/smoke.steps.ts b/e2e/src/steps/discover/smoke.steps.ts
new file mode 100644
index 00000000000..a9807b8c27d
--- /dev/null
+++ b/e2e/src/steps/discover/smoke.steps.ts
@@ -0,0 +1,34 @@
+import { Then } from '@cucumber/cucumber';
+import { expect } from '@playwright/test';
+
+import { CustomWorld } from '../../support/world';
+
+// ============================================
+// Then Steps (Assertions)
+// ============================================
+
+Then('I should see the search bar', async function (this: CustomWorld) {
+ // Wait for network to be idle to ensure Suspense components are loaded
+ await this.page.waitForLoadState('networkidle', { timeout: 120_000 });
+
+ // The SearchBar component from @lobehub/ui may not pass through data-testid
+ // Try to find the input element within the search component
+ const searchBar = this.page.locator('input[type="text"]').first();
+ await expect(searchBar).toBeVisible({ timeout: 120_000 });
+});
+
+Then('I should see assistant cards', async function (this: CustomWorld) {
+ // Wait for content to load
+ await this.page.waitForLoadState('networkidle', { timeout: 120_000 });
+
+ // After migrating to SPA (react-router), links use relative paths like /assistant/:id
+ // Look for assistant items by data-testid instead of href
+ const assistantItems = this.page.locator('[data-testid="assistant-item"]');
+
+ // Wait for at least one item to be visible
+ await expect(assistantItems.first()).toBeVisible({ timeout: 120_000 });
+
+ // Check we have multiple items
+ const count = await assistantItems.count();
+ expect(count).toBeGreaterThan(0);
+});
diff --git a/e2e/src/steps/hooks.ts b/e2e/src/steps/hooks.ts
new file mode 100644
index 00000000000..2b8033b0f97
--- /dev/null
+++ b/e2e/src/steps/hooks.ts
@@ -0,0 +1,69 @@
+import { After, AfterAll, Before, BeforeAll, Status, setDefaultTimeout } from '@cucumber/cucumber';
+
+import { startWebServer, stopWebServer } from '../support/webServer';
+import { CustomWorld } from '../support/world';
+
+// Set default timeout for all steps to 120 seconds
+setDefaultTimeout(120_000);
+
+BeforeAll({ timeout: 120_000 }, async function () {
+ console.log('🚀 Starting E2E test suite...');
+
+ const PORT = process.env.PORT ? Number(process.env.PORT) : 3010;
+ const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
+
+ console.log(`Base URL: ${BASE_URL}`);
+
+ // Start web server if not using external BASE_URL
+ if (!process.env.BASE_URL) {
+ await startWebServer({
+ command: 'npm run dev',
+ port: PORT,
+ reuseExistingServer: !process.env.CI,
+ });
+ }
+});
+
+Before(async function (this: CustomWorld, { pickle }) {
+ await this.init();
+
+ const testId = pickle.tags.find((tag) => tag.name.startsWith('@DISCOVER-'));
+ console.log(`\n📝 Running: ${pickle.name}${testId ? ` (${testId.name.replace('@', '')})` : ''}`);
+});
+
+After(async function (this: CustomWorld, { pickle, result }) {
+ const testId = pickle.tags
+ .find((tag) => tag.name.startsWith('@DISCOVER-'))
+ ?.name.replace('@', '');
+
+ if (result?.status === Status.FAILED) {
+ const screenshot = await this.takeScreenshot(`${testId || 'failure'}-${Date.now()}`);
+ this.attach(screenshot, 'image/png');
+
+ const html = await this.page.content();
+ this.attach(html, 'text/html');
+
+ if (this.testContext.jsErrors.length > 0) {
+ const errors = this.testContext.jsErrors.map((e) => e.message).join('\n');
+ this.attach(`JavaScript Errors:\n${errors}`, 'text/plain');
+ }
+
+ console.log(`❌ Failed: ${pickle.name}`);
+ if (result.message) {
+ console.log(` Error: ${result.message}`);
+ }
+ } else if (result?.status === Status.PASSED) {
+ console.log(`✅ Passed: ${pickle.name}`);
+ }
+
+ await this.cleanup();
+});
+
+AfterAll(async function () {
+ console.log('\n🏁 Test suite completed');
+
+ // Stop web server if we started it
+ if (!process.env.BASE_URL && process.env.CI) {
+ await stopWebServer();
+ }
+});
diff --git a/e2e/src/steps/routes/routes.steps.ts b/e2e/src/steps/routes/routes.steps.ts
new file mode 100644
index 00000000000..6d1aa7c584b
--- /dev/null
+++ b/e2e/src/steps/routes/routes.steps.ts
@@ -0,0 +1,41 @@
+import { Given, Then } from '@cucumber/cucumber';
+import { expect } from '@playwright/test';
+
+import { CustomWorld } from '../../support/world';
+
+// ============================================
+// Given Steps (Preconditions)
+// ============================================
+
+Given('the application is running', async function (this: CustomWorld) {
+ // This is a placeholder step to indicate that the app should be running
+ // The actual server startup is handled outside the test (in CI or locally)
+ // We just verify we can reach the base URL
+ const response = await this.page.goto('/');
+ expect(response).toBeTruthy();
+ // Store the response for later assertions
+ this.testContext.lastResponse = response;
+});
+
+// ============================================
+// Then Steps (Assertions)
+// ============================================
+
+Then(
+ 'the response status should be less than {int}',
+ async function (this: CustomWorld, maxStatus: number) {
+ const status = this.testContext.lastResponse?.status() ?? 0;
+ expect(status, `Expected status < ${maxStatus}, but got ${status}`).toBeLessThan(maxStatus);
+ },
+);
+
+Then(
+ 'the page title should not contain {string} or {string}',
+ async function (this: CustomWorld, text1: string, text2: string) {
+ const title = await this.page.title();
+ const regex = new RegExp(`${text1}|${text2}`, 'i');
+ expect(title, `Page title "${title}" should not contain "${text1}" or "${text2}"`).not.toMatch(
+ regex,
+ );
+ },
+);
diff --git a/e2e/src/support/webServer.ts b/e2e/src/support/webServer.ts
new file mode 100644
index 00000000000..977a4428033
--- /dev/null
+++ b/e2e/src/support/webServer.ts
@@ -0,0 +1,96 @@
+import { type ChildProcess, exec } from 'node:child_process';
+import { resolve } from 'node:path';
+
+let serverProcess: ChildProcess | null = null;
+let serverStartPromise: Promise | null = null;
+
+interface WebServerOptions {
+ command: string;
+ env?: Record;
+ port: number;
+ reuseExistingServer?: boolean;
+ timeout?: number;
+}
+
+async function isServerRunning(port: number): Promise {
+ try {
+ const response = await fetch(`http://localhost:${port}/chat`, {
+ method: 'HEAD',
+ });
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
+
+export async function startWebServer(options: WebServerOptions): Promise {
+ const { command, port, timeout = 120_000, env = {}, reuseExistingServer = true } = options;
+
+ // If server is already being started by another worker, wait for it
+ if (serverStartPromise) {
+ console.log(`⏳ Waiting for server to start (started by another worker)...`);
+ return serverStartPromise;
+ }
+
+ // Check if server is already running
+ if (reuseExistingServer && (await isServerRunning(port))) {
+ console.log(`✅ Reusing existing server on port ${port}`);
+ return;
+ }
+
+ // Create a promise for the server startup and store it
+ serverStartPromise = (async () => {
+ console.log(`🚀 Starting web server: ${command}`);
+
+ // Get the project root directory (parent of e2e folder)
+ const projectRoot = resolve(__dirname, '../../..');
+
+ // Start the server process
+ serverProcess = exec(command, {
+ cwd: projectRoot,
+ env: {
+ ...process.env,
+ ENABLE_AUTH_PROTECTION: '0',
+ ENABLE_OIDC: '0',
+ NEXT_PUBLIC_ENABLE_CLERK_AUTH: '0',
+ NEXT_PUBLIC_ENABLE_NEXT_AUTH: '0',
+ NODE_OPTIONS: '--max-old-space-size=6144',
+ PORT: String(port),
+ ...env,
+ },
+ });
+
+ // Forward server output to console for debugging
+ serverProcess.stdout?.on('data', (data) => {
+ console.log(`[server] ${data}`);
+ });
+
+ serverProcess.stderr?.on('data', (data) => {
+ console.error(`[server] ${data}`);
+ });
+
+ // Wait for server to be ready
+ const startTime = Date.now();
+ while (!(await isServerRunning(port))) {
+ if (Date.now() - startTime > timeout) {
+ throw new Error(`Server failed to start within ${timeout}ms`);
+ }
+ await new Promise((resolve) => {
+ setTimeout(resolve, 1000);
+ });
+ }
+
+ console.log(`✅ Web server is ready on port ${port}`);
+ })();
+
+ return serverStartPromise;
+}
+
+export async function stopWebServer(): Promise {
+ if (serverProcess) {
+ console.log('🛑 Stopping web server...');
+ serverProcess.kill();
+ serverProcess = null;
+ serverStartPromise = null;
+ }
+}
diff --git a/e2e/src/support/world.ts b/e2e/src/support/world.ts
new file mode 100644
index 00000000000..3d4cf650c5b
--- /dev/null
+++ b/e2e/src/support/world.ts
@@ -0,0 +1,76 @@
+import { IWorldOptions, World, setWorldConstructor } from '@cucumber/cucumber';
+import { Browser, BrowserContext, Page, Response, chromium } from '@playwright/test';
+
+export interface TestContext {
+ [key: string]: any;
+ consoleErrors: string[];
+ jsErrors: Error[];
+ lastResponse?: Response | null;
+ previousUrl?: string;
+}
+
+export class CustomWorld extends World {
+ browser!: Browser;
+ browserContext!: BrowserContext;
+ page!: Page;
+ testContext: TestContext;
+
+ constructor(options: IWorldOptions) {
+ super(options);
+ this.testContext = {
+ consoleErrors: [],
+ jsErrors: [],
+ };
+ }
+
+ // Getter for easier access
+ get context(): TestContext {
+ return this.testContext;
+ }
+
+ async init() {
+ const PORT = process.env.PORT ? Number(process.env.PORT) : 3010;
+ const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
+
+ this.browser = await chromium.launch({
+ headless: process.env.HEADLESS !== 'false',
+ });
+
+ this.browserContext = await this.browser.newContext({
+ baseURL: BASE_URL,
+ viewport: { height: 720, width: 1280 },
+ });
+
+ // Set expect timeout for assertions (e.g., toBeVisible, toHaveText)
+ this.browserContext.setDefaultTimeout(120_000);
+
+ this.page = await this.browserContext.newPage();
+
+ // Set up error listeners
+ this.page.on('pageerror', (error) => {
+ this.testContext.jsErrors.push(error);
+ console.error('Page error:', error.message);
+ });
+
+ this.page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ this.testContext.consoleErrors.push(msg.text());
+ }
+ });
+
+ this.page.setDefaultTimeout(120_000);
+ }
+
+ async cleanup() {
+ await this.page?.close();
+ await this.browserContext?.close();
+ await this.browser?.close();
+ }
+
+ async takeScreenshot(name: string): Promise {
+ console.log(name);
+ return await this.page.screenshot({ fullPage: true });
+ }
+}
+
+setWorldConstructor(CustomWorld);
diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json
new file mode 100644
index 00000000000..e3b8b712a9c
--- /dev/null
+++ b/e2e/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "moduleResolution": "node",
+ "target": "ES2020",
+ "lib": ["ES2020"],
+ "types": ["node", "@cucumber/cucumber", "@playwright/test"],
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["../*"]
+ }
+ },
+ "exclude": ["node_modules", "reports"],
+ "extends": "../tsconfig.json",
+ "include": ["src/**/*", "*.js"]
+}
diff --git a/package.json b/package.json
index 8774f05520c..8b03919dde8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@lobehub/chat",
- "version": "1.141.4",
+ "version": "1.141.6",
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
"keywords": [
"framework",
@@ -26,7 +26,8 @@
"author": "LobeHub ",
"sideEffects": false,
"workspaces": [
- "packages/*"
+ "packages/*",
+ "e2e"
],
"scripts": {
"prebuild": "tsx scripts/prebuild.mts && npm run lint",
@@ -54,7 +55,7 @@
"dev:mobile": "next dev --turbopack -p 3018",
"docs:i18n": "lobe-i18n md && npm run lint:md && npm run lint:mdx && prettier -c --write locales/**/*",
"docs:seo": "lobe-seo && npm run lint:mdx",
- "e2e": "playwright test",
+ "e2e": "cd e2e && npm run test:smoke",
"e2e:install": "playwright install",
"e2e:ui": "playwright test --ui",
"i18n": "npm run workflow:i18n && lobe-i18n && prettier -c --write \"locales/**\"",
@@ -80,6 +81,8 @@
"test": "npm run test-app && npm run test-server",
"test-app": "vitest run",
"test-app:coverage": "vitest --coverage --silent='passed-only'",
+ "test:e2e": "pnpm --filter @lobechat/e2e-tests test",
+ "test:e2e:smoke": "pnpm --filter @lobechat/e2e-tests test:smoke",
"test:update": "vitest -u",
"type-check": "tsgo --noEmit",
"webhook:ngrok": "ngrok http http://localhost:3011",
@@ -266,7 +269,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",
diff --git a/packages/electron-client-ipc/src/events/index.ts b/packages/electron-client-ipc/src/events/index.ts
index 6de3a54e290..b9788e7d219 100644
--- a/packages/electron-client-ipc/src/events/index.ts
+++ b/packages/electron-client-ipc/src/events/index.ts
@@ -50,3 +50,5 @@ export type MainBroadcastEventKey = keyof MainBroadcastEvents;
export type MainBroadcastParams = Parameters<
MainBroadcastEvents[T]
>[0];
+
+export type { OpenSettingsWindowOptions } from './windows';
diff --git a/packages/electron-client-ipc/src/events/windows.ts b/packages/electron-client-ipc/src/events/windows.ts
index 14191590a99..093b127fec6 100644
--- a/packages/electron-client-ipc/src/events/windows.ts
+++ b/packages/electron-client-ipc/src/events/windows.ts
@@ -1,44 +1,50 @@
import { InterceptRouteParams, InterceptRouteResponse } from '../types/route';
+export interface OpenSettingsWindowOptions {
+ /**
+ * Query parameters that should be appended to the settings URL.
+ */
+ searchParams?: Record;
+ /**
+ * 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 {
/**
- * 拦截客户端路由导航请求
- * @param params 包含路径和来源信息的参数对象
- * @returns 路由拦截结果
- */
- interceptRoute: (params: InterceptRouteParams) => InterceptRouteResponse;
-
- /**
- * open the LobeHub Devtools
+ * Close all windows by template
+ * @param templateId Template identifier
+ * @returns Operation result
*/
- openDevtools: () => void;
-
- openSettingsWindow: (tab?: string) => void;
+ 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;
+ createMultiInstanceWindow: (
+ params: CreateMultiInstanceWindowParams,
+ ) => CreateMultiInstanceWindowResponse;
/**
* Get all windows by template
@@ -48,9 +54,16 @@ export interface WindowsDispatchEvents {
getWindowsByTemplate: (templateId: string) => GetWindowsByTemplateResponse;
/**
- * Close all windows by template
- * @param templateId Template identifier
- * @returns Operation result
+ * 拦截客户端路由导航请求
+ * @param params 包含路径和来源信息的参数对象
+ * @returns 路由拦截结果
*/
- closeWindowsByTemplate: (templateId: string) => { success: boolean; error?: string };
+ interceptRoute: (params: InterceptRouteParams) => InterceptRouteResponse;
+
+ /**
+ * open the LobeHub Devtools
+ */
+ openDevtools: () => void;
+
+ openSettingsWindow: (options?: OpenSettingsWindowOptions | string) => void;
}
diff --git a/packages/utils/src/server/responsive.ts b/packages/utils/src/server/responsive.ts
index ec7e51eb4fa..7f6d5c929ed 100644
--- a/packages/utils/src/server/responsive.ts
+++ b/packages/utils/src/server/responsive.ts
@@ -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');
}
diff --git a/playwright.config.ts b/playwright.config.ts
deleted file mode 100644
index 017fa29ced3..00000000000
--- a/playwright.config.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { defineConfig, devices } from '@playwright/test';
-
-const PORT = process.env.PORT ? Number(process.env.PORT) : 3010;
-
-export default defineConfig({
- expect: { timeout: 10_000 },
- fullyParallel: true,
- projects: [
- {
- name: 'chromium',
- use: { ...devices['Desktop Chrome'] },
- },
- ],
- reporter: 'list',
- retries: 0,
- testDir: './e2e',
- timeout: 60_000,
- use: {
- baseURL: `http://localhost:${PORT}`,
- trace: 'on-first-retry',
- },
- webServer: {
- command: 'npm run dev',
- env: {
- ENABLE_AUTH_PROTECTION: '0',
- ENABLE_OIDC: '0',
- NEXT_PUBLIC_ENABLE_CLERK_AUTH: '0',
- NEXT_PUBLIC_ENABLE_NEXT_AUTH: '0',
- NODE_OPTIONS: '--max-old-space-size=6144',
- },
- reuseExistingServer: true,
- timeout: 120_000,
- url: `http://localhost:${PORT}/chat`,
- },
-});
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 466721bc4ce..adf9741840c 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,4 +1,5 @@
packages:
- 'packages/**'
- '.'
+ - 'e2e'
- '!apps/**'
diff --git a/src/app/(backend)/trpc/desktop/[trpc]/route.ts b/src/app/(backend)/trpc/desktop/[trpc]/route.ts
index 8f82dc50953..987ca4b8650 100644
--- a/src/app/(backend)/trpc/desktop/[trpc]/route.ts
+++ b/src/app/(backend)/trpc/desktop/[trpc]/route.ts
@@ -20,6 +20,11 @@ const handler = (req: NextRequest) =>
},
req,
+ responseMeta({ ctx }) {
+ const headers = ctx?.resHeaders;
+
+ return { headers };
+ },
router: desktopRouter,
});
diff --git a/src/app/[variants]/(main)/discover/(detail)/_layout/DetailLayout.tsx b/src/app/[variants]/(main)/discover/(detail)/_layout/DetailLayout.tsx
new file mode 100644
index 00000000000..bfeafa61e45
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(detail)/_layout/DetailLayout.tsx
@@ -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(({ children, mobile }) => {
+ if (mobile) {
+ return {children};
+ }
+
+ return {children};
+});
+
+DetailLayout.displayName = 'DetailLayout';
+
+export default DetailLayout;
diff --git a/src/app/[variants]/(main)/discover/(detail)/_layout/Mobile/Header.tsx b/src/app/[variants]/(main)/discover/(detail)/_layout/Mobile/Header.tsx
index 9cec918152f..2aa9c144231 100644
--- a/src/app/[variants]/(main)/discover/(detail)/_layout/Mobile/Header.tsx
+++ b/src/app/[variants]/(main)/discover/(detail)/_layout/Mobile/Header.tsx
@@ -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 (
router.push(urlJoin('/discover', path))}
+ onBackClick={() => navigate(`/${path}`)}
showBackButton
style={mobileHeaderSticky}
/>
diff --git a/src/app/[variants]/(main)/discover/(detail)/assistant/AssistantDetailPage.tsx b/src/app/[variants]/(main)/discover/(detail)/assistant/AssistantDetailPage.tsx
new file mode 100644
index 00000000000..bb24a0a34d8
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(detail)/assistant/AssistantDetailPage.tsx
@@ -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(({ 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 ;
+ if (!data) return ;
+
+ return (
+
+
+ {!mobile && }
+
+
+
+
+
+
+ );
+});
+
+export default withSuspense(AssistantDetailPage);
diff --git a/src/app/[variants]/(main)/discover/(detail)/assistant/[...slugs]/features/Header.tsx b/src/app/[variants]/(main)/discover/(detail)/assistant/[...slugs]/features/Header.tsx
index 98dc963793b..a0dc252f72c 100644
--- a/src/app/[variants]/(main)/discover/(detail)/assistant/[...slugs]/features/Header.tsx
+++ b/src/app/[variants]/(main)/discover/(detail)/assistant/[...slugs]/features/Header.tsx
@@ -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 = (
-
-
+
);
return (
@@ -105,7 +106,7 @@ const Header = memo<{ mobile?: boolean }>(({ mobile: isMobile }) => {
- (({ mobile: isMobile }) => {
target={'_blank'}
>
-
+
{author && (
-
+
{author}
-
+
)}
{
{related?.map((item, index) => {
- const link = urlJoin('/discover/assistant', item.identifier);
+ const link = urlJoin('/assistant', item.identifier);
return (
-
+
);
diff --git a/src/app/[variants]/(main)/discover/(detail)/assistant/[...slugs]/page.tsx b/src/app/[variants]/(main)/discover/(detail)/assistant/[...slugs]/page.tsx
deleted file mode 100644
index 292b558345e..00000000000
--- a/src/app/[variants]/(main)/discover/(detail)/assistant/[...slugs]/page.tsx
+++ /dev/null
@@ -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 (
- <>
-
- {!isMobile && }
-
- >
- );
-};
-
-export const generateStaticParams = async () => [];
-
-Page.DisplayName = 'DiscoverAssistantsDetail';
-
-export default Page;
diff --git a/src/app/[variants]/(main)/discover/(detail)/components/NotFound.tsx b/src/app/[variants]/(main)/discover/(detail)/components/NotFound.tsx
new file mode 100644
index 00000000000..7f0ce59e411
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(detail)/components/NotFound.tsx
@@ -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 (
+
+ {t('title')}
+
+ );
+});
+
+export default NotFound;
diff --git a/src/app/[variants]/(main)/discover/(detail)/features/Back.tsx b/src/app/[variants]/(main)/discover/(detail)/features/Back.tsx
index 330a520b82a..0b997608b55 100644
--- a/src/app/[variants]/(main)/discover/(detail)/features/Back.tsx
+++ b/src/app/[variants]/(main)/discover/(detail)/features/Back.tsx
@@ -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 (
-
+
{t(`back`)}
diff --git a/src/app/[variants]/(main)/discover/(detail)/features/Breadcrumb.tsx b/src/app/[variants]/(main)/discover/(detail)/features/Breadcrumb.tsx
index ecb30f44f4d..92327a6a1c5 100644
--- a/src/app/[variants]/(main)/discover/(detail)/features/Breadcrumb.tsx
+++ b/src/app/[variants]/(main)/discover/(detail)/features/Breadcrumb.tsx
@@ -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
Discover,
+ title: Discover,
},
{
title: (
-
+
{tab === DiscoverTab.Mcp ? 'MCP Servers' : t(`tab.${tab}` as any)}
),
diff --git a/src/app/[variants]/(main)/discover/(detail)/layout.tsx b/src/app/[variants]/(main)/discover/(detail)/layout.tsx
deleted file mode 100644
index 9e846916552..00000000000
--- a/src/app/[variants]/(main)/discover/(detail)/layout.tsx
+++ /dev/null
@@ -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({ Desktop, Mobile });
-
-MainLayout.displayName = 'DiscoverAssistantsDetailLayout';
-
-export default MainLayout;
diff --git a/src/app/[variants]/(main)/discover/(detail)/mcp/McpDetailPage.tsx b/src/app/[variants]/(main)/discover/(detail)/mcp/McpDetailPage.tsx
new file mode 100644
index 00000000000..0017295f6f9
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(detail)/mcp/McpDetailPage.tsx
@@ -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(({ 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 ;
+ if (!data) return ;
+
+ return (
+
+
+ {!mobile && }
+
+
+
+
+
+
+ );
+});
+
+export default withSuspense(McpDetailPage);
diff --git a/src/app/[variants]/(main)/discover/(detail)/mcp/[slug]/features/Sidebar/Related/index.tsx b/src/app/[variants]/(main)/discover/(detail)/mcp/[slug]/features/Sidebar/Related/index.tsx
index 798e997b2cd..e265fb85084 100644
--- a/src/app/[variants]/(main)/discover/(detail)/mcp/[slug]/features/Sidebar/Related/index.tsx
+++ b/src/app/[variants]/(main)/discover/(detail)/mcp/[slug]/features/Sidebar/Related/index.tsx
@@ -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(() => {
{related?.map((item, index) => {
- const link = urlJoin('/discover/mcp', item.identifier);
+ const link = urlJoin('/mcp', item.identifier);
return (
-
+
);
diff --git a/src/app/[variants]/(main)/discover/(detail)/mcp/[slug]/page.tsx b/src/app/[variants]/(main)/discover/(detail)/mcp/[slug]/page.tsx
deleted file mode 100644
index 0c4da01f9ee..00000000000
--- a/src/app/[variants]/(main)/discover/(detail)/mcp/[slug]/page.tsx
+++ /dev/null
@@ -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 && }
-
- >
- );
-};
-
-Page.displayName = 'DiscoverMCPDetail';
-
-export default Page;
diff --git a/src/app/[variants]/(main)/discover/(detail)/model/ModelDetailPage.tsx b/src/app/[variants]/(main)/discover/(detail)/model/ModelDetailPage.tsx
new file mode 100644
index 00000000000..2d8c45ffa60
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(detail)/model/ModelDetailPage.tsx
@@ -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(({ 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 ;
+ if (!data) return ;
+
+ return (
+
+ {!mobile && }
+
+
+
+
+
+ );
+});
+
+export default withSuspense(ModelDetailPage);
diff --git a/src/app/[variants]/(main)/discover/(detail)/model/[...slugs]/features/Sidebar/Related/index.tsx b/src/app/[variants]/(main)/discover/(detail)/model/[...slugs]/features/Sidebar/Related/index.tsx
index 2cd51e72750..cce17369a67 100644
--- a/src/app/[variants]/(main)/discover/(detail)/model/[...slugs]/features/Sidebar/Related/index.tsx
+++ b/src/app/[variants]/(main)/discover/(detail)/model/[...slugs]/features/Sidebar/Related/index.tsx
@@ -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(() => {
{related?.map((item, index) => {
- const link = urlJoin('/discover/model', item.identifier);
+ const link = urlJoin('/model', item.identifier);
return (
-
+
);
diff --git a/src/app/[variants]/(main)/discover/(detail)/model/[...slugs]/page.tsx b/src/app/[variants]/(main)/discover/(detail)/model/[...slugs]/page.tsx
deleted file mode 100644
index 5f0fe4d7459..00000000000
--- a/src/app/[variants]/(main)/discover/(detail)/model/[...slugs]/page.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
- >
- );
-};
-
-Page.DisplayName = 'DiscoverModelDetail';
-
-export default Page;
diff --git a/src/app/[variants]/(main)/discover/(detail)/provider/ProviderDetailPage.tsx b/src/app/[variants]/(main)/discover/(detail)/provider/ProviderDetailPage.tsx
new file mode 100644
index 00000000000..0201923a0d3
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(detail)/provider/ProviderDetailPage.tsx
@@ -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(({ 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 ;
+ if (!data) return ;
+
+ return (
+
+ {!mobile && }
+
+
+
+
+
+ );
+});
+
+export default withSuspense(ProviderDetailPage);
diff --git a/src/app/[variants]/(main)/discover/(detail)/provider/[...slugs]/features/Sidebar/ActionButton/ProviderConfig.tsx b/src/app/[variants]/(main)/discover/(detail)/provider/[...slugs]/features/Sidebar/ActionButton/ProviderConfig.tsx
index 08f34e05df0..81a3144591b 100644
--- a/src/app/[variants]/(main)/discover/(detail)/provider/[...slugs]/features/Sidebar/ActionButton/ProviderConfig.tsx
+++ b/src/app/[variants]/(main)/discover/(detail)/provider/[...slugs]/features/Sidebar/ActionButton/ProviderConfig.tsx
@@ -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'
diff --git a/src/app/[variants]/(main)/discover/(detail)/provider/[...slugs]/features/Sidebar/Related/index.tsx b/src/app/[variants]/(main)/discover/(detail)/provider/[...slugs]/features/Sidebar/Related/index.tsx
index 7e18682fdcc..b6e883b9316 100644
--- a/src/app/[variants]/(main)/discover/(detail)/provider/[...slugs]/features/Sidebar/Related/index.tsx
+++ b/src/app/[variants]/(main)/discover/(detail)/provider/[...slugs]/features/Sidebar/Related/index.tsx
@@ -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(() => {
{related?.map((item, index) => {
- const link = urlJoin('/discover/provider', item.identifier);
+ const link = urlJoin('/provider', item.identifier);
return (
-
+
);
diff --git a/src/app/[variants]/(main)/discover/(detail)/provider/[...slugs]/page.tsx b/src/app/[variants]/(main)/discover/(detail)/provider/[...slugs]/page.tsx
deleted file mode 100644
index cea26686ccd..00000000000
--- a/src/app/[variants]/(main)/discover/(detail)/provider/[...slugs]/page.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
- >
- );
-};
-
-Page.DisplayName = 'DiscoverProviderDetail';
-
-export default Page;
diff --git a/src/app/[variants]/(main)/discover/(list)/(home)/HomePage.tsx b/src/app/[variants]/(main)/discover/(list)/(home)/HomePage.tsx
new file mode 100644
index 00000000000..3dd3ac68a26
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(list)/(home)/HomePage.tsx
@@ -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 ;
+
+ return (
+ <>
+
+ {t('home.featuredAssistants')}
+
+
+
+
+ {t('home.featuredTools')}
+
+
+ >
+ );
+});
+
+export default HomePage;
diff --git a/src/app/[variants]/(main)/discover/(list)/(home)/page.tsx b/src/app/[variants]/(main)/discover/(list)/(home)/page.tsx
deleted file mode 100644
index 41674ea818e..00000000000
--- a/src/app/[variants]/(main)/discover/(list)/(home)/page.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
- >
- );
-};
-
-Page.DisplayName = 'DiscoverHome';
-
-export default Page;
diff --git a/src/app/[variants]/(main)/discover/(list)/_layout/Desktop/Nav.tsx b/src/app/[variants]/(main)/discover/(list)/_layout/Desktop/Nav.tsx
index 1f54d6c6b56..1886f9a5d75 100644
--- a/src/app/[variants]/(main)/discover/(list)/_layout/Desktop/Nav.tsx
+++ b/src/app/[variants]/(main)/discover/(list)/_layout/Desktop/Nav.tsx
@@ -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 (
@@ -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 });
diff --git a/src/app/[variants]/(main)/discover/(list)/_layout/ListLayout.tsx b/src/app/[variants]/(main)/discover/(list)/_layout/ListLayout.tsx
new file mode 100644
index 00000000000..fde7ad32a62
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(list)/_layout/ListLayout.tsx
@@ -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(({ children, mobile }) => {
+ if (mobile) {
+ return {children};
+ }
+
+ return {children};
+});
+
+ListLayout.displayName = 'ListLayout';
+
+export default ListLayout;
diff --git a/src/app/[variants]/(main)/discover/(list)/_layout/Mobile/Nav.tsx b/src/app/[variants]/(main)/discover/(list)/_layout/Mobile/Nav.tsx
index ce02ed01dac..20bb8110920 100644
--- a/src/app/[variants]/(main)/discover/(list)/_layout/Mobile/Nav.tsx
+++ b/src/app/[variants]/(main)/discover/(list)/_layout/Mobile/Nav.tsx
@@ -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
diff --git a/src/app/[variants]/(main)/discover/(list)/assistant/AssistantLayout.tsx b/src/app/[variants]/(main)/discover/(list)/assistant/AssistantLayout.tsx
new file mode 100644
index 00000000000..3a66b31ce3a
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(list)/assistant/AssistantLayout.tsx
@@ -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(({ children, mobile }) => {
+ if (mobile) {
+ return {children};
+ }
+ return {children};
+});
+
+AssistantLayout.displayName = 'AssistantLayout';
+
+export default AssistantLayout;
diff --git a/src/app/[variants]/(main)/discover/(list)/assistant/AssistantPage.tsx b/src/app/[variants]/(main)/discover/(list)/assistant/AssistantPage.tsx
new file mode 100644
index 00000000000..161c29c1d40
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(list)/assistant/AssistantPage.tsx
@@ -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 ;
+
+ const { items, currentPage, pageSize, totalCount } = data;
+
+ return (
+
+
+
+
+ );
+});
+
+export default withSuspense(AssistantPage);
diff --git a/src/app/[variants]/(main)/discover/(list)/assistant/features/Category/index.tsx b/src/app/[variants]/(main)/discover/(list)/assistant/features/Category/index.tsx
index daf8aa73660..cfc4ee83eac 100644
--- a/src/app/[variants]/(main)/discover/(list)/assistant/features/Category/index.tsx
+++ b/src/app/[variants]/(main)/discover/(list)/assistant/features/Category/index.tsx
@@ -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: ,
- label: {item.label},
+ label: {item.label},
};
})}
mode={'inline'}
diff --git a/src/app/[variants]/(main)/discover/(list)/assistant/features/List/Item.tsx b/src/app/[variants]/(main)/discover/(list)/assistant/features/List/Item.tsx
index fbabbaf096f..4320fa2b6da 100644
--- a/src/app/[variants]/(main)/discover/(list)/assistant/features/List/Item.tsx
+++ b/src/app/[variants]/(main)/discover/(list)/assistant/features/List/Item.tsx
@@ -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,16 +62,17 @@ const AssistantItem = memo(
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 (
{
- router.push(link);
+ navigate(link);
}}
style={{
overflow: 'hidden',
@@ -119,7 +119,7 @@ const AssistantItem = memo(
overflow: 'hidden',
}}
>
-
+
{title}
@@ -129,16 +129,17 @@ const AssistantItem = memo(
- e.stopPropagation()}
+ rel="noopener noreferrer"
target={'_blank'}
>
-
+
diff --git a/src/app/[variants]/(main)/discover/(list)/assistant/layout.tsx b/src/app/[variants]/(main)/discover/(list)/assistant/layout.tsx
deleted file mode 100644
index 98e75a6debc..00000000000
--- a/src/app/[variants]/(main)/discover/(list)/assistant/layout.tsx
+++ /dev/null
@@ -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({ Desktop, Mobile });
-
-MainLayout.displayName = 'DiscoverAssistantsLayout';
-
-export default MainLayout;
diff --git a/src/app/[variants]/(main)/discover/(list)/assistant/page.tsx b/src/app/[variants]/(main)/discover/(list)/assistant/page.tsx
deleted file mode 100644
index 77b130ecb4b..00000000000
--- a/src/app/[variants]/(main)/discover/(list)/assistant/page.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
- >
- );
-};
-
-Page.DisplayName = 'DiscoverAssistants';
-
-export default Page;
diff --git a/src/app/[variants]/(main)/discover/(list)/features/Pagination.tsx b/src/app/[variants]/(main)/discover/(list)/features/Pagination.tsx
index 986f43f883f..c31bb79fab0 100644
--- a/src/app/[variants]/(main)/discover/(list)/features/Pagination.tsx
+++ b/src/app/[variants]/(main)/discover/(list)/features/Pagination.tsx
@@ -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(({ 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 });
diff --git a/src/app/[variants]/(main)/discover/(list)/layout.tsx b/src/app/[variants]/(main)/discover/(list)/layout.tsx
deleted file mode 100644
index 2cb096e758a..00000000000
--- a/src/app/[variants]/(main)/discover/(list)/layout.tsx
+++ /dev/null
@@ -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({ Desktop, Mobile });
-
-MainLayout.displayName = 'DiscoverLayout';
-
-export default MainLayout;
diff --git a/src/app/[variants]/(main)/discover/(list)/mcp/McpLayout.tsx b/src/app/[variants]/(main)/discover/(list)/mcp/McpLayout.tsx
new file mode 100644
index 00000000000..b26f6badcc7
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(list)/mcp/McpLayout.tsx
@@ -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(({ children, mobile }) => {
+ if (mobile) {
+ return {children};
+ }
+ return {children};
+});
+
+McpLayout.displayName = 'McpLayout';
+
+export default McpLayout;
diff --git a/src/app/[variants]/(main)/discover/(list)/mcp/McpPage.tsx b/src/app/[variants]/(main)/discover/(list)/mcp/McpPage.tsx
new file mode 100644
index 00000000000..f1377b9d5ce
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(list)/mcp/McpPage.tsx
@@ -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 ;
+
+ const { items, currentPage, pageSize, totalCount } = data;
+
+ return (
+
+
+
+
+ );
+});
+
+export default withSuspense(McpPage);
diff --git a/src/app/[variants]/(main)/discover/(list)/mcp/features/Category/index.tsx b/src/app/[variants]/(main)/discover/(list)/mcp/features/Category/index.tsx
index c2bdb58a474..9da1fc47dd6 100644
--- a/src/app/[variants]/(main)/discover/(list)/mcp/features/Category/index.tsx
+++ b/src/app/[variants]/(main)/discover/(list)/mcp/features/Category/index.tsx
@@ -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: ,
- label: {item.label},
+ label: {item.label},
};
})}
mode={'inline'}
diff --git a/src/app/[variants]/(main)/discover/(list)/mcp/features/List/Item.tsx b/src/app/[variants]/(main)/discover/(list)/mcp/features/List/Item.tsx
index 5c640108c07..4b61e5c8684 100644
--- a/src/app/[variants]/(main)/discover/(list)/mcp/features/List/Item.tsx
+++ b/src/app/[variants]/(main)/discover/(list)/mcp/features/List/Item.tsx
@@ -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(
}) => {
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 (
{
- router.push(link);
+ navigate(link);
}}
style={{
overflow: 'hidden',
@@ -128,7 +127,7 @@ const McpItem = memo(
overflow: 'hidden',
}}
>
-
+
{name}
@@ -145,9 +144,14 @@ const McpItem = memo(
{installationMethods && }
{github && (
- e.stopPropagation()} target={'_blank'}>
+ e.stopPropagation()}
+ rel="noopener noreferrer"
+ target={'_blank'}
+ >
-
+
)}
diff --git a/src/app/[variants]/(main)/discover/(list)/mcp/layout.tsx b/src/app/[variants]/(main)/discover/(list)/mcp/layout.tsx
deleted file mode 100644
index d5482207f79..00000000000
--- a/src/app/[variants]/(main)/discover/(list)/mcp/layout.tsx
+++ /dev/null
@@ -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({ Desktop, Mobile });
-
-MainLayout.displayName = 'DiscoverToolsLayout';
-
-export default MainLayout;
diff --git a/src/app/[variants]/(main)/discover/(list)/mcp/page.tsx b/src/app/[variants]/(main)/discover/(list)/mcp/page.tsx
deleted file mode 100644
index 9075063a676..00000000000
--- a/src/app/[variants]/(main)/discover/(list)/mcp/page.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
- >
- );
-};
-
-Page.DisplayName = 'DiscoverMCP';
-
-export default Page;
diff --git a/src/app/[variants]/(main)/discover/(list)/model/ModelLayout.tsx b/src/app/[variants]/(main)/discover/(list)/model/ModelLayout.tsx
new file mode 100644
index 00000000000..7a87c027a89
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(list)/model/ModelLayout.tsx
@@ -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(({ children, mobile }) => {
+ if (mobile) {
+ return {children};
+ }
+ return {children};
+});
+
+ModelLayout.displayName = 'ModelLayout';
+
+export default ModelLayout;
diff --git a/src/app/[variants]/(main)/discover/(list)/model/ModelPage.tsx b/src/app/[variants]/(main)/discover/(list)/model/ModelPage.tsx
new file mode 100644
index 00000000000..98e434fab76
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(list)/model/ModelPage.tsx
@@ -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 ;
+
+ const { items, currentPage, pageSize, totalCount } = data;
+
+ return (
+
+
+
+
+ );
+});
+
+export default withSuspense(ModelPage);
diff --git a/src/app/[variants]/(main)/discover/(list)/model/_layout/Desktop.tsx b/src/app/[variants]/(main)/discover/(list)/model/_layout/Desktop.tsx
index a13845e1ac6..2cc6cc013b8 100644
--- a/src/app/[variants]/(main)/discover/(list)/model/_layout/Desktop.tsx
+++ b/src/app/[variants]/(main)/discover/(list)/model/_layout/Desktop.tsx
@@ -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 (
diff --git a/src/app/[variants]/(main)/discover/(list)/model/features/Category/index.tsx b/src/app/[variants]/(main)/discover/(list)/model/features/Category/index.tsx
index e5788bc1e16..7869f4693a3 100644
--- a/src/app/[variants]/(main)/discover/(list)/model/features/Category/index.tsx
+++ b/src/app/[variants]/(main)/discover/(list)/model/features/Category/index.tsx
@@ -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: ,
- label: {item.label},
+ label: {item.label},
};
})}
mode={'inline'}
diff --git a/src/app/[variants]/(main)/discover/(list)/model/features/List/Item.tsx b/src/app/[variants]/(main)/discover/(list)/model/features/List/Item.tsx
index 47cfe002140..a46c4c86ab9 100644
--- a/src/app/[variants]/(main)/discover/(list)/model/features/List/Item.tsx
+++ b/src/app/[variants]/(main)/discover/(list)/model/features/List/Item.tsx
@@ -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(
({ 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 (
{
- router.push(link);
+ navigate(link);
}}
style={{
overflow: 'hidden',
@@ -106,7 +105,7 @@ const ModelItem = memo(
overflow: 'hidden',
}}
>
-
+
{displayName}
diff --git a/src/app/[variants]/(main)/discover/(list)/model/layout.tsx b/src/app/[variants]/(main)/discover/(list)/model/layout.tsx
deleted file mode 100644
index b41b524924d..00000000000
--- a/src/app/[variants]/(main)/discover/(list)/model/layout.tsx
+++ /dev/null
@@ -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({ Desktop, Mobile });
-
-MainLayout.displayName = 'DiscoverModelsLayout';
-
-export default MainLayout;
diff --git a/src/app/[variants]/(main)/discover/(list)/model/page.tsx b/src/app/[variants]/(main)/discover/(list)/model/page.tsx
deleted file mode 100644
index 730f413fae3..00000000000
--- a/src/app/[variants]/(main)/discover/(list)/model/page.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
- >
- );
-};
-
-Page.DisplayName = 'DiscoverModels';
-
-export default Page;
diff --git a/src/app/[variants]/(main)/discover/(list)/provider/ProviderPage.tsx b/src/app/[variants]/(main)/discover/(list)/provider/ProviderPage.tsx
new file mode 100644
index 00000000000..89301d73600
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/(list)/provider/ProviderPage.tsx
@@ -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 ;
+
+ const { items, currentPage, pageSize, totalCount } = data;
+
+ return (
+
+
+
+
+ );
+});
+
+export default withSuspense(ProviderPage);
diff --git a/src/app/[variants]/(main)/discover/(list)/provider/features/List/Item.tsx b/src/app/[variants]/(main)/discover/(list)/provider/features/List/Item.tsx
index 01b3477e8ea..a157feafbf7 100644
--- a/src/app/[variants]/(main)/discover/(list)/provider/features/List/Item.tsx
+++ b/src/app/[variants]/(main)/discover/(list)/provider/features/List/Item.tsx
@@ -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(
({ 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(
clickable
height={'100%'}
onClick={() => {
- router.push(link);
+ navigate(link);
}}
style={{
overflow: 'hidden',
@@ -80,22 +79,28 @@ const ProviderItem = memo(
}}
title={identifier}
>
-
+
@{name}
- e.stopPropagation()} target={'_blank'}>
+ e.stopPropagation()}
+ rel="noopener noreferrer"
+ target={'_blank'}
+ >
-
-
+ e.stopPropagation()}
+ rel="noopener noreferrer"
target={'_blank'}
>
-
+
@@ -122,7 +127,7 @@ const ProviderItem = memo(
.slice(0, 6)
.filter(Boolean)
.map((tag: string) => (
-
+
))}
diff --git a/src/app/[variants]/(main)/discover/(list)/provider/page.tsx b/src/app/[variants]/(main)/discover/(list)/provider/page.tsx
deleted file mode 100644
index 1a4b22642e9..00000000000
--- a/src/app/[variants]/(main)/discover/(list)/provider/page.tsx
+++ /dev/null
@@ -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 (
- <>
-
-
- >
- );
-};
-
-Page.DisplayName = 'DiscoverProviders';
-
-export default Page;
diff --git a/src/app/[variants]/(main)/discover/DiscoverRouter.tsx b/src/app/[variants]/(main)/discover/DiscoverRouter.tsx
new file mode 100644
index 00000000000..0b887e91dbf
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/DiscoverRouter.tsx
@@ -0,0 +1,169 @@
+'use client';
+
+import { memo, useEffect } from 'react';
+import { useMediaQuery } from 'react-responsive';
+import { MemoryRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
+
+import DetailLayout from './(detail)/_layout/DetailLayout';
+import AssistantDetailPage from './(detail)/assistant/AssistantDetailPage';
+import McpDetailPage from './(detail)/mcp/McpDetailPage';
+import ModelDetailPage from './(detail)/model/ModelDetailPage';
+import ProviderDetailPage from './(detail)/provider/ProviderDetailPage';
+import HomePage from './(list)/(home)/HomePage';
+import ListLayout from './(list)/_layout/ListLayout';
+import AssistantLayout from './(list)/assistant/AssistantLayout';
+import AssistantPage from './(list)/assistant/AssistantPage';
+import McpLayout from './(list)/mcp/McpLayout';
+import McpPage from './(list)/mcp/McpPage';
+import ModelLayout from './(list)/model/ModelLayout';
+import ModelPage from './(list)/model/ModelPage';
+import ProviderPage from './(list)/provider/ProviderPage';
+import DiscoverLayout from './_layout/DiscoverLayout';
+
+// 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 (
+
+
+
+
+ {/* List routes with ListLayout */}
+
+
+
+ }
+ path="/"
+ />
+
+
+
+
+
+ }
+ path="/assistant"
+ />
+
+
+
+
+
+ }
+ path="/model"
+ />
+
+
+
+ }
+ path="/provider"
+ />
+
+
+
+
+
+ }
+ path="/mcp"
+ />
+
+ {/* Detail routes with DetailLayout */}
+
+
+
+ }
+ path="/assistant/*"
+ />
+
+
+
+ }
+ path="/model/*"
+ />
+
+
+
+ }
+ path="/provider/*"
+ />
+
+
+
+ }
+ path="/mcp/*"
+ />
+
+ {/* Fallback */}
+ } path="*" />
+
+
+
+ );
+});
+
+DiscoverRouter.displayName = 'DiscoverRouter';
+
+export default DiscoverRouter;
diff --git a/src/app/[variants]/(main)/discover/[[...path]]/page.tsx b/src/app/[variants]/(main)/discover/[[...path]]/page.tsx
new file mode 100644
index 00000000000..81de7f2a920
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/[[...path]]/page.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+
+import { BrandTextLoading } from '@/components/Loading';
+
+const DiscoverRouter = dynamic(() => import('../DiscoverRouter'), {
+ loading: BrandTextLoading,
+ ssr: false,
+});
+
+export default DiscoverRouter;
diff --git a/src/app/[variants]/(main)/discover/_layout/Desktop/Header.tsx b/src/app/[variants]/(main)/discover/_layout/Desktop/Header.tsx
index 305d389ca48..e5f0f352578 100644
--- a/src/app/[variants]/(main)/discover/_layout/Desktop/Header.tsx
+++ b/src/app/[variants]/(main)/discover/_layout/Desktop/Header.tsx
@@ -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 (
+
}
diff --git a/src/app/[variants]/(main)/discover/_layout/DiscoverLayout.tsx b/src/app/[variants]/(main)/discover/_layout/DiscoverLayout.tsx
new file mode 100644
index 00000000000..5cdbcb11228
--- /dev/null
+++ b/src/app/[variants]/(main)/discover/_layout/DiscoverLayout.tsx
@@ -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(({ children, mobile }) => {
+ if (mobile) {
+ return {children};
+ }
+
+ return {children};
+});
+
+DiscoverLayout.displayName = 'DiscoverLayout';
+
+export default DiscoverLayout;
diff --git a/src/app/[variants]/(main)/discover/components/Title.tsx b/src/app/[variants]/(main)/discover/components/Title.tsx
index dd97edca317..bfee007558b 100644
--- a/src/app/[variants]/(main)/discover/components/Title.tsx
+++ b/src/app/[variants]/(main)/discover/components/Title.tsx
@@ -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(({ tag, children, moreLink, more }) => {
title
)}
{moreLink && (
-
+