From 8afb4e29812f761b3f77f27e1235e59a4a0efa4d Mon Sep 17 00:00:00 2001 From: CanisMinor Date: Sat, 21 Sep 2024 14:58:13 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20perf:=20Use=20nextjs=20pwa=20feat?= =?UTF-8?q?=20to=20gen=20manifest=20file=20(#4042)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * โšก perf: Use nextjs pwa feat to gen manifest file * ๐Ÿ› fix: Fix review problem * โœ… test: Add test * ๐Ÿ’„ style: Update next config * ๐Ÿ› fix: Fix test config * ๐Ÿ’„ style: Update layout --- next.config.mjs | 47 ++- public/manifest.json | 166 ---------- .../discover/(detail)/_layout/Desktop.tsx | 8 +- .../(detail)/assistant/[slug]/page.tsx | 2 +- .../(detail)/model/[...slugs]/page.tsx | 2 +- .../discover/(detail)/plugin/[slug]/page.tsx | 2 +- .../(detail)/provider/[slug]/page.tsx | 2 +- src/app/manifest.ts | 86 +++++ src/features/PWAInstall/index.tsx | 6 +- src/server/manifest.test.ts | 164 ++++++++++ src/server/manifest.ts | 101 ++++++ src/server/metadata.ts | 4 +- src/server/services/discover/index.test.ts | 306 ++++++++++++++++++ src/server/services/discover/index.ts | 7 + vitest.config.ts | 2 +- 15 files changed, 729 insertions(+), 176 deletions(-) delete mode 100644 public/manifest.json create mode 100644 src/app/manifest.ts create mode 100644 src/server/manifest.test.ts create mode 100644 src/server/manifest.ts create mode 100644 src/server/services/discover/index.test.ts diff --git a/next.config.mjs b/next.config.mjs index 4ba5ed23ed34..1494c6a7cd93 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -101,6 +101,19 @@ const nextConfig = { ], source: '/apple-touch-icon.png', }, + { + headers: [ + { + key: 'Content-Type', + value: 'application/javascript; charset=utf-8', + }, + { + key: 'Content-Security-Policy', + value: "default-src 'self'; script-src 'self'", + }, + ], + source: '/sw.js', + }, ]; }, @@ -113,10 +126,42 @@ const nextConfig = { source: '/sitemap.xml', }, { - destination: '/discover', + destination: '/manifest.webmanifest', + permanent: true, + source: '/manifest.json', + }, + { + destination: '/discover/assistant/:slug', + has: [ + { + key: 'agent', + type: 'query', + value: '(?.*)', + }, + ], permanent: true, source: '/market', }, + { + destination: '/discover/assistants', + permanent: true, + source: '/discover/assistant', + }, + { + destination: '/discover/models', + permanent: true, + source: '/discover/model', + }, + { + destination: '/discover/plugins', + permanent: true, + source: '/discover/plugin', + }, + { + destination: '/discover/providers', + permanent: true, + source: '/discover/provider', + }, { destination: '/settings/common', permanent: true, diff --git a/public/manifest.json b/public/manifest.json deleted file mode 100644 index 0fca79133299..000000000000 --- a/public/manifest.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "background_color": "#000000", - "cache_busting_mode": "all", - "categories": ["productivity", "design", "development", "education"], - "description": "Personal LLM productivity tool, brings you the best user experience of ChatGPT, OLLaMA, Gemini, Claude WebUI. Customize AI assistant features flexibly according to personalized needs to solve problems, enhance productivity, and explore future workflow in LobeChat.", - "display": "standalone", - "display_override": ["tabbed"], - "edge_side_panel": { - "preferred_width": 480 - }, - "handle_links": "auto", - "icons": [ - { - "src": "/icons/icon-192x192.png?v=1", - "sizes": "192x192", - "type": "image/png", - "purpose": "any", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/icons/icon-192x192.maskable.png?v=1", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/icons/icon-512x512.png?v=1", - "sizes": "512x512", - "type": "image/png", - "purpose": "any", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/icons/icon-512x512.maskable.png?v=1", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - } - ], - "id": "lobe-chat", - "immutable": "true", - "launch_handler": { - "client_mode": ["navigate-existing", "auto"] - }, - "max_age": 31536000, - "name": "LobeChat", - "orientation": "portrait", - "related_applications": [ - { - "platform": "webapp", - "url": "https://chat-preview.lobehub.com/manifest.json" - } - ], - "scope": "/", - "screenshots": [ - { - "src": "/screenshots/shot-1.mobile.png?v=1", - "sizes": "640x1138", - "type": "image/png", - "form_factor": "narrow", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/screenshots/shot-2.mobile.png?v=1", - "sizes": "640x1138", - "type": "image/png", - "form_factor": "narrow", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/screenshots/shot-3.mobile.png?v=1", - "sizes": "640x1138", - "type": "image/png", - "form_factor": "narrow", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/screenshots/shot-4.mobile.png?v=1", - "sizes": "640x1138", - "type": "image/png", - "form_factor": "narrow", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/screenshots/shot-5.mobile.png?v=1", - "sizes": "640x1138", - "type": "image/png", - "form_factor": "narrow", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/screenshots/shot-1.desktop.png?v=1", - "sizes": "1280x676", - "type": "image/png", - "form_factor": "wide", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/screenshots/shot-2.desktop.png?v=1", - "sizes": "1280x676", - "type": "image/png", - "form_factor": "wide", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/screenshots/shot-3.desktop.png?v=1", - "sizes": "1280x676", - "type": "image/png", - "form_factor": "wide", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/screenshots/shot-4.desktop.png?v=1", - "sizes": "1280x676", - "type": "image/png", - "form_factor": "wide", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - }, - { - "src": "/screenshots/shot-5.desktop.png?v=1", - "sizes": "1280x676", - "type": "image/png", - "form_factor": "wide", - "cache_busting_mode": "query", - "max_age": 31536000, - "immutable": "true" - } - ], - "short_name": "LobeChat", - "splash_pages": null, - "start_url": ".", - "tab_strip": { - "new_tab_button": { - "url": "/" - } - }, - "theme_color": "#000000" -} diff --git a/src/app/(main)/discover/(detail)/_layout/Desktop.tsx b/src/app/(main)/discover/(detail)/_layout/Desktop.tsx index 66405a5cdacc..503906bcc8af 100644 --- a/src/app/(main)/discover/(detail)/_layout/Desktop.tsx +++ b/src/app/(main)/discover/(detail)/_layout/Desktop.tsx @@ -9,10 +9,14 @@ const Layout = ({ children }: PropsWithChildren) => { align={'center'} flex={1} padding={24} - style={{ overflowX: 'hidden', overflowY: 'scroll', position: 'relative' }} + style={{ overflowX: 'hidden', overflowY: 'auto', position: 'relative' }} width={'100%'} > - + {children} diff --git a/src/app/(main)/discover/(detail)/assistant/[slug]/page.tsx b/src/app/(main)/discover/(detail)/assistant/[slug]/page.tsx index 112ac455af40..a3c8c4240816 100644 --- a/src/app/(main)/discover/(detail)/assistant/[slug]/page.tsx +++ b/src/app/(main)/discover/(detail)/assistant/[slug]/page.tsx @@ -27,7 +27,7 @@ export const generateMetadata = async ({ params, searchParams }: Props) => { const discoverService = new DiscoverService(); const data = await discoverService.getAssistantById(locale, identifier); - if (!data) return notFound(); + if (!data) return; const { meta, createdAt, homepage, author } = data; diff --git a/src/app/(main)/discover/(detail)/model/[...slugs]/page.tsx b/src/app/(main)/discover/(detail)/model/[...slugs]/page.tsx index 4972c4feeff4..526ebd6fc030 100644 --- a/src/app/(main)/discover/(detail)/model/[...slugs]/page.tsx +++ b/src/app/(main)/discover/(detail)/model/[...slugs]/page.tsx @@ -27,7 +27,7 @@ export const generateMetadata = async ({ params, searchParams }: Props) => { const discoverService = new DiscoverService(); const data = await discoverService.getModelById(locale, identifier); - if (!data) return notFound(); + if (!data) return; const { meta, createdAt, providers } = data; diff --git a/src/app/(main)/discover/(detail)/plugin/[slug]/page.tsx b/src/app/(main)/discover/(detail)/plugin/[slug]/page.tsx index 0f698ee339f9..16e26dfa7393 100644 --- a/src/app/(main)/discover/(detail)/plugin/[slug]/page.tsx +++ b/src/app/(main)/discover/(detail)/plugin/[slug]/page.tsx @@ -24,7 +24,7 @@ export const generateMetadata = async ({ params, searchParams }: Props) => { const discoverService = new DiscoverService(); const data = await discoverService.getPluginById(locale, identifier); - if (!data) return notFound(); + if (!data) return; const { meta, createdAt, homepage, author } = data; diff --git a/src/app/(main)/discover/(detail)/provider/[slug]/page.tsx b/src/app/(main)/discover/(detail)/provider/[slug]/page.tsx index b9b33224d2bc..e6acb700fb06 100644 --- a/src/app/(main)/discover/(detail)/provider/[slug]/page.tsx +++ b/src/app/(main)/discover/(detail)/provider/[slug]/page.tsx @@ -27,7 +27,7 @@ export const generateMetadata = async ({ params, searchParams }: Props) => { const discoverService = new DiscoverService(); const data = await discoverService.getProviderById(locale, identifier); - if (!data) return notFound(); + if (!data) return; const { meta, createdAt, models } = data; diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 000000000000..753b3ee1e331 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,86 @@ +import { kebabCase } from 'lodash-es'; +import type { MetadataRoute } from 'next'; + +import { BRANDING_LOGO_URL, BRANDING_NAME } from '@/const/branding'; +import { manifestModule } from '@/server/manifest'; +import { translation } from '@/server/translation'; + +const manifest = async (): Promise => { + const { t } = await translation('metadata'); + + return manifestModule.generate({ + description: t('chat.description', { appName: BRANDING_NAME }), + icons: [ + { + purpose: 'any', + sizes: '192x192', + url: '/icons/icon-192x192.png', + }, + { + purpose: 'maskable', + sizes: '192x192', + url: '/icons/icon-192x192.maskable.png', + }, + { + purpose: 'any', + sizes: '512x512', + url: '/icons/icon-512x512.png', + }, + { + purpose: 'maskable', + sizes: '512x512', + url: '/icons/icon-512x512.maskable.png', + }, + ], + id: kebabCase(BRANDING_NAME), + name: BRANDING_NAME, + screenshots: BRANDING_LOGO_URL + ? [] + : [ + { + form_factor: 'narrow', + url: '/screenshots/shot-1.mobile.png', + }, + { + form_factor: 'narrow', + url: '/screenshots/shot-2.mobile.png', + }, + { + form_factor: 'narrow', + sizes: '640x1138', + + url: '/screenshots/shot-3.mobile.png', + }, + { + form_factor: 'narrow', + url: '/screenshots/shot-4.mobile.png', + }, + { + form_factor: 'narrow', + url: '/screenshots/shot-5.mobile.png', + }, + { + form_factor: 'wide', + url: '/screenshots/shot-1.desktop.png', + }, + { + form_factor: 'wide', + url: '/screenshots/shot-2.desktop.png', + }, + { + form_factor: 'wide', + url: '/screenshots/shot-3.desktop.png', + }, + { + form_factor: 'wide', + url: '/screenshots/shot-4.desktop.png', + }, + { + form_factor: 'wide', + url: '/screenshots/shot-5.desktop.png', + }, + ], + }); +}; + +export default manifest; diff --git a/src/features/PWAInstall/index.tsx b/src/features/PWAInstall/index.tsx index 6f9e41e1fe20..23830198b811 100644 --- a/src/features/PWAInstall/index.tsx +++ b/src/features/PWAInstall/index.tsx @@ -68,7 +68,11 @@ const PWAInstall = memo(() => { if (isPWA) return null; return ( - + ); }); diff --git a/src/server/manifest.test.ts b/src/server/manifest.test.ts new file mode 100644 index 000000000000..cc1b813743b4 --- /dev/null +++ b/src/server/manifest.test.ts @@ -0,0 +1,164 @@ +// @vitest-environment node +import qs from 'query-string'; +import { describe, expect, it, vi } from 'vitest'; + +import { BRANDING_LOGO_URL } from '@/const/branding'; +import { getCanonicalUrl } from '@/server/utils/url'; + +import { Manifest, manifestModule } from './manifest'; + +// Mock external dependencies +vi.mock('@/const/branding', () => ({ + BRANDING_LOGO_URL: 'https://example.com/logo.png', +})); + +vi.mock('@/server/utils/url', () => ({ + getCanonicalUrl: vi.fn().mockReturnValue('https://example.com/manifest.webmanifest'), +})); + +describe('Manifest', () => { + const manifest = new Manifest(); + + describe('generate', () => { + it('should generate a valid manifest object', () => { + const input = { + color: '#FF0000', + description: 'Test description', + name: 'Test App', + id: 'test-app', + icons: [{ purpose: 'any' as const, sizes: '192x192', url: 'icon.png' }], + screenshots: [{ form_factor: 'wide' as const, url: 'screenshot.png' }], + }; + + const result = manifest.generate(input); + + expect(result).toMatchObject({ + background_color: input.color, + description: input.description, + name: input.name, + id: input.id, + icons: expect.arrayContaining([ + expect.objectContaining({ + purpose: 'any', + sizes: '192x192', + }), + ]), + screenshots: expect.arrayContaining([ + expect.objectContaining({ + form_factor: 'wide', + sizes: '1280x676', + }), + ]), + }); + }); + + it('should use default color if not provided', () => { + const input = { + description: 'Test description', + name: 'Test App', + id: 'test-app', + icons: [], + screenshots: [], + }; + + const result = manifest.generate(input); + + expect(result.background_color).toBe('#000000'); + expect(result.theme_color).toBe('#000000'); + }); + }); + + describe('_getImage', () => { + it('should return correct image object', () => { + const url = 'https://example.com/image.png'; + const version = 2; + + // @ts-ignore - Accessing private method for testing + const result = manifest._getImage(url, version); + + expect(result).toEqual({ + cache_busting_mode: 'query', + immutable: 'true', + max_age: 31536000, + src: qs.stringifyUrl({ query: { v: version }, url: BRANDING_LOGO_URL || url }), + }); + }); + + it('should use default version if not provided', () => { + const url = 'https://example.com/image.png'; + + // @ts-ignore - Accessing private method for testing + const result = manifest._getImage(url); + + expect(result.src).toContain('v=1'); + }); + }); + + describe('_getIcon', () => { + it('should return correct icon object', () => { + const icon = { + url: 'https://example.com/icon.png', + version: 3, + sizes: '64x64', + purpose: 'maskable' as const, + }; + + // @ts-ignore - Accessing private method for testing + const result = manifest._getIcon(icon); + + expect(result).toMatchObject({ + purpose: 'maskable', + sizes: '64x64', + type: 'image/png', + }); + expect(result.src).toContain('v=3'); + }); + }); + + describe('_getScreenshot', () => { + it('should return correct screenshot object for wide form factor', () => { + const screenshot = { + form_factor: 'wide' as const, + url: 'https://example.com/screenshot.png', + version: 4, + }; + + // @ts-ignore - Accessing private method for testing + const result = manifest._getScreenshot(screenshot); + + expect(result).toMatchObject({ + form_factor: 'wide', + sizes: '1280x676', + type: 'image/png', + }); + expect(result.src).toContain('v=4'); + }); + + it('should return correct screenshot object for narrow form factor', () => { + const screenshot = { + form_factor: 'narrow' as const, + url: 'https://example.com/screenshot.png', + sizes: '320x569', + }; + + // @ts-ignore - Accessing private method for testing + const result = manifest._getScreenshot(screenshot); + + expect(result).toMatchObject({ + cache_busting_mode: 'query', + form_factor: 'narrow', + immutable: 'true', + max_age: 31536000, + sizes: '1280x676', + src: 'https://example.com/logo.png?v=1', + type: 'image/png', + }); + }); + }); +}); + +describe('manifestModule', () => { + it('should be an instance of Manifest', () => { + expect(manifestModule).toBeInstanceOf(Manifest); + }); +}); diff --git a/src/server/manifest.ts b/src/server/manifest.ts new file mode 100644 index 000000000000..c1e6507ca1cb --- /dev/null +++ b/src/server/manifest.ts @@ -0,0 +1,101 @@ +import qs from 'query-string'; + +import { BRANDING_LOGO_URL } from '@/const/branding'; +import { getCanonicalUrl } from '@/server/utils/url'; + +const MAX_AGE = 31_536_000; +const COLOR = '#000000'; + +interface IconItem { + purpose: 'any' | 'maskable'; + sizes: string; + url: string; + version?: number; +} + +interface ScreenshotItem { + form_factor: 'wide' | 'narrow'; + sizes?: string; + url: string; + version?: number; +} + +export class Manifest { + public generate({ + color = COLOR, + description, + name, + id, + icons, + screenshots, + }: { + color?: string; + description: string; + icons: IconItem[]; + id: string; + name: string; + screenshots: ScreenshotItem[]; + }) { + return { + background_color: color, + cache_busting_mode: 'all', + categories: ['productivity', 'design', 'development', 'education'], + description: description, + display: 'standalone', + display_override: ['tabbed'], + edge_side_panel: { + preferred_width: 480, + }, + handle_links: 'auto', + icons: icons.map((item) => this._getIcon(item)), + id: id, + immutable: 'true', + launch_handler: { + client_mode: ['navigate-existing', 'auto'], + }, + max_age: MAX_AGE, + name: name, + orientation: 'portrait', + related_applications: [ + { + platform: 'webapp', + url: getCanonicalUrl('manifest.webmanifest'), + }, + ], + scope: '/', + screenshots: screenshots.map((item) => this._getScreenshot(item)), + short_name: name, + splash_pages: null, + start_url: '.', + tab_strip: { + new_tab_button: { + url: '/', + }, + }, + theme_color: color, + }; + } + + private _getImage = (url: string, version: number = 1) => ({ + cache_busting_mode: 'query', + immutable: 'true', + max_age: MAX_AGE, + src: qs.stringifyUrl({ query: { v: version }, url: BRANDING_LOGO_URL || url }), + }); + + private _getIcon = ({ url, version, sizes, purpose }: IconItem) => ({ + ...this._getImage(url, version), + purpose, + sizes, + type: 'image/png', + }); + + private _getScreenshot = ({ form_factor, url, version, sizes }: ScreenshotItem) => ({ + ...this._getImage(url, version), + form_factor, + sizes: sizes || form_factor === 'wide' ? '1280x676' : '640x1138', + type: 'image/png', + }); +} + +export const manifestModule = new Manifest(); diff --git a/src/server/metadata.ts b/src/server/metadata.ts index 71dc1aea088b..4664f9e91007 100644 --- a/src/server/metadata.ts +++ b/src/server/metadata.ts @@ -35,7 +35,9 @@ export class Meta { const siteTitle = title.includes(BRANDING_NAME) ? title : title + ` ยท ${BRANDING_NAME}`; return { alternates: { - canonical: getCanonicalUrl(url), + canonical: getCanonicalUrl( + alternate ? qs.stringifyUrl({ query: { hl: locale }, url }) : url, + ), languages: alternate ? this.genAlternateLocales(locale, url) : undefined, }, description: formatedDescription, diff --git a/src/server/services/discover/index.test.ts b/src/server/services/discover/index.test.ts new file mode 100644 index 000000000000..9a123ed778eb --- /dev/null +++ b/src/server/services/discover/index.test.ts @@ -0,0 +1,306 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_LANG } from '@/const/locale'; +import { AssistantCategory, PluginCategory } from '@/types/discover'; + +import { DiscoverService } from './index'; + +// ๆจกๆ‹Ÿ fetch ๅ‡ฝๆ•ฐ +global.fetch = vi.fn(); + +describe('DiscoverService', () => { + let service: DiscoverService; + + beforeEach(() => { + service = new DiscoverService(); + vi.resetAllMocks(); + }); + + describe('Assistants', () => { + it('should search assistants', async () => { + const mockAssistants = [ + { + author: 'John', + meta: { title: 'Test Assistant', description: 'A test assistant', tags: ['test'] }, + }, + { + author: 'Jane', + meta: { + title: 'Another Assistant', + description: 'Another test assistant', + tags: ['demo'], + }, + }, + ]; + + vi.spyOn(service, 'getAssistantList').mockResolvedValue(mockAssistants as any); + + const result = await service.searchAssistant('en-US', 'A test assistant'); + expect(result).toHaveLength(1); + expect(result[0].author).toBe('John'); + }); + + it('should get assistant category', async () => { + const mockAssistants = [ + { meta: { category: AssistantCategory.General } }, + { meta: { category: AssistantCategory.Academic } }, + ]; + + vi.spyOn(service, 'getAssistantList').mockResolvedValue(mockAssistants as any); + + const result = await service.getAssistantCategory('en-US', AssistantCategory.General); + expect(result).toHaveLength(1); + expect(result[0].meta.category).toBe(AssistantCategory.General); + }); + + it('should get assistant list', async () => { + const mockResponse = { agents: [{ id: 'test-assistant' }] }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + } as any); + + const result = await service.getAssistantList('en-US'); + expect(result).toEqual(mockResponse.agents); + }); + + it('should get assistant by id', async () => { + const mockAssistant = { + identifier: 'test-assistant', + meta: { category: AssistantCategory.General }, + }; + + vi.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue(mockAssistant), + } as any); + + vi.spyOn(service, 'getAssistantCategory').mockResolvedValue([]); + + const result = await service.getAssistantById('en-US', 'test-assistant'); + + expect(result).toBeDefined(); + expect(result?.identifier).toBe('test-assistant'); + }); + + it('should get assistants by ids', async () => { + const mockAssistants = [{ identifier: 'assistant1' }, { identifier: 'assistant2' }]; + + vi.spyOn(service, 'getAssistantById').mockImplementation( + async (_, id) => mockAssistants.find((a) => a.identifier === id) as any, + ); + + const result = await service.getAssistantByIds('en-US', [ + 'assistant1', + 'assistant2', + 'nonexistent', + ]); + expect(result).toHaveLength(2); + expect(result[0].identifier).toBe('assistant1'); + expect(result[1].identifier).toBe('assistant2'); + }); + }); + + describe('Plugins', () => { + it('should search plugins', async () => { + const mockPlugins = [ + { + author: 'John', + meta: { title: 'Test Plugin', description: 'A test plugin', tags: ['test'] }, + }, + { + author: 'Jane', + meta: { title: 'Another Plugin', description: 'Another test plugin', tags: ['demo'] }, + }, + ]; + + vi.spyOn(service, 'getPluginList').mockResolvedValue(mockPlugins as any); + + const result = await service.searchPlugin('en-US', 'A test plugin'); + expect(result).toHaveLength(1); + expect(result[0].author).toBe('John'); + }); + + it('should get plugin category', async () => { + const mockPlugins = [ + { meta: { category: PluginCategory.Tools } }, + { meta: { category: PluginCategory.Social } }, + ]; + + vi.spyOn(service, 'getPluginList').mockResolvedValue(mockPlugins as any); + + const result = await service.getPluginCategory('en-US', PluginCategory.Tools); + expect(result).toHaveLength(1); + expect(result[0].meta.category).toBe(PluginCategory.Tools); + }); + + it('should get plugin list', async () => { + const mockResponse = { plugins: [{ id: 'test-plugin' }] }; + vi.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + } as any); + + const result = await service.getPluginList('en-US'); + expect(result).toEqual(mockResponse.plugins); + }); + + it('should get plugin by id', async () => { + const mockPlugin = { + identifier: 'test-plugin', + meta: { category: PluginCategory.Tools }, + }; + + vi.spyOn(service, 'getPluginList').mockResolvedValue([mockPlugin] as any); + vi.spyOn(service, 'getPluginCategory').mockResolvedValue([]); + + const result = await service.getPluginById('en-US', 'test-plugin'); + + expect(result).toBeDefined(); + expect(result?.identifier).toBe('test-plugin'); + }); + + it('should get plugins by ids', async () => { + const mockPlugins = [{ identifier: 'plugin1' }, { identifier: 'plugin2' }]; + + vi.spyOn(service, 'getPluginById').mockImplementation( + async (_, id) => mockPlugins.find((p) => p.identifier === id) as any, + ); + + const result = await service.getPluginByIds('en-US', ['plugin1', 'plugin2', 'nonexistent']); + expect(result).toHaveLength(2); + expect(result[0].identifier).toBe('plugin1'); + expect(result[1].identifier).toBe('plugin2'); + }); + }); + + describe('Providers', () => { + it('should get provider list', async () => { + const result = await service.getProviderList('en-US'); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('identifier'); + expect(result[0]).toHaveProperty('meta'); + expect(result[0]).toHaveProperty('models'); + }); + + it('should search providers', async () => { + const mockProviders = [ + { identifier: 'provider1', meta: { title: 'Test Provider' } }, + { identifier: 'provider2', meta: { title: 'Another Provider' } }, + ]; + + vi.spyOn(service, 'getProviderList').mockResolvedValue(mockProviders as any); + + const result = await service.searchProvider('en-US', 'test'); + expect(result).toHaveLength(1); + expect(result[0].identifier).toBe('provider1'); + }); + + it('should get provider by id', async () => { + const mockProvider = { + identifier: 'test-provider', + meta: { title: 'Test Provider' }, + models: ['model1', 'model2'], + }; + + vi.spyOn(service, 'getProviderList').mockResolvedValue([mockProvider] as any); + + const result = await service.getProviderById('en-US', 'test-provider'); + + expect(result).toBeDefined(); + expect(result?.identifier).toBe('test-provider'); + }); + + it('should get providers by ids', async () => { + const mockProviders = [{ identifier: 'provider1' }, { identifier: 'provider2' }]; + + vi.spyOn(service, 'getProviderById').mockImplementation( + async (_, id) => mockProviders.find((p) => p.identifier === id) as any, + ); + + const result = await service.getProviderByIds('en-US', [ + 'provider1', + 'provider2', + 'nonexistent', + ]); + expect(result).toHaveLength(2); + expect(result[0].identifier).toBe('provider1'); + expect(result[1].identifier).toBe('provider2'); + }); + }); + + describe('Models', () => { + it('should get model list', async () => { + const result = await service.getModelList('en-US'); + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + expect(result[0]).toHaveProperty('identifier'); + expect(result[0]).toHaveProperty('meta'); + expect(result[0]).toHaveProperty('providers'); + }); + + it('should search models', async () => { + const mockModels = [ + { + identifier: 'model1', + meta: { title: 'Test Model', description: 'A test model' }, + providers: ['provider1'], + }, + { + identifier: 'model2', + meta: { title: 'Another Model', description: 'Another test model' }, + providers: ['provider2'], + }, + ]; + + vi.spyOn(service as any, '_getModelList').mockResolvedValue(mockModels); + + const result = await service.searchModel('en-US', 'A test model'); + expect(result).toHaveLength(1); + expect(result[0].identifier).toBe('model1'); + }); + + it('should get model category', async () => { + const mockModels = [{ meta: { category: 'category1' } }, { meta: { category: 'category2' } }]; + + vi.spyOn(service as any, '_getModelList').mockResolvedValue(mockModels); + + const result = await service.getModelCategory('en-US', 'category1'); + expect(result).toHaveLength(1); + expect(result[0].meta.category).toBe('category1'); + }); + + it('should get model by id', async () => { + const mockModel = { + identifier: 'test-model', + meta: { category: 'test-category' }, + providers: ['provider1'], + }; + + vi.spyOn(service, 'getModelList').mockResolvedValue([mockModel] as any); + vi.spyOn(service, 'getModelCategory').mockResolvedValue([]); + + const result = await service.getModelById('en-US', 'test-model'); + + expect(result).toBeDefined(); + expect(result?.identifier).toBe('test-model'); + }); + + it('should get models by ids', async () => { + const mockModels = [{ identifier: 'model1' }, { identifier: 'model2' }]; + + vi.spyOn(service, 'getModelById').mockImplementation( + async (_, id) => mockModels.find((m) => m.identifier === id) as any, + ); + + const result = await service.getModelByIds('en-US', ['model1', 'model2', 'nonexistent']); + expect(result).toHaveLength(2); + expect(result[0].identifier).toBe('model1'); + expect(result[1].identifier).toBe('model2'); + }); + }); +}); diff --git a/src/server/services/discover/index.ts b/src/server/services/discover/index.ts index 6666309dae32..6ca45e4873a4 100644 --- a/src/server/services/discover/index.ts +++ b/src/server/services/discover/index.ts @@ -60,6 +60,8 @@ export class DiscoverService { }); } + if (!res.ok) return []; + const json = await res.json(); return json.agents; @@ -79,6 +81,8 @@ export class DiscoverService { }); } + if (!res.ok) return; + let assistant = await res.json(); if (!assistant) return; @@ -148,7 +152,10 @@ export class DiscoverService { }); } + if (!res.ok) return []; + const json = await res.json(); + return json.plugins; }; diff --git a/vitest.config.ts b/vitest.config.ts index 15406e3850d2..54e43f578dea 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ '**/dist/**', '**/build/**', 'src/database/server/**/**', - 'src/server/services/**/**', + 'src/server/services/!(discover)/**/**', ], globals: true, server: {