@lobehub/chat 1.19.3 → 1.19.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @lobehub/chat might be problematic. Click here for more details.

Files changed (30) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/next.config.mjs +10 -0
  3. package/package.json +6 -7
  4. package/scripts/buildSitemapIndex/index.ts +12 -0
  5. package/src/app/(main)/discover/(detail)/assistant/[slug]/page.tsx +1 -0
  6. package/src/app/(main)/discover/(detail)/model/[...slugs]/features/ProviderList/ProviderItem.tsx +1 -0
  7. package/src/app/(main)/discover/(detail)/model/[...slugs]/page.tsx +1 -0
  8. package/src/app/(main)/discover/(detail)/plugin/[slug]/page.tsx +1 -0
  9. package/src/app/(main)/discover/(detail)/provider/[slug]/features/ModelList/ModelItem.tsx +1 -0
  10. package/src/app/(main)/discover/(detail)/provider/[slug]/page.tsx +1 -0
  11. package/src/app/(main)/discover/(list)/_layout/Desktop/Nav.tsx +1 -0
  12. package/src/app/(main)/discover/_layout/Desktop/index.tsx +1 -0
  13. package/src/app/page.tsx +1 -1
  14. package/src/app/robots.tsx +16 -0
  15. package/src/app/sitemap.tsx +30 -0
  16. package/src/config/modelProviders/qwen.ts +112 -52
  17. package/src/config/modelProviders/stepfun.ts +8 -0
  18. package/src/const/url.ts +2 -2
  19. package/src/server/ld.test.ts +102 -0
  20. package/src/server/ld.ts +3 -9
  21. package/src/server/metadata.test.ts +138 -0
  22. package/src/server/metadata.ts +3 -3
  23. package/src/server/modules/AssistantStore/index.test.ts +1 -1
  24. package/src/server/modules/AssistantStore/index.ts +2 -6
  25. package/src/server/sitemap.test.ts +179 -0
  26. package/src/server/sitemap.ts +243 -0
  27. package/src/server/translation.test.ts +137 -0
  28. package/src/server/utils/url.test.ts +61 -0
  29. package/src/server/utils/url.ts +9 -0
  30. package/next-sitemap.config.mjs +0 -53
