diff --git a/.gitignore b/.gitignore
index ce28e0b9fc80..af5abc9fad6a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,6 +55,8 @@ next-env.d.ts
.next
.env
public/*.js
+public/sitemap.xml
+public/sitemap-index.xml
bun.lockb
sitemap*.xml
robots.txt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d308537fec02..82057eceb539 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,74 @@
# Changelog
+### [Version 1.19.5](https://github.com/lobehub/lobe-chat/compare/v1.19.4...v1.19.5)
+
+Released on **2024-09-19**
+
+#### 💄 Styles
+
+- **misc**: Enable functioncall for stepfun models, Update qwen models.
+
+
+
+
+Improvements and Fixes
+
+#### Styles
+
+- **misc**: Enable functioncall for stepfun models, closes [#4022](https://github.com/lobehub/lobe-chat/issues/4022) ([afb3509](https://github.com/lobehub/lobe-chat/commit/afb3509))
+- **misc**: Update qwen models, closes [#4026](https://github.com/lobehub/lobe-chat/issues/4026) ([6169e8f](https://github.com/lobehub/lobe-chat/commit/6169e8f))
+
+
+
+
+
+[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
+
+
+
+### [Version 1.19.4](https://github.com/lobehub/lobe-chat/compare/v1.19.3...v1.19.4)
+
+Released on **2024-09-19**
+
+#### ♻ Code Refactoring
+
+- **misc**: Refactor the sitemap implement.
+
+
+
+
+Improvements and Fixes
+
+#### Code refactoring
+
+- **misc**: Refactor the sitemap implement, closes [#4012](https://github.com/lobehub/lobe-chat/issues/4012) ([d93a161](https://github.com/lobehub/lobe-chat/commit/d93a161))
+
+
+
+
+
+[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
+
+
+
+### [Version 1.19.3](https://github.com/lobehub/lobe-chat/compare/v1.19.2...v1.19.3)
+
+Released on **2024-09-19**
+
+
+
+
+Improvements and Fixes
+
+
+
+
+
+[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
+
+
+
### [Version 1.19.2](https://github.com/lobehub/lobe-chat/compare/v1.19.1...v1.19.2)
Released on **2024-09-19**
diff --git a/Dockerfile b/Dockerfile
index 9611ae4a175a..ae9603a571b2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,23 +1,30 @@
## Base image for all the stages
-FROM node:20-alpine AS base
+FROM node:20-slim AS base
ARG USE_CN_MIRROR
+ENV DEBIAN_FRONTEND="noninteractive"
+
RUN \
# If you want to build docker in China, build with --build-arg USE_CN_MIRROR=true
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then \
- sed -i "s/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g" "/etc/apk/repositories"; \
+ sed -i "s/deb.debian.org/mirrors.ustc.edu.cn/g" "/etc/apt/sources.list.d/debian.sources"; \
fi \
# Add required package & update base package
- && apk update \
- && apk add --no-cache bind-tools proxychains-ng sudo \
- && apk upgrade --no-cache \
- # Add user nextjs to run the app
+ && apt update \
+ && apt install busybox proxychains-ng -qy \
+ && apt full-upgrade -qy \
+ && apt autoremove -qy --purge \
+ && apt clean -qy \
+ # Configure BusyBox
+ && busybox --install -s \
+ # Add nextjs:nodejs to run the app
&& addgroup --system --gid 1001 nodejs \
- && adduser --system --uid 1001 nextjs \
- && chown -R nextjs:nodejs "/etc/proxychains" \
- && echo "nextjs ALL=(ALL) NOPASSWD: /bin/chmod * /etc/resolv.conf" >> /etc/sudoers \
- && rm -rf /tmp/* /var/cache/apk/*
+ && adduser --system --home "/app" --gid 1001 -uid 1001 nextjs \
+ # Set permission for nextjs:nodejs
+ && chown -R nextjs:nodejs "/etc/proxychains4.conf" \
+ # Cleanup temp files
+ && rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/*
## Builder image, install all the dependencies and build the app
FROM base AS builder
@@ -89,7 +96,8 @@ FROM base
# Copy all the files from app, set the correct permission for prerender cache
COPY --from=app --chown=nextjs:nodejs /app /app
-ENV NODE_ENV="production"
+ENV NODE_ENV="production" \
+ NODE_TLS_REJECT_UNAUTHORIZED=""
# set hostname to localhost
ENV HOSTNAME="0.0.0.0" \
@@ -121,6 +129,8 @@ ENV \
DEEPSEEK_API_KEY="" \
# Fireworks AI
FIREWORKSAI_API_KEY="" FIREWORKSAI_MODEL_LIST="" \
+ # GitHub
+ GITHUB_TOKEN="" GITHUB_MODEL_LIST="" \
# Google
GOOGLE_API_KEY="" GOOGLE_PROXY_URL="" \
# Groq
@@ -193,15 +203,7 @@ CMD \
'tcp_read_time_out 15000' \
'[ProxyList]' \
"$protocol $host $port" \
- > "/etc/proxychains/proxychains.conf"; \
- fi; \
- # Fix DNS resolving issue in Docker Compose, ref https://github.com/lobehub/lobe-chat/pull/3837
- if [ -f "/etc/resolv.conf" ]; then \
- sudo chmod 666 "/etc/resolv.conf"; \
- resolv_conf=$(grep '^nameserver' "/etc/resolv.conf" | awk '{print "nameserver " $2}'); \
- printf "%s\n" \
- "$resolv_conf" \
- > "/etc/resolv.conf"; \
+ > "/etc/proxychains4.conf"; \
fi; \
# Run the server
${PROXYCHAINS} node "/app/server.js";
diff --git a/Dockerfile.database b/Dockerfile.database
index 1df23248e9c3..3f1138140b03 100644
--- a/Dockerfile.database
+++ b/Dockerfile.database
@@ -1,23 +1,30 @@
## Base image for all the stages
-FROM node:20-alpine AS base
+FROM node:20-slim AS base
ARG USE_CN_MIRROR
+ENV DEBIAN_FRONTEND="noninteractive"
+
RUN \
# If you want to build docker in China, build with --build-arg USE_CN_MIRROR=true
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then \
- sed -i "s/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g" "/etc/apk/repositories"; \
+ sed -i "s/deb.debian.org/mirrors.ustc.edu.cn/g" "/etc/apt/sources.list.d/debian.sources"; \
fi \
# Add required package & update base package
- && apk update \
- && apk add --no-cache bind-tools proxychains-ng sudo \
- && apk upgrade --no-cache \
- # Add user nextjs to run the app
+ && apt update \
+ && apt install busybox proxychains-ng -qy \
+ && apt full-upgrade -qy \
+ && apt autoremove -qy --purge \
+ && apt clean -qy \
+ # Configure BusyBox
+ && busybox --install -s \
+ # Add nextjs:nodejs to run the app
&& addgroup --system --gid 1001 nodejs \
- && adduser --system --uid 1001 nextjs \
- && chown -R nextjs:nodejs "/etc/proxychains" \
- && echo "nextjs ALL=(ALL) NOPASSWD: /bin/chmod * /etc/resolv.conf" >> /etc/sudoers \
- && rm -rf /tmp/* /var/cache/apk/*
+ && adduser --system --home "/app" --gid 1001 -uid 1001 nextjs \
+ # Set permission for nextjs:nodejs
+ && chown -R nextjs:nodejs "/etc/proxychains4.conf" \
+ # Cleanup temp files
+ && rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/*
## Builder image, install all the dependencies and build the app
FROM base AS builder
@@ -102,7 +109,8 @@ FROM base
# Copy all the files from app, set the correct permission for prerender cache
COPY --from=app --chown=nextjs:nodejs /app /app
-ENV NODE_ENV="production"
+ENV NODE_ENV="production" \
+ NODE_TLS_REJECT_UNAUTHORIZED=""
# set hostname to localhost
ENV HOSTNAME="0.0.0.0" \
@@ -153,6 +161,8 @@ ENV \
DEEPSEEK_API_KEY="" \
# Fireworks AI
FIREWORKSAI_API_KEY="" FIREWORKSAI_MODEL_LIST="" \
+ # GitHub
+ GITHUB_TOKEN="" GITHUB_MODEL_LIST="" \
# Google
GOOGLE_API_KEY="" GOOGLE_PROXY_URL="" \
# Groq
@@ -225,15 +235,7 @@ CMD \
'tcp_read_time_out 15000' \
'[ProxyList]' \
"$protocol $host $port" \
- > "/etc/proxychains/proxychains.conf"; \
- fi; \
- # Fix DNS resolving issue in Docker Compose, ref https://github.com/lobehub/lobe-chat/pull/3837
- if [ -f "/etc/resolv.conf" ]; then \
- sudo chmod 666 "/etc/resolv.conf"; \
- resolv_conf=$(grep '^nameserver' "/etc/resolv.conf" | awk '{print "nameserver " $2}'); \
- printf "%s\n" \
- "$resolv_conf" \
- > "/etc/resolv.conf"; \
+ > "/etc/proxychains4.conf"; \
fi; \
# Run migration
node "/app/docker.cjs"; \
diff --git a/next-sitemap.config.mjs b/next-sitemap.config.mjs
deleted file mode 100644
index 66295e4c9ab1..000000000000
--- a/next-sitemap.config.mjs
+++ /dev/null
@@ -1,53 +0,0 @@
-import { glob } from 'glob';
-
-const isVercelPreview = process.env.VERCEL === '1' && process.env.VERCEL_ENV !== 'production';
-
-const vercelPreviewUrl = `https://${process.env.VERCEL_URL}`;
-
-const siteUrl = isVercelPreview ? vercelPreviewUrl : 'https://lobechat.com';
-
-/** @type {import('next-sitemap').IConfig} */
-const config = {
- // next-sitemap does not work with app dir inside the /src dir (and have other problems e.g. with route groups)
- // https://github.com/iamvishnusankar/next-sitemap/issues/700#issuecomment-1759458127
- // https://github.com/iamvishnusankar/next-sitemap/issues/701
- // additionalPaths is a workaround for this (once the issues are fixed, we can remove it)
- additionalPaths: async () => {
- const routes = await glob('src/app/**/page.{md,mdx,ts,tsx}', {
- cwd: new URL('.', import.meta.url).pathname,
- });
-
- // https://nextjs.org/docs/app/building-your-application/routing/colocation#private-folders
- const publicRoutes = routes.filter(
- (page) => !page.split('/').some((folder) => folder.startsWith('_')),
- );
-
- // https://nextjs.org/docs/app/building-your-application/routing/colocation#route-groups
- const publicRoutesWithoutRouteGroups = publicRoutes.map((page) =>
- page
- .split('/')
- .filter((folder) => !folder.startsWith('(') && !folder.endsWith(')'))
- .join('/'),
- );
-
- const locs = publicRoutesWithoutRouteGroups.map((route) => {
- const path = route.replace(/^src\/app/, '').replace(/\/[^/]+$/, '');
- const loc = path === '' ? siteUrl : `${siteUrl}/${path}`;
-
- return loc;
- });
-
- const paths = locs.map((loc) => ({
- changefreq: 'daily',
- lastmod: new Date().toISOString(),
- loc,
- priority: 0.7,
- }));
-
- return paths;
- },
- generateRobotsTxt: true,
- siteUrl,
-};
-
-export default config;
diff --git a/next.config.mjs b/next.config.mjs
index 2803fa6f6668..4ba5ed23ed34 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -107,6 +107,16 @@ const nextConfig = {
output: buildWithDocker ? 'standalone' : undefined,
reactStrictMode: true,
redirects: async () => [
+ {
+ destination: '/sitemap-index.xml',
+ permanent: true,
+ source: '/sitemap.xml',
+ },
+ {
+ destination: '/discover',
+ permanent: true,
+ source: '/market',
+ },
{
destination: '/settings/common',
permanent: true,
diff --git a/package.json b/package.json
index 96e00983a2d6..538aa6e07302 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@lobehub/chat",
- "version": "1.19.2",
+ "version": "1.19.5",
"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",
@@ -29,11 +29,11 @@
"build": "next build",
"postbuild": "npm run build-sitemap && npm run build-migrate-db",
"build-migrate-db": "bun run db:migrate",
- "build-sitemap": "next-sitemap --config next-sitemap.config.mjs",
+ "build-sitemap": "tsx ./scripts/buildSitemapIndex/index.ts",
"build:analyze": "ANALYZE=true next build",
"build:docker": "DOCKER=true next build && npm run build-sitemap",
"db:generate": "drizzle-kit generate",
- "db:migrate": "MIGRATION_DB=1 tsx scripts/migrateServerDB/index.ts",
+ "db:migrate": "MIGRATION_DB=1 tsx ./scripts/migrateServerDB/index.ts",
"db:push": "drizzle-kit push",
"db:push-test": "NODE_ENV=test drizzle-kit push",
"db:studio": "drizzle-kit studio",
@@ -65,11 +65,11 @@
"test:update": "vitest -u",
"type-check": "tsc --noEmit",
"webhook:ngrok": "ngrok http http://localhost:3011",
- "workflow:docs": "tsx scripts/docsWorkflow/index.ts",
- "workflow:i18n": "tsx scripts/i18nWorkflow/index.ts",
+ "workflow:docs": "tsx ./scripts/docsWorkflow/index.ts",
+ "workflow:i18n": "tsx ./scripts/i18nWorkflow/index.ts",
"workflow:mdx": "tsx ./scripts/mdxWorkflow/index.ts",
"workflow:mdx-with-lint": "tsx ./scripts/mdxWorkflow/index.ts && eslint \"docs/**/*.mdx\" --quiet --fix",
- "workflow:readme": "tsx scripts/readmeWorkflow/index.ts"
+ "workflow:readme": "tsx ./scripts/readmeWorkflow/index.ts"
},
"lint-staged": {
"*.md": [
@@ -172,7 +172,6 @@
"next": "14.2.8",
"next-auth": "beta",
"next-mdx-remote": "^4.4.1",
- "next-sitemap": "^4.2.3",
"nextjs-toploader": "^3.6.15",
"numeral": "^2.0.6",
"nuqs": "^1.17.8",
diff --git a/scripts/buildSitemapIndex/index.ts b/scripts/buildSitemapIndex/index.ts
new file mode 100644
index 000000000000..15a7fe83bfda
--- /dev/null
+++ b/scripts/buildSitemapIndex/index.ts
@@ -0,0 +1,12 @@
+import { writeFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+import { sitemapModule } from '@/server/sitemap';
+
+const genSitemap = () => {
+ const sitemapIndexXML = sitemapModule.getIndex();
+ const filename = resolve(__dirname, '../../', 'public', 'sitemap-index.xml');
+ writeFileSync(filename, sitemapIndexXML);
+};
+
+genSitemap();
diff --git a/src/app/(main)/discover/(detail)/assistant/[slug]/page.tsx b/src/app/(main)/discover/(detail)/assistant/[slug]/page.tsx
index 2ff4082ee725..112ac455af40 100644
--- a/src/app/(main)/discover/(detail)/assistant/[slug]/page.tsx
+++ b/src/app/(main)/discover/(detail)/assistant/[slug]/page.tsx
@@ -105,6 +105,7 @@ const Page = async ({ params, searchParams }: Props) => {
/>
}
/* ↓ cloud slot ↓ */
+
/* ↑ cloud slot ↑ */
>
diff --git a/src/app/(main)/discover/(detail)/model/[...slugs]/features/ProviderList/ProviderItem.tsx b/src/app/(main)/discover/(detail)/model/[...slugs]/features/ProviderList/ProviderItem.tsx
index a3252d0ebb2e..4793d40feae3 100644
--- a/src/app/(main)/discover/(detail)/model/[...slugs]/features/ProviderList/ProviderItem.tsx
+++ b/src/app/(main)/discover/(detail)/model/[...slugs]/features/ProviderList/ProviderItem.tsx
@@ -67,6 +67,7 @@ const ProviderItem = memo(({ mobile, modelId, identifier }) =
: '--',
},
/* ↓ cloud slot ↓ */
+
/* ↑ cloud slot ↑ */
];
diff --git a/src/app/(main)/discover/(detail)/model/[...slugs]/page.tsx b/src/app/(main)/discover/(detail)/model/[...slugs]/page.tsx
index a37aed167acf..4972c4feeff4 100644
--- a/src/app/(main)/discover/(detail)/model/[...slugs]/page.tsx
+++ b/src/app/(main)/discover/(detail)/model/[...slugs]/page.tsx
@@ -100,6 +100,7 @@ const Page = async ({ params, searchParams }: Props) => {
mobile={mobile}
sidebar={}
/* ↓ cloud slot ↓ */
+
/* ↑ cloud slot ↑ */
>
diff --git a/src/app/(main)/discover/(detail)/plugin/[slug]/page.tsx b/src/app/(main)/discover/(detail)/plugin/[slug]/page.tsx
index c0b6d6d39125..0f698ee339f9 100644
--- a/src/app/(main)/discover/(detail)/plugin/[slug]/page.tsx
+++ b/src/app/(main)/discover/(detail)/plugin/[slug]/page.tsx
@@ -89,6 +89,7 @@ const Page = async ({ params, searchParams }: Props) => {
mobile={mobile}
sidebar={}
/* ↓ cloud slot ↓ */
+
/* ↑ cloud slot ↑ */
>
diff --git a/src/app/(main)/discover/(detail)/provider/[slug]/features/ModelList/ModelItem.tsx b/src/app/(main)/discover/(detail)/provider/[slug]/features/ModelList/ModelItem.tsx
index 50cb2e257e90..4b4d04074351 100644
--- a/src/app/(main)/discover/(detail)/provider/[slug]/features/ModelList/ModelItem.tsx
+++ b/src/app/(main)/discover/(detail)/provider/[slug]/features/ModelList/ModelItem.tsx
@@ -79,6 +79,7 @@ const ModelItem = memo(({ mobile, meta, identifier }) => {
: '--',
},
/* ↓ cloud slot ↓ */
+
/* ↑ cloud slot ↑ */
];
diff --git a/src/app/(main)/discover/(detail)/provider/[slug]/page.tsx b/src/app/(main)/discover/(detail)/provider/[slug]/page.tsx
index fdbf14eb04de..b9b33224d2bc 100644
--- a/src/app/(main)/discover/(detail)/provider/[slug]/page.tsx
+++ b/src/app/(main)/discover/(detail)/provider/[slug]/page.tsx
@@ -102,6 +102,7 @@ const Page = async ({ params, searchParams }: Props) => {
mobile={mobile}
sidebar={}
/* ↓ cloud slot ↓ */
+
/* ↑ cloud slot ↑ */
>
diff --git a/src/app/(main)/discover/(list)/_layout/Desktop/Nav.tsx b/src/app/(main)/discover/(list)/_layout/Desktop/Nav.tsx
index 0661b7e8a240..7053828d197a 100644
--- a/src/app/(main)/discover/(list)/_layout/Desktop/Nav.tsx
+++ b/src/app/(main)/discover/(list)/_layout/Desktop/Nav.tsx
@@ -108,6 +108,7 @@ const Nav = memo(() => {
{!isHome && !isProviders && (
{/* ↓ cloud slot ↓ */}
+
{/* ↑ cloud slot ↑ */}
)}
diff --git a/src/app/(main)/discover/_layout/Desktop/index.tsx b/src/app/(main)/discover/_layout/Desktop/index.tsx
index 98d5f569e1cd..9c6bd0d50566 100644
--- a/src/app/(main)/discover/_layout/Desktop/index.tsx
+++ b/src/app/(main)/discover/_layout/Desktop/index.tsx
@@ -14,6 +14,7 @@ const Layout = ({ children }: PropsWithChildren) => {
{children}
{/* ↓ cloud slot ↓ */}
+
{/* ↑ cloud slot ↑ */}
>
);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 6f0ec82c6c58..83a80a0a9b91 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,6 +1,6 @@
import { Metadata } from 'next';
-import { getCanonicalUrl } from '@/const/url';
+import { getCanonicalUrl } from '@/server/utils/url';
import Client from './(loading)/Client';
import Redirect from './(loading)/Redirect';
diff --git a/src/app/robots.tsx b/src/app/robots.tsx
new file mode 100644
index 000000000000..fc257fdcf8f8
--- /dev/null
+++ b/src/app/robots.tsx
@@ -0,0 +1,16 @@
+import { MetadataRoute } from 'next';
+
+import { sitemapModule } from '@/server/sitemap';
+import { getCanonicalUrl } from '@/server/utils/url';
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ host: getCanonicalUrl(),
+ rules: {
+ allow: ['/'],
+ disallow: ['/api/*'],
+ userAgent: '*',
+ },
+ sitemap: sitemapModule.getRobots(),
+ };
+}
diff --git a/src/app/sitemap.tsx b/src/app/sitemap.tsx
new file mode 100644
index 000000000000..cc42ece4d997
--- /dev/null
+++ b/src/app/sitemap.tsx
@@ -0,0 +1,30 @@
+import { MetadataRoute } from 'next';
+
+import { SitemapType, sitemapModule } from '@/server/sitemap';
+
+export const generateSitemaps = async () => {
+ // Fetch the total number of products and calculate the number of sitemaps needed
+ return sitemapModule.sitemapIndexs;
+};
+
+const Sitemap = async ({ id }: { id: SitemapType }): Promise => {
+ switch (id) {
+ case SitemapType.Pages: {
+ return sitemapModule.getPage();
+ }
+ case SitemapType.Assistants: {
+ return sitemapModule.getAssistants();
+ }
+ case SitemapType.Plugins: {
+ return sitemapModule.getPlugins();
+ }
+ case SitemapType.Models: {
+ return sitemapModule.getModels();
+ }
+ case SitemapType.Providers: {
+ return sitemapModule.getProviders();
+ }
+ }
+};
+
+export default Sitemap;
diff --git a/src/config/modelProviders/qwen.ts b/src/config/modelProviders/qwen.ts
index d4849c248b03..7c13ebb5de88 100644
--- a/src/config/modelProviders/qwen.ts
+++ b/src/config/modelProviders/qwen.ts
@@ -1,22 +1,10 @@
import { ModelProviderCard } from '@/types/llm';
-// ref :https://help.aliyun.com/zh/dashscope/developer-reference/api-details
+// ref: https://help.aliyun.com/zh/model-studio/getting-started/models
const Qwen: ModelProviderCard = {
chatModels: [
{
- description:
- '通义千问超大规模语言模型,支持长文本上下文,以及基于长文档、多文档等多个场景的对话功能。',
- displayName: 'Qwen Long',
- id: 'qwen-long',
- pricing: {
- currency: 'CNY',
- input: 0.5,
- output: 2,
- },
- tokens: 1_000_000, // https://help.aliyun.com/zh/dashscope/developer-reference/model-introduction
- },
- {
- description: '通义千问超大规模语言模型,支持中文、英文等不同语言输入',
+ description: '通义千问超大规模语言模型,支持中文、英文等不同语言输入。',
displayName: 'Qwen Turbo',
enabled: true,
functionCall: true,
@@ -26,10 +14,10 @@ const Qwen: ModelProviderCard = {
input: 0.3,
output: 0.6,
},
- tokens: 131_072, // https://help.aliyun.com/zh/dashscope/developer-reference/model-introduction
+ tokens: 131_072,
},
{
- description: '通义千问超大规模语言模型增强版,支持中文、英文等不同语言输入',
+ description: '通义千问超大规模语言模型增强版,支持中文、英文等不同语言输入。',
displayName: 'Qwen Plus',
enabled: true,
functionCall: true,
@@ -39,11 +27,11 @@ const Qwen: ModelProviderCard = {
input: 0.8,
output: 2,
},
- tokens: 131_072, // https://help.aliyun.com/zh/dashscope/developer-reference/model-introduction
+ tokens: 131_072,
},
{
description:
- '通义千问千亿级别超大规模语言模型,支持中文、英文等不同语言输入,当前通义千问2.5产品版本背后的API模型',
+ '通义千问千亿级别超大规模语言模型,支持中文、英文等不同语言输入,当前通义千问2.5产品版本背后的API模型。',
displayName: 'Qwen Max',
enabled: true,
functionCall: true,
@@ -53,7 +41,30 @@ const Qwen: ModelProviderCard = {
input: 20,
output: 60,
},
- tokens: 32_768, // https://help.aliyun.com/zh/dashscope/developer-reference/model-introduction
+ tokens: 32_768,
+ },
+ {
+ description: '通义千问代码模型。',
+ displayName: 'Qwen Coder',
+ id: 'qwen-coder-turbo-latest',
+ pricing: {
+ currency: 'CNY',
+ input: 2,
+ output: 6,
+ },
+ tokens: 131_072,
+ },
+ {
+ description:
+ '通义千问超大规模语言模型,支持长文本上下文,以及基于长文档、多文档等多个场景的对话功能。',
+ displayName: 'Qwen Long',
+ id: 'qwen-long',
+ pricing: {
+ currency: 'CNY',
+ input: 0.5,
+ output: 2,
+ },
+ tokens: 1_000_000,
},
{
description:
@@ -66,8 +77,8 @@ const Qwen: ModelProviderCard = {
input: 8,
output: 8,
},
- tokens: 8192,
- vision: true, // https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-qianwen-vl-plus-api
+ tokens: 8000,
+ vision: true,
},
{
description:
@@ -80,71 +91,120 @@ const Qwen: ModelProviderCard = {
input: 20,
output: 20,
},
- tokens: 32_768,
- vision: true, // https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-qianwen-vl-plus-api
+ tokens: 32_000,
+ vision: true,
},
- // ref :https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-qianwen-7b-14b-72b-api-detailes
{
- description: '通义千问2.5对外开源的7B规模的模型',
+ description:
+ '通义千问数学模型是专门用于数学解题的语言模型。',
+ displayName: 'Qwen Math Turbo',
+ id: 'qwen-math-turbo-latest',
+ pricing: {
+ currency: 'CNY',
+ input: 2,
+ output: 6,
+ },
+ tokens: 4096,
+ },
+ {
+ description:
+ '通义千问数学模型是专门用于数学解题的语言模型。',
+ displayName: 'Qwen Math Plus',
+ id: 'qwen-math-plus-latest',
+ pricing: {
+ currency: 'CNY',
+ input: 4,
+ output: 12,
+ },
+ tokens: 4096,
+ },
+ {
+ description: '通义千问2.5对外开源的7B规模的模型。',
displayName: 'Qwen2.5 7B',
functionCall: true,
id: 'qwen2.5-7b-instruct',
- tokens: 131_072, // https://huggingface.co/Qwen/Qwen2.5-7B-Instruct
+ tokens: 131_072,
+ },
+ {
+ description: '通义千问2.5对外开源的14B规模的模型。',
+ displayName: 'Qwen2.5 14B',
+ functionCall: true,
+ id: 'qwen2.5-14b-instruct',
+ tokens: 131_072,
},
{
- description: '通义千问2.5对外开源的32B规模的模型',
+ description: '通义千问2.5对外开源的32B规模的模型。',
displayName: 'Qwen2.5 32B',
functionCall: true,
id: 'qwen2.5-32b-instruct',
- tokens: 131_072, // https://huggingface.co/Qwen/Qwen2.5-32B-Instruct
+ tokens: 131_072,
},
{
- description: '通义千问2.5对外开源的72B规模的模型',
+ description: '通义千问2.5对外开源的72B规模的模型。',
displayName: 'Qwen2.5 72B',
functionCall: true,
id: 'qwen2.5-72b-instruct',
- tokens: 131_072, // https://huggingface.co/Qwen/Qwen2.5-72B-Instruct
+ tokens: 131_072,
},
{
- description: '通义千问2对外开源的7B规模的模型',
- displayName: 'Qwen2 7B',
- functionCall: true,
- id: 'qwen2-7b-instruct',
- tokens: 131_072, // https://huggingface.co/Qwen/Qwen2-7B-Instruct
- },
- {
- description: '通义千问2对外开源的57B规模14B激活参数的MOE模型',
+ description: '通义千问2对外开源的57B规模14B激活参数的MOE模型。',
displayName: 'Qwen2 57B A14B MoE',
functionCall: true,
id: 'qwen2-57b-a14b-instruct',
- tokens: 65_536, // https://huggingface.co/Qwen/Qwen2-57B-A14B-Instruct
+ tokens: 65_536,
},
{
- description: '通义千问2对外开源的72B规模的模型',
- displayName: 'Qwen2 72B',
- functionCall: true,
- id: 'qwen2-72b-instruct',
- tokens: 131_072, // https://huggingface.co/Qwen/Qwen2-72B-Instruct
+ description: 'Qwen-Math 模型具有强大的数学解题能力。',
+ displayName: 'Qwen2.5 Math 1.5B',
+ id: 'qwen2.5-math-1.5b-instruct',
+ pricing: {
+ currency: 'CNY',
+ input: 0,
+ output: 0,
+ },
+ tokens: 4096,
},
{
- description: 'Qwen2-Math 模型具有强大的数学解题能力',
- displayName: 'Qwen2 Math 72B',
- functionCall: true,
- id: 'qwen2-math-72b-instruct',
- tokens: 4096, // https://help.aliyun.com/zh/dashscope/developer-reference/use-qwen2-math-by-calling-api
+ description: 'Qwen-Math 模型具有强大的数学解题能力。',
+ displayName: 'Qwen2.5 Math 7B',
+ id: 'qwen2.5-math-7b-instruct',
+ tokens: 4096,
+ },
+ {
+ description: 'Qwen-Math 模型具有强大的数学解题能力。',
+ displayName: 'Qwen2.5 Math 72B',
+ id: 'qwen2.5-math-72b-instruct',
+ tokens: 4096,
+ },
+ {
+ description: '通义千问代码模型开源版。',
+ displayName: 'Qwen2.5 Coder 1.5B',
+ id: 'qwen2.5-coder-1.5b-instruct',
+ pricing: {
+ currency: 'CNY',
+ input: 0,
+ output: 0,
+ },
+ tokens: 131_072,
+ },
+ {
+ description: '通义千问代码模型开源版。',
+ displayName: 'Qwen2.5 Coder 7B',
+ id: 'qwen2.5-coder-7b-instruct',
+ tokens: 131_072,
},
{
description: '以 Qwen-7B 语言模型初始化,添加图像模型,图像输入分辨率为448的预训练模型。',
displayName: 'Qwen VL',
id: 'qwen-vl-v1',
- tokens: 8192, // https://huggingface.co/Qwen/Qwen-VL/blob/main/config.json
+ tokens: 8000,
vision: true,
},
{
description: '通义千问VL支持灵活的交互方式,包括多图、多轮问答、创作等能力的模型。',
displayName: 'Qwen VL Chat',
id: 'qwen-vl-chat-v1',
- tokens: 8192, // https://huggingface.co/Qwen/Qwen-VL-Chat/blob/main/config.json
+ tokens: 8000,
vision: true,
},
],
@@ -160,7 +220,7 @@ const Qwen: ModelProviderCard = {
speed: 2,
text: true,
},
- url: 'https://tongyi.aliyun.com',
+ url: 'https://www.aliyun.com/product/bailian',
};
export default Qwen;
diff --git a/src/config/modelProviders/stepfun.ts b/src/config/modelProviders/stepfun.ts
index b37da210f792..312eab2162fc 100644
--- a/src/config/modelProviders/stepfun.ts
+++ b/src/config/modelProviders/stepfun.ts
@@ -7,6 +7,7 @@ const Stepfun: ModelProviderCard = {
{
description: '支持大规模上下文交互,适合复杂对话场景。',
displayName: 'Step 2 16K',
+ functionCall: true,
enabled: true,
id: 'step-2-16k',
tokens: 16_000,
@@ -14,12 +15,14 @@ const Stepfun: ModelProviderCard = {
{
description: '具备超长上下文处理能力,尤其适合长文档分析。',
displayName: 'Step 1 256K',
+ functionCall: true,
id: 'step-1-256k',
tokens: 256_000,
},
{
description: '平衡性能与成本,适合一般场景。',
displayName: 'Step 1 128K',
+ functionCall: true,
enabled: true,
id: 'step-1-128k',
tokens: 128_000,
@@ -27,6 +30,7 @@ const Stepfun: ModelProviderCard = {
{
description: '支持中等长度的对话,适用于多种应用场景。',
displayName: 'Step 1 32K',
+ functionCall: true,
enabled: true,
id: 'step-1-32k',
tokens: 32_000,
@@ -34,6 +38,7 @@ const Stepfun: ModelProviderCard = {
{
description: '小型模型,适合轻量级任务。',
displayName: 'Step 1 8K',
+ functionCall: true,
enabled: true,
id: 'step-1-8k',
tokens: 8000,
@@ -41,6 +46,7 @@ const Stepfun: ModelProviderCard = {
{
description: '高速模型,适合实时对话。',
displayName: 'Step 1 Flash',
+ functionCall: true,
enabled: true,
id: 'step-1-flash',
tokens: 8000,
@@ -48,6 +54,7 @@ const Stepfun: ModelProviderCard = {
{
description: '支持视觉输入,增强多模态交互体验。',
displayName: 'Step 1V 32K',
+ functionCall: true,
enabled: true,
id: 'step-1v-32k',
tokens: 32_000,
@@ -56,6 +63,7 @@ const Stepfun: ModelProviderCard = {
{
description: '小型视觉模型,适合基本的图文任务。',
displayName: 'Step 1V 8K',
+ functionCall: true,
enabled: true,
id: 'step-1v-8k',
tokens: 8000,
diff --git a/src/const/url.ts b/src/const/url.ts
index 42860ffad8b8..ef4f73f01cc2 100644
--- a/src/const/url.ts
+++ b/src/const/url.ts
@@ -2,6 +2,7 @@ import qs from 'query-string';
import urlJoin from 'url-join';
import { withBasePath } from '@/utils/basePath';
+import { isDev } from '@/utils/env';
import pkg from '../../package.json';
import { INBOX_SESSION_ID } from './session';
@@ -12,8 +13,6 @@ export const OFFICIAL_URL = 'https://lobechat.com/';
export const OFFICIAL_PREVIEW_URL = 'https://chat-preview.lobehub.com/';
export const OFFICIAL_SITE = 'https://lobehub.com/';
-export const getCanonicalUrl = (path: string) => urlJoin(OFFICIAL_URL, path);
-
export const OG_URL = '/og/cover.png?v=1';
export const GITHUB = pkg.homepage;
@@ -73,3 +72,4 @@ export const mailTo = (email: string) => `mailto:${email}`;
export const AES_GCM_URL = 'https://datatracker.ietf.org/doc/html/draft-ietf-avt-srtp-aes-gcm-01';
export const BASE_PROVIDER_DOC_URL = 'https://lobehub.com/docs/usage/providers';
+export const SITEMAP_BASE_URL = isDev ? '/sitemap.xml/' : 'sitemap';
diff --git a/src/server/ld.test.ts b/src/server/ld.test.ts
new file mode 100644
index 000000000000..454844f1faad
--- /dev/null
+++ b/src/server/ld.test.ts
@@ -0,0 +1,102 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest';
+
+import { DEFAULT_LANG } from '@/const/locale';
+
+import { AUTHOR_LIST, Ld } from './ld';
+
+describe('Ld', () => {
+ const ld = new Ld();
+
+ describe('generate', () => {
+ it('should generate correct LD+JSON structure', () => {
+ const result = ld.generate({
+ title: 'Test Title',
+ description: 'Test Description',
+ url: 'https://example.com/test',
+ locale: DEFAULT_LANG,
+ });
+
+ expect(result['@context']).toBe('https://schema.org');
+ expect(Array.isArray(result['@graph'])).toBe(true);
+ expect(result['@graph'].length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('genOrganization', () => {
+ it('should generate correct organization structure', () => {
+ const org = ld.genOrganization();
+
+ expect(org['@type']).toBe('Organization');
+ expect(org.name).toBe('LobeHub');
+ expect(org.url).toBe('https://lobehub.com/');
+ });
+ });
+
+ describe('getAuthors', () => {
+ it('should return default author when no ids provided', () => {
+ const author = ld.getAuthors();
+ expect(author['@type']).toBe('Organization');
+ });
+
+ it('should return person when valid id provided', () => {
+ const author = ld.getAuthors(['arvinxx']);
+ expect(author['@type']).toBe('Person');
+ // @ts-ignore
+ expect(author.name).toBe(AUTHOR_LIST.arvinxx.name);
+ });
+ });
+
+ describe('genWebPage', () => {
+ it('should generate correct webpage structure', () => {
+ const webpage = ld.genWebPage({
+ title: 'Test Page',
+ description: 'Test Description',
+ url: 'https://example.com/test',
+ locale: DEFAULT_LANG,
+ });
+
+ expect(webpage['@type']).toBe('WebPage');
+ expect(webpage.name).toBe('Test Page · LobeChat');
+ expect(webpage.description).toBe('Test Description');
+ });
+ });
+
+ describe('genImageObject', () => {
+ it('should generate correct image object', () => {
+ const image = ld.genImageObject({
+ image: 'https://example.com/image.jpg',
+ url: 'https://example.com/test',
+ });
+
+ expect(image['@type']).toBe('ImageObject');
+ expect(image.url).toBe('https://example.com/image.jpg');
+ });
+ });
+
+ describe('genWebSite', () => {
+ it('should generate correct website structure', () => {
+ const website = ld.genWebSite();
+
+ expect(website['@type']).toBe('WebSite');
+ expect(website.name).toBe('LobeChat');
+ });
+ });
+
+ describe('genArticle', () => {
+ it('should generate correct article structure', () => {
+ const article = ld.genArticle({
+ title: 'Test Article',
+ description: 'Test Description',
+ url: 'https://example.com/test',
+ author: ['arvinxx'],
+ identifier: 'test-id',
+ locale: DEFAULT_LANG,
+ });
+
+ expect(article['@type']).toBe('Article');
+ expect(article.headline).toBe('Test Article · LobeChat');
+ expect(article.author['@type']).toBe('Person');
+ });
+ });
+});
diff --git a/src/server/ld.ts b/src/server/ld.ts
index 5d0121f990ff..945fde5163f6 100644
--- a/src/server/ld.ts
+++ b/src/server/ld.ts
@@ -3,15 +3,9 @@ import urlJoin from 'url-join';
import { BRANDING_NAME } from '@/const/branding';
import { DEFAULT_LANG } from '@/const/locale';
-import {
- EMAIL_BUSINESS,
- EMAIL_SUPPORT,
- OFFICIAL_SITE,
- OFFICIAL_URL,
- X,
- getCanonicalUrl,
-} from '@/const/url';
+import { EMAIL_BUSINESS, EMAIL_SUPPORT, OFFICIAL_SITE, OFFICIAL_URL, X } from '@/const/url';
import { Locales } from '@/locales/resources';
+import { getCanonicalUrl } from '@/server/utils/url';
import pkg from '../../package.json';
@@ -37,7 +31,7 @@ export const AUTHOR_LIST = {
},
};
-class Ld {
+export class Ld {
generate({
image = '/og/cover.png',
article,
diff --git a/src/server/metadata.test.ts b/src/server/metadata.test.ts
new file mode 100644
index 000000000000..dcef1f47e516
--- /dev/null
+++ b/src/server/metadata.test.ts
@@ -0,0 +1,138 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest';
+
+import { BRANDING_NAME } from '@/const/branding';
+import { OG_URL } from '@/const/url';
+
+import { Meta } from './metadata';
+
+describe('Metadata', () => {
+ const meta = new Meta();
+
+ describe('generate', () => {
+ it('should generate metadata with default values', () => {
+ const result = meta.generate({
+ title: 'Test Title',
+ url: 'https://example.com',
+ });
+
+ expect(result).toMatchObject({
+ title: 'Test Title',
+ description: expect.any(String),
+ openGraph: expect.objectContaining({
+ title: `Test Title · ${BRANDING_NAME}`,
+ description: expect.any(String),
+ images: [{ url: OG_URL, alt: `Test Title · ${BRANDING_NAME}` }],
+ }),
+ twitter: expect.objectContaining({
+ title: `Test Title · ${BRANDING_NAME}`,
+ description: expect.any(String),
+ images: [OG_URL],
+ }),
+ });
+ });
+
+ it('should generate metadata with custom values', () => {
+ const result = meta.generate({
+ title: 'Custom Title',
+ description: 'Custom description',
+ image: 'https://custom-image.com',
+ url: 'https://example.com/custom',
+ type: 'article',
+ tags: ['tag1', 'tag2'],
+ locale: 'fr-FR',
+ alternate: true,
+ });
+
+ expect(result).toMatchObject({
+ title: 'Custom Title',
+ description: expect.stringContaining('Custom description'),
+ openGraph: expect.objectContaining({
+ title: `Custom Title · ${BRANDING_NAME}`,
+ description: 'Custom description',
+ images: [{ url: 'https://custom-image.com', alt: `Custom Title · ${BRANDING_NAME}` }],
+ type: 'article',
+ locale: 'fr-FR',
+ }),
+ twitter: expect.objectContaining({
+ title: `Custom Title · ${BRANDING_NAME}`,
+ description: 'Custom description',
+ images: ['https://custom-image.com'],
+ }),
+ alternates: expect.objectContaining({
+ languages: expect.any(Object),
+ }),
+ });
+ });
+ });
+
+ describe('genAlternateLocales', () => {
+ it('should generate alternate locales correctly', () => {
+ const result = (meta as any).genAlternateLocales('en', '/test');
+
+ expect(result).toHaveProperty('x-default', expect.stringContaining('/test'));
+ expect(result).toHaveProperty('zh-CN', expect.stringContaining('hl=zh-CN'));
+ expect(result).not.toHaveProperty('en');
+ });
+ });
+
+ describe('genTwitter', () => {
+ it('should generate Twitter metadata correctly', () => {
+ const result = (meta as any).genTwitter({
+ title: 'Twitter Title',
+ description: 'Twitter description',
+ image: 'https://twitter-image.com',
+ url: 'https://example.com/twitter',
+ });
+
+ expect(result).toEqual({
+ card: 'summary_large_image',
+ title: 'Twitter Title',
+ description: 'Twitter description',
+ images: ['https://twitter-image.com'],
+ site: '@lobehub',
+ url: 'https://example.com/twitter',
+ });
+ });
+ });
+
+ describe('genOpenGraph', () => {
+ it('should generate OpenGraph metadata correctly', () => {
+ const result = (meta as any).genOpenGraph({
+ title: 'OG Title',
+ description: 'OG description',
+ image: 'https://og-image.com',
+ url: 'https://example.com/og',
+ locale: 'es-ES',
+ type: 'article',
+ alternate: true,
+ });
+
+ expect(result).toMatchObject({
+ title: 'OG Title',
+ description: 'OG description',
+ images: [{ url: 'https://og-image.com', alt: 'OG Title' }],
+ locale: 'es-ES',
+ type: 'article',
+ url: 'https://example.com/og',
+ siteName: 'LobeChat',
+ alternateLocale: expect.arrayContaining([
+ 'ar',
+ 'bg-BG',
+ 'de-DE',
+ 'en-US',
+ 'es-ES',
+ 'fr-FR',
+ 'ja-JP',
+ 'ko-KR',
+ 'pt-BR',
+ 'ru-RU',
+ 'tr-TR',
+ 'zh-CN',
+ 'zh-TW',
+ 'vi-VN',
+ ]),
+ });
+ });
+ });
+});
diff --git a/src/server/metadata.ts b/src/server/metadata.ts
index 53ba3ae98ae8..71dc1aea088b 100644
--- a/src/server/metadata.ts
+++ b/src/server/metadata.ts
@@ -3,8 +3,9 @@ import qs from 'query-string';
import { BRANDING_NAME } from '@/const/branding';
import { DEFAULT_LANG } from '@/const/locale';
-import { OG_URL, getCanonicalUrl } from '@/const/url';
+import { OG_URL } from '@/const/url';
import { Locales, locales } from '@/locales/resources';
+import { getCanonicalUrl } from '@/server/utils/url';
import { formatDescLength, formatTitleLength } from '@/utils/genOG';
export class Meta {
@@ -59,7 +60,6 @@ export class Meta {
let links: any = {};
const defaultLink = getCanonicalUrl(path);
for (const alterLocales of locales) {
- if (locale === alterLocales) continue;
links[alterLocales] = qs.stringifyUrl({
query: { hl: alterLocales },
url: defaultLink,
@@ -125,7 +125,7 @@ export class Meta {
};
if (alternate) {
- data['alternateLocale'] = locales.filter((l) => l !== locale);
+ data['alternateLocale'] = locales;
}
return data;
diff --git a/src/server/modules/AssistantStore/index.test.ts b/src/server/modules/AssistantStore/index.test.ts
index 4631beb02354..975bb67190f8 100644
--- a/src/server/modules/AssistantStore/index.test.ts
+++ b/src/server/modules/AssistantStore/index.test.ts
@@ -13,7 +13,7 @@ describe('AssistantStore', () => {
it('should return the index URL for a not supported language', () => {
const agentMarket = new AssistantStore();
- const url = agentMarket.getAgentIndexUrl('ko-KR');
+ const url = agentMarket.getAgentIndexUrl('xxx' as any);
expect(url).toBe('https://chat-agents.lobehub.com');
});
diff --git a/src/server/modules/AssistantStore/index.ts b/src/server/modules/AssistantStore/index.ts
index 3c36224107af..313dcece6f13 100644
--- a/src/server/modules/AssistantStore/index.ts
+++ b/src/server/modules/AssistantStore/index.ts
@@ -4,10 +4,6 @@ import { appEnv } from '@/config/app';
import { DEFAULT_LANG, isLocaleNotSupport } from '@/const/locale';
import { Locales, normalizeLocale } from '@/locales/resources';
-const checkSupportLocale = (lang: Locales) => {
- return isLocaleNotSupport(lang) || normalizeLocale(lang) !== 'zh-CN';
-};
-
export class AssistantStore {
private readonly baseUrl: string;
@@ -16,13 +12,13 @@ export class AssistantStore {
}
getAgentIndexUrl = (lang: Locales = DEFAULT_LANG) => {
- if (checkSupportLocale(lang)) return this.baseUrl;
+ if (isLocaleNotSupport(lang)) return this.baseUrl;
return urlJoin(this.baseUrl, `index.${normalizeLocale(lang)}.json`);
};
getAgentUrl = (identifier: string, lang: Locales = DEFAULT_LANG) => {
- if (checkSupportLocale(lang)) return urlJoin(this.baseUrl, `${identifier}.json`);
+ if (isLocaleNotSupport(lang)) return urlJoin(this.baseUrl, `${identifier}.json`);
return urlJoin(this.baseUrl, `${identifier}.${normalizeLocale(lang)}.json`);
};
diff --git a/src/server/sitemap.test.ts b/src/server/sitemap.test.ts
new file mode 100644
index 000000000000..31396dff4eb5
--- /dev/null
+++ b/src/server/sitemap.test.ts
@@ -0,0 +1,179 @@
+// @vitest-environment node
+import { describe, expect, it, vi } from 'vitest';
+
+import { getCanonicalUrl } from '@/server/utils/url';
+import { AssistantCategory, PluginCategory } from '@/types/discover';
+
+import { LAST_MODIFIED, Sitemap, SitemapType } from './sitemap';
+
+describe('Sitemap', () => {
+ const sitemap = new Sitemap();
+
+ describe('getIndex', () => {
+ it('should return a valid sitemap index', () => {
+ const index = sitemap.getIndex();
+ expect(index).toContain('');
+ expect(index).toContain('');
+ [
+ SitemapType.Pages,
+ SitemapType.Assistants,
+ SitemapType.Plugins,
+ SitemapType.Models,
+ SitemapType.Providers,
+ ].forEach((type) => {
+ expect(index).toContain(`${getCanonicalUrl(`/sitemap/${type}.xml`)}`);
+ });
+ expect(index).toContain(`${LAST_MODIFIED}`);
+ });
+ });
+
+ describe('getPage', () => {
+ it('should return a valid page sitemap', async () => {
+ const pageSitemap = await sitemap.getPage();
+ expect(pageSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl('/'),
+ changeFrequency: 'monthly',
+ priority: 0.4,
+ }),
+ );
+ expect(pageSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl('/discover'),
+ changeFrequency: 'daily',
+ priority: 0.7,
+ }),
+ );
+ Object.values(AssistantCategory).forEach((category) => {
+ expect(pageSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl(`/discover/assistants/${category}`),
+ changeFrequency: 'daily',
+ priority: 0.7,
+ }),
+ );
+ });
+ Object.values(PluginCategory).forEach((category) => {
+ expect(pageSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl(`/discover/plugins/${category}`),
+ changeFrequency: 'daily',
+ priority: 0.7,
+ }),
+ );
+ });
+ });
+ });
+
+ describe('getAssistants', () => {
+ it('should return a valid assistants sitemap', async () => {
+ vi.spyOn(sitemap['discoverService'], 'getAssistantList').mockResolvedValue([
+ // @ts-ignore
+ { identifier: 'test-assistant', createdAt: '2023-01-01' },
+ ]);
+
+ const assistantsSitemap = await sitemap.getAssistants();
+ expect(assistantsSitemap.length).toBe(14);
+ expect(assistantsSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl('/discover/assistant/test-assistant'),
+ lastModified: '2023-01-01T00:00:00.000Z',
+ }),
+ );
+ expect(assistantsSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl('/discover/assistant/test-assistant?hl=zh-CN'),
+ lastModified: '2023-01-01T00:00:00.000Z',
+ }),
+ );
+ });
+ });
+
+ describe('getPlugins', () => {
+ it('should return a valid plugins sitemap', async () => {
+ vi.spyOn(sitemap['discoverService'], 'getPluginList').mockResolvedValue([
+ // @ts-ignore
+ { identifier: 'test-plugin', createdAt: '2023-01-01' },
+ ]);
+
+ const pluginsSitemap = await sitemap.getPlugins();
+ expect(pluginsSitemap.length).toBe(14);
+ expect(pluginsSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl('/discover/plugin/test-plugin'),
+ lastModified: '2023-01-01T00:00:00.000Z',
+ }),
+ );
+ expect(pluginsSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl('/discover/plugin/test-plugin?hl=ja-JP'),
+ lastModified: '2023-01-01T00:00:00.000Z',
+ }),
+ );
+ });
+ });
+
+ describe('getModels', () => {
+ it('should return a valid models sitemap', async () => {
+ vi.spyOn(sitemap['discoverService'], 'getModelList').mockResolvedValue([
+ // @ts-ignore
+ { identifier: 'test:model', createdAt: '2023-01-01' },
+ ]);
+
+ const modelsSitemap = await sitemap.getModels();
+ expect(modelsSitemap.length).toBe(14);
+ expect(modelsSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl('/discover/model/test:model'),
+ lastModified: '2023-01-01T00:00:00.000Z',
+ }),
+ );
+ expect(modelsSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl('/discover/model/test:model?hl=ko-KR'),
+ lastModified: '2023-01-01T00:00:00.000Z',
+ }),
+ );
+ });
+ });
+
+ describe('getProviders', () => {
+ it('should return a valid providers sitemap', async () => {
+ vi.spyOn(sitemap['discoverService'], 'getProviderList').mockResolvedValue([
+ // @ts-ignore
+ { identifier: 'test-provider', createdAt: '2023-01-01' },
+ ]);
+
+ const providersSitemap = await sitemap.getProviders();
+ expect(providersSitemap.length).toBe(14);
+ expect(providersSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl('/discover/provider/test-provider'),
+ lastModified: '2023-01-01T00:00:00.000Z',
+ }),
+ );
+ expect(providersSitemap).toContainEqual(
+ expect.objectContaining({
+ url: getCanonicalUrl('/discover/provider/test-provider?hl=ar'),
+ lastModified: '2023-01-01T00:00:00.000Z',
+ }),
+ );
+ });
+ });
+
+ describe('getRobots', () => {
+ it('should return correct robots.txt entries', () => {
+ const robots = sitemap.getRobots();
+ expect(robots).toContain(getCanonicalUrl('/sitemap-index.xml'));
+ [
+ SitemapType.Pages,
+ SitemapType.Assistants,
+ SitemapType.Plugins,
+ SitemapType.Models,
+ SitemapType.Providers,
+ ].forEach((type) => {
+ expect(robots).toContain(getCanonicalUrl(`/sitemap/${type}.xml`));
+ });
+ });
+ });
+});
diff --git a/src/server/sitemap.ts b/src/server/sitemap.ts
new file mode 100644
index 000000000000..e0aa38cdd9d1
--- /dev/null
+++ b/src/server/sitemap.ts
@@ -0,0 +1,243 @@
+import { flatten } from 'lodash-es';
+import { MetadataRoute } from 'next';
+import qs from 'query-string';
+import urlJoin from 'url-join';
+
+import { DEFAULT_LANG } from '@/const/locale';
+import { SITEMAP_BASE_URL } from '@/const/url';
+import { Locales, locales as allLocales } from '@/locales/resources';
+import { DiscoverService } from '@/server/services/discover';
+import { getCanonicalUrl } from '@/server/utils/url';
+import { AssistantCategory, PluginCategory } from '@/types/discover';
+import { isDev } from '@/utils/env';
+
+export interface SitemapItem {
+ alternates?: {
+ languages?: string;
+ };
+ changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
+ lastModified?: string | Date;
+ priority?: number;
+ url: string;
+}
+
+export enum SitemapType {
+ Assistants = 'assistants',
+ Models = 'models',
+ Pages = 'pages',
+ Plugins = 'plugins',
+ Providers = 'providers',
+}
+
+export const LAST_MODIFIED = new Date().toISOString();
+
+export class Sitemap {
+ sitemapIndexs = [
+ { id: SitemapType.Pages },
+ { id: SitemapType.Assistants },
+ { id: SitemapType.Plugins },
+ { id: SitemapType.Models },
+ { id: SitemapType.Providers },
+ ];
+
+ private discoverService = new DiscoverService();
+
+ private _generateSitemapLink(url: string) {
+ return [
+ '',
+ `${url}`,
+ `${LAST_MODIFIED}`,
+ '',
+ ].join('\n');
+ }
+
+ private _formatTime(time?: string) {
+ try {
+ if (!time) return LAST_MODIFIED;
+ return new Date(time).toISOString() || LAST_MODIFIED;
+ } catch {
+ return LAST_MODIFIED;
+ }
+ }
+
+ private _genSitemapItem = (
+ lang: Locales,
+ url: string,
+ {
+ lastModified,
+ changeFrequency = 'monthly',
+ priority = 0.4,
+ noLocales,
+ locales = allLocales,
+ }: {
+ changeFrequency?: SitemapItem['changeFrequency'];
+ lastModified?: string;
+ locales?: typeof allLocales;
+ noLocales?: boolean;
+ priority?: number;
+ } = {},
+ ) => {
+ const sitemap = {
+ changeFrequency,
+ lastModified: this._formatTime(lastModified),
+ priority,
+ url:
+ lang === DEFAULT_LANG
+ ? getCanonicalUrl(url)
+ : qs.stringifyUrl({ query: { hl: lang }, url: getCanonicalUrl(url) }),
+ };
+ if (noLocales) return sitemap;
+
+ const languages: any = {};
+ for (const locale of locales) {
+ if (locale === lang) continue;
+ languages[locale] = qs.stringifyUrl({
+ query: { hl: locale },
+ url: getCanonicalUrl(url),
+ });
+ }
+ return {
+ alternates: {
+ languages,
+ },
+ ...sitemap,
+ };
+ };
+
+ private _genSitemap(
+ url: string,
+ {
+ lastModified,
+ changeFrequency = 'monthly',
+ priority = 0.4,
+ noLocales,
+ locales = allLocales,
+ }: {
+ changeFrequency?: SitemapItem['changeFrequency'];
+ lastModified?: string;
+ locales?: typeof allLocales;
+ noLocales?: boolean;
+ priority?: number;
+ } = {},
+ ) {
+ if (noLocales)
+ return [
+ this._genSitemapItem(DEFAULT_LANG, url, {
+ changeFrequency,
+ lastModified,
+ locales,
+ noLocales,
+ priority,
+ }),
+ ];
+ return locales.map((lang) =>
+ this._genSitemapItem(lang, url, {
+ changeFrequency,
+ lastModified,
+ locales,
+ noLocales,
+ priority,
+ }),
+ );
+ }
+
+ getIndex(): string {
+ return [
+ '',
+ '',
+ ...this.sitemapIndexs.map((item) =>
+ this._generateSitemapLink(
+ getCanonicalUrl(SITEMAP_BASE_URL, isDev ? item.id : `${item.id}.xml`),
+ ),
+ ),
+ '',
+ ].join('\n');
+ }
+
+ async getAssistants(): Promise {
+ const list = await this.discoverService.getAssistantList(DEFAULT_LANG);
+ const sitmap = list.map((item) =>
+ this._genSitemap(urlJoin('/discover/assistant', item.identifier), {
+ lastModified: item?.createdAt || LAST_MODIFIED,
+ }),
+ );
+ return flatten(sitmap);
+ }
+
+ async getPlugins(): Promise {
+ const list = await this.discoverService.getPluginList(DEFAULT_LANG);
+ const sitmap = list.map((item) =>
+ this._genSitemap(urlJoin('/discover/plugin', item.identifier), {
+ lastModified: item?.createdAt || LAST_MODIFIED,
+ }),
+ );
+ return flatten(sitmap);
+ }
+
+ async getModels(): Promise {
+ const list = await this.discoverService.getModelList(DEFAULT_LANG);
+ const sitmap = list.map((item) =>
+ this._genSitemap(urlJoin('/discover/model', item.identifier), {
+ lastModified: item?.createdAt || LAST_MODIFIED,
+ }),
+ );
+ return flatten(sitmap);
+ }
+
+ async getProviders(): Promise {
+ const list = await this.discoverService.getProviderList(DEFAULT_LANG);
+ const sitmap = list.map((item) =>
+ this._genSitemap(urlJoin('/discover/provider', item.identifier), {
+ lastModified: item?.createdAt || LAST_MODIFIED,
+ }),
+ );
+ return flatten(sitmap);
+ }
+
+ async getPage(): Promise {
+ const assistantsCategory = Object.values(AssistantCategory);
+ const pluginCategory = Object.values(PluginCategory);
+ const modelCategory = await this.discoverService.getProviderList(DEFAULT_LANG);
+ return [
+ ...this._genSitemap('/', { noLocales: true }),
+ ...this._genSitemap('/chat', { noLocales: true }),
+ ...this._genSitemap('/welcome', { noLocales: true }),
+ /* ↓ cloud slot ↓ */
+
+ /* ↑ cloud slot ↑ */
+ ...this._genSitemap('/discover', { changeFrequency: 'daily', priority: 0.7 }),
+ ...this._genSitemap('/discover/assistants', { changeFrequency: 'daily', priority: 0.7 }),
+ ...assistantsCategory.flatMap((slug) =>
+ this._genSitemap(`/discover/assistants/${slug}`, {
+ changeFrequency: 'daily',
+ priority: 0.7,
+ }),
+ ),
+ ...this._genSitemap('/discover/plugins', { changeFrequency: 'daily', priority: 0.7 }),
+ ...pluginCategory.flatMap((slug) =>
+ this._genSitemap(`/discover/plugins/${slug}`, {
+ changeFrequency: 'daily',
+ priority: 0.7,
+ }),
+ ),
+ ...this._genSitemap('/discover/models', { changeFrequency: 'daily', priority: 0.7 }),
+ ...modelCategory.flatMap((slug) =>
+ this._genSitemap(`/discover/models/${slug}`, {
+ changeFrequency: 'daily',
+ priority: 0.7,
+ }),
+ ),
+ ...this._genSitemap('/discover/providers', { changeFrequency: 'daily', priority: 0.7 }),
+ ];
+ }
+ getRobots() {
+ return [
+ getCanonicalUrl('/sitemap-index.xml'),
+ ...this.sitemapIndexs.map((index) =>
+ getCanonicalUrl(SITEMAP_BASE_URL, isDev ? index.id : `${index.id}.xml`),
+ ),
+ ];
+ }
+}
+
+export const sitemapModule = new Sitemap();
diff --git a/src/server/translation.test.ts b/src/server/translation.test.ts
new file mode 100644
index 000000000000..c7de88295f8b
--- /dev/null
+++ b/src/server/translation.test.ts
@@ -0,0 +1,137 @@
+// @vitest-environment node
+import { cookies } from 'next/headers';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { DEFAULT_LANG, LOBE_LOCALE_COOKIE } from '@/const/locale';
+import { normalizeLocale } from '@/locales/resources';
+import * as env from '@/utils/env';
+
+import { getLocale, translation } from './translation';
+
+// Mock external dependencies
+vi.mock('next/headers', () => ({
+ cookies: vi.fn(),
+}));
+
+vi.mock('node:fs', () => ({
+ existsSync: vi.fn(),
+ readFileSync: vi.fn(),
+}));
+
+vi.mock('node:path', () => ({
+ join: vi.fn(),
+}));
+
+vi.mock('@/const/locale', () => ({
+ DEFAULT_LANG: 'en-US',
+ LOBE_LOCALE_COOKIE: 'LOBE_LOCALE',
+}));
+
+vi.mock('@/locales/resources', () => ({
+ normalizeLocale: vi.fn((locale) => locale),
+}));
+
+vi.mock('@/utils/env', () => ({
+ isDev: false,
+}));
+
+describe('getLocale', () => {
+ const mockCookieStore = {
+ get: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (cookies as any).mockReturnValue(mockCookieStore);
+ });
+
+ it('should return the provided locale if hl is specified', async () => {
+ const result = await getLocale('fr-FR');
+ expect(result).toBe('fr-FR');
+ expect(normalizeLocale).toHaveBeenCalledWith('fr-FR');
+ });
+
+ it('should return the locale from cookie if available', async () => {
+ mockCookieStore.get.mockReturnValue({ value: 'de-DE' });
+ const result = await getLocale();
+ expect(result).toBe('de-DE');
+ expect(mockCookieStore.get).toHaveBeenCalledWith(LOBE_LOCALE_COOKIE);
+ });
+
+ it('should return DEFAULT_LANG if no cookie is set', async () => {
+ mockCookieStore.get.mockReturnValue(undefined);
+ const result = await getLocale();
+ expect(result).toBe(DEFAULT_LANG);
+ });
+});
+
+describe('translation', () => {
+ const mockTranslations = {
+ key1: 'Value 1',
+ key2: 'Value 2 with {{param}}',
+ nested: { key: 'Nested value' },
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (fs.existsSync as any).mockReturnValue(true);
+ (fs.readFileSync as any).mockReturnValue(JSON.stringify(mockTranslations));
+ (path.join as any).mockImplementation((...args: any) => args.join('/'));
+ });
+
+ it('should return correct translation object', async () => {
+ const result = await translation('common', 'en-US');
+ expect(result).toHaveProperty('locale', 'en-US');
+ expect(result).toHaveProperty('t');
+ expect(typeof result.t).toBe('function');
+ });
+
+ it('should translate keys correctly', async () => {
+ const { t } = await translation('common', 'en-US');
+ expect(t('key1')).toBe('Value 1');
+ expect(t('key2', { param: 'test' })).toBe('Value 2 with test');
+ expect(t('nested.key')).toBe('Nested value');
+ });
+
+ it('should return key if translation is not found', async () => {
+ const { t } = await translation('common', 'en-US');
+ expect(t('nonexistent.key')).toBe('nonexistent.key');
+ });
+
+ it('should use fallback language if specified locale file does not exist', async () => {
+ (fs.existsSync as any).mockReturnValueOnce(false);
+ await translation('common', 'nonexistent-LANG');
+ expect(fs.readFileSync).toHaveBeenCalledWith(
+ expect.stringContaining(`/${DEFAULT_LANG}/common.json`),
+ 'utf8',
+ );
+ });
+
+ it('should use zh-CN in dev mode when fallback is needed', async () => {
+ (fs.existsSync as any).mockReturnValueOnce(false);
+ (env.isDev as unknown as boolean) = true;
+ await translation('common', 'nonexistent-LANG');
+ expect(fs.readFileSync).toHaveBeenCalledWith(
+ expect.stringContaining('/zh-CN/common.json'),
+ 'utf8',
+ );
+ });
+
+ it('should handle file reading errors', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ (fs.readFileSync as any).mockImplementation(() => {
+ throw new Error('File read error');
+ });
+
+ const result = await translation('common', 'en-US');
+ expect(result.t('any.key')).toBe('any.key');
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error while reading translation file',
+ expect.any(Error),
+ );
+
+ consoleErrorSpy.mockRestore();
+ });
+});
diff --git a/src/server/utils/url.test.ts b/src/server/utils/url.test.ts
new file mode 100644
index 000000000000..945d83f8f414
--- /dev/null
+++ b/src/server/utils/url.test.ts
@@ -0,0 +1,61 @@
+// @vitest-environment node
+import urlJoin from 'url-join';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// 模拟 urlJoin 函数
+vi.mock('url-join', () => ({
+ default: vi.fn((...args) => args.join('/')),
+}));
+
+describe('getCanonicalUrl', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ // 在每个测试前重置 process.env
+ vi.resetModules();
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ // 在每个测试后恢复原始的 process.env
+ process.env = originalEnv;
+ });
+
+ it('should return correct URL for production environment', async () => {
+ process.env.VERCEL = undefined;
+ process.env.VERCEL_ENV = undefined;
+
+ const { getCanonicalUrl } = await import('./url'); // 动态导入以获取最新的环境变量状态
+ const result = getCanonicalUrl('path', 'to', 'page');
+ expect(result).toBe('https://lobechat.com/path/to/page');
+ expect(urlJoin).toHaveBeenCalledWith('https://lobechat.com', 'path', 'to', 'page');
+ });
+
+ it('should return correct URL for Vercel preview environment', async () => {
+ process.env.VERCEL = '1';
+ process.env.VERCEL_ENV = 'preview';
+ process.env.VERCEL_URL = 'preview-url.vercel.app';
+
+ const { getCanonicalUrl } = await import('./url'); // 动态导入
+ const result = getCanonicalUrl('path', 'to', 'page');
+ expect(result).toBe('https://preview-url.vercel.app/path/to/page');
+ expect(urlJoin).toHaveBeenCalledWith('https://preview-url.vercel.app', 'path', 'to', 'page');
+ });
+
+ it('should return production URL when VERCEL is set but VERCEL_ENV is production', async () => {
+ process.env.VERCEL = '1';
+ process.env.VERCEL_ENV = 'production';
+
+ const { getCanonicalUrl } = await import('./url'); // 动态导入
+ const result = getCanonicalUrl('path', 'to', 'page');
+ expect(result).toBe('https://lobechat.com/path/to/page');
+ expect(urlJoin).toHaveBeenCalledWith('https://lobechat.com', 'path', 'to', 'page');
+ });
+
+ it('should work correctly without additional path arguments', async () => {
+ const { getCanonicalUrl } = await import('./url'); // 动态导入
+ const result = getCanonicalUrl();
+ expect(result).toBe('https://lobechat.com');
+ expect(urlJoin).toHaveBeenCalledWith('https://lobechat.com');
+ });
+});
diff --git a/src/server/utils/url.ts b/src/server/utils/url.ts
new file mode 100644
index 000000000000..44106f4ff512
--- /dev/null
+++ b/src/server/utils/url.ts
@@ -0,0 +1,9 @@
+import urlJoin from 'url-join';
+
+const isVercelPreview = process.env.VERCEL === '1' && process.env.VERCEL_ENV !== 'production';
+
+const vercelPreviewUrl = `https://${process.env.VERCEL_URL}`;
+
+const siteUrl = isVercelPreview ? vercelPreviewUrl : 'https://lobechat.com';
+
+export const getCanonicalUrl = (...paths: string[]) => urlJoin(siteUrl, ...paths);