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 + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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 && ( - +