@@ -0,0 +1,243 @@
1
+ import { flatten } from 'lodash-es';
2
+ import { MetadataRoute } from 'next';
3
+ import qs from 'query-string';
4
+ import urlJoin from 'url-join';
5
+
6
+ import { DEFAULT_LANG } from '@/const/locale';
7
+ import { SITEMAP_BASE_URL } from '@/const/url';
8
+ import { Locales, locales as allLocales } from '@/locales/resources';
9
+ import { DiscoverService } from '@/server/services/discover';
10
+ import { getCanonicalUrl } from '@/server/utils/url';
11
+ import { AssistantCategory, PluginCategory } from '@/types/discover';
12
+ import { isDev } from '@/utils/env';
13
+
14
+ export interface SitemapItem {
15
+ alternates?: {
16
+ languages?: string;
17
+ };
18
+ changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
19
+ lastModified?: string | Date;
20
+ priority?: number;
21
+ url: string;
22
+ }
23
+
24
+ export enum SitemapType {
25
+ Assistants = 'assistants',
26
+ Models = 'models',
27
+ Pages = 'pages',
28
+ Plugins = 'plugins',
29
+ Providers = 'providers',
30
+ }
31
+
32
+ export const LAST_MODIFIED = new Date().toISOString();
33
+
34
+ export class Sitemap {
35
+ sitemapIndexs = [
36
+ { id: SitemapType.Pages },
37
+ { id: SitemapType.Assistants },
38
+ { id: SitemapType.Plugins },
39
+ { id: SitemapType.Models },
40
+ { id: SitemapType.Providers },
41
+ ];
42
+
43
+ private discoverService = new DiscoverService();
44
+
45
+ private _generateSitemapLink(url: string) {
46
+ return [
47
+ '<sitemap>',
48
+ `<loc>${url}</loc>`,
49
+ `<lastmod>${LAST_MODIFIED}</lastmod>`,
50
+ '</sitemap>',
51
+ ].join('\n');
52
+ }
53
+
54
+ private _formatTime(time?: string) {
55
+ try {
56
+ if (!time) return LAST_MODIFIED;
57
+ return new Date(time).toISOString() || LAST_MODIFIED;
58
+ } catch {
59
+ return LAST_MODIFIED;
60
+ }
61
+ }
62
+
63
+ private _genSitemapItem = (
64
+ lang: Locales,
65
+ url: string,
66
+ {
67
+ lastModified,
68
+ changeFrequency = 'monthly',
69
+ priority = 0.4,
70
+ noLocales,
71
+ locales = allLocales,
72
+ }: {
73
+ changeFrequency?: SitemapItem['changeFrequency'];
74
+ lastModified?: string;
75
+ locales?: typeof allLocales;
76
+ noLocales?: boolean;
77
+ priority?: number;
78
+ } = {},
79
+ ) => {
80
+ const sitemap = {
81
+ changeFrequency,
82
+ lastModified: this._formatTime(lastModified),
83
+ priority,
84
+ url:
85
+ lang === DEFAULT_LANG
86
+ ? getCanonicalUrl(url)
87
+ : qs.stringifyUrl({ query: { hl: lang }, url: getCanonicalUrl(url) }),
88
+ };
89
+ if (noLocales) return sitemap;
90
+
91
+ const languages: any = {};
92
+ for (const locale of locales) {
93
+ if (locale === lang) continue;
94
+ languages[locale] = qs.stringifyUrl({
95
+ query: { hl: locale },
96
+ url: getCanonicalUrl(url),
97
+ });
98
+ }
99
+ return {
100
+ alternates: {
101
+ languages,
102
+ },
103
+ ...sitemap,
104
+ };
105
+ };
106
+
107
+ private _genSitemap(
108
+ url: string,
109
+ {
110
+ lastModified,
111
+ changeFrequency = 'monthly',
112
+ priority = 0.4,
113
+ noLocales,
114
+ locales = allLocales,
115
+ }: {
116
+ changeFrequency?: SitemapItem['changeFrequency'];
117
+ lastModified?: string;
118
+ locales?: typeof allLocales;
119
+ noLocales?: boolean;
120
+ priority?: number;
121
+ } = {},
122
+ ) {
123
+ if (noLocales)
124
+ return [
125
+ this._genSitemapItem(DEFAULT_LANG, url, {
126
+ changeFrequency,
127
+ lastModified,
128
+ locales,
129
+ noLocales,
130
+ priority,
131
+ }),
132
+ ];
133
+ return locales.map((lang) =>
134
+ this._genSitemapItem(lang, url, {
135
+ changeFrequency,
136
+ lastModified,
137
+ locales,
138
+ noLocales,
139
+ priority,
140
+ }),
141
+ );
142
+ }
143
+
144
+ getIndex(): string {
145
+ return [
146
+ '<?xml version="1.0" encoding="UTF-8"?>',
147
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
148
+ ...this.sitemapIndexs.map((item) =>
149
+ this._generateSitemapLink(
150
+ getCanonicalUrl(SITEMAP_BASE_URL, isDev ? item.id : `${item.id}.xml`),
151
+ ),
152
+ ),
153
+ '</sitemapindex>',
154
+ ].join('\n');
155
+ }
156
+
157
+ async getAssistants(): Promise<MetadataRoute.Sitemap> {
158
+ const list = await this.discoverService.getAssistantList(DEFAULT_LANG);
159
+ const sitmap = list.map((item) =>
160
+ this._genSitemap(urlJoin('/discover/assistant', item.identifier), {
161
+ lastModified: item?.createdAt || LAST_MODIFIED,
162
+ }),
163
+ );
164
+ return flatten(sitmap);
165
+ }
166
+
167
+ async getPlugins(): Promise<MetadataRoute.Sitemap> {
168
+ const list = await this.discoverService.getPluginList(DEFAULT_LANG);
169
+ const sitmap = list.map((item) =>
170
+ this._genSitemap(urlJoin('/discover/plugin', item.identifier), {
171
+ lastModified: item?.createdAt || LAST_MODIFIED,
172
+ }),
173
+ );
174
+ return flatten(sitmap);
175
+ }
176
+
177
+ async getModels(): Promise<MetadataRoute.Sitemap> {
178
+ const list = await this.discoverService.getModelList(DEFAULT_LANG);
179
+ const sitmap = list.map((item) =>
180
+ this._genSitemap(urlJoin('/discover/model', item.identifier), {
181
+ lastModified: item?.createdAt || LAST_MODIFIED,
182
+ }),
183
+ );
184
+ return flatten(sitmap);
185
+ }
186
+
187
+ async getProviders(): Promise<MetadataRoute.Sitemap> {
188
+ const list = await this.discoverService.getProviderList(DEFAULT_LANG);
189
+ const sitmap = list.map((item) =>
190
+ this._genSitemap(urlJoin('/discover/provider', item.identifier), {
191
+ lastModified: item?.createdAt || LAST_MODIFIED,
192
+ }),
193
+ );
194
+ return flatten(sitmap);
195
+ }
196
+
197
+ async getPage(): Promise<MetadataRoute.Sitemap> {
198
+ const assistantsCategory = Object.values(AssistantCategory);
199
+ const pluginCategory = Object.values(PluginCategory);
200
+ const modelCategory = await this.discoverService.getProviderList(DEFAULT_LANG);
201
+ return [
202
+ ...this._genSitemap('/', { noLocales: true }),
203
+ ...this._genSitemap('/chat', { noLocales: true }),
204
+ ...this._genSitemap('/welcome', { noLocales: true }),
205
+ /* ↓ cloud slot ↓ */
206
+
207
+ /* ↑ cloud slot ↑ */
208
+ ...this._genSitemap('/discover', { changeFrequency: 'daily', priority: 0.7 }),
209
+ ...this._genSitemap('/discover/assistants', { changeFrequency: 'daily', priority: 0.7 }),
210
+ ...assistantsCategory.flatMap((slug) =>
211
+ this._genSitemap(`/discover/assistants/${slug}`, {
212
+ changeFrequency: 'daily',
213
+ priority: 0.7,
214
+ }),
215
+ ),
216
+ ...this._genSitemap('/discover/plugins', { changeFrequency: 'daily', priority: 0.7 }),
217
+ ...pluginCategory.flatMap((slug) =>
218
+ this._genSitemap(`/discover/plugins/${slug}`, {
219
+ changeFrequency: 'daily',
220
+ priority: 0.7,
221
+ }),
222
+ ),
223
+ ...this._genSitemap('/discover/models', { changeFrequency: 'daily', priority: 0.7 }),
224
+ ...modelCategory.flatMap((slug) =>
225
+ this._genSitemap(`/discover/models/${slug}`, {
226
+ changeFrequency: 'daily',
227
+ priority: 0.7,
228
+ }),
229
+ ),
230
+ ...this._genSitemap('/discover/providers', { changeFrequency: 'daily', priority: 0.7 }),
231
+ ];
232
+ }
233
+ getRobots() {
234
+ return [
235
+ getCanonicalUrl('/sitemap-index.xml'),
236
+ ...this.sitemapIndexs.map((index) =>
237
+ getCanonicalUrl(SITEMAP_BASE_URL, isDev ? index.id : `${index.id}.xml`),
238
+ ),
239
+ ];
240
+ }
241
+ }
242
+
243
+ export const sitemapModule = new Sitemap();
@@ -0,0 +1,137 @@
1
+ // @vitest-environment node
2
+ import { cookies } from 'next/headers';
3
+ import * as fs from 'node:fs';
4
+ import * as path from 'node:path';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ import { DEFAULT_LANG, LOBE_LOCALE_COOKIE } from '@/const/locale';
8
+ import { normalizeLocale } from '@/locales/resources';
9
+ import * as env from '@/utils/env';
10
+
11
+ import { getLocale, translation } from './translation';
12
+
13
+ // Mock external dependencies
14
+ vi.mock('next/headers', () => ({
15
+ cookies: vi.fn(),
16
+ }));
17
+
18
+ vi.mock('node:fs', () => ({
19
+ existsSync: vi.fn(),
20
+ readFileSync: vi.fn(),
21
+ }));
22
+
23
+ vi.mock('node:path', () => ({
24
+ join: vi.fn(),
25
+ }));
26
+
27
+ vi.mock('@/const/locale', () => ({
28
+ DEFAULT_LANG: 'en-US',
29
+ LOBE_LOCALE_COOKIE: 'LOBE_LOCALE',
30
+ }));
31
+
32
+ vi.mock('@/locales/resources', () => ({
33
+ normalizeLocale: vi.fn((locale) => locale),
34
+ }));
35
+
36
+ vi.mock('@/utils/env', () => ({
37
+ isDev: false,
38
+ }));
39
+
40
+ describe('getLocale', () => {
41
+ const mockCookieStore = {
42
+ get: vi.fn(),
43
+ };
44
+
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ (cookies as any).mockReturnValue(mockCookieStore);
48
+ });
49
+
50
+ it('should return the provided locale if hl is specified', async () => {
51
+ const result = await getLocale('fr-FR');
52
+ expect(result).toBe('fr-FR');
53
+ expect(normalizeLocale).toHaveBeenCalledWith('fr-FR');
54
+ });
55
+
56
+ it('should return the locale from cookie if available', async () => {
57
+ mockCookieStore.get.mockReturnValue({ value: 'de-DE' });
58
+ const result = await getLocale();
59
+ expect(result).toBe('de-DE');
60
+ expect(mockCookieStore.get).toHaveBeenCalledWith(LOBE_LOCALE_COOKIE);
61
+ });
62
+
63
+ it('should return DEFAULT_LANG if no cookie is set', async () => {
64
+ mockCookieStore.get.mockReturnValue(undefined);
65
+ const result = await getLocale();
66
+ expect(result).toBe(DEFAULT_LANG);
67
+ });
68
+ });
69
+
70
+ describe('translation', () => {
71
+ const mockTranslations = {
72
+ key1: 'Value 1',
73
+ key2: 'Value 2 with {{param}}',
74
+ nested: { key: 'Nested value' },
75
+ };
76
+
77
+ beforeEach(() => {
78
+ vi.clearAllMocks();
79
+ (fs.existsSync as any).mockReturnValue(true);
80
+ (fs.readFileSync as any).mockReturnValue(JSON.stringify(mockTranslations));
81
+ (path.join as any).mockImplementation((...args: any) => args.join('/'));
82
+ });
83
+
84
+ it('should return correct translation object', async () => {
85
+ const result = await translation('common', 'en-US');
86
+ expect(result).toHaveProperty('locale', 'en-US');
87
+ expect(result).toHaveProperty('t');
88
+ expect(typeof result.t).toBe('function');
89
+ });
90
+
91
+ it('should translate keys correctly', async () => {
92
+ const { t } = await translation('common', 'en-US');
93
+ expect(t('key1')).toBe('Value 1');
94
+ expect(t('key2', { param: 'test' })).toBe('Value 2 with test');
95
+ expect(t('nested.key')).toBe('Nested value');
96
+ });
97
+
98
+ it('should return key if translation is not found', async () => {
99
+ const { t } = await translation('common', 'en-US');
100
+ expect(t('nonexistent.key')).toBe('nonexistent.key');
101
+ });
102
+
103
+ it('should use fallback language if specified locale file does not exist', async () => {
104
+ (fs.existsSync as any).mockReturnValueOnce(false);
105
+ await translation('common', 'nonexistent-LANG');
106
+ expect(fs.readFileSync).toHaveBeenCalledWith(
107
+ expect.stringContaining(`/${DEFAULT_LANG}/common.json`),
108
+ 'utf8',
109
+ );
110
+ });
111
+
112
+ it('should use zh-CN in dev mode when fallback is needed', async () => {
113
+ (fs.existsSync as any).mockReturnValueOnce(false);
114
+ (env.isDev as unknown as boolean) = true;
115
+ await translation('common', 'nonexistent-LANG');
116
+ expect(fs.readFileSync).toHaveBeenCalledWith(
117
+ expect.stringContaining('/zh-CN/common.json'),
118
+ 'utf8',
119
+ );
120
+ });
121
+
122
+ it('should handle file reading errors', async () => {
123
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
124
+ (fs.readFileSync as any).mockImplementation(() => {
125
+ throw new Error('File read error');
126
+ });
127
+
128
+ const result = await translation('common', 'en-US');
129
+ expect(result.t('any.key')).toBe('any.key');
130
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
131
+ 'Error while reading translation file',
132
+ expect.any(Error),
133
+ );
134
+
135
+ consoleErrorSpy.mockRestore();
136
+ });
137
+ });
@@ -0,0 +1,61 @@
1
+ // @vitest-environment node
2
+ import urlJoin from 'url-join';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ // 模拟 urlJoin 函数
6
+ vi.mock('url-join', () => ({
7
+ default: vi.fn((...args) => args.join('/')),
8
+ }));
9
+
10
+ describe('getCanonicalUrl', () => {
11
+ const originalEnv = process.env;
12
+
13
+ beforeEach(() => {
14
+ // 在每个测试前重置 process.env
15
+ vi.resetModules();
16
+ process.env = { ...originalEnv };
17
+ });
18
+
19
+ afterEach(() => {
20
+ // 在每个测试后恢复原始的 process.env
21
+ process.env = originalEnv;
22
+ });
23
+
24
+ it('should return correct URL for production environment', async () => {
25
+ process.env.VERCEL = undefined;
26
+ process.env.VERCEL_ENV = undefined;
27
+
28
+ const { getCanonicalUrl } = await import('./url'); // 动态导入以获取最新的环境变量状态
29
+ const result = getCanonicalUrl('path', 'to', 'page');
30
+ expect(result).toBe('https://lobechat.com/path/to/page');
31
+ expect(urlJoin).toHaveBeenCalledWith('https://lobechat.com', 'path', 'to', 'page');
32
+ });
33
+
34
+ it('should return correct URL for Vercel preview environment', async () => {
35
+ process.env.VERCEL = '1';
36
+ process.env.VERCEL_ENV = 'preview';
37
+ process.env.VERCEL_URL = 'preview-url.vercel.app';
38
+
39
+ const { getCanonicalUrl } = await import('./url'); // 动态导入
40
+ const result = getCanonicalUrl('path', 'to', 'page');
41
+ expect(result).toBe('https://preview-url.vercel.app/path/to/page');
42
+ expect(urlJoin).toHaveBeenCalledWith('https://preview-url.vercel.app', 'path', 'to', 'page');
43
+ });
44
+
45
+ it('should return production URL when VERCEL is set but VERCEL_ENV is production', async () => {
46
+ process.env.VERCEL = '1';
47
+ process.env.VERCEL_ENV = 'production';
48
+
49
+ const { getCanonicalUrl } = await import('./url'); // 动态导入
50
+ const result = getCanonicalUrl('path', 'to', 'page');
51
+ expect(result).toBe('https://lobechat.com/path/to/page');
52
+ expect(urlJoin).toHaveBeenCalledWith('https://lobechat.com', 'path', 'to', 'page');
53
+ });
54
+
55
+ it('should work correctly without additional path arguments', async () => {
56
+ const { getCanonicalUrl } = await import('./url'); // 动态导入
57
+ const result = getCanonicalUrl();
58
+ expect(result).toBe('https://lobechat.com');
59
+ expect(urlJoin).toHaveBeenCalledWith('https://lobechat.com');
60
+ });
61
+ });
@@ -0,0 +1,9 @@
1
+ import urlJoin from 'url-join';
2
+
3
+ const isVercelPreview = process.env.VERCEL === '1' && process.env.VERCEL_ENV !== 'production';
4
+
5
+ const vercelPreviewUrl = `https://${process.env.VERCEL_URL}`;
6
+
7
+ const siteUrl = isVercelPreview ? vercelPreviewUrl : 'https://lobechat.com';
8
+
9
+ export const getCanonicalUrl = (...paths: string[]) => urlJoin(siteUrl, ...paths);
@@ -1,53 +0,0 @@
1
- import { glob } from 'glob';
2
-
3
- const isVercelPreview = process.env.VERCEL === '1' && process.env.VERCEL_ENV !== 'production';
4
-
5
- const vercelPreviewUrl = `https://${process.env.VERCEL_URL}`;
6
-
7
- const siteUrl = isVercelPreview ? vercelPreviewUrl : 'https://lobechat.com';
8
-
9
- /** @type {import('next-sitemap').IConfig} */
10
- const config = {
11
- // next-sitemap does not work with app dir inside the /src dir (and have other problems e.g. with route groups)
12
- // https://github.com/iamvishnusankar/next-sitemap/issues/700#issuecomment-1759458127
13
- // https://github.com/iamvishnusankar/next-sitemap/issues/701
14
- // additionalPaths is a workaround for this (once the issues are fixed, we can remove it)
15
- additionalPaths: async () => {
16
- const routes = await glob('src/app/**/page.{md,mdx,ts,tsx}', {
17
- cwd: new URL('.', import.meta.url).pathname,
18
- });
19
-
20
- // https://nextjs.org/docs/app/building-your-application/routing/colocation#private-folders
21
- const publicRoutes = routes.filter(
22
- (page) => !page.split('/').some((folder) => folder.startsWith('_')),
23
- );
24
-
25
- // https://nextjs.org/docs/app/building-your-application/routing/colocation#route-groups
26
- const publicRoutesWithoutRouteGroups = publicRoutes.map((page) =>
27
- page
28
- .split('/')
29
- .filter((folder) => !folder.startsWith('(') && !folder.endsWith(')'))
30
- .join('/'),
31
- );
32
-
33
- const locs = publicRoutesWithoutRouteGroups.map((route) => {
34
- const path = route.replace(/^src\/app/, '').replace(/\/[^/]+$/, '');
35
- const loc = path === '' ? siteUrl : `${siteUrl}/${path}`;
36
-
37
- return loc;
38
- });
39
-
40
- const paths = locs.map((loc) => ({
41
- changefreq: 'daily',
42
- lastmod: new Date().toISOString(),
43
- loc,
44
- priority: 0.7,
45
- }));
46
-
47
- return paths;
48
- },
49
- generateRobotsTxt: true,
50
- siteUrl,
51
- };
52
-
53
- export default config